castle-rb 4.2.0 → 6.0.1

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 (127) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +156 -41
  3. data/lib/castle.rb +46 -21
  4. data/lib/castle/api.rb +24 -12
  5. data/lib/castle/api/approve_device.rb +25 -0
  6. data/lib/castle/api/authenticate.rb +34 -0
  7. data/lib/castle/api/end_impersonation.rb +29 -0
  8. data/lib/castle/api/get_device.rb +25 -0
  9. data/lib/castle/api/get_devices_for_user.rb +25 -0
  10. data/lib/castle/api/identify.rb +26 -0
  11. data/lib/castle/api/report_device.rb +25 -0
  12. data/lib/castle/api/review.rb +24 -0
  13. data/lib/castle/api/start_impersonation.rb +29 -0
  14. data/lib/castle/api/track.rb +26 -0
  15. data/lib/castle/client.rb +52 -45
  16. data/lib/castle/{extractors/client_id.rb → client_id/extract.rb} +2 -2
  17. data/lib/castle/commands/approve_device.rb +21 -0
  18. data/lib/castle/commands/authenticate.rb +13 -13
  19. data/lib/castle/commands/end_impersonation.rb +25 -0
  20. data/lib/castle/commands/get_device.rb +21 -0
  21. data/lib/castle/commands/get_devices_for_user.rb +21 -0
  22. data/lib/castle/commands/identify.rb +12 -13
  23. data/lib/castle/commands/report_device.rb +21 -0
  24. data/lib/castle/commands/review.rb +6 -3
  25. data/lib/castle/commands/start_impersonation.rb +25 -0
  26. data/lib/castle/commands/track.rb +12 -13
  27. data/lib/castle/configuration.rb +45 -28
  28. data/lib/castle/context/{default.rb → get_default.rb} +5 -6
  29. data/lib/castle/context/{merger.rb → merge.rb} +3 -3
  30. data/lib/castle/context/prepare.rb +18 -0
  31. data/lib/castle/context/{sanitizer.rb → sanitize.rb} +1 -1
  32. data/lib/castle/core/get_connection.rb +25 -0
  33. data/lib/castle/{api/response.rb → core/process_response.rb} +4 -2
  34. data/lib/castle/core/process_webhook.rb +20 -0
  35. data/lib/castle/core/send_request.rb +50 -0
  36. data/lib/castle/errors.rb +2 -0
  37. data/lib/castle/events.rb +1 -1
  38. data/lib/castle/failover/prepare_response.rb +23 -0
  39. data/lib/castle/failover/strategy.rb +20 -0
  40. data/lib/castle/headers/extract.rb +47 -0
  41. data/lib/castle/headers/filter.rb +37 -0
  42. data/lib/castle/headers/format.rb +24 -0
  43. data/lib/castle/{extractors/ip.rb → ips/extract.rb} +29 -9
  44. data/lib/castle/logger.rb +19 -0
  45. data/lib/castle/payload/prepare.rb +27 -0
  46. data/lib/castle/secure_mode.rb +6 -2
  47. data/lib/castle/session.rb +18 -0
  48. data/lib/castle/singleton_configuration.rb +9 -0
  49. data/lib/castle/utils/clean_invalid_chars.rb +24 -0
  50. data/lib/castle/utils/clone.rb +15 -0
  51. data/lib/castle/utils/deep_symbolize_keys.rb +45 -0
  52. data/lib/castle/utils/get_timestamp.rb +15 -0
  53. data/lib/castle/utils/{merger.rb → merge.rb} +3 -3
  54. data/lib/castle/utils/secure_compare.rb +22 -0
  55. data/lib/castle/validators/not_supported.rb +1 -0
  56. data/lib/castle/validators/present.rb +1 -0
  57. data/lib/castle/verdict.rb +13 -0
  58. data/lib/castle/version.rb +1 -1
  59. data/lib/castle/webhooks/verify.rb +43 -0
  60. data/spec/integration/rails/rails_spec.rb +33 -7
  61. data/spec/integration/rails/support/application.rb +3 -1
  62. data/spec/integration/rails/support/home_controller.rb +47 -5
  63. data/spec/lib/castle/api/approve_device_spec.rb +21 -0
  64. data/spec/lib/castle/api/authenticate_spec.rb +140 -0
  65. data/spec/lib/castle/api/end_impersonation_spec.rb +59 -0
  66. data/spec/lib/castle/api/get_device_spec.rb +19 -0
  67. data/spec/lib/castle/api/get_devices_for_user_spec.rb +19 -0
  68. data/spec/lib/castle/api/identify_spec.rb +68 -0
  69. data/spec/lib/castle/api/report_device_spec.rb +21 -0
  70. data/spec/lib/castle/{review_spec.rb → api/review_spec.rb} +3 -3
  71. data/spec/lib/castle/api/start_impersonation_spec.rb +59 -0
  72. data/spec/lib/castle/api/track_spec.rb +68 -0
  73. data/spec/lib/castle/api_spec.rb +16 -1
  74. data/spec/lib/castle/{extractors/client_id_spec.rb → client_id/extract_spec.rb} +2 -2
  75. data/spec/lib/castle/client_spec.rb +41 -23
  76. data/spec/lib/castle/commands/approve_device_spec.rb +24 -0
  77. data/spec/lib/castle/commands/authenticate_spec.rb +7 -16
  78. data/spec/lib/castle/commands/end_impersonation_spec.rb +82 -0
  79. data/spec/lib/castle/commands/get_device_spec.rb +24 -0
  80. data/spec/lib/castle/commands/get_devices_for_user_spec.rb +24 -0
  81. data/spec/lib/castle/commands/identify_spec.rb +5 -16
  82. data/spec/lib/castle/commands/report_device_spec.rb +24 -0
  83. data/spec/lib/castle/commands/review_spec.rb +1 -1
  84. data/spec/lib/castle/commands/{impersonate_spec.rb → start_impersonation_spec.rb} +9 -34
  85. data/spec/lib/castle/commands/track_spec.rb +5 -16
  86. data/spec/lib/castle/configuration_spec.rb +9 -138
  87. data/spec/lib/castle/context/{default_spec.rb → get_default_spec.rb} +1 -2
  88. data/spec/lib/castle/context/{merger_spec.rb → merge_spec.rb} +1 -1
  89. data/spec/lib/castle/context/prepare_spec.rb +44 -0
  90. data/spec/lib/castle/context/{sanitizer_spec.rb → sanitize_spec.rb} +1 -1
  91. data/spec/lib/castle/core/get_connection_spec.rb +59 -0
  92. data/spec/lib/castle/{api/response_spec.rb → core/process_response_spec.rb} +56 -1
  93. data/spec/lib/castle/core/process_webhook_spec.rb +46 -0
  94. data/spec/lib/castle/core/send_request_spec.rb +102 -0
  95. data/spec/lib/castle/failover/strategy_spec.rb +12 -0
  96. data/spec/lib/castle/{extractors/headers_spec.rb → headers/extract_spec.rb} +18 -18
  97. data/spec/lib/castle/{headers_filter_spec.rb → headers/filter_spec.rb} +6 -5
  98. data/spec/lib/castle/headers/format_spec.rb +25 -0
  99. data/spec/lib/castle/{extractors/ip_spec.rb → ips/extract_spec.rb} +30 -2
  100. data/spec/lib/castle/logger_spec.rb +42 -0
  101. data/spec/lib/castle/payload/prepare_spec.rb +54 -0
  102. data/spec/lib/castle/session_spec.rb +88 -0
  103. data/spec/lib/castle/singleton_configuration_spec.rb +18 -0
  104. data/spec/lib/castle/utils/clean_invalid_chars_spec.rb +69 -0
  105. data/spec/lib/castle/utils/{cloner_spec.rb → clone_spec.rb} +3 -3
  106. data/spec/lib/castle/utils/deep_symbolize_keys_spec.rb +50 -0
  107. data/spec/lib/castle/utils/{timestamp_spec.rb → get_timestamp_spec.rb} +1 -1
  108. data/spec/lib/castle/utils/{merger_spec.rb → merge_spec.rb} +3 -3
  109. data/spec/lib/castle/verdict_spec.rb +9 -0
  110. data/spec/lib/castle/webhooks/verify_spec.rb +69 -0
  111. data/spec/spec_helper.rb +2 -0
  112. data/spec/support/shared_examples/configuration.rb +129 -0
  113. metadata +133 -56
  114. data/lib/castle/api/request.rb +0 -42
  115. data/lib/castle/api/session.rb +0 -39
  116. data/lib/castle/commands/impersonate.rb +0 -26
  117. data/lib/castle/extractors/headers.rb +0 -45
  118. data/lib/castle/failover_auth_response.rb +0 -21
  119. data/lib/castle/headers_filter.rb +0 -35
  120. data/lib/castle/headers_formatter.rb +0 -22
  121. data/lib/castle/review.rb +0 -11
  122. data/lib/castle/utils.rb +0 -55
  123. data/lib/castle/utils/cloner.rb +0 -11
  124. data/lib/castle/utils/timestamp.rb +0 -12
  125. data/spec/lib/castle/api/request_spec.rb +0 -72
  126. data/spec/lib/castle/headers_formatter_spec.rb +0 -25
  127. data/spec/lib/castle/utils_spec.rb +0 -156
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Castle
4
- module API
4
+ module Core
5
5
  # parses api response
