castle-rb 3.6.2 → 4.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +41 -5
  3. data/lib/castle.rb +3 -1
  4. data/lib/castle/client.rb +20 -15
  5. data/lib/castle/configuration.rb +30 -3
  6. data/lib/castle/context/default.rb +36 -18
  7. data/lib/castle/context/sanitizer.rb +1 -0
  8. data/lib/castle/events.rb +49 -0
  9. data/lib/castle/extractors/client_id.rb +7 -3
  10. data/lib/castle/extractors/headers.rb +24 -30
  11. data/lib/castle/extractors/ip.rb +49 -5
  12. data/lib/castle/header_filter.rb +35 -0
  13. data/lib/castle/header_formatter.rb +3 -0
  14. data/lib/castle/validators/not_supported.rb +1 -0
  15. data/lib/castle/validators/present.rb +1 -0
  16. data/lib/castle/version.rb +1 -1
  17. data/spec/integration/rails/rails_spec.rb +61 -0
  18. data/spec/integration/rails/support/all.rb +6 -0
  19. data/spec/integration/rails/support/application.rb +15 -0
  20. data/spec/integration/rails/support/home_controller.rb +21 -0
  21. data/spec/lib/castle/api/request/build_spec.rb +4 -2
  22. data/spec/lib/castle/api/request_spec.rb +1 -0
  23. data/spec/lib/castle/client_spec.rb +5 -3
  24. data/spec/lib/castle/commands/authenticate_spec.rb +1 -0
  25. data/spec/lib/castle/commands/identify_spec.rb +1 -0
  26. data/spec/lib/castle/commands/impersonate_spec.rb +1 -0
  27. data/spec/lib/castle/commands/track_spec.rb +1 -0
  28. data/spec/lib/castle/configuration_spec.rb +18 -2
  29. data/spec/lib/castle/context/default_spec.rb +10 -11
  30. data/spec/lib/castle/events_spec.rb +5 -0
  31. data/spec/lib/castle/extractors/client_id_spec.rb +2 -1
  32. data/spec/lib/castle/extractors/headers_spec.rb +66 -49
  33. data/spec/lib/castle/extractors/ip_spec.rb +56 -12
  34. data/spec/lib/castle/header_filter_spec.rb +38 -0
  35. data/spec/lib/castle/header_formatter_spec.rb +1 -1
  36. data/spec/lib/castle/utils/cloner_spec.rb +1 -0
  37. data/spec/lib/castle/utils/timestamp_spec.rb +3 -4
  38. data/spec/lib/castle/utils_spec.rb +1 -1
  39. data/spec/lib/castle/validators/not_supported_spec.rb +1 -3
  40. metadata +31 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 01fa92f200806e9649d71afa71dff0e8ff37db950a74e122134fdbda5413ef8a
4
- data.tar.gz: f3a11d66711788d68c228ff63b647326e5446fff0ddb1be4e33e4b400d4587a8
3
+ metadata.gz: 40234604d033086c79d951cbec5a52902df4ccda433eff1749c20cfeaca114d6
4
+ data.tar.gz: e46b4d91acbcf6c370de3a0804d3f9e75ad86bc181b40e2727e92d5d63579953
5
5
  SHA512:
6
- metadata.gz: 5329a71bd213ee88665b68ab4a79f6eca7987355d839d9e26b4d132ea3d4b7986714d23c3b6f97e7743ce774d969438365ea004fdcdd99eb7643f5c07c43d7b9
7
- data.tar.gz: 89d8241793e59288651ac45b8d799c545a9b60aa25e6ae8d0092cda6183e9ecdf3b9d5b8ee68e0184b8021dc8633f9f949c987c6f0cd3abc1db6f7f2b47bda99
6
+ metadata.gz: d70f1b03fb44e3d23afcb03ca83165f5b521a53269f34d2ed63740cc181e110da3692db50d6f701477a24430262d08859a71f7faeae8867c8962242d7421545d
7
+ data.tar.gz: b4a32a603c55df97450e59f936677d5b56387b8d4bf145d4355ca7f5ec606c557d00da72a92bb3f2b641d93a3d3f6e21b9f9412fff35f1840c43db66789b3b6c
data/README.md CHANGED
@@ -85,20 +85,51 @@ Castle.configure do |config|
85
85
  # We always blacklist Cookie and Authentication headers. If you use any other headers that
