Skip to main content

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

EndpointPurpose
POST /api/v1/auth/mfa/enrol/startReturns the Base32 secret and an otpauth:// URI to render as a QR code
POST /api/v1/auth/mfa/enrol/confirmConfirms with a live code, switches MFA on, returns recovery codes
GET /api/v1/auth/mfa/statusWhether the account is enrolled, whether policy obliges it, codes remaining
POST /api/v1/auth/mfa/disableRemoves the second factor. Requires a current code
POST /api/v1/auth/mfa/recovery-codesReissues recovery codes, invalidating the old ones. Requires a current code
POST /api/v1/auth/mfa/step-upRe-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

SettingEffect
MFA_REQUIRED_FOR_ALL_USERStrue obliges every account to enrol
MFA_REQUIRED_ROLESComma-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.