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, BRANDING, LoginPasswordRequest, LoginStep, SetupRequest, TotpEnrollFinish } from '../../dto-auth.js';5import { HttpError, authOf, parse, type Ctx } from '../../http.js';6import { unseal } from '../../security/crypto.js';7import { clearSessionCookie } from '../../security/guard.js';8import { dummyVerify, hashPassword, needsRehash, verifyPassword } from '../../security/password.js';9import { revokeOtherSessions, revokeSession } from '../../security/sessions.js';10import { checkSetupToken, deleteSetupToken, userExists } from '../../security/setup-token.js';11import { throttleRecord } from '../../security/throttle.js';12import { base32Decode, verifyTotp } from '../../security/totp.js';13import {14 USER_COLS, enforceThrottle, factorsOf, invalidCredentials, issueSession, step, upgradeCurrentSession, userById,15 type UserRow,16} from './common.js';17import { registerFactorRoutes } from './factors.js';1819export const ChangePassword = z.object({20 currentPassword: z.string().max(256),21 newPassword: z.string().min(12).max(256),22});2324/** Verifies a TOTP code for the user and burns its counter (replay protection). */25export async function checkTotp(ctx: Ctx, user: UserRow, code: string): Promise<boolean> {26 if (!user.totp_secret_enc) return false;27 const secret = base32Decode(unseal(ctx.config.secretKey, user.totp_secret_enc, `totp:${user.id}`));28 const counter = verifyTotp(secret, code, ctx.now(), user.totp_last_counter);29 if (counter === null) return false;30 // Conditional update: two concurrent requests cannot both spend the same code.31 const rows = await ctx.db.q(32 'UPDATE users SET totp_last_counter = $2 WHERE id = $1 AND (totp_last_counter IS NULL OR totp_last_counter < $2) RETURNING id',33 [user.id, counter],34 );35 return rows.length === 1;36}3738export default async function authPlugin(app: FastifyInstance, ctx: Ctx): Promise<void> {39 const { db, audit, config } = ctx;4041 app.get('/auth/status', { config: { auth: 'preauth', rate: 'status', doc: { summary: 'Auth state + CSRF token', response: AuthStatus, responseName: 'AuthStatus' } } }, async (req): Promise<AuthStatus> => {42 const a = req.auth;43 // Anonymous callers learn nothing about enrolled factors.44 const f = a ? await factorsOf(db, a.userId) : { totp: false, passkeys: 0 };45 return {46 setupRequired: !(await userExists(db)),47 authenticated: a?.secondFactor === true,48 user: a ? { id: a.userId, username: a.username } : null,49 methods: { password: true, passkeys: f.passkeys, totp: f.totp },50 csrfToken: a?.csrfToken ?? null,51 sessionExpiresAt: a ? a.expiresAt.toISOString() : null,52 branding: BRANDING,53 };54 });5556 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> => {57 if (await userExists(db)) throw new HttpError(404, 'not_found', 'not found');58 const body = parse(SetupRequest, req.body);59 await enforceThrottle(ctx, reply, { ip: req.ip });60 if (!(await checkSetupToken(config.dataDir, body.setupToken))) {61 await throttleRecord(db, { ip: req.ip }, false);62 await audit.req(req, 'auth.setup', { ok: false });63 throw new HttpError(401, 'invalid_setup_token', 'invalid setup token');64 }65 const hash = await hashPassword(body.password, config.passwordCost);66 const userId = await db.withTx(async (tx) => {67 await tx.q("SELECT pg_advisory_xact_lock(hashtext('aos_setup'))");68 if (await userExists(tx)) return null;69 const row = await tx.one<{ id: string }>(70 'INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id',71 [body.username.toLowerCase(), hash],72 );73 return row?.id ?? null;74 });75 if (!userId) throw new HttpError(404, 'not_found', 'not found');76 await deleteSetupToken(config.dataDir);77 await throttleRecord(db, { ip: req.ip }, true);78 await issueSession(ctx, req, reply, userId, false);79 await audit.record({ actor: body.username.toLowerCase(), action: 'auth.setup', ip: req.ip });80 return step('enroll_second_factor');81 });8283 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> => {84 const body = parse(LoginPasswordRequest, req.body);85 const username = body.username.toLowerCase();86 const keys = { ip: req.ip, username };87 await enforceThrottle(ctx, reply, keys);88 const user = await db.one<UserRow>(`SELECT ${USER_COLS} FROM users WHERE username = $1`, [username]);89 let ok = false;90 if (user) ok = await verifyPassword(body.password, user.password_hash);91 else await dummyVerify(body.password, config.passwordCost);92 if (!user || !ok) {93 await throttleRecord(db, keys, false);94 await audit.record({ actor: 'anonymous', action: 'auth.login', ip: req.ip, ok: false, detail: { reason: 'credentials' } });95 throw invalidCredentials();96 }97 if (needsRehash(user.password_hash, config.passwordCost)) {98 await db.q('UPDATE users SET password_hash = $2, updated_at = now() WHERE id = $1', [user.id, await hashPassword(body.password, config.passwordCost)]);99 }100 const f = await factorsOf(db, user.id);101 // NOTE: a correct password alone never records a throttle success — otherwise re-entering102 // the password would reset the counter protecting the 6-digit TOTP step.103 if (!f.totp && f.passkeys === 0) {104 await throttleRecord(db, keys, true);105 await issueSession(ctx, req, reply, user.id, false);106 await audit.record({ actor: username, action: 'auth.login', ip: req.ip, detail: { step: 'enroll_second_factor' } });107 return step('enroll_second_factor');108 }109 if (body.totp !== undefined && f.totp) {110 if (!(await checkTotp(ctx, user, body.totp))) {111 await throttleRecord(db, keys, false);112 await audit.record({ actor: username, action: 'auth.login', ip: req.ip, ok: false, detail: { reason: 'totp' } });113 throw invalidCredentials();114 }115 await throttleRecord(db, keys, true);116 await issueSession(ctx, req, reply, user.id, true);117 await audit.record({ actor: username, action: 'auth.login', ip: req.ip, detail: { step: 'done', factor: 'totp' } });118 return step('done');119 }120 await issueSession(ctx, req, reply, user.id, false);121 await audit.record({ actor: username, action: 'auth.login', ip: req.ip, detail: { step: 'second_factor' } });122 return step(f.totp ? 'totp' : 'passkey');123 });124125 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> => {126 const a = authOf(req);127 const body = parse(TotpEnrollFinish, req.body);128 if (a.secondFactor) return step('done');129 const keys = { ip: req.ip, username: a.username };130 await enforceThrottle(ctx, reply, keys);131 const user = await userById(db, a.userId);132 if (!user || !(await checkTotp(ctx, user, body.code))) {133 await throttleRecord(db, keys, false);134 await audit.req(req, 'auth.totp', { ok: false });135 throw invalidCredentials();136 }137 await throttleRecord(db, keys, true);138 await upgradeCurrentSession(ctx, req, reply);139 await audit.req(req, 'auth.totp');140 return step('done');141 });142143 app.post('/auth/logout', { config: { auth: 'restricted', doc: { summary: 'Revoke the current session' } } }, async (req, reply) => {144 const a = authOf(req);145 await revokeSession(db, a.sessionId);146 ctx.live.kickAuthSessions([a.sessionId]);147 clearSessionCookie(ctx, reply);148 await audit.req(req, 'auth.logout');149 return { ok: true };150 });151152 app.post('/auth/password', { config: { doc: { summary: 'Change password (revokes all other sessions)', body: ChangePassword, bodyName: 'ChangePassword' } } }, async (req, reply) => {153 const a = authOf(req);154 const body = parse(ChangePassword, req.body);155 const keys = { ip: req.ip, username: a.username };156 await enforceThrottle(ctx, reply, keys);157 const user = await userById(db, a.userId);158 if (!user || !(await verifyPassword(body.currentPassword, user.password_hash))) {159 await throttleRecord(db, keys, false);160 await audit.req(req, 'auth.password', { ok: false });161 throw invalidCredentials();162 }163 await db.q('UPDATE users SET password_hash = $2, updated_at = now() WHERE id = $1', [user.id, await hashPassword(body.newPassword, config.passwordCost)]);164 const revoked = await revokeOtherSessions(db, user.id, a.sessionId);165 ctx.live.kickAuthSessions(revoked);166 await audit.req(req, 'auth.password', { detail: { revokedSessions: revoked.length } });167 return { ok: true };168 });169170 registerFactorRoutes(app, ctx);171}172