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
@@ -0,0 +1,6 @@
1
+ module Castle
2
+ class Monitoring < Model
3
+ collection_path '/v1' # Her doesn't accept the empty string
4
+ custom_post :heartbeat
5
+ end
6
+ end
@@ -0,0 +1,11 @@
1
+ module Castle
2
+ class Pairing < Model
3
+ collection_path "users/:user_id/pairings"
4
+ instance_post :verify
5
+ instance_post :set_default!
6
+ belongs_to :user
7
+ has_one :config
8
+ end
9
+
10
+ class Config < Model; end
11
+ end
@@ -0,0 +1,3 @@
1
+ module Castle
2
+ class Recommendation < Model; end
3
+ end
@@ -0,0 +1,6 @@
1
+ module Castle
2
+ class Session < Model
3
+ collection_path "users/:user_id/sessions"
4
+ has_one :context
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module Castle
2
+ class TrustedDevice < Model
3
+ collection_path "users/:user_id/trusted_devices"
4
+ end
5
+ end
@@ -0,0 +1,20 @@
1
+ module Castle
2
+ class User < Model
3
+ instance_post :enable_mfa!
4
+ instance_post :disable_mfa!
5
+
6
+ has_many :challenges
7
+ has_many :events
8
+ has_many :pairings
9
+ has_many :sessions
10
+ has_many :trusted_devices
11
+
12
+ def backup_codes(params={})
13
+ Castle::BackupCodes.get("/v1/users/#{id}/backup_codes", params)
14
+ end
15
+
16
+ def generate_backup_codes(params={})
17
+ Castle::BackupCodes.post("/v1/users/#{id}/backup_codes", params)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,164 @@
1
+ module Castle
2
+ module Request
3
+
4
+ def self.client_user_agent
5
+ @uname ||= get_uname
6
+ lang_version = "#{RUBY_VERSION} p#{RUBY_PATCHLEVEL} (#{RUBY_RELEASE_DATE})"
7
+
8
+ {
9
+ :bindings_version => Castle::VERSION,
10
+ :lang => 'ruby',
11
+ :lang_version => lang_version,
12
+ :platform => RUBY_PLATFORM,
13
+ :publisher => 'castle',
14
+ :uname => @uname
15
+ }
16
+ end
17
+
18
+ def self.get_uname
19
+ `uname -a 2>/dev/null`.strip if RUBY_PLATFORM =~ /linux|darwin/i
20
+ rescue Errno::ENOMEM # couldn't create subprocess
21
+ "uname lookup failed"
22
+ end
23
+
24
+ #
25
+ # Faraday middleware
26
+ #
27
+ module Middleware
28
+ # Sets credentials dynamically, allowing them to change between requests.
29
+ #
30
+ class BasicAuth < Faraday::Middleware
31
+ def initialize(app, api_secret)
32
+ super(app)
33
+ @api_secret = api_secret
34
+ end
35
+
36
+ def call(env)
37
+ value = Base64.encode64(":#{@api_secret || Castle.config.api_secret}")
38
+ value.delete!("\n")
39
+ env[:request_headers]["Authorization"] = "Basic #{value}"
40
+ @app.call(env)
41
+ end
42
+ end
43
+
44
+ # Handle request errors
45
+ #
46
+ class RequestErrorHandler < Faraday::Middleware
47
+ def call(env)
48
+ env.request.timeout = Castle.config.request_timeout
49
+ begin
50
+ @app.call(env)
51
+ rescue Faraday::ConnectionFailed
52
+ raise Castle::RequestError, 'Could not connect to Castle API'
53
+ rescue Faraday::TimeoutError
54
+ raise Castle::RequestError, 'Castle API timed out'
55
+ end
56
+ end
57
+ end
58
+
59
+ # Adds details about current environment
60
+ #
61
+ class EnvironmentHeaders < Faraday::Middleware
62
+ def call(env)
63
+ begin
64
+ env[:request_headers]["X-Castle-Client-User-Agent"] =
65
+ MultiJson.encode(Castle::Request.client_user_agent)
66
+ rescue # ignored
67
+ end
68
+
69
+ env[:request_headers]["User-Agent"] =
70
+ "Castle/v1 RubyBindings/#{Castle::VERSION}"
71
+
72
+ @app.call(env)
73
+ end
74
+ end
75
+
76
+ # Sends the active session token in a header, and extracts the returned
77
+ # session token and sets it locally.
78
+ #
79
+ class SessionToken < Faraday::Middleware
80
+ def call(env)
81
+ castle = RequestStore.store[:castle]
82
+ return @app.call(env) unless castle
83
+
84
+ # get the session token from our local store
85
+ if castle.session_token
86
+ env[:request_headers]['X-Castle-Session-Token'] =
87
+ castle.session_token.to_s
88
+ end
89
+
90
+ # call the API
91
+ response = @app.call(env)
92
+
93
+ # update the local store with the updated session token
94
+ token = response.env.response_headers['X-Castle-set-session-token']
95
+ castle.session_token = token if token
96
+
97
+ response
98
+ end
99
+ end
100
+
101
+ # Adds request context like IP address and user agent to any request.
102
+ #
103
+ class ContextHeaders < Faraday::Middleware
104
+ def call(env)
105
+ castle = RequestStore.store[:castle]
106
+ return @app.call(env) unless castle
107
+
108
+ castle.request_context.each do |key, value|
109
+ if value
110
+ header =
111
+ "X-Castle-#{key.to_s.gsub('_', '-').gsub(/\w+/) {|m| m.capitalize}}"
112
+ env[:request_headers][header] = value
113
+ end
114
+ end
115
+ @app.call(env)
116
+ end
117
+ end
118
+
119
+ class JSONParser < Faraday::Response::Middleware
120
+ def on_complete(env)
121
+ response = if env[:body].nil? || env[:body].empty?
122
+ {}
123
+ else
124
+ begin
125
+ MultiJson.load(env[:body], :symbolize_keys => true)
126
+ rescue MultiJson::LoadError
127
+ raise Castle::ApiError, 'Invalid response from Castle API'
128
+ end
129
+ end
130
+
131
+ case env[:status]
132
+ when 200..299
133
+ # OK
134
+ when 400
135
+ raise Castle::BadRequestError, response[:message]
136
+ when 401
137
+ raise Castle::UnauthorizedError, response[:message]
138
+ when 403
139
+ raise Castle::ForbiddenError, response[:message]
140
+ when 404
141
+ raise Castle::NotFoundError, response[:message]
142
+ when 419
143
+ # session token is invalid so clear it
144
+ RequestStore.store[:castle].session_token = nil
145
+
146
+ raise Castle::UserUnauthorizedError, response[:message]
147
+ when 422
148
+ raise Castle::InvalidParametersError, response[:message]
149
+ else
150
+ raise Castle::ApiError, response[:message]
151
+ end
152
+
153
+ env[:body] = {
154
+ data: response,
155
+ metadata: [],
156
+ errors: {}
157
+ }
158
+ end
159
+ end
160
+
161
+ end
162
+
163
+ end
164
+ end
@@ -0,0 +1,35 @@
1
+ module Castle
2
+ class SessionStore
3
+ class Rack < SessionStore
4
+ def initialize(session)
5
+ @session = session
6
+ end
7
+
8
+ def user_id
9
+ @session['Castleuser_id']
10
+ end
11
+
12
+ def user_id=(value)
13
+ @session['Castleuser_id'] = value
14
+ end
15
+
16
+ def read
17
+ @session[key]
18
+ end
19
+
20
+ def write(value)
21
+ @session[key] = value
22
+ end
23
+
24
+ def destroy
25
+ @session.delete(key)
26
+ end
27
+
28
+ private
29
+
30
+ def key
31
+ "Castleuser.#{user_id}"
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,39 @@
1
+ require 'jwt'
2
+
3
+ module Castle
4
+ class SessionToken
5
+ def initialize(token)
6
+ if token
7
+ @jwt = Castle::JWT.new(token)
8
+ end
9
+ end
10
+
11
+ def to_s
12
+ @jwt.to_token
13
+ end
14
+
15
+ def expired?
16
+ @jwt.expired?
17
+ end
18
+
19
+ def device_trusted?
20
+ @jwt.payload['tru'] == 1
21
+ end
22
+
23
+ def has_default_pairing?
24
+ @jwt.payload['dpr'] > 0
25
+ end
26
+
27
+ def mfa_enabled?
28
+ @jwt.payload['mfa'] == 1
29
+ end
30
+
31
+ def mfa_in_progress?
32
+ @jwt.payload['chg'] == 1
33
+ end
34
+
35
+ def mfa_required?
36
+ @jwt.payload['vfy'] > 0
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,48 @@
1
+ module Castle
2
+ module CookieStore
3
+ class Base
4
+ def initialize(request, response)
5
+ @request = request
6
+ @response = response
7
+ end
8
+
9
+ def [](key)
10
+ @request.cookies[key]
11
+ end
12
+
13
+ def []=(key, value)
14
+ @request.cookies[key] = value
15
+ if value
16
+ @response.set_cookie(key, value: value,
17
+ expires: Time.now + (20 * 365 * 24 * 60 * 60),
18
+ path: '/')
19
+ else
20
+ @response.delete_cookie(key)
21
+ end
22
+ end
23
+ end
24
+
25
+ class Rack
26
+ def initialize(cookies)
27
+ @cookies = cookies
28
+ end
29
+
30
+ def [](key)
31
+ @cookies[key]
32
+ end
33
+
34
+ def []=(key, value)
35
+ if value
36
+ @cookies[key] = {
37
+ value: value,
38
+ expires: Time.now + (20 * 365 * 24 * 60 * 60),
39
+ path: '/'
40
+ }
41
+ else
42
+ @cookies.delete(key)
43
+ end
44
+ end
45
+ end
46
+
47
+ end
48
+ end
@@ -0,0 +1,19 @@
1
+ require_relative 'cookie_store'
2
+
3
+ module Padrino
4
+ class Application
5
+ module Castle
6
+ module Helpers
7
+ def castle
8
+ @castle ||= ::Castle::Client.new(request, response)
9
+ end
10
+ end
11
+
12
+ def self.registered(app)
13
+ app.helpers Helpers
14
+ end
15
+ end
16
+
17
+ register Castle
18
+ end
19
+ end
@@ -0,0 +1,11 @@
1
+ module Castle
2
+ module CastleClient
3
+ def castle
4
+ @castle ||= env['castle'] || Castle::Client.new(request, response)
5
+ end
6
+ end
7
+
8
+ ActiveSupport.on_load(:action_controller) do
9
+ include CastleClient
10
+ end
11
+ end
@@ -0,0 +1,17 @@
1
+ require_relative 'cookie_store'
2
+
3
+ module Sinatra
4
+ module Castle
5
+ module Helpers
6
+ def castle
7
+ @castle ||= ::Castle::Client.new(request, response)
8
+ end
9
+ end
10
+
11
+ def self.registered(app)
12
+ app.helpers Helpers
13
+ end
14
+ end
15
+
16
+ register Castle
17
+ end
@@ -0,0 +1,30 @@
1
+ module Castle
2
+ class TokenStore
3
+ def initialize(cookies)
4
+ @cookies = cookies
5
+ end
6
+
7
+ def session_token
8
+ token = @cookies['_ubs']
9
+ Castle::SessionToken.new(token) if token
10
+ end
11
+
12
+ def session_token=(value)
13
+ @cookies['_ubs'] = value
14
+
15
+ if value && value != @cookies['_ubs']
16
+ @cookies['_ubs']
17
+ elsif !value
18
+ @cookies['_ubs'] = nil
19
+ end
20
+ end
21
+
22
+ def trusted_device_token
23
+ @cookies['_ubt']
24
+ end
25
+
26
+ def trusted_device_token=(value)
27
+ @cookies['_ubt'] = value
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,22 @@
1
+ # TODO: scope Castle::Utils
2
+
3
+ module Castle
4
+ class << self
5
+ def setup_api(api_secret = nil)
6
+ api_endpoint = ENV.fetch('CASTLE_API_ENDPOINT') {
7
+ "https://api.castle.io/v1"
8
+ }
9
+
10
+ Her::API.setup url: api_endpoint do |c|
11
+ c.use Castle::Request::Middleware::BasicAuth, api_secret
12
+ c.use Castle::Request::Middleware::RequestErrorHandler
13
+ c.use Castle::Request::Middleware::EnvironmentHeaders
14
+ c.use Castle::Request::Middleware::ContextHeaders
15
+ c.use Castle::Request::Middleware::SessionToken
16
+ c.use FaradayMiddleware::EncodeJson
17
+ c.use Castle::Request::Middleware::JSONParser
18
+ c.use Faraday::Adapter::NetHttp
19
+ end
20
+ end
21
+ end
22
+ end