castle-rb 2.3.2 → 3.0.0

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 (62) hide show
  1. checksums.yaml +5 -5
  2. data/README.md +55 -9
  3. data/lib/castle.rb +17 -7
  4. data/lib/castle/api.rb +20 -22
  5. data/lib/castle/client.rb +50 -19
  6. data/lib/castle/command.rb +5 -0
  7. data/lib/castle/commands/authenticate.rb +25 -0
  8. data/lib/castle/commands/identify.rb +30 -0
  9. data/lib/castle/commands/review.rb +13 -0
  10. data/lib/castle/commands/track.rb +25 -0
  11. data/lib/castle/commands/with_context.rb +28 -0
  12. data/lib/castle/configuration.rb +46 -14
  13. data/lib/castle/context_merger.rb +13 -0
  14. data/lib/castle/default_context.rb +28 -0
  15. data/lib/castle/errors.rb +2 -0
  16. data/lib/castle/extractors/client_id.rb +4 -14
  17. data/lib/castle/extractors/headers.rb +6 -18
  18. data/lib/castle/failover_auth_response.rb +21 -0
  19. data/lib/castle/header_formatter.rb +9 -0
  20. data/lib/castle/request.rb +7 -13
  21. data/lib/castle/response.rb +2 -0
  22. data/lib/castle/review.rb +11 -0
  23. data/lib/castle/secure_mode.rb +11 -0
  24. data/lib/castle/support/hanami.rb +19 -0
  25. data/lib/castle/support/padrino.rb +1 -1
  26. data/lib/castle/support/rails.rb +1 -1
  27. data/lib/castle/support/sinatra.rb +4 -2
  28. data/lib/castle/utils.rb +55 -0
  29. data/lib/castle/utils/cloner.rb +11 -0
  30. data/lib/castle/utils/merger.rb +23 -0
  31. data/lib/castle/version.rb +1 -1
  32. data/spec/lib/castle/api_spec.rb +16 -25
  33. data/spec/lib/castle/client_spec.rb +175 -39
  34. data/spec/lib/castle/command_spec.rb +9 -0
  35. data/spec/lib/castle/commands/authenticate_spec.rb +106 -0
  36. data/spec/lib/castle/commands/identify_spec.rb +85 -0
  37. data/spec/lib/castle/commands/review_spec.rb +24 -0
  38. data/spec/lib/castle/commands/track_spec.rb +107 -0
  39. data/spec/lib/castle/configuration_spec.rb +75 -27
  40. data/spec/lib/castle/context_merger_spec.rb +34 -0
  41. data/spec/lib/castle/default_context_spec.rb +35 -0
  42. data/spec/lib/castle/extractors/client_id_spec.rb +13 -5
  43. data/spec/lib/castle/extractors/headers_spec.rb +6 -5
  44. data/spec/lib/castle/extractors/ip_spec.rb +2 -9
  45. data/spec/lib/castle/header_formatter_spec.rb +21 -0
  46. data/spec/lib/castle/request_spec.rb +12 -9
  47. data/spec/lib/castle/response_spec.rb +1 -3
  48. data/spec/lib/castle/review_spec.rb +23 -0
  49. data/spec/lib/castle/secure_mode_spec.rb +9 -0
  50. data/spec/lib/castle/utils/cloner_spec.rb +18 -0
  51. data/spec/lib/castle/utils/merger_spec.rb +13 -0
  52. data/spec/lib/castle/utils_spec.rb +156 -0
  53. data/spec/lib/castle/version_spec.rb +1 -5
  54. data/spec/lib/castle_spec.rb +8 -15
  55. data/spec/spec_helper.rb +3 -9
  56. metadata +46 -12
  57. data/lib/castle/cookie_store.rb +0 -52
  58. data/lib/castle/headers.rb +0 -39
  59. data/lib/castle/support.rb +0 -11
  60. data/lib/castle/system.rb +0 -36
  61. data/spec/lib/castle/headers_spec.rb +0 -82
  62. data/spec/lib/castle/system_spec.rb +0 -70
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: f939445efe5b4a33c2907c272077c731037e3098
4
- data.tar.gz: 928e47fa3ad0459534b1453c4bad4b09a5fdbb16
2
+ SHA256:
3
+ metadata.gz: 42c05b0f98dc4b62b7dd63aa69c0f78b3470c4e3a9432a454b1d7ca7918dbc17
4
+ data.tar.gz: a8761230e18ee47bb2337878774f637bea68ddd1d96bdc55827ec475d29a0026
5
5
  SHA512:
