castle_devise 0.1.0

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