86
86
  # might contain sensitive information, you should blacklist them.
87
87
  config.blacklisted = ['HTTP-X-header']
88
+
89
+ # Castle needs the original IP of the client, not the IP of your proxy or load balancer.
90
+ # we try to fetch proper ip based on X-Forwarded-For, X-Client-Id or Remote-Addr headers in that order
91
+ # but sometimes proper ip may be stored in different header or order could be different.
92
+ # SDK can extract ip automatically for you, but you must configure which ip_headers you would like to use
93
+ configuration.ip_headers = []
94
+
95
+ # Additionally to make X-Forwarded-For or X-Client-Id work better discovering client ip address,
96
+ # and not the address of a reverse proxy server, you can define trusted proxies
97
+ # which will help to fetch proper ip from those headers
98
+ configuration.trusted_proxies = []
99
+ # *Note: proxies list can be provided as an array of regular expressions
100
+ # *Note: default always marked as trusty list is here: Castle::Configuration::TRUSTED_PROXIES
88
101
  end
89
102
  ```
90
103
 
104
+ ## Event Context
105
+
91
106
  The client will automatically configure the context for each request.
92
107
 
108
+ ### Overriding Default Context Properties
109
+
110
+ If you need to modify the event context properties or if you desire to add additional properties such as user traits to the context, you can pass the properties in as options to the method of interest. An example:
111
+ ```ruby
112
+ request_context = ::Castle::Client.to_context(request)
113
+ track_options = ::Castle::Client.to_options({
114
+ event: ::Castle::Events::LOGIN_SUCCEEDED,
115
+ user_id: user.id,
116
+ properties: {
117
+ key: 'value'
118
+ },
119
+ user_traits: {
120
+ key: 'value'
121
+ }
122
+ })
123
+ ```
124
+
93
125
  ## Tracking
94
126
 
95
127
  Here is a simple example of a track event.
96
128
 
97
-
98
129
  ```ruby
99
130
  begin
100
131
  castle.track(
101
- event: '$login.succeeded',
132
+ event: ::Castle::Events::LOGIN_SUCCEEDED,
102
133
  user_id: user.id
103
134
  )
104
135
  rescue Castle::Error => e
@@ -132,7 +163,7 @@ end
132
163
  ```ruby
133
164
  request_context = ::Castle::Client.to_context(request)
134
165
  track_options = ::Castle::Client.to_options({
135
- event: '$login.succeeded',
166
+ event: ::Castle::Events::LOGIN_SUCCEEDED,
136
167
  user_id: user.id,
137
168
  properties: {
138
169
  key: 'value'
@@ -144,13 +175,18 @@ track_options = ::Castle::Client.to_options({
144
175
  CastleTrackingWorker.perform_async(request_context, track_options)
145
176
  ```
146
177
 
