SPB Git

Repository Public

index.ts 9.0 KB 172 lines typescript

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
Files main
Menu

Theme