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 0000000..5b4e2e6 --- /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 2335c33..a8a72e2 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/config/index.ts b/backend/src/config/index.ts index 911967f..bf2b137 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 4bb6134..d12503c 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'; @@ -64,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). @@ -197,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 697f47b..9ed130f 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 4170386..b1c0bcc 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 f6dd758..a05503a 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/secret-crypto.ts b/backend/src/modules/auth/services/secret-crypto.ts new file mode 100644 index 0000000..63c339d --- /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 923171d..f61dd83 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'; @@ -13,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'; } /** @@ -35,8 +46,25 @@ export function signRefreshToken(payload: RefreshTokenPayload): 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): RefreshTokenPayload { diff --git a/backend/src/modules/auth/services/totp.ts b/backend/src/modules/auth/services/totp.ts new file mode 100644 index 0000000..a255b6b --- /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 0000000..4c8dc93 --- /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 e992454..af05cb0 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), +}); diff --git a/backend/tests/integration/two-factor.test.mts b/backend/tests/integration/two-factor.test.mts new file mode 100644 index 0000000..ed68706 --- /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 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'; +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 = 5450; +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 0000000..c456277 --- /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 0000000..397a5a8 --- /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 b184413..750282e 100644 --- a/backend/vitest.config.mts +++ b/backend/vitest.config.mts @@ -16,6 +16,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 4df31f5..0146213 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -138,6 +138,32 @@ cookie, 30-day TTL, `SameSite=None; Secure` in production and (`backend/src/config/index.ts`) so the refresh and CSRF cookies can never drift apart. See `docs/adr/0003` for why production needs `SameSite=None`. +### 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 |