masks 0.2.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/MIT-LICENSE +20 -0
- data/README.md +50 -0
- data/Rakefile +11 -0
- data/app/assets/builds/application.css +4764 -0
- data/app/assets/builds/application.js +8236 -0
- data/app/assets/builds/application.js.map +7 -0
- data/app/assets/builds/masks/application.css +1 -0
- data/app/assets/builds/masks/application.js +7122 -0
- data/app/assets/builds/masks/application.js.map +7 -0
- data/app/assets/images/masks.png +0 -0
- data/app/assets/javascripts/application.js +2 -0
- data/app/assets/javascripts/controllers/application.js +9 -0
- data/app/assets/javascripts/controllers/emails_controller.js +28 -0
- data/app/assets/javascripts/controllers/index.js +12 -0
- data/app/assets/javascripts/controllers/keys_controller.js +20 -0
- data/app/assets/javascripts/controllers/recover_controller.js +21 -0
- data/app/assets/javascripts/controllers/recover_password_controller.js +21 -0
- data/app/assets/javascripts/controllers/session_controller.js +94 -0
- data/app/assets/manifest.js +2 -0
- data/app/assets/masks_manifest.js +2 -0
- data/app/assets/stylesheets/application.css +26 -0
- data/app/controllers/concerns/masks/controller.rb +114 -0
- data/app/controllers/masks/actors_controller.rb +15 -0
- data/app/controllers/masks/application_controller.rb +35 -0
- data/app/controllers/masks/backup_codes_controller.rb +34 -0
- data/app/controllers/masks/debug_controller.rb +9 -0
- data/app/controllers/masks/devices_controller.rb +20 -0
- data/app/controllers/masks/emails_controller.rb +60 -0
- data/app/controllers/masks/error_controller.rb +14 -0
- data/app/controllers/masks/keys_controller.rb +45 -0
- data/app/controllers/masks/manage/actor_controller.rb +35 -0
- data/app/controllers/masks/manage/actors_controller.rb +12 -0
- data/app/controllers/masks/manage/base_controller.rb +12 -0
- data/app/controllers/masks/one_time_code_controller.rb +49 -0
- data/app/controllers/masks/passwords_controller.rb +33 -0
- data/app/controllers/masks/recoveries_controller.rb +43 -0
- data/app/controllers/masks/sessions_controller.rb +53 -0
- data/app/helpers/masks/application_helper.rb +49 -0
- data/app/jobs/masks/application_job.rb +7 -0
- data/app/jobs/masks/expire_actors_job.rb +15 -0
- data/app/jobs/masks/expire_recoveries_job.rb +15 -0
- data/app/mailers/masks/actor_mailer.rb +22 -0
- data/app/mailers/masks/application_mailer.rb +15 -0
- data/app/models/concerns/masks/access.rb +162 -0
- data/app/models/concerns/masks/actor.rb +132 -0
- data/app/models/concerns/masks/adapter.rb +68 -0
- data/app/models/concerns/masks/role.rb +9 -0
- data/app/models/concerns/masks/scoped.rb +54 -0
- data/app/models/masks/access/actor_password.rb +20 -0
- data/app/models/masks/access/actor_scopes.rb +18 -0
- data/app/models/masks/access/actor_signup.rb +22 -0
- data/app/models/masks/actors/anonymous.rb +40 -0
- data/app/models/masks/actors/system.rb +24 -0
- data/app/models/masks/adapters/active_record.rb +85 -0
- data/app/models/masks/application_model.rb +15 -0
- data/app/models/masks/application_record.rb +8 -0
- data/app/models/masks/check.rb +192 -0
- data/app/models/masks/credential.rb +166 -0
- data/app/models/masks/credentials/backup_code.rb +30 -0
- data/app/models/masks/credentials/device.rb +59 -0
- data/app/models/masks/credentials/email.rb +48 -0
- data/app/models/masks/credentials/factor2.rb +71 -0
- data/app/models/masks/credentials/key.rb +38 -0
- data/app/models/masks/credentials/last_login.rb +12 -0
- data/app/models/masks/credentials/masquerade.rb +32 -0
- data/app/models/masks/credentials/nickname.rb +63 -0
- data/app/models/masks/credentials/one_time_code.rb +34 -0
- data/app/models/masks/credentials/password.rb +28 -0
- data/app/models/masks/credentials/recovery.rb +71 -0
- data/app/models/masks/credentials/session.rb +67 -0
- data/app/models/masks/device.rb +30 -0
- data/app/models/masks/error.rb +51 -0
- data/app/models/masks/event.rb +14 -0
- data/app/models/masks/mask.rb +255 -0
- data/app/models/masks/rails/actor.rb +190 -0
- data/app/models/masks/rails/actor_role.rb +12 -0
- data/app/models/masks/rails/device.rb +47 -0
- data/app/models/masks/rails/email.rb +96 -0
- data/app/models/masks/rails/key.rb +61 -0
- data/app/models/masks/rails/recovery.rb +116 -0
- data/app/models/masks/rails/role.rb +20 -0
- data/app/models/masks/rails/scope.rb +15 -0
- data/app/models/masks/session.rb +447 -0
- data/app/models/masks/sessions/access.rb +26 -0
- data/app/models/masks/sessions/inline.rb +16 -0
- data/app/models/masks/sessions/request.rb +42 -0
- data/app/resources/masks/actor_resource.rb +9 -0
- data/app/resources/masks/session_resource.rb +15 -0
- data/app/views/layouts/masks/application.html.erb +17 -0
- data/app/views/layouts/masks/mailer.html.erb +17 -0
- data/app/views/layouts/masks/mailer.text.erb +1 -0
- data/app/views/layouts/masks/manage.html.erb +25 -0
- data/app/views/masks/actor_mailer/recover_credentials.html.erb +33 -0
- data/app/views/masks/actor_mailer/recover_credentials.text.erb +1 -0
- data/app/views/masks/actor_mailer/verify_email.html.erb +34 -0
- data/app/views/masks/actor_mailer/verify_email.text.erb +8 -0
- data/app/views/masks/actors/current.html.erb +152 -0
- data/app/views/masks/application/_header.html.erb +31 -0
- data/app/views/masks/backup_codes/new.html.erb +103 -0
- data/app/views/masks/emails/new.html.erb +103 -0
- data/app/views/masks/emails/verify.html.erb +51 -0
- data/app/views/masks/keys/new.html.erb +127 -0
- data/app/views/masks/manage/actor/show.html.erb +126 -0
- data/app/views/masks/manage/actors/index.html.erb +40 -0
- data/app/views/masks/one_time_code/new.html.erb +150 -0
- data/app/views/masks/passwords/edit.html.erb +58 -0
- data/app/views/masks/recoveries/new.html.erb +71 -0
- data/app/views/masks/recoveries/password.html.erb +64 -0
- data/app/views/masks/sessions/new.html.erb +153 -0
- data/config/brakeman.ignore +28 -0
- data/config/locales/en.yml +286 -0
- data/config/routes.rb +46 -0
- data/db/migrate/20231205173845_create_actors.rb +94 -0
- data/lib/generators/masks/install/USAGE +8 -0
- data/lib/generators/masks/install/install_generator.rb +33 -0
- data/lib/generators/masks/install/templates/initializer.rb +5 -0
- data/lib/generators/masks/install/templates/masks.json +6 -0
- data/lib/masks/configuration.rb +236 -0
- data/lib/masks/engine.rb +25 -0
- data/lib/masks/middleware.rb +70 -0
- data/lib/masks/version.rb +5 -0
- data/lib/masks.rb +183 -0
- data/lib/tasks/masks_tasks.rake +71 -0
- data/masks.json +274 -0
- metadata +416 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Masks
|
|
4
|
+
# Represents an individual mask, its properties, and methods for interpreting them.
|
|
5
|
+
#
|
|
6
|
+
# When a session is created it finds the first matching mask to use for
|
|
7
|
+
# dictating how to control access. A Mask contains rules that allow sessions
|
|
8
|
+
# to match them.
|
|
9
|
+
#
|
|
10
|
+
# @see Masks::Check Masks::Check
|
|
11
|
+
# @see Masks::Credentials Masks::Credentials
|
|
12
|
+
class Mask < ApplicationModel
|
|
13
|
+
# @!attribute [rw] name
|
|
14
|
+
# a unique name for the mask
|
|
15
|
+
# @return [String]
|
|
16
|
+
attribute :name
|
|
17
|
+
# @!attribute [rw] skip
|
|
18
|
+
# Whether or not to skip processing by masks
|
|
19
|
+
# @return [Boolean]
|
|
20
|
+
attribute :skip, default: false
|
|
21
|
+
# @!attribute [rw] type
|
|
22
|
+
# A type name to inherit configuration from
|
|
23
|
+
# @return [String]
|
|
24
|
+
attribute :type
|
|
25
|
+
# @!attribute [rw] types
|
|
26
|
+
# A list of required type names
|
|
27
|
+
# @return [String]
|
|
28
|
+
attribute :types
|
|
29
|
+
# @!attribute [rw] checks
|
|
30
|
+
# A hash of checks required to pass the session
|
|
31
|
+
# @return [Hash]
|
|
32
|
+
attribute :checks
|
|
33
|
+
# @!attribute [rw] credentials
|
|
34
|
+
# An array of credentials that will check the session
|
|
35
|
+
# @return [Array<Class>]
|
|
36
|
+
attribute :credentials
|
|
37
|
+
# @!attribute [rw] scopes
|
|
38
|
+
# An array of scopes required to access the session
|
|
39
|
+
# @return [Array<String>]
|
|
40
|
+
attribute :scopes
|
|
41
|
+
# @!attribute [rw] request
|
|
42
|
+
# A hash of properties an HTTP request must match to use this mask
|
|
43
|
+
# @return [Hash]
|
|
44
|
+
attribute :request
|
|
45
|
+
# @!attribute [rw] access
|
|
46
|
+
# A list of access classes allowed during this session
|
|
47
|
+
# @return [Array<String>]
|
|
48
|
+
attribute :access
|
|
49
|
+
# @!attribute [rw] actor
|
|
50
|
+
# The expected class name of the actor using the sesssion, as a string
|
|
51
|
+
# @return [String]
|
|
52
|
+
attribute :actor
|
|
53
|
+
# @!attribute [rw] anon
|
|
54
|
+
# Whether or not to allow "anonymous" actors
|
|
55
|
+
# @return [Boolean]
|
|
56
|
+
attribute :anon
|
|
57
|
+
# @!attribute [rw] pass
|
|
58
|
+
# What to do when the session passes, typically a redirect uri
|
|
59
|
+
# @return [String]
|
|
60
|
+
attribute :pass, default: "/"
|
|
61
|
+
# @!attribute [rw] fail
|
|
62
|
+
# What to do when the session is failed by checks, credentials, or another error
|
|
63
|
+
# @return [String|Boolean]
|
|
64
|
+
attribute :fail, default: true
|
|
65
|
+
# @!attribute [rw] backup
|
|
66
|
+
# Whether or not to save results of masks
|
|
67
|
+
# @return [Boolean]
|
|
68
|
+
attribute :backup, default: true
|
|
69
|
+
|
|
70
|
+
# @visibility private
|
|
71
|
+
attribute :config
|
|
72
|
+
|
|
73
|
+
# @visibility private
|
|
74
|
+
def initialize(attrs = {})
|
|
75
|
+
if attrs[:type]
|
|
76
|
+
type = attrs[:config].mask(attrs[:type])
|
|
77
|
+
attrs = type.deep_merge(**attrs)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
super(attrs)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Returns the class name expected for any actor attached to this session.
|
|
84
|
+
#
|
|
85
|
+
# @return [String]
|
|
86
|
+
def actor
|
|
87
|
+
super || config.models[:actor]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Returns the constantized version of +#actor+.
|
|
91
|
+
#
|
|
92
|
+
# @return [String]
|
|
93
|
+
def actor_scope
|
|
94
|
+
(actor.constantize unless skip?)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Whether or not sessions matching this mask should skip all work.
|
|
98
|
+
#
|
|
99
|
+
# @return [Boolean]
|
|
100
|
+
def skip?
|
|
101
|
+
!!skip
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Whether or not anonymous actors are allowed in the session.
|
|
105
|
+
#
|
|
106
|
+
# @return [Boolean]
|
|
107
|
+
def allow_anonymous?
|
|
108
|
+
anon
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Whether or not sessions using this mask should be saved.
|
|
112
|
+
#
|
|
113
|
+
# Some masks definitely want this enabled, as it is what stores the results
|
|
114
|
+
# of masks, credentials, and checks in the rails session. In other cases, it
|
|
115
|
+
# is not necessary, for example when verifying an API key.
|
|
116
|
+
def backup?
|
|
117
|
+
return false if skip?
|
|
118
|
+
|
|
119
|
+
!!backup
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# A hash of check configuration, keyed by their name.
|
|
123
|
+
#
|
|
124
|
+
# @return [Hash]
|
|
125
|
+
def checks
|
|
126
|
+
return {} if skip?
|
|
127
|
+
|
|
128
|
+
(type_config&.fetch(:checks, {}) || {}).merge(super || {})
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Converts credentials to classes before returning the list.
|
|
132
|
+
#
|
|
133
|
+
# @return [Array<Class>]
|
|
134
|
+
# @raise Masks::Error::InvalidConfiguration
|
|
135
|
+
def credentials
|
|
136
|
+
return [] if skip?
|
|
137
|
+
|
|
138
|
+
merged = [
|
|
139
|
+
*(type_config&.fetch(:credentials, []) || []),
|
|
140
|
+
*(super || [])
|
|
141
|
+
].uniq
|
|
142
|
+
merged.map do |cls|
|
|
143
|
+
case cls
|
|
144
|
+
when Class
|
|
145
|
+
cls
|
|
146
|
+
when /:+/
|
|
147
|
+
cls.constantize
|
|
148
|
+
when String
|
|
149
|
+
"Masks::Credentials::#{cls}".constantize
|
|
150
|
+
else
|
|
151
|
+
raise Masks::Error::InvalidConfiguration, cls
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Returns whether or not the mask matches the passed session.
|
|
157
|
+
#
|
|
158
|
+
# The behaviour of this method depends on the mask's configuration. For
|
|
159
|
+
# example, a session with an anonymous actor will return true only if the
|
|
160
|
+
# mask's +#allow_anonymous?+ method returns true.
|
|
161
|
+
#
|
|
162
|
+
# @param [Masks::Session] session
|
|
163
|
+
# @return [Boolean]
|
|
164
|
+
def matches_session?(session)
|
|
165
|
+
actor = session.actor
|
|
166
|
+
|
|
167
|
+
return false unless actor
|
|
168
|
+
return true if actor.anonymous? && allow_anonymous?
|
|
169
|
+
|
|
170
|
+
case self.actor
|
|
171
|
+
when String
|
|
172
|
+
return false unless actor.is_a?(self.actor.constantize)
|
|
173
|
+
when Class
|
|
174
|
+
return false unless actor.is_a?(self.actor)
|
|
175
|
+
else
|
|
176
|
+
return false unless actor.is_a?(config.model(:actor))
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
matches_scopes?(session.scopes)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Returns whether or not the mask's request confiig matches the request passed.
|
|
183
|
+
#
|
|
184
|
+
# The following parameters are supported in the +request+ hash:
|
|
185
|
+
#
|
|
186
|
+
# - +path+ - if specified, the request path must be in this list.
|
|
187
|
+
# - +method+ - if specified, the request method must be in this list.
|
|
188
|
+
# - +param+ - if specified, the key must exist in the session params.
|
|
189
|
+
# - +header+ - if specified, the header must be present in the request.
|
|
190
|
+
#
|
|
191
|
+
# @return [Boolean]
|
|
192
|
+
def matches_request?(request)
|
|
193
|
+
return false unless self.request
|
|
194
|
+
return false unless matches_path?(request)
|
|
195
|
+
return false unless matches_method?(request)
|
|
196
|
+
|
|
197
|
+
param = self.request.fetch(:param, nil)
|
|
198
|
+
|
|
199
|
+
if param && param != "*" && !request.params&.fetch(param.to_sym, nil)
|
|
200
|
+
return false
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
header = self.request.fetch(:header, nil)
|
|
204
|
+
|
|
205
|
+
return false if header && !request.headers[header]
|
|
206
|
+
|
|
207
|
+
true
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
private
|
|
211
|
+
|
|
212
|
+
# Returns whether or not the mask's allowed scopes includes one of the scopes passed.
|
|
213
|
+
#
|
|
214
|
+
# @return [Boolean]
|
|
215
|
+
def matches_scopes?(scopes)
|
|
216
|
+
return true if self.scopes.blank?
|
|
217
|
+
|
|
218
|
+
# the mask scopes and scopes passed should have at least one match
|
|
219
|
+
# otherwise this will return false because the intersection is blank
|
|
220
|
+
(Array.wrap(self.scopes) & (scopes || [])).present?
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def matches_path?(request)
|
|
224
|
+
return true unless (paths = self.request.fetch(:path, nil))
|
|
225
|
+
|
|
226
|
+
Array
|
|
227
|
+
.wrap(paths)
|
|
228
|
+
.any? do |path|
|
|
229
|
+
case path
|
|
230
|
+
when /^r.+/
|
|
231
|
+
Regexp.new(path.slice(1..)).match?(request.url)
|
|
232
|
+
else
|
|
233
|
+
Fuzzyurl.matches?(Fuzzyurl.mask(path:), request.url)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def matches_method?(request)
|
|
239
|
+
return true unless (methods = self.request&.fetch(:method, nil))
|
|
240
|
+
return true if !methods || methods == "*"
|
|
241
|
+
|
|
242
|
+
Array
|
|
243
|
+
.wrap(methods)
|
|
244
|
+
.map(&:upcase)
|
|
245
|
+
.any? do |m|
|
|
246
|
+
m == request.method ||
|
|
247
|
+
(request.method == "POST" && request.params[:_method]&.upcase == m)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def type_config
|
|
252
|
+
@type_config ||= config.mask(type) if type
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Masks
|
|
4
|
+
module Rails
|
|
5
|
+
class Actor < ApplicationRecord
|
|
6
|
+
include Masks::Actor
|
|
7
|
+
|
|
8
|
+
self.table_name = "actors"
|
|
9
|
+
|
|
10
|
+
scope :expired,
|
|
11
|
+
lambda {
|
|
12
|
+
where(
|
|
13
|
+
"last_login_at < ?",
|
|
14
|
+
Masks.configuration.lifetimes[:expired_actor]&.ago ||
|
|
15
|
+
6.months.ago
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
has_many :saved_scopes,
|
|
20
|
+
class_name: Masks.configuration.models[:scope],
|
|
21
|
+
autosave: true
|
|
22
|
+
has_many :saved_roles,
|
|
23
|
+
class_name: Masks.configuration.models[:role],
|
|
24
|
+
autosave: true
|
|
25
|
+
has_many :recoveries,
|
|
26
|
+
class_name: Masks.configuration.models[:recovery],
|
|
27
|
+
autosave: true
|
|
28
|
+
has_many :emails, class_name: Masks.configuration.models[:email]
|
|
29
|
+
has_many :devices,
|
|
30
|
+
class_name: Masks.configuration.models[:device],
|
|
31
|
+
autosave: true
|
|
32
|
+
has_many :keys,
|
|
33
|
+
class_name: Masks.configuration.models[:key],
|
|
34
|
+
autosave: true
|
|
35
|
+
|
|
36
|
+
has_secure_password
|
|
37
|
+
|
|
38
|
+
attribute :signup
|
|
39
|
+
attribute :session
|
|
40
|
+
attribute :totp_code
|
|
41
|
+
|
|
42
|
+
before_validation :reset_version, unless: :version
|
|
43
|
+
|
|
44
|
+
validates :nickname, uniqueness: { case_sensitive: false }
|
|
45
|
+
validates :totp_secret, presence: true, if: :totp_code
|
|
46
|
+
validates :version, presence: true
|
|
47
|
+
validate :validates_totp, if: :totp_code
|
|
48
|
+
validate :validates_password, if: :password
|
|
49
|
+
validate :validates_signup
|
|
50
|
+
|
|
51
|
+
before_save :regenerate_backup_codes
|
|
52
|
+
|
|
53
|
+
serialize :backup_codes, coder: JSON
|
|
54
|
+
|
|
55
|
+
def email_addresses
|
|
56
|
+
emails.pluck(:email)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def factor2?
|
|
60
|
+
phone_number || totp_secret
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def remove_factor2!
|
|
64
|
+
self.added_totp_secret_at = nil
|
|
65
|
+
saved_backup_codes_at
|
|
66
|
+
self.totp_secret = nil
|
|
67
|
+
self.backup_codes = nil
|
|
68
|
+
save!
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def totp_uri
|
|
72
|
+
(totp || random_totp).provisioning_uri(nickname)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def totp_svg(**opts)
|
|
76
|
+
qrcode = RQRCode::QRCode.new(totp_uri)
|
|
77
|
+
qrcode.as_svg(**opts)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def totp
|
|
81
|
+
return unless totp_secret
|
|
82
|
+
|
|
83
|
+
ROTP::TOTP.new(totp_secret, issuer: Masks.configuration.issuer)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def random_totp
|
|
87
|
+
ROTP::TOTP.new(random_totp_secret, issuer: Masks.configuration.issuer)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def random_totp_secret
|
|
91
|
+
@random_totp_secret ||= ROTP::Base32.random
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def assign_scopes(*list)
|
|
95
|
+
list.map { |scope| saved_scopes.find_or_initialize_by(name: scope) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def assign_scopes!(*list)
|
|
99
|
+
list.map { |scope| saved_scopes.find_or_create_by(name: scope) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def remove_scopes!(*list)
|
|
103
|
+
saved_scopes.where(name: list).destroy_all
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def assign_role(record, **opts)
|
|
107
|
+
saved_roles
|
|
108
|
+
.find_by!(record:)
|
|
109
|
+
.tap { |role| role.assign_attributes(**opts) }
|
|
110
|
+
rescue ActiveRecord::RecordNotFound
|
|
111
|
+
saved_roles.build(record:, **opts)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def assign_role!(record, **opts)
|
|
115
|
+
assign_role(record, **opts).save!
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def remove_role!(record)
|
|
119
|
+
saved_roles.where(record:).destroy_all
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def scopes
|
|
123
|
+
@scopes ||= saved_scopes.order(created_at: :desc).pluck(:name)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def roles(record, **opts)
|
|
127
|
+
case record
|
|
128
|
+
when Class, String
|
|
129
|
+
saved_roles.where(record_type: record.to_s, **opts).includes(:record)
|
|
130
|
+
else
|
|
131
|
+
saved_roles.where(record:, **opts)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def mask!
|
|
136
|
+
save # sub-classes are encouraged to override
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def should_save_backup_codes?
|
|
140
|
+
factor2? && saved_backup_codes_at.blank?
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def saved_backup_codes?
|
|
144
|
+
factor2? && saved_backup_codes_at.present?
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def changed_during_mask?
|
|
148
|
+
changed? && changes.keys != ["session"]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def validates_totp
|
|
154
|
+
errors.add(:totp_code, :invalid) unless totp.verify(totp_code)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def validates_password
|
|
158
|
+
opts = Masks.configuration.dat.password&.length&.to_h
|
|
159
|
+
|
|
160
|
+
return unless password && opts
|
|
161
|
+
|
|
162
|
+
if opts[:min] && password.length < opts[:min]
|
|
163
|
+
errors.add(:password, :too_short, count: opts[:min])
|
|
164
|
+
elsif opts[:max] && password.length > opts[:max]
|
|
165
|
+
errors.add(key, :too_long, count: opts[:max])
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def validates_signup
|
|
170
|
+
return unless !persisted? && !Masks.configuration.signups? && signup
|
|
171
|
+
|
|
172
|
+
errors.add(:signup, :disabled)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def reset_version
|
|
176
|
+
self.version = SecureRandom.hex
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def regenerate_backup_codes
|
|
180
|
+
if factor2?
|
|
181
|
+
self.backup_codes ||=
|
|
182
|
+
(1..12).to_h { |_i| [SecureRandom.base58(10), true] }
|
|
183
|
+
else
|
|
184
|
+
self.backup_codes = nil
|
|
185
|
+
self.saved_backup_codes_at = nil
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Masks
|
|
4
|
+
module Rails
|
|
5
|
+
class ActorRole < ApplicationRecord
|
|
6
|
+
self.table_name = "actor_roles"
|
|
7
|
+
|
|
8
|
+
belongs_to :actor, class_name: Masks.configuration.models[:actor]
|
|
9
|
+
belongs_to :role, class_name: Masks.configuration.models[:role]
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Masks
|
|
4
|
+
module Rails
|
|
5
|
+
class Device < ApplicationRecord
|
|
6
|
+
self.table_name = "devices"
|
|
7
|
+
|
|
8
|
+
attribute :session
|
|
9
|
+
|
|
10
|
+
belongs_to :actor, class_name: Masks.configuration.models[:actor]
|
|
11
|
+
|
|
12
|
+
validates :key, presence: true, uniqueness: { scope: :actor_id }
|
|
13
|
+
validates :known?, presence: true
|
|
14
|
+
|
|
15
|
+
after_initialize :reset_version, unless: :version
|
|
16
|
+
before_validation :copy_session, if: :session
|
|
17
|
+
|
|
18
|
+
def known?
|
|
19
|
+
session.device&.known?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def session_key
|
|
23
|
+
Digest::SHA512.hexdigest([key, version].join("-"))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def reset_version
|
|
27
|
+
self.version = SecureRandom.hex
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
delegate :name, :device_type, :device_name, :os_name, to: :detected
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def detected
|
|
35
|
+
@detected ||= DeviceDetector.new(user_agent || session&.user_agent)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def copy_session
|
|
39
|
+
return unless known?
|
|
40
|
+
|
|
41
|
+
self.user_agent ||= session.user_agent
|
|
42
|
+
self.ip_address ||= session.ip_address
|
|
43
|
+
self.fingerprint ||= session.fingerprint
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Masks
|
|
4
|
+
module Rails
|
|
5
|
+
class Email < ApplicationRecord
|
|
6
|
+
self.table_name = "emails"
|
|
7
|
+
|
|
8
|
+
belongs_to :actor, class_name: Masks.configuration.models[:actor]
|
|
9
|
+
|
|
10
|
+
validates :token, presence: true, uniqueness: true
|
|
11
|
+
validates :email, presence: true, email: true
|
|
12
|
+
validates :expired?, absence: true
|
|
13
|
+
|
|
14
|
+
validate :validates_verified_email
|
|
15
|
+
|
|
16
|
+
after_initialize :generate_token
|
|
17
|
+
before_validation :downcase_email
|
|
18
|
+
|
|
19
|
+
def notify!(session)
|
|
20
|
+
return if verified?
|
|
21
|
+
|
|
22
|
+
if expired? && !verified?
|
|
23
|
+
self.notified_at = nil
|
|
24
|
+
self.token = nil
|
|
25
|
+
|
|
26
|
+
generate_token
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
return if notified?
|
|
30
|
+
|
|
31
|
+
self.notified_at = Time.current
|
|
32
|
+
|
|
33
|
+
return unless valid?
|
|
34
|
+
|
|
35
|
+
save
|
|
36
|
+
ActorMailer.with(session:, email: self).verify_email.deliver_now
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def verify!
|
|
40
|
+
return if verified?
|
|
41
|
+
|
|
42
|
+
self.verified_at = Time.current
|
|
43
|
+
self.verified = true
|
|
44
|
+
save!
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_param
|
|
48
|
+
token
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def notified?
|
|
52
|
+
notified_at.present?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def expired?
|
|
56
|
+
return false unless notified?
|
|
57
|
+
|
|
58
|
+
notified_at <=
|
|
59
|
+
(Masks.configuration.lifetimes[:verification_email] || 1.hour).ago
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def taken?
|
|
63
|
+
self.class.where(email:, verified: true).any?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def validates_verified_email
|
|
69
|
+
# not sure how to check this with uniqueness conditions,
|
|
70
|
+
# so using a custom validation
|
|
71
|
+
query =
|
|
72
|
+
self.class.where(
|
|
73
|
+
[
|
|
74
|
+
persisted? ? "id != :id AND (" : "(",
|
|
75
|
+
"(email = :email AND verified) OR",
|
|
76
|
+
"(actor_id = :actor_id AND email = :email)",
|
|
77
|
+
")"
|
|
78
|
+
].compact.join(" "),
|
|
79
|
+
actor_id: actor.id,
|
|
80
|
+
email: email.downcase,
|
|
81
|
+
id:
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
errors.add(:email, :taken) if query.any?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def generate_token
|
|
88
|
+
self.token ||= SecureRandom.hex
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def downcase_email
|
|
92
|
+
self.email = email&.downcase
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Masks
|
|
4
|
+
module Rails
|
|
5
|
+
class Key < ApplicationRecord
|
|
6
|
+
include Masks::Scoped
|
|
7
|
+
|
|
8
|
+
self.table_name = "keys"
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def sha(secret)
|
|
12
|
+
Digest::SHA512.hexdigest(secret)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
scope :latest, -> { order(created_at: :desc) }
|
|
17
|
+
|
|
18
|
+
attribute :session
|
|
19
|
+
attribute :secret
|
|
20
|
+
|
|
21
|
+
serialize :scopes, coder: JSON
|
|
22
|
+
|
|
23
|
+
belongs_to :actor, class_name: Masks.configuration.models[:actor]
|
|
24
|
+
|
|
25
|
+
after_initialize :generate_hash
|
|
26
|
+
|
|
27
|
+
validates :name, presence: true, length: { maximum: 32 }
|
|
28
|
+
validates :sha, presence: true, uniqueness: true
|
|
29
|
+
|
|
30
|
+
def nickname
|
|
31
|
+
[name.parameterize, sha.slice(0...32)].join("-")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
alias slug nickname
|
|
35
|
+
|
|
36
|
+
def scopes
|
|
37
|
+
value = self[:scopes]
|
|
38
|
+
|
|
39
|
+
return [] unless value
|
|
40
|
+
|
|
41
|
+
value & (actor&.scopes || [])
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def roles_for(_record, **_opts)
|
|
45
|
+
[]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def generate_secret
|
|
51
|
+
self.secret ||= SecureRandom.uuid
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def generate_hash
|
|
55
|
+
self.secret ||= SecureRandom.uuid
|
|
56
|
+
|
|
57
|
+
self.sha = self.class.sha(secret) if self.secret
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|