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