masks 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +50 -0
- data/Rakefile +11 -0
- data/app/assets/builds/application.css +4764 -0
- data/app/assets/builds/application.js +8236 -0
- data/app/assets/builds/application.js.map +7 -0
- data/app/assets/builds/masks/application.css +1 -0
- data/app/assets/builds/masks/application.js +7122 -0
- data/app/assets/builds/masks/application.js.map +7 -0
- data/app/assets/images/masks.png +0 -0
- data/app/assets/javascripts/application.js +2 -0
- data/app/assets/javascripts/controllers/application.js +9 -0
- data/app/assets/javascripts/controllers/emails_controller.js +28 -0
- data/app/assets/javascripts/controllers/index.js +12 -0
- data/app/assets/javascripts/controllers/keys_controller.js +20 -0
- data/app/assets/javascripts/controllers/recover_controller.js +21 -0
- data/app/assets/javascripts/controllers/recover_password_controller.js +21 -0
- data/app/assets/javascripts/controllers/session_controller.js +94 -0
- data/app/assets/manifest.js +2 -0
- data/app/assets/masks_manifest.js +2 -0
- data/app/assets/stylesheets/application.css +26 -0
- data/app/controllers/concerns/masks/controller.rb +114 -0
- data/app/controllers/masks/actors_controller.rb +15 -0
- data/app/controllers/masks/application_controller.rb +35 -0
- data/app/controllers/masks/backup_codes_controller.rb +34 -0
- data/app/controllers/masks/debug_controller.rb +9 -0
- data/app/controllers/masks/devices_controller.rb +20 -0
- data/app/controllers/masks/emails_controller.rb +60 -0
- data/app/controllers/masks/error_controller.rb +14 -0
- data/app/controllers/masks/keys_controller.rb +45 -0
- data/app/controllers/masks/manage/actor_controller.rb +35 -0
- data/app/controllers/masks/manage/actors_controller.rb +12 -0
- data/app/controllers/masks/manage/base_controller.rb +12 -0
- data/app/controllers/masks/one_time_code_controller.rb +49 -0
- data/app/controllers/masks/passwords_controller.rb +33 -0
- data/app/controllers/masks/recoveries_controller.rb +43 -0
- data/app/controllers/masks/sessions_controller.rb +53 -0
- data/app/helpers/masks/application_helper.rb +49 -0
- data/app/jobs/masks/application_job.rb +7 -0
- data/app/jobs/masks/expire_actors_job.rb +15 -0
- data/app/jobs/masks/expire_recoveries_job.rb +15 -0
- data/app/mailers/masks/actor_mailer.rb +22 -0
- data/app/mailers/masks/application_mailer.rb +15 -0
- data/app/models/concerns/masks/access.rb +162 -0
- data/app/models/concerns/masks/actor.rb +132 -0
- data/app/models/concerns/masks/adapter.rb +68 -0
- data/app/models/concerns/masks/role.rb +9 -0
- data/app/models/concerns/masks/scoped.rb +54 -0
- data/app/models/masks/access/actor_password.rb +20 -0
- data/app/models/masks/access/actor_scopes.rb +18 -0
- data/app/models/masks/access/actor_signup.rb +22 -0
- data/app/models/masks/actors/anonymous.rb +40 -0
- data/app/models/masks/actors/system.rb +24 -0
- data/app/models/masks/adapters/active_record.rb +85 -0
- data/app/models/masks/application_model.rb +15 -0
- data/app/models/masks/application_record.rb +8 -0
- data/app/models/masks/check.rb +192 -0
- data/app/models/masks/credential.rb +166 -0
- data/app/models/masks/credentials/backup_code.rb +30 -0
- data/app/models/masks/credentials/device.rb +59 -0
- data/app/models/masks/credentials/email.rb +48 -0
- data/app/models/masks/credentials/factor2.rb +71 -0
- data/app/models/masks/credentials/key.rb +38 -0
- data/app/models/masks/credentials/last_login.rb +12 -0
- data/app/models/masks/credentials/masquerade.rb +32 -0
- data/app/models/masks/credentials/nickname.rb +63 -0
- data/app/models/masks/credentials/one_time_code.rb +34 -0
- data/app/models/masks/credentials/password.rb +28 -0
- data/app/models/masks/credentials/recovery.rb +71 -0
- data/app/models/masks/credentials/session.rb +67 -0
- data/app/models/masks/device.rb +30 -0
- data/app/models/masks/error.rb +51 -0
- data/app/models/masks/event.rb +14 -0
- data/app/models/masks/mask.rb +255 -0
- data/app/models/masks/rails/actor.rb +190 -0
- data/app/models/masks/rails/actor_role.rb +12 -0
- data/app/models/masks/rails/device.rb +47 -0
- data/app/models/masks/rails/email.rb +96 -0
- data/app/models/masks/rails/key.rb +61 -0
- data/app/models/masks/rails/recovery.rb +116 -0
- data/app/models/masks/rails/role.rb +20 -0
- data/app/models/masks/rails/scope.rb +15 -0
- data/app/models/masks/session.rb +447 -0
- data/app/models/masks/sessions/access.rb +26 -0
- data/app/models/masks/sessions/inline.rb +16 -0
- data/app/models/masks/sessions/request.rb +42 -0
- data/app/resources/masks/actor_resource.rb +9 -0
- data/app/resources/masks/session_resource.rb +15 -0
- data/app/views/layouts/masks/application.html.erb +17 -0
- data/app/views/layouts/masks/mailer.html.erb +17 -0
- data/app/views/layouts/masks/mailer.text.erb +1 -0
- data/app/views/layouts/masks/manage.html.erb +25 -0
- data/app/views/masks/actor_mailer/recover_credentials.html.erb +33 -0
- data/app/views/masks/actor_mailer/recover_credentials.text.erb +1 -0
- data/app/views/masks/actor_mailer/verify_email.html.erb +34 -0
- data/app/views/masks/actor_mailer/verify_email.text.erb +8 -0
- data/app/views/masks/actors/current.html.erb +152 -0
- data/app/views/masks/application/_header.html.erb +31 -0
- data/app/views/masks/backup_codes/new.html.erb +103 -0
- data/app/views/masks/emails/new.html.erb +103 -0
- data/app/views/masks/emails/verify.html.erb +51 -0
- data/app/views/masks/keys/new.html.erb +127 -0
- data/app/views/masks/manage/actor/show.html.erb +126 -0
- data/app/views/masks/manage/actors/index.html.erb +40 -0
- data/app/views/masks/one_time_code/new.html.erb +150 -0
- data/app/views/masks/passwords/edit.html.erb +58 -0
- data/app/views/masks/recoveries/new.html.erb +71 -0
- data/app/views/masks/recoveries/password.html.erb +64 -0
- data/app/views/masks/sessions/new.html.erb +153 -0
- data/config/brakeman.ignore +28 -0
- data/config/locales/en.yml +286 -0
- data/config/routes.rb +46 -0
- data/db/migrate/20231205173845_create_actors.rb +94 -0
- data/lib/generators/masks/install/USAGE +8 -0
- data/lib/generators/masks/install/install_generator.rb +33 -0
- data/lib/generators/masks/install/templates/initializer.rb +5 -0
- data/lib/generators/masks/install/templates/masks.json +6 -0
- data/lib/masks/configuration.rb +236 -0
- data/lib/masks/engine.rb +25 -0
- data/lib/masks/middleware.rb +70 -0
- data/lib/masks/version.rb +5 -0
- data/lib/masks.rb +183 -0
- data/lib/tasks/masks_tasks.rake +71 -0
- data/masks.json +274 -0
- metadata +416 -0
|
@@ -0,0 +1,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
|