Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -176,6 +176,7 @@ model User {
doctorProfile DoctorProfile?
facilityProfile FacilityProfile?
refreshSessions RefreshSession[]
twoFactorAuth TwoFactorAuth?

emailVerificationTokens EmailVerificationToken[]
passwordResetTokens PasswordResetToken[]
Expand Down Expand Up @@ -272,6 +273,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
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 @@ -7,6 +7,8 @@ import {
passwordResetRequestSchema,
signupSchema,
signupWithInviteSchema,
twoFactorCodeSchema,
twoFactorVerifySchema,
verifyEmailSchema,
} from '../validation';
import { HttpError } from '../../../http-error';
Expand Down Expand Up @@ -70,8 +72,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 @@ -225,3 +235,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, db: typeof prisma | Prisma.TransactionC
return db.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 @@ -40,6 +40,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
59 changes: 56 additions & 3 deletions backend/src/modules/auth/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,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 Down Expand Up @@ -249,7 +250,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 @@ -288,11 +299,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
43 changes: 43 additions & 0 deletions backend/src/modules/auth/services/secret-crypto.ts
Original file line number Diff line number Diff line change
@@ -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');
}
8 changes: 8 additions & 0 deletions backend/src/modules/auth/services/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ export function signRefreshToken(payload: RefreshTokenPayload): string {
return signWithKeyring(config.jwt.refreshKeys, payload, 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 verifyWithKeyring<AccessTokenPayload>(config.jwt.accessKeys, token);
}
Expand Down
Loading
Loading