Multi-factor authentication (TOTP)
Applies to deployments running with KEYCLOAK_ENABLED=false, which is the deployed profile.
What it is
A time-based one-time password (RFC 6238) from any standard authenticator app — Google Authenticator, Authy, 1Password, Microsoft Authenticator. Before this there was no second factor of any kind, and account lockout was the only thing standing between a stolen password and a complete compromise of a clinical record system.
Where the challenge sits
POST /api/v1/auth/login password correct, account HAS MFA
-> { "mfaRequired": true, "mfaChallengeToken": "<opaque>" } ← no tokens at all
POST /api/v1/auth/mfa/verify { challengeToken, code | recoveryCode }
-> { accessToken, refreshToken, ... } ← the session, at last
⚠ A correct password alone buys nothing — not even the unscoped access token that can list the tenants and facilities an account may enter. The challenge sits before any token exists, not between the token and its scope.
⚠ The challenge token is opaque and random, never a JWT. LocalJwtAuthenticationFilter
authenticates any token it can decode, so a signed "challenge" token would be a fully usable access
token. It is stored hashed, is single-use, and expires with MFA_CHALLENGE_TTL.
An account without MFA signs in exactly as before: one call, tokens returned.
Enrolment
| Endpoint | Purpose |
|---|---|
POST /api/v1/auth/mfa/enrol/start | Returns the Base32 secret and an otpauth:// URI to render as a QR code |
POST /api/v1/auth/mfa/enrol/confirm | Confirms with a live code, switches MFA on, returns recovery codes |
GET /api/v1/auth/mfa/status | Whether the account is enrolled, whether policy obliges it, codes remaining |
POST /api/v1/auth/mfa/disable | Removes the second factor. Requires a current code |
POST /api/v1/auth/mfa/recovery-codes | Reissues recovery codes, invalidating the old ones. Requires a current code |
POST /api/v1/auth/mfa/step-up | Re-proves the factor for a high-risk action |
⚠ Starting enrolment does not switch MFA on. The secret is stored, but the account is not enrolled until a real code proves the secret reached the phone. Otherwise a user who scanned nothing, or scanned it wrong, would be locked out of their own account by their own settings screen.
⚠ Disabling requires a current code, because turning MFA off is exactly what an attacker holding a stolen session would do next.
Recovery codes
Ten single-use codes, issued at enrolment, shown once. Only their SHA-256 hashes are stored, so
they cannot be shown again. A redemption raises an MFA_RECOVERY_CODE_USED security event and the row
is marked spent rather than deleted, so it stays answerable afterwards.
Without them the only route back into an enrolled account whose phone is lost is an administrator disabling MFA, which is both a support burden and a social-engineering target.
The two controls that make it real
Replay. RFC 6238 accepts a code for a whole 30-second step, so a code is not naturally single-use.
The accepted step is recorded on the account (mfa_last_time_step) and any code from that step or
earlier is refused. ⚠ It is a column, not a cache, because the platform runs several replicas and
an in-memory guard would let the same intercepted code be replayed against a different pod.
Brute force. A six-digit code is 10^6 guesses. POST /api/v1/auth/mfa/verify is registered with
M33-002's per-IP limiter — see auth-rate-limiting.md. ⚠ Without that entry the second factor is
minutes of brute force and the control is decorative. Separately, each challenge tolerates
MFA_MAX_ATTEMPTS_PER_CHALLENGE wrong codes and is then burned.
⚠ Wrong codes are counted on the challenge, never on the account's lockout counter. Counting them against the password lockout would let anyone disable any account by repeatedly failing a second factor they cannot reach.
Policy: mandatory or opt-in
| Setting | Effect |
|---|---|
MFA_REQUIRED_FOR_ALL_USERS | true obliges every account to enrol |
MFA_REQUIRED_ROLES | Comma-separated role codes obliged to enrol, when the above is false |
Both default to off, so MFA is opt-in: today's behaviour plus the ability to enrol.
⚠⚠ Turning either on locks out every user who has not enrolled yet. Do not do it before the
enrolment screen has shipped and users have been given time and notice to use it. The backend reports
required on GET /mfa/status so a frontend can prompt; enforcing enrolment as an interstitial is a
frontend change (FE-364).
⚠ Keycloak mode has no MFA, and says so
KeycloakAuthService.completeMfaLogin refuses rather than pretending. Keycloak mode authenticates
through the resource-owner password grant, which has no place to interpose a second factor: the token
comes back already usable, so there is no authenticated-but-not-yet-entitled moment for a challenge to
occupy. Doing it properly means moving to authorization-code + PKCE and letting Keycloak run its own
OTP flow — a change that reaches into the frontend auth flow.
The deployed profile runs KEYCLOAK_ENABLED: "false", so the mode that matters is covered. What must
not happen is MFA appearing to be on while a Keycloak deployment quietly has none, which is why that
path throws instead of shrugging.
Security events
MFA_ENROLLED, MFA_DISABLED, MFA_RECOVERY_CODE_USED. ⚠ MFA_DISABLED is the most interesting of
the three: it is indistinguishable from a user replacing a phone unless somebody can see it happened.