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.
@@ -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: []