SPB Git

Repository Public

common.ts 4.1 KB 90 lines typescript

1/** Helpers shared by the auth route files. */2import type { FastifyReply, FastifyRequest } from 'fastify';3import { LoginStep } from '../../dto-auth.js';4import { HttpError, type Ctx } from '../../http.js';5import type { Queryable } from '../../db.js';6import { setSessionCookie } from '../../security/guard.js';7import { ABSOLUTE_SEC, RESTRICTED_SEC, createSession, revokeSession, upgradeSession } from '../../security/sessions.js';8import { throttleCheck, type ThrottleKeys } from '../../security/throttle.js';9import type { StoredCredential } from '../../security/webauthn.js';1011export interface UserRow {12  id: string;13  username: string;14  password_hash: string;15  totp_secret_enc: string | null;16  totp_last_counter: number | null;17}18export const USER_COLS = 'id, username, password_hash, totp_secret_enc, totp_last_counter';19export const userById = (db: Queryable, id: string): Promise<UserRow | null> =>20  db.one<UserRow>(`SELECT ${USER_COLS} FROM users WHERE id = $1`, [id]);2122export async function credentialsOf(db: Queryable, userId: string): Promise<StoredCredential[]> {23  const rows = await db.q<{ credential_id: string; public_key: Buffer; counter: number; transports: string[] }>(24    'SELECT credential_id, public_key, counter, transports FROM devices WHERE user_id = $1 ORDER BY created_at',25    [userId],26  );27  return rows.map((r) => ({ credentialId: r.credential_id, publicKey: r.public_key, counter: r.counter, transports: r.transports }));28}29export async function factorsOf(db: Queryable, userId: string): Promise<{ totp: boolean; passkeys: number }> {30  const r = await db.one<{ totp: boolean; passkeys: number }>(31    `SELECT (totp_secret_enc IS NOT NULL) AS totp,32            (SELECT count(*)::int FROM devices d WHERE d.user_id = u.id) AS passkeys33       FROM users u WHERE u.id = $1`,34    [userId],35  );36  return r ?? { totp: false, passkeys: 0 };37}3839/** Throws 429 (with `retryAfterSec`) when the caller is currently locked out. */40export async function enforceThrottle(ctx: Ctx, reply: FastifyReply, keys: ThrottleKeys): Promise<void> {41  const wait = await throttleCheck(ctx.db, keys);42  if (wait > 0) {43    void reply.header('Retry-After', String(wait));44    throw new HttpError(429, 'locked', 'too many failed attempts, try again later', { retryAfterSec: wait });45  }46}4748/** Creates a session and sets the cookie. Any session already presented by the caller is revoked. */49export async function issueSession(50  ctx: Ctx,51  req: FastifyRequest,52  reply: FastifyReply,53  userId: string,54  secondFactor: boolean,55): Promise<void> {56  if (req.auth) {57    await revokeSession(ctx.db, req.auth.sessionId);58    ctx.live.kickAuthSessions([req.auth.sessionId]);59  }60  const s = await createSession(ctx.db, {61    userId,62    secondFactor,63    userAgent: typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : null,64    ip: req.ip,65  });66  setSessionCookie(ctx, reply, s.token, secondFactor ? ABSOLUTE_SEC : RESTRICTED_SEC);67}6869/** Marks the second factor complete: the token and CSRF token are rotated (new cookie). */70export async function upgradeCurrentSession(ctx: Ctx, req: FastifyRequest, reply: FastifyReply): Promise<void> {71  if (!req.auth) throw new HttpError(401, 'unauthenticated', 'authentication required');72  const s = await upgradeSession(ctx.db, req.auth.sessionId);73  setSessionCookie(ctx, reply, s.token, ABSOLUTE_SEC);74}7576export const step = (next: LoginStep['next'], ok = true): LoginStep => ({ ok, next });77export const invalidCredentials = (): HttpError => new HttpError(401, 'invalid_credentials', 'invalid credentials');7879/**80 * Enrolling a NEW second factor is allowed from a fully authenticated session, or from a81 * restricted one only while the account has no second factor at all (first-run enrolment).82 * Without this check a stolen password alone could attach an attacker-controlled factor.83 */84export async function assertMayEnrol(ctx: Ctx, req: FastifyRequest): Promise<void> {85  if (!req.auth) throw new HttpError(401, 'unauthenticated', 'authentication required');86  if (req.auth.secondFactor) return;87  const f = await factorsOf(ctx.db, req.auth.userId);88  if (f.totp || f.passkeys > 0) throw new HttpError(403, 'second_factor_required', 'second factor required');89}90
Files main
Menu

Theme