castle-rb 3.6.2

Sign up to get free protection for your applications and to get access to all the features.
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