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.
- checksums.yaml +7 -0
- data/README.md +157 -0
- data/lib/castle-rb.rb +3 -0
- data/lib/castle.rb +62 -0
- data/lib/castle/api.rb +40 -0
- data/lib/castle/api/request.rb +37 -0
- data/lib/castle/api/request/build.rb +27 -0
- data/lib/castle/api/response.rb +40 -0
- data/lib/castle/client.rb +106 -0
- data/lib/castle/command.rb +5 -0
- data/lib/castle/commands/authenticate.rb +23 -0
- data/lib/castle/commands/identify.rb +23 -0
- data/lib/castle/commands/impersonate.rb +26 -0
- data/lib/castle/commands/review.rb +14 -0
- data/lib/castle/commands/track.rb +23 -0
- data/lib/castle/configuration.rb +80 -0
- data/lib/castle/context/default.rb +40 -0
- data/lib/castle/context/merger.rb +14 -0
- data/lib/castle/context/sanitizer.rb +23 -0
- data/lib/castle/errors.rb +41 -0
- data/lib/castle/extractors/client_id.rb +17 -0
- data/lib/castle/extractors/headers.rb +51 -0
- data/lib/castle/extractors/ip.rb +18 -0
- data/lib/castle/failover_auth_response.rb +21 -0
- data/lib/castle/header_formatter.rb +9 -0
- data/lib/castle/review.rb +11 -0
- data/lib/castle/secure_mode.rb +11 -0
- data/lib/castle/support/hanami.rb +19 -0
- data/lib/castle/support/padrino.rb +19 -0
- data/lib/castle/support/rails.rb +13 -0
- data/lib/castle/support/sinatra.rb +19 -0
- data/lib/castle/utils.rb +55 -0
- data/lib/castle/utils/cloner.rb +11 -0
- data/lib/castle/utils/merger.rb +23 -0
- data/lib/castle/utils/timestamp.rb +12 -0
- data/lib/castle/validators/not_supported.rb +16 -0
- data/lib/castle/validators/present.rb +16 -0
- data/lib/castle/version.rb +5 -0
- data/spec/lib/castle/api/request/build_spec.rb +44 -0
- data/spec/lib/castle/api/request_spec.rb +59 -0
- data/spec/lib/castle/api/response_spec.rb +58 -0
- data/spec/lib/castle/api_spec.rb +37 -0
- data/spec/lib/castle/client_spec.rb +358 -0
- data/spec/lib/castle/command_spec.rb +9 -0
- data/spec/lib/castle/commands/authenticate_spec.rb +108 -0
- data/spec/lib/castle/commands/identify_spec.rb +87 -0
- data/spec/lib/castle/commands/impersonate_spec.rb +106 -0
- data/spec/lib/castle/commands/review_spec.rb +24 -0
- data/spec/lib/castle/commands/track_spec.rb +113 -0
- data/spec/lib/castle/configuration_spec.rb +130 -0
- data/spec/lib/castle/context/default_spec.rb +41 -0
- data/spec/lib/castle/context/merger_spec.rb +23 -0
- data/spec/lib/castle/context/sanitizer_spec.rb +27 -0
- data/spec/lib/castle/extractors/client_id_spec.rb +62 -0
- data/spec/lib/castle/extractors/headers_spec.rb +89 -0
- data/spec/lib/castle/extractors/ip_spec.rb +27 -0
- data/spec/lib/castle/header_formatter_spec.rb +25 -0
- data/spec/lib/castle/review_spec.rb +19 -0
- data/spec/lib/castle/secure_mode_spec.rb +9 -0
- data/spec/lib/castle/utils/cloner_spec.rb +18 -0
- data/spec/lib/castle/utils/merger_spec.rb +13 -0
- data/spec/lib/castle/utils/timestamp_spec.rb +17 -0
- data/spec/lib/castle/utils_spec.rb +156 -0
- data/spec/lib/castle/validators/not_supported_spec.rb +26 -0
- data/spec/lib/castle/validators/present_spec.rb +33 -0
- data/spec/lib/castle/version_spec.rb +5 -0
- data/spec/lib/castle_spec.rb +66 -0
- data/spec/spec_helper.rb +25 -0
- metadata +139 -0
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Castle
|
4
|
+
module Commands
|
5
|
+
class Authenticate
|
6
|
+
def initialize(context)
|
7
|
+
@context = context
|
8
|
+
end
|
9
|
+
|
10
|
+
def build(options = {})
|
11
|
+
Castle::Validators::Present.call(options, %i[event])
|
12
|
+
context = Castle::Context::Merger.call(@context, options[:context])
|
13
|
+
context = Castle::Context::Sanitizer.call(context)
|
14
|
+
|
15
|
+
Castle::Command.new(
|
16
|
+
'authenticate',
|
17
|
+
options.merge(context: context, sent_at: Castle::Utils::Timestamp.call),
|
18
|
+
:post
|
19
|
+
)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Castle
|
4
|
+
module Commands
|
5
|
+
class Identify
|
6
|
+
def initialize(context)
|
7
|
+
@context = context
|
8
|
+
end
|
9
|
+
|
10
|
+
def build(options = {})
|
11
|
+
Castle::Validators::NotSupported.call(options, %i[properties])
|
12
|
+
context = Castle::Context::Merger.call(@context, options[:context])
|
13
|
+
context = Castle::Context::Sanitizer.call(context)
|
14
|
+
|
15
|
+
Castle::Command.new(
|
16
|
+
'identify',
|
17
|
+
options.merge(context: context, sent_at: Castle::Utils::Timestamp.call),
|
18
|
+
:post
|
19
|
+
)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Castle
|
4
|
+
module Commands
|
5
|
+
# builder for impersonate command
|
6
|
+
class Impersonate
|
7
|
+
def initialize(context)
|
8
|
+
@context = context
|
9
|
+
end
|
10
|
+
|
11
|
+
def build(options = {})
|
12
|
+
Castle::Validators::Present.call(options, %i[user_id])
|
13
|
+
context = Castle::Context::Merger.call(@context, options[:context])
|
14
|
+
context = Castle::Context::Sanitizer.call(context)
|
15
|
+
|
16
|
+
Castle::Validators::Present.call(context, %i[user_agent ip])
|
17
|
+
|
18
|
+
Castle::Command.new(
|
19
|
+
'impersonate',
|
20
|
+
options.merge(context: context, sent_at: Castle::Utils::Timestamp.call),
|
21
|
+
options[:reset] ? :delete : :post
|
22
|
+
)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Castle
|
4
|
+
module Commands
|
5
|
+
class Review
|
6
|
+
class << self
|
7
|
+
def build(review_id)
|
8
|
+
Castle::Validators::Present.call({ review_id: review_id }, %i[review_id])
|
9
|
+
Castle::Command.new("reviews/#{review_id}", nil, :get)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Castle
|
4
|
+
module Commands
|
5
|
+
class Track
|
6
|
+
def initialize(context)
|
7
|
+
@context = context
|
8
|
+
end
|
9
|
+
|
10
|
+
def build(options = {})
|
11
|
+
Castle::Validators::Present.call(options, %i[event])
|
12
|
+
context = Castle::Context::Merger.call(@context, options[:context])
|
13
|
+
context = Castle::Context::Sanitizer.call(context)
|
14
|
+
|
15
|
+
Castle::Command.new(
|
16
|
+
'track',
|
17
|
+
options.merge(context: context, sent_at: Castle::Utils::Timestamp.call),
|
18
|
+
:post
|
19
|
+
)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Castle
|
4
|
+
# manages configuration variables
|
5
|
+
class Configuration
|
6
|
+
HOST = 'api.castle.io'
|
7
|
+
PORT = 443
|
8
|
+
URL_PREFIX = 'v1'
|
9
|
+
FAILOVER_STRATEGY = :allow
|
10
|
+
REQUEST_TIMEOUT = 500 # in milliseconds
|
11
|
+
FAILOVER_STRATEGIES = %i[allow deny challenge throw].freeze
|
12
|
+
|
13
|
+
# @note this value is not assigned as we don't recommend using a whitelist. If you need to use
|
14
|
+
# one, this constant is provided as a good default.
|
15
|
+
DEFAULT_WHITELIST = %w[
|
16
|
+
Accept
|
17
|
+
Accept-Charset
|
18
|
+
Accept-Datetime
|
19
|
+
Accept-Encoding
|
20
|
+
Accept-Language
|
21
|
+
Cache-Control
|
22
|
+
Connection
|
23
|
+
Content-Length
|
24
|
+
Content-Type
|
25
|
+
Host
|
26
|
+
Origin
|
27
|
+
Pragma
|
28
|
+
Referer
|
29
|
+
TE
|
30
|
+
Upgrade-Insecure-Requests
|
31
|
+
X-Castle-Client-Id
|
32
|
+
].freeze
|
33
|
+
|
34
|
+
attr_accessor :host, :port, :request_timeout, :url_prefix
|
35
|
+
attr_reader :api_secret, :whitelisted, :blacklisted, :failover_strategy
|
36
|
+
|
37
|
+
def initialize
|
38
|
+
@formatter = Castle::HeaderFormatter.new
|
39
|
+
@request_timeout = REQUEST_TIMEOUT
|
40
|
+
self.failover_strategy = FAILOVER_STRATEGY
|
41
|
+
self.host = HOST
|
42
|
+
self.port = PORT
|
43
|
+
self.url_prefix = URL_PREFIX
|
44
|
+
self.whitelisted = [].freeze
|
45
|
+
self.blacklisted = [].freeze
|
46
|
+
self.api_secret = ''
|
47
|
+
end
|
48
|
+
|
49
|
+
def api_secret=(value)
|
50
|
+
@api_secret = ENV.fetch('CASTLE_API_SECRET', value).to_s
|
51
|
+
end
|
52
|
+
|
53
|
+
def whitelisted=(value)
|
54
|
+
@whitelisted = (value ? value.map { |header| @formatter.call(header) } : []).freeze
|
55
|
+
end
|
56
|
+
|
57
|
+
def blacklisted=(value)
|
58
|
+
@blacklisted = (value ? value.map { |header| @formatter.call(header) } : []).freeze
|
59
|
+
end
|
60
|
+
|
61
|
+
def valid?
|
62
|
+
!api_secret.to_s.empty? && !host.to_s.empty? && !port.to_s.empty?
|
63
|
+
end
|
64
|
+
|
65
|
+
def failover_strategy=(value)
|
66
|
+
@failover_strategy = FAILOVER_STRATEGIES.detect { |strategy| strategy == value.to_sym }
|
67
|
+
raise Castle::ConfigurationError, 'unrecognized failover strategy' if @failover_strategy.nil?
|
68
|
+
end
|
69
|
+
|
70
|
+
private
|
71
|
+
|
72
|
+
def respond_to_missing?(method_name, _include_private)
|
73
|
+
/^(\w+)=$/ =~ method_name
|
74
|
+
end
|
75
|
+
|
76
|
+
def method_missing(setting, *_args)
|
77
|
+
raise Castle::ConfigurationError, "there is no such a config #{setting}"
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Castle
|
4
|
+
module Context
|
5
|
+
class Default
|
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
|
10
|
+
end
|
11
|
+
|
12
|
+
def call
|
13
|
+
defaults.merge!(additional_defaults)
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def defaults
|
19
|
+
{
|
20
|
+
client_id: @client_id,
|
21
|
+
active: true,
|
22
|
+
origin: 'web',
|
23
|
+
headers: @headers,
|
24
|
+
ip: @request_ip,
|
25
|
+
library: {
|
26
|
+
name: 'castle-rb',
|
27
|
+
version: Castle::VERSION
|
28
|
+
}
|
29
|
+
}
|
30
|
+
end
|
31
|
+
|
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
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Castle
|
4
|
+
module Context
|
5
|
+
class Merger
|
6
|
+
class << self
|
7
|
+
def call(initial_context, request_context)
|
8
|
+
main_context = Castle::Utils::Cloner.call(initial_context)
|
9
|
+
Castle::Utils::Merger.call(main_context, request_context || {})
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Castle
|
4
|
+
module Context
|
5
|
+
# removes not proper active flag values
|
6
|
+
class Sanitizer
|
7
|
+
class << self
|
8
|
+
def call(context)
|
9
|
+
sanitized_active_mode(context) || {}
|
10
|
+
end
|
11
|
+
|
12
|
+
private
|
13
|
+
|
14
|
+
def sanitized_active_mode(context)
|
15
|
+
return unless context
|
16
|
+
return context unless context.key?(:active)
|
17
|
+
return context if [true, false].include?(context[:active])
|
18
|
+
context.reject { |key| key == :active }
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Castle
|
4
|
+
# general error
|
5
|
+
class Error < RuntimeError; end
|
6
|
+
# Raised when anything is wrong with the request (any unhappy path)
|
7
|
+
# This error indicates that either we would wait too long for a response or something
|
8
|
+
# else happened somewhere in the middle and we weren't able to get the results
|
9
|
+
class RequestError < Castle::Error
|
10
|
+
attr_reader :reason
|
11
|
+
|
12
|
+
# @param reason [Exception] the core exception that causes this error
|
13
|
+
def initialize(reason)
|
14
|
+
@reason = reason
|
15
|
+
end
|
16
|
+
end
|
17
|
+
# security error
|
18
|
+
class SecurityError < Castle::Error; end
|
19
|
+
# wrong configuration error
|
20
|
+
class ConfigurationError < Castle::Error; end
|
21
|
+
# error returned by api
|
22
|
+
class ApiError < Castle::Error; end
|
23
|
+
|
24
|
+
# api error bad request 400
|
25
|
+
class BadRequestError < Castle::ApiError; end
|
26
|
+
# api error forbidden 403
|
27
|
+
class ForbiddenError < Castle::ApiError; end
|
28
|
+
# api error not found 404
|
29
|
+
class NotFoundError < Castle::ApiError; end
|
30
|
+
# api error user unauthorized 419
|
31
|
+
class UserUnauthorizedError < Castle::ApiError; end
|
32
|
+
# api error invalid param 422
|
33
|
+
class InvalidParametersError < Castle::ApiError; end
|
34
|
+
# api error unauthorized 401
|
35
|
+
class UnauthorizedError < Castle::ApiError; end
|
36
|
+
# all internal server errors
|
37
|
+
class InternalServerError < Castle::ApiError; end
|
38
|
+
|
39
|
+
# impersonation command failed
|
40
|
+
class ImpersonationFailed < Castle::ApiError; end
|
41
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Castle
|
4
|
+
module Extractors
|
5
|
+
# used for extraction of cookies and headers from the request
|
6
|
+
class ClientId
|
7
|
+
def initialize(request, cookies)
|
8
|
+
@request = request
|
9
|
+
@cookies = cookies || {}
|
10
|
+
end
|
11
|
+
|
12
|
+
def call
|
13
|
+
@request.env['HTTP_X_CASTLE_CLIENT_ID'] || @cookies['__cid'] || ''
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Castle
|
4
|
+
module Extractors
|
5
|
+
# used for extraction of cookies and headers from the request
|
6
|
+
class Headers
|
7
|
+
# Headers that we will never scrub, even if they land on the configuration blacklist.
|
8
|
+
ALWAYS_INCLUDED_HEADERS = %w[User-Agent].freeze
|
9
|
+
|
10
|
+
# Headers that will always be scrubbed, even if whitelisted.
|
11
|
+
ALWAYS_SCRUBBED_HEADERS = %w[Cookie Authorization].freeze
|
12
|
+
|
13
|
+
# Rack does not add the HTTP_ prefix to Content-Length for some reason
|
14
|
+
CONTENT_LENGTH = 'CONTENT_LENGTH'
|
15
|
+
|
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
|
26
|
+
end
|
27
|
+
|
28
|
+
# Serialize HTTP headers
|
29
|
+
# @return [Hash]
|
30
|
+
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
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Castle
|
4
|
+
module Extractors
|
5
|
+
# used for extraction of ip from the request
|
6
|
+
class IP
|
7
|
+
def initialize(request)
|
8
|
+
@request = request
|
9
|
+
end
|
10
|
+
|
11
|
+
def call
|
12
|
+
return @request.env['HTTP_CF_CONNECTING_IP'] if @request.env['HTTP_CF_CONNECTING_IP']
|
13
|
+
return @request.remote_ip if @request.respond_to?(:remote_ip)
|
14
|
+
@request.ip
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Castle
|
4
|
+
# generate failover authentication response
|
5
|
+
class FailoverAuthResponse
|
6
|
+
def initialize(user_id, strategy: Castle.config.failover_strategy, reason:)
|
7
|
+
@strategy = strategy
|
8
|
+
@reason = reason
|
9
|
+
@user_id = user_id
|
10
|
+
end
|
11
|
+
|
12
|
+
def generate
|
13
|
+
{
|
14
|
+
action: @strategy.to_s,
|
15
|
+
user_id: @user_id,
|
16
|
+
failover: true,
|
17
|
+
failover_reason: @reason
|
18
|
+
}
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|