Skip to content

feat(#557): TOTP two-factor authentication for admin accounts - #601

Merged
Wilfred007 merged 2 commits into
Protocol-Guild:mainfrom
balisdev:feat/557-admin-2fa
Aug 23, 2026
Merged

feat(#557): TOTP two-factor authentication for admin accounts#601
Wilfred007 merged 2 commits into
Protocol-Guild:mainfrom
balisdev:feat/557-admin-2fa

Conversation

@balisdev

Copy link
Copy Markdown
Contributor

Closes #557

Implements TOTP-based two-factor authentication for admin accounts, using the @otplib/preset-default and qrcode dependencies that were already installed.

Acceptance criteria

Criterion Where
Admin can enable 2FA from settings Settings → Two-Factor Authentication (frontend/src/pages/TwoFactorSettings.tsx), backed by POST /api/auth/2fa/setup + /verify
QR code generated for authenticator app POST /api/auth/2fa/setup returns a data:image/png QR plus the otpauth URL and a manual-entry key
Login requires TOTP code when 2FA enabled POST /api/auth/login returns a challenge instead of a session; POST /api/auth/2fa/authenticate completes it
8 recovery codes generated POST /api/auth/2fa/verify returns exactly 8, shown once
2FA can be disabled with current TOTP code POST /api/auth/2fa/disable, which rejects recovery codes

What changed

Enrolment is a two-step handshake. Setup parks the secret in totp_pending_secret and returns the QR code; only a verified code promotes it to totp_secret and flips is_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. /login no 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 a typ claim and authenticateJWT rejects 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:

  • TOTP secrets are encrypted at rest (AES-256-GCM) instead of stored in plaintext.
  • Recovery codes are 8 (was 10), hashed, and single-use — redeemed by an UPDATE … WHERE used_at IS NULL, so a code cannot be redeemed twice even under concurrency.
  • Accepted TOTP codes are burned via totp_last_used_step, closing the replay window a code otherwise keeps for the rest of its 30-second period.
  • Brute-force lockout: 5 consecutive failures lock verification for 15 minutes; an expired lockout restarts the count rather than leaving the account one mistake from the next.
  • The 2FA endpoints now require authentication and an admin role and take the account from the verified JWT rather than a request body. Previously walletAddress came 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-supplied x-user-wallet header, and consumes the code it checks.
  • Disabling deliberately refuses recovery codes: a leaked recovery code should let its owner back in, not let anyone strip the second factor off the account.
  • Failures report a generic INVALID_CODE; no secret, recovery code, or submitted code is ever logged.

API

Method Path Auth Purpose
POST /api/auth/2fa/setup JWT + admin role Start enrolment; returns QR code + otpauth URL
POST /api/auth/2fa/verify JWT + admin role Confirm code, enable 2FA, return 8 recovery codes
POST /api/auth/2fa/disable JWT + admin role Disable; requires a current TOTP code
GET /api/auth/2fa/status JWT Enrolment state + unused recovery-code count
POST /api/auth/2fa/authenticate challenge token Second step of login

Migration

029_admin_two_factor_auth.sql widens the users.role CHECK constraint to include ADMIN (it previously rejected the role the code already modelled), adds the pending-secret / replay / lockout columns, creates user_recovery_codes, and drops the plaintext users.recovery_codes array. Any pre-existing plaintext totp_secret is 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.

Test Suites: 3 passed, 3 total
Tests:       52 passed, 52 total

The rest of the backend suite is unchanged from main (much of it fails at baseline under ESM with jest is not defined; the three suites here use @jest/globals and pass). Frontend lint, prettier --check, build, and the Playwright E2E suite all pass locally.

Docs: docs/TWO_FACTOR_AUTH.md.

…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.
@Wilfred007

Copy link
Copy Markdown
Contributor

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?

Frontend

Since this introduces a new user-facing 2FA experience, please add a few screenshots or, preferably, a short screen recording showing:

  • Settings → Two-Factor Authentication
  • QR code/manual setup
  • Successful verification and recovery-code display
  • Login showing the 2FA challenge
  • Successful authentication with a TOTP/recovery code
  • Disable-2FA flow
  • Relevant error states

Please use test accounts and make sure no real TOTP secrets or recovery codes are exposed.

Backend

For 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:

  • 2FA endpoints require authentication + admin authorization.
  • Challenge tokens cannot be used as normal access tokens.
  • TOTP codes cannot be replayed.
  • Recovery codes are single-use, including concurrent requests.
  • Five failed attempts trigger the lockout.
  • Disabling 2FA requires a current TOTP and rejects recovery codes.
  • OAuth login cannot bypass 2FA.
  • TOTP secrets are encrypted at rest.
  • Recovery codes are stored as hashes rather than plaintext.

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.
@balisdev

Copy link
Copy Markdown
Contributor Author

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 (3762c5b) as well as the evidence.

The bug the evidence found

The brute-force lockout never engaged.

two_factor_locked_until was a bare TIMESTAMP. Postgres stores that in UTC, but node-pg parses TIMESTAMP WITHOUT TIME ZONE as local time, so on a host east of UTC the value came back in the past. assertNotLocked compared it in JS, concluded the cool-off had elapsed, and let every attempt through. On my UTC+1 machine:

attempt 1: 401 INVALID_CODE      db: two_factor_failed_attempts = 6
attempt 2: 401 INVALID_CODE          two_factor_locked_until    = 2026-08-23 19:53:58
...
D. valid code after 5 failures -> 200          <-- should have been 429
value as JS Date : Sun Aug 23 2026 19:53:58 GMT+0100
lock in future?  : false          <-- the lockout silently disabled

Any deployment whose app server is not on UTC would have shipped with no brute-force protection at all. Fixed on both sides:

  • the new timestamp columns are TIMESTAMPTZ, so the instant is unambiguous;
  • the lock decision is now computed by Postgres against its own clock (two_factor_locked_until > NOW() AS is_locked) instead of in Node, so it cannot regress on client-side parsing again. The timestamp is only used for the "try again in N seconds" message.

Re-run after the fix — all four properties pass:

A. recovery code x10 concurrent -> 1 accepted, 9 rejected  PASS
B. TOTP code x10 concurrent      -> 1 accepted, 9 rejected  PASS
C. replay of accepted TOTP       -> 401 INVALID_CODE        PASS
D. valid code after 5 failures   -> 429 TWO_FACTOR_LOCKED   PASS

Backend — security properties → tests

Test count is now 63 (was 52). Everything below is an automated test, not a manual check.

Property Test
2FA endpoints require authentication authController.test.tsrejects unauthenticated callers (setup + disable)
…and admin authorization authController.test.tsrejects non-privileged roles
Challenge tokens are not access tokens authController.test.tsrejects a 2FA challenge token used as an access token
…and vice versa authController.test.tsrejects an access token presented as a challenge
TOTP codes cannot be replayed twoFactorService.test.tsrejects a TOTP code that was already spent; require2fa.test.tsrejects a code that was already spent, so a header cannot be replayed
Recovery codes are single-use twoFactorService.test.tsrejects a recovery code that was already redeemed
including concurrent requests twoFactorService.stateful.test.tsaccepts a recovery code exactly once across concurrent requests (and the TOTP equivalent)
Five failed attempts trigger lockout twoFactorService.stateful.test.tslocks verification on the fifth consecutive failure
…and a success resets the counter twoFactorService.stateful.test.tsclears the failure count after a successful verification
…and the lockout is timezone-proof twoFactorService.test.tsstays locked even when the stored timestamp reads as past locally + asks the database whether the account is locked
Disabling requires a current TOTP twoFactorService.test.tsrefuses to disable without a valid current TOTP code
…and rejects recovery codes twoFactorService.test.tsdoes not accept a recovery code in place of a TOTP code
OAuth login cannot bypass 2FA authController.test.tscannot be used to bypass 2FA — issues a challenge instead of a session + applies the same rule to the GitHub callback
TOTP secrets encrypted at rest twoFactorService.test.tsencrypts the secret before it reaches the database
Recovery codes stored as hashes twoFactorService.test.tsnever stores a recovery code in a recoverable form; statefulstores only hashes, so the issued codes never appear in the table

The three marked in bold/italics above were missing and are new in 3762c5b, along with the timezone regression tests.

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 (used_at IS NULL, totp_last_used_step < $2, > NOW() AS is_locked) to the SQL text — dropping a guard fails there — and with the real-Postgres run above.

Confirmed directly in the database after enrolment:

 role  | is_2fa_enabled |         totp_secret_prefix         | totp_pending_secret | totp_last_used_step
-------+----------------+------------------------------------+---------------------+---------------------
 ADMIN | t              | v1.QMIXTqLvT6WAEVR1.zMYjHA4DDUMYnj |                     |            59583754

 8 rows in user_recovery_codes, every code_hash a SHA-256 digest, used_at NULL

Frontend

Captured from the real UI driving the real controller against real Postgres — no stubbed API calls.

Settings → Two-Factor Authentication

Settings entry
Status off

QR code and manual setup key

QR and manual key

Successful verification and recovery-code display (exactly 8)

Recovery codes

Login showing the 2FA challengePOST /login returned {"requires2fa":true,"accessToken":null}; no session is issued

Login challenge

Successful authentication with a TOTP code

Login success TOTP

…and with a recovery code, after which the count drops to 7

Recovery code entry
Seven codes left

Disable flow — a recovery code is refused here by design, a current TOTP works

Disable rejects recovery code
Disabled

Error states — invalid code at enrolment, at login, and at disable

Enrol invalid
Login invalid
Disable invalid

On your note about exposure: everything visible — secret, QR, recovery codes — belongs to a throwaway admin@example.test account in a disposable Docker Postgres container that was destroyed after capture. No production or real user credentials appear. The images live on a separate evidence/557-2fa-screenshots branch on my fork rather than in this PR, so the diff stays clean.

Two unrelated things I hit while standing this up

Neither is touched by this PR — flagging them rather than scope-creeping:

  1. backend/src/services/tenantConfigService.ts does not parse. A stray } at line 199 closes the class early, orphaning getRateLimitOverrides / setRateLimitOverrides. tsc --noEmit reports 15 syntax errors and the backend will not boot. Happy to send a one-line PR.
  2. 004_tenant_configurations.sql fails to applysyntax error at or near "desc" (reserved word), which stops the migration chain at 004. I applied the auth chain manually to test 029.

Worth knowing too: most of the backend Jest suite fails on main under ESM with jest is not defined. The suites here import from @jest/globals and pass; the overall failure count went down, not up.

@Wilfred007
Wilfred007 merged commit c45bb6d into Protocol-Guild:main Aug 23, 2026
2 checks passed
@grantfox-oss grantfox-oss Bot mentioned this pull request Aug 23, 2026
5 tasks
@Wilfred007

Copy link
Copy Markdown
Contributor

Thank you for contributing to this project @balisdev

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add 2FA support for admin accounts

2 participants