SPB Git

Repository Public

factors.ts 13.0 KB 230 lines typescript

1/** Second factors: TOTP enrolment, passkey registration / login, device + session management. */2import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';3import { z } from 'zod';4import { Device, Id, LoginStep, TotpEnrollFinish, TotpEnrollStart } from '@cr/shared';5import { HttpError, authOf, notFound, parse, type Ctx } from '../../http.js';6import { randomToken, seal, sha256Hex, unseal } from '../../security/crypto.js';7import { cookieOptions } from '../../security/guard.js';8import { findSessionById, revokeSession, setPending } from '../../security/sessions.js';9import { throttleRecord } from '../../security/throttle.js';10import { base32Decode, generateTotpSecret, otpauthUrl, verifyTotp } from '../../security/totp.js';11import {12  AuthenticationCredential, CHALLENGE_TTL_MS, RegistrationCredential, authenticationOptions, registrationOptions,13  verifyAuthentication, verifyRegistration,14} from '../../security/webauthn.js';15import {16  assertMayEnrol, credentialsOf, enforceThrottle, factorsOf, invalidCredentials, issueSession, step, upgradeCurrentSession,17} from './common.js';1819/** Body of the verify endpoints: `{credential, label?}`; a bare credential JSON is accepted too. */20const wrapped = <S extends z.ZodType>(cred: S) =>21  z.preprocess(22    (v) => (v && typeof v === 'object' && 'id' in v && !('credential' in v) ? { credential: v } : v),23    z.object({ credential: cred, label: z.string().min(1).max(60).optional() }),24  );25const RegisterVerify = wrapped(RegistrationCredential);26const LoginVerify = wrapped(AuthenticationCredential);27const Pending = z.object({ challenge: z.string(), exp: z.number() });28const PendingTotp = z.object({ secretEnc: z.string(), exp: z.number() });2930async function pendingOf(ctx: Ctx, sessionId: string): Promise<Record<string, unknown>> {31  return (await findSessionById(ctx.db, sessionId))?.pending ?? {};32}3334export function registerFactorRoutes(app: FastifyInstance, ctx: Ctx): void {35  const { db, audit, config } = ctx;3637  // ---------- TOTP enrolment ----------38  app.post('/auth/totp/enroll/start', { config: { auth: 'restricted', doc: { summary: 'Begin TOTP enrolment', response: TotpEnrollStart, responseName: 'TotpEnrollStart' } } }, async (req) => {39    const a = authOf(req);40    await assertMayEnrol(ctx, req);41    const secret = generateTotpSecret();42    await setPending(db, a.sessionId, 'totpEnroll', {43      secretEnc: seal(config.secretKey, secret, `totp-enroll:${a.sessionId}`),44      exp: ctx.now() + 10 * 60_000,45    });46    await audit.req(req, 'auth.totp.enroll.start');47    return { secret, otpauthUrl: otpauthUrl(secret, a.username, ctx.config.agent.productName) };48  });4950  app.post('/auth/totp/enroll/finish', { config: { auth: 'restricted', doc: { summary: 'Confirm TOTP enrolment with a code', body: TotpEnrollFinish, bodyName: 'TotpCode', response: LoginStep, responseName: 'LoginStep' } } }, async (req, reply) => {51    const a = authOf(req);52    await assertMayEnrol(ctx, req);53    const body = parse(TotpEnrollFinish, req.body);54    const keys = { ip: req.ip, username: a.username };55    await enforceThrottle(ctx, reply, keys);56    const p = PendingTotp.safeParse((await pendingOf(ctx, a.sessionId)).totpEnroll);57    if (!p.success || p.data.exp < ctx.now()) throw new HttpError(409, 'no_enrolment', 'no TOTP enrolment in progress');58    const secret = unseal(config.secretKey, p.data.secretEnc, `totp-enroll:${a.sessionId}`);59    const counter = verifyTotp(base32Decode(secret), body.code, ctx.now(), null);60    if (counter === null) {61      await throttleRecord(db, keys, false);62      await audit.req(req, 'auth.totp.enroll.finish', { ok: false });63      throw new HttpError(401, 'invalid_code', 'invalid code');64    }65    await db.q('UPDATE users SET totp_secret_enc = $2, totp_last_counter = $3, updated_at = now() WHERE id = $1', [66      a.userId, seal(config.secretKey, secret, `totp:${a.userId}`), counter,67    ]);68    await setPending(db, a.sessionId, 'totpEnroll', null);69    if (!a.secondFactor) await upgradeCurrentSession(ctx, req, reply);70    await audit.req(req, 'auth.totp.enroll.finish');71    return step('done');72  });7374  app.delete('/auth/totp', { config: { doc: { summary: 'Remove TOTP (needs another second factor)' } } }, async (req) => {75    const a = authOf(req);76    if ((await factorsOf(db, a.userId)).passkeys === 0) {77      throw new HttpError(409, 'last_second_factor', 'cannot remove the last second factor');78    }79    await db.q('UPDATE users SET totp_secret_enc = NULL, totp_last_counter = NULL, updated_at = now() WHERE id = $1', [a.userId]);80    await audit.req(req, 'auth.totp.delete');81    return { ok: true };82  });8384  // ---------- passkey registration ----------85  app.post('/auth/passkey/register/options', { config: { auth: 'restricted', doc: { summary: 'WebAuthn registration options' } } }, async (req) => {86    const a = authOf(req);87    await assertMayEnrol(ctx, req);88    const options = await registrationOptions(config, { id: a.userId, username: a.username }, await credentialsOf(db, a.userId));89    await setPending(db, a.sessionId, 'webauthnRegister', { challenge: options.challenge, exp: ctx.now() + CHALLENGE_TTL_MS });90    return options;91  });9293  app.post('/auth/passkey/register/verify', { config: { auth: 'restricted', doc: { summary: 'WebAuthn registration verification', body: RegisterVerify, bodyName: 'PasskeyRegisterVerify', response: LoginStep, responseName: 'LoginStep' } } }, async (req, reply) => {94    const a = authOf(req);95    await assertMayEnrol(ctx, req);96    const body = parse(RegisterVerify, req.body);97    const keys = { ip: req.ip, username: a.username };98    await enforceThrottle(ctx, reply, keys);99    const p = Pending.safeParse((await pendingOf(ctx, a.sessionId)).webauthnRegister);100    await setPending(db, a.sessionId, 'webauthnRegister', null); // single use101    if (!p.success || p.data.exp < ctx.now()) throw new HttpError(409, 'no_challenge', 'no registration in progress');102    const cred = await verifyRegistration(config, body.credential, p.data.challenge);103    if (!cred) {104      await throttleRecord(db, keys, false);105      await audit.req(req, 'auth.passkey.register', { ok: false });106      throw new HttpError(401, 'passkey_rejected', 'passkey registration could not be verified');107    }108    const row = await db.one<{ id: string }>(109      `INSERT INTO devices (user_id, label, credential_id, public_key, counter, transports)110       VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (credential_id) DO NOTHING RETURNING id`,111      [a.userId, body.label ?? 'Passkey', cred.credentialId, cred.publicKey, cred.counter, cred.transports],112    );113    if (!row) throw new HttpError(409, 'already_registered', 'this passkey is already registered');114    if (!a.secondFactor) await upgradeCurrentSession(ctx, req, reply);115    await audit.req(req, 'auth.passkey.register', { target: row.id });116    return step('done');117  });118119  // ---------- passkey login (first factor on its own, or second step after the password) ----------120  const preCookie = config.preAuthCookieName;121  const clearPre = (reply: FastifyReply): void => void reply.clearCookie(preCookie, cookieOptions(ctx, 0));122123  app.post('/auth/passkey/login/options', { config: { auth: 'preauth', doc: { summary: 'WebAuthn authentication options' } } }, async (req, reply) => {124    await enforceThrottle(ctx, reply, { ip: req.ip });125    if (req.auth) {126      const options = await authenticationOptions(config, await credentialsOf(db, req.auth.userId));127      await setPending(db, req.auth.sessionId, 'webauthnLogin', { challenge: options.challenge, exp: ctx.now() + CHALLENGE_TTL_MS });128      return options;129    }130    const options = await authenticationOptions(config, null);131    const token = randomToken(32);132    await db.q(133      "INSERT INTO preauth_challenges (token_hash, challenge, ip, expires_at) VALUES ($1, $2, $3, now() + interval '5 minutes')",134      [sha256Hex(token), options.challenge, req.ip],135    );136    void reply.setCookie(preCookie, token, cookieOptions(ctx, CHALLENGE_TTL_MS / 1000));137    return options;138  });139140  async function takeChallenge(req: FastifyRequest, reply: FastifyReply): Promise<string | null> {141    if (req.auth) {142      const p = Pending.safeParse((await pendingOf(ctx, req.auth.sessionId)).webauthnLogin);143      await setPending(db, req.auth.sessionId, 'webauthnLogin', null);144      return p.success && p.data.exp >= ctx.now() ? p.data.challenge : null;145    }146    const token = req.cookies[preCookie];147    clearPre(reply);148    if (!token) return null;149    const row = await db.one<{ challenge: string }>(150      'DELETE FROM preauth_challenges WHERE token_hash = $1 AND expires_at > now() RETURNING challenge',151      [sha256Hex(token)],152    );153    return row?.challenge ?? null;154  }155156  app.post('/auth/passkey/login/verify', { config: { auth: 'preauth', doc: { summary: 'WebAuthn authentication verification', body: LoginVerify, bodyName: 'PasskeyLoginVerify', response: LoginStep, responseName: 'LoginStep' } } }, async (req, reply) => {157    const body = parse(LoginVerify, req.body);158    await enforceThrottle(ctx, reply, { ip: req.ip, username: req.auth?.username });159    const challenge = await takeChallenge(req, reply);160    if (!challenge) throw new HttpError(409, 'no_challenge', 'no passkey login in progress');161    const dev = await db.one<{ id: string; user_id: string; username: string; credential_id: string; public_key: Buffer; counter: number; transports: string[] }>(162      `SELECT d.id, d.user_id, u.username, d.credential_id, d.public_key, d.counter, d.transports163         FROM devices d JOIN users u ON u.id = d.user_id WHERE d.credential_id = $1`,164      [body.credential.id],165    );166    const sameUser = !req.auth || req.auth.userId === dev?.user_id;167    const newCounter =168      dev && sameUser169        ? await verifyAuthentication(config, body.credential, challenge, {170            credentialId: dev.credential_id, publicKey: dev.public_key, counter: dev.counter, transports: dev.transports,171          })172        : null;173    const keys = { ip: req.ip, username: dev?.username ?? req.auth?.username };174    if (!dev || newCounter === null) {175      await throttleRecord(db, keys, false);176      await audit.req(req, 'auth.passkey.login', { ok: false });177      throw invalidCredentials();178    }179    await db.q('UPDATE devices SET counter = $2, last_used_at = now() WHERE id = $1', [dev.id, newCounter]);180    await throttleRecord(db, keys, true);181    if (req.auth && !req.auth.secondFactor) await upgradeCurrentSession(ctx, req, reply);182    else await issueSession(ctx, req, reply, dev.user_id, true);183    await audit.record({ actor: dev.username, action: 'auth.passkey.login', ip: req.ip, target: dev.id });184    return step('done');185  });186187  // ---------- devices (passkeys + active sessions) ----------188  app.get('/auth/devices', { config: { doc: { summary: 'Passkeys and active sessions', response: z.array(Device), responseName: 'DeviceList' } } }, async (req): Promise<Device[]> => {189    const a = authOf(req);190    const passkeys = await db.q<{ id: string; label: string; created_at: Date; last_used_at: Date | null }>(191      'SELECT id, label, created_at, last_used_at FROM devices WHERE user_id = $1 ORDER BY created_at', [a.userId],192    );193    const sessions = await db.q<{ id: string; user_agent: string | null; ip: string | null; created_at: Date; last_seen_at: Date }>(194      `SELECT id, user_agent, host(ip) AS ip, created_at, last_seen_at FROM auth_sessions195        WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > now() AND second_factor196          AND last_seen_at > now() - interval '12 hours' ORDER BY last_seen_at DESC`,197      [a.userId],198    );199    return [200      ...passkeys.map((p): Device => ({201        id: p.id, kind: 'passkey', label: p.label, createdAt: p.created_at.toISOString(),202        lastUsedAt: p.last_used_at?.toISOString() ?? null, current: false, userAgent: null, ip: null,203      })),204      ...sessions.map((s): Device => ({205        id: s.id, kind: 'session', label: 'Browser session', createdAt: s.created_at.toISOString(),206        lastUsedAt: s.last_seen_at.toISOString(), current: s.id === a.sessionId, userAgent: s.user_agent, ip: s.ip,207      })),208    ];209  });210211  app.delete('/auth/devices/:id', { config: { doc: { summary: 'Remove a passkey or revoke a session' } } }, async (req) => {212    const a = authOf(req);213    const { id } = parse(z.object({ id: Id }), req.params);214    const passkey = await db.one<{ id: string }>('SELECT id FROM devices WHERE id = $1 AND user_id = $2', [id, a.userId]);215    if (passkey) {216      const f = await factorsOf(db, a.userId);217      if (!f.totp && f.passkeys <= 1) throw new HttpError(409, 'last_second_factor', 'cannot remove the last second factor');218      await db.q('DELETE FROM devices WHERE id = $1', [id]);219      await audit.req(req, 'auth.device.delete', { target: id, detail: { kind: 'passkey' } });220      return { ok: true };221    }222    const session = await db.one<{ id: string }>('SELECT id FROM auth_sessions WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL', [id, a.userId]);223    if (!session) throw notFound('device');224    await revokeSession(db, id);225    ctx.live.kickAuthSessions([id]);226    await audit.req(req, 'auth.device.delete', { target: id, detail: { kind: 'session' } });227    return { ok: true };228  });229}230
Files main
Menu

Theme