178
+ ## Events
179
+
180
+ List of Recognized Events can be found [here](https://github.com/castle/castle-ruby/tree/master/lib/castle/events.rb) or in the [docs](https://docs.castle.io/api_reference/#list-of-recognized-events)
181
+
147
182
  ## Impersonation mode
148
183
 
149
- https://castle.io/docs/impersonation
184
+ https://castle.io/docs/impersonation_mode
150
185
 
151
186
  ## Exceptions
152
187
 
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).
188
+ `Castle::Error` will be thrown if the Castle API returns a 400 or a 500 level HTTP response.
189
+ You can also choose to catch a more [finegrained error](https://github.com/castle/castle-ruby/blob/master/lib/castle/errors.rb).
154
190
 
155
191
  ## Documentation
156
192
 
@@ -9,6 +9,7 @@
9
9
 
10
10
  %w[
11
11
  castle/version
12
+ castle/events
12
13
  castle/errors
13
14
  castle/command
14
15
  castle/utils
@@ -28,6 +29,7 @@
28
29
  castle/configuration
29
30
  castle/failover_auth_response
30
31
  castle/client
32
+ castle/header_filter
31
33
  castle/header_formatter
32
34
  castle/secure_mode
33
35
  castle/extractors/client_id
@@ -52,7 +54,7 @@ module Castle
52
54
  end
53
55
 
54
56
  def config
55
- @configuration ||= Castle::Configuration.new
57
+ @config ||= Castle::Configuration.new
56
58
  end
57
59
 
58
60
  def api_secret=(api_secret)
@@ -23,6 +23,7 @@ module Castle
23
23
 
24
24
  def failover_response_or_raise(failover_response, error)
25
25
  return failover_response.generate unless Castle.config.failover_strategy == :throw
26
+
26
27
  raise error
27
28
  end
28
29
  end
@@ -38,21 +39,16 @@ module Castle
38
39
  def authenticate(options = {})
39
40
  options = Castle::Utils.deep_symbolize_keys(options || {})
40
41
 
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
42
+ return generate_do_not_track_response(options[:user_id]) unless tracked?
43
+
44
+ add_timestamp_if_necessary(options)
45
+ command = Castle::Commands::Authenticate.new(@context).build(options)
46
+ begin
47
+ Castle::API.request(command).merge(failover: false, failover_reason: nil)
48
+ rescue Castle::RequestError, Castle::InternalServerError => e
49
+ self.class.failover_response_or_raise(
50
+ FailoverAuthResponse.new(options[:user_id], reason: e.to_s), e
51
+ )
56
52
  end
57
53
  end
58
54
 
@@ -60,6 +56,7 @@ module Castle
60
56
  options = Castle::Utils.deep_symbolize_keys(options || {})
61
57
 
62
58
  return unless tracked?
59
+
63
60
  add_timestamp_if_necessary(options)
64
61
 
65
62
  command = Castle::Commands::Identify.new(@context).build(options)
@@ -70,6 +67,7 @@ module Castle
70
67
  options = Castle::Utils.deep_symbolize_keys(options || {})
71
68
 
72
69
  return unless tracked?
70
+
73
71
  add_timestamp_if_necessary(options)
74
72
 
75
73
  command = Castle::Commands::Track.new(@context).build(options)
@@ -99,6 +97,13 @@ module Castle
99
97
 
100
98
  private
101
99
 
100
+ def generate_do_not_track_response(user_id)
101
+ FailoverAuthResponse.new(
102
+ user_id,
103
+ strategy: :allow, reason: 'Castle is set to do not track.'
104
+ ).generate
105
+ end
106
+
102
107
  def add_timestamp_if_necessary(options)
103
108
  options[:timestamp] ||= @timestamp if @timestamp
104
109
  end
@@ -9,6 +9,15 @@ module Castle
9
9
  FAILOVER_STRATEGY = :allow
10
10
  REQUEST_TIMEOUT = 500 # in milliseconds
11
11
  FAILOVER_STRATEGIES = %i[allow deny challenge throw].freeze
12
+ # regexp of trusted proxies which is always appended to the trusted proxy list
13
+ TRUSTED_PROXIES = [/
14
+ \A127\.0\.0\.1\Z|
15
+ \A(10|172\.(1[6-9]|2[0-9]|30|31)|192\.168)\.|
16
+ \A::1\Z|\Afd[0-9a-f]{2}:.+|
17
+ \Alocalhost\Z|
18
+ \Aunix\Z|
19
+ \Aunix:
20
+ /ix].freeze
12
21
 
13
22
  # @note this value is not assigned as we don't recommend using a whitelist. If you need to use
14
23
  # one, this constant is provided as a good default.
@@ -32,7 +41,7 @@ module Castle
32
41
  ].freeze
33
42
 
34
43
  attr_accessor :host, :port, :request_timeout, :url_prefix
35
- attr_reader :api_secret, :whitelisted, :blacklisted, :failover_strategy
44
+ attr_reader :api_secret, :whitelisted, :blacklisted, :failover_strategy, :ip_headers, :trusted_proxies
36
45
 
37
46
  def initialize
38
47
  @formatter = Castle::HeaderFormatter.new
@@ -43,11 +52,13 @@ module Castle
43
52
  self.url_prefix = URL_PREFIX
44
53
  self.whitelisted = [].freeze
45
54
  self.blacklisted = [].freeze
46
- self.api_secret = ''
55
+ self.api_secret = ENV.fetch('CASTLE_API_SECRET', '')
56
+ self.ip_headers = [].freeze
57
+ self.trusted_proxies = [].freeze
47
58
  end
48
59
 
49
60
  def api_secret=(value)
50
- @api_secret = ENV.fetch('CASTLE_API_SECRET', value).to_s
61
+ @api_secret = value.to_s
51
62
  end
52
63
 
53
64
  def whitelisted=(value)
@@ -58,6 +69,22 @@ module Castle
58
69
  @blacklisted = (value ? value.map { |header| @formatter.call(header) } : []).freeze
59
70
  end
60
71
 
72
+ # sets ip headers
73
+ # @param value [Array<String>]
74
+ def ip_headers=(value)
75
+ raise Castle::ConfigurationError, 'ip headers must be an Array' unless value.is_a?(Array)
76
+
77
+ @ip_headers = value.map { |header| @formatter.call(header) }.freeze
78
+ end
79
+
80
+ # sets trusted proxies
81
+ # @param value [Array<String|Regexp>]
82
+ def trusted_proxies=(value)
83
+ raise Castle::ConfigurationError, 'trusted proxies must be an Array' unless value.is_a?(Array)
84
+
85
+ @trusted_proxies = value
86
+ end
87
+
61
88
  def valid?
62
89
  !api_secret.to_s.empty? && !host.to_s.empty? && !port.to_s.empty?
63
90
  end
@@ -4,36 +4,54 @@ module Castle
4
4
  module Context
5
5
  class Default
6
6
  def initialize(request, cookies = nil)
7
- @client_id = Extractors::ClientId.new(request, cookies || request.cookies).call
8
- @headers = Extractors::Headers.new(request).call
9
- @request_ip = Extractors::IP.new(request).call
7
+ @pre_headers = HeaderFilter.new(request).call
8
+ @cookies = cookies || request.cookies
9
+ @request = request
10
10
  end
11
11
 
12
12
  def call
13
- defaults.merge!(additional_defaults)
14
- end
15
-
16
- private
17
-
18
- def defaults
19
13
  {
20
- client_id: @client_id,
14
+ client_id: client_id,
21
15
  active: true,
22
16
  origin: 'web',
23
- headers: @headers,
24
- ip: @request_ip,
17
+ headers: headers,
18
+ ip: ip,
25
19
  library: {
26
20
  name: 'castle-rb',
27
21
  version: Castle::VERSION
28
22
  }
29
- }
23
+ }.tap do |result|
24
+ result[:locale] = locale if locale
25
+ result[:user_agent] = user_agent if user_agent
26
+ end
30
27
  end
