SPB Git

Repository Public

index.ts 9.2 KB 174 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, 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
Files main
Menu

Theme