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.
- checksums.yaml +7 -0
- data/README.md +77 -0
- data/lib/castle.rb +47 -0
- data/lib/castle/client.rb +139 -0
- data/lib/castle/configuration.rb +37 -0
- data/lib/castle/errors.rb +16 -0
- data/lib/castle/ext/her.rb +14 -0
- data/lib/castle/jwt.rb +36 -0
- data/lib/castle/models/account.rb +11 -0
- data/lib/castle/models/backup_codes.rb +4 -0
- data/lib/castle/models/challenge.rb +7 -0
- data/lib/castle/models/context.rb +18 -0
- data/lib/castle/models/event.rb +7 -0
- data/lib/castle/models/model.rb +71 -0
- data/lib/castle/models/monitoring.rb +6 -0
- data/lib/castle/models/pairing.rb +11 -0
- data/lib/castle/models/recommendation.rb +3 -0
- data/lib/castle/models/session.rb +6 -0
- data/lib/castle/models/trusted_device.rb +5 -0
- data/lib/castle/models/user.rb +20 -0
- data/lib/castle/request.rb +164 -0
- data/lib/castle/session_store.rb +35 -0
- data/lib/castle/session_token.rb +39 -0
- data/lib/castle/support/cookie_store.rb +48 -0
- data/lib/castle/support/padrino.rb +19 -0
- data/lib/castle/support/rails.rb +11 -0
- data/lib/castle/support/sinatra.rb +17 -0
- data/lib/castle/token_store.rb +30 -0
- data/lib/castle/utils.rb +22 -0
- data/lib/castle/version.rb +3 -0
- data/spec/fixtures/vcr_cassettes/challenge_create.yml +48 -0
- data/spec/fixtures/vcr_cassettes/challenge_verify.yml +42 -0
- data/spec/fixtures/vcr_cassettes/session_create.yml +47 -0
- data/spec/fixtures/vcr_cassettes/session_refresh.yml +47 -0
- data/spec/fixtures/vcr_cassettes/session_verify.yml +47 -0
- data/spec/fixtures/vcr_cassettes/user_find.yml +44 -0
- data/spec/fixtures/vcr_cassettes/user_find_non_existing.yml +42 -0
- data/spec/fixtures/vcr_cassettes/user_import.yml +46 -0
- data/spec/fixtures/vcr_cassettes/user_update.yml +47 -0
- data/spec/helpers_spec.rb +38 -0
- data/spec/jwt_spec.rb +67 -0
- data/spec/models/challenge_spec.rb +18 -0
- data/spec/models/session_spec.rb +14 -0
- data/spec/models/user_spec.rb +31 -0
- data/spec/spec_helper.rb +29 -0
- data/spec/utils_spec.rb +59 -0
- 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
|
+
[](https://travis-ci.org/castle/castle-ruby)
|
4
|
+
[](http://badge.fury.io/rb/castle)
|
5
|
+
[](https://gemnasium.com/castle/castle-ruby)
|
6
|
+
[](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,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,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
|