Skip to content
Original file line number Diff line number Diff line change
@@ -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;

40 changes: 40 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ model User {
doctorProfile DoctorProfile?
facilityProfile FacilityProfile?
refreshSessions RefreshSession[]
twoFactorAuth TwoFactorAuth?

emailVerificationTokens EmailVerificationToken[]
adminInvitesSent AdminInvite[]
Expand Down Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions backend/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
54 changes: 52 additions & 2 deletions backend/src/modules/auth/controllers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
loginSchema,
signupSchema,
signupWithInviteSchema,
twoFactorCodeSchema,
twoFactorVerifySchema,
verifyEmailSchema,
} from '../validation';
import { HttpError } from '../../../http-error';
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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();
}
56 changes: 56 additions & 0 deletions backend/src/modules/auth/repositories/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down
10 changes: 10 additions & 0 deletions backend/src/modules/auth/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`. */
Expand Down
70 changes: 67 additions & 3 deletions backend/src/modules/auth/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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<AuthSession> {
/** 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<LoginResult> {
const throttleKeys = {
emailKey: emailThrottleKey(input.email),
ipKey: normalizeClientIp(input.ip),
Expand Down Expand Up @@ -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<AuthSession> {
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. */
Expand Down
Loading
Loading