31
28
 
32
- def additional_defaults
33
- {}.tap do |result|
34
- result[:locale] = @headers['Accept-Language'] if @headers['Accept-Language']
35
- result[:user_agent] = @headers['User-Agent'] if @headers['User-Agent']
36
- end
29
+ private
30
+
31
+ # @return [String]
32
+ def locale
33
+ @pre_headers['Accept-Language']
34
+ end
35
+
36
+ # @return [String]
37
+ def user_agent
38
+ @pre_headers['User-Agent']
39
+ end
40
+
41
+ # @return [String]
42
+ def ip
43
+ Extractors::IP.new(@pre_headers).call
44
+ end
45
+
46
+ # @return [String]
47
+ def client_id
48
+ Extractors::ClientId.new(@pre_headers, @cookies).call
49
+ end
50
+
51
+ # formatted and filtered headers
52
+ # @return [Hash]
53
+ def headers
54
+ Extractors::Headers.new(@pre_headers).call
37
55
  end
38
56
  end
39
57
  end
@@ -15,6 +15,7 @@ module Castle
15
15
  return unless context
16
16
  return context unless context.key?(:active)
17
17
  return context if [true, false].include?(context[:active])
18
+
18
19
  context.reject { |key| key == :active }
19
20
  end
20
21
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ # list of events based on https://docs.castle.io/api_reference/#list-of-recognized-events
5
+ module Events
6
+ # Record when a user succesfully logs in.
7
+ LOGIN_SUCCEEDED = '$login.succeeded'
8
+ # Record when a user failed to log in.
9
+ LOGIN_FAILED = '$login.failed'
10
+ # Record when a user logs out.
11
+ LOGOUT_SUCCEEDED = '$logout.succeeded'
12
+ # Record when a user updated their profile (including password, email, phone, etc).
13
+ PROFILE_UPDATE_SUCCEEDED = '$profile_update.succeeded'
14
+ # Record errors when updating profile.
15
+ PROFILE_UPDATE_FAILED = '$profile_update.failed'
16
+ # Capture account creation, both when a user signs up as well as when created manually
17
+ # by an administrator.
18
+ REGISTRATION_SUCCEEDED = '$registration.succeeded'
19
+ # Record when an account failed to be created.
20
+ REGISTRATION_FAILED = '$registration.failed'
21
+ # The user completed all of the steps in the password reset process and the password was
22
+ # successfully reset.Password resets do not required knowledge of the current password.
23
+ PASSWORD_RESET_SUCCEEDED = '$password_reset.succeeded'
24
+ # Use to record when a user failed to reset their password.
25
+ PASSWORD_RESET_FAILED = '$password_reset.failed'
26
+ # The user successfully requested a password reset.
27
+ PASSWORD_RESET_REQUEST_SUCCCEEDED = '$password_reset_request.succeeded'
28
+ # The user failed to request a password reset.
29
+ PASSWORD_RESET_REQUEST_FAILED = '$password_reset_request.failed'
30
+ # User account has been reset.
31
+ INCIDENT_MITIGATED = '$incident.mitigated'
32
+ # User confirmed malicious activity.
33
+ REVIEW_ESCALATED = '$review.escalated'
34
+ # User confirmed safe activity.
35
+ REVIEW_RESOLVED = '$review.resolved'
36
+ # Record when a user is prompted with additional verification, such as two-factor
37
+ # authentication or a captcha.
38
+ CHALLENGE_REQUESTED = '$challenge.requested'
39
+ # Record when additional verification was successful.
40
+ CHALLENGE_SUCCEEDED = '$challenge.succeeded'
41
+ # Record when additional verification failed.
42
+ CHALLENGE_FAILED = '$challenge.failed'
43
+ # Record when a user attempts an in-app transaction, such as a purchase or withdrawal.
44
+ TRANSACTION_ATTEMPTED = '$transaction.attempted'
45
+ # Record when a user session is extended, or use any time you want
46
+ # to re-authenticate a user mid-session.
47
+ SESSION_EXTENDED = '$session.extended'
48
+ end
49
+ end
@@ -4,13 +4,17 @@ module Castle
4
4
  module Extractors
