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.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: %i[spec]
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,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -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