castle-rb 1.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 (47) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +77 -0
  3. data/lib/castle.rb +47 -0
  4. data/lib/castle/client.rb +139 -0
  5. data/lib/castle/configuration.rb +37 -0
  6. data/lib/castle/errors.rb +16 -0
  7. data/lib/castle/ext/her.rb +14 -0
  8. data/lib/castle/jwt.rb +36 -0
  9. data/lib/castle/models/account.rb +11 -0
  10. data/lib/castle/models/backup_codes.rb +4 -0
  11. data/lib/castle/models/challenge.rb +7 -0
  12. data/lib/castle/models/context.rb +18 -0
  13. data/lib/castle/models/event.rb +7 -0
  14. data/lib/castle/models/model.rb +71 -0
  15. data/lib/castle/models/monitoring.rb +6 -0
  16. data/lib/castle/models/pairing.rb +11 -0
  17. data/lib/castle/models/recommendation.rb +3 -0
  18. data/lib/castle/models/session.rb +6 -0
  19. data/lib/castle/models/trusted_device.rb +5 -0
  20. data/lib/castle/models/user.rb +20 -0
  21. data/lib/castle/request.rb +164 -0
  22. data/lib/castle/session_store.rb +35 -0
  23. data/lib/castle/session_token.rb +39 -0
  24. data/lib/castle/support/cookie_store.rb +48 -0
  25. data/lib/castle/support/padrino.rb +19 -0
  26. data/lib/castle/support/rails.rb +11 -0
  27. data/lib/castle/support/sinatra.rb +17 -0
  28. data/lib/castle/token_store.rb +30 -0
  29. data/lib/castle/utils.rb +22 -0
  30. data/lib/castle/version.rb +3 -0
  31. data/spec/fixtures/vcr_cassettes/challenge_create.yml +48 -0
  32. data/spec/fixtures/vcr_cassettes/challenge_verify.yml +42 -0
  33. data/spec/fixtures/vcr_cassettes/session_create.yml +47 -0
  34. data/spec/fixtures/vcr_cassettes/session_refresh.yml +47 -0
  35. data/spec/fixtures/vcr_cassettes/session_verify.yml +47 -0
  36. data/spec/fixtures/vcr_cassettes/user_find.yml +44 -0
  37. data/spec/fixtures/vcr_cassettes/user_find_non_existing.yml +42 -0
  38. data/spec/fixtures/vcr_cassettes/user_import.yml +46 -0
  39. data/spec/fixtures/vcr_cassettes/user_update.yml +47 -0
  40. data/spec/helpers_spec.rb +38 -0
  41. data/spec/jwt_spec.rb +67 -0
  42. data/spec/models/challenge_spec.rb +18 -0
  43. data/spec/models/session_spec.rb +14 -0
  44. data/spec/models/user_spec.rb +31 -0
  45. data/spec/spec_helper.rb +29 -0
  46. data/spec/utils_spec.rb +59 -0
  47. metadata +273 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 7cf6e4ab5cc8e82409be1c17d5c4a27a9c71190d
