KemalIdentity::MFA::Repository
Storage for enrolled factors and recovery codes.
Two operations here have to be atomic, and they are the ones that matter
#consume_counter and #consume_recovery_code are single-use operations, and
blueprints/0011-action-token-atomicity.md already established what that means in this
shard: one statement that both checks and marks, never a read followed by a write.
The read-then-write version passes every spec written against one fiber and fails against
two. Two requests arrive with the same intercepted TOTP code, both read
last_used_counter, both find it lower, both accept, and the replay defence was never
there. The same is true of a recovery code spent twice. An implementation must express
these as one UPDATE ... WHERE ... RETURNING, and the contract spec runs them
concurrently against a real database rather than trusting the comment.
The rest is ordinary CRUD.
Instance methods
Zeroes the consecutive failure count. What a successful verification calls.
Returns false if the factor does not exist. Idempotent: a factor already at zero is
not an error, since every success calls this and most successes follow a success.
Marks enrolment finished, recording the counter that proved it.
Returns false if the factor does not exist or was already confirmed. Confirming twice
is not an error — a double-submitted form is a normal thing — but the caller still
learns nothing changed, and the first timestamp is the one an audit trail wants.
Records that counter has now been used, if and only if it is strictly greater than
whatever this factor was last used at.
Returns false when the counter has already been spent, which the caller must treat as
a failed verification even though the code itself was arithmetically correct. This is
the replay defence, and it must be one statement — see the note above.
Spends the code with this digest, if it exists for this account and is unused.
Returns false for an unknown or already-spent code. One statement, for the same reason
as #consume_counter: two requests carrying the same code must not both succeed.
Stores a newly enrolled, unconfirmed factor.
Raises InfrastructureError if the id already exists, rather than overwriting: a
collision means the id source is broken, and silently replacing an enrolled factor is
how somebody loses access to their account.
Removes every factor for an account, returning how many. What "disable MFA" calls.
Marks a factor disabled, returning false if it does not exist or was already disabled.
at is stamped rather than defaulted so the caller's clock is the one on the row. A
disabled factor must stop authenticating — see Factor#usable? — while remaining
visible to a management listing, so this is a flag and not a delete.
Every factor for an account, confirmed or not, oldest first. Unconfirmed ones are included because the enrolment screen has to show what is half-finished.
Records that a code offered for this factor was wrong, returning the new consecutive
failure count — or nil if the factor does not exist.
One statement (UPDATE ... SET consecutive_failures = consecutive_failures + 1 ... RETURNING consecutive_failures, or the dialect's equivalent), because the count is what
a lifetime bound is enforced against and two parallel wrong guesses must count as two.
A read followed by a write loses one of them, which is the direction that favours the
guesser.
Replaces an account's recovery codes with codes, atomically.
Atomic because the two halves are a security hole apart: an account left briefly with no codes cannot recover, and one left briefly with both sets has old codes that were supposed to be void. Regenerating is exactly the operation somebody performs when they think the old codes leaked.