Repository Public
1/** Auth entry points: status, first-run setup, password login, TOTP step, logout, password change. */2import type { FastifyInstance } from 'fastify';3import { z } from 'zod';4import { AuthStatus, LoginPasswordRequest, LoginStep, SetupRequest, TotpEnrollFinish } from '@cr/shared';5import { brandingOf } from '@cr/shared';6import { HttpError, authOf, parse, type Ctx } from '../../http.js';7import { unseal } from '../../security/crypto.js';8import { clearSessionCookie } from '../../security/guard.js';9import { dummyVerify, hashPassword, needsRehash, verifyPassword } from '../../security/password.js';10import { revokeOtherSessions, revokeSession } from '../../security/sessions.js';11import { checkSetupToken, deleteSetupToken, userExists } from '../../security/setup-token.js';12import { throttleRecord } from '../../security/throttle.js';13import { base32Decode, verifyTotp } from '../../security/totp.js';14import {15 USER_COLS, enforceThrottle, factorsOf, invalidCredentials, issueSession, step, upgradeCurrentSession, userById,16 type UserRow,17} from './common.js';18import { registerFactorRoutes } from './factors.js';1920export const ChangePassword = z.object({21 currentPassword: z.string().max(256),22 newPassword: z.string().min(12).max(256),23});2425/** Verifies a TOTP code for the user and burns its counter (replay protection). */26export async function checkTotp(ctx: Ctx, user: UserRow, code: string): Promise<boolean> {27 if (!user.totp_secret_enc) return false;28 const secret = base32Decode(unseal(ctx.config.secretKey, user.totp_secret_enc, `totp:${user.id}`));29 const counter = verifyTotp(secret, code, ctx.now(), user.totp_last_counter);30 if (counter === null) return false;31 // Conditional update: two concurrent requests cannot both spend the same code.32 const rows = await ctx.db.q(33 'UPDATE users SET totp_last_counter = $2 WHERE id = $1 AND (totp_last_counter IS NULL OR totp_last_counter < $2) RETURNING id',34 [user.id, counter],35 );36 return rows.length === 1;37}3839export default async function authPlugin(app: FastifyInstance, ctx: Ctx): Promise<void> {40 const { db, audit, config } = ctx;4142 app.get('/auth/status', { config: { auth: 'preauth', rate: 'status', doc: { summary: 'Auth state + CSRF token', response: AuthStatus, responseName: 'AuthStatus' } } }, async (req): Promise<AuthStatus> => {43 const a = req.auth;44 // Anonymous callers learn nothing about enrolled factors.45 const f = a ? await factorsOf(db, a.userId) : { totp: false, passkeys: 0 };46 return {47 setupRequired: !(await userExists(db)),48 authenticated: a?.secondFactor === true,49 user: a ? { id: a.userId, username: a.username } : null,50 methods: { password: true, passkeys: f.passkeys, totp: f.totp },51 csrfToken: a?.csrfToken ?? null,52 sessionExpiresAt: a ? a.expiresAt.toISOString() : null,53 // Public and static: which product this instance is (the web build serves both).54 branding: brandingOf(config.agent.id),55 };56 });5758 app.post('/auth/setup', { config: { auth: 'preauth', doc: { summary: 'First-run administrator creation (one-time token)', body: SetupRequest, bodyName: 'SetupRequest', response: LoginStep, responseName: 'LoginStep' } } }, async (req, reply): Promise<LoginStep> => {59 if (await userExists(db)) throw new HttpError(404, 'not_found', 'not found');60 const body = parse(SetupRequest, req.body);61 await enforceThrottle(ctx, reply, { ip: req.ip });62 if (!(await checkSetupToken(config.dataDir, body.setupToken))) {63 await throttleRecord(db, { ip: req.ip }, false);64 await audit.req(req, 'auth.setup', { ok: false });65 throw new HttpError(401, 'invalid_setup_token', 'invalid setup token');66 }67 const hash = await hashPassword(body.password, config.passwordCost);68 const userId = await db.withTx(async (tx) => {69 await tx.q("SELECT pg_advisory_xact_lock(hashtext('cr_setup'))");70 if (await userExists(tx)) return null;71 const row = await tx.one<{ id: string }>(72 'INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id',73 [body.username.toLowerCase(), hash],74 );75 return row?.id ?? null;76 });77 if (!userId) throw new HttpError(404, 'not_found', 'not found');78 await deleteSetupToken(config.dataDir);79 await throttleRecord(db, { ip: req.ip }, true);80 await issueSession(ctx, req, reply, userId, false);81 await audit.record({ actor: body.username.toLowerCase(), action: 'auth.setup', ip: req.ip });82 return step('enroll_second_factor');83 });8485 app.post('/auth/login', { config: { auth: 'preauth', doc: { summary: 'Password step (optionally with TOTP)', body: LoginPasswordRequest, bodyName: 'LoginPasswordRequest', response: LoginStep, responseName: 'LoginStep' } } }, async (req, reply): Promise<LoginStep> => {86 const body = parse(LoginPasswordRequest, req.body);87 const username = body.username.toLowerCase();88 const keys = { ip: req.ip, username };89 await enforceThrottle(ctx, reply, keys);90 const user = await db.one<UserRow>(`SELECT ${USER_COLS} FROM users WHERE username = $1`, [username]);91 let ok = false;92 if (user) ok = await verifyPassword(body.password, user.password_hash);93 else await dummyVerify(body.password, config.passwordCost);94 if (!user || !ok) {95 await throttleRecord(db, keys, false);96 await audit.record({ actor: 'anonymous', action: 'auth.login', ip: req.ip, ok: false, detail: { reason: 'credentials' } });97 throw invalidCredentials();98 }99 if (needsRehash(user.password_hash, config.passwordCost)) {100 await db.q('UPDATE users SET password_hash = $2, updated_at = now() WHERE id = $1', [user.id, await hashPassword(body.password, config.passwordCost)]);101 }102 const f = await factorsOf(db, user.id);103 // NOTE: a correct password alone never records a throttle success — otherwise re-entering104 // the password would reset the counter protecting the 6-digit TOTP step.105 if (!f.totp && f.passkeys === 0) {106 await throttleRecord(db, keys, true);107 await issueSession(ctx, req, reply, user.id, false);108 await audit.record({ actor: username, action: 'auth.login', ip: req.ip, detail: { step: 'enroll_second_factor' } });109 return step('enroll_second_factor');110 }111 if (body.totp !== undefined && f.totp) {112 if (!(await checkTotp(ctx, user, body.totp))) {113 await throttleRecord(db, keys, false);114 await audit.record({ actor: username, action: 'auth.login', ip: req.ip, ok: false, detail: { reason: 'totp' } });115 throw invalidCredentials();116 }117 await throttleRecord(db, keys, true);118 await issueSession(ctx, req, reply, user.id, true);119 await audit.record({ actor: username, action: 'auth.login', ip: req.ip, detail: { step: 'done', factor: 'totp' } });120 return step('done');121 }122 await issueSession(ctx, req, reply, user.id, false);123 await audit.record({ actor: username, action: 'auth.login', ip: req.ip, detail: { step: 'second_factor' } });124 return step(f.totp ? 'totp' : 'passkey');125 });126127 app.post('/auth/totp', { config: { auth: 'restricted', rate: 'credentials', doc: { summary: 'TOTP second step', body: TotpEnrollFinish, bodyName: 'TotpCode', response: LoginStep, responseName: 'LoginStep' } } }, async (req, reply): Promise<LoginStep> => {128 const a = authOf(req);129 const body = parse(TotpEnrollFinish, req.body);130 if (a.secondFactor) return step('done');131 const keys = { ip: req.ip, username: a.username };132 await enforceThrottle(ctx, reply, keys);133 const user = await userById(db, a.userId);134 if (!user || !(await checkTotp(ctx, user, body.code))) {135 await throttleRecord(db, keys, false);136 await audit.req(req, 'auth.totp', { ok: false });137 throw invalidCredentials();138 }139 await throttleRecord(db, keys, true);140 await upgradeCurrentSession(ctx, req, reply);141 await audit.req(req, 'auth.totp');142 return step('done');143 });144145 app.post('/auth/logout', { config: { auth: 'restricted', doc: { summary: 'Revoke the current session' } } }, async (req, reply) => {146 const a = authOf(req);147 await revokeSession(db, a.sessionId);148 ctx.live.kickAuthSessions([a.sessionId]);149 clearSessionCookie(ctx, reply);150 await audit.req(req, 'auth.logout');151 return { ok: true };152 });153154 app.post('/auth/password', { config: { doc: { summary: 'Change password (revokes all other sessions)', body: ChangePassword, bodyName: 'ChangePassword' } } }, async (req, reply) => {155 const a = authOf(req);156 const body = parse(ChangePassword, req.body);157 const keys = { ip: req.ip, username: a.username };158 await enforceThrottle(ctx, reply, keys);159 const user = await userById(db, a.userId);160 if (!user || !(await verifyPassword(body.currentPassword, user.password_hash))) {161 await throttleRecord(db, keys, false);162 await audit.req(req, 'auth.password', { ok: false });163 throw invalidCredentials();164 }165 await db.q('UPDATE users SET password_hash = $2, updated_at = now() WHERE id = $1', [user.id, await hashPassword(body.newPassword, config.passwordCost)]);166 const revoked = await revokeOtherSessions(db, user.id, a.sessionId);167 ctx.live.kickAuthSessions(revoked);168 await audit.req(req, 'auth.password', { detail: { revokedSessions: revoked.length } });169 return { ok: true };170 });171172 registerFactorRoutes(app, ctx);173}174