4
+ data.tar.gz: 2a79f7284ad075e025d2c5c3c160c1bce6ef5134
5
+ SHA512:
6
+ metadata.gz: f0487c283b4da37dabb38d870be3d79d69013ef6ac267954dea165f8d2aa8b2fa61570ddc9911e880a533921fc5b0bdb05743164b0c5572dbb80b69cbd24bee7
7
+ data.tar.gz: 309970c57c13c40158906f226af70ce790077a3a6f818f24d39a319c0fbd74a777141d9dc14019199e4d366bab38b3c211381e70740b4332d5f8f56d46934064
data/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # Ruby SDK for Castle
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.png)](http://badge.fury.io/rb/castle)
5
+ [![Dependency Status](https://gemnasium.com/castle/castle-ruby.png)](https://gemnasium.com/castle/castle-ruby)
6
+ [![Coverage Status](https://coveralls.io/repos/castle/castle-ruby/badge.png)](https://coveralls.io/r/castle/castle-ruby)
7
+
8
+ **[Castle](https://castle.io) adds real-time monitoring of your authentication stack, instantly notifying you and your users on potential account hijacks.**
9
+
10
+ ## Installation
11
+
12
+ Add the `castle-rb` gem to your `Gemfile`
13
+
14
+ ```ruby
15
+ gem 'castle-rb'
16
+ ```
17
+
18
+ Load and configure the library with your Castle API secret in an initializer or similar.
19
+
20
+ ```ruby
21
+ Castle.api_secret = 'YOUR_API_SECRET'
22
+ ```
23
+
24
+ A Castle client instance will automatically be made available as `castle` in your Rails, Sinatra or Padrino controllers.
25
+
26
+ ## Tracking security events
27
+
28
+ `track` lets you record the security-related actions your users perform. The more actions you track, the more accurate Castle is in identifying fraudsters.
29
+
30
+ Event names and detail properties that have semantic meaning are prefixed `$`, and we handle them in special ways.
31
+
32
+ When you have access to a **logged in user**, set `user_id` to the same user identifier as when you initiated Castle.js.
33
+
34
+ ```ruby
35
+ castle.track(
36
+ name: '$login.succeeded',
37
+ user_id: user.id)
38
+ ```
39
+
40
+ When you **don't** have access to a logged in user just omit `user_id`, typically when tracking `$login.failed` and `$password_reset.requested`. Instead, whenever you have access to the user-submitted form value, add this to the event details as `$login`.
41
+
42
+ ```ruby
43
+ castle.track(
44
+ name: '$login.failed',
45
+ details: {
46
+ '$login' => 'johan@castle.io'
47
+ })
48
+ ```
49
+
50
+ ### Supported events
51
+
52
+ - `$login.succeeded`: Record when a user attempts to log in.
53
+ - `$login.failed`: Record when a user logs out.
54
+ - `$logout.succeeded`: Record when a user logs out.
55
+ - `$registration.succeeded`: Capture account creation, both when a user signs up as well as when created manually by an administrator.
56
+ - `$registration.failed`: Record when an account failed to be created.
57
+ - `$password_reset.requested`: An attempt was made to reset a user’s password.
58
+ - `$password_reset.succeeded`: The user completed all of the steps in the password reset process and the password was successfully reset. Password resets **do not** required knowledge of the current password.
59
+ - `$password_reset.failed`: Use to record when a user failed to reset their password.
60
+ - `$password_change.succeeded`: Use to record when a user changed their password. This event is only logged when users change their **own** password.
61
+ - `$password_change.failed`: Use to record when a user failed to change their password.
62
+
63
+ ### Supported detail properties
64
+
65
+ - `$login`: The submitted email or username from when the user attempted to log in or reset their password. Useful when there is no `user_id` available.
66
+
67
+ ## Configuration
68
+
69
+ ```ruby
70
+ Castle.configure do |config|
71
+ # Same as setting it through Castle.api_secret
72
+ config.api_secret = 'secret'
73
+
74
+ # Castle::RequestError is raised when timing out (default: 30.0)
75
+ config.request_timeout = 2.0
76
+ end
77
+ ```
data/lib/castle.rb ADDED
@@ -0,0 +1,47 @@
1
+ require 'her'
2
+ require 'castle/ext/her'
3
+ require 'faraday_middleware'
4
+ require 'multi_json'
5
+ require 'openssl'
6
+ require 'net/http'
7
+ require 'request_store'
8
+ require 'active_support/core_ext/hash/indifferent_access'
9
+
10
+ require 'castle/version'
11
+
12
+ require 'castle/configuration'
13
+ require 'castle/client'
14
+ require 'castle/errors'
15
+ require 'castle/token_store'
16
+ require 'castle/jwt'
17
+ require 'castle/utils'
18
+ require 'castle/request'
19
+ require 'castle/session_token'
20
+
21
+ require 'castle/support/cookie_store'
22
+ require 'castle/support/rails' if defined?(Rails::Railtie)
23
+ if defined?(Sinatra::Base)
24
+ if defined?(Padrino)
25
+ require 'castle/support/padrino'
26
+ else
27
+ require 'castle/support/sinatra'
28
+ end
29
+ end
30
+
31
+ module Castle
32
+ API = Castle.setup_api
33
+ end
34
+
35
+ # These need to be required after setting up Her
36
+ require 'castle/models/model'
37
+ require 'castle/models/account'
38
+ require 'castle/models/event'
39
+ require 'castle/models/challenge'
40
+ require 'castle/models/context'
41
+ require 'castle/models/monitoring'
42
+ require 'castle/models/pairing'
43
+ require 'castle/models/backup_codes'
44
+ require 'castle/models/recommendation'
45
+ require 'castle/models/session'
46
+ require 'castle/models/trusted_device'
47
+ require 'castle/models/user'
@@ -0,0 +1,139 @@
1
+ module Castle
2
+ class Client
3
+
4
+ attr_accessor :request_context
5
+
6
+ def self.install_proxy_methods(*names)
7
+ names.each do |name|
8
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
9
+ def #{name}(*args)
10
+ Castle::User.new('$current').#{name}(*args)
11
+ end
12
+ RUBY
13
+ end
14
+ end
15
+
16
+ install_proxy_methods :challenges, :events, :sessions, :pairings,
17
+ :backup_codes, :generate_backup_codes, :trusted_devices,
18
+ :enable_mfa!, :disable_mfa!
19
+
20
+ def initialize(request, response, opts = {})
21
+ # Save a reference in the per-request store so that the request
22
+ # middleware in request.rb can access it
23
+ RequestStore.store[:castle] = self
24
+
25
+ if response.class.name == 'ActionDispatch::Cookies::CookieJar'
26
+ cookies = Castle::CookieStore::Rack.new(response)
27
+ else
28
+ cookies = Castle::CookieStore::Base.new(request, response)
29
+ end
30
+
31
+ @store = Castle::TokenStore.new(cookies)
32
+
33
+ @request_context = {
34
+ ip: request.ip,
35
+ user_agent: request.user_agent,
36
+ cookie_id: cookies['__cid']
37
+ }
38
+ end
39
+
40
+ def session_token
41
+ @store.session_token
42
+ end
43
+
44
+ def session_token=(session_token)
45
+ @store.session_token = session_token
46
+ end
47
+
48
+ def authorize!
49
+ unless @store.session_token
50
+ raise Castle::UserUnauthorizedError,
51
+ 'Need to call login before authorize'
52
+ end
53
+
54
+ if @store.session_token.expired?
55
+ Castle::Monitoring.heartbeat
56
+ end
57
+
58
+ if mfa_in_progress?
59
+ logout
60
+ raise Castle::UserUnauthorizedError,
61
+ 'Logged out due to being unverified'
62
+ end
63
+
64
+ if mfa_required? && !device_trusted?
65
+ raise Castle::ChallengeRequiredError
66
+ end
67
+ end
68
+
69
+ def authorized?
70
+ !!@store.session_token
71
+ end
72
+
73
+ def login(user_id, user_attrs = {})
74
+ # Clear the session token if any
75
+ @store.session_token = nil
76
+
77
+ user = Castle::User.new(user_id.to_s)
78
+ session = user.sessions.create(
79
+ user: user_attrs, trusted_device_token: @store.trusted_device_token)
80
+
81
+ # Set the session token for use in all subsequent requests
82
+ @store.session_token = session.token
83
+
84
+ session
85
+ end
86
+
87
+ def logout
88
+ return unless @store.session_token
89
+
90
+ # Destroy the current session specified in the session token
91
+ begin
92
+ sessions.destroy('$current')
93
+ rescue Castle::ApiError # ignored
94
+ end
95
+
96
+ # Clear the session token
97
+ @store.session_token = nil
98
+ end
99
+
100
+ def trust_device(attrs = {})
101
+ unless @store.session_token
102
+ raise Castle::UserUnauthorizedError,
103
+ 'Need to call login before trusting device'
104
+ end
105
+ trusted_device = trusted_devices.create(attrs)
106
+
107
+ # Set the session token for use in all subsequent requests
108
+ @store.trusted_device_token = trusted_device.token
109
+ end
110
+
111
+ def mfa_enabled?
112
+ @store.session_token ? @store.session_token.mfa_enabled? : false
113
+ end
114
+
115
+ def device_trusted?
116
+ @store.session_token ? @store.session_token.device_trusted? : false
117
+ end
118
+
119
+ def mfa_in_progress?
120
+ @store.session_token ? @store.session_token.mfa_in_progress? : false
121
+ end
122
+
123
+ def mfa_required?
124
+ @store.session_token ? @store.session_token.mfa_required? : false
125
+ end
126
+
127
+ def has_default_pairing?
128
+ @store.session_token ? @store.session_token.has_default_pairing? : false
129
+ end
130
+
131
+ def track(opts = {})
132
+ Castle::Event.post('/v1/events', opts)
133
+ end
134
+
135
+ def recommendation(opts = {})
136
+ Castle::Recommendation.get('/v1/recommendation', opts)
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,37 @@
1
+ module Castle
2
+ class << self
3
+ def configure(config_hash=nil)
4
+ if config_hash
5
+ config_hash.each do |k,v|
6
+ config.send("#{k}=", v)
7
+ end
8
+ end
9
+
10
+ yield(config) if block_given?
11
+ end
12
+
13
+ def config
14
+ @configuration ||= Castle::Configuration.new
15
+ end
16
+
17
+ def api_secret=(api_secret)
18
+ config.api_secret = api_secret
19
+ end
20
+ end
21
+
22
+ class Configuration
23
+ attr_accessor :request_timeout
24
+
25
+ def initialize
26
+ self.request_timeout = 30.0
27
+ end
28
+
29
+ def api_secret
30
+ ENV['CASTLE_API_SECRET'] || @_api_secret || ''
31
+ end
32
+
33
+ def api_secret=(value)
34
+ @_api_secret = value
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,16 @@
1
+ class Castle::Error < Exception; end
2
+
3
+ class Castle::RequestError < Castle::Error; end
4
+ class Castle::SecurityError < Castle::Error; end
5
+ class Castle::ConfigurationError < Castle::Error; end
6
+
7
+ class Castle::ApiError < Castle::Error; end
8
+
9
+ class Castle::BadRequestError < Castle::ApiError; end
10
+ class Castle::ForbiddenError < Castle::ApiError; end
11
+ class Castle::NotFoundError < Castle::ApiError; end
12
+ class Castle::UserUnauthorizedError < Castle::ApiError; end
13
+ class Castle::InvalidParametersError < Castle::ApiError; end
14
+
15
+ class Castle::UnauthorizedError < Castle::ApiError; end
16
+ class Castle::ChallengeRequiredError < Castle::ApiError; end
@@ -0,0 +1,14 @@
1
+ #
2
+ # Add destroy to association: user.challenges.destroy(id)
3
+ #
4
+ module Her::Model::Associations
5
+ class AssociationProxy
6
+ install_proxy_methods :association, :destroy
7
+ end
8
+
9
+ class HasManyAssociation < Association ## remove inheritance
10
+ def destroy(id)
11
+ @klass.destroy_existing(id, :"#{@parent.singularized_resource_name}_id" => @parent.id)
12
+ end
13
+ end
14
+ end
data/lib/castle/jwt.rb ADDED
@@ -0,0 +1,36 @@
1
+ require 'jwt'
2
+
3
+ module Castle
4
+ class JWT
5
+ attr_accessor :header, :payload
6
+
7
+ def initialize(jwt)
8
+ begin
9
+ raise Castle::SecurityError, 'Empty JWT' unless jwt
10
+ @payload = ::JWT.decode(jwt, Castleconfig.api_secret, true) do |header|
11
+ @header = header.with_indifferent_access
12
+ Castleconfig.api_secret # used by the 'key finder' in the JWT gem
13
+ end.with_indifferent_access
14
+ rescue ::JWT::DecodeError => e
15
+ raise Castle::SecurityError.new(e)
16
+ end
17
+ end
18
+
19
+ def expired?
20
+ Time.now.utc > Time.at(@header['exp']).utc
21
+ end
22
+
23
+ def merge!(payload = {})
24
+ @payload.merge!(payload)
25
+ end
26
+
27
+ def to_json
28
+ @payload
29
+ end
30
+
31
+ def to_token
32
+ ::JWT.encode(@payload, Castleconfig.api_secret, "HS256", @header)
33
+ end
34
+
35
+ end
36
+ end
@@ -0,0 +1,11 @@
1
+ module Castle
2
+ class Account < Model
3
+ def self.fetch
4
+ get('/v1/account')
5
+ end
6
+
7
+ def self.update(settings = {})
8
+ put('/v1/account', settings: settings)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,4 @@
1
+ module Castle
2
+ class BackupCodes < Model
3
+ end
4
+ end
@@ -0,0 +1,7 @@
1
+ module Castle
2
+ class Challenge < Model
3
+ collection_path "users/:user_id/challenges"
4
+ has_one :pairing
5
+ instance_post :verify
6
+ end
7
+ end
@@ -0,0 +1,18 @@
1
+ module Castle
2
+ class Context < Model
3
+ def user_agent
4
+ if attributes['user_agent']
5
+ Castle::UserAgent.new(attributes['user_agent'])
6
+ end
7
+ end
8
+
9
+ def location
10
+ if attributes['location']
11
+ Castle::Location.new(attributes['location'])
12
+ end
13
+ end
14
+ end
15
+
16
+ class UserAgent < Model; end
17
+ class Location < Model; end
18
+ end
@@ -0,0 +1,7 @@
1
+ module Castle
2
+ class Event < Model
3
+ collection_path "users/:user_id/events"
4
+ belongs_to :user
5
+ has_one :context
6
+ end
7
+ end
@@ -0,0 +1,71 @@
1
+ require 'her'
2
+
3
+ class Her::Collection
4
+ # Call the overridden to_json in Castle::Model
5
+ def to_json
6
+ self.map { |m| m.to_json }
7
+ end
8
+ end
9
+
10
+ module Castle
11
+ class Model
12
+ include Her::Model
13
+ use_api Castle::API
14
+
15
+ def initialize(args = {})
16
+ # allow initializing with id as a string
17
+ args = { id: args } if args.is_a? String
18
+ super(args)
19
+ end
20
+
21
+ # Transform model.user.id to model.user_id to allow calls on nested models
22
+ def attributes
23
+ attrs = super
24
+ if attrs['user'] && attrs['user']['id']
25
+ attrs.merge!('user_id' => attrs['user']['id'])
26
+ attrs.delete 'user'
27
+ end
28
+ attrs
29
+ end
30
+
31
+ # Remove the auto-generated embedded User model to prevent recursion
32
+ def to_json
33
+ attrs = attributes
34
+ if attrs['user'] && attrs['user']['id'] == '$current'
35
+ attrs.delete 'user'
36
+ end
37
+ attrs.to_json
38
+ end
39
+
40
+ METHODS.each do |method|
41
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
42
+ def self.instance_#{method}(action)
43
+ instance_custom(:#{method}, action)
44
+ end
45
+ RUBY
46
+ end
47
+
48
+ def self.instance_custom(method, action)
49
+ #
50
+ # Add method calls to association: user.challenges.verify(id, attributes)
51
+ #
52
+ AssociationProxy.class_eval <<-RUBY, __FILE__, __LINE__ + 1
53
+ install_proxy_methods :association, :#{action}
54
+ RUBY
55
+ HasManyAssociation.class_eval <<-RUBY, __FILE__, __LINE__ + 1
56
+ def #{action}(id, attributes={})
57
+ @klass.build({:id => id, :"\#{@parent.singularized_resource_name}_id" => @parent.id}).#{action}(attributes)
58
+ end
59
+ RUBY
60
+
61
+ #
62
+ # Add method call to instance: user.enable_mfa
63
+ #
64
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
65
+ def #{action}(params={})
66
+ self.class.#{method}("\#{request_path}/#{action.to_s.delete('!')}", params)
67
+ end
68
+ RUBY
69
+ end
70
+ end
71
+ end