feat(#557): TOTP two-factor authentication for admin accounts - #601
Conversation
…counts Admin accounts hold elevated privileges (payroll execution, employee management, org settings), so they can now be protected with a TOTP second factor. Enrolment is a two-step handshake: setup parks the secret in totp_pending_secret and returns a QR code, and only a verified code promotes it and enables 2FA — an abandoned setup leaves the account untouched. Confirming enrolment issues exactly 8 recovery codes, shown once and stored only as single-use hashes. Login no longer issues a session for an account with 2FA enabled. It returns a short-lived challenge token that is exchanged for a session only alongside a valid TOTP or recovery code, and the OAuth callbacks follow the same rule so a social sign-in cannot sidestep the second factor. Challenge tokens carry a `typ` claim and authenticateJWT rejects anything that is not an access token, so a challenge cannot be replayed against the rest of the API. Hardening along the way: - TOTP secrets are encrypted at rest (AES-256-GCM) rather than stored in plaintext, keyed by TWO_FACTOR_ENCRYPTION_KEY. - Accepted TOTP codes are burned via totp_last_used_step, closing the replay window a code otherwise has for the rest of its period. - Recovery codes are redeemed by a guarded UPDATE, so a code cannot be used twice even under concurrent requests. - Five consecutive failures lock verification for 15 minutes. - The 2FA endpoints now require authentication and an admin role, and take the account from the verified JWT instead of a request body, so nobody can enrol or disable 2FA on someone else's account. - require2FA keys off the authenticated user rather than a client-supplied wallet header, and consumes the code it checks. - Disabling refuses recovery codes: a leaked recovery code should let its owner back in, not let anyone strip the second factor. Adds a Settings > Two-Factor Authentication page covering enable, QR scan, recovery-code display, and disable, plus a code prompt on the auth callback, in both locales. 52 tests cover enrolment, verification, recovery-code use, login with 2FA enabled, disable, lockout, and the step-up middleware.
|
Really nice work on this PR! @balisdev The implementation goes beyond just adding the basic TOTP flow — the encryption of secrets at rest, recovery-code hashing/single-use handling, replay protection, lockout mechanism, challenge-token separation, and the OAuth 2FA handling are all great security considerations. The 52 tests covering the main flows are also a strong addition. Before we merge, could you please add a little more implementation evidence to the PR? FrontendSince this introduces a new user-facing 2FA experience, please add a few screenshots or, preferably, a short screen recording showing:
Please use test accounts and make sure no real TOTP secrets or recovery codes are exposed. BackendFor the backend, screenshots aren't necessary. The existing automated tests are much stronger evidence. It would be helpful if you could point to the relevant tests (or add any missing ones) demonstrating the key security properties, particularly:
The implementation looks solid from the PR description and the existing test coverage. Adding this evidence would make it much easier for reviewers to verify the frontend experience and the security-related acceptance criteria before merging. |
…ve the guarantees Running the flow against a real PostgreSQL instance surfaced a bug the mocked tests could not: the brute-force lockout never engaged. two_factor_locked_until was a bare TIMESTAMP. Postgres stores it in UTC, but node-pg parses a TIMESTAMP WITHOUT TIME ZONE as *local* time, so on a host east of UTC the value came back an hour or more in the past. The JS-side comparison in assertNotLocked then decided the cool-off had already elapsed and let every attempt through. Verified on a UTC+1 host: the counter reached 6 and the lock timestamp was set, yet a valid code was still accepted after five consecutive failures. Fixed on both sides: - The new timestamp columns are TIMESTAMPTZ, so the instant is unambiguous regardless of server timezone. - The lock decision is computed by Postgres against its own clock (`two_factor_locked_until > NOW() AS is_locked`) rather than by comparing timestamps in Node, so it cannot depend on client parsing again. The timestamp is now used only for the "try again in N seconds" message. Tests added for the properties this PR claims, including the ones that only appear across a sequence of calls: - lockout engages on the fifth consecutive failure, and a success clears the counter; - a recovery code and a TOTP code are each accepted exactly once across 10 concurrent verifications; - the lockout holds even when the stored timestamp reads as past locally — the regression that encodes the bug above; - loadUser asks the database for the lock decision; - OAuth login cannot bypass 2FA, for both the Google and GitHub callbacks. The behavioural tests run against an in-memory stand-in that applies the same guards as the service's SQL; the companion assertions pin those guard clauses to the SQL text, so dropping one fails there. 63 tests, all passing.
|
Thanks for the careful review — the request for evidence was well worth making. Standing up the real stack to capture it surfaced a security bug that the mocked tests could not catch, so this reply comes with a fix ( The bug the evidence foundThe brute-force lockout never engaged.
Any deployment whose app server is not on UTC would have shipped with no brute-force protection at all. Fixed on both sides:
Re-run after the fix — all four properties pass: Backend — security properties → testsTest count is now 63 (was 52). Everything below is an automated test, not a manual check.
The three marked in bold/italics above were missing and are new in One note on method, so you can weigh it: the concurrency and lockout tests run against a small in-memory stand-in that applies the same guards as the service's SQL. A stand-in models Postgres rather than proving its behaviour, so it is paired with assertions that pin the guard clauses ( Confirmed directly in the database after enrolment: FrontendCaptured from the real UI driving the real controller against real Postgres — no stubbed API calls. Settings → Two-Factor Authentication QR code and manual setup key Successful verification and recovery-code display (exactly 8) Login showing the 2FA challenge — Successful authentication with a TOTP code …and with a recovery code, after which the count drops to 7 Disable flow — a recovery code is refused here by design, a current TOTP works Error states — invalid code at enrolment, at login, and at disable On your note about exposure: everything visible — secret, QR, recovery codes — belongs to a throwaway Two unrelated things I hit while standing this upNeither is touched by this PR — flagging them rather than scope-creeping:
Worth knowing too: most of the backend Jest suite fails on |
|
Thank you for contributing to this project @balisdev |













Closes #557
Implements TOTP-based two-factor authentication for admin accounts, using the
@otplib/preset-defaultandqrcodedependencies that were already installed.Acceptance criteria
frontend/src/pages/TwoFactorSettings.tsx), backed byPOST /api/auth/2fa/setup+/verifyPOST /api/auth/2fa/setupreturns adata:image/pngQR plus the otpauth URL and a manual-entry keyPOST /api/auth/loginreturns a challenge instead of a session;POST /api/auth/2fa/authenticatecompletes itPOST /api/auth/2fa/verifyreturns exactly 8, shown oncePOST /api/auth/2fa/disable, which rejects recovery codesWhat changed
Enrolment is a two-step handshake. Setup parks the secret in
totp_pending_secretand returns the QR code; only a verified code promotes it tototp_secretand flipsis_2fa_enabled. An abandoned setup therefore cannot leave an account half-enrolled. Confirming enrolment issues the 8 recovery codes — shown once, stored only as hashes.Login genuinely requires the second factor.
/loginno longer issues tokens for an account with 2FA on; it returns a 5-minute challenge token that is exchanged for a session only alongside a valid TOTP or recovery code. The Google/GitHub callbacks follow the same rule, so a social sign-in cannot sidestep 2FA. Challenge tokens are signed with the same secret as access tokens, so both now carry atypclaim andauthenticateJWTrejects anything that is not an access token — otherwise a challenge token would have been usable against the rest of the API.Security hardening on the first-pass implementation that was in the tree:
UPDATE … WHERE used_at IS NULL, so a code cannot be redeemed twice even under concurrency.totp_last_used_step, closing the replay window a code otherwise keeps for the rest of its 30-second period.walletAddresscame from the body on unauthenticated routes, so anyone could enrol or disable 2FA on any account.require2FA(SEP-31 / SEP-24 step-up) keys off the authenticated user instead of a client-suppliedx-user-walletheader, and consumes the code it checks.INVALID_CODE; no secret, recovery code, or submitted code is ever logged.API
POST/api/auth/2fa/setupPOST/api/auth/2fa/verifyPOST/api/auth/2fa/disableGET/api/auth/2fa/statusPOST/api/auth/2fa/authenticateMigration
029_admin_two_factor_auth.sqlwidens theusers.roleCHECK constraint to includeADMIN(it previously rejected the role the code already modelled), adds the pending-secret / replay / lockout columns, createsuser_recovery_codes, and drops the plaintextusers.recovery_codesarray. Any pre-existing plaintexttotp_secretis cleared, since those predate encryption at rest — affected admins re-run setup.Tests
52 tests across
twoFactorService, the auth endpoints, and the step-up middleware — covering enrolment, valid/invalid verification, TOTP and recovery-code replay, login with 2FA enabled, challenge-token misuse, disable, lockout, and role/auth gating. All pass.The rest of the backend suite is unchanged from
main(much of it fails at baseline under ESM withjest is not defined; the three suites here use@jest/globalsand pass). Frontendlint,prettier --check,build, and the Playwright E2E suite all pass locally.Docs:
docs/TWO_FACTOR_AUTH.md.