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