castle-rb 2.3.2 → 3.0.0

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