castle-rb 3.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +157 -0
  3. data/lib/castle-rb.rb +3 -0
  4. data/lib/castle.rb +62 -0
  5. data/lib/castle/api.rb +40 -0
  6. data/lib/castle/api/request.rb +37 -0
  7. data/lib/castle/api/request/build.rb +27 -0
  8. data/lib/castle/api/response.rb +40 -0
  9. data/lib/castle/client.rb +106 -0
  10. data/lib/castle/command.rb +5 -0
  11. data/lib/castle/commands/authenticate.rb +23 -0
  12. data/lib/castle/commands/identify.rb +23 -0
  13. data/lib/castle/commands/impersonate.rb +26 -0
  14. data/lib/castle/commands/review.rb +14 -0
  15. data/lib/castle/commands/track.rb +23 -0
  16. data/lib/castle/configuration.rb +80 -0
  17. data/lib/castle/context/default.rb +40 -0
  18. data/lib/castle/context/merger.rb +14 -0
  19. data/lib/castle/context/sanitizer.rb +23 -0
  20. data/lib/castle/errors.rb +41 -0
  21. data/lib/castle/extractors/client_id.rb +17 -0
  22. data/lib/castle/extractors/headers.rb +51 -0
  23. data/lib/castle/extractors/ip.rb +18 -0
  24. data/lib/castle/failover_auth_response.rb +21 -0
  25. data/lib/castle/header_formatter.rb +9 -0
  26. data/lib/castle/review.rb +11 -0
  27. data/lib/castle/secure_mode.rb +11 -0
  28. data/lib/castle/support/hanami.rb +19 -0
  29. data/lib/castle/support/padrino.rb +19 -0
  30. data/lib/castle/support/rails.rb +13 -0
  31. data/lib/castle/support/sinatra.rb +19 -0
  32. data/lib/castle/utils.rb +55 -0
  33. data/lib/castle/utils/cloner.rb +11 -0
  34. data/lib/castle/utils/merger.rb +23 -0
  35. data/lib/castle/utils/timestamp.rb +12 -0
  36. data/lib/castle/validators/not_supported.rb +16 -0
  37. data/lib/castle/validators/present.rb +16 -0
  38. data/lib/castle/version.rb +5 -0
  39. data/spec/lib/castle/api/request/build_spec.rb +44 -0
  40. data/spec/lib/castle/api/request_spec.rb +59 -0
  41. data/spec/lib/castle/api/response_spec.rb +58 -0
  42. data/spec/lib/castle/api_spec.rb +37 -0
  43. data/spec/lib/castle/client_spec.rb +358 -0
  44. data/spec/lib/castle/command_spec.rb +9 -0
  45. data/spec/lib/castle/commands/authenticate_spec.rb +108 -0
  46. data/spec/lib/castle/commands/identify_spec.rb +87 -0
  47. data/spec/lib/castle/commands/impersonate_spec.rb +106 -0
  48. data/spec/lib/castle/commands/review_spec.rb +24 -0
  49. data/spec/lib/castle/commands/track_spec.rb +113 -0
  50. data/spec/lib/castle/configuration_spec.rb +130 -0
  51. data/spec/lib/castle/context/default_spec.rb +41 -0
  52. data/spec/lib/castle/context/merger_spec.rb +23 -0
  53. data/spec/lib/castle/context/sanitizer_spec.rb +27 -0
  54. data/spec/lib/castle/extractors/client_id_spec.rb +62 -0
  55. data/spec/lib/castle/extractors/headers_spec.rb +89 -0
  56. data/spec/lib/castle/extractors/ip_spec.rb +27 -0
  57. data/spec/lib/castle/header_formatter_spec.rb +25 -0
  58. data/spec/lib/castle/review_spec.rb +19 -0
  59. data/spec/lib/castle/secure_mode_spec.rb +9 -0
  60. data/spec/lib/castle/utils/cloner_spec.rb +18 -0
  61. data/spec/lib/castle/utils/merger_spec.rb +13 -0
  62. data/spec/lib/castle/utils/timestamp_spec.rb +17 -0
  63. data/spec/lib/castle/utils_spec.rb +156 -0
  64. data/spec/lib/castle/validators/not_supported_spec.rb +26 -0
  65. data/spec/lib/castle/validators/present_spec.rb +33 -0
  66. data/spec/lib/castle/version_spec.rb +5 -0
  67. data/spec/lib/castle_spec.rb +66 -0
  68. data/spec/spec_helper.rb +25 -0
  69. metadata +139 -0
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 01fa92f200806e9649d71afa71dff0e8ff37db950a74e122134fdbda5413ef8a
4
+ data.tar.gz: f3a11d66711788d68c228ff63b647326e5446fff0ddb1be4e33e4b400d4587a8
5
+ SHA512:
6
+ metadata.gz: 5329a71bd213ee88665b68ab4a79f6eca7987355d839d9e26b4d132ea3d4b7986714d23c3b6f97e7743ce774d969438365ea004fdcdd99eb7643f5c07c43d7b9
7
+ data.tar.gz: 89d8241793e59288651ac45b8d799c545a9b60aa25e6ae8d0092cda6183e9ecdf3b9d5b8ee68e0184b8021dc8633f9f949c987c6f0cd3abc1db6f7f2b47bda99
@@ -0,0 +1,157 @@
1
+ # Ruby SDK for Castle
2
+
3
+ [![Build Status](https://travis-ci.org/castle/castle-ruby.svg?branch=master)](https://travis-ci.org/castle/castle-ruby)
4
+ [![Coverage Status](https://coveralls.io/repos/github/castle/castle-ruby/badge.svg?branch=coveralls)](https://coveralls.io/github/castle/castle-ruby?branch=coveralls)
5
+ [![Gem Version](https://badge.fury.io/rb/castle-rb.svg)](https://badge.fury.io/rb/castle-rb)
6
+
7
+ **[Castle](https://castle.io) analyzes device, location, and interaction patterns in your web and mobile apps and lets you stop account takeover attacks in real-time..**
8
+
9
+ ## Installation
10
+
11
+ Add the `castle-rb` gem to your `Gemfile`
12
+
13
+ ```ruby
14
+ gem 'castle-rb'
15
+ ```
16
+
17
+ ## Configuration
18
+
19
+ ### Framework configuration
20
+
21
+ Load and configure the library with your Castle API secret in an initializer or similar.
22
+
23
+ ```ruby
24
+ Castle.api_secret = 'YOUR_API_SECRET'
25
+ ```
26
+
27
+ A Castle client instance will be made available as `castle` in your
28
+
29
+ * Rails controllers when you add `require 'castle/support/rails'`
30
+
31
+ * Padrino controllers when you add `require 'castle/support/padrino'`
32
+
33
+ * Sinatra app when you add `require 'castle/support/sinatra'` (and additionally explicitly add `register Sinatra::Castle` to your `Sinatra::Base` class if you have a modular application)
34
+
35
+ ```ruby
36
+ require 'castle/support/sinatra'
37
+
38
+ class ApplicationController < Sinatra::Base
39
+ register Sinatra::Castle
40
+ end
41
+ ```
42
+
43
+ * Hanami when you add `require 'castle/support/hanami'` and include `Castle::Hanami` to your Hanami application
44
+
45
+ ```ruby
46
+ require 'castle/support/hanami'
47
+
48
+ module Web
49
+ class Application < Hanami::Application
50
+ include Castle::Hanami
51
+ end
52
+ end
53
+ ```
54
+
55
+ ### Client configuration
56
+
57
+ ```ruby
58
+ Castle.configure do |config|
59
+ # Same as setting it through Castle.api_secret
60
+ config.api_secret = 'secret'
61
+
62
+ # For authenticate method you can set failover strategies: allow(default), deny, challenge, throw
63
+ config.failover_strategy = :deny
64
+
65
+ # Castle::RequestError is raised when timing out in milliseconds (default: 500 milliseconds)
66
+ config.request_timeout = 2000
67
+
68
+ # Whitelisted and Blacklisted headers are case insensitive and allow to use _ and - as a separator, http prefixes are removed
69
+ # Whitelisted headers
70
+ # By default, the SDK sends all HTTP headers, except for Cookie and Authorization.
71
+ # If you decide to use a whitelist, the SDK will:
72
+ # - always send the User-Agent header
73
+ # - send scrubbed values of non-whitelisted headers
74
+ # - send proper values of whitelisted headers.
75
+ # @example
76
+ # config.whitelisted = ['X_HEADER']
77
+ # # will send { 'User-Agent' => 'Chrome', 'X_HEADER' => 'proper value', 'Any-Other-Header' => true }
78
+ #
79
+ # We highly suggest using blacklist instead of whitelist, so that Castle can use as many data points
80
+ # as possible to secure your users. If you want to use the whitelist, this is the minimal
81
+ # amount of headers we recommend:
82
+ config.whitelisted = Castle::Configuration::DEFAULT_WHITELIST
83
+
84
+ # Blacklisted headers take precedence over whitelisted elements
85
+ # We always blacklist Cookie and Authentication headers. If you use any other headers that
86
+ # might contain sensitive information, you should blacklist them.
87
+ config.blacklisted = ['HTTP-X-header']
88
+ end
89
+ ```
90
+
91
+ The client will automatically configure the context for each request.
92
+
93
+ ## Tracking
94
+
95
+ Here is a simple example of a track event.
96
+
97
+
98
+ ```ruby
99
+ begin
100
+ castle.track(
101
+ event: '$login.succeeded',
102
+ user_id: user.id
103
+ )
104
+ rescue Castle::Error => e
105
+ puts e.message
106
+ end
107
+ ```
108
+
109
+ ## Signature
110
+
111
+ `Castle::SecureMode.signature(user_id)` will create a signed user_id.
112
+
113
+ ## Async tracking
114
+
115
+ By default Castle sends requests synchronously. To eg. use Sidekiq to send requests in a background worker you can pass data to the worker:
116
+
117
+ #### castle_tracking_worker.rb
118
+
119
+ ```ruby
120
+ class CastleTrackingWorker
121
+ include Sidekiq::Worker
122
+
123
+ def perform(context, track_options = {})
124
+ client = ::Castle::Client.new(context)
125
+ client.track(track_options)
126
+ end
127
+ end
128
+ ```
129
+
130
+ #### tracking_controller.rb
131
+
132
+ ```ruby
133
+ request_context = ::Castle::Client.to_context(request)
134
+ track_options = ::Castle::Client.to_options({
135
+ event: '$login.succeeded',
136
+ user_id: user.id,
137
+ properties: {
138
+ key: 'value'
139
+ },
140
+ user_traits: {
141
+ key: 'value'
142
+ }
143
+ })
144
+ CastleTrackingWorker.perform_async(request_context, track_options)
145
+ ```
146
+
147
+ ## Impersonation mode
148
+
149
+ https://castle.io/docs/impersonation
150
+
151
+ ## Exceptions
152
+
153
+ `Castle::Error` will be thrown if the Castle API returns a 400 or a 500 level HTTP response. You can also choose to catch a more [finegrained error](https://github.com/castle/castle-ruby/blob/master/lib/castle/errors.rb).
154
+
155
+ ## Documentation
156
+
157
+ [Official Castle docs](https://castle.io/docs)
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'castle'
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ %w[
4
+ openssl
5
+ net/http
6
+ json
7
+ time
8
+ ].each(&method(:require))
9
+
10
+ %w[
11
+ castle/version
12
+ castle/errors
13
+ castle/command
14
+ castle/utils
15
+ castle/utils/merger
16
+ castle/utils/cloner
17
+ castle/utils/timestamp
18
+ castle/validators/present
19
+ castle/validators/not_supported
20
+ castle/context/merger
21
+ castle/context/sanitizer
22
+ castle/context/default
23
+ castle/commands/identify
24
+ castle/commands/authenticate
25
+ castle/commands/track
26
+ castle/commands/review
27
+ castle/commands/impersonate
28
+ castle/configuration
29
+ castle/failover_auth_response
30
+ castle/client
31
+ castle/header_formatter
32
+ castle/secure_mode
33
+ castle/extractors/client_id
34
+ castle/extractors/headers
35
+ castle/extractors/ip
36
+ castle/api/response
37
+ castle/api/request
38
+ castle/api/request/build
39
+ castle/review
40
+ castle/api
41
+ ].each(&method(:require))
42
+
43
+ # main sdk module
44
+ module Castle
45
+ class << self
46
+ def configure(config_hash = nil)
47
+ (config_hash || {}).each do |config_name, config_value|
48
+ config.send("#{config_name}=", config_value)
49
+ end
50
+
51
+ yield(config) if block_given?
52
+ end
53
+
54
+ def config
55
+ @configuration ||= Castle::Configuration.new
56
+ end
57
+
58
+ def api_secret=(api_secret)
59
+ config.api_secret = api_secret
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ # this class is responsible for making requests to api
5
+ module API
6
+ # Errors we handle internally
7
+ HANDLED_ERRORS = [
8
+ Timeout::Error,
9
+ Errno::EINVAL,
10
+ Errno::ECONNRESET,
11
+ EOFError,
12
+ Net::HTTPBadResponse,
13
+ Net::HTTPHeaderSyntaxError,
14
+ Net::ProtocolError
15
+ ].freeze
16
+
17
+ private_constant :HANDLED_ERRORS
18
+
19
+ class << self
20
+ def request(command, headers = {})
21
+ raise Castle::ConfigurationError, 'configuration is not valid' unless Castle.config.valid?
22
+
23
+ begin
24
+ Castle::API::Response.call(
25
+ Castle::API::Request.call(
26
+ command,
27
+ Castle.config.api_secret,
28
+ headers
29
+ )
30
+ )
31
+ rescue *HANDLED_ERRORS => e
32
+ # @note We need to initialize the error, as the original error is a cause for this
33
+ # custom exception. If we would do it the default Ruby way, the original error
34
+ # would get converted into a string
35
+ raise Castle::RequestError.new(e) # rubocop:disable Style/RaiseArgs
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ # this class is responsible for making requests to api
5
+ module API
6
+ module Request
7
+ # Default headers that we add to passed ones
8
+ DEFAULT_HEADERS = {
9
+ 'Content-Type' => 'application/json'
10
+ }.freeze
11
+
12
+ private_constant :DEFAULT_HEADERS
13
+
14
+ class << self
15
+ def call(command, api_secret, headers)
16
+ http.request(
17
+ Castle::API::Request::Build.call(
18
+ command,
19
+ headers.merge(DEFAULT_HEADERS),
20
+ api_secret
21
+ )
22
+ )
23
+ end
24
+
25
+ def http
26
+ http = Net::HTTP.new(Castle.config.host, Castle.config.port)
27
+ http.read_timeout = Castle.config.request_timeout / 1000.0
28
+ if Castle.config.port == 443
29
+ http.use_ssl = true
30
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
31
+ end
32
+ http
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ module API
5
+ # generate api request
6
+ module Request
7
+ module Build
8
+ class << self
9
+ def call(command, headers, api_secret)
10
+ request = Net::HTTP.const_get(
11
+ command.method.to_s.capitalize
12
+ ).new("/#{Castle.config.url_prefix}/#{command.path}", headers)
13
+
14
+ unless command.method == :get
15
+ request.body = ::Castle::Utils.replace_invalid_characters(
16
+ command.data
17
+ ).to_json
18
+ end
19
+
20
+ request.basic_auth('', api_secret)
21
+ request
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ module API
5
+ # parses api response
6
+ module Response
7
+ RESPONSE_ERRORS = {
8
+ 400 => Castle::BadRequestError,
9
+ 401 => Castle::UnauthorizedError,
10
+ 403 => Castle::ForbiddenError,
11
+ 404 => Castle::NotFoundError,
12
+ 419 => Castle::UserUnauthorizedError,
13
+ 422 => Castle::InvalidParametersError
14
+ }.freeze
15
+
16
+ class << self
17
+ def call(response)
18
+ verify!(response)
19
+
20
+ return {} if response.body.nil? || response.body.empty?
21
+
22
+ begin
23
+ JSON.parse(response.body, symbolize_names: true)
24
+ rescue JSON::ParserError
25
+ raise Castle::ApiError, 'Invalid response from Castle API'
26
+ end
27
+ end
28
+
29
+ def verify!(response)
30
+ return if response.code.to_i.between?(200, 299)
31
+
32
+ raise Castle::InternalServerError if response.code.to_i.between?(500, 599)
33
+
34
+ error = RESPONSE_ERRORS.fetch(response.code.to_i, Castle::ApiError)
35
+ raise error, response[:message]
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ class Client
5
+ class << self
6
+ def from_request(request, options = {})
7
+ new(
8
+ to_context(request, options),
9
+ to_options(options)
10
+ )
11
+ end
12
+
13
+ def to_context(request, options = {})
14
+ default_context = Castle::Context::Default.new(request, options[:cookies]).call
15
+ Castle::Context::Merger.call(default_context, options[:context])
16
+ end
17
+
18
+ def to_options(options = {})
19
+ options[:timestamp] ||= Castle::Utils::Timestamp.call
20
+ warn '[DEPRECATION] use user_traits instead of traits key' if options.key?(:traits)
21
+ options
22
+ end
23
+
24
+ def failover_response_or_raise(failover_response, error)
25
+ return failover_response.generate unless Castle.config.failover_strategy == :throw
26
+ raise error
27
+ end
28
+ end
29
+
30
+ attr_accessor :context
31
+
32
+ def initialize(context, options = {})
33
+ @do_not_track = options.fetch(:do_not_track, false)
34
+ @timestamp = options[:timestamp]
35
+ @context = context
36
+ end
37
+
38
+ def authenticate(options = {})
39
+ options = Castle::Utils.deep_symbolize_keys(options || {})
40
+
41
+ if tracked?
42
+ add_timestamp_if_necessary(options)
43
+ command = Castle::Commands::Authenticate.new(@context).build(options)
44
+ begin
45
+ Castle::API.request(command).merge(failover: false, failover_reason: nil)
46
+ rescue Castle::RequestError, Castle::InternalServerError => e
47
+ self.class.failover_response_or_raise(
48
+ FailoverAuthResponse.new(options[:user_id], reason: e.to_s), e
49
+ )
50
+ end
51
+ else
52
+ FailoverAuthResponse.new(
53
+ options[:user_id],
54
+ strategy: :allow, reason: 'Castle set to do not track.'
55
+ ).generate
56
+ end
57
+ end
58
+
59
+ def identify(options = {})
60
+ options = Castle::Utils.deep_symbolize_keys(options || {})
61
+
62
+ return unless tracked?
63
+ add_timestamp_if_necessary(options)
64
+
65
+ command = Castle::Commands::Identify.new(@context).build(options)
66
+ Castle::API.request(command)
67
+ end
68
+
69
+ def track(options = {})
70
+ options = Castle::Utils.deep_symbolize_keys(options || {})
71
+
72
+ return unless tracked?
73
+ add_timestamp_if_necessary(options)
74
+
75
+ command = Castle::Commands::Track.new(@context).build(options)
76
+ Castle::API.request(command)
77
+ end
78
+
79
+ def impersonate(options = {})
80
+ options = Castle::Utils.deep_symbolize_keys(options || {})
81
+ add_timestamp_if_necessary(options)
82
+ command = Castle::Commands::Impersonate.new(@context).build(options)
83
+ Castle::API.request(command).tap do |response|
84
+ raise Castle::ImpersonationFailed unless response[:success]
85
+ end
86
+ end
87
+
88
+ def disable_tracking
89
+ @do_not_track = true
90
+ end
91
+
92
+ def enable_tracking
93
+ @do_not_track = false
94
+ end
95
+
96
+ def tracked?
97
+ !@do_not_track
98
+ end
99
+
100
+ private
101
+
102
+ def add_timestamp_if_necessary(options)
103
+ options[:timestamp] ||= @timestamp if @timestamp
104
+ end
105
+ end
106
+ end