Repository navigation
feat(auth): TOTP two-factor authentication for doctor, facility and admin roles - #142
Oyinkans0la12 wants to merge 7 commits into
Conversation
…dmin roles Adds enrollment (POST /auth/2fa/setup -> otpauth URI, then POST /auth/2fa/enable with a first code), two-step login (POST /auth/2fa/verify), 10 single-use recovery codes, disable, and a status endpoint. TOTP is RFC 6238 implemented on node:crypto rather than a new dependency -- avoiding a provider dependency is why TOTP was chosen over SMS, and the algorithm is pinned by the RFC's own published test vectors. The shared secret is AES-256-GCM encrypted at rest with TOTP_ENCRYPTION_KEY. An enrolled user's password step returns a short-lived mfa_pending token instead of a session. It is signed with the access-token secret but carries scope: 'mfa_pending', and verifyAccessToken refuses any scoped token, so it is accepted by exactly one route and by no protected route (tested in both directions). Replay is rejected by storing the highest spent time step per user; recovery codes are bcrypt-hashed and single-use; disabling requires proof of possession. REQUIRE_2FA_FOR_ADMIN makes 2FA mandatory for admin logins; its bootstrap caveat is flagged in ARCHITECTURE_ESSENTIALS.md rather than papered over. Docs updated in the same change: ARCHITECTURE.md §4.9/§4.10, ARCHITECTURE_ESSENTIALS.md, docs/api/README.md, ROADMAP.md. Refs DocForum#36
|
@Oyinkans0la12 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
|
CI is failing on this PR.
Please push a fix and CI will re-run. Before pushing, run |
|
CI fails at |
|
@Oyinkans0la12 Thanks for the PR. It can't be merged yet. To unblock it: CI is failing. Here's what broke:
Please fix these errors and run the same steps locally before pushing ( Merge conflicts: this branch conflicts with Push to this same branch and CI will re-run. Once it's green and conflict-free, it'll be reviewed for merge. |
The main merge resolution dropped the TwoFactorAuth/TwoFactorRecoveryCode models, the twoFactor config block, the /auth/2fa routes, validation schemas and the mfa_pending login path, so the Prisma client could not be generated and the login service had no second-step handling.
|
@Oyinkans0la12 Thanks for rebasing, there are no conflicts now. The backend CI failure ( Every integration test file starts its own Postgres on a fixed Ports already taken on |
|
@Oyinkans0la12 This PR now has merge conflicts with git fetch upstream # upstream = https://github.com/DocForum/docforum-core.git
git rebase upstream/main
# resolve the conflicts, re-run the tests, then:
git push --force-with-leaseThe usual culprits are shared files like Automated message. It won't repeat unless the PR conflicts again after your next push. |
|
@Oyinkans0la12 CI is failing on this branch (after it was updated with the latest The two-factor repository functions that |
The merge with main kept the tests but lost the TwoFactorAuth / TwoFactorRecoveryCode models, the repository functions, the /auth/2fa routes and controllers, the mfa_pending login branch in the service, and the `scope` claim on AccessTokenPayload — so typecheck failed on unused schema imports, missing repository exports and the undeclared `scope` field, and the Prisma client had no twoFactorAuth/twoFactorRecoveryCode delegates for the integration test to use. Also move two-factor.test.mts off port 5437: that is csrf.test.mts's port, and vitest runs integration files in parallel, each with its own embedded Postgres. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
|
@Oyinkans0la12 This PR now has merge conflicts with git fetch upstream # upstream = https://github.com/DocForum/docforum-core.git
git rebase upstream/main
# resolve the conflicts, re-run the tests, then:
git push --force-with-leaseThe usual culprits are shared files like Automated message. It won't repeat unless the PR conflicts again after your next push. |
What changed
Implements TOTP two-factor authentication from #36: enrollment
(
POST /auth/2fa/setup→POST /auth/2fa/enable), two-step login(
POST /auth/2fa/verify), 10 single-use recovery codes,POST /auth/2fa/disable,GET /auth/2fa, and theREQUIRE_2FA_FOR_ADMINconfig flag.Closes #36
Why
Doctor, facility and admin accounts can read clinical data or move escrowed
funds; a password alone is a weak control for those roles. TOTP adds a second
factor with no SMS/email provider dependency, so it ships while #17 is still
undecided.
Implementation notes
node:crypto(
auth/services/totp.ts) — avoiding a provider/package dependency is thewhole reason TOTP was chosen over SMS. Correctness is pinned by the RFC's own
published test vectors (SHA-1, secret
12345678901234567890), not byself-consistent expectations. No
ARCHITECTURE.md§2 stack change to record.secretEncryptedis AES-256-GCM(
iv.tag.ciphertext, key derived fromTOTP_ENCRYPTION_KEY) and is neverreturned by the API after the enrollment URI. Missing key ⇒ a loud, named
error rather than a plaintext fallback.
mfa_pendingtoken can't reach anything else. The password step foran enrolled user returns no session at all — just a 5-minute token signed
with the access-token secret but carrying
scope: 'mfa_pending'.verifyAccessTokenrejects any token with ascope, so every authenticatedroute refuses it;
verifyMfaPendingTokenaccepts only that claim, and onlyPOST /auth/2fa/verifycalls it. Tested in both directions (anmfa_pendingtoken on/doctors/meand/auth/2fa/setup→ 401; a realaccess token offered to
/auth/2fa/verify→ 401).(
lastUsedTimeStep); a code at or below it is rejected even inside the ±1window. Integration-tested: the same code succeeds once, then 401.
sampling, displayed exactly once, stored bcrypt-hashed, marked used on first
success (tested: reuse → 401, next code → 200).
code, not just a password-authenticated session.
REQUIRE_2FA_FOR_ADMINenforces a second factor for admin logins(checked after the password is verified, so it isn't an admin-account
enumerator). Default
false.Schema change (proposed)
TwoFactorAuth:id, userId (unique), secretEncrypted, enabledAt (nullable), lastUsedTimeStep (nullable), createdAt, updatedAt— ARCHITECTURE.md §4.9.TwoFactorRecoveryCode:id, twoFactorAuthId, codeHash, usedAt, createdAt—§4.9.
Migration:
backend/prisma/migrations/20261015182841_add_two_factor_auth/generated with
scripts/gen-migration.mjs(real Postgres diff).New env var
TOTP_ENCRYPTION_KEY(documented in.env.example), also added tovitest.config.mts's test env.Assumptions / gaps flagged, not silently resolved
REQUIRE_2FA_FOR_ADMIN=true, an admin whohas not enrolled cannot log in to reach
/auth/2fa/setup, so the flag shouldonly be switched on after admins have enrolled (or once admin-invited
provisioning / a one-time enrollment token exists). Recorded in
ARCHITECTURE_ESSENTIALS.mdknown gaps.as a known gap.
mfa_pendingtoken stays usable until its 5-minute expiry after asuccessful verify; replay protection already makes a code single-use, so
this isn't a second-factor bypass, but there's no token-revocation store yet
(that's [Phase 8] Session management: list and revoke active sessions per user #38/[Phase 8] JWT refresh-token revocation #18 territory).
Not specified in the issue, flagged rather than invented differently.
Cross-PR note
auth/services/tokens.tsandARCHITECTURE.md§4 are also touched by #140(the still-open password reset PR). Whichever lands second needs a trivial
rebase/numbering fix — called out here so it isn't a surprise.
Docs updated in the same PR
ARCHITECTURE.md(§4.9 models, §4.10 token scopes),ARCHITECTURE_ESSENTIALS.md(data model map + two new known gaps),
docs/api/README.md(all five endpointsROADMAP.md(Phase 8 item + changelog).Tests
npm run typecheckandnpm run buildclean.npm run test→ 36 passed.npm run test:integration→ 11 passed (1 existing booking + 10 new).Unit: RFC 6238 test vectors at six timestamps, base32 round-trip/rejection,
±1 window acceptance and ±2 rejection, replay rejection, otpauth URI params,
recovery-code normalization, AES-GCM round-trip / unique IV / tamper detection.
Integration (
tests/integration/two-factor.test.mts, real Postgres): the flowabove end to end, plus enrollment-doesn't-enforce-until-confirmed, no cookie or
access token on the first login step, recovery-code single-use, disable →
single-step login again, and the admin flag (403 for an unenrolled admin, while
a non-admin still logs in — the flag is role-scoped).