castle_devise 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devise
4
+ module Models
5
+ # This module contains methods that will be included in your Devise model when you
6
+ # include the castle_protectable Devise module.
7
+ #
8
+ # Configuration:
9
+ #
10
+ # castle_hooks: configures which events trigger Castle API calls
11
+ # {
12
+ # after_login: true, # trigger risk($login) and log($login, $failed),
13
+ # before_registration: true # trigger filter($registration)
14
+ # }
15
+ module CastleProtectable
16
+ extend ActiveSupport::Concern
17
+
18
+ module ClassMethods
19
+ Devise::Models.config(self, :castle_hooks)
20
+ end
21
+
22
+ # @return [String, nil] ID used for sending requests to Castle
23
+ def castle_id
24
+ id&.to_s
25
+ end
26
+
27
+ # @return [Hash] additional traits that will be sent to Castle
28
+ #
29
+ # @example
30
+ # @example
31
+ # class User
32
+ # belongs_to :company
33
+ #
34
+ # devise :castle_protectable,
35
+ # :confirmable,
36
+ # :database_authenticatable,
37
+ # :registerable,
38
+ # :rememberable,
39
+ # :validatable
40
+ #
41
+ # def castle_traits
42
+ # {
43
+ # company_name: company.name
44
+ # }
45
+ # end
46
+ # end
47
+ def castle_traits
48
+ {}
49
+ end
50
+
51
+ # This method is meant to be overridden with a human-readable username
52
+ # that will be shown on the Castle Dashboard.
53
+ #
54
+ # @return [String, nil]
55
+ #
56
+ # @example
57
+ # class User
58
+ # devise :castle_protectable,
59
+ # :confirmable,
60
+ # :database_authenticatable,
61
+ # :registerable,
62
+ # :rememberable,
63
+ # :validatable
64
+ #
65
+ # def castle_name
66
+ # [first_name, last_name].join(' ').strip
67
+ # end
68
+ # end
69
+ def castle_name
70
+ nil
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CastleDevise
4
+ module Patches
5
+ class << self
6
+ # Applies monkey-patches to Devise controllers
7
+ # @api private
8
+ def apply
9
+ Devise::RegistrationsController.send(:include, Patches::RegistrationsController)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CastleDevise
4
+ module Patches
5
+ # Monkey-patch for
6
+ # {https://github.com/heartcombo/devise/blob/master/app/controllers/devise/registrations_controller.rb Devise::RegistrationsController}
7
+ # which includes Castle in the registration workflow.
8
+ module RegistrationsController
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ before_action :castle_filter, only: :create
13
+ end
14
+
15
+ # Sends a /v1/filter request to Castle
16
+ def castle_filter
17
+ return unless resource_class.castle_hooks[:before_registration]
18
+
19
+ response = CastleDevise.sdk_facade.filter(
20
+ event: "$registration",
21
+ context: CastleDevise::Context.from_rack_env(request.env, resource_name)
22
+ )
23
+
24
+ return if CastleDevise.monitoring_mode?
25
+
26
+ case response.dig(:policy, :action)
27
+ when "deny"
28
+ set_flash_message!(:alert, "blocked_by_castle")
29
+ flash.alert = "Account cannot be created at this moment. Please try again later."
30
+ redirect_to new_session_path(resource_name)
31
+ false
32
+ else
33
+ # everything fine, continue
34
+ end
35
+ rescue Castle::InvalidParametersError
36
+ # TODO: We should act differently if the error is about missing/invalid request token
37
+ # compared to any other validation errors. However, we can't do this with the
38
+ # current Castle SDK as it doesn't give us any way to differentiate these two cases.
39
+ CastleDevise.logger.warn(
40
+ "[CastleDevise] /v1/filter request contained invalid parameters." \
41
+ " This might mean that either you didn't configure Castle's Javascript properly, or" \
42
+ " a request has been made without Javascript (eg. cURL/bot)." \
43
+ " Such a request is treated as if Castle responded with a 'deny' action in non-monitoring mode."
44
+ )
45
+
46
+ unless CastleDevise.monitoring_mode?
47
+ set_flash_message!(:alert, "blocked_by_castle")
48
+ redirect_to new_session_path(resource_name)
49
+ false
50
+ end
51
+ rescue Castle::Error => e
52
+ # log API errors and allow
53
+ CastleDevise.logger.error("[CastleDevise] filter($registration): #{e}")
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ ActiveSupport.on_load(:action_controller) do
4
+ include CastleDevise::Controllers::Helpers
5
+ end
6
+
7
+ ActiveSupport.on_load(:action_view) do
8
+ include CastleDevise::Helpers::CastleHelper
9
+ end
10
+
11
+ ActiveSupport::Reloader.to_prepare do
12
+ CastleDevise::Patches.apply
13
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CastleDevise
4
+ # A Facade layer providing a simpler API on top of the Castle SDK
5
+ class SdkFacade
6
+ # @return [Castle::Client]
7
+ attr_reader :castle
8
+
9
+ # @param castle [Castle::Client]
10
+ # @param before_request_hooks [Array<Proc>]
11
+ # @param after_request_hooks [Array<Proc>]
12
+ def initialize(castle, before_request_hooks = [], after_request_hooks = [])
13
+ @castle = castle
14
+ @before_request_hooks = before_request_hooks
15
+ @after_request_hooks = after_request_hooks
16
+ end
17
+
18
+ # Sends request to the /v1/filter endpoint.
19
+ # @param event [String]
20
+ # @param context [CastleDevise::Context]
21
+ # @return [Hash] Raw API response
22
+ # @see https://docs.castle.io/v1/reference/api-reference/#v1filter
23
+ def filter(event:, context:)
24
+ payload = {
25
+ event: event,
26
+ user: {
27
+ email: context.email
28
+ },
29
+ request_token: context.request_token,
30
+ context: payload_context(context.rack_request)
31
+ }
32
+
33
+ with_request_hooks(:filter, context, payload) do
34
+ castle.filter(payload)
35
+ end
36
+ end
37
+
38
+ # Sends request to the /v1/risk endpoint.
39
+ # @param event [String]
40
+ # @param context [CastleDevise::Context]
41
+ # @return [Hash] Raw API response
42
+ # @see https://docs.castle.io/v1/reference/api-reference/#v1risk
43
+ def risk(event:, context:)
44
+ payload = {
45
+ event: event,
46
+ status: "$succeeded",
47
+ user: {
48
+ id: context.castle_id,
49
+ email: context.email,
50
+ registered_at: format_time(context.registered_at),
51
+ traits: context.user_traits
52
+ },
53
+ request_token: context.request_token,
54
+ context: payload_context(context.rack_request)
55
+ }
56
+
57
+ payload[:user][:name] = context.username if context.username
58
+
59
+ with_request_hooks(:risk, context, payload) do
60
+ castle.risk(payload)
61
+ end
62
+ end
63
+
64
+ # Sends request to the /v1/log endpoint.
65
+ # @param event [String]
66
+ # @param status [String, nil]
67
+ # @param context [CastleDevise::Context]
68
+ # @return [Hash] Raw API response
69
+ # @see https://docs.castle.io/v1/reference/api-reference/#v1log
70
+ def log(event:, status:, context:)
71
+ payload = {
72
+ event: event,
73
+ status: status,
74
+ user: {
75
+ id: context.castle_id,
76
+ email: context.email,
77
+ registered_at: format_time(context.registered_at),
78
+ traits: context.user_traits
79
+ }.compact,
80
+ context: payload_context(context.rack_request)
81
+ }
82
+
83
+ # request_token is optional on the Log endpoint, but if it's sent it must
84
+ # be a valid Castle token
85
+ payload[:request_token] = context.request_token if context.request_token
86
+
87
+ with_request_hooks(:log, context, payload) do
88
+ castle.log(payload)
89
+ end
90
+ end
91
+
92
+ private
93
+
94
+ attr_reader :before_request_hooks, :after_request_hooks
95
+
96
+ # @param rack_request [Rack::Request]
97
+ # @return [Hash]
98
+ def payload_context(rack_request)
99
+ ctx = Castle::Context::Prepare.call(rack_request)
100
+
101
+ # Castle SDK still generates some legacy parameters which can be removed
102
+ # when sending requests to the new Castle endpoints
103
+ ctx.slice!(:headers, :ip, :library)
104
+
105
+ ctx
106
+ end
107
+
108
+ # @param time [Time, nil]
109
+ # @return [String, nil]
110
+ def format_time(time)
111
+ time&.utc&.iso8601(3)
112
+ end
113
+
114
+ # @param action [Symbol] Castle API method
115
+ # @param context [CastleDevise::Context]
116
+ # @param payload [Hash] payload passed to the Castle Client
117
+ def with_request_hooks(action, context, payload)
118
+ before_request(action, context, payload)
119
+
120
+ yield.tap do |response|
121
+ after_request(action, context, payload, response)
122
+ end
123
+ end
124
+
125
+ # @param action [Symbol] Castle API method
126
+ # @param context [CastleDevise::Context]
127
+ # @param payload [Hash] payload passed to the Castle Client
128
+ def before_request(action, context, payload)
129
+ before_request_hooks.each do |hook|
130
+ hook.call(action, context, payload)
131
+ end
132
+ end
133
+
134
+ # @param action [Symbol] Castle API method
135
+ # @param context [CastleDevise::Context]
136
+ # @param payload [Hash] payload passed to the Castle Client
137
+ # @param response [Hash] response received from Castle
138
+ def after_request(action, context, payload, response)
139
+ after_request_hooks.each do |hook|
140
+ hook.call(action, context, payload, response)
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CastleDevise
4
+ VERSION = "0.1.0"
5
+ end
metadata ADDED
@@ -0,0 +1,127 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: castle_devise
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Kacper Madej
8
+ - Johan Brissmyr
9
+ autorequire:
10
+ bindir: exe
11
+ cert_chain: []
12
+ date: 2021-07-08 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: castle-rb
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ">="
19
+ - !ruby/object:Gem::Version
20
+ version: '7.0'
21
+ - - "<"
22
+ - !ruby/object:Gem::Version
23
+ version: '8.0'
24
+ type: :runtime
25
+ prerelease: false
26
+ version_requirements: !ruby/object:Gem::Requirement
27
+ requirements:
28
+ - - ">="
29
+ - !ruby/object:Gem::Version
30
+ version: '7.0'
31
+ - - "<"
32
+ - !ruby/object:Gem::Version
33
+ version: '8.0'
34
+ - !ruby/object:Gem::Dependency
35
+ name: activesupport
36
+ requirement: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '5.0'
41
+ type: :runtime
42
+ prerelease: false
43
+ version_requirements: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '5.0'
48
+ - !ruby/object:Gem::Dependency
49
+ name: devise
50
+ requirement: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: 4.3.0
55
+ - - "<"
56
+ - !ruby/object:Gem::Version
57
+ version: '5.0'
58
+ type: :runtime
59
+ prerelease: false
60
+ version_requirements: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: 4.3.0
65
+ - - "<"
66
+ - !ruby/object:Gem::Version
67
+ version: '5.0'
68
+ description: castle_devise provides out-of-the-box protection against bot registrations
69
+ and account takeover attacks.
70
+ email:
71
+ - kacper@castle.io
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - ".github/workflows/lint.yml"
77
+ - ".github/workflows/specs.yml"
78
+ - ".gitignore"
79
+ - ".rspec"
80
+ - CHANGELOG.md
81
+ - Gemfile
82
+ - Gemfile.lock
83
+ - LICENSE
84
+ - README.md
85
+ - Rakefile
86
+ - bin/console
87
+ - bin/setup
88
+ - castle_devise.gemspec
89
+ - lib/castle_devise.rb
90
+ - lib/castle_devise/configuration.rb
91
+ - lib/castle_devise/context.rb
92
+ - lib/castle_devise/controllers/helpers.rb
93
+ - lib/castle_devise/helpers/castle_helper.rb
94
+ - lib/castle_devise/hooks/castle_protectable.rb
95
+ - lib/castle_devise/models/castle_protectable.rb
96
+ - lib/castle_devise/patches.rb
97
+ - lib/castle_devise/patches/registrations_controller.rb
98
+ - lib/castle_devise/rails.rb
99
+ - lib/castle_devise/sdk_facade.rb
100
+ - lib/castle_devise/version.rb
101
+ homepage: https://github.com/castle/castle_devise
102
+ licenses:
103
+ - MIT
104
+ metadata:
105
+ homepage_uri: https://github.com/castle/castle_devise
106
+ source_code_uri: https://github.com/castle/castle_devise
107
+ changelog_uri: https://github.com/castle/castle_devise/CHANGELOG.md
108
+ post_install_message:
109
+ rdoc_options: []
110
+ require_paths:
111
+ - lib
112
+ required_ruby_version: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: 2.5.0
117
+ required_rubygems_version: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - ">="
120
+ - !ruby/object:Gem::Version
121
+ version: '0'
122
+ requirements: []
123
+ rubygems_version: 3.0.3
124
+ signing_key:
125
+ specification_version: 4
126
+ summary: Integrates Castle with Devise
127
+ test_files: []