5
5
  # used for extraction of cookies and headers from the request
6
6
  class ClientId
7
- def initialize(request, cookies)
8
- @request = request
7
+ # @param headers [Hash]
8
+ # @param cookies [NilClass|Hash]
9
+ def initialize(headers, cookies)
10
+ @headers = headers
9
11
  @cookies = cookies || {}
10
12
  end
11
13
 
14
+ # extracts client id
15
+ # @return [String]
12
16
  def call
13
- @request.env['HTTP_X_CASTLE_CLIENT_ID'] || @cookies['__cid'] || ''
17
+ @headers['X-Castle-Client-Id'] || @cookies['__cid'] || ''
14
18
  end
15
19
  end
16
20
  end
@@ -5,47 +5,41 @@ module Castle
5
5
  # used for extraction of cookies and headers from the request
6
6
  class Headers
7
7
  # Headers that we will never scrub, even if they land on the configuration blacklist.
8
- ALWAYS_INCLUDED_HEADERS = %w[User-Agent].freeze
8
+ ALWAYS_WHITELISTED = %w[User-Agent].freeze
9
9
 
10
10
  # Headers that will always be scrubbed, even if whitelisted.
11
- ALWAYS_SCRUBBED_HEADERS = %w[Cookie Authorization].freeze
11
+ ALWAYS_BLACKLISTED = %w[Cookie Authorization].freeze
12
12
 
