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.
Files changed (126) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +50 -0
  4. data/Rakefile +11 -0
  5. data/app/assets/builds/application.css +4764 -0
  6. data/app/assets/builds/application.js +8236 -0
  7. data/app/assets/builds/application.js.map +7 -0
  8. data/app/assets/builds/masks/application.css +1 -0
  9. data/app/assets/builds/masks/application.js +7122 -0
  10. data/app/assets/builds/masks/application.js.map +7 -0
  11. data/app/assets/images/masks.png +0 -0
  12. data/app/assets/javascripts/application.js +2 -0
  13. data/app/assets/javascripts/controllers/application.js +9 -0
  14. data/app/assets/javascripts/controllers/emails_controller.js +28 -0
  15. data/app/assets/javascripts/controllers/index.js +12 -0
  16. data/app/assets/javascripts/controllers/keys_controller.js +20 -0
  17. data/app/assets/javascripts/controllers/recover_controller.js +21 -0
  18. data/app/assets/javascripts/controllers/recover_password_controller.js +21 -0
  19. data/app/assets/javascripts/controllers/session_controller.js +94 -0
  20. data/app/assets/manifest.js +2 -0
  21. data/app/assets/masks_manifest.js +2 -0
  22. data/app/assets/stylesheets/application.css +26 -0
  23. data/app/controllers/concerns/masks/controller.rb +114 -0
  24. data/app/controllers/masks/actors_controller.rb +15 -0
  25. data/app/controllers/masks/application_controller.rb +35 -0
  26. data/app/controllers/masks/backup_codes_controller.rb +34 -0
  27. data/app/controllers/masks/debug_controller.rb +9 -0
  28. data/app/controllers/masks/devices_controller.rb +20 -0
  29. data/app/controllers/masks/emails_controller.rb +60 -0
  30. data/app/controllers/masks/error_controller.rb +14 -0
  31. data/app/controllers/masks/keys_controller.rb +45 -0
  32. data/app/controllers/masks/manage/actor_controller.rb +35 -0
  33. data/app/controllers/masks/manage/actors_controller.rb +12 -0
  34. data/app/controllers/masks/manage/base_controller.rb +12 -0
  35. data/app/controllers/masks/one_time_code_controller.rb +49 -0
  36. data/app/controllers/masks/passwords_controller.rb +33 -0
  37. data/app/controllers/masks/recoveries_controller.rb +43 -0
  38. data/app/controllers/masks/sessions_controller.rb +53 -0
  39. data/app/helpers/masks/application_helper.rb +49 -0
  40. data/app/jobs/masks/application_job.rb +7 -0
  41. data/app/jobs/masks/expire_actors_job.rb +15 -0
  42. data/app/jobs/masks/expire_recoveries_job.rb +15 -0
  43. data/app/mailers/masks/actor_mailer.rb +22 -0
  44. data/app/mailers/masks/application_mailer.rb +15 -0
  45. data/app/models/concerns/masks/access.rb +162 -0
  46. data/app/models/concerns/masks/actor.rb +132 -0
  47. data/app/models/concerns/masks/adapter.rb +68 -0
  48. data/app/models/concerns/masks/role.rb +9 -0
  49. data/app/models/concerns/masks/scoped.rb +54 -0
  50. data/app/models/masks/access/actor_password.rb +20 -0
  51. data/app/models/masks/access/actor_scopes.rb +18 -0
  52. data/app/models/masks/access/actor_signup.rb +22 -0
  53. data/app/models/masks/actors/anonymous.rb +40 -0
  54. data/app/models/masks/actors/system.rb +24 -0
  55. data/app/models/masks/adapters/active_record.rb +85 -0
  56. data/app/models/masks/application_model.rb +15 -0
  57. data/app/models/masks/application_record.rb +8 -0
  58. data/app/models/masks/check.rb +192 -0
  59. data/app/models/masks/credential.rb +166 -0
  60. data/app/models/masks/credentials/backup_code.rb +30 -0
  61. data/app/models/masks/credentials/device.rb +59 -0
  62. data/app/models/masks/credentials/email.rb +48 -0
  63. data/app/models/masks/credentials/factor2.rb +71 -0
  64. data/app/models/masks/credentials/key.rb +38 -0
  65. data/app/models/masks/credentials/last_login.rb +12 -0
  66. data/app/models/masks/credentials/masquerade.rb +32 -0
  67. data/app/models/masks/credentials/nickname.rb +63 -0
  68. data/app/models/masks/credentials/one_time_code.rb +34 -0
  69. data/app/models/masks/credentials/password.rb +28 -0
  70. data/app/models/masks/credentials/recovery.rb +71 -0
  71. data/app/models/masks/credentials/session.rb +67 -0
  72. data/app/models/masks/device.rb +30 -0
  73. data/app/models/masks/error.rb +51 -0
  74. data/app/models/masks/event.rb +14 -0
  75. data/app/models/masks/mask.rb +255 -0
  76. data/app/models/masks/rails/actor.rb +190 -0
  77. data/app/models/masks/rails/actor_role.rb +12 -0
  78. data/app/models/masks/rails/device.rb +47 -0
  79. data/app/models/masks/rails/email.rb +96 -0
  80. data/app/models/masks/rails/key.rb +61 -0
  81. data/app/models/masks/rails/recovery.rb +116 -0
  82. data/app/models/masks/rails/role.rb +20 -0
  83. data/app/models/masks/rails/scope.rb +15 -0
  84. data/app/models/masks/session.rb +447 -0
  85. data/app/models/masks/sessions/access.rb +26 -0
  86. data/app/models/masks/sessions/inline.rb +16 -0
  87. data/app/models/masks/sessions/request.rb +42 -0
  88. data/app/resources/masks/actor_resource.rb +9 -0
  89. data/app/resources/masks/session_resource.rb +15 -0
  90. data/app/views/layouts/masks/application.html.erb +17 -0
  91. data/app/views/layouts/masks/mailer.html.erb +17 -0
  92. data/app/views/layouts/masks/mailer.text.erb +1 -0
  93. data/app/views/layouts/masks/manage.html.erb +25 -0
  94. data/app/views/masks/actor_mailer/recover_credentials.html.erb +33 -0
  95. data/app/views/masks/actor_mailer/recover_credentials.text.erb +1 -0
  96. data/app/views/masks/actor_mailer/verify_email.html.erb +34 -0
  97. data/app/views/masks/actor_mailer/verify_email.text.erb +8 -0
  98. data/app/views/masks/actors/current.html.erb +152 -0
  99. data/app/views/masks/application/_header.html.erb +31 -0
  100. data/app/views/masks/backup_codes/new.html.erb +103 -0
  101. data/app/views/masks/emails/new.html.erb +103 -0
  102. data/app/views/masks/emails/verify.html.erb +51 -0
  103. data/app/views/masks/keys/new.html.erb +127 -0
  104. data/app/views/masks/manage/actor/show.html.erb +126 -0
  105. data/app/views/masks/manage/actors/index.html.erb +40 -0
  106. data/app/views/masks/one_time_code/new.html.erb +150 -0
  107. data/app/views/masks/passwords/edit.html.erb +58 -0
  108. data/app/views/masks/recoveries/new.html.erb +71 -0
  109. data/app/views/masks/recoveries/password.html.erb +64 -0
  110. data/app/views/masks/sessions/new.html.erb +153 -0
  111. data/config/brakeman.ignore +28 -0
  112. data/config/locales/en.yml +286 -0
  113. data/config/routes.rb +46 -0
  114. data/db/migrate/20231205173845_create_actors.rb +94 -0
  115. data/lib/generators/masks/install/USAGE +8 -0
  116. data/lib/generators/masks/install/install_generator.rb +33 -0
  117. data/lib/generators/masks/install/templates/initializer.rb +5 -0
  118. data/lib/generators/masks/install/templates/masks.json +6 -0
  119. data/lib/masks/configuration.rb +236 -0
  120. data/lib/masks/engine.rb +25 -0
  121. data/lib/masks/middleware.rb +70 -0
  122. data/lib/masks/version.rb +5 -0
  123. data/lib/masks.rb +183 -0
  124. data/lib/tasks/masks_tasks.rake +71 -0
  125. data/masks.json +274 -0
  126. 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