Class: OpenLoam::MfaCredential
- Inherits:
-
ApplicationRecord
- Object
- ApplicationRecord
- OpenLoam::MfaCredential
- Includes:
- Encryptable, GeneratedKey
- Defined in:
- app/models/open_loam/mfa_credential.rb
Overview
A user's multi-factor credential: a TOTP secret plus single-use recovery
codes. Deliberately NOT tenant-scoped — MFA belongs to the PERSON, who spans
tenants, and the second-factor challenge runs at login BEFORE any tenant is
chosen. So the secret is encrypted under a USER-scoped key (OpenLoam::Encryptable
scope:), which decrypts in any tenant and with no tenant at all — the whole
reason a per-tenant key would be a lockout bug here.
Constant Summary collapse
- RECOVERY_CODE_COUNT =
10
Class Method Summary collapse
-
.active_for(user) ⇒ Object
The active credential for a user, or nil — nil while enrollment is pending (a secret exists but was never confirmed) so an un-activated credential never gates login.
Instance Method Summary collapse
-
#activate_with!(candidate_secret, code) ⇒ Object
Confirm enrollment against a CANDIDATE secret (held in the session, never written until proven) with a live code, then activate: adopt the secret, mint recovery codes, and record the confirming step so it cannot be replayed at the next login.
- #activated? ⇒ Boolean
-
#consume_recovery_code(code) ⇒ Object
Consume a recovery code: valid exactly once.
- #provisioning_uri(issuer:) ⇒ Object
- #unused_recovery_code_count ⇒ Object
-
#verify_totp(code) ⇒ Object
Verify a TOTP code AND consume its timestep, so a captured code cannot be replayed within its ~90s validity window (at login or at sudo).
Methods included from Encryptable
Methods included from GeneratedKey
Class Method Details
.active_for(user) ⇒ Object
The active credential for a user, or nil — nil while enrollment is pending (a secret exists but was never confirmed) so an un-activated credential never gates login.
31 32 33 34 35 36 |
# File 'app/models/open_loam/mfa_credential.rb', line 31 def self.active_for(user) return nil unless user credential = find_by(user_id: user.id) credential&.activated? ? credential : nil end |
Instance Method Details
#activate_with!(candidate_secret, code) ⇒ Object
Confirm enrollment against a CANDIDATE secret (held in the session, never written until proven) with a live code, then activate: adopt the secret, mint recovery codes, and record the confirming step so it cannot be replayed at the next login. Returns the plaintext codes (shown ONCE) or nil if the code is wrong. The old secret stays valid until this succeeds, so a half-finished re-enrollment never downgrades an active credential.
48 49 50 51 52 53 54 55 56 57 58 |
# File 'app/models/open_loam/mfa_credential.rb', line 48 def activate_with!(candidate_secret, code) step = OpenLoam::Totp.matching_step(candidate_secret, code) return nil unless step self.totp_secret = candidate_secret self.activated_at = Time.current self.last_totp_step = step plaintext = mint_recovery_codes! save! plaintext end |
#activated? ⇒ Boolean
38 39 40 |
# File 'app/models/open_loam/mfa_credential.rb', line 38 def activated? activated_at.present? end |
#consume_recovery_code(code) ⇒ Object
Consume a recovery code: valid exactly once. with_lock reloads and re-checks inside the transaction, so two concurrent submits of the same code cannot both succeed. Constant-time per candidate via BCrypt.
82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 |
# File 'app/models/open_loam/mfa_credential.rb', line 82 def consume_recovery_code(code) code = code.to_s.strip.downcase return false if code.empty? with_lock do entry = recovery_codes.find { |e| e["used_at"].nil? && BCrypt::Password.new(e["digest"]) == code } if entry entry["used_at"] = Time.current.iso8601 save! true else false end end end |
#provisioning_uri(issuer:) ⇒ Object
102 103 104 |
# File 'app/models/open_loam/mfa_credential.rb', line 102 def provisioning_uri(issuer:) OpenLoam::Totp.provisioning_uri(totp_secret, account: user.email, issuer: issuer) end |
#unused_recovery_code_count ⇒ Object
98 99 100 |
# File 'app/models/open_loam/mfa_credential.rb', line 98 def unused_recovery_code_count recovery_codes.count { |e| e["used_at"].nil? } end |
#verify_totp(code) ⇒ Object
Verify a TOTP code AND consume its timestep, so a captured code cannot be
replayed within its ~90s validity window (at login or at sudo). The lock +
last_totp_step check closes the read-modify-write race of two concurrent
submits. On SQLite FOR UPDATE is dropped (writer serialization + the
re-check still hold); Postgres takes a real row lock.
65 66 67 68 69 70 71 72 73 74 75 76 77 |
# File 'app/models/open_loam/mfa_credential.rb', line 65 def verify_totp(code) return false unless activated? with_lock do step = OpenLoam::Totp.matching_step(totp_secret, code) if step && (last_totp_step.nil? || step > last_totp_step) update!(last_totp_step: step) true else false end end end |