masks 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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