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,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ # A smaller interface that all scoped actors should adhere to.
5
+ #
6
+ # @see Masks::Rails::Actor Masks::Rails::Actor
7
+ # @see Masks::Actor Masks::Actor
8
+ module Scoped
9
+ # Returns a list of scopes granted to the actor.
10
+ #
11
+ # @return [Array<String>] An array of scopes (as strings)
12
+ def scopes
13
+ raise NotImplementedError
14
+ end
15
+
16
+ # Returns whether or not a scope is available.
17
+ #
18
+ # In practice this is similar to calling +scopes.include?(scope)+,
19
+ # but implementations may provide faster implementations.
20
+ #
21
+ # @param [String] scope
22
+ # @return [Boolean]
23
+ def scope?(scope)
24
+ scopes.include?(scope.to_s)
25
+ end
26
+
27
+ # Returns a list of Masks::Role records for the scoped actor.
28
+ #
29
+ # @param [String|Object] record or type
30
+ # @param [Hash] opts to use for additional filtering
31
+ # @return [Masks::Role]
32
+ def roles(record, **opts)
33
+ raise NotImplementedError
34
+ end
35
+
36
+ # Returns whether or not a role is available to the scoped actor.
37
+ #
38
+ # @param [String|Object] record or type
39
+ # @param [Hash] opts to use for additional filtering
40
+ # @return [Boolean]
41
+ def role?(record, **opts)
42
+ roles(record, **opts).any?
43
+ end
44
+
45
+ # Similar to roles_for, except all _records_ are returned instead of the role.
46
+ #
47
+ # @param [String|Object] record_type record or type
48
+ # @param [Hash] opts to use for additional filtering
49
+ # @return [Object] a list of records, duplicates removed
50
+ def role_records(record_type, **opts)
51
+ roles(record_type, **opts).map(&:record).uniq
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Access
5
+ # Access class for +actor.password+
6
+ #
7
+ # This access class can change that actor's password.
8
+ class ActorPassword
9
+ include Access
10
+
11
+ access "actor.password"
12
+
13
+ def change_password(password)
14
+ actor.changed_password_at = Time.current
15
+ actor.password = password
16
+ actor.save if actor.valid?
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Access
5
+ # Access class for +actor.scopes+
6
+ #
7
+ # This access class can add or remove scopes from an actor.
8
+ class ActorScopes
9
+ include Access
10
+
11
+ access "actor.scopes"
12
+
13
+ def assign_scopes(scopes)
14
+ actor.assign_scopes!(*scopes)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Access
5
+ # Access class for +actor.signup+.
6
+ #
7
+ # This access class creates a new actor.
8
+ class ActorSignup
9
+ include Access
10
+
11
+ access "actor.signup"
12
+
13
+ def signup(**opts)
14
+ actor =
15
+ configuration.build_actor(session, **opts.slice(:nickname, :email))
16
+ actor.password = opts[:password]
17
+ actor.save if actor.valid?
18
+ actor
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Actors
5
+ # An anonymous actor, used for cases where deemed acceptable.
6
+ #
7
+ # @see Masks::Actor
8
+ class Anonymous < ApplicationModel
9
+ include Masks::Actor
10
+
11
+ attribute :session
12
+
13
+ # Generates and returns random nickname for the actor.
14
+ #
15
+ # @return [String]
16
+ def nickname
17
+ @nickname ||= "anon:#{SecureRandom.hex}"
18
+ end
19
+
20
+ # @return [Array] an empty array, since no scopes are available to anonymous actors
21
+ def scopes
22
+ []
23
+ end
24
+
25
+ # This is a no-op for anonymous actors. It always returns true.
26
+ #
27
+ # @return [Boolean]
28
+ def mask!
29
+ true
30
+ end
31
+
32
+ # Mark this actor as anonymous.
33
+ #
34
+ # @return [Boolean]
35
+ def anonymous?
36
+ true
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Actors
5
+ # Actor for system tasks.
6
+ class System < ApplicationModel
7
+ include Masks::Actor
8
+
9
+ attribute :session
10
+
11
+ def nickname
12
+ @nickname ||= "system:#{SecureRandom.hex}"
13
+ end
14
+
15
+ def scopes
16
+ []
17
+ end
18
+
19
+ def mask!
20
+ true
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Adapters
5
+ # ActiveRecord adapter for masks.
6
+ #
7
+ # Although this is designed for masks built-in models, any models
8
+ # adhering to the same interface can be used. It is also possible to
9
+ # override and extend the models used in the configuration.
10
+ #
11
+ # @see Masks::Adapter
12
+ class ActiveRecord
13
+ include Masks::Adapter
14
+
15
+ def find_key(_session, secret:)
16
+ Masks::Rails::Key.find_by(sha: Masks::Rails::Key.sha(secret))
17
+ end
18
+
19
+ def find_device(session, actor: nil, key: nil)
20
+ device = session.device
21
+ actor ||= session.actor
22
+
23
+ return unless device.known? && actor
24
+
25
+ key ||=
26
+ Digest::SHA512.hexdigest(
27
+ [
28
+ device.name,
29
+ device.os_name,
30
+ device.device_name,
31
+ device.device_type
32
+ ].compact.join("-")
33
+ )
34
+
35
+ record = Masks::Rails::Device.find_or_initialize_by(actor:, key:)
36
+ record.session = session
37
+ record
38
+ end
39
+
40
+ def find_actor(session, **opts)
41
+ if opts[:email]
42
+ session
43
+ .mask
44
+ .actor_scope
45
+ .includes(:emails)
46
+ .find_by(emails: { email: opts[:email]&.downcase, verified: true })
47
+ elsif opts[:nickname]
48
+ session.mask.actor_scope.find_by(nickname: opts[:nickname])
49
+ end
50
+ end
51
+
52
+ def find_actors(session, ids)
53
+ session.mask.actor_scope.where(nickname: ids).to_a
54
+ end
55
+
56
+ def build_actor(session, **opts)
57
+ opts[:session] = session
58
+ record =
59
+ session.mask.actor_scope.new(session:, nickname: opts[:nickname])
60
+ record.emails.build(email: opts[:email]) if opts[:email]
61
+ record
62
+ end
63
+
64
+ def expire_actors
65
+ @config.model(:actor).expired.destroy_all
66
+ end
67
+
68
+ def expire_recoveries
69
+ @config.model(:recovery).expired.destroy_all
70
+ end
71
+
72
+ def find_recovery(_session, **opts)
73
+ if opts[:token]
74
+ @config.model(:recovery).recent.find_by(token: opts[:token])
75
+ elsif opts[:id]
76
+ @config.model(:recovery).recent.find_by(id: opts[:id])
77
+ end
78
+ end
79
+
80
+ def build_recovery(session, **opts)
81
+ @config.model(:recovery).new(configuration: @config, session:, **opts)
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ # Base model for synthetic +ActiveRecord+-style models in masks.
5
+ #
6
+ # Most models in masks use this in their inheritance tree, as it
7
+ # provides attributes, validations, and other features from
8
+ # +ActiveModel+.
9
+ class ApplicationModel
10
+ include ActiveModel::Model
11
+ include ActiveModel::Validations
12
+ include ActiveModel::Attributes
13
+ include ActiveModel::Serializers::JSON
14
+ end
15
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ # Base model for +ActiveRecord+-backed database tables in masks.
5
+ class ApplicationRecord < ActiveRecord::Base
6
+ self.abstract_class = true
7
+ end
8
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ # Checks track attempts to verify one attribute of a session, like the actor
5
+ # or their password.
6
+ #
7
+ # Every session contains a list of checks that can be manipulated while it is
8
+ # masked. Credentials associated with the session are typical consumers of
9
+ # checks, but direct manipulation is possible as well.
10
+ #
11
+ # Once a check's consumers have reported their status (approved, denied, or
12
+ # skipped) it will report an overall status based on the results, either:
13
+ #
14
+ # - +passed?+ - true if attempts were made and all were approved, not skipped, or the check is optional
15
+ # - +failed?+ - true if attempts were made and any were denied
16
+ #
17
+ # **Note**: Checks can exist in a middle state, neither passed or failed, in
18
+ # the case that no attempts were made.
19
+ #
20
+ # @see Masks::Credential Masks::Credential
21
+ class Check < ApplicationModel
22
+ attribute :key
23
+ attribute :lifetime
24
+ attribute :optional, default: false
25
+ attribute :attempted, default: -> { {} }
26
+ attribute :approved
27
+ attribute :skipped
28
+ attribute :denied
29
+
30
+ # Returns a hash of attempts for the check.
31
+ #
32
+ # Each key in the hash is the name of a specific attemptee, like a class or
33
+ # credential. The value is a hash of data about the attempt (like when it was
34
+ # attempted, approved, denied, and/or skipped).
35
+ #
36
+ # @return [Hash]
37
+ def attempts
38
+ attempted.deep_merge(@attempts || {}).deep_stringify_keys
39
+ end
40
+
41
+ # Whether or not the check is optional.
42
+ #
43
+ # Optional checks always return +true+ for +passed?+.
44
+ #
45
+ # @return Boolean
46
+ def optional?
47
+ optional
48
+ end
49
+
50
+ # Whether or not the check passed.
51
+ #
52
+ # +true+ if attempts were made and all were approved, not skipped, or the check is optional
53
+ #
54
+ # @return Boolean
55
+ def passed?
56
+ return true if optional? && !failed?
57
+ return false if attempts.keys.empty?
58
+
59
+ attempts.all? do |id, _opts|
60
+ attempt_approved?(id) || attempt_skipped?(id)
61
+ end
62
+ end
63
+
64
+ # Returns true if a specific attempt was approved.
65
+ # @param [String] id
66
+ # @return Boolean
67
+ def attempt_approved?(id)
68
+ opts = attempts.fetch(id.to_s, {})
69
+
70
+ return approved unless lifetime
71
+ return false if opts["skipped_at"]
72
+
73
+ time =
74
+ case opts["approved_at"]
75
+ when nil
76
+ return false
77
+ when String
78
+ Time.try(:parse, opts["approved_at"])
79
+ else
80
+ time
81
+ end
82
+
83
+ if time
84
+ time + ActiveSupport::Duration.parse(lifetime) > Time.current
85
+ else
86
+ false
87
+ end
88
+ end
89
+
90
+ # Returns true if a specific attempt was skipped.
91
+ # @param [String] id
92
+ # @return Boolean
93
+ def attempt_skipped?(id)
94
+ opts = attempts.fetch(id.to_s, {})
95
+ opts["skipped_at"] && optional
96
+ end
97
+
98
+ # Approves an attempt.
99
+ #
100
+ # Additional metadata can be passed as keyword arguments, and it will be
101
+ # saved alongside the attempt data.
102
+ #
103
+ # @param [String] id
104
+ # @param [Hash] opts
105
+ # @return Boolean
106
+ def approve!(id, **opts)
107
+ self.approved = true
108
+
109
+ merge_attempt(
110
+ id,
111
+ opts.merge(approved_at: Time.current.iso8601, skipped_at: nil)
112
+ )
113
+ end
114
+
115
+ # Skips an attempt. Skips count as approvals.
116
+ #
117
+ # Additional metadata can be passed as keyword arguments, and it will be
118
+ # saved alongside the attempt data.
119
+ #
120
+ # @param [String] id
121
+ # @param [Hash] opts
122
+ # @return Boolean
123
+ def skip!(id, **opts)
124
+ self.skipped = true
125
+
126
+ merge_attempt(
127
+ id,
128
+ opts.merge(approved_at: nil, skipped_at: Time.current.iso8601)
129
+ )
130
+ end
131
+
132
+ # Denies an attempt.
133
+ #
134
+ # Additional metadata can be passed as keyword arguments, and it will be
135
+ # saved alongside the attempt data.
136
+ #
137
+ # @param [String] id
138
+ # @param [Hash] opts
139
+ # @return Boolean
140
+ def deny!(id, **opts)
141
+ self.denied = true
142
+
143
+ merge_attempt(id, opts.merge(approved_at: nil, skipped_at: nil))
144
+ end
145
+
146
+ # Returns the time the check passed, if it did.
147
+ # @return [Datetime]
148
+ def passed_at
149
+ return unless passed?
150
+
151
+ attempts
152
+ .map do |_id, opts|
153
+ time = opts["approved_at"] || opts["skipped_at"]
154
+ Time.try(:parse, time) if time
155
+ end
156
+ .compact
157
+ .max
158
+ end
159
+
160
+ # Clears all data for attempts by the given +id+.
161
+ # @param [String] id
162
+ # @return [Datetime]
163
+ def clear!(id)
164
+ @attempts&.except!(id)
165
+ attempted.except!(id)
166
+ end
167
+
168
+ # Returns a version of the check intended for the rails session.
169
+ # @return [Hash]
170
+ def to_session
171
+ return { optional:, attempted: } unless lifetime
172
+
173
+ { optional:, attempted: attempts }
174
+ end
175
+
176
+ private
177
+
178
+ def failed?
179
+ return false if attempts.keys.empty?
180
+
181
+ attempts.any? do |id, _opts|
182
+ !attempt_approved?(id) && !attempt_skipped?(id)
183
+ end
184
+ end
185
+
186
+ def merge_attempt(id, data)
187
+ @attempts ||= {}
188
+ @attempts[id] ||= {}
189
+ @attempts[id].deep_merge!(data.deep_stringify_keys)
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ # A base class for credentials, which identify actors and check their access.
5
+ #
6
+ # When a session is masked, a set of credentials are given the chance to
7
+ # inspect the session parameters, propose an actor, and approve or deny
8
+ # their access.
9
+ #
10
+ # There are a few lifecycle methods available to credentials:
11
+ #
12
+ # - +lookup+ - should return an identified actor if found
13
+ # - +maskup+ - validates the session, actor, and any other data
14
+ # - +backup+ - records the status of the credential's check(s), if necessary
15
+ # - +cleanup+ - deletes any recorded data for the credential
16
+ #
17
+ # Sessions expect credentials to use checks to record their results, so there
18
+ # are helper methods to approve, deny, or skip associated checks—+approve!+,
19
+ # +deny!+, and +skip!+ respectively.
20
+ #
21
+ # @see Masks::Check Masks::Check
22
+ # @see Masks::Credentials Masks::Credentials
23
+ class Credential < ApplicationModel
24
+ class << self
25
+ def checks(value = nil)
26
+ @checks ||= {}
27
+ @checks[self.class.name] = value.to_s if value
28
+ @checks[self.class.name]
29
+ end
30
+ end
31
+
32
+ attribute :session
33
+ attribute :masked_at
34
+ attribute :passed_at
35
+
36
+ delegate :config,
37
+ :actor,
38
+ :session_params,
39
+ :account_params,
40
+ :params,
41
+ :writable?,
42
+ to: :session
43
+
44
+ # return an actor if it's found and valid
45
+ def lookup
46
+ nil
47
+ end
48
+
49
+ def mask!
50
+ before_mask
51
+
52
+ # existing checks (found from the session) can be
53
+ # skipped when already present and not expired
54
+ return if check&.passed? && check.attempts[slug] && valid?
55
+
56
+ self.masked_at = Time.current
57
+
58
+ maskup
59
+ end
60
+
61
+ # verify the session and actor
62
+ def maskup
63
+ nil
64
+ end
65
+
66
+ def backup!
67
+ self.passed_at = Time.current if check&.passed? &&
68
+ check&.attempt_approved?(slug)
69
+
70
+ backup
71
+ end
72
+
73
+ # write any data after all credentials/checks have run
74
+ def backup
75
+ nil # but overridable
76
+ end
77
+
78
+ # cleanup data re: the mask
79
+ def cleanup
80
+ nil # but overridable
81
+ end
82
+
83
+ def cleanup!
84
+ cleanup
85
+ reset!
86
+ end
87
+
88
+ delegate :optional?,
89
+ :passed?,
90
+ :skipped?,
91
+ :invalidated?,
92
+ to: :check,
93
+ allow_nil: true
94
+
95
+ def slug
96
+ self.class.name.split("::").join("_").underscore
97
+ end
98
+
99
+ def name
100
+ I18n.t("auth.credentials.#{slug}.name")
101
+ end
102
+
103
+ def check
104
+ session&.find_check(self.class.checks)
105
+ end
106
+
107
+ def patch_params
108
+ session&.account_params&.fetch(slug, {})
109
+ end
110
+
111
+ private
112
+
113
+ def before_mask
114
+ nil
115
+ end
116
+
117
+ def approve!(**opts)
118
+ check&.approve!(slug, **opts)
119
+ end
120
+
121
+ def deny!(**opts)
122
+ check&.deny!(slug, **opts)
123
+ end
124
+
125
+ def skip!(**opts)
126
+ check&.skip!(slug, **opts)
127
+ end
128
+
129
+ def reset!
130
+ check&.clear!(slug)
131
+ end
132
+
133
+ def nickname_config
134
+ session.config.dat.nickname
135
+ end
136
+
137
+ def nickname_format
138
+ return unless nickname_config.format
139
+
140
+ Regexp.new(nickname_config.format)
141
+ end
142
+
143
+ def prefix_nickname(value, default: nil)
144
+ prefix = nickname_config&.prefix
145
+
146
+ return default unless value.present?
147
+
148
+ prefixed = value
149
+ prefixed = "#{prefix}#{value}" if prefix && !value.start_with?(prefix)
150
+
151
+ return default if nickname_format && !nickname_format.match?(prefixed)
152
+
153
+ prefixed
154
+ end
155
+
156
+ def validates_length(key, opts)
157
+ return unless opts
158
+
159
+ if opts[:min] && send(key).length < opts[:min]
160
+ errors.add(key, :too_short, count: opts[:min])
161
+ elsif opts[:max] && send(key).length > opts[:max]
162
+ errors.add(key, :too_long, count: opts[:max])
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Credentials
5
+ # Checks :factor2 for a valid backup code.
6
+ class BackupCode < Masks::Credential
7
+ include Factor2
8
+
9
+ private
10
+
11
+ def param
12
+ :backup_code
13
+ end
14
+
15
+ def secret
16
+ actor&.backup_codes if actor&.saved_backup_codes?
17
+ end
18
+
19
+ def verify(code)
20
+ code if secret&.fetch(code, false)
21
+ end
22
+
23
+ def backup
24
+ return unless verified?
25
+
26
+ actor.update_attribute("backup_codes", secret.merge(code => false))
27
+ end
28
+ end
29
+ end
30
+ end