13
- # Rack does not add the HTTP_ prefix to Content-Length for some reason
14
- CONTENT_LENGTH = 'CONTENT_LENGTH'
13
+ private_constant :ALWAYS_WHITELISTED, :ALWAYS_BLACKLISTED
15
14
 
16
- # Prefix that Rack adds for HTTP headers
17
- HTTP_HEADER_PREFIX = 'HTTP_'
18
-
19
- private_constant :ALWAYS_INCLUDED_HEADERS, :ALWAYS_SCRUBBED_HEADERS,
20
- :CONTENT_LENGTH, :HTTP_HEADER_PREFIX
21
-
22
- # @param request [Rack::Request]
23
- def initialize(request)
24
- @request_env = request.env
25
- @formatter = HeaderFormatter.new
15
+ # @param headers [Hash]
16
+ def initialize(headers)
17
+ @headers = headers
18
+ @no_whitelist = Castle.config.whitelisted.empty?
26
19
  end
27
20
 
28
21
  # Serialize HTTP headers
29
22
  # @return [Hash]
30
23
  def call
31
- @request_env.keys.each_with_object({}) do |env_header, acc|
32
- next unless env_header.to_s.start_with?(HTTP_HEADER_PREFIX) || env_header == CONTENT_LENGTH
33
-
34
- header = @formatter.call(env_header)
35
-
36
- if ALWAYS_SCRUBBED_HEADERS.include?(header)
37
- acc[header] = true
38
- elsif ALWAYS_INCLUDED_HEADERS.include?(header)
39
- acc[header] = @request_env[env_header]
40
- elsif Castle.config.blacklisted.include?(header)
41
- acc[header] = true
42
- elsif Castle.config.whitelisted.empty? || Castle.config.whitelisted.include?(header)
43
- acc[header] = @request_env[env_header]
44
- else
45
- acc[header] = true
46
- end
24
+ @headers.each_with_object({}) do |(name, value), acc|
25
+ acc[name] = header_value(name, value)
47
26
  end
48
27
  end
28
+
29
+ private
30
+
31
+ # scrub header value
32
+ # @param name [String]
33
+ # @param value [String]
34
+ # @return [TrueClass | FalseClass | String]
35
+ def header_value(name, value)
36
+ return true if ALWAYS_BLACKLISTED.include?(name)
37
+ return value if ALWAYS_WHITELISTED.include?(name)
38
+ return true if Castle.config.blacklisted.include?(name)
39
+ return value if @no_whitelist || Castle.config.whitelisted.include?(name)
40
+
41
+ true
42
+ end
49
43
  end
50
44
  end
51
45
  end