add_auth 0.2.1
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/CHANGELOG.md +125 -0
- data/CODE_OF_CONDUCT.md +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +545 -0
- data/ROADMAP.md +244 -0
- data/SECURITY.md +75 -0
- data/app/controllers/add_auth/assets_controller.rb +54 -0
- data/app/controllers/add_auth/passkeys_controller.rb +144 -0
- data/app/controllers/add_auth/reauthentications_controller.rb +81 -0
- data/app/controllers/add_auth/recoveries_controller.rb +50 -0
- data/app/controllers/add_auth/sessions_controller.rb +62 -0
- data/app/controllers/add_auth/sign_ins_controller.rb +65 -0
- data/app/helpers/add_auth/sign_ins_helper.rb +42 -0
- data/app/javascript/controllers/.keep +0 -0
- data/app/jobs/add_auth/delivery_job.rb +31 -0
- data/app/jobs/add_auth/email_delivery_job.rb +15 -0
- data/app/jobs/add_auth/email_request_job.rb +21 -0
- data/app/jobs/add_auth/security_notification_job.rb +13 -0
- data/app/mailers/add_auth/security_mailer.rb +16 -0
- data/app/mailers/add_auth/sign_in_mailer.rb +36 -0
- data/app/models/concerns/.keep +0 -0
- data/app/views/.keep +0 -0
- data/app/views/add_auth/passkeys/_controls.html.erb +17 -0
- data/app/views/add_auth/passkeys/_list.html.erb +32 -0
- data/app/views/add_auth/reauthentications/_check_email.html.erb +3 -0
- data/app/views/add_auth/reauthentications/_confirmation.html.erb +7 -0
- data/app/views/add_auth/reauthentications/_different_browser.html.erb +3 -0
- data/app/views/add_auth/reauthentications/_form.html.erb +27 -0
- data/app/views/add_auth/reauthentications/_invalid_link.html.erb +3 -0
- data/app/views/add_auth/recoveries/_check_email.html.erb +3 -0
- data/app/views/add_auth/recoveries/_confirmation.html.erb +9 -0
- data/app/views/add_auth/recoveries/_form.html.erb +11 -0
- data/app/views/add_auth/recoveries/_invalid_link.html.erb +3 -0
- data/app/views/add_auth/security_mailer/notice.text.erb +3 -0
- data/app/views/add_auth/sessions/_list.html.erb +25 -0
- data/app/views/add_auth/sessions/_revoke_all.html.erb +18 -0
- data/app/views/add_auth/sessions/index.html.erb +12 -0
- data/app/views/add_auth/sign_in_mailer/link.text.erb +5 -0
- data/app/views/add_auth/sign_ins/_check_email.html.erb +5 -0
- data/app/views/add_auth/sign_ins/_confirmation.html.erb +9 -0
- data/app/views/add_auth/sign_ins/_different_browser.html.erb +3 -0
- data/app/views/add_auth/sign_ins/_form.html.erb +34 -0
- data/app/views/add_auth/sign_ins/_invalid_link.html.erb +3 -0
- data/app/views/add_auth/sign_ins/show.html.erb +1 -0
- data/app/views/layouts/add_auth/authentication.html.erb +19 -0
- data/lib/add_auth/configuration.rb +61 -0
- data/lib/add_auth/core/access_policy.rb +55 -0
- data/lib/add_auth/core/browser_binding.rb +28 -0
- data/lib/add_auth/core/challenge/base.rb +100 -0
- data/lib/add_auth/core/challenge/http.rb +130 -0
- data/lib/add_auth/core/challenge/null.rb +21 -0
- data/lib/add_auth/core/challenge/recaptcha.rb +76 -0
- data/lib/add_auth/core/challenge/test.rb +32 -0
- data/lib/add_auth/core/challenge/turnstile.rb +46 -0
- data/lib/add_auth/core/delivery.rb +55 -0
- data/lib/add_auth/core/digest/base.rb +34 -0
- data/lib/add_auth/core/digest/hmac.rb +41 -0
- data/lib/add_auth/core/intake.rb +59 -0
- data/lib/add_auth/core/maintenance.rb +45 -0
- data/lib/add_auth/core/rate_limit.rb +30 -0
- data/lib/add_auth/core/security_events.rb +48 -0
- data/lib/add_auth/core/sessions.rb +287 -0
- data/lib/add_auth/core/step_up.rb +129 -0
- data/lib/add_auth/core/strategies/email_link.rb +201 -0
- data/lib/add_auth/core/strategies/passkey.rb +286 -0
- data/lib/add_auth/rails/authentication.rb +67 -0
- data/lib/add_auth/rails/authentication_pages.rb +66 -0
- data/lib/add_auth/rails/delivery_cipher.rb +33 -0
- data/lib/add_auth/rails/doctor.rb +154 -0
- data/lib/add_auth/rails/ejection.rb +138 -0
- data/lib/add_auth/rails/elevation.rb +35 -0
- data/lib/add_auth/rails/engine.rb +34 -0
- data/lib/add_auth/rails/password_entry.rb +34 -0
- data/lib/add_auth/rails/runtime.rb +216 -0
- data/lib/add_auth/rails/stores/account_lock.rb +40 -0
- data/lib/add_auth/rails/stores/delivery_state.rb +21 -0
- data/lib/add_auth/rails/stores/email_tokens.rb +92 -0
- data/lib/add_auth/rails/stores/maintenance.rb +46 -0
- data/lib/add_auth/rails/stores/passkeys.rb +63 -0
- data/lib/add_auth/rails/stores/security_events.rb +40 -0
- data/lib/add_auth/rails/stores/sessions.rb +62 -0
- data/lib/add_auth/rails/user_lifecycle.rb +35 -0
- data/lib/add_auth/result.rb +55 -0
- data/lib/add_auth/testing.rb +30 -0
- data/lib/add_auth/version.rb +5 -0
- data/lib/add_auth.rb +52 -0
- data/lib/generators/add_auth/challenge/challenge_generator.rb +48 -0
- data/lib/generators/add_auth/challenge/templates/recaptcha.rb.tt +28 -0
- data/lib/generators/add_auth/challenge/templates/turnstile.rb.tt +24 -0
- data/lib/generators/add_auth/controllers/controllers_generator.rb +14 -0
- data/lib/generators/add_auth/ejection.rb +18 -0
- data/lib/generators/add_auth/email_link/email_link_generator.rb +56 -0
- data/lib/generators/add_auth/email_link/templates/add_add_auth_email_binding.rb.tt +5 -0
- data/lib/generators/add_auth/email_link/templates/add_add_auth_email_delivery.rb.tt +10 -0
- data/lib/generators/add_auth/email_link/templates/add_auth.css +28 -0
- data/lib/generators/add_auth/email_link/templates/add_auth_challenge.js +129 -0
- data/lib/generators/add_auth/email_tokens/email_tokens_generator.rb +35 -0
- data/lib/generators/add_auth/email_tokens/templates/add_auth_sign_in_token.rb +4 -0
- data/lib/generators/add_auth/email_tokens/templates/create_add_auth_sign_in_tokens.rb.tt +18 -0
- data/lib/generators/add_auth/install/install_generator.rb +27 -0
- data/lib/generators/add_auth/install/templates/initializer.rb +46 -0
- data/lib/generators/add_auth/javascript/javascript_generator.rb +14 -0
- data/lib/generators/add_auth/javascript/templates/application.js +2 -0
- data/lib/generators/add_auth/javascript/templates/codec.js +28 -0
- data/lib/generators/add_auth/javascript/templates/passkey.js +81 -0
- data/lib/generators/add_auth/mailer_views/mailer_views_generator.rb +14 -0
- data/lib/generators/add_auth/notifications/notifications_generator.rb +32 -0
- data/lib/generators/add_auth/notifications/templates/add_auth_security_event.rb +4 -0
- data/lib/generators/add_auth/notifications/templates/create_add_auth_security_events.rb.tt +18 -0
- data/lib/generators/add_auth/passkeys/passkeys_generator.rb +75 -0
- data/lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt +45 -0
- data/lib/generators/add_auth/passkeys/templates/add_auth_ceremony.rb +4 -0
- data/lib/generators/add_auth/passkeys/templates/add_auth_credential.rb +4 -0
- data/lib/generators/add_auth/session_upgrade/session_upgrade_generator.rb +79 -0
- data/lib/generators/add_auth/session_upgrade/templates/add_add_auth_elevation.rb.tt +12 -0
- data/lib/generators/add_auth/session_upgrade/templates/extend_sessions_for_add_auth.rb.tt +14 -0
- data/lib/generators/add_auth/step_up/step_up_generator.rb +44 -0
- data/lib/generators/add_auth/step_up/templates/add_add_auth_reauthentication.rb.tt +9 -0
- data/lib/generators/add_auth/views/views_generator.rb +16 -0
- data/lib/tasks/add_auth.rake +38 -0
- metadata +361 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Core
|
|
7
|
+
module Digest
|
|
8
|
+
# Rails derives the base secret; Core only receives key material.
|
|
9
|
+
# Purpose separation remains identical for injected and Rails-derived keys.
|
|
10
|
+
class Hmac < Base
|
|
11
|
+
KEY_LENGTH = 32
|
|
12
|
+
|
|
13
|
+
def initialize(salt:, secret:)
|
|
14
|
+
unless salt.is_a?(String) && !salt.strip.empty?
|
|
15
|
+
raise ArgumentError, "salt must be a nonblank string"
|
|
16
|
+
end
|
|
17
|
+
unless secret.is_a?(String) && secret.bytesize >= KEY_LENGTH
|
|
18
|
+
raise ArgumentError, "secret must contain at least 32 bytes of key material"
|
|
19
|
+
end
|
|
20
|
+
@key = OpenSSL::HMAC.digest("SHA256", secret, salt).freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def digest(token)
|
|
24
|
+
unless token.is_a?(String) && !token.empty?
|
|
25
|
+
raise ArgumentError, "token must be a nonempty string"
|
|
26
|
+
end
|
|
27
|
+
OpenSSL::HMAC.hexdigest("SHA256", @key, token)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def matches?(digest, token)
|
|
31
|
+
return false unless digest.is_a?(String) && digest.bytesize == 64
|
|
32
|
+
return false unless token.is_a?(String) && !token.empty?
|
|
33
|
+
|
|
34
|
+
OpenSSL.fixed_length_secure_compare(digest, self.digest(token))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def inspect = "#<#{self.class} [FILTERED]>"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Core
|
|
5
|
+
class Intake
|
|
6
|
+
def initialize(digest:, normalizer:, limiter:, challenge:, challenge_on:, challenge_when_unavailable: :closed,
|
|
7
|
+
on_challenge_unavailable: nil)
|
|
8
|
+
@digest, @normalizer, @limiter = digest, normalizer, limiter
|
|
9
|
+
@challenge, @challenge_on = challenge, challenge_on
|
|
10
|
+
@challenge_when_unavailable = challenge_when_unavailable.to_sym
|
|
11
|
+
unless %i[closed open].include?(@challenge_when_unavailable)
|
|
12
|
+
raise ArgumentError, "challenge_when_unavailable must be :closed or :open"
|
|
13
|
+
end
|
|
14
|
+
@on_challenge_unavailable = on_challenge_unavailable
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(identifier:, ip:, action:, challenge_token: nil)
|
|
18
|
+
value = (identifier.is_a?(String) && identifier.valid_encoding? && identifier.bytesize.between?(1, 254)) ? @normalizer.call(identifier) : ""
|
|
19
|
+
value = "" unless value.is_a?(String) && value.valid_encoding? && value.bytesize.between?(1, 254)
|
|
20
|
+
ip_ok = @limiter.call(key: @digest.digest("ip:#{ip}:#{action}"), limit: 30)
|
|
21
|
+
identifier_ok = @limiter.call(key: @digest.digest("identifier:#{value}:#{action}"), limit: 5)
|
|
22
|
+
return :rate_limited unless ip_ok && identifier_ok
|
|
23
|
+
return :invalid_credentials if value.to_s.empty?
|
|
24
|
+
rejection = verify_challenge(ip: ip, action: action, challenge_token: challenge_token)
|
|
25
|
+
rejection || value
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def anonymous(ip:, action:, challenge_token: nil)
|
|
29
|
+
return :rate_limited unless @limiter.call(key: @digest.digest("ip:#{ip}:#{action}"), limit: 30)
|
|
30
|
+
verify_challenge(ip: ip, action: action, challenge_token: challenge_token) || true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private def verify_challenge(ip:, action:, challenge_token:)
|
|
34
|
+
if @challenge_on.include?(action)
|
|
35
|
+
result = @challenge.verify(token: challenge_token, remote_ip: ip, action: action)
|
|
36
|
+
if result.unavailable?
|
|
37
|
+
return :challenge_unavailable if @challenge_when_unavailable == :closed
|
|
38
|
+
@on_challenge_unavailable&.call(action: action)
|
|
39
|
+
elsif result.rejected?
|
|
40
|
+
return :challenge_rejected
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.email_available?(config) = config.session.enabled && config.email_link.enabled
|
|
47
|
+
|
|
48
|
+
# no-referrer form navigation may send null; this is never a substitute
|
|
49
|
+
# for the adapter's mandatory session-bound CSRF-token verification.
|
|
50
|
+
def self.origin_allowed?(origin:, base_url:)
|
|
51
|
+
origin.nil? || origin == "null" || origin == base_url
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.revoke_after_change?(changes)
|
|
55
|
+
(changes.keys.map(&:to_s) & %w[password_digest email_address]).any?
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Core
|
|
5
|
+
# One bounded, replayable pass. Stores repeat cutoffs at mutation time;
|
|
6
|
+
# returning counts records committed work, never a claim of an empty backlog.
|
|
7
|
+
class Maintenance
|
|
8
|
+
def initialize(stores:, options:, enqueue:, clock: Time)
|
|
9
|
+
@stores, @options, @enqueue, @clock = stores, options, enqueue, clock
|
|
10
|
+
unless options.batch_size.is_a?(Integer) && (1..1000).cover?(options.batch_size)
|
|
11
|
+
raise ArgumentError, "maintenance.batch_size must be an integer from 1 to 1000"
|
|
12
|
+
end
|
|
13
|
+
%i[session email notification].each do |kind|
|
|
14
|
+
value = options.public_send(:"#{kind}_retention")
|
|
15
|
+
valid = value.nil? || (value.is_a?(Numeric) && value.finite? && value >= 0)
|
|
16
|
+
unless valid
|
|
17
|
+
raise ArgumentError, "maintenance.#{kind}_retention must be nonnegative seconds or nil"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
now, limit = @clock.now, @options.batch_size
|
|
24
|
+
counts = {}
|
|
25
|
+
@stores.each do |kind, store|
|
|
26
|
+
if kind == :ceremony
|
|
27
|
+
counts[:ceremonies_deleted] = store.purge_expired(before: now, now: now, limit: limit)
|
|
28
|
+
next
|
|
29
|
+
end
|
|
30
|
+
retention = @options.public_send(:"#{kind}_retention")
|
|
31
|
+
if kind != :session
|
|
32
|
+
counts[:"#{kind}_payloads_erased"] = store.erase_expired_payloads(now: now, limit: limit)
|
|
33
|
+
ids = store.pending_ids(now: now, limit: limit)
|
|
34
|
+
ids.each { |id| @enqueue.call(kind, id) }
|
|
35
|
+
counts[:"#{kind}_enqueued"] = ids.length
|
|
36
|
+
end
|
|
37
|
+
if retention
|
|
38
|
+
counts[:"#{kind}_deleted"] = store.purge_expired(before: now - retention, now: now, limit: limit)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
counts
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Core
|
|
5
|
+
class RateLimit
|
|
6
|
+
WINDOW = 300
|
|
7
|
+
STAGGER = 60
|
|
8
|
+
DURATION = WINDOW + STAGGER
|
|
9
|
+
|
|
10
|
+
def initialize(counter:, clock: Time)
|
|
11
|
+
@counter, @clock = counter, clock
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(key:, limit:)
|
|
15
|
+
now = @clock.now.to_i
|
|
16
|
+
# Every rolling five-minute interval fits in at least one of these
|
|
17
|
+
# overlapping six-minute windows. Atomic increments keep that bound
|
|
18
|
+
# across workers without a read/modify/write race or a boundary burst.
|
|
19
|
+
counts = (DURATION / STAGGER).times.map do |phase|
|
|
20
|
+
bucket = (now - phase * STAGGER) / DURATION
|
|
21
|
+
@counter.call(key: "add_auth:rate:v2:#{phase}:#{bucket}:#{key}", expires_in: DURATION)
|
|
22
|
+
end
|
|
23
|
+
unless counts.all? { |count| count.is_a?(Integer) && count.positive? }
|
|
24
|
+
raise AddAuth::Error, "rate limit store must support atomic increment"
|
|
25
|
+
end
|
|
26
|
+
counts.all? { |count| count <= limit }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module AddAuth
|
|
7
|
+
module Core
|
|
8
|
+
class SecurityEvents
|
|
9
|
+
include Delivery
|
|
10
|
+
|
|
11
|
+
KINDS = %i[password_changed email_changed passkey_added passkey_removed policy_changed recovery_completed].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(store:, digest:, delivery_cipher:, clock: Time, random: SecureRandom)
|
|
14
|
+
@store, @digest, @cipher, @clock, @random = store, digest, delivery_cipher, clock, random
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Called inside the authentication/account mutation transaction. The
|
|
18
|
+
# encrypted outbox survives an enqueue failure or a process crash.
|
|
19
|
+
def issue(user:, kind:, at:, recipient: user.email_address)
|
|
20
|
+
raise ArgumentError, "unsupported security event" unless KINDS.include?(kind.to_sym)
|
|
21
|
+
unless recipient.is_a?(String) && recipient.valid_encoding? && recipient.bytesize.between?(3, 254) &&
|
|
22
|
+
recipient.include?("@") && !recipient.match?(/[\r\n]/)
|
|
23
|
+
raise ArgumentError, "invalid notification recipient"
|
|
24
|
+
end
|
|
25
|
+
digest = @digest.digest(@random.urlsafe_base64(32))
|
|
26
|
+
expires_at = at + 7 * 86_400
|
|
27
|
+
payload = @cipher.encrypt(token: JSON.generate(recipient: recipient, kind: kind.to_s), digest: digest, expires_at: expires_at)
|
|
28
|
+
@store.append(user: user, kind: kind.to_s, digest: digest, expires_at: expires_at, delivery_payload: payload, created_at: at)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def rejection(_user, record)
|
|
34
|
+
!record || record.revoked_at || record.expires_at <= @clock.now
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def delivery_details(_user, record)
|
|
38
|
+
raw = @cipher.decrypt(payload: record.delivery_payload, digest: record.digest)
|
|
39
|
+
return unless raw
|
|
40
|
+
value = JSON.parse(raw)
|
|
41
|
+
return unless value["kind"] == record.kind && KINDS.map(&:to_s).include?(record.kind)
|
|
42
|
+
{recipient: value.fetch("recipient"), kind: record.kind}
|
|
43
|
+
rescue JSON::ParserError, KeyError
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Core
|
|
7
|
+
class Sessions
|
|
8
|
+
Grant = Struct.new(:session, :bearer) do
|
|
9
|
+
def inspect = "#<AddAuth::Core::Sessions::Grant [FILTERED]>"
|
|
10
|
+
end
|
|
11
|
+
class Entry
|
|
12
|
+
attr_reader :id, :current, :authenticated_with, :authenticated_at, :created_at,
|
|
13
|
+
:last_seen_at, :expires_at, :ip_address, :user_agent
|
|
14
|
+
|
|
15
|
+
def initialize(id:, current:, authenticated_with:, authenticated_at:, created_at:,
|
|
16
|
+
last_seen_at:, expires_at:, ip_address:, user_agent:)
|
|
17
|
+
@id, @current, @authenticated_with, @authenticated_at, @created_at = id, current,
|
|
18
|
+
authenticated_with, authenticated_at, created_at
|
|
19
|
+
@last_seen_at, @expires_at, @ip_address, @user_agent = last_seen_at, expires_at,
|
|
20
|
+
ip_address, user_agent
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def inspect = "#<AddAuth::Core::Sessions::Entry id=#{id} current=#{current.inspect}>"
|
|
24
|
+
end
|
|
25
|
+
PATTERN = /\Alk1:[A-Za-z0-9_-]{43}\z/
|
|
26
|
+
PAGE_SIZE = 50
|
|
27
|
+
Page = Struct.new(:entries, :next_cursor)
|
|
28
|
+
|
|
29
|
+
def initialize(store:, digest:, eligible:, lifetime: 43_200, idle_timeout: 1800,
|
|
30
|
+
legacy_bridge_until: nil, clock: Time, access_policy: nil)
|
|
31
|
+
unless [lifetime, idle_timeout].all? { |n| n.is_a?(Numeric) && n.finite? && n.positive? } && idle_timeout <= lifetime
|
|
32
|
+
raise ArgumentError, "session timeouts must be positive and idle_timeout <= lifetime"
|
|
33
|
+
end
|
|
34
|
+
unless legacy_bridge_until.nil? || legacy_bridge_until.is_a?(Time)
|
|
35
|
+
raise ArgumentError, "legacy_bridge_until must be an absolute Time or nil"
|
|
36
|
+
end
|
|
37
|
+
@store, @digest, @eligible, @clock = store, digest, eligible, clock
|
|
38
|
+
@access_policy = access_policy
|
|
39
|
+
@lifetime, @idle, @deadline = lifetime, idle_timeout, legacy_bridge_until
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def start(user:, method:, replacing: nil, **hints)
|
|
43
|
+
# The original Rails controller verifies before calling its session hook.
|
|
44
|
+
# A reset between that proof and this lock must invalidate the proof too.
|
|
45
|
+
return if method.to_s == "password" && !user.respond_to?(:password_digest)
|
|
46
|
+
verified_password_digest = user.password_digest if method.to_s == "password"
|
|
47
|
+
@store.with_user(id: user.id, replacing: replacing) do |account|
|
|
48
|
+
next if method.to_s == "password" && account && account.password_digest != verified_password_digest
|
|
49
|
+
create_in_transaction(user: account, method: method, replacing: replacing, **hints)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def authenticate(identifier:, password:, replacing: nil, **hints)
|
|
54
|
+
@store.with_identifier(identifier: identifier, replacing: replacing) do |user|
|
|
55
|
+
verified = yield
|
|
56
|
+
create_in_transaction(user: user, method: :password, replacing: replacing, **hints) if user && verified && verified.id == user.id
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Runs inside the store's account transaction. When replacing a browser,
|
|
61
|
+
# that session's account must also be locked on the same connection.
|
|
62
|
+
def create_in_transaction(user:, method:, persist: nil, replacing: nil, proof: nil, **hints)
|
|
63
|
+
return unless user && @eligible.call(user) == true
|
|
64
|
+
raise ArgumentError, "unsupported proof" unless %w[password email_link passkey].include?(method.to_s)
|
|
65
|
+
return if @access_policy && !@access_policy.sign_in_allowed?(user, method)
|
|
66
|
+
if method.to_s == "passkey"
|
|
67
|
+
return unless proof.is_a?(StepUp::Evidence) && proof.strong? && proof.user_id == user.id &&
|
|
68
|
+
proof.verified_at <= @clock.now && proof.verified_at > @clock.now - 300
|
|
69
|
+
end
|
|
70
|
+
if user.respond_to?(:add_auth_policy_version)
|
|
71
|
+
hints = hints.merge(authentication_policy_version: @access_policy ? @access_policy.version(user) : user.add_auth_policy_version,
|
|
72
|
+
authentication_credential_id: proof&.credential_id, authentication_uv: proof&.user_verification || false)
|
|
73
|
+
end
|
|
74
|
+
raw = bearer
|
|
75
|
+
now = @clock.now
|
|
76
|
+
if replacing
|
|
77
|
+
prior = @store.find_for_user_in_transaction(user_id: replacing.user_id, session_id: replacing.id)
|
|
78
|
+
@store.update(prior, revoked_at: now) if prior && !prior.revoked_at && prior.token_digest == replacing.token_digest
|
|
79
|
+
end
|
|
80
|
+
writer = persist || ->(**attributes) { @store.create(user: user, **attributes) }
|
|
81
|
+
row = writer.call(token_digest: @digest.digest(raw), authenticated_with: method.to_s,
|
|
82
|
+
authenticated_at: now, expires_at: now + @lifetime, last_seen_at: now, **hints)
|
|
83
|
+
Grant.new(session: row, bearer: raw)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The adapter must pass only the value returned by Rails cookies.signed.
|
|
87
|
+
def resume(signed_value:)
|
|
88
|
+
lookup = cookie_lookup(signed_value)
|
|
89
|
+
return unless lookup
|
|
90
|
+
legacy = lookup.key?(:id)
|
|
91
|
+
@store.with_session(**lookup) do |user, row|
|
|
92
|
+
now = @clock.now
|
|
93
|
+
next unless live?(user, row, now: now)
|
|
94
|
+
if legacy
|
|
95
|
+
next if row.token_digest || now >= @deadline
|
|
96
|
+
raw = bearer
|
|
97
|
+
@store.update(row, token_digest: @digest.digest(raw), expires_at: [now + @lifetime, @deadline, row.expires_at].compact.min,
|
|
98
|
+
last_seen_at: now, authenticated_with: nil, authenticated_at: nil)
|
|
99
|
+
Grant.new(session: row, bearer: raw)
|
|
100
|
+
else
|
|
101
|
+
@store.update(row, last_seen_at: now) if row.last_seen_at <= now - 60
|
|
102
|
+
Grant.new(session: row)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# A trusted cookie identifies what to retire, even if that account is now
|
|
108
|
+
# ineligible. This snapshot is replacement input, never authentication.
|
|
109
|
+
def replacement_for(signed_value:)
|
|
110
|
+
lookup = cookie_lookup(signed_value)
|
|
111
|
+
return unless lookup
|
|
112
|
+
@store.with_session(**lookup) do |_user, row|
|
|
113
|
+
row unless lookup.key?(:id) && row&.token_digest
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def revoke(session:)
|
|
118
|
+
@store.with_session(id: session.id) do |_user, row|
|
|
119
|
+
@store.update(row, revoked_at: @clock.now) if row && !row.revoked_at && row.token_digest == session.token_digest
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Returns display-safe metadata only. Bearers and digests never cross this
|
|
124
|
+
# boundary, even when a host chooses to render its own management page.
|
|
125
|
+
def list(user:, current_session_id: nil)
|
|
126
|
+
list_page(user: user, current_session_id: current_session_id).entries
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def list_page(user:, current_session_id: nil, before: nil)
|
|
130
|
+
empty = Page.new(entries: [])
|
|
131
|
+
return empty unless user && @eligible.call(user) == true
|
|
132
|
+
return empty unless before.nil? || before.to_s.match?(/\A[1-9]\d{0,18}\z/)
|
|
133
|
+
|
|
134
|
+
now = @clock.now
|
|
135
|
+
candidates = @store.list_for_user(user_id: user.id, before: before&.to_i,
|
|
136
|
+
excluding: current_session_id, limit: PAGE_SIZE + 1, now: now,
|
|
137
|
+
active_after: now - @idle, legacy: !!(@deadline && now < @deadline))
|
|
138
|
+
cursor = candidates[PAGE_SIZE - 1].id if candidates.length > PAGE_SIZE
|
|
139
|
+
rows = candidates.first(PAGE_SIZE)
|
|
140
|
+
if before.nil? && current_session_id
|
|
141
|
+
current = @store.find_for_user(user_id: user.id, session_id: current_session_id)
|
|
142
|
+
rows.unshift(current) if current
|
|
143
|
+
end
|
|
144
|
+
entries = rows.filter_map do |row|
|
|
145
|
+
next unless live?(user, row, now: now)
|
|
146
|
+
|
|
147
|
+
Entry.new(id: row.id, current: row.id == current_session_id,
|
|
148
|
+
authenticated_with: row.authenticated_with, authenticated_at: row.authenticated_at,
|
|
149
|
+
created_at: row.created_at, last_seen_at: row.last_seen_at, expires_at: row.expires_at,
|
|
150
|
+
ip_address: row.ip_address, user_agent: row.user_agent)
|
|
151
|
+
end
|
|
152
|
+
Page.new(entries: entries, next_cursor: cursor)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Both the initiating bearer and target ownership are checked under lock.
|
|
156
|
+
def revoke_one(user:, session:, session_id:)
|
|
157
|
+
return false unless session_id.is_a?(Integer) || session_id.to_s.match?(/\A[1-9]\d*\z/)
|
|
158
|
+
|
|
159
|
+
with_live_session(user: user, session: session) do |account, _current, now|
|
|
160
|
+
target = @store.find_for_user_in_transaction(user_id: account.id, session_id: session_id.to_i)
|
|
161
|
+
next false unless target
|
|
162
|
+
@store.update(target, revoked_at: now) unless target.revoked_at
|
|
163
|
+
true
|
|
164
|
+
end || false
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Password proof is obtained from the locked, current account. Intake
|
|
168
|
+
# limits must be applied by the caller before reaching this expensive port.
|
|
169
|
+
def reauthenticate(user:, session:, purpose:, policy:)
|
|
170
|
+
with_live_session(user: user, session: session) do |account, row, _now|
|
|
171
|
+
next Result.failure(reason: :elevation_required) if @access_policy && !@access_policy.sign_in_allowed?(account, :password)
|
|
172
|
+
verified = yield account
|
|
173
|
+
next Result.failure(reason: :invalid_credentials) unless verified && verified.id == account.id
|
|
174
|
+
evidence = StepUp::Evidence.new(user_id: account.id, session_id: row.id,
|
|
175
|
+
method: :password, verified_at: @clock.now, session_digest: row.token_digest,
|
|
176
|
+
credential_version: policy.password_version(account))
|
|
177
|
+
policy.authorize(user: account, session_id: row.id, purpose: purpose, evidence: evidence)
|
|
178
|
+
end || Result.failure(reason: :elevation_required)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def revoke_all(user:, session:, grant:)
|
|
182
|
+
with_authority(user: user, session: session, grant: grant, purpose: :sign_out_everywhere) do |account, _row, now|
|
|
183
|
+
@store.revoke_all_in_transaction(user_id: account.id, at: now)
|
|
184
|
+
end || false
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def rotate_for_step_up(user:, session:, grant:)
|
|
188
|
+
return unless grant.is_a?(StepUp::Grant)
|
|
189
|
+
with_authority(user: user, session: session, grant: grant, purpose: grant.purpose) do |account, row, _now|
|
|
190
|
+
rotate_in_transaction(user: account, session: row, grant: grant)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Caller owns the account transaction, as with email-token consumption.
|
|
195
|
+
def current_in_transaction(user:, session:)
|
|
196
|
+
return unless user && session && session.user_id == user.id
|
|
197
|
+
row = @store.find_for_user_in_transaction(user_id: user.id, session_id: session.id)
|
|
198
|
+
row if live?(user, row, now: @clock.now) && row.token_digest == session.token_digest
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def rotate_in_transaction(user:, session:, grant:)
|
|
202
|
+
row = current_in_transaction(user: user, session: session)
|
|
203
|
+
return unless row && grant.is_a?(StepUp::Grant) && grant.valid_for?(user: user,
|
|
204
|
+
user_id: user.id, session_id: row.id, session_digest: row.token_digest, purpose: grant.purpose, now: @clock.now)
|
|
205
|
+
raw = bearer
|
|
206
|
+
attributes = {token_digest: @digest.digest(raw), elevated_at: grant.verified_at,
|
|
207
|
+
elevated_with: grant.method.to_s, elevation_purpose: grant.purpose.to_s,
|
|
208
|
+
elevation_credential_id: grant.credential_id, elevation_uv: grant.user_verification,
|
|
209
|
+
last_seen_at: @clock.now}
|
|
210
|
+
if row.respond_to?(:elevation_version)
|
|
211
|
+
attributes[:elevation_version] = grant.credential_version
|
|
212
|
+
attributes[:elevation_expires_at] = grant.expires_at
|
|
213
|
+
end
|
|
214
|
+
@store.update(row, **attributes)
|
|
215
|
+
Grant.new(session: row, bearer: raw)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# A guard can call this without a block. Mutations must use the block so
|
|
219
|
+
# eligibility, bearer generation and proof are checked under the same lock.
|
|
220
|
+
# Hosts own resource authorization and target/version checks inside it.
|
|
221
|
+
def with_elevation(user:, session:, purpose:, policy:)
|
|
222
|
+
with_live_session(user: user, session: session) do |account, row, _now|
|
|
223
|
+
result = elevation_in_transaction(user: account, session: row, purpose: purpose, policy: policy)
|
|
224
|
+
next result unless result.success?
|
|
225
|
+
yield account if block_given?
|
|
226
|
+
result
|
|
227
|
+
end || Result.failure(reason: :elevation_required)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def elevation_in_transaction(user:, session:, purpose:, policy:)
|
|
231
|
+
row = current_in_transaction(user: user, session: session)
|
|
232
|
+
now = @clock.now
|
|
233
|
+
return Result.failure(reason: :elevation_required) unless row&.respond_to?(:elevation_version) && row.elevated_at &&
|
|
234
|
+
row.elevation_purpose == purpose.to_s && row.elevation_expires_at && now < row.elevation_expires_at
|
|
235
|
+
evidence = StepUp::Evidence.new(user_id: user.id, session_id: row.id,
|
|
236
|
+
method: row.elevated_with, verified_at: row.elevated_at, session_digest: row.token_digest,
|
|
237
|
+
credential_id: row.elevation_credential_id, user_verification: row.elevation_uv,
|
|
238
|
+
credential_version: row.elevation_version)
|
|
239
|
+
policy.authorize(user: user, session_id: row.id, purpose: purpose, evidence: evidence)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def self.safe_return(value)
|
|
243
|
+
value if value.is_a?(String) && value.start_with?("/") && !value.start_with?("//") &&
|
|
244
|
+
!value.match?(/[\\\x00-\x20\x7f]/) && !value.match?(/%[0-9a-f]{2}/i)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
private
|
|
248
|
+
|
|
249
|
+
def cookie_lookup(value)
|
|
250
|
+
if value.is_a?(String) && PATTERN.match?(value)
|
|
251
|
+
{digest: @digest.digest(value)}
|
|
252
|
+
elsif value.is_a?(Integer) && value.positive? && @deadline && @clock.now < @deadline
|
|
253
|
+
{id: value}
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def with_authority(user:, session:, grant:, purpose:)
|
|
258
|
+
return unless grant.is_a?(StepUp::Grant)
|
|
259
|
+
with_live_session(user: user, session: session) do |account, row, now|
|
|
260
|
+
next unless grant.valid_for?(user: account, user_id: account.id, session_id: row.id,
|
|
261
|
+
session_digest: row.token_digest, purpose: purpose, now: now)
|
|
262
|
+
yield account, row, now
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def with_live_session(user:, session:)
|
|
267
|
+
return unless user && session
|
|
268
|
+
@store.with_session(id: session.id) do |account, row|
|
|
269
|
+
now = @clock.now
|
|
270
|
+
next unless account && account.id == user.id && live?(account, row, now: now) &&
|
|
271
|
+
row.token_digest == session.token_digest
|
|
272
|
+
yield account, row, now
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def live?(user, row, now:)
|
|
277
|
+
return false unless user && row && @eligible.call(user) == true && !row.revoked_at
|
|
278
|
+
return false if @access_policy && !@access_policy.session_allowed?(user, row)
|
|
279
|
+
legacy = !row.token_digest && @deadline && now < @deadline
|
|
280
|
+
(legacy || (row.expires_at && row.last_seen_at)) &&
|
|
281
|
+
(!row.expires_at || row.expires_at > now) && (!row.last_seen_at || row.last_seen_at > now - @idle)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def bearer = "lk1:#{SecureRandom.urlsafe_base64(32)}"
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Core
|
|
5
|
+
# Evidence is issued by a trusted verifier, never deserialized from a client.
|
|
6
|
+
# A grant retains that verifier's current policy and the proof's original age.
|
|
7
|
+
class StepUp
|
|
8
|
+
METHODS = %i[password email_link passkey].freeze
|
|
9
|
+
|
|
10
|
+
class Evidence
|
|
11
|
+
attr_reader :user_id, :session_id, :method, :verified_at, :credential_id,
|
|
12
|
+
:user_verification, :session_digest, :credential_version
|
|
13
|
+
|
|
14
|
+
def initialize(user_id:, session_id:, method:, verified_at:, credential_id: nil,
|
|
15
|
+
user_verification: false, session_digest: nil, credential_version: nil)
|
|
16
|
+
@user_id, @session_id, @method, @verified_at = user_id, session_id, method.to_sym, verified_at
|
|
17
|
+
@credential_id, @user_verification = credential_id, user_verification == true
|
|
18
|
+
@session_digest, @credential_version = session_digest&.dup&.freeze, credential_version&.dup&.freeze
|
|
19
|
+
@verified_at = verified_at.dup.freeze
|
|
20
|
+
@credential_id = credential_id&.dup&.freeze
|
|
21
|
+
freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def strong?
|
|
25
|
+
method == :passkey && user_verification && credential_id.is_a?(String) && !credential_id.empty?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def inspect = "#<AddAuth::Core::StepUp::Evidence [FILTERED]>"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class Grant
|
|
32
|
+
attr_reader :purpose, :expires_at
|
|
33
|
+
def initialize(evidence:, purpose:, expires_at:, policy:)
|
|
34
|
+
@evidence, @purpose, @expires_at, @policy = evidence, purpose, expires_at, policy
|
|
35
|
+
freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
%i[user_id session_id method verified_at credential_id user_verification session_digest credential_version].each do |attribute|
|
|
39
|
+
define_method(attribute) { @evidence.public_send(attribute) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def valid_for?(user_id:, session_id:, purpose:, now:, user: nil, session_digest: nil)
|
|
43
|
+
user && user.id == user_id && @evidence.user_id == user_id && @evidence.session_id == session_id &&
|
|
44
|
+
purpose.to_sym == @purpose && @evidence.session_digest == session_digest &&
|
|
45
|
+
now.is_a?(Time) && now >= verified_at && now < @expires_at &&
|
|
46
|
+
@policy.current?(user: user, purpose: @purpose, evidence: @evidence)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def inspect = "#<AddAuth::Core::StepUp::Grant purpose=#{purpose.inspect} method=#{method.inspect}>"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class Rule
|
|
53
|
+
attr_reader :methods, :require_passkey, :return_to, :label, :reauthentication
|
|
54
|
+
def initialize(methods:, require_passkey: false, return_to: "/", label: "Continue", reauthentication: true)
|
|
55
|
+
@reauthentication = reauthentication == true
|
|
56
|
+
@methods = Array(methods).map(&:to_sym).uniq.freeze
|
|
57
|
+
raise ArgumentError, "unsupported step-up method" unless (@methods - METHODS).empty?
|
|
58
|
+
raise ArgumentError, "return_to must be a safe local GET path" unless Sessions.safe_return(return_to)
|
|
59
|
+
@return_to, @label = return_to.dup.freeze, label.to_s.dup.freeze
|
|
60
|
+
@require_passkey = require_passkey == true
|
|
61
|
+
freeze
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def initialize(purposes:, fresh_for: 600, strong_for: 300, clock: Time, credential_current: nil, password_version: ->(user) { user.password_digest }, methods_available: ->(_user) { METHODS })
|
|
66
|
+
unless [fresh_for, strong_for].all? { |value| value.is_a?(Numeric) && value.finite? && value.positive? }
|
|
67
|
+
raise ArgumentError, "step-up windows must be positive"
|
|
68
|
+
end
|
|
69
|
+
raise ArgumentError, "strong_for must not exceed fresh_for" if strong_for > fresh_for
|
|
70
|
+
@purposes = purposes.transform_keys(&:to_sym).transform_values do |rule|
|
|
71
|
+
rule.is_a?(Rule) ? rule : Rule.new(**rule)
|
|
72
|
+
end.freeze
|
|
73
|
+
@fresh_for, @strong_for, @clock, @credential_current = fresh_for, strong_for, clock, credential_current
|
|
74
|
+
@password_version, @methods_available = password_version, methods_available
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def rule_for(purpose)
|
|
78
|
+
return unless purpose.is_a?(String) || purpose.is_a?(Symbol)
|
|
79
|
+
return if purpose.to_s.bytesize > 64
|
|
80
|
+
@purposes[purpose.to_sym]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def reauthentication_rule_for(purpose)
|
|
84
|
+
rule = rule_for(purpose)
|
|
85
|
+
rule if rule&.reauthentication
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def return_to(purpose) = rule_for(purpose)&.return_to || "/"
|
|
89
|
+
|
|
90
|
+
def methods_for(user:, purpose:)
|
|
91
|
+
rule = rule_for(purpose)
|
|
92
|
+
return [] unless user && rule
|
|
93
|
+
available = rule.methods & @methods_available.call(user)
|
|
94
|
+
rule.require_passkey ? available & [:passkey] : available
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def password_version(user) = @password_version.call(user)
|
|
98
|
+
|
|
99
|
+
def authorize(user:, session_id:, purpose:, evidence:)
|
|
100
|
+
now = @clock.now
|
|
101
|
+
unless evidence.is_a?(Evidence) && user && evidence.user_id == user.id &&
|
|
102
|
+
evidence.session_id == session_id && evidence.verified_at.is_a?(Time) && now.is_a?(Time) &&
|
|
103
|
+
current?(user: user, purpose: purpose, evidence: evidence)
|
|
104
|
+
return Result.failure(reason: :elevation_required)
|
|
105
|
+
end
|
|
106
|
+
window = evidence.strong? ? @strong_for : @fresh_for
|
|
107
|
+
return Result.failure(reason: :elevation_required) unless evidence.verified_at <= now && now < evidence.verified_at + window
|
|
108
|
+
|
|
109
|
+
grant = Grant.new(evidence: evidence, purpose: purpose.to_sym,
|
|
110
|
+
expires_at: evidence.verified_at + window, policy: self)
|
|
111
|
+
Result.success(user: user, strategy: :step_up, credential: grant)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def current?(user:, purpose:, evidence:)
|
|
115
|
+
rule = rule_for(purpose)
|
|
116
|
+
return false unless rule && methods_for(user: user, purpose: purpose).include?(evidence.method)
|
|
117
|
+
return false if rule.require_passkey && !evidence.strong?
|
|
118
|
+
return false unless evidence.session_digest.is_a?(String) && !evidence.session_digest.empty?
|
|
119
|
+
return false if evidence.method == :passkey && !evidence.strong?
|
|
120
|
+
if evidence.method == :password
|
|
121
|
+
return false unless evidence.credential_version.is_a?(String) && !evidence.credential_version.empty?
|
|
122
|
+
return false unless user.respond_to?(:password_digest) && password_version(user) == evidence.credential_version
|
|
123
|
+
return true unless @credential_current
|
|
124
|
+
end
|
|
125
|
+
@credential_current && @credential_current.call(user: user, evidence: evidence) == true
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|