Class: OpenLoam::MfaCredential

Inherits:
ApplicationRecord
  • Object
show all
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

Instance Method Summary collapse

Methods included from Encryptable

#open_loam_reencrypt!

Methods included from GeneratedKey

included

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

Returns:

  • (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