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