castle-rb 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
[![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,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
|