6
- metadata.gz: 2100298d0f3a413d392c520136ab25f18077ae57bf3bd173121feacd5c351f1b7d2e018e62777dd80a084e39839490656e8e896a3da66cdab6f4a7dc19a85ec5
7
- data.tar.gz: '0696b449a74bffcda95879e55e9a07d791a3afa88d68998c2a6131c695c4674039197e5a0c245ec0e6fb535aaa6d58a491bc2c78821e5aa012169705711528ce'
6
+ metadata.gz: '08a34db09b0d770ae486c520d71d5643c07aa436643679303f679bafbb7bf783222282db54dcfcbf36217a47401a19f73aaca49a3534e2919f5378f26fead199'
7
+ data.tar.gz: f502877e392ec738ff2425bb98ba4c8b9bdaa73a2ca9025dad7b2c51f7e20e4c50544f5275df10a9f83639fc8410feae33787bdd8b312b5d98017e40541f5fa9
data/README.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # Ruby SDK for Castle
2
2
 
3
- [![Build Status](https://travis-ci.org/castle/castle-ruby.png)](https://travis-ci.org/castle/castle-ruby)
4
- [![Gem Version](https://badge.fury.io/rb/castle-rb.png)](http://badge.fury.io/rb/castle-rb)
5
- [![Dependency Status](https://gemnasium.com/castle/castle-ruby.png)](https://gemnasium.com/castle/castle-ruby)
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
+ [![Dependency Status](https://gemnasium.com/badges/github.com/castle/castle-ruby.svg)](https://gemnasium.com/github.com/castle/castle-ruby)
6
7
 
7
8
  **[Castle](https://castle.io) adds real-time monitoring of your authentication stack, instantly notifying you and your users on potential account hijacks.**
8
9
 
@@ -20,7 +21,35 @@ Load and configure the library with your Castle API secret in an initializer or
20
21
  Castle.api_secret = 'YOUR_API_SECRET'
21
22
  ```
22
23
 
23
- A Castle client instance will be made available as `castle` in your Rails, Sinatra or Padrino controllers. The client will automatically configure the [request context](https://api.castle.io/docs#request-context) for each request.
24
+ A Castle client instance will be made available as `castle` in your
25
+
26
+ * Rails controllers when you add `require 'castle/support/rails'`
27
+
28
+ * Padrino controllers when you add `require 'castle/support/padrino'`
29
+
30
+ * 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)
31
+
32
+ ```
33
+ require 'castle/support/sinatra'
34
+
35
+ class ApplicationController < Sinatra::Base
36
+ register Sinatra::Castle
37
+ end
38
+ ```
39
+
40
+ * Hanami when you add `require 'castle/support/hanami'` and include `Castle::Hanami` to your Hanami application
41
+
42
+ ```
43
+ require 'castle/support/hanami'
44
+
45
+ module Web
46
+ class Application < Hanami::Application
47
+ include Castle::Hanami
48
+ end
49
+ end
50
+ ```
51
+
52
+ The client will automatically configure the [request context](https://api.castle.io/docs#request-context) for each request.
24
53
 
25
54
  ## Documentation
26
55
 
@@ -34,7 +63,8 @@ A Castle client instance will be made available as `castle` in your Rails, Sinat
34
63
  begin
35
64
  castle.track(
36
65
  name: '$login.succeeded',
37
- user_id: user.id)
66
+ user_id: user.id
67
+ )
38
68
  rescue Castle::Error => e
39
69
  puts e.message
40
70
  end
@@ -47,10 +77,26 @@ Castle.configure do |config|
47
77
  # Same as setting it through Castle.api_secret
48
78
  config.api_secret = 'secret'
49
79
 
50
- # Castle::RequestError is raised when timing out (default: 30.0)
51
- config.request_timeout = 2.0
80
+ # For authenticate method you can set failover strategies: allow(default), deny, challenge, throw
81
+ config.failover_strategy = :deny
52
82
 
53
- # For tracking in non-web environments: https://castle.io/docs/sources (default: 'web')
54
- config.source_header = 'backend'
83
+ # Castle::RequestError is raised when timing out in seconds (default: 500 milliseconds)
84
+ config.request_timeout = 2000
85
+
86
+ # Whitelisted and Blacklisted headers are case insensitive and allow to use _ and - as a separator, http prefixes are removed
87
+ # Whitelisted headers
88
+ config.whitelisted = ['X_HEADER']
89
+ # or append to default
90
+ config.whitelisted += ['http-x-header']
91
+
92
+ # Blacklisted headers take advantage over whitelisted elements
93
+ config.blacklisted = ['HTTP-X-header']
94
+ # or append to default
95
+ config.blacklisted += ['X_HEADER']
55
96
  end
56
97
  ```
98
+
99
+
100
+ ## Signature
101
+
102
+ `Castle::SecureMode.signature(user_id)` will create a signed user_id.
data/lib/castle.rb CHANGED
@@ -2,21 +2,33 @@
2
2
 
3
3
  require 'openssl'
4
4
  require 'net/http'
5
+ require 'json'
5
6
 
6
7
  require 'castle/version'
7
-
8
+ require 'castle/errors'
9
+ require 'castle/command'
10
+ require 'castle/utils'
11
+ require 'castle/utils/merger'
12
+ require 'castle/utils/cloner'
13
+ require 'castle/context_merger'
14
+ require 'castle/default_context'
15
+ require 'castle/commands/with_context'
16
+ require 'castle/commands/identify'
17
+ require 'castle/commands/authenticate'
18
+ require 'castle/commands/track'
19
+ require 'castle/commands/review'
8
20
  require 'castle/configuration'
21
+ require 'castle/failover_auth_response'
9
22
  require 'castle/client'
10
- require 'castle/errors'
11
- require 'castle/system'
23
+ require 'castle/header_formatter'
24
+ require 'castle/secure_mode'
12
25
  require 'castle/extractors/client_id'
13
26
  require 'castle/extractors/headers'
14
27
  require 'castle/extractors/ip'
15
- require 'castle/headers'
16
28
  require 'castle/response'
17
29
  require 'castle/request'
30
+ require 'castle/review'
18
31
  require 'castle/api'
19
- require 'castle/cookie_store'
20
32
 
21
33
  # main sdk module
22
34
  module Castle
@@ -38,5 +50,3 @@ module Castle
38
50
  end
39
51
  end
40
52
  end
41
-
42
- require 'castle/support'
data/lib/castle/api.rb CHANGED
@@ -3,32 +3,27 @@
3
3
  module Castle
4
4
  # this class is responsible for making requests to api
5
5
  class API
6
- def initialize(cookie_id, ip, headers)
6
+ def initialize(headers = {})
7
7
  @config = Castle.config
8
- @config_api_endpoint = @config.api_endpoint
9
8
  @http = prepare_http
10
- @headers = Castle::Headers.new.prepare(cookie_id, ip, headers)
9
+ @headers = headers.merge('Content-Type' => 'application/json')
11
10
  end
12
11
 
13
- def request_query(endpoint)
14
- request = Castle::Request.new(@headers).build_query(endpoint)
15
- perform_request(request)
16
- end
17
-
18
- def request(endpoint, args, method = :post)
19
- request = Castle::Request.new(@headers).build(endpoint, args, method)
12
+ def request(command)
13
+ request = Castle::Request.new(@headers).build(
14
+ command.path,
15
+ command.data,
16
+ command.method
17
+ )
20
18
  perform_request(request)
21
19
  end
22
20
 
23
21
  private
24
22
 
25
23
  def prepare_http
26
- http = Net::HTTP.new(
27
- @config_api_endpoint.host,
28
- @config_api_endpoint.port
29
- )
30
- http.read_timeout = @config.request_timeout
31
- prepare_http_for_ssl(http) if @config_api_endpoint.scheme == 'https'
24
+ http = Net::HTTP.new(@config.host, @config.port)
25
+ http.read_timeout = @config.request_timeout / 1000.0
26
+ prepare_http_for_ssl(http) if @config.port == 443
32
27
  http
33
28
  end
34
29
 
@@ -37,12 +32,15 @@ module Castle
37
32
  http.verify_mode = OpenSSL::SSL::VERIFY_PEER
38
33
  end
39
34
 
40
- def perform_request(req)
41
- Castle::Response.new(@http.request(req)).parse
42
- rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, EOFError,
43
- Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
44
- Net::ProtocolError
45
- raise Castle::RequestError, 'Castle API connection error'
35
+ def perform_request(request)
36
+ raise Castle::ConfigurationError, 'configuration is not valid' unless @config.valid?
37
+ begin
38
+ Castle::Response.new(@http.request(request)).parse
39
+ rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, EOFError,
40
+ Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
41
+ Net::ProtocolError
42
+ raise Castle::RequestError, 'Castle API connection error'
43
+ end
46
44
  end
47
45
  end
48
46
  end
data/lib/castle/client.rb CHANGED
@@ -4,40 +4,71 @@ module Castle
4
4
  class Client
5
5
  attr_accessor :api
6
6
 
7
- def initialize(request, response)
8
- @do_not_track = false
9
- cookie_id = Extractors::ClientId.new(request).call(response, '__cid')
10
- ip = Extractors::IP.new(request).call
11
- headers = Extractors::Headers.new(request).call
12
- @api = API.new(cookie_id, ip, headers)
7
+ def initialize(request, options = {})
8
+ @do_not_track = default_tracking(options)
9
+ @context = setup_context(request, options[:cookies], options[:context])
10
+ @api = API.new
13
11
  end
14
12
 
15
- def fetch_review(id)
16
- @api.request_query("reviews/#{id}")
17
- end
13
+ def authenticate(options = {})
14
+ options = Castle::Utils.deep_symbolize_keys(options || {})
18
15
 
19
- def identify(args)
20
- @api.request('identify', args) unless do_not_track?
16
+ if tracked?
17
+ command = Castle::Commands::Authenticate.new(@context).build(options)
18
+ begin
19
+ @api.request(command).merge('failover' => false, 'failover_reason' => nil)
20
+ rescue Castle::RequestError, Castle::InternalServerError => error
21
+ failover_response_or_raise(FailoverAuthResponse.new(options[:user_id], reason: error.to_s), error)
22
+ end
23
+ else
24
+ FailoverAuthResponse.new(options[:user_id], strategy: :allow, reason: 'Castle set to do not track.').generate
25
+ end
21
26
  end
22
27
 
23
- def authenticate(args)
24
- @api.request('authenticate', args)
28
+ def identify(options = {})
29
+ options = Castle::Utils.deep_symbolize_keys(options || {})
30
+
31
+ return unless tracked?
32
+
33
+ command = Castle::Commands::Identify.new(@context).build(options)
34
+ @api.request(command)
25
35
  end
26
36
 
27
- def track(args)
28
- @api.request('track', args) unless do_not_track?
37
+ def track(options = {})
38
+ options = Castle::Utils.deep_symbolize_keys(options || {})
39
+
40
+ return unless tracked?
41
+
42
+ command = Castle::Commands::Track.new(@context).build(options)
43
+ @api.request(command)
29
44
  end
30
45
 
31
- def do_not_track!
46
+ def disable_tracking
32
47
  @do_not_track = true
33
48
  end
34
49
 
35
- def track!
50
+ def enable_tracking
36
51
  @do_not_track = false
37
52
  end
38
53
 
39
- def do_not_track?
40
- @do_not_track
54
+ def tracked?
55
+ !@do_not_track
56
+ end
57
+
58
+ private
59
+
60
+ def setup_context(request, cookies, additional_context)
61
+ default_context = Castle::DefaultContext.new(request, cookies).call
62
+ Castle::ContextMerger.new(default_context).call(additional_context || {})
63
+ end
64
+
65
+ def failover_response_or_raise(failover_response, error)
66
+ return failover_response.generate unless Castle.config.failover_strategy == :throw
67
+ raise error
68
+ end
69
+
70
+ def default_tracking(options)
71
+ options.key?(:do_not_track) ? options[:do_not_track] : false
41
72
  end
42
73
  end
43
74
  end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ Command = Struct.new(:path, :data, :method)
5
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ module Commands
5
+ class Authenticate
6
+ include WithContext
7
+
8
+ def build(options = {})
9
+ validate!(options)
10
+ build_context!(options)
11
+
12
+ Castle::Command.new('authenticate', options, :post)
13
+ end
14
+
15
+ private
16
+
17
+ def validate!(options)
18
+ %i[event user_id].each do |key|
19
+ next unless options[key].to_s.empty?
20
+ raise Castle::InvalidParametersError, "#{key} is missing or empty"
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ module Commands
5
+ class Identify
6
+ include WithContext
7
+
8
+ def build(options = {})
9
+ validate!(options)
10
+ build_context!(options)
11
+
12
+ Castle::Command.new('identify', options, :post)
13
+ end
14
+
15
+ private
16
+
17
+ def validate!(options)
18
+ %i[user_id].each do |key|
19
+ next unless options[key].to_s.empty?
20
+ raise Castle::InvalidParametersError, "#{key} is missing or empty"
21
+ end
22
+
23
+ if options[:properties]
24
+ raise Castle::InvalidParametersError,
25
+ 'properties are not supported in identify calls'
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ module Commands
5
+ class Review
6
+ def build(review_id)
7
+ raise Castle::InvalidParametersError if review_id.nil? || review_id.to_s.empty?
8
+
9
+ Castle::Command.new("reviews/#{review_id}", nil, :get)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ module Commands
5
+ class Track
6
+ include WithContext
7
+
8
+ def build(options = {})
9
+ validate!(options)
10
+ build_context!(options)
11
+
12
+ Castle::Command.new('track', options, :post)
13
+ end
14
+
15
+ private
16
+
17
+ def validate!(options)
18
+ %i[event].each do |key|
19
+ next unless options[key].to_s.empty?
20
+ raise Castle::InvalidParametersError, "#{key} is missing or empty"
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ module Commands
5
+ module WithContext
6
+ def initialize(context)
7
+ @context_merger = ContextMerger.new(context)
8
+ end
9
+
10
+ private
11
+
12
+ def build_context!(options)
13
+ sanitize_active_mode!(options)
14
+ options[:context] = merge_context(options[:context])
15
+ end
16
+
17
+ def sanitize_active_mode!(options)
18
+ return unless options[:context] && options[:context].key?(:active)
19
+ return if [true, false].include?(options[:context][:active])
20
+ options[:context].delete(:active)
21
+ end
22
+
23
+ def merge_context(request_context)
24
+ @context_merger.call(request_context || {})
25
+ end
26
+ end
27
+ end
28
+ end
@@ -3,37 +3,69 @@
3
3
  module Castle
4
4
  # manages configuration variables
5
5
  class Configuration
6
- SUPPORTED = %i[source_header request_timeout api_secret api_endpoint].freeze
7
- REQUEST_TIMEOUT = 30.0
8
- API_ENDPOINT = 'https://api.castle.io/v1'
6
+ REQUEST_TIMEOUT = 500 # in milliseconds
7
+ FAILOVER_STRATEGIES = %i[allow deny challenge throw].freeze
8
+ WHITELISTED = [
9
+ 'User-Agent',
10
+ 'Accept-Language',
11
+ 'Accept-Encoding',
12
+ 'Accept-Charset',
13
+ 'Accept',
14
+ 'Accept-Datetime',
15
+ 'X-Forwarded-For',
16
+ 'Forwarded',
17
+ 'X-Forwarded',
18
+ 'X-Real-IP',
19
+ 'REMOTE_ADDR'
20
+ ].freeze
9
21
 
10
- attr_accessor :request_timeout, :source_header
11
- attr_reader :api_secret, :api_endpoint
22
+ BLACKLISTED = ['HTTP_COOKIE'].freeze
23
+
24
+ attr_accessor :host, :port, :request_timeout, :url_prefix
25
+ attr_reader :api_secret, :host, :port, :whitelisted, :blacklisted, :failover_strategy
12
26
 
13
27
  def initialize
28
+ @formatter = Castle::HeaderFormatter.new
14
29
  @request_timeout = REQUEST_TIMEOUT
15
- self.api_endpoint = API_ENDPOINT
30
+ self.failover_strategy = :allow
31
+ self.host = 'api.castle.io'
32
+ self.port = 443
33
+ self.url_prefix = 'v1'
34
+ self.whitelisted = WHITELISTED
35
+ self.blacklisted = BLACKLISTED
16
36
  self.api_secret = ''
17
37
  end
18
38
 
19
- def api_endpoint=(value)
20
- @api_endpoint = URI(
21
- ENV.fetch('CASTLE_API_ENDPOINT', value)
22
- )
23
- end
24
-
25
39
  def api_secret=(value)
26
40
  @api_secret = ENV.fetch('CASTLE_API_SECRET', value).to_s
27
41
  end
28
42
 
43
+ def whitelisted=(value)
44
+ @whitelisted = (value ? value.map { |header| @formatter.call(header) } : []).freeze
45
+ end
46
+
47
+ def blacklisted=(value)
48
+ @blacklisted = (value ? value.map { |header| @formatter.call(header) } : []).freeze
49
+ end
50
+
51
+ def valid?
52
+ !api_secret.to_s.empty? && !host.to_s.empty? && !port.to_s.empty?
53
+ end
54
+
55
+ def failover_strategy=(value)
56
+ @failover_strategy = FAILOVER_STRATEGIES.detect { |strategy| strategy == value.to_sym }
57
+ raise Castle::ConfigurationError, 'unrecognized failover strategy' if @failover_strategy.nil?
58
+ @failover_strategy
59
+ end
60
+
29
61
  private
30
62
 
31
63
  def respond_to_missing?(method_name, _include_private)
32
64
  /^(\w+)=$/ =~ method_name
33
65
  end
34
66
 
35
- def method_missing(_m, *_args)
36
- raise Castle::ConfigurationError, 'there is no such a config'
67
+ def method_missing(m, *_args)
68
+ raise Castle::ConfigurationError, "there is no such a config #{m}"
37
69
  end
38
70
  end
39
71
  end