From b80deb480d93f25adb6657509db4a09050709179 Mon Sep 17 00:00:00 2001 From: Oyinkans0la12 <147610565+Oyinkans0la12@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:15:22 +0000 Subject: [PATCH 1/3] feat(auth): TOTP two-factor authentication for doctor, facility and admin roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #36 --- ARCHITECTURE.md | 36 +++ ARCHITECTURE_ESSENTIALS.md | 5 +- ROADMAP.md | 15 +- backend/.env.example | 8 + .../migration.sql | 36 +++ backend/prisma/schema.prisma | 40 +++ backend/src/config/index.ts | 18 ++ backend/src/modules/auth/controllers/index.ts | 59 +++- .../src/modules/auth/repositories/index.ts | 56 ++++ backend/src/modules/auth/routes/index.ts | 11 + backend/src/modules/auth/services/index.ts | 76 ++++- .../modules/auth/services/secret-crypto.ts | 43 +++ backend/src/modules/auth/services/tokens.ts | 30 +- backend/src/modules/auth/services/totp.ts | 127 ++++++++ .../src/modules/auth/services/two-factor.ts | 201 +++++++++++++ backend/src/modules/auth/validation.ts | 12 + backend/tests/integration/two-factor.test.mts | 273 ++++++++++++++++++ backend/tests/unit/secret-crypto.test.ts | 33 +++ backend/tests/unit/totp.test.ts | 124 ++++++++ backend/vitest.config.mts | 3 + docs/api/README.md | 33 ++- 21 files changed, 1230 insertions(+), 9 deletions(-) create mode 100644 backend/prisma/migrations/20261015180232_add_two_factor_auth/migration.sql create mode 100644 backend/src/modules/auth/services/secret-crypto.ts create mode 100644 backend/src/modules/auth/services/totp.ts create mode 100644 backend/src/modules/auth/services/two-factor.ts create mode 100644 backend/tests/integration/two-factor.test.mts create mode 100644 backend/tests/unit/secret-crypto.test.ts create mode 100644 backend/tests/unit/totp.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c98333db..f23d997f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -167,6 +167,42 @@ Notification id, user_id, type, payload (jsonb), read_at (nullable), created_at ``` +### 4.9 Auth hardening (Phase 8) +``` +TwoFactorAuth (issue #36) + id, user_id (FK -> User, unique, cascade) + secret_encrypted (AES-256-GCM with TOTP_ENCRYPTION_KEY — never returned + by the API after the enrollment URI) + enabled_at (nullable — null until a first code confirms enrollment; + an unconfirmed enrollment is NOT a second factor) + last_used_time_step (nullable bigint — highest TOTP step already spent; + this is the replay guard) + created_at, updated_at + +TwoFactorRecoveryCode (issue #36) + id, two_factor_auth_id (FK -> TwoFactorAuth, cascade) + code_hash (bcrypt, same as passwords), used_at (nullable), created_at +``` +Design notes: TOTP is RFC 6238 implemented on `node:crypto` +(`auth/services/totp.ts`), deliberately not a new dependency — the reason for +choosing TOTP over SMS in the first place was to avoid a provider dependency, +and the algorithm is pinned by the RFC's own test vectors in +`tests/unit/totp.test.ts`. Login becomes two-step for enrolled users: the +password step returns a short-lived `mfa_pending` token instead of a session. +That token is signed with the access-token secret but carries a +`scope: 'mfa_pending'` claim, and `verifyAccessToken` rejects anything with a +`scope` — so it is accepted by exactly one route (`POST /auth/2fa/verify`) and +by no authenticated route. `REQUIRE_2FA_FOR_ADMIN` makes a second factor +mandatory for admin logins; see ARCHITECTURE_ESSENTIALS.md for its bootstrap +caveat (an admin who has not enrolled cannot log in to enroll while it's on). + +### 4.10 Auth token scopes +Every token is one of: access (15 min), refresh (30 d, httpOnly cookie), or +`mfa_pending` (5 min, single-purpose). They are separated by *secret* and/or +*claim*, not by route convention — a refresh token fails verification where an +access token is expected and vice versa, and an `mfa_pending` token fails +`verifyAccessToken` on every protected route. + ## 5. Key flows (sequence-level, not code) ### 5.1 Booking (concurrency-safe) diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index 257f27bd..d2abc8c9 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -19,7 +19,8 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc 6. Every module = `routes/ controllers/ services/ repositories/`. Keep that shape. ## Data model map (see ARCHITECTURE.md §4 for full field lists) -`User` → `PatientProfile | DoctorProfile | FacilityProfile` +`User` → `PatientProfile | DoctorProfile | FacilityProfile` ; `User` → `TwoFactorAuth` —< `TwoFactorRecoveryCode` (TOTP, secret encrypted at rest, single-use recovery codes — Phase 8 #36) +Token kinds: access (15 min) / refresh (httpOnly cookie, 30 d) / `mfa_pending` (5 min, single purpose — accepted only by `POST /auth/2fa/verify`). `DoctorProfile` —< `DoctorSpecialty` >— `Specialty` `AvailabilitySlot` (doctor's calendar) → `Appointment` → `IntakeForm`, `Consultation` `Consultation` → (outcome=referred) → `Referral` (snapshot!) → new `Appointment` @@ -39,6 +40,8 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc - No expiry/TTL job for held slots or pending referrals — currently just an enum value with no worker. - Polymorphic `FulfillmentRecord.order_id` is a sketch, not final — may need splitting into two tables. - No `AuditEvent` table yet, despite PRD requiring auditability — needed before real patient data. +- `REQUIRE_2FA_FOR_ADMIN=true` makes 2FA mandatory for admin logins, but an admin who hasn't enrolled can't log in to reach `/auth/2fa/setup` — there is no bootstrap path (admin-invited provisioning or a one-time enrollment token). Turn that flag on only after admins have enrolled, until one exists. +- 2FA secrets are encrypted with one process-wide key (`TOTP_ENCRYPTION_KEY`) — no rotation, no per-user key. Fine for v1; needs a KMS/rotation story before real patient data. - One doctor = one calendar assumed; multi-facility doctors not modeled. ## Overengineering guardrails (things NOT to build yet) diff --git a/ROADMAP.md b/ROADMAP.md index bf51c734..c0b5dc99 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -107,7 +107,8 @@ for status and issue links. - [ ] ~~Shared: auth screens (signup/login per role).~~ → already built, `docforum-web` Phase W1. ## Phase 8 — Hardening (safety, audit, security) -**Status: Not started. Three of five items scoped into issues #18–#20 (independent of Phases 3–7); the other two remain blocked.** +**Status: In progress — TOTP 2FA (#36) landed, and two more auth-hardening issues opened since (#33 password reset, #38 session management). The other two original items remain blocked on Phases 3/4/5.** +- [x] TOTP two-factor authentication for doctor, facility and admin roles — enrollment (`POST /auth/2fa/setup` → otpauth URI → `POST /auth/2fa/enable` with a first code), two-step login (`POST /auth/2fa/verify` exchanges a short-lived `mfa_pending` token for a real session), 10 single-use bcrypt-hashed recovery codes, and `POST /auth/2fa/disable` requiring a current code. RFC 6238 implemented on `node:crypto` (no new dependency), pinned by the RFC's own test vectors; ±1 step window with per-user replay rejection; secret stored AES-256-GCM encrypted. Architected so an `mfa_pending` token is accepted by exactly one route. `REQUIRE_2FA_FOR_ADMIN` config flag enforces 2FA for admin logins (bootstrap caveat flagged in ARCHITECTURE_ESSENTIALS.md). Closes [issue #36](https://github.com/DocForum/docforum-core/issues/36). - [ ] `AuditEvent` table + write path for referral/prescription/lab-order state transitions (ARCHITECTURE.md §6.2.4 — flagged as needed before real patient data). Still blocked — needs the Phase 3/4 models (referral, prescription, lab order) to exist first. Not yet scoped into an issue. - [ ] JWT refresh revocation list (ARCHITECTURE.md §6.1.4). Tracked as [issue #18](https://github.com/DocForum/docforum-core/issues/18) (Medium, 150 pts) — unblocked now, operates on Phase 1's auth module. - [ ] Held-slot TTL/release job (ARCHITECTURE.md §6.2.1). Tracked as [issue #19](https://github.com/DocForum/docforum-core/issues/19) (Medium, 150 pts) — unblocked now, operates on Phase 1's `AvailabilitySlot`. @@ -341,3 +342,15 @@ for status and issue links. yet Accepted. Added to the docs site. - 2026-10-09 — Opened issue #32: upgrade to escrow SDK 0.3.0 and the current testnet contract. +- 2026-10-15 — Landed Phase 8's TOTP two-factor authentication (issue + #36): `POST /auth/2fa/{setup,enable,verify,disable}` plus `GET /auth/2fa`. + Enrollment returns an otpauth URI, then a first code confirms it and + issues 10 single-use recovery codes; login becomes two-step for enrolled + users, where the password step returns a short-lived `mfa_pending` token + that `verifyAccessToken` refuses everywhere except the verify endpoint. + TOTP is RFC 6238 on `node:crypto` (no new dependency) and is pinned to + the RFC's published test vectors; replay is rejected per user by storing + the highest spent time step. The TOTP secret is AES-256-GCM encrypted + with `TOTP_ENCRYPTION_KEY`; `REQUIRE_2FA_FOR_ADMIN` makes a second + factor mandatory for admin logins. ARCHITECTURE.md §4.9/§4.10, + ARCHITECTURE_ESSENTIALS.md and docs/api/README.md updated. diff --git a/backend/.env.example b/backend/.env.example index 03c9b5d1..6b6025da 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -4,6 +4,14 @@ JWT_REFRESH_SECRET=changeme PORT=4000 CORS_ORIGIN=http://localhost:5173 +# TOTP two-factor auth (Phase 8 #36). Encrypts the stored TOTP secret at +# rest (AES-256-GCM). Required only when 2FA endpoints are actually used; +# generate one with: openssl rand -base64 32 +TOTP_ENCRYPTION_KEY= +TOTP_ISSUER=DocForum +# "true" makes 2FA mandatory for admin logins (see ARCHITECTURE.md §4.9). +REQUIRE_2FA_FOR_ADMIN=false + # Payments (Phase 5.5 — custodial v1, see docs/adr/0004-custodial-payments-v1.md) # Testnet only (docforum-escrow hard rule 4) — real secret keys, never commit # a filled-in .env. Generate + fund your own with: diff --git a/backend/prisma/migrations/20261015180232_add_two_factor_auth/migration.sql b/backend/prisma/migrations/20261015180232_add_two_factor_auth/migration.sql new file mode 100644 index 00000000..5b4e2e62 --- /dev/null +++ b/backend/prisma/migrations/20261015180232_add_two_factor_auth/migration.sql @@ -0,0 +1,36 @@ +-- CreateTable +CREATE TABLE "TwoFactorAuth" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "secretEncrypted" TEXT NOT NULL, + "enabledAt" TIMESTAMP(3), + "lastUsedTimeStep" BIGINT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TwoFactorAuth_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TwoFactorRecoveryCode" ( + "id" TEXT NOT NULL, + "twoFactorAuthId" TEXT NOT NULL, + "codeHash" TEXT NOT NULL, + "usedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TwoFactorRecoveryCode_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "TwoFactorAuth_userId_key" ON "TwoFactorAuth"("userId"); + +-- CreateIndex +CREATE INDEX "TwoFactorRecoveryCode_twoFactorAuthId_idx" ON "TwoFactorRecoveryCode"("twoFactorAuthId"); + +-- AddForeignKey +ALTER TABLE "TwoFactorAuth" ADD CONSTRAINT "TwoFactorAuth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TwoFactorRecoveryCode" ADD CONSTRAINT "TwoFactorRecoveryCode_twoFactorAuthId_fkey" FOREIGN KEY ("twoFactorAuthId") REFERENCES "TwoFactorAuth"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index e0239b77..b33feb87 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -106,10 +106,50 @@ model User { patientProfile PatientProfile? doctorProfile DoctorProfile? facilityProfile FacilityProfile? + twoFactorAuth TwoFactorAuth? @@index([role]) } +// TOTP two-factor authentication (Phase 8 #36). One row per user who has +// started (or completed) enrollment. +model TwoFactorAuth { + id String @id @default(uuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + // The TOTP shared secret (RFC 6238), AES-256-GCM encrypted at rest with + // TOTP_ENCRYPTION_KEY. Never returned by the API after the initial + // enrollment URI — see auth/services/secret-crypto.ts. + secretEncrypted String + + // Null until POST /auth/2fa/enable confirms a first code. An enrollment + // in this state does NOT count as enabled (login stays single-step). + enabledAt DateTime? + + // Highest TOTP time step ever accepted for this user. A code whose step + // is <= this is a replay and is rejected, even inside the ±1 window. + lastUsedTimeStep BigInt? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + recoveryCodes TwoFactorRecoveryCode[] +} + +// Single-use recovery codes: 10 generated at enable time, stored hashed +// (bcrypt, same as passwords — the plaintext is shown once, never again). +model TwoFactorRecoveryCode { + id String @id @default(uuid()) + twoFactorAuthId String + twoFactorAuth TwoFactorAuth @relation(fields: [twoFactorAuthId], references: [id], onDelete: Cascade) + codeHash String + usedAt DateTime? + createdAt DateTime @default(now()) + + @@index([twoFactorAuthId]) +} + model PatientProfile { id String @id @default(uuid()) userId String @unique diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 3339a26f..20d07e0b 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -21,6 +21,24 @@ export const config = { refreshTtl: '30d', }, corsOrigin: process.env.CORS_ORIGIN ?? 'http://localhost:5173', + // TOTP two-factor auth (Phase 8 #36). The encryption key is read here but + // deliberately NOT required at boot (same reasoning as payments below) — + // the 2FA service itself fails loudly with a named error if it's missing + // when a secret actually has to be encrypted or decrypted. + twoFactor: { + encryptionKey: process.env.TOTP_ENCRYPTION_KEY, + issuer: process.env.TOTP_ISSUER ?? 'DocForum', + // Short-lived, single-purpose token handed back instead of a session + // when a password step succeeds but the TOTP step hasn't happened yet. + // Accepted only by POST /auth/2fa/verify. + mfaPendingTtl: '5m', + recoveryCodeCount: 10, + // When true, an admin account must have 2FA enabled to log in at all — + // enforced at login. Defaults to false so enabling it can't lock out an + // admin who hasn't enrolled yet without that being a deliberate ops + // decision; see the bootstrap caveat in ARCHITECTURE_ESSENTIALS.md. + requireForAdmin: process.env.REQUIRE_2FA_FOR_ADMIN === 'true', + }, // Deliberately NOT required at startup via requireEnv() — unrelated // parts of the app (auth, availability, etc.) shouldn't fail to boot // just because payments isn't configured. The payments module itself diff --git a/backend/src/modules/auth/controllers/index.ts b/backend/src/modules/auth/controllers/index.ts index ea1c07b2..dd4d8786 100644 --- a/backend/src/modules/auth/controllers/index.ts +++ b/backend/src/modules/auth/controllers/index.ts @@ -1,6 +1,6 @@ import type { Request, Response } from 'express'; import * as authService from '../services'; -import { loginSchema, signupSchema } from '../validation'; +import { loginSchema, signupSchema, twoFactorCodeSchema, twoFactorVerifySchema } from '../validation'; import { HttpError } from '../../../http-error'; const REFRESH_COOKIE = 'docforum_refresh'; @@ -46,8 +46,16 @@ export async function signup(req: Request, res: Response) { export async function login(req: Request, res: Response) { const input = loginSchema.parse(req.body); - const session = await authService.login(input); - sessionResponse(res, session); + const result = await authService.login(input); + + // Two-step login: no cookie, no access token yet — just a short-lived + // token that only POST /auth/2fa/verify accepts. + if ('mfaRequired' in result) { + res.status(200).json(result); + return; + } + + sessionResponse(res, result); } export async function refresh(req: Request, res: Response) { @@ -62,3 +70,48 @@ export function logout(_req: Request, res: Response) { res.clearCookie(REFRESH_COOKIE, REFRESH_COOKIE_OPTIONS); res.status(204).send(); } + +// --- TOTP two-factor auth (Phase 8 #36) --------------------------------- + +function requireUserId(req: Request): string { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + return req.user.id; +} + +/** Enrollment step 1: returns the otpauth URI (QR payload). Nothing is + * enforced until /auth/2fa/enable confirms a code. */ +export async function setupTwoFactor(req: Request, res: Response) { + const result = await authService.setupTwoFactor({ userId: requireUserId(req) }); + res.status(200).json(result); +} + +/** Enrollment step 2: confirms with a first code and returns the recovery + * codes — the only time their plaintext exists. */ +export async function enableTwoFactor(req: Request, res: Response) { + const { code } = twoFactorCodeSchema.parse(req.body); + const result = await authService.enableTwoFactor({ userId: requireUserId(req), code }); + res.status(200).json(result); +} + +export async function getTwoFactorStatus(req: Request, res: Response) { + const status = await authService.getTwoFactorStatus(requireUserId(req)); + res.status(200).json(status); +} + +/** Second step of login. Note this is the *only* route that may read an + * `mfa_pending` token — an access token here is rejected by the service + * (it isn't an mfa_pending token), and an mfa_pending token is rejected by + * every other route (verifyAccessToken refuses scoped tokens). */ +export async function verifyTwoFactor(req: Request, res: Response) { + const input = twoFactorVerifySchema.parse(req.body); + const session = await authService.completeTwoFactorLogin(input); + sessionResponse(res, session); +} + +/** Disabling requires proof of possession: a current code or a recovery + * code — see services/two-factor.ts. */ +export async function disableTwoFactor(req: Request, res: Response) { + const { code } = twoFactorCodeSchema.parse(req.body); + await authService.disableTwoFactor({ userId: requireUserId(req), code }); + res.status(204).send(); +} diff --git a/backend/src/modules/auth/repositories/index.ts b/backend/src/modules/auth/repositories/index.ts index c59338dd..cfa541e7 100644 --- a/backend/src/modules/auth/repositories/index.ts +++ b/backend/src/modules/auth/repositories/index.ts @@ -13,6 +13,62 @@ export function findUserById(id: string) { return prisma.user.findUnique({ where: { id } }); } +// --- TOTP two-factor auth (Phase 8 #36) --------------------------------- + +export function findTwoFactorByUserId(userId: string) { + return prisma.twoFactorAuth.findUnique({ + where: { userId }, + include: { recoveryCodes: { where: { usedAt: null }, orderBy: { createdAt: 'asc' } } }, + }); +} + +/** Starts (or restarts) enrollment. Re-enrolling replaces the pending + * secret; the service refuses this once `enabledAt` is set, so a live + * second factor can't be silently swapped out by an unauthenticated + * enrollment call. */ +export function upsertTwoFactorEnrollment(input: { userId: string; secretEncrypted: string }) { + return prisma.twoFactorAuth.upsert({ + where: { userId: input.userId }, + create: { userId: input.userId, secretEncrypted: input.secretEncrypted }, + update: { secretEncrypted: input.secretEncrypted, lastUsedTimeStep: null }, + }); +} + +/** Confirms enrollment and installs a fresh set of recovery codes in one + * transaction — there is never a moment where 2FA is enabled with no + * recovery path. */ +export function confirmTwoFactorEnablement(input: { + twoFactorId: string; + recoveryCodeHashes: string[]; + enabledAt: Date; +}) { + return prisma.$transaction(async (tx) => { + await tx.twoFactorRecoveryCode.deleteMany({ where: { twoFactorAuthId: input.twoFactorId } }); + await tx.twoFactorAuth.update({ + where: { id: input.twoFactorId }, + data: { + enabledAt: input.enabledAt, + recoveryCodes: { create: input.recoveryCodeHashes.map((codeHash) => ({ codeHash })) }, + }, + }); + }); +} + +export function recordTotpTimeStep(twoFactorId: string, timeStep: number) { + return prisma.twoFactorAuth.update({ + where: { id: twoFactorId }, + data: { lastUsedTimeStep: BigInt(timeStep) }, + }); +} + +export function markRecoveryCodeUsed(id: string) { + return prisma.twoFactorRecoveryCode.update({ where: { id }, data: { usedAt: new Date() } }); +} + +export function deleteTwoFactor(userId: string) { + return prisma.twoFactorAuth.deleteMany({ where: { userId } }); +} + export interface CreateSelfServeUserInput { email: string; phone?: string; diff --git a/backend/src/modules/auth/routes/index.ts b/backend/src/modules/auth/routes/index.ts index c048acf9..d5136d57 100644 --- a/backend/src/modules/auth/routes/index.ts +++ b/backend/src/modules/auth/routes/index.ts @@ -1,5 +1,6 @@ import { Router } from 'express'; import * as authController from '../controllers'; +import { authenticate } from '../../../middleware/auth'; export const authRouter = Router(); @@ -7,3 +8,13 @@ authRouter.post('/signup', authController.signup); authRouter.post('/login', authController.login); authRouter.post('/refresh', authController.refresh); authRouter.post('/logout', authController.logout); + +// TOTP two-factor auth (Phase 8 #36). +// Setup/enable/status/disable all require a full session; `/verify` is the +// second step of login and takes the mfa_pending token in its *body*, not +// an Authorization header — it is the only route that accepts one. +authRouter.get('/2fa', authenticate, authController.getTwoFactorStatus); +authRouter.post('/2fa/setup', authenticate, authController.setupTwoFactor); +authRouter.post('/2fa/enable', authenticate, authController.enableTwoFactor); +authRouter.post('/2fa/verify', authController.verifyTwoFactor); +authRouter.post('/2fa/disable', authenticate, authController.disableTwoFactor); diff --git a/backend/src/modules/auth/services/index.ts b/backend/src/modules/auth/services/index.ts index eaf3b5e5..f1abfbff 100644 --- a/backend/src/modules/auth/services/index.ts +++ b/backend/src/modules/auth/services/index.ts @@ -4,7 +4,15 @@ import { HttpError } from '../../../http-error'; import { createSelfServeUser, findUserByEmail, findUserById } from '../repositories'; import { hashPassword, verifyPassword } from './password'; -import { signAccessToken, signRefreshToken, verifyRefreshToken } from './tokens'; +import { + signAccessToken, + signMfaPendingToken, + signRefreshToken, + verifyMfaPendingToken, + verifyRefreshToken, +} from './tokens'; +import { isTwoFactorEnabled, verifyTwoFactorLogin } from './two-factor'; +import { config } from '../../../config'; import type { Role } from '@prisma/client'; // Re-exported so middleware/auth.ts (shared infra, not a peer module) can @@ -13,6 +21,17 @@ import type { Role } from '@prisma/client'; export { verifyAccessToken } from './tokens'; export type { AccessTokenPayload } from './tokens'; +// TOTP two-factor auth (Phase 8 #36) — its own file, exposed through this +// module's single service surface. +export { + disableTwoFactor, + enableTwoFactor, + getTwoFactorStatus, + normalizeRecoveryCode, + setupTwoFactor, + type TwoFactorStatus, +} from './two-factor'; + export interface AuthenticatedUser { id: string; email: string; @@ -80,7 +99,17 @@ export interface LoginInput { password: string; } -export async function login(input: LoginInput): Promise { +/** Returned instead of a session when the password step passed but the + * second factor is still outstanding. Contains a short-lived token that + * is accepted by exactly one route — POST /auth/2fa/verify. */ +export interface MfaRequiredResult { + mfaRequired: true; + mfaPendingToken: string; +} + +export type LoginResult = AuthSession | MfaRequiredResult; + +export async function login(input: LoginInput): Promise { const user = await findUserByEmail(input.email); // Same message whether the email doesn't exist or the password is wrong // — don't leak which one it was. @@ -89,6 +118,49 @@ export async function login(input: LoginInput): Promise { const valid = await verifyPassword(input.password, user.passwordHash); if (!valid) throw new HttpError(401, 'Invalid email or password.'); + // Two-step login for enrolled users. The password step deliberately + // yields no access token at all — an mfa_pending token can't be used + // anywhere except the verify endpoint (see services/tokens.ts). + if (await isTwoFactorEnabled(user.id)) { + return { + mfaRequired: true, + mfaPendingToken: signMfaPendingToken({ sub: user.id, role: user.role }), + }; + } + + // Config flag: admin accounts must have 2FA on to sign in. Checked after + // the password is verified, so it can't be used to enumerate admin + // accounts without credentials. NOTE (flagged, not hidden): an admin who + // has not enrolled yet cannot sign in to enroll while this flag is on — + // see ARCHITECTURE_ESSENTIALS.md known gaps. + if (config.twoFactor.requireForAdmin && user.role === 'admin') { + throw new HttpError(403, 'Two-factor authentication is required for admin accounts. Enable it before signing in.'); + } + + const tokens = issueSession(user); + return { user: toPublicUser(user), ...tokens }; +} + +/** + * Second step of a two-factor login: exchanges an `mfa_pending` token plus + * a TOTP (or recovery) code for a real session. + */ +export async function completeTwoFactorLogin(input: { + mfaPendingToken: string; + code: string; +}): Promise { + let payload; + try { + payload = verifyMfaPendingToken(input.mfaPendingToken); + } catch { + throw new HttpError(401, 'Invalid or expired two-factor session. Sign in again.'); + } + + const user = await findUserById(payload.sub); + if (!user) throw new HttpError(401, 'Invalid or expired two-factor session. Sign in again.'); + + await verifyTwoFactorLogin({ userId: user.id, code: input.code }); + const tokens = issueSession(user); return { user: toPublicUser(user), ...tokens }; } diff --git a/backend/src/modules/auth/services/secret-crypto.ts b/backend/src/modules/auth/services/secret-crypto.ts new file mode 100644 index 00000000..63c339d8 --- /dev/null +++ b/backend/src/modules/auth/services/secret-crypto.ts @@ -0,0 +1,43 @@ +// Envelope encryption for the stored TOTP secret (Phase 8 #36 requires the +// secret be encrypted at rest). +// +// AES-256-GCM with a key derived from TOTP_ENCRYPTION_KEY. Format is +// `iv.tag.ciphertext`, all base64 — self-describing so re-keying or +// rotating the scheme later is a data migration, not a guessing game. +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; +import { config } from '../../../config'; +import { HttpError } from '../../../http-error'; + +const IV_BYTES = 12; // GCM standard nonce length +const ALGORITHM = 'aes-256-gcm'; + +function encryptionKey(): Buffer { + const configured = config.twoFactor.encryptionKey; + if (!configured) { + // Fails loudly and by name rather than silently storing a plaintext + // secret — the whole point of this file. + throw new HttpError( + 500, + 'Two-factor authentication is not configured on this server (missing TOTP_ENCRYPTION_KEY).', + ); + } + return createHash('sha256').update(configured).digest(); +} + +export function encryptSecret(plaintext: string): string { + const iv = randomBytes(IV_BYTES); + const cipher = createCipheriv(ALGORITHM, encryptionKey(), iv); + const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + return [iv.toString('base64'), cipher.getAuthTag().toString('base64'), ciphertext.toString('base64')].join('.'); +} + +export function decryptSecret(encrypted: string): string { + const [ivPart, tagPart, ciphertextPart] = encrypted.split('.'); + if (!ivPart || !tagPart || !ciphertextPart) { + throw new HttpError(500, 'Stored two-factor secret is malformed.'); + } + + const decipher = createDecipheriv(ALGORITHM, encryptionKey(), Buffer.from(ivPart, 'base64')); + decipher.setAuthTag(Buffer.from(tagPart, 'base64')); + return Buffer.concat([decipher.update(Buffer.from(ciphertextPart, 'base64')), decipher.final()]).toString('utf8'); +} diff --git a/backend/src/modules/auth/services/tokens.ts b/backend/src/modules/auth/services/tokens.ts index ba342c74..f698933d 100644 --- a/backend/src/modules/auth/services/tokens.ts +++ b/backend/src/modules/auth/services/tokens.ts @@ -1,3 +1,11 @@ +// Token signing/verification. +// +// `scope` is what keeps the half-authenticated 2FA token (Phase 8 #36) +// from being mistaken for a real access token: it's signed with the same +// secret, but `verifyAccessToken` rejects anything carrying a `scope`, so +// `authenticate` — and therefore every protected route — refuses it. Only +// `verifyMfaPendingToken` accepts it, and only POST /auth/2fa/verify calls +// that. import jwt from 'jsonwebtoken'; import { config } from '../../../config'; import type { Role } from '@prisma/client'; @@ -5,6 +13,9 @@ import type { Role } from '@prisma/client'; export interface AccessTokenPayload { sub: string; // user id role: Role; + /** Present only on single-purpose tokens (currently `mfa_pending`). All + * other token types must leave it undefined. */ + scope?: 'mfa_pending'; } export function signAccessToken(payload: AccessTokenPayload): string { @@ -15,8 +26,25 @@ export function signRefreshToken(payload: AccessTokenPayload): string { return jwt.sign(payload, config.jwt.refreshSecret, { expiresIn: config.jwt.refreshTtl }); } +/** Issued in place of a session when the password step passes but the TOTP + * step hasn't happened yet. Short-lived and single-purpose on purpose. */ +export function signMfaPendingToken(payload: AccessTokenPayload): string { + return jwt.sign({ ...payload, scope: 'mfa_pending' }, config.jwt.accessSecret, { + expiresIn: config.twoFactor.mfaPendingTtl, + }); +} + export function verifyAccessToken(token: string): AccessTokenPayload { - return jwt.verify(token, config.jwt.accessSecret) as AccessTokenPayload; + const payload = jwt.verify(token, config.jwt.accessSecret) as AccessTokenPayload; + // A scoped token is not an access token, whatever signature it carries. + if (payload.scope !== undefined) throw new Error('Token is not an access token.'); + return payload; +} + +export function verifyMfaPendingToken(token: string): AccessTokenPayload { + const payload = jwt.verify(token, config.jwt.accessSecret) as AccessTokenPayload; + if (payload.scope !== 'mfa_pending') throw new Error('Token is not an mfa_pending token.'); + return payload; } export function verifyRefreshToken(token: string): AccessTokenPayload { diff --git a/backend/src/modules/auth/services/totp.ts b/backend/src/modules/auth/services/totp.ts new file mode 100644 index 00000000..a255b6bb --- /dev/null +++ b/backend/src/modules/auth/services/totp.ts @@ -0,0 +1,127 @@ +// TOTP (RFC 6238) over HMAC-SHA1, implemented on node:crypto. +// +// Deliberately dependency-free: this is ~60 lines of well-specified +// algorithm, and the whole point of choosing TOTP over SMS was to avoid a +// provider dependency (issue #36). Adding a crypto dependency to save those +// lines would trade a spec we can test against published RFC vectors for a +// package we'd have to trust and update. Correctness is pinned by the RFC +// 6238 test vectors in tests/unit/totp.test.ts. +import { createHmac, randomBytes } from 'node:crypto'; + +const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; +export const TOTP_DIGITS = 6; +export const TOTP_PERIOD_SECONDS = 30; +export const TOTP_SECRET_BYTES = 20; // 160 bits — RFC 4226 §4 recommended minimum +/** ±1 time step (~±30s) of clock skew tolerated, per the issue. */ +export const TOTP_WINDOW = 1; + +export function base32Encode(buffer: Buffer): string { + let bits = 0; + let value = 0; + let output = ''; + for (const byte of buffer) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + if (bits > 0) output += BASE32_ALPHABET[(value << (5 - bits)) & 31]; + return output; +} + +export function base32Decode(input: string): Buffer { + const normalized = input.toUpperCase().replace(/=+$/, '').replace(/\s/g, ''); + let bits = 0; + let value = 0; + const bytes: number[] = []; + for (const char of normalized) { + const index = BASE32_ALPHABET.indexOf(char); + if (index === -1) throw new Error(`Invalid base32 character: ${char}`); + value = (value << 5) | index; + bits += 5; + if (bits >= 8) { + bytes.push((value >>> (bits - 8)) & 0xff); + bits -= 8; + } + } + return Buffer.from(bytes); +} + +export function generateTotpSecret(): string { + return base32Encode(randomBytes(TOTP_SECRET_BYTES)); +} + +export function timeStepFor(at: Date | number = Date.now()): number { + const ms = at instanceof Date ? at.getTime() : at; + return Math.floor(ms / 1000 / TOTP_PERIOD_SECONDS); +} + +/** The code for one specific time step — the primitive; callers pass an + * explicit step so a test can pin RFC vectors. */ +export function totpCodeAtStep(secretBase32: string, timeStep: number, digits = TOTP_DIGITS): string { + const key = base32Decode(secretBase32); + const counter = Buffer.alloc(8); + counter.writeBigUInt64BE(BigInt(timeStep)); + + const digest = createHmac('sha1', key).update(counter).digest(); + // RFC 4226 §5.3 dynamic truncation. + const offset = digest[digest.length - 1] & 0x0f; + const binary = + ((digest[offset] & 0x7f) << 24) | + ((digest[offset + 1] & 0xff) << 16) | + ((digest[offset + 2] & 0xff) << 8) | + (digest[offset + 3] & 0xff); + + return String(binary % 10 ** digits).padStart(digits, '0'); +} + +export interface TotpVerification { + valid: boolean; + /** The time step the submitted code matched, when valid. Callers persist + * this and reject anything <= it later — that's the replay guard. */ + timeStep?: number; +} + +/** + * Verifies a submitted code against the current step and its neighbours. + * `lastUsedTimeStep` (the highest step this user has already spent) is + * checked here too, so a code can't be replayed inside its own validity + * window. + */ +export function verifyTotpCode(input: { + secretBase32: string; + code: string; + at?: Date | number; + lastUsedTimeStep?: bigint | number | null; +}): TotpVerification { + const current = timeStepFor(input.at); + const lastUsed = input.lastUsedTimeStep == null ? null : BigInt(input.lastUsedTimeStep); + const submitted = input.code.trim(); + + for (let offset = -TOTP_WINDOW; offset <= TOTP_WINDOW; offset += 1) { + const step = current + offset; + if (totpCodeAtStep(input.secretBase32, step) !== submitted) continue; + if (lastUsed !== null && BigInt(step) <= lastUsed) { + // Right code, but this step's code has already been spent. + return { valid: false }; + } + return { valid: true, timeStep: step }; + } + + return { valid: false }; +} + +/** The `otpauth://` URI a client renders as a QR code. */ +export function buildOtpauthUri(input: { secretBase32: string; label: string; issuer: string }): string { + const params = new URLSearchParams({ + secret: input.secretBase32, + issuer: input.issuer, + algorithm: 'SHA1', + digits: String(TOTP_DIGITS), + period: String(TOTP_PERIOD_SECONDS), + }); + const label = encodeURIComponent(`${input.issuer}:${input.label}`); + return `otpauth://totp/${label}?${params.toString()}`; +} diff --git a/backend/src/modules/auth/services/two-factor.ts b/backend/src/modules/auth/services/two-factor.ts new file mode 100644 index 00000000..4c8dc933 --- /dev/null +++ b/backend/src/modules/auth/services/two-factor.ts @@ -0,0 +1,201 @@ +// TOTP two-factor authentication (Phase 8 #36). See: +// - services/totp.ts — the RFC 6238 algorithm itself +// - services/secret-crypto.ts — encryption at rest for the shared secret +// +// Everything security-relevant lives here rather than in the controller: +// replay protection, single-use recovery codes, the "already enrolled" +// guard, and the disable rules. +import { randomBytes } from 'node:crypto'; +import { config } from '../../../config'; +import { HttpError } from '../../../http-error'; +import { hashPassword, verifyPassword } from './password'; +import { buildOtpauthUri, generateTotpSecret, verifyTotpCode } from './totp'; +import { decryptSecret, encryptSecret } from './secret-crypto'; +import { + confirmTwoFactorEnablement, + deleteTwoFactor, + findTwoFactorByUserId, + findUserById, + markRecoveryCodeUsed, + recordTotpTimeStep, + upsertTwoFactorEnrollment, +} from '../repositories'; + +const RECOVERY_CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTVWXYZ23456789'; // no I/L/O/0/1 — read-aloud safe + +export interface TwoFactorStatus { + enabled: boolean; + enabledAt: Date | null; + /** True once a secret has been issued but not yet confirmed. */ + pendingEnrollment: boolean; +} + +export async function getTwoFactorStatus(userId: string): Promise { + const record = await findTwoFactorByUserId(userId); + return { + enabled: record?.enabledAt != null, + enabledAt: record?.enabledAt ?? null, + pendingEnrollment: record != null && record.enabledAt == null, + }; +} + +export async function isTwoFactorEnabled(userId: string): Promise { + const record = await findTwoFactorByUserId(userId); + return record?.enabledAt != null; +} + +/** + * Starts enrollment: generates a secret, stores it encrypted, and returns + * the `otpauth://` URI for the authenticator app. Nothing is enforceable + * until `enableTwoFactor` confirms a first code, so a lost/abandoned + * enrollment never locks anyone out. + */ +export async function setupTwoFactor(input: { userId: string }): Promise<{ otpauthUri: string }> { + const existing = await findTwoFactorByUserId(input.userId); + if (existing?.enabledAt) { + throw new HttpError(409, 'Two-factor authentication is already enabled for this account.'); + } + + const user = await findUserById(input.userId); + if (!user) throw new HttpError(401, 'Authentication required.'); + + const secretBase32 = generateTotpSecret(); + await upsertTwoFactorEnrollment({ userId: input.userId, secretEncrypted: encryptSecret(secretBase32) }); + + return { + otpauthUri: buildOtpauthUri({ + secretBase32, + // The authenticator's own entry label — the email is what the user + // recognizes, and it never leaves this server except in the URI the + // user themselves requested. + label: user.email, + issuer: config.twoFactor.issuer, + }), + }; +} + +/** + * Confirms enrollment with a first code and issues the recovery codes. + * The plaintext codes are returned exactly once — only bcrypt hashes are + * stored, same as passwords. + */ +export async function enableTwoFactor(input: { userId: string; code: string }): Promise<{ recoveryCodes: string[] }> { + const record = await findTwoFactorByUserId(input.userId); + if (!record) throw new HttpError(400, 'Start two-factor setup before enabling it.'); + if (record.enabledAt) throw new HttpError(409, 'Two-factor authentication is already enabled for this account.'); + + const secretBase32 = decryptSecret(record.secretEncrypted); + // No replay check here: enrollment is a one-shot transition, and the + // first code the authenticator shows is legitimately the current step. + if (!verifyTotpCode({ secretBase32, code: input.code }).valid) { + throw new HttpError(401, 'That code is not valid. Check your authenticator app and try again.'); + } + + const recoveryCodes = Array.from({ length: config.twoFactor.recoveryCodeCount }, generateRecoveryCode); + const recoveryCodeHashes = await Promise.all(recoveryCodes.map((code) => hashPassword(code))); + + await confirmTwoFactorEnablement({ + twoFactorId: record.id, + recoveryCodeHashes, + enabledAt: new Date(), + }); + + return { recoveryCodes }; +} + +/** + * Second step of login: a TOTP code (or a single-use recovery code). + * Records the consumed time step so the same code can't be replayed in its + * own window. + */ +export async function verifyTwoFactorLogin(input: { userId: string; code: string }): Promise { + const record = await findTwoFactorByUserId(input.userId); + if (!record?.enabledAt) { + throw new HttpError(400, 'Two-factor authentication is not enabled for this account.'); + } + + const secretBase32 = decryptSecret(record.secretEncrypted); + const verification = verifyTotpCode({ + secretBase32, + code: input.code, + lastUsedTimeStep: record.lastUsedTimeStep, + }); + + if (verification.valid && verification.timeStep !== undefined) { + await recordTotpTimeStep(record.id, verification.timeStep); + return; + } + + // Not a valid TOTP code — try it as a recovery code before failing, so a + // lost device isn't a lockout. + if (await consumeRecoveryCode(record.recoveryCodes, input.code)) return; + + throw new HttpError(401, 'Invalid two-factor code.'); +} + +/** Recovery codes are compared against every unused hash (they're bcrypt, + * so there's no lookup by value) and marked used on match. */ +async function consumeRecoveryCode( + candidates: { id: string; codeHash: string }[], + submitted: string, +): Promise { + const normalized = normalizeRecoveryCode(submitted); + for (const candidate of candidates) { + if (await verifyPassword(normalized, candidate.codeHash)) { + await markRecoveryCodeUsed(candidate.id); + return true; + } + } + return false; +} + +/** + * Disabling requires proof of possession: a current TOTP code or a + * recovery code. A password-authenticated request alone is not enough — + * that's the same credential an attacker who stole the password would + * have. + */ +export async function disableTwoFactor(input: { userId: string; code: string }): Promise { + const record = await findTwoFactorByUserId(input.userId); + if (!record?.enabledAt) throw new HttpError(400, 'Two-factor authentication is not enabled for this account.'); + + const secretBase32 = decryptSecret(record.secretEncrypted); + const verification = verifyTotpCode({ + secretBase32, + code: input.code, + lastUsedTimeStep: record.lastUsedTimeStep, + }); + + if (!verification.valid && !(await consumeRecoveryCode(record.recoveryCodes, input.code))) { + throw new HttpError(401, 'Invalid two-factor code.'); + } + + await deleteTwoFactor(input.userId); +} + +const RECOVERY_CODE_LENGTH = 10; + +/** Unbiased sampling (rejection sampling rather than `byte % 30`, which + * would skew toward the first six characters of the alphabet). */ +function generateRecoveryCode(): string { + const alphabetSize = RECOVERY_CODE_ALPHABET.length; + const unbiasedLimit = Math.floor(256 / alphabetSize) * alphabetSize; + const chars: string[] = []; + + while (chars.length < RECOVERY_CODE_LENGTH) { + for (const byte of randomBytes(RECOVERY_CODE_LENGTH)) { + if (byte >= unbiasedLimit) continue; + chars.push(RECOVERY_CODE_ALPHABET[byte % alphabetSize]); + if (chars.length === RECOVERY_CODE_LENGTH) break; + } + } + + return `${chars.slice(0, 5).join('')}-${chars.slice(5).join('')}`; +} + +/** Users paste codes with or without the dash, in any case. */ +export function normalizeRecoveryCode(code: string): string { + const stripped = code.trim().toUpperCase().replace(/[^A-Z0-9]/g, ''); + if (stripped.length !== 10) return code.trim().toUpperCase(); + return `${stripped.slice(0, 5)}-${stripped.slice(5)}`; +} diff --git a/backend/src/modules/auth/validation.ts b/backend/src/modules/auth/validation.ts index 1e8a0489..95d359d4 100644 --- a/backend/src/modules/auth/validation.ts +++ b/backend/src/modules/auth/validation.ts @@ -11,3 +11,15 @@ export const loginSchema = z.object({ email: z.string().email(), password: z.string().min(1), }); + +// Two-factor auth (Phase 8 #36). `code` is either a 6-digit TOTP code or a +// 10-character recovery code (with or without its dash) — the service +// normalizes it, so the schema only bounds the size loosely rather than +// rejecting a valid recovery code shape. +export const twoFactorCodeSchema = z.object({ + code: z.string().trim().min(6).max(16), +}); + +export const twoFactorVerifySchema = twoFactorCodeSchema.extend({ + mfaPendingToken: z.string().min(1), +}); diff --git a/backend/tests/integration/two-factor.test.mts b/backend/tests/integration/two-factor.test.mts new file mode 100644 index 00000000..67a46b69 --- /dev/null +++ b/backend/tests/integration/two-factor.test.mts @@ -0,0 +1,273 @@ +// TOTP two-factor auth (Phase 8 #36) — integration, against a real +// Postgres: enrollment, the two-step login, the mfa_pending token's +// single-purpose scope, replay rejection, single-use recovery codes, the +// disable rule, and the admin-mandatory config flag. +// +// Self-contained like the other integration suites: throwaway Postgres on +// port 5437 (one port per file — vitest runs files in parallel). +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import EmbeddedPostgres from 'embedded-postgres'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5437; +const runId = Date.now(); + +// Set before importing anything from src/ — config/index.ts reads +// process.env at module load, and tokens.ts reads the 2FA TTL. +process.env.DATABASE_URL = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; +process.env.REQUIRE_2FA_FOR_ADMIN = 'true'; + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-2fa-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); + +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; + +type App = ReturnType; + +describe('TOTP two-factor authentication', () => { + let app: App; + let prisma: typeof import('../../src/db/prisma-client.js').prisma; + let totpCodeAtStep: typeof import('../../src/modules/auth/services/totp.js').totpCodeAtStep; + let timeStepFor: typeof import('../../src/modules/auth/services/totp.js').timeStepFor; + let hashPassword: typeof import('../../src/modules/auth/services/password.js').hashPassword; + + const doctorEmail = `doctor-2fa-${runId}@example.com`; + const password = 'doctor-password-1'; + let secret: string; + let recoveryCodes: string[]; + + function currentCode(offsetSteps = 0): string { + return totpCodeAtStep(secret, timeStepFor(Date.now()) + offsetSteps); + } + + function login(email = doctorEmail, pwd = password) { + return request(app).post('/auth/login').send({ email, password: pwd }); + } + + function verify(mfaPendingToken: unknown, code: string) { + return request(app).post('/auth/2fa/verify').send({ mfaPendingToken, code }); + } + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_test'); + + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + + const [appModule, dbModule, totpModule, passwordModule] = await Promise.all([ + import('../../src/app.js'), + import('../../src/db/prisma-client.js'), + import('../../src/modules/auth/services/totp.js'), + import('../../src/modules/auth/services/password.js'), + ]); + app = appModule.createApp(); + prisma = dbModule.prisma; + totpCodeAtStep = totpModule.totpCodeAtStep; + timeStepFor = totpModule.timeStepFor; + hashPassword = passwordModule.hashPassword; + + const signup = await request(app) + .post('/auth/signup') + .send({ role: 'doctor', email: doctorEmail, password, fullName: 'Two Factor Doctor' }); + expect(signup.status).toBe(200); + }); + + afterAll(async () => { + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('reports 2FA as off before enrollment', async () => { + const loginRes = await login(); + expect(loginRes.status).toBe(200); + const token = loginRes.body.accessToken as string; + + const status = await request(app).get('/auth/2fa').set('Authorization', `Bearer ${token}`); + expect(status.status).toBe(200); + expect(status.body).toEqual({ enabled: false, enabledAt: null, pendingEnrollment: false }); + }); + + it('starts enrollment with an otpauth URI and stores the secret encrypted at rest', async () => { + const token = (await login()).body.accessToken as string; + const setup = await request(app).post('/auth/2fa/setup').set('Authorization', `Bearer ${token}`); + + expect(setup.status).toBe(200); + expect(setup.body.otpauthUri.startsWith('otpauth://totp/')).toBe(true); + secret = new URL(setup.body.otpauthUri.replace('otpauth://', 'https://')).searchParams.get('secret') as string; + expect(secret).toHaveLength(32); + + const row = await prisma.twoFactorAuth.findFirstOrThrow({ where: { user: { email: doctorEmail } } }); + // Encrypted at rest: the stored value is not the secret, and it isn't + // readable without the key. + expect(row.secretEncrypted).not.toBe(secret); + expect(row.secretEncrypted).not.toContain(secret); + expect(row.enabledAt).toBeNull(); + }); + + it('does not enforce anything until a first code confirms enrollment', async () => { + const token = (await login()).body.accessToken as string; + + const wrong = await request(app) + .post('/auth/2fa/enable') + .set('Authorization', `Bearer ${token}`) + .send({ code: '000000' }); + expect(wrong.status).toBe(401); + + const enabled = await request(app) + .post('/auth/2fa/enable') + .set('Authorization', `Bearer ${token}`) + .send({ code: currentCode() }); + expect(enabled.status).toBe(200); + expect(enabled.body.recoveryCodes).toHaveLength(10); + recoveryCodes = enabled.body.recoveryCodes as string[]; + + // Plaintext recovery codes are returned exactly once — only hashes + // are persisted. + const stored = await prisma.twoFactorRecoveryCode.findMany(); + expect(stored).toHaveLength(10); + for (const code of recoveryCodes) { + expect(stored.some((row) => row.codeHash === code)).toBe(false); + } + expect(stored.every((row) => row.codeHash.startsWith('$2'))).toBe(true); + }); + + it('makes login two-step and returns no session until the code is supplied', async () => { + const res = await login(); + expect(res.status).toBe(200); + expect(res.body.mfaRequired).toBe(true); + expect(res.body.mfaPendingToken).toBeTruthy(); + expect(res.body.accessToken).toBeUndefined(); + // No refresh cookie either — a half-authenticated login must not be + // able to start a session. + expect(res.headers['set-cookie']).toBeUndefined(); + }); + + it('rejects the mfa_pending token on every route except /auth/2fa/verify', async () => { + const { mfaPendingToken } = (await login()).body as { mfaPendingToken: string }; + + const protectedRoute = await request(app).get('/doctors/me').set('Authorization', `Bearer ${mfaPendingToken}`); + expect(protectedRoute.status).toBe(401); + + const setupRoute = await request(app).post('/auth/2fa/setup').set('Authorization', `Bearer ${mfaPendingToken}`); + expect(setupRoute.status).toBe(401); + + // Not a refresh token either — different secret. + const refreshed = await request(app) + .post('/auth/refresh') + .set('Cookie', `docforum_refresh=${mfaPendingToken}`); + expect(refreshed.status).toBe(401); + + // The reverse direction: a genuine access token (here, one obtained by + // spending a recovery code) is not an mfa_pending token, so the verify + // endpoint refuses it too. Both directions of the scope check. + const session = await verify((await login()).body.mfaPendingToken, recoveryCodes[9]); + expect(session.status).toBe(200); + const asPending = await verify(session.body.accessToken, currentCode()); + expect(asPending.status).toBe(401); + }); + + it('issues a real session for a valid code', async () => { + const { mfaPendingToken } = (await login()).body as { mfaPendingToken: string }; + + const bad = await verify(mfaPendingToken, '000000'); + expect(bad.status).toBe(401); + + const res = await verify(mfaPendingToken, currentCode()); + expect(res.status).toBe(200); + expect(res.body.accessToken).toBeTruthy(); + expect(res.headers['set-cookie']?.[0]).toContain('docforum_refresh'); + + const me = await request(app).get('/doctors/me').set('Authorization', `Bearer ${res.body.accessToken}`); + expect(me.status).toBe(200); + }); + + it('rejects a replayed code inside its own window', async () => { + // The next step's code: inside the ±1 window, and guaranteed not to be + // the step the previous test spent (so the first call is a real + // success and the second is a genuine replay, not a clock artifact). + const code = currentCode(1); + + const first = await verify((await login()).body.mfaPendingToken, code); + expect(first.status).toBe(200); + + const replay = await verify((await login()).body.mfaPendingToken, code); + expect(replay.status).toBe(401); + }); + + it('accepts each recovery code exactly once', async () => { + const first = (await login()).body as { mfaPendingToken: string }; + expect((await verify(first.mfaPendingToken, recoveryCodes[0])).status).toBe(200); + + const second = (await login()).body as { mfaPendingToken: string }; + expect((await verify(second.mfaPendingToken, recoveryCodes[0])).status).toBe(401); + const third = (await login()).body as { mfaPendingToken: string }; + expect((await verify(third.mfaPendingToken, recoveryCodes[1])).status).toBe(200); + }); + + it('requires a current code or a recovery code to disable 2FA', async () => { + const token = (await login()).body.mfaPendingToken as string; + + const wrong = await request(app) + .post('/auth/2fa/disable') + .set('Authorization', `Bearer ${token}`) + .send({ code: '000000' }); + // The mfa_pending token isn't an access token, so this is a 401 at the + // middleware — and the endpoint can't be reached without a real code. + expect(wrong.status).toBe(401); + + const session = await verify((await login()).body.mfaPendingToken, recoveryCodes[2]); + expect(session.status).toBe(200); + + const disabled = await request(app) + .post('/auth/2fa/disable') + .set('Authorization', `Bearer ${session.body.accessToken}`) + .send({ code: recoveryCodes[3] }); + expect(disabled.status).toBe(204); + + // Back to single-step login. + const after = await login(); + expect(after.status).toBe(200); + expect(after.body.accessToken).toBeTruthy(); + expect(after.body.mfaRequired).toBeUndefined(); + }); + + it('blocks an unenrolled admin when REQUIRE_2FA_FOR_ADMIN is on, but only admins', async () => { + const adminEmail = `admin-${runId}@example.com`; + await prisma.user.create({ + data: { + email: adminEmail, + role: 'admin', + passwordHash: await hashPassword('admin-password-1'), + }, + }); + + const adminLogin = await login(adminEmail, 'admin-password-1'); + expect(adminLogin.status).toBe(403); + expect(adminLogin.body.message).toMatch(/two-factor/i); + + // The flag is admin-scoped — a non-admin still logs in single-step. + const patientEmail = `patient-flag-${runId}@example.com`; + await request(app) + .post('/auth/signup') + .send({ role: 'patient', email: patientEmail, password: 'patient-password-1', fullName: 'Flag Patient' }); + expect((await login(patientEmail, 'patient-password-1')).status).toBe(200); + }); +}); diff --git a/backend/tests/unit/secret-crypto.test.ts b/backend/tests/unit/secret-crypto.test.ts new file mode 100644 index 00000000..c4562775 --- /dev/null +++ b/backend/tests/unit/secret-crypto.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { decryptSecret, encryptSecret } from '../../src/modules/auth/services/secret-crypto'; + +describe('encryptSecret / decryptSecret', () => { + it('round-trips a TOTP secret', () => { + const secret = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'; + expect(decryptSecret(encryptSecret(secret))).toBe(secret); + }); + + it('never returns the plaintext in the ciphertext', () => { + const secret = 'JBSWY3DPEHPK3PXP'; + const encrypted = encryptSecret(secret); + expect(encrypted).not.toContain(secret); + // iv.tag.ciphertext, base64 each. + expect(encrypted.split('.')).toHaveLength(3); + }); + + it('uses a fresh IV, so the same secret encrypts differently each time', () => { + const secret = 'JBSWY3DPEHPK3PXP'; + expect(encryptSecret(secret)).not.toBe(encryptSecret(secret)); + }); + + it('rejects tampered ciphertext (GCM auth tag)', () => { + const [iv, tag, ciphertext] = encryptSecret('JBSWY3DPEHPK3PXP').split('.'); + const flipped = Buffer.from(ciphertext, 'base64'); + flipped[0] ^= 0xff; + expect(() => decryptSecret([iv, tag, flipped.toString('base64')].join('.'))).toThrow(); + }); + + it('rejects a malformed envelope', () => { + expect(() => decryptSecret('not-an-envelope')).toThrow(/malformed/); + }); +}); diff --git a/backend/tests/unit/totp.test.ts b/backend/tests/unit/totp.test.ts new file mode 100644 index 00000000..397a5a8e --- /dev/null +++ b/backend/tests/unit/totp.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import { + TOTP_PERIOD_SECONDS, + base32Decode, + base32Encode, + buildOtpauthUri, + generateTotpSecret, + timeStepFor, + totpCodeAtStep, + verifyTotpCode, +} from '../../src/modules/auth/services/totp'; +import { normalizeRecoveryCode } from '../../src/modules/auth/services/two-factor'; + +// RFC 6238 §Appendix B uses the ASCII secret "12345678901234567890" for the +// SHA-1 vectors. Working from the RFC's own inputs (rather than values +// produced by this code) is the point — it pins the implementation to the +// spec, not to itself. +const RFC_SECRET = base32Encode(Buffer.from('12345678901234567890', 'ascii')); + +describe('RFC 6238 test vectors (SHA-1, 6 digits)', () => { + const vectors: [number, string][] = [ + // [unix seconds, last 6 digits of the RFC's 8-digit expected value] + [59, '287082'], + [1111111109, '081804'], + [1111111111, '050471'], + [1234567890, '005924'], + [2000000000, '279037'], + [20000000000, '353130'], + ]; + + it.each(vectors)('matches the published code at T=%i', (epochSeconds, expected) => { + expect(totpCodeAtStep(RFC_SECRET, timeStepFor(epochSeconds * 1000))).toBe(expected); + }); + + it('uses a 30-second period', () => { + expect(TOTP_PERIOD_SECONDS).toBe(30); + expect(timeStepFor(0)).toBe(0); + expect(timeStepFor(29_000)).toBe(0); + expect(timeStepFor(30_000)).toBe(1); + }); +}); + +describe('base32', () => { + it('round-trips arbitrary bytes', () => { + const bytes = Buffer.from([0, 1, 2, 250, 255, 128, 7]); + expect(base32Decode(base32Encode(bytes))).toEqual(bytes); + }); + + it('ignores padding and lowercase on decode', () => { + expect(base32Decode('gezdgnbvgy3tqojqgezdgnbvgy3tqojq====')).toEqual( + base32Decode('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'), + ); + }); + + it('rejects a character outside the alphabet', () => { + expect(() => base32Decode('ABC1!!!')).toThrow(/Invalid base32/); + }); +}); + +describe('generateTotpSecret', () => { + it('produces a distinct 32-character base32 secret each time', () => { + const a = generateTotpSecret(); + const b = generateTotpSecret(); + expect(a).toHaveLength(32); // 20 bytes → 160 bits → 32 symbols + expect(a).not.toBe(b); + }); +}); + +describe('verifyTotpCode', () => { + const at = 1_600_000_000_000; + + it('accepts the current, previous and next step (±1 window)', () => { + const step = timeStepFor(at); + for (const offset of [-1, 0, 1]) { + const code = totpCodeAtStep(RFC_SECRET, step + offset); + expect(verifyTotpCode({ secretBase32: RFC_SECRET, code, at })).toEqual({ valid: true, timeStep: step + offset }); + } + }); + + it('rejects a code two steps away', () => { + const step = timeStepFor(at); + const code = totpCodeAtStep(RFC_SECRET, step + 2); + expect(verifyTotpCode({ secretBase32: RFC_SECRET, code, at }).valid).toBe(false); + }); + + it('rejects a wrong code and malformed input', () => { + expect(verifyTotpCode({ secretBase32: RFC_SECRET, code: '000000', at }).valid).toBe(false); + expect(verifyTotpCode({ secretBase32: RFC_SECRET, code: '', at }).valid).toBe(false); + }); + + it('rejects replay of a step already spent, even inside the window', () => { + const step = timeStepFor(at); + const code = totpCodeAtStep(RFC_SECRET, step); + // Already used the current step → the same code is a replay. + expect(verifyTotpCode({ secretBase32: RFC_SECRET, code, at, lastUsedTimeStep: step }).valid).toBe(false); + // The previous step's code is still acceptable while the current one + // hasn't been spent. + const previous = totpCodeAtStep(RFC_SECRET, step - 1); + expect(verifyTotpCode({ secretBase32: RFC_SECRET, code: previous, at, lastUsedTimeStep: null }).valid).toBe(true); + }); +}); + +describe('buildOtpauthUri', () => { + it('builds a scannable URI with the documented parameters', () => { + const uri = buildOtpauthUri({ secretBase32: RFC_SECRET, label: 'doc@example.com', issuer: 'DocForum' }); + expect(uri.startsWith('otpauth://totp/')).toBe(true); + + const params = new URL(uri.replace('otpauth://', 'https://')).searchParams; + expect(params.get('secret')).toBe(RFC_SECRET); + expect(params.get('issuer')).toBe('DocForum'); + expect(params.get('algorithm')).toBe('SHA1'); + expect(params.get('digits')).toBe('6'); + expect(params.get('period')).toBe('30'); + expect(decodeURIComponent(uri)).toContain('DocForum:doc@example.com'); + }); +}); + +describe('normalizeRecoveryCode', () => { + it('accepts a code with or without its dash, in any case', () => { + expect(normalizeRecoveryCode('abcde-fghjk')).toBe('ABCDE-FGHJK'); + expect(normalizeRecoveryCode('ABCDEFGHJK')).toBe('ABCDE-FGHJK'); + expect(normalizeRecoveryCode(' abcde fghjk ')).toBe('ABCDE-FGHJK'); + }); +}); diff --git a/backend/vitest.config.mts b/backend/vitest.config.mts index c5006018..df1d9b47 100644 --- a/backend/vitest.config.mts +++ b/backend/vitest.config.mts @@ -10,6 +10,9 @@ export default defineConfig({ env: { JWT_ACCESS_SECRET: 'test-access-secret', JWT_REFRESH_SECRET: 'test-refresh-secret', + // Encrypts stored TOTP secrets at rest (2FA, Phase 8 #36). Test-only + // value, never a real key. + TOTP_ENCRYPTION_KEY: 'test-totp-encryption-key', // Port 5433, not 5432 — tests/integration/booking.test.mts spins up // its own throwaway Postgres cluster on this port so the suite is // self-contained (no pre-existing DB required). A distinct port diff --git a/docs/api/README.md b/docs/api/README.md index aa21154f..d57ed21a 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -13,9 +13,14 @@ spec yet — TBD, tracked informally, not blocking Phase 1. | Method & path | Auth | Body | Notes | |---|---|---|---| | `POST /auth/signup` | none | `{ role: 'patient'\|'doctor', email, password, fullName }` | **`role` is restricted to `patient`/`doctor` server-side, not just in docforum-web's UI** — facility accounts are admin-invited only (PRD OQ-2). Returns `{ user, accessToken }`; sets an httpOnly refresh cookie. | -| `POST /auth/login` | none | `{ email, password }` | Same response shape as signup. | +| `POST /auth/login` | none | `{ email, password }` | Same response shape as signup — **unless the user has 2FA enabled**, in which case it returns `{ mfaRequired: true, mfaPendingToken }` with no session (see the two-step sequence below). | | `POST /auth/refresh` | refresh cookie | — | Reads the httpOnly cookie (not the body). Returns a fresh `{ user, accessToken }` and rotates the cookie. | | `POST /auth/logout` | none | — | Clears the refresh cookie. `204 No Content`. | +| `GET /auth/2fa` | authenticated | — | `{ enabled, enabledAt, pendingEnrollment }`. | +| `POST /auth/2fa/setup` | authenticated | — | `{ otpauthUri }` to scan/enter in an authenticator app. Rejects `409` if 2FA is already enabled. Nothing is enforced until `/enable` confirms a code. | +| `POST /auth/2fa/enable` | authenticated | `{ code }` | Confirms enrollment with a first TOTP code and returns `{ recoveryCodes: [...] }` — 10 single-use codes, shown **once**, stored bcrypt-hashed. | +| `POST /auth/2fa/verify` | mfa_pending token | `{ mfaPendingToken, code }` | Second step of login. `code` is a TOTP code **or** a recovery code. Returns the normal `{ user, accessToken }` + refresh cookie. `401` on a wrong code, a replayed code, or a spent recovery code. | +| `POST /auth/2fa/disable` | authenticated | `{ code }` | Requires a current TOTP code or a recovery code — a password-authenticated request alone is not enough. `204 No Content`. | Access tokens: `Authorization: Bearer `, 15-minute TTL, no revocation list yet (see `ARCHITECTURE_ESSENTIALS.md` known gaps — @@ -24,6 +29,32 @@ cookie, 30-day TTL, `sameSite: 'lax'`/`secure: false` — **local-dev-only choice**, see the comment above `setRefreshCookie` in `backend/src/modules/auth/controllers/index.ts` for what production needs. +### Two-step login (2FA enabled) + +``` +POST /auth/login { email, password } + → 200 { mfaRequired: true, mfaPendingToken } # no cookie, no access token +POST /auth/2fa/verify { mfaPendingToken, code } + → 200 { user, accessToken } + httpOnly refresh cookie +``` + +The `mfaPendingToken` is signed with the access-token secret but carries a +`scope: 'mfa_pending'` claim, and `verifyAccessToken` refuses any token that +has one — so it is **accepted by exactly one route** (this verify endpoint) +and rejected by every authenticated route (tested both directions). It expires +after 5 minutes. `code` is a 6-digit RFC 6238 TOTP code (SHA-1, 30s period, +±1 step of clock skew) or one of the recovery codes. + +TOTP codes are replay-protected: the highest accepted time step is stored per +user and any code at or below it is rejected, even inside the ±1 window. +Recovery codes are single-use (bcrypt-hashed; the plaintext is returned only +from `/2fa/enable`). The TOTP secret is stored AES-256-GCM encrypted with +`TOTP_ENCRYPTION_KEY`. + +`REQUIRE_2FA_FOR_ADMIN=true` makes 2FA mandatory for admin logins: an admin +without 2FA enrolled gets `403` at login. See +`ARCHITECTURE_ESSENTIALS.md` for the enrollment bootstrap caveat on that flag. + ## Doctors (`/doctors`) — `backend/src/modules/doctors` | Method & path | Auth | Notes | From 8abeec5a9b6c9155e99e794401228badffae4281 Mon Sep 17 00:00:00 2001 From: Oyinkans0la12 <147610565+Oyinkans0la12@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:46:45 +0000 Subject: [PATCH 2/3] fix(auth): restore TOTP two-factor schema and login wiring lost in 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. --- backend/prisma/schema.prisma | 39 ++++++++++ backend/src/config/index.ts | 18 +++++ backend/src/modules/auth/controllers/index.ts | 14 +++- backend/src/modules/auth/routes/index.ts | 11 ++- backend/src/modules/auth/services/index.ts | 75 ++++++++++++++++++- backend/src/modules/auth/services/tokens.ts | 3 + backend/src/modules/auth/validation.ts | 12 +++ 7 files changed, 167 insertions(+), 5 deletions(-) diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index d30b4baf..e19c410d 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -197,6 +197,45 @@ model LoginAttempt { @@index([lastFailedAt]) } +// TOTP two-factor authentication (Phase 8 #36). One row per user who has +// started (or completed) enrollment. +model TwoFactorAuth { + id String @id @default(uuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + // The TOTP shared secret (RFC 6238), AES-256-GCM encrypted at rest with + // TOTP_ENCRYPTION_KEY. Never returned by the API after the initial + // enrollment URI — see auth/services/secret-crypto.ts. + secretEncrypted String + + // Null until POST /auth/2fa/enable confirms a first code. An enrollment + // in this state does NOT count as enabled (login stays single-step). + enabledAt DateTime? + + // Highest TOTP time step ever accepted for this user. A code whose step + // is <= this is a replay and is rejected, even inside the ±1 window. + lastUsedTimeStep BigInt? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + recoveryCodes TwoFactorRecoveryCode[] +} + +// Single-use recovery codes: 10 generated at enable time, stored hashed +// (bcrypt, same as passwords — the plaintext is shown once, never again). +model TwoFactorRecoveryCode { + id String @id @default(uuid()) + twoFactorAuthId String + twoFactorAuth TwoFactorAuth @relation(fields: [twoFactorAuthId], references: [id], onDelete: Cascade) + codeHash String + usedAt DateTime? + createdAt DateTime @default(now()) + + @@index([twoFactorAuthId]) +} + model PatientProfile { id String @id @default(uuid()) userId String @unique diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 911967f8..bf2b1376 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -43,6 +43,24 @@ export const config = { accessTtl: '15m', refreshTtl: '30d', }, + // TOTP two-factor auth (Phase 8 #36). The encryption key is read here but + // deliberately NOT required at boot (same reasoning as payments below) — + // the 2FA service itself fails loudly with a named error if it's missing + // when a secret actually has to be encrypted or decrypted. + twoFactor: { + encryptionKey: process.env.TOTP_ENCRYPTION_KEY, + issuer: process.env.TOTP_ISSUER ?? 'DocForum', + // Short-lived, single-purpose token handed back instead of a session + // when a password step succeeds but the TOTP step hasn't happened yet. + // Accepted only by POST /auth/2fa/verify. + mfaPendingTtl: '5m', + recoveryCodeCount: 10, + // When true, an admin account must have 2FA enabled to log in at all — + // enforced at login. Defaults to false so enabling it can't lock out an + // admin who hasn't enrolled yet without that being a deliberate ops + // decision; see the bootstrap caveat in ARCHITECTURE_ESSENTIALS.md. + requireForAdmin: process.env.REQUIRE_2FA_FOR_ADMIN === 'true', + }, // Phase 8 hardening knobs (issues #34/#35/#37). Env-configurable so a // preview deployment can be tightened without a code change, and so // tests can set short windows instead of waiting real minutes. diff --git a/backend/src/modules/auth/controllers/index.ts b/backend/src/modules/auth/controllers/index.ts index 0c0f0e67..a8d2686d 100644 --- a/backend/src/modules/auth/controllers/index.ts +++ b/backend/src/modules/auth/controllers/index.ts @@ -5,6 +5,8 @@ import { loginSchema, signupSchema, signupWithInviteSchema, + twoFactorCodeSchema, + twoFactorVerifySchema, verifyEmailSchema, } from '../validation'; import { HttpError } from '../../../http-error'; @@ -52,8 +54,16 @@ export async function login(req: Request, res: Response) { // Express's `req.ip` honours the `trust proxy` setting (app.ts) — it is // the whole reason the throttle keys on the IP the client believes it // is, rather than Render's proxy address (see config.trustProxy). - const session = await authService.login({ ...input, ip: req.ip }); - sessionResponse(res, session); + const result = await authService.login({ ...input, ip: req.ip }); + + // Two-step login: no cookie, no access token yet — just a short-lived + // token that only POST /auth/2fa/verify accepts. + if ('mfaRequired' in result) { + res.status(200).json(result); + return; + } + + sessionResponse(res, result); } catch (err) { // Phase 8, issue #35: a throttled attempt must tell the client how long // to wait, both as a header and in the message (see LoginThrottledError). diff --git a/backend/src/modules/auth/routes/index.ts b/backend/src/modules/auth/routes/index.ts index 0132f3ab..7961eebb 100644 --- a/backend/src/modules/auth/routes/index.ts +++ b/backend/src/modules/auth/routes/index.ts @@ -2,7 +2,6 @@ import { Router } from 'express'; import { authenticate, requireRole } from '../../../middleware/auth'; import { requireAllowedOrigin } from '../../../middleware/csrf'; import * as authController from '../controllers'; -import { authenticate } from '../../../middleware/auth'; export const authRouter = Router(); @@ -28,3 +27,13 @@ authRouter.post('/login-attempts/clear', authenticate, requireRole('admin'), aut // /logout. See middleware/csrf.ts for the choice and its justification. authRouter.post('/refresh', requireAllowedOrigin, authController.refresh); authRouter.post('/logout', requireAllowedOrigin, authController.logout); + +// TOTP two-factor auth (Phase 8 #36). +// Setup/enable/status/disable all require a full session; `/verify` is the +// second step of login and takes the mfa_pending token in its *body*, not +// an Authorization header — it is the only route that accepts one. +authRouter.get('/2fa', authenticate, authController.getTwoFactorStatus); +authRouter.post('/2fa/setup', authenticate, authController.setupTwoFactor); +authRouter.post('/2fa/enable', authenticate, authController.enableTwoFactor); +authRouter.post('/2fa/verify', authController.verifyTwoFactor); +authRouter.post('/2fa/disable', authenticate, authController.disableTwoFactor); diff --git a/backend/src/modules/auth/services/index.ts b/backend/src/modules/auth/services/index.ts index 455c87b9..0b16732f 100644 --- a/backend/src/modules/auth/services/index.ts +++ b/backend/src/modules/auth/services/index.ts @@ -14,7 +14,14 @@ import { replaceVerificationToken, } from '../repositories'; import { hashPassword, verifyPassword } from './password'; -import { signAccessToken, signRefreshToken, verifyRefreshToken } from './tokens'; +import { + signAccessToken, + signMfaPendingToken, + signRefreshToken, + verifyMfaPendingToken, + verifyRefreshToken, +} from './tokens'; +import { isTwoFactorEnabled, verifyTwoFactorLogin } from './two-factor'; import { generateVerificationToken, hashVerificationToken } from './verification-tokens'; import { sendEmail } from './email-dispatcher'; import { @@ -36,6 +43,17 @@ import type { PatientExportSection } from '../../patients/services'; export { verifyAccessToken } from './tokens'; export type { AccessTokenPayload } from './tokens'; +// TOTP two-factor auth (Phase 8 #36) — its own file, exposed through this +// module's single service surface. +export { + disableTwoFactor, + enableTwoFactor, + getTwoFactorStatus, + normalizeRecoveryCode, + setupTwoFactor, + type TwoFactorStatus, +} from './two-factor'; + // Re-exported for the same reason: tests (and, at Phase 6, the provider // integration) replace the outbound email sender through this module's // service surface, not by importing the dispatcher file directly. @@ -193,7 +211,17 @@ export interface LoginInput { * response body is identical. */ const ABSENT_ACCOUNT_HASH = '$2b$12$z8J5gwFm9TiwC.YpeD6yguMkgakFPA8egT1KEKYiaeVkmb3CHRGXu'; -export async function login(input: LoginInput): Promise { +/** Returned instead of a session when the password step passed but the + * second factor is still outstanding. Contains a short-lived token that + * is accepted by exactly one route — POST /auth/2fa/verify. */ +export interface MfaRequiredResult { + mfaRequired: true; + mfaPendingToken: string; +} + +export type LoginResult = AuthSession | MfaRequiredResult; + +export async function login(input: LoginInput): Promise { const throttleKeys = { emailKey: emailThrottleKey(input.email), ipKey: normalizeClientIp(input.ip), @@ -229,6 +257,49 @@ export async function login(input: LoginInput): Promise { throw new HttpError(403, 'This account has been deactivated. Contact an administrator.'); } + // Two-step login for enrolled users. The password step deliberately + // yields no access token at all — an mfa_pending token can't be used + // anywhere except the verify endpoint (see services/tokens.ts). + if (await isTwoFactorEnabled(user.id)) { + return { + mfaRequired: true, + mfaPendingToken: signMfaPendingToken({ sub: user.id, role: user.role }), + }; + } + + // Config flag: admin accounts must have 2FA on to sign in. Checked after + // the password is verified, so it can't be used to enumerate admin + // accounts without credentials. NOTE (flagged, not hidden): an admin who + // has not enrolled yet cannot sign in to enroll while this flag is on — + // see ARCHITECTURE_ESSENTIALS.md known gaps. + if (config.twoFactor.requireForAdmin && user.role === 'admin') { + throw new HttpError(403, 'Two-factor authentication is required for admin accounts. Enable it before signing in.'); + } + + const tokens = issueSession(user); + return { user: toPublicUser(user), ...tokens }; +} + +/** + * Second step of a two-factor login: exchanges an `mfa_pending` token plus + * a TOTP (or recovery) code for a real session. + */ +export async function completeTwoFactorLogin(input: { + mfaPendingToken: string; + code: string; +}): Promise { + let payload; + try { + payload = verifyMfaPendingToken(input.mfaPendingToken); + } catch { + throw new HttpError(401, 'Invalid or expired two-factor session. Sign in again.'); + } + + const user = await findUserById(payload.sub); + if (!user) throw new HttpError(401, 'Invalid or expired two-factor session. Sign in again.'); + + await verifyTwoFactorLogin({ userId: user.id, code: input.code }); + const tokens = issueSession(user); return { user: toPublicUser(user), ...tokens }; } diff --git a/backend/src/modules/auth/services/tokens.ts b/backend/src/modules/auth/services/tokens.ts index 71106ea8..e4f8ceb9 100644 --- a/backend/src/modules/auth/services/tokens.ts +++ b/backend/src/modules/auth/services/tokens.ts @@ -17,6 +17,9 @@ export interface AccessTokenPayload { * compare against `User.sessionsInvalidatedAt` (Phase 8, issue #39) — * tokens minted before a deactivation must stop working immediately. */ iat?: number; + /** Present only on single-purpose tokens (currently `mfa_pending`). All + * other token types must leave it undefined. */ + scope?: 'mfa_pending'; } /** diff --git a/backend/src/modules/auth/validation.ts b/backend/src/modules/auth/validation.ts index e9924542..af05cb0d 100644 --- a/backend/src/modules/auth/validation.ts +++ b/backend/src/modules/auth/validation.ts @@ -36,3 +36,15 @@ export const clearLoginAttemptsSchema = z .refine((value) => Boolean(value.email || value.ip), { message: 'Provide an email or an ip to clear.', }); + +// Two-factor auth (Phase 8 #36). `code` is either a 6-digit TOTP code or a +// 10-character recovery code (with or without its dash) — the service +// normalizes it, so the schema only bounds the size loosely rather than +// rejecting a valid recovery code shape. +export const twoFactorCodeSchema = z.object({ + code: z.string().trim().min(6).max(16), +}); + +export const twoFactorVerifySchema = twoFactorCodeSchema.extend({ + mfaPendingToken: z.string().min(1), +}); From ee8558fac60c4214ea1ea8ac69fd0c98d34344e7 Mon Sep 17 00:00:00 2001 From: Oyinkans0la12 <147610565+Oyinkans0la12@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:55:40 +0000 Subject: [PATCH 3/3] fix(auth): restore TOTP two-factor wiring dropped in the sessions merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/prisma/schema.prisma | 40 +++++++++++ backend/src/modules/auth/controllers/index.ts | 52 +++++++++++++- .../src/modules/auth/repositories/index.ts | 56 +++++++++++++++ backend/src/modules/auth/routes/index.ts | 10 +++ backend/src/modules/auth/services/index.ts | 70 ++++++++++++++++++- backend/src/modules/auth/services/tokens.ts | 3 + backend/tests/integration/two-factor.test.mts | 4 +- 7 files changed, 228 insertions(+), 7 deletions(-) diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 2335c33b..a8a72e2f 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -131,6 +131,7 @@ model User { doctorProfile DoctorProfile? facilityProfile FacilityProfile? refreshSessions RefreshSession[] + twoFactorAuth TwoFactorAuth? emailVerificationTokens EmailVerificationToken[] adminInvitesSent AdminInvite[] @@ -201,6 +202,45 @@ model LoginAttempt { @@index([lastFailedAt]) } +// TOTP two-factor authentication (Phase 8 #36). One row per user who has +// started (or completed) enrollment. +model TwoFactorAuth { + id String @id @default(uuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + // The TOTP shared secret (RFC 6238), AES-256-GCM encrypted at rest with + // TOTP_ENCRYPTION_KEY. Never returned by the API after the initial + // enrollment URI — see auth/services/secret-crypto.ts. + secretEncrypted String + + // Null until POST /auth/2fa/enable confirms a first code. An enrollment + // in this state does NOT count as enabled (login stays single-step). + enabledAt DateTime? + + // Highest TOTP time step ever accepted for this user. A code whose step + // is <= this is a replay and is rejected, even inside the ±1 window. + lastUsedTimeStep BigInt? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + recoveryCodes TwoFactorRecoveryCode[] +} + +// Single-use recovery codes: 10 generated at enable time, stored hashed +// (bcrypt, same as passwords — the plaintext is shown once, never again). +model TwoFactorRecoveryCode { + id String @id @default(uuid()) + twoFactorAuthId String + twoFactorAuth TwoFactorAuth @relation(fields: [twoFactorAuthId], references: [id], onDelete: Cascade) + codeHash String + usedAt DateTime? + createdAt DateTime @default(now()) + + @@index([twoFactorAuthId]) +} + // One row per refresh-token *family*, i.e. per login. This is the // revocation store session management needs (Phase 8 #38) and the shape // issue #18's refresh-token revocation wanted: the refresh JWT carries diff --git a/backend/src/modules/auth/controllers/index.ts b/backend/src/modules/auth/controllers/index.ts index ee3b80de..d12503ce 100644 --- a/backend/src/modules/auth/controllers/index.ts +++ b/backend/src/modules/auth/controllers/index.ts @@ -66,8 +66,16 @@ export async function login(req: Request, res: Response) { // Express's `req.ip` honours the `trust proxy` setting (app.ts) — it is // the whole reason the throttle keys on the IP the client believes it // is, rather than Render's proxy address (see config.trustProxy). - const session = await authService.login({ ...input, ip: req.ip }, sessionContext(req)); - sessionResponse(res, session); + const result = await authService.login({ ...input, ip: req.ip }, sessionContext(req)); + + // Two-step login: no cookie, no access token yet — just a short-lived + // token that only POST /auth/2fa/verify accepts. + if ('mfaRequired' in result) { + res.status(200).json(result); + return; + } + + sessionResponse(res, result); } catch (err) { // Phase 8, issue #35: a throttled attempt must tell the client how long // to wait, both as a header and in the message (see LoginThrottledError). @@ -199,3 +207,43 @@ export async function revokeUserSessions(req: Request, res: Response) { const revoked = await authService.revokeAllUserSessions(getParam(req, 'id')); res.status(200).json({ revoked }); } + +// --- TOTP two-factor auth (Phase 8 #36) --------------------------------- + +/** Enrollment step 1: returns the otpauth URI (QR payload). Nothing is + * enforced until /auth/2fa/enable confirms a code. */ +export async function setupTwoFactor(req: Request, res: Response) { + const result = await authService.setupTwoFactor({ userId: requireUserId(req) }); + res.status(200).json(result); +} + +/** Enrollment step 2: confirms with a first code and returns the recovery + * codes — the only time their plaintext exists. */ +export async function enableTwoFactor(req: Request, res: Response) { + const { code } = twoFactorCodeSchema.parse(req.body); + const result = await authService.enableTwoFactor({ userId: requireUserId(req), code }); + res.status(200).json(result); +} + +export async function getTwoFactorStatus(req: Request, res: Response) { + const status = await authService.getTwoFactorStatus(requireUserId(req)); + res.status(200).json(status); +} + +/** Second step of login. Note this is the *only* route that may read an + * `mfa_pending` token — an access token here is rejected by the service + * (it isn't an mfa_pending token), and an mfa_pending token is rejected by + * every other route (verifyAccessToken refuses scoped tokens). */ +export async function verifyTwoFactor(req: Request, res: Response) { + const input = twoFactorVerifySchema.parse(req.body); + const session = await authService.completeTwoFactorLogin(input, sessionContext(req)); + sessionResponse(res, session); +} + +/** Disabling requires proof of possession: a current code or a recovery + * code — see services/two-factor.ts. */ +export async function disableTwoFactor(req: Request, res: Response) { + const { code } = twoFactorCodeSchema.parse(req.body); + await authService.disableTwoFactor({ userId: requireUserId(req), code }); + res.status(204).send(); +} diff --git a/backend/src/modules/auth/repositories/index.ts b/backend/src/modules/auth/repositories/index.ts index 697f47b8..9ed130fa 100644 --- a/backend/src/modules/auth/repositories/index.ts +++ b/backend/src/modules/auth/repositories/index.ts @@ -13,6 +13,62 @@ export function findUserById(id: string) { return prisma.user.findUnique({ where: { id } }); } +// --- TOTP two-factor auth (Phase 8 #36) --------------------------------- + +export function findTwoFactorByUserId(userId: string) { + return prisma.twoFactorAuth.findUnique({ + where: { userId }, + include: { recoveryCodes: { where: { usedAt: null }, orderBy: { createdAt: 'asc' } } }, + }); +} + +/** Starts (or restarts) enrollment. Re-enrolling replaces the pending + * secret; the service refuses this once `enabledAt` is set, so a live + * second factor can't be silently swapped out by an unauthenticated + * enrollment call. */ +export function upsertTwoFactorEnrollment(input: { userId: string; secretEncrypted: string }) { + return prisma.twoFactorAuth.upsert({ + where: { userId: input.userId }, + create: { userId: input.userId, secretEncrypted: input.secretEncrypted }, + update: { secretEncrypted: input.secretEncrypted, lastUsedTimeStep: null }, + }); +} + +/** Confirms enrollment and installs a fresh set of recovery codes in one + * transaction — there is never a moment where 2FA is enabled with no + * recovery path. */ +export function confirmTwoFactorEnablement(input: { + twoFactorId: string; + recoveryCodeHashes: string[]; + enabledAt: Date; +}) { + return prisma.$transaction(async (tx) => { + await tx.twoFactorRecoveryCode.deleteMany({ where: { twoFactorAuthId: input.twoFactorId } }); + await tx.twoFactorAuth.update({ + where: { id: input.twoFactorId }, + data: { + enabledAt: input.enabledAt, + recoveryCodes: { create: input.recoveryCodeHashes.map((codeHash) => ({ codeHash })) }, + }, + }); + }); +} + +export function recordTotpTimeStep(twoFactorId: string, timeStep: number) { + return prisma.twoFactorAuth.update({ + where: { id: twoFactorId }, + data: { lastUsedTimeStep: BigInt(timeStep) }, + }); +} + +export function markRecoveryCodeUsed(id: string) { + return prisma.twoFactorRecoveryCode.update({ where: { id }, data: { usedAt: new Date() } }); +} + +export function deleteTwoFactor(userId: string) { + return prisma.twoFactorAuth.deleteMany({ where: { userId } }); +} + // --- Refresh sessions (Phase 8 #38) ------------------------------------- export function createRefreshSession(input: { userId: string; userAgent?: string; ip?: string }) { diff --git a/backend/src/modules/auth/routes/index.ts b/backend/src/modules/auth/routes/index.ts index 4170386c..b1c0bcc4 100644 --- a/backend/src/modules/auth/routes/index.ts +++ b/backend/src/modules/auth/routes/index.ts @@ -35,6 +35,16 @@ authRouter.get('/sessions', authenticate, authController.listSessions); authRouter.delete('/sessions', authenticate, authController.revokeOtherSessions); authRouter.delete('/sessions/:id', authenticate, authController.revokeSession); +// TOTP two-factor auth (Phase 8 #36). Setup/enable/status/disable all +// require a full session; `/verify` is the second step of login and takes +// the mfa_pending token in its *body*, not an Authorization header — it is +// the only route that accepts one. +authRouter.get('/2fa', authenticate, authController.getTwoFactorStatus); +authRouter.post('/2fa/setup', authenticate, authController.setupTwoFactor); +authRouter.post('/2fa/enable', authenticate, authController.enableTwoFactor); +authRouter.post('/2fa/verify', authController.verifyTwoFactor); +authRouter.post('/2fa/disable', authenticate, authController.disableTwoFactor); + /** Admin routes for the auth module, mounted at `/admin` in app.ts. Session * administration acts on *another* user, so it doesn't belong under * `/auth`. */ diff --git a/backend/src/modules/auth/services/index.ts b/backend/src/modules/auth/services/index.ts index f6dd758c..a05503aa 100644 --- a/backend/src/modules/auth/services/index.ts +++ b/backend/src/modules/auth/services/index.ts @@ -14,7 +14,8 @@ import { replaceVerificationToken, } from '../repositories'; import { hashPassword, verifyPassword } from './password'; -import { signAccessToken, signRefreshToken, verifyRefreshToken } from './tokens'; +import { signAccessToken, signMfaPendingToken, signRefreshToken, verifyMfaPendingToken, verifyRefreshToken } from './tokens'; +import { isTwoFactorEnabled, verifyTwoFactorLogin } from './two-factor'; import { assertSessionUsable, recordSessionUse, startSession, type SessionContext } from './sessions'; import { generateVerificationToken, hashVerificationToken } from './verification-tokens'; import { sendEmail } from './email-dispatcher'; @@ -37,6 +38,17 @@ import type { PatientExportSection } from '../../patients/services'; export { verifyAccessToken } from './tokens'; export type { AccessTokenPayload } from './tokens'; +// TOTP two-factor auth (Phase 8 #36) — its own file, exposed through this +// module's single service surface. +export { + disableTwoFactor, + enableTwoFactor, + getTwoFactorStatus, + normalizeRecoveryCode, + setupTwoFactor, + type TwoFactorStatus, +} from './two-factor'; + export { assertSessionUsable, listSessions, @@ -220,7 +232,17 @@ export interface LoginInput { * response body is identical. */ const ABSENT_ACCOUNT_HASH = '$2b$12$z8J5gwFm9TiwC.YpeD6yguMkgakFPA8egT1KEKYiaeVkmb3CHRGXu'; -export async function login(input: LoginInput, context: SessionContext = {}): Promise { +/** Returned instead of a session when the password step passed but the + * second factor is still outstanding. Contains a short-lived token that + * is accepted by exactly one route — POST /auth/2fa/verify. */ +export interface MfaRequiredResult { + mfaRequired: true; + mfaPendingToken: string; +} + +export type LoginResult = AuthSession | MfaRequiredResult; + +export async function login(input: LoginInput, context: SessionContext = {}): Promise { const throttleKeys = { emailKey: emailThrottleKey(input.email), ipKey: normalizeClientIp(input.ip), @@ -256,11 +278,53 @@ export async function login(input: LoginInput, context: SessionContext = {}): Pr throw new HttpError(403, 'This account has been deactivated. Contact an administrator.'); } + // Two-step login for enrolled users. The password step deliberately + // yields no access token and starts no session at all — an mfa_pending + // token can't be used anywhere except the verify endpoint (see + // services/tokens.ts). + if (await isTwoFactorEnabled(user.id)) { + return { + mfaRequired: true, + mfaPendingToken: signMfaPendingToken({ sub: user.id, role: user.role }), + }; + } + + // Config flag: admin accounts must have 2FA on to sign in. Checked after + // the password is verified, so it can't be used to enumerate admin + // accounts without credentials. NOTE (flagged, not hidden): an admin who + // has not enrolled yet cannot sign in to enroll while this flag is on — + // see ARCHITECTURE_ESSENTIALS.md known gaps. + if (config.twoFactor.requireForAdmin && user.role === 'admin') { + throw new HttpError(403, 'Two-factor authentication is required for admin accounts. Enable it before signing in.'); + } + const tokens = await issueNewSession(user, context); return { user: toPublicUser(user), ...tokens }; } -// --- Email verification (Phase 8, issue #34) ----------------------------- +/** + * Second step of a two-factor login: exchanges an `mfa_pending` token plus + * a TOTP (or recovery) code for a real session. + */ +export async function completeTwoFactorLogin(input: { + mfaPendingToken: string; + code: string; +}, context: SessionContext = {}): Promise { + let payload; + try { + payload = verifyMfaPendingToken(input.mfaPendingToken); + } catch { + throw new HttpError(401, 'Invalid or expired two-factor session. Sign in again.'); + } + + const user = await findUserById(payload.sub); + if (!user) throw new HttpError(401, 'Invalid or expired two-factor session. Sign in again.'); + + await verifyTwoFactorLogin({ userId: user.id, code: input.code }); + + const tokens = await issueNewSession(user, context); + return { user: toPublicUser(user), ...tokens }; +} /** Mints a fresh token, retires any outstanding one, and hands the link to * the email dispatcher. The raw token is never persisted and never logged. */ diff --git a/backend/src/modules/auth/services/tokens.ts b/backend/src/modules/auth/services/tokens.ts index 27785373..f61dd834 100644 --- a/backend/src/modules/auth/services/tokens.ts +++ b/backend/src/modules/auth/services/tokens.ts @@ -21,6 +21,9 @@ export interface AccessTokenPayload { * the access token stays stateless so protected requests don't need a * session lookup. */ sid?: string; + /** Present only on single-purpose tokens (currently `mfa_pending`). All + * other token types must leave it undefined. */ + scope?: 'mfa_pending'; } /** diff --git a/backend/tests/integration/two-factor.test.mts b/backend/tests/integration/two-factor.test.mts index 67a46b69..ed68706a 100644 --- a/backend/tests/integration/two-factor.test.mts +++ b/backend/tests/integration/two-factor.test.mts @@ -4,7 +4,7 @@ // disable rule, and the admin-mandatory config flag. // // Self-contained like the other integration suites: throwaway Postgres on -// port 5437 (one port per file — vitest runs files in parallel). +// port 5450 (one port per file — vitest runs files in parallel). import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; @@ -15,7 +15,7 @@ import request from 'supertest'; import EmbeddedPostgres from 'embedded-postgres'; const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const PORT = 5437; +const PORT = 5450; const runId = Date.now(); // Set before importing anything from src/ — config/index.ts reads