SPB Git

Repository Public

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

Theme