KemalIdentity::MFA::Service
Enrolling, proving and removing second factors.
What makes TOTP safe is here, not in TOTP
TOTP computes and compares six digits. Six digits is one of a million, a code is valid
for its whole period plus the drift either side, and neither of those facts is a problem
until you notice that nothing so far stops an attacker submitting codes in a loop. Three
things do, and all three live in this class:
- A rate limit on every attempt, consumed before the code is checked. Without it, a million guesses against a 90-second window is a few minutes of traffic.
- Single use. A counter that verified once is spent, atomically, so a code read over somebody's shoulder is worthless by the time it is typed a second time.
- Confirmation before the factor counts. A secret that was generated but never proved is a secret nobody may actually hold, and treating it as a factor is how a person locks themselves out of their own account.
Freshness is the caller's to enforce
docs/06-roadmap.md requires fresh authentication for disabling MFA, replacing a factor
and using a recovery code. That check belongs at the route — env.auth.require_fresh! —
and not here, because this class takes an account id and has no request to inspect. The
methods that need it say so, and examples/ shows the guard.
Constants
Attempts allowed before the limiter is consulted for a verdict, per account.
How many recovery codes to issue at once.
Ten is the usual number, and the trade-off is real in both directions: too few and the person runs out at the worst moment, too many and the printed list is a long-lived bypass sitting in somebody's downloads folder.
Bytes of entropy per recovery code.
The full RandomSource::TOKEN_BYTES, which is the floor docs/02-security-model.md sets
for any secret handed to a browser. A recovery code skips the second factor outright, so
it is the last thing that should be granted an exception to that rule — and the usual
argument for a shorter one, that people type these by hand, is answered by printing them
in groups: #redeem_recovery_code strips the spacing back out.
Constructors
Instance methods
Finishes enrolment by checking a code from the new factor.
Returns nil when the code does not verify, and Confirmed when it does — carrying
recovery codes if this is what turned MFA on for the account. Rate limited like any other
code submission: an unconfirmed factor is still a guessable secret.
account_id, when given, is checked against the factor's own: a route that takes the
factor id from a client should pass it, for the reason #remove gives. Confirming
somebody else's pending enrolment also needs a code from their secret, so this is the
cheaper of the two guards rather than the load-bearing one.
Turns MFA off for an account: every factor, and every recovery code with them.
This lowers the account's security, so it is exactly the operation
docs/06-roadmap.md requires fresh authentication for. Audited at warning level —
an attacker who has hijacked a session will try this before anything else.
Generates a secret and stores it unconfirmed, returning what to show the person.
Nothing is protected yet. The factor does not count until #confirm proves that whoever
asked for it can actually produce a code from it.
label is what the person will see in their authenticator app next to the six digits,
so the login is the usual choice.
Whether this account has at least one factor that has been proved and still works.
Unconfirmed factors deliberately do not count: a half-finished enrolment must not make the login screen start demanding a code nobody can produce. Nor do disabled ones, for the same reason from the other end — a factor switched off for consecutive failures cannot produce an accepted code either, and an account left with only those must fall back to recovery rather than being asked for a code that can never work.
Every factor on the account, for a management screen. Unconfirmed ones included, since that screen has to show what is half-finished.
How many consecutive wrong codes disable a factor, or nil for no bound.
nil is the shipped default and does not meet NIST SP 800-63B, which says the verifier
SHALL limit consecutive failed attempts against one authenticator "to no more than
100 by disabling that authenticator". A rate limiter cannot stand in for it: a window
resets and grants the same budget again forever. Measured with a five-minute window of
twelve — the configuration a real consumer had — that is 103,680 attempts in thirty
days, which against six digits with drift: 1 is about a 27% chance of being guessed.
blueprints/0025 (MFA-04) has the measurement and blueprints/0029 the decision.
It defaults to nil rather than to 100 because switching it on can disable a factor,
and a deployment that has been running without it may have factors sitting on hundreds
of accumulated failures from ordinary typos over years — turning the bound on would
lock those people out at once. Set it deliberately, after deciding what your support
path for a disabled factor is. New deployments should set it.
Spends a recovery code.
The way back in when the phone is gone, and therefore a full bypass of the second factor:
rate limited like #verify, consumed atomically so two requests cannot both spend it,
and audited at warning level. A recovery code being used is either somebody's worst
day or an attacker's best one, and it is worth an alert either way.
docs/06-roadmap.md requires fresh authentication for this; enforce it at the route.
It signs the account's other sessions out
docs/02-security-model.md lists MFA recovery among the events that revoke all of an
account's sessions, and the reason is the situation that produces one: somebody is
redeeming a code because they have lost the device, and "lost" and "taken" look identical
from here. Anything already signed in elsewhere is exactly what needs ending.
except_session_id spares the session doing the redeeming, which is normally the one
half-way through a login. Pass it, or the person is signed out by their own recovery.
Requires a Sessions::Service; with none configured the codes still work and nothing is
revoked, which is a weaker arrangement and one an application has to choose deliberately.
Its quota is its own
A recovery code is spent by somebody whose second factor has just failed them, so
sharing a bucket with #verify meant the credential for that exact situation was
unavailable in that exact situation — measured in blueprints/0025 (MFA-04). The bucket
is separate, and recovery_rate_limiter: can give it a different limit as well as a
different key. Safe because a recovery code is a 43-character CSPRNG string rather than
six digits: throttling it protects the endpoint, not the secret.
Issues a fresh set of recovery codes, voiding whatever was there.
Voiding is the point: this is what somebody calls when they think the old list leaked. Requires fresh authentication at the route.
Removes one factor by id alone, so it is an administrative call.
A route that lets a client name the factor to remove — DELETE /mfa/factors/:id — must
use the account-scoped form below, or it will happily remove somebody else's second
factor. A factor id is not secret material: it appears in mfa.verified and
mfa.factor_removed audit lines and in any management listing, which is the same
reasoning ApiTokens::Service#revoke carries.
Recovery codes are left alone: removing one of two devices is not "MFA is off", and voiding the codes would be a surprise in the direction of locking somebody out.
Requires fresh authentication at the route.
Removes one factor only if it belongs to account_id, which is what a "remove this
device" button in a user's own settings needs.
Answers false for a factor that belongs to somebody else and for one that does not
exist — the same answer, so a caller cannot use the difference to discover whether an id
is real.
allow_last defaults to false here and to true in the administrative form, and the
asymmetry is the point: removing a person's only remaining second factor turns MFA off
for their account, which is a different intent from unregistering a device they still
have two of. A settings screen that means the first says so, and then gets the recovery
codes voided with it.
Checks a code against every confirmed factor on the account.
The attempt is counted before any code is checked, for the reason RateLimiter
gives: counting afterwards means a wrong guess that times out is a free guess.
Every confirmed factor is tried, because a person with two devices should be able to use either, and only the factor that actually matched has its counter spent.