6
- module Response
6
+ module ProcessResponse
7
7
  RESPONSE_ERRORS = {
8
8
  400 => Castle::BadRequestError,
9
9
  401 => Castle::UnauthorizedError,
@@ -17,6 +17,8 @@ module Castle
17
17
  def call(response)
18
18
  verify!(response)
19
19
 
20
+ Castle::Logger.call('response:', response.body.to_s)
21
+
20
22
  return {} if response.body.nil? || response.body.empty?
21
23
 
22
24
  begin
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ module Core
5
+ # Parses a webhook
6
+ module ProcessWebhook
7
+ class << self
8
+ # Checks if webhook is valid
9
+ # @param webhook [Request]
10
+ def call(webhook)
11
+ webhook.body.read.tap do |result|
12
+ raise Castle::ApiError, 'Invalid webhook from Castle API' if result.blank?
13
+
14
+ Castle::Logger.call('webhook:', result.to_s)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ module Core
5
+ # this class is responsible for making requests to api
6
+ module SendRequest
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
+ # @param command [String]
16
+ # @param headers [Hash]
17
+ # @param http [Net::HTTP]
18
+ # @param config [Castle::Configuration, Castle::SingletonConfiguration]
19
+ def call(command, headers, http = nil, config = Castle.config)
20
+ (http || Castle::Core::GetConnection.call).request(
21
+ build(
22
+ command,
23
+ headers.merge(DEFAULT_HEADERS),
24
+ config
25
+ )
26
+ )
27
+ end
28
+
29
+ # @param command [String]
30
+ # @param headers [Hash]
31
+ # @param config [Castle::Configuration, Castle::SingletonConfiguration]
32
+ def build(command, headers, config = Castle.config)
33
+ url = "#{config.base_url.path}/#{command.path}"
34
+ request_obj = Net::HTTP.const_get(command.method.to_s.capitalize).new(url, headers)
35
+
36
+ unless command.method == :get
37
+ request_obj.body = ::Castle::Utils::CleanInvalidChars.call(
38
+ command.data
39
+ ).to_json
40
+ end
41
+
42
+ Castle::Logger.call("#{url}:", request_obj.body)
43
+
44
+ request_obj.basic_auth('', config.api_secret)
45
+ request_obj
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -20,6 +20,8 @@ module Castle
20
20
  class ConfigurationError < Castle::Error; end
21
21
  # error returned by api
22
22
  class ApiError < Castle::Error; end
23
+ # webhook signature verification error
24
+ class WebhookVerificationError < Castle::Error; end
23
25
 
24
26
  # api error bad request 400
25
27
  class BadRequestError < Castle::ApiError; end
@@ -3,7 +3,7 @@
3
3
  module Castle
4
4
  # list of events based on https://docs.castle.io/api_reference/#list-of-recognized-events
5
5
  module Events
6
- # Record when a user succesfully logs in.
6
+ # Record when a user successfully logs in.
7
7
  LOGIN_SUCCEEDED = '$login.succeeded'
8
8
  # Record when a user failed to log in.
9
9
  LOGIN_FAILED = '$login.failed'
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ module Failover
5
+ # generate failover authentication response
6
+ class PrepareResponse
7
+ def initialize(user_id, reason:, strategy: Castle.config.failover_strategy)
8
+ @strategy = strategy
9
+ @reason = reason
10
+ @user_id = user_id
11
+ end
12
+
13
+ def call
14
+ {
15
+ action: @strategy.to_s,
16
+ user_id: @user_id,
17
+ failover: true,
18
+ failover_reason: @reason
19
+ }
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ module Failover
5
+ # handles failover strategy consts
6
+ module Strategy
7
+ # allow
8
+ ALLOW = :allow
9
+ # deny
10
+ DENY = :deny
11
+ # challenge
12
+ CHALLENGE = :challenge
13
+ # throw an error
14
+ THROW = :throw
15
+ end
16
+
17
+ # list of possible strategies
18
+ STRATEGIES = %i[allow deny challenge throw].freeze
19
+ end
20
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ module Headers
5
+ # used for extraction of cookies and headers from the request
6
+ class Extract
7
+ # Headers that we will never scrub, even if they land on the configuration denylist.
8
+ ALWAYS_ALLOWLISTED = %w[User-Agent].freeze
9
+
10
+ # Headers that will always be scrubbed, even if allowlisted.
11
+ ALWAYS_DENYLISTED = %w[Cookie Authorization].freeze
12
+
13
+ private_constant :ALWAYS_ALLOWLISTED, :ALWAYS_DENYLISTED
14
+
15
+ # @param headers [Hash]
16
+ # @param config [Castle::Configuration, Castle::SingletonConfiguration]
17
+ def initialize(headers, config = Castle.config)
18
+ @headers = headers
19
+ @config = config
20
+ @no_allowlist = config.allowlisted.empty?
21
+ end
22
+
23
+ # Serialize HTTP headers
24
+ # @return [Hash]
25
+ def call
26
+ @headers.each_with_object({}) do |(name, value), acc|
27
+ acc[name] = header_value(name, value)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ # scrub header value
34
+ # @param name [String]
35
+ # @param value [String]
36
+ # @return [TrueClass | FalseClass | String]
37
+ def header_value(name, value)
38
+ return true if ALWAYS_DENYLISTED.include?(name)
39
+ return value if ALWAYS_ALLOWLISTED.include?(name)
40
+ return true if @config.denylisted.include?(name)
41
+ return value if @no_allowlist || @config.allowlisted.include?(name)
42
+
43
+ true
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ module Headers
5
+ # used for preparing valuable headers list
6
+ class Filter
7
+ # headers filter
8
+ # HTTP_ - this is how Rack prefixes incoming HTTP headers
9
+ # CONTENT_LENGTH - for responses without Content-Length or Transfer-Encoding header
10
+ # REMOTE_ADDR - ip address header returned by web server
11
+ VALUABLE_HEADERS = /^
12
+ HTTP(?:_|-).*|
13
+ CONTENT(?:_|-)LENGTH|
14
+ REMOTE(?:_|-)ADDR
15
+ $/xi.freeze
16
+
17
+ private_constant :VALUABLE_HEADERS
18
+
19
+ # @param request [Rack::Request]
20
+ def initialize(request)
21
+ @request_env = request.env
22
+ @header_format = Castle::Headers::Format
23
+ end
24
+
25
+ # Serialize HTTP headers
26
+ # @return [Hash]
27
+ def call
28
+ @request_env.keys.each_with_object({}) do |header_name, acc|
29
+ next unless header_name.match(VALUABLE_HEADERS)
30
+
31
+ formatted_name = @header_format.call(header_name)
32
+ acc[formatted_name] = @request_env[header_name]
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ module Headers
5
+ # formats header name
6
+ class Format
7
+ class << self
8
+ # @param header [String]
9
+ # @return [String]
10
+ def call(header)
11
+ format(header.to_s.gsub(/^HTTP(?:_|-)/i, ''))
12
+ end
13
+
14
+ private
15
+
16
+ # @param header [String]
17
+ # @return [String]
18
+ def format(header)
19
+ header.split(/_|-/).map(&:capitalize).join('-')
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -1,19 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Castle
4
- module Extractors
4
+ # IPs-related module
5
+ module IPs
5
6
  # used for extraction of ip from the request
6
- class IP
7
+ class Extract
7
8
  # ordered list of ip headers for ip extraction
8
9
  DEFAULT = %w[X-Forwarded-For Remote-Addr].freeze
10
+ # list of header which are used with proxy depth setting
11
+ DEPTH_RELATED = %w[X-Forwarded-For].freeze
9
12
 
10
13
  private_constant :DEFAULT
11
14
 
12
15
  # @param headers [Hash]
13
- def initialize(headers)
16
+ # @param config [Castle::Configuration, Castle::SingletonConfiguration]
17
+ def initialize(headers, config = Castle.config)
14
18
  @headers = headers
15
- @ip_headers = Castle.config.ip_headers.empty? ? DEFAULT : Castle.config.ip_headers
16
- @proxies = Castle.config.trusted_proxies + Castle::Configuration::TRUSTED_PROXIES
19
+ @ip_headers = config.ip_headers.empty? ? DEFAULT : config.ip_headers
20
+ @proxies = config.trusted_proxies + Castle::Configuration::TRUSTED_PROXIES
21
+ @trust_proxy_chain = config.trust_proxy_chain
22
+ @trusted_proxy_depth = config.trusted_proxy_depth
17
23
  end
18
24
 
19
25
  # Order of headers:
@@ -26,13 +32,14 @@ module Castle
26
32
 
27
33
  @ip_headers.each do |ip_header|
28
34
  ips = ips_from(ip_header)
29
- ip_value = remove_proxies(ips).last
35
+ ip_value = remove_proxies(ips)
36
+
30
37
  return ip_value if ip_value
31
38
 
32
39
  all_ips.push(*ips)
33
40
  end
34
41
 
35
- # fallback to first whatever ip
42
+ # fallback to first listed ip
36
43
  all_ips.first
37
44
  end
38
45
 
@@ -41,7 +48,9 @@ module Castle
41
48
  # @param ips [Array<String>]
42
49
  # @return [Array<String>]
43
50
  def remove_proxies(ips)
44
- ips.reject { |ip| proxy?(ip) }
51
+ return ips.first if @trust_proxy_chain
52
+
53
+ ips.reject { |ip| proxy?(ip) }.last
45
54
  end
46
55
 
47
56
  # @param ip [String]
@@ -57,7 +66,18 @@ module Castle
57
66
 
58
67
  return [] unless value
59
68
 
60
- value.strip.split(/[,\s]+/)
69
+ ips = value.strip.split(/[,\s]+/)
70
+
71
+ limit_proxy_depth(ips, header)
72
+ end
73
+
74
+ # @param ips [Array<String>]
75
+ # @param ip_header [String]
76
+ # @return [Array<String>]
77
+ def limit_proxy_depth(ips, ip_header)
78
+ ips.pop(@trusted_proxy_depth) if DEPTH_RELATED.include?(ip_header)
79
+
80
+ ips
61
81
  end
62
82
  end
63
83
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ # module for logger handling
5
+ module Logger
6
+ class << self
7
+ # @param message [String]
8
+ # @param data [String]
9
+ # @param config [Castle::Configuration, Castle::SingletonConfiguration]
10
+ def call(message, data = nil, config = Castle.config)
11
+ logger = config.logger
12
+
13
+ return unless logger
14
+
15
+ logger.info("[CASTLE] #{message} #{data}".strip)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ module Payload
5
+ # this prepare payload based on the request
6
+ module Prepare
7
+ class << self
8
+ # @param payload_options [Hash]
9
+ # @param request [Request]
10
+ # @param options [Hash] required for context preparation
11
+ # @return [Hash]
12
+ def call(payload_options, request, options = {})
13
+ context = Castle::Context::Prepare.call(request, payload_options.merge(options))
14
+
15
+ payload = Castle::Utils::DeepSymbolizeKeys.call(
16
+ payload_options || {}
17
+ ).merge(context: context)
18
+ payload[:timestamp] ||= Castle::Utils::GetTimestamp.call
19
+
20
+ warn '[DEPRECATION] use user_traits instead of traits key' if payload.key?(:traits)
21
+
22
+ payload
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -4,8 +4,12 @@ require 'openssl'
4
4
 
5
5
  module Castle
6
6
  module SecureMode
7
- def self.signature(user_id)
8
- OpenSSL::HMAC.hexdigest('sha256', Castle.config.api_secret, user_id.to_s)
7
+ class << self
8
+ # @param user_id [String]
9
+ # @param config [Castle::Configuration, Castle::SingletonConfiguration]
10
+ def signature(user_id, config = Castle.config)
11
+ OpenSSL::HMAC.hexdigest('sha256', config.api_secret, user_id.to_s)
12
+ end
9
13
  end
10
14
  end
11
15
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ # this module uses the Connection object
5
+ # and provides start method for persistent connection usage
6
+ # when there is a need of sending multiple requests at once
7
+ module Session
8
+ HTTPS_SCHEME = 'https'
9
+
10
+ class << self
11
+ def call(&block)
12
+ return unless block_given?
13
+
14
+ Castle::Core::GetConnection.call.start(&block)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'singleton'
4
+
5
+ module Castle
6
+ class SingletonConfiguration < Configuration
7
+ include Singleton
8
+ end
9
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ module Utils
5
+ module CleanInvalidChars
6
+ class << self
7
+ def call(arg)
8
+ case arg
9
+ when ::String
10
+ arg.encode('UTF-8', invalid: :replace, undef: :replace)
11
+ when ::Hash
12
+ arg.transform_values do |v|
13
+ Castle::Utils::CleanInvalidChars.call(v)
14
+ end
15
+ when ::Array
16
+ arg.map { |el| Castle::Utils::CleanInvalidChars.call(el) }
17
+ else
18
+ arg
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end