castle_devise 0.1.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/.github/workflows/lint.yml +18 -0
- data/.github/workflows/specs.yml +25 -0
- data/.gitignore +18 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +5 -0
- data/Gemfile +17 -0
- data/Gemfile.lock +169 -0
- data/LICENSE +21 -0
- data/README.md +197 -0
- data/Rakefile +8 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/castle_devise.gemspec +35 -0
- data/lib/castle_devise.rb +78 -0
- data/lib/castle_devise/configuration.rb +54 -0
- data/lib/castle_devise/context.rb +86 -0
- data/lib/castle_devise/controllers/helpers.rb +29 -0
- data/lib/castle_devise/helpers/castle_helper.rb +83 -0
- data/lib/castle_devise/hooks/castle_protectable.rb +63 -0
- data/lib/castle_devise/models/castle_protectable.rb +74 -0
- data/lib/castle_devise/patches.rb +13 -0
- data/lib/castle_devise/patches/registrations_controller.rb +57 -0
- data/lib/castle_devise/rails.rb +13 -0
- data/lib/castle_devise/sdk_facade.rb +144 -0
- data/lib/castle_devise/version.rb +5 -0
- metadata +127 -0
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "bundler/setup"
|
5
|
+
require "castle/devise"
|
6
|
+
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
9
|
+
|
10
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
11
|
+
# require "pry"
|
12
|
+
# Pry.start
|
13
|
+
|
14
|
+
require "irb"
|
15
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "lib/castle_devise/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = "castle_devise"
|
7
|
+
spec.version = CastleDevise::VERSION
|
8
|
+
spec.license = "MIT"
|
9
|
+
spec.summary = "Integrates Castle with Devise"
|
10
|
+
spec.description = "castle_devise provides out-of-the-box protection against bot registrations and account takeover attacks."
|
11
|
+
spec.homepage = "https://github.com/castle/castle_devise"
|
12
|
+
spec.required_ruby_version = Gem::Requirement.new(">= 2.5.0")
|
13
|
+
|
14
|
+
spec.authors = ["Kacper Madej", "Johan Brissmyr"]
|
15
|
+
spec.email = ["kacper@castle.io"]
|
16
|
+
|
17
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
18
|
+
spec.metadata["source_code_uri"] = "https://github.com/castle/castle_devise"
|
19
|
+
spec.metadata["changelog_uri"] = "https://github.com/castle/castle_devise/CHANGELOG.md"
|
20
|
+
|
21
|
+
# Specify which files should be added to the gem when it is released.
|
22
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
23
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
24
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
|
25
|
+
end
|
26
|
+
spec.bindir = "exe"
|
27
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
28
|
+
spec.require_paths = ["lib"]
|
29
|
+
|
30
|
+
# Uncomment to register a new dependency of your gem
|
31
|
+
spec.add_dependency "castle-rb", ">= 7.0", "< 8.0"
|
32
|
+
spec.add_dependency "activesupport", ">= 5.0"
|
33
|
+
|
34
|
+
spec.add_runtime_dependency "devise", ">= 4.3.0", "< 5.0"
|
35
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "castle"
|
4
|
+
require "devise"
|
5
|
+
|
6
|
+
# CastleDevise consists of a few different parts:
|
7
|
+
#
|
8
|
+
# - Devise castle_protectable module defined in lib/castle_devise/models/
|
9
|
+
# - Minimal monkey patches to Devise controller defined in lib/castle_devise/patches/
|
10
|
+
# - Warden hooks defined in lib/castle_devise/hooks/
|
11
|
+
# - A Facade layer on top of the Castle SDK: {CastleDevise::SdkFacade}
|
12
|
+
# - A Context object that contains all the data you might want to use when integrating
|
13
|
+
# Castle with your application: {CastleDevise::Context}
|
14
|
+
module CastleDevise
|
15
|
+
class << self
|
16
|
+
# @return [CastleDevise::Configuration]
|
17
|
+
def configuration
|
18
|
+
@configuration ||= Configuration.new
|
19
|
+
end
|
20
|
+
|
21
|
+
# @return [Logger]
|
22
|
+
def logger
|
23
|
+
configuration.logger
|
24
|
+
end
|
25
|
+
|
26
|
+
# @yieldparam [CastleDevise::Configuration] configuration object
|
27
|
+
def configure
|
28
|
+
yield configuration
|
29
|
+
|
30
|
+
Castle.api_secret = configuration.api_secret
|
31
|
+
Castle.config.logger = configuration.logger
|
32
|
+
end
|
33
|
+
|
34
|
+
# @return [true, false] whether in monitoring mode or not
|
35
|
+
def monitoring_mode?
|
36
|
+
configuration.monitoring_mode
|
37
|
+
end
|
38
|
+
|
39
|
+
# @return [CastleDevise::SdkFacade]
|
40
|
+
def sdk_facade
|
41
|
+
@sdk_facade ||= CastleDevise::SdkFacade.new(
|
42
|
+
castle,
|
43
|
+
configuration.before_request_hooks,
|
44
|
+
configuration.after_request_hooks
|
45
|
+
)
|
46
|
+
end
|
47
|
+
|
48
|
+
# @return [Castle::Client]
|
49
|
+
def castle
|
50
|
+
@castle ||= Castle::Client.new
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
require_relative "castle_devise/configuration"
|
56
|
+
require_relative "castle_devise/context"
|
57
|
+
require_relative "castle_devise/patches"
|
58
|
+
require_relative "castle_devise/sdk_facade"
|
59
|
+
require_relative "castle_devise/controllers/helpers"
|
60
|
+
require_relative "castle_devise/helpers/castle_helper"
|
61
|
+
require_relative "castle_devise/hooks/castle_protectable"
|
62
|
+
require_relative "castle_devise/models/castle_protectable"
|
63
|
+
require_relative "castle_devise/patches/registrations_controller"
|
64
|
+
|
65
|
+
require_relative "castle_devise/rails"
|
66
|
+
|
67
|
+
# Monkey patching Devise module in order to add
|
68
|
+
# additional configuration options
|
69
|
+
module Devise
|
70
|
+
# Configures which events trigger Castle API calls
|
71
|
+
mattr_accessor :castle_hooks
|
72
|
+
@@castle_hooks = {
|
73
|
+
before_registration: true,
|
74
|
+
after_login: true
|
75
|
+
}
|
76
|
+
end
|
77
|
+
|
78
|
+
Devise.add_module :castle_protectable, model: "castle_devise/models/castle_protectable"
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/configurable"
|
4
|
+
require "logger"
|
5
|
+
|
6
|
+
module CastleDevise
|
7
|
+
# Configuration object using {ActiveSupport::Configurable}
|
8
|
+
class Configuration
|
9
|
+
include ActiveSupport::Configurable
|
10
|
+
|
11
|
+
# @!attribute api_secret
|
12
|
+
# @return [String] Your API secret
|
13
|
+
config_accessor(:api_secret)
|
14
|
+
|
15
|
+
# @!attribute app_id
|
16
|
+
# @return [String] Your Castle App ID
|
17
|
+
config_accessor(:app_id)
|
18
|
+
|
19
|
+
# @!attribute monitoring_mode
|
20
|
+
# When CastleDevise is in monitoring mode, it sends requests to Castle
|
21
|
+
# but it doesn't act on "deny" verdicts.
|
22
|
+
#
|
23
|
+
# This mode is useful if you're just checking Castle out and you're not yet sure whether
|
24
|
+
# your configuration is correct so you don't accidentally block legitimate users
|
25
|
+
# from logging in/registering.
|
26
|
+
#
|
27
|
+
# @return [true, false] whether to act on deny requests or not
|
28
|
+
config_accessor(:monitoring_mode) { false }
|
29
|
+
|
30
|
+
# @!attribute logger
|
31
|
+
# @return [Logger] A Logger instance. You might want to use Rails.logger here.
|
32
|
+
config_accessor(:logger) { Logger.new("/dev/null") }
|
33
|
+
|
34
|
+
# @!attribute before_request_hooks
|
35
|
+
# @return [Array<Proc>] Array of procs that will get called before a request to the Castle API
|
36
|
+
config_accessor(:before_request_hooks) { [] }
|
37
|
+
|
38
|
+
# @!attribute after_request_hooks
|
39
|
+
# @return [Array<Proc>] Array of procs that will get called after a request to the Castle API
|
40
|
+
config_accessor(:after_request_hooks) { [] }
|
41
|
+
|
42
|
+
# Adds a new before_request hook
|
43
|
+
# @param blk [Proc]
|
44
|
+
def before_request(&blk)
|
45
|
+
before_request_hooks << blk
|
46
|
+
end
|
47
|
+
|
48
|
+
# Adds a new after_request hook
|
49
|
+
# @param blk [Proc]
|
50
|
+
def after_request(&blk)
|
51
|
+
after_request_hooks << blk
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module CastleDevise
|
4
|
+
# Provides a small layer of abstraction on top of raw Rack::Request and Warden
|
5
|
+
class Context
|
6
|
+
class << self
|
7
|
+
# @param rack_env [Hash]
|
8
|
+
# @param scope [Symbol] Warden scope
|
9
|
+
# @param resource [ActiveRecord::Base, nil]
|
10
|
+
# @return [CastleDevise::Context]
|
11
|
+
def from_rack_env(rack_env, scope, resource = nil)
|
12
|
+
new(rack_request: Rack::Request.new(rack_env), scope: scope, resource: resource)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
# @return [Rack::Request]
|
17
|
+
attr_reader :rack_request
|
18
|
+
# @return [ActiveRecord::Base, nil]
|
19
|
+
attr_reader :resource
|
20
|
+
# @return [Symbol] The Devise scope for the resource
|
21
|
+
attr_reader :scope
|
22
|
+
|
23
|
+
# @param rack_request [Rack::Request]
|
24
|
+
# @param resource [ActiveRecord::Base, nil]
|
25
|
+
# @param scope [Symbol] Warden scope
|
26
|
+
def initialize(rack_request:, scope:, resource: nil)
|
27
|
+
@rack_request = rack_request
|
28
|
+
@resource = resource
|
29
|
+
@scope = scope
|
30
|
+
end
|
31
|
+
|
32
|
+
# @return [String, nil] Castle request token, if present in POST params
|
33
|
+
def request_token
|
34
|
+
rack_request.env.dig("rack.request.form_hash", "castle_request_token")
|
35
|
+
end
|
36
|
+
|
37
|
+
# @return [String, nil] user_id that will be sent to Castle
|
38
|
+
def castle_id
|
39
|
+
resource&.castle_id
|
40
|
+
end
|
41
|
+
|
42
|
+
# Email for the current context. If there is no resource available (eg. for a failed login request)
|
43
|
+
# this method will attempt to fetch an email from Rack POST parameters using the current Devise scope.
|
44
|
+
#
|
45
|
+
# @return [String, nil]
|
46
|
+
def email
|
47
|
+
resource&.email || email_from_form_params
|
48
|
+
end
|
49
|
+
|
50
|
+
# @return [String, nil]
|
51
|
+
def username
|
52
|
+
resource&.castle_name
|
53
|
+
end
|
54
|
+
|
55
|
+
# @return [Hash] additional user traits that will be sent to Castle
|
56
|
+
def user_traits
|
57
|
+
resource&.castle_traits || {}
|
58
|
+
end
|
59
|
+
|
60
|
+
# @return [Time, nil]
|
61
|
+
def registered_at
|
62
|
+
resource&.created_at
|
63
|
+
end
|
64
|
+
|
65
|
+
# Logs out current resource. Adds `castle_devise: :skip` to the authentication options
|
66
|
+
# to make sure we do not accidentally call Castle due to auth failure.
|
67
|
+
def logout!
|
68
|
+
warden.logout(scope)
|
69
|
+
throw(:warden, scope: scope, castle_devise: :skip, message: :not_found_in_database)
|
70
|
+
end
|
71
|
+
|
72
|
+
private
|
73
|
+
|
74
|
+
# Attempts to retrieve an email from Rack form parameters for the current Devise scope.
|
75
|
+
#
|
76
|
+
# @return [String, nil]
|
77
|
+
def email_from_form_params
|
78
|
+
rack_request.env.dig("rack.request.form_hash", scope.to_s, "email")
|
79
|
+
end
|
80
|
+
|
81
|
+
# @return [Warden::Proxy]
|
82
|
+
def warden
|
83
|
+
rack_request.env["warden"]
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module CastleDevise
|
4
|
+
module Controllers
|
5
|
+
# Methods defined here will be included in all your controllers.
|
6
|
+
module Helpers
|
7
|
+
# @return [Castle::Client]
|
8
|
+
def castle
|
9
|
+
CastleDevise.castle
|
10
|
+
end
|
11
|
+
|
12
|
+
# Returns a Castle response from /v1/risk endpoint, if such a request has been made
|
13
|
+
# during the request.
|
14
|
+
#
|
15
|
+
# @return [Hash, nil]
|
16
|
+
def castle_risk_response
|
17
|
+
request.env["castle_devise.risk_response"]
|
18
|
+
end
|
19
|
+
|
20
|
+
# Returns true if Castle Risk API call resulted in a "challenge" action.
|
21
|
+
# Returns false if no request has been made, or the action was different than "challenge".
|
22
|
+
#
|
23
|
+
# @return [true, false]
|
24
|
+
def castle_challenge?
|
25
|
+
castle_risk_response&.dig(:policy, :action) == "challenge"
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module CastleDevise
|
4
|
+
module Helpers
|
5
|
+
# Methods defined here will be available in all your views.
|
6
|
+
module CastleHelper
|
7
|
+
# Creates a <script> tag that includes our c.js script from a CDN.
|
8
|
+
# You have to make sure that your app_id is valid, otherwise the script won't work.
|
9
|
+
#
|
10
|
+
# You shouldn't call this method if you bundle our c.js script with your other
|
11
|
+
# JS packages.
|
12
|
+
#
|
13
|
+
# You should put this in the <head> section of your page:
|
14
|
+
#
|
15
|
+
# @example
|
16
|
+
# # app/views/layouts/application.html.erb
|
17
|
+
# <!DOCTYPE html>
|
18
|
+
# <html>
|
19
|
+
# <head>
|
20
|
+
# <%= castle_javascript_tag %>
|
21
|
+
# <title>Your app title</title>
|
22
|
+
#
|
23
|
+
# <!-- the rest of your layout -->
|
24
|
+
def castle_javascript_tag
|
25
|
+
javascript_include_tag(
|
26
|
+
"https://d2t77mnxyo7adj.cloudfront.net/v1/c.js?#{CastleDevise.configuration.app_id}"
|
27
|
+
)
|
28
|
+
end
|
29
|
+
|
30
|
+
# Puts an inline <script> tag that includes a "castle_devise_token" field
|
31
|
+
# within the current form.
|
32
|
+
#
|
33
|
+
# @example
|
34
|
+
# <%= form_for(resource, as: resource_name, url: sessions_path(resource_name)) do |f| %>
|
35
|
+
# <%= castle_request_token %>
|
36
|
+
# <%= f.email_field :email %>
|
37
|
+
# <%= f.password_field :password, autocomplete: 'off' %>
|
38
|
+
# <% end %>
|
39
|
+
#
|
40
|
+
# @return [String]
|
41
|
+
def castle_request_token
|
42
|
+
tag = <<~HEREDOC
|
43
|
+
<script>
|
44
|
+
// The current script tag is the last one at the time of load
|
45
|
+
var el = document.getElementsByTagName('script');
|
46
|
+
el = el[el.length - 1];
|
47
|
+
|
48
|
+
// Traverse up until we find a form
|
49
|
+
while (el && el !== document) {
|
50
|
+
if (el.tagName === 'FORM') break;
|
51
|
+
el = el.parentNode;
|
52
|
+
}
|
53
|
+
|
54
|
+
// Intercept the form submit
|
55
|
+
if (el.tagName === 'FORM') {
|
56
|
+
el.onsubmit = function(e) {
|
57
|
+
e.preventDefault();
|
58
|
+
|
59
|
+
_castle('createRequestToken').then(function(requestToken) {
|
60
|
+
// Populate a hidden field called `castle_request_token` with the
|
61
|
+
// request token
|
62
|
+
var hiddenInput = document.createElement('input');
|
63
|
+
hiddenInput.setAttribute('type', 'hidden');
|
64
|
+
hiddenInput.setAttribute('name', 'castle_request_token');
|
65
|
+
hiddenInput.setAttribute('value', requestToken);
|
66
|
+
|
67
|
+
// Add the hidden field to the form so it gets sent to the server
|
68
|
+
// before submitting the form
|
69
|
+
el.appendChild(hiddenInput);
|
70
|
+
|
71
|
+
el.submit();
|
72
|
+
});
|
73
|
+
};
|
74
|
+
} else {
|
75
|
+
console.log('[Castle] The script helper needs to be within a <form> tag')
|
76
|
+
}
|
77
|
+
</script>
|
78
|
+
HEREDOC
|
79
|
+
tag.html_safe
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
Warden::Manager.after_authentication do |resource, warden, opts|
|
4
|
+
next unless resource.devise_modules.include?(:castle_protectable)
|
5
|
+
next unless resource.class.castle_hooks[:after_login]
|
6
|
+
|
7
|
+
context = CastleDevise::Context.from_rack_env(warden.env, opts[:scope], resource)
|
8
|
+
|
9
|
+
warden.env["castle_devise.risk_context"] = context
|
10
|
+
|
11
|
+
begin
|
12
|
+
response = CastleDevise.sdk_facade.risk(
|
13
|
+
event: "$login",
|
14
|
+
context: context
|
15
|
+
)
|
16
|
+
|
17
|
+
warden.env["castle_devise.risk_response"] = response
|
18
|
+
|
19
|
+
next if CastleDevise.monitoring_mode?
|
20
|
+
|
21
|
+
if response.dig(:policy, :action) == "deny"
|
22
|
+
# high ATO risk, pretend the User does not exist
|
23
|
+
context.logout!
|
24
|
+
end
|
25
|
+
rescue Castle::InvalidParametersError
|
26
|
+
# TODO: We should act differently if the error is about missing/invalid request token
|
27
|
+
# compared to any other validation errors. However, we can't do this with the
|
28
|
+
# current Castle SDK as it doesn't give us any way to differentiate these two cases.
|
29
|
+
CastleDevise.logger.warn(
|
30
|
+
"[CastleDevise] /v1/risk request contained invalid parameters." \
|
31
|
+
" This might mean that either you didn't configure Castle's Javascript properly, or" \
|
32
|
+
" a request has been made without Javascript (eg. cURL/bot)." \
|
33
|
+
" Such a request is treated as if Castle responded with a 'deny' action in non-monitoring mode."
|
34
|
+
)
|
35
|
+
|
36
|
+
context.logout! unless CastleDevise.monitoring_mode?
|
37
|
+
rescue Castle::Error => e
|
38
|
+
# log API errors and allow
|
39
|
+
CastleDevise.logger.error("[CastleDevise] risk($login): #{e}")
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
Warden::Manager.before_failure do |env, opts|
|
44
|
+
next if opts[:castle_devise] == :skip
|
45
|
+
|
46
|
+
resource_class = Devise.mappings[opts[:scope]].to
|
47
|
+
|
48
|
+
next if resource_class.nil?
|
49
|
+
next unless resource_class.devise_modules.include?(:castle_protectable)
|
50
|
+
next unless resource_class.castle_hooks[:after_login]
|
51
|
+
|
52
|
+
context = CastleDevise::Context.from_rack_env(env, opts[:scope])
|
53
|
+
|
54
|
+
begin
|
55
|
+
CastleDevise.sdk_facade.log(
|
56
|
+
event: "$login",
|
57
|
+
status: "$failed",
|
58
|
+
context: context
|
59
|
+
)
|
60
|
+
rescue Castle::Error => e
|
61
|
+
CastleDevise.logger.error("[CastleDevise] log($login, $failed): #{e}")
|
62
|
+
end
|
63
|
+
end
|