castle-rb 1.0.0

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