SPB Git

Repository Public

auth.ts 30.6 KB 441 lines typescript

1import { randomBytes, randomUUID } from 'node:crypto';2import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';3import '@fastify/cookie';4import {5  generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse,6  type AuthenticationResponseJSON, type RegistrationResponseJSON,7} from '@simplewebauthn/server';8import { z } from 'zod';9import { masterKey, usedBytes } from './blob.ts';10import { config, notFound, secureCookies, UserError } from './config.ts';11import { base32, newToken, seal, sha256hex, tokenHash, totpVerify, unseal } from './crypto.ts';12import { type Actor, audit, type Db, notify, sql, tx } from './db.ts';1314export const COOKIE = secureCookies ? '__Host-spd' : 'spd';15const ABSOLUTE_DAYS = 30, IDLE_DAYS = 14, ROTATE_HOURS = 24, REAUTH_MIN = 5, RECOVERY_SESSION_MIN = 20;1617export interface User { id: string; email: string; display_name: string; role: 'admin' | 'user'; locale: string }18export interface Session { id: string; method: 'passkey' | 'recovery'; reauth_at: Date | null; user: User }19declare module 'fastify' { interface FastifyRequest { session?: Session } interface FastifyContextConfig { allowRecovery?: boolean } }2021export const actorOf = (req: FastifyRequest): Actor => ({22  kind: req.session ? 'user' : 'anonymous', userId: req.session?.user.id ?? null, ip: req.ip, userAgent: String(req.headers['user-agent'] ?? ''),23});2425// --- sessions ----------------------------------------------------------------------------------------------26function setCookie(reply: FastifyReply, token: string, maxAgeS: number) {27  reply.setCookie(COOKIE, token, { httpOnly: true, secure: secureCookies, sameSite: 'lax', path: '/', maxAge: maxAgeS });28}29async function startSession(t: Db, req: FastifyRequest, reply: FastifyReply, userId: string, method: 'passkey' | 'recovery'): Promise<void> {30  const token = newToken();31  const life = method === 'recovery' ? `${RECOVERY_SESSION_MIN} minutes` : `${ABSOLUTE_DAYS} days`;32  await t`insert into sessions (user_id, token_hash, method, expires_at, reauth_at, ip, user_agent)33    values (${userId}, ${tokenHash(token)}, ${method}, now() + ${life}::interval, ${method === 'passkey' ? new Date() : null}, ${req.ip},34      ${String(req.headers['user-agent'] ?? '').slice(0, 300)})`;35  setCookie(reply, token, method === 'recovery' ? RECOVERY_SESSION_MIN * 60 : ABSOLUTE_DAYS * 86400);36}3738interface SessionRow { id: string; method: 'passkey' | 'recovery'; reauth_at: Date | null; rotated_at: Date; expires_at: Date; user_id: string; email: string; display_name: string; role: 'admin' | 'user'; locale: string; via_prev: boolean }39/** Resolve the session cookie. Rotates the token once a day; the previous token stays valid one minute for requests in flight. */40export async function loadSession(req: FastifyRequest, reply: FastifyReply): Promise<Session | undefined> {41  const token = req.cookies[COOKIE];42  if (!token || token.length > 100) return undefined;43  const h = tokenHash(token);44  const [s] = await sql<SessionRow[]>`45    update sessions s set last_seen_at = now() from users u46    where u.id = s.user_id and u.disabled_at is null and s.revoked_at is null and s.expires_at > now()47      and s.last_seen_at > now() - ${`${IDLE_DAYS} days`}::interval48      and (s.token_hash = ${h} or (s.prev_token_hash = ${h} and s.prev_valid_until > now()))49    returning s.id, s.method, s.reauth_at, s.rotated_at, s.expires_at, u.id as user_id, u.email, u.display_name, u.role, u.locale, (s.token_hash <> ${h}) as via_prev`;50  if (!s) return undefined;51  if (!s.via_prev && s.method === 'passkey' && Date.now() - s.rotated_at.getTime() > ROTATE_HOURS * 3600_000) {52    const next = newToken();53    const [r] = await sql`update sessions set prev_token_hash = token_hash, prev_valid_until = now() + interval '60 seconds', token_hash = ${tokenHash(next)}, rotated_at = now()54      where id = ${s.id} and token_hash = ${h} returning id`;55    if (r) setCookie(reply, next, Math.max(60, Math.floor((s.expires_at.getTime() - Date.now()) / 1000)));56  }57  return { id: s.id, method: s.method, reauth_at: s.reauth_at, user: { id: s.user_id, email: s.email, display_name: s.display_name, role: s.role, locale: s.locale } };58}5960/** preHandler: a signed-in account. A recovery session may only reach routes that opt in. */61export async function requireUser(req: FastifyRequest): Promise<void> {62  if (!req.session) throw new UserError('unauthenticated', 'sign in required', 401);63  if (req.session.method === 'recovery' && !(req.routeOptions.config as { allowRecovery?: boolean }).allowRecovery)64    throw new UserError('recovery_session', 'this recovery session can only enrol a new passkey', 403);65}66export const userOf = (req: FastifyRequest): User => { if (!req.session) throw new UserError('unauthenticated', 'sign in required', 401); return req.session.user; };67/** Sensitive operations need a passkey assertion made in the last few minutes. */68export function requireFreshAuth(req: FastifyRequest): void {69  const at = req.session?.reauth_at;70  if (!at || Date.now() - at.getTime() > REAUTH_MIN * 60_000) throw new UserError('reauth_required', 'confirm with your passkey', 401);71}72export function requireAdmin(req: FastifyRequest): void {73  if (userOf(req).role !== 'admin') throw notFound();74}7576// --- challenges --------------------------------------------------------------------------------------------77async function putChallenge(kind: string, challenge: string, userId: string | null = null, data: Record<string, unknown> = {}): Promise<string> {78  const id = randomUUID();79  await sql`delete from auth_challenges where expires_at < now()`;80  await sql`insert into auth_challenges (id, kind, challenge, user_id, data, expires_at) values (${id}, ${kind}, ${challenge}, ${userId}, ${sql.json(data as never)}, now() + interval '5 minutes')`;81  return id;82}83async function takeChallenge(id: string, kind: string, userId: string | null = null): Promise<{ challenge: string; data: Record<string, any> }> {84  const [r] = await sql<{ challenge: string; data: Record<string, any> }[]>`delete from auth_challenges85    where id = ${id} and kind = ${kind} and expires_at > now() and user_id is not distinct from ${userId} returning challenge, data`;86  if (!r) throw new UserError('challenge_expired', 'challenge expired, try again', 401);87  return r;88}8990// --- WebAuthn helpers --------------------------------------------------------------------------------------91interface Passkey { id: string; user_id: string; credential_id: string; public_key: string; counter: string; transports: string[]; label: string; device_type: string; backed_up: boolean; created_at: Date; last_used_at: Date | null }92const passkeysOf = (userId: string) => sql<Passkey[]>`select * from passkeys where user_id = ${userId} order by created_at`;9394const regOptions = (u: { handle: Buffer; email: string; displayName: string }, exclude: Passkey[]) => generateRegistrationOptions({95  rpName: 'SP Drive', rpID: config.rpId, userName: u.email, userDisplayName: u.displayName, userID: new Uint8Array(u.handle),96  attestationType: 'none', excludeCredentials: exclude.map((c) => ({ id: c.credential_id, transports: c.transports as never })),97  authenticatorSelection: { residentKey: 'required', userVerification: 'required' },98});99async function verifyReg(response: unknown, expectedChallenge: string) {100  const v = await verifyRegistrationResponse({ response: response as RegistrationResponseJSON, expectedChallenge, expectedOrigin: config.origin, expectedRPID: config.rpId, requireUserVerification: true })101    .catch((e: Error) => { throw new UserError('registration_failed', e.message, 401); });102  if (!v.verified || !v.registrationInfo) throw new UserError('registration_failed', 'passkey registration not verified', 401);103  const c = v.registrationInfo.credential;104  return { credentialId: c.id, publicKey: Buffer.from(c.publicKey).toString('base64url'), counter: c.counter, transports: (c.transports ?? []) as string[],105    deviceType: v.registrationInfo.credentialDeviceType, backedUp: v.registrationInfo.credentialBackedUp };106}107type NewCred = Awaited<ReturnType<typeof verifyReg>>;108const insertPasskey = (t: Db, userId: string, c: NewCred, label: string) => t`insert into passkeys (user_id, credential_id, public_key, counter, transports, device_type, backed_up, label)109  values (${userId}, ${c.credentialId}, ${c.publicKey}, ${c.counter}, ${t.json(c.transports)}, ${c.deviceType}, ${c.backedUp}, ${label})`;110111/** Verify an assertion against the stored credential; returns the passkey. `userId` restricts to one account (re-authentication). */112async function verifyAssertion(response: unknown, expectedChallenge: string, userId?: string): Promise<Passkey> {113  const r = response as AuthenticationResponseJSON;114  const fail = () => new UserError('authentication_failed', 'passkey not accepted', 401);115  if (!r || typeof r.id !== 'string' || r.id.length > 1024) throw fail();116  const [pk] = await sql<Passkey[]>`select p.* from passkeys p join users u on u.id = p.user_id where p.credential_id = ${r.id} and u.disabled_at is null`;117  if (!pk || (userId && pk.user_id !== userId)) throw fail();118  const v = await verifyAuthenticationResponse({119    response: r, expectedChallenge, expectedOrigin: config.origin, expectedRPID: config.rpId, requireUserVerification: true,120    credential: { id: pk.credential_id, publicKey: new Uint8Array(Buffer.from(pk.public_key, 'base64url')), counter: Number(pk.counter), transports: pk.transports as never },121  }).catch(() => { throw fail(); });122  if (!v.verified) throw fail();123  await sql`update passkeys set counter = ${v.authenticationInfo.newCounter}, last_used_at = now() where id = ${pk.id}`;124  return pk;125}126127// --- recovery codes ----------------------------------------------------------------------------------------128const codeHash = (code: string) => sha256hex(`spd-recovery-v1:${code.replace(/[^A-Za-z0-9]/g, '').toUpperCase()}`);129async function issueRecoveryCodes(t: Db, userId: string): Promise<string[]> {130  await t`delete from recovery_codes where user_id = ${userId}`;131  const codes = Array.from({ length: 10 }, () => { const s = base32(randomBytes(10)); return `${s.slice(0, 4)}-${s.slice(4, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}`; });132  for (const c of codes) await t`insert into recovery_codes (user_id, code_hash) values (${userId}, ${codeHash(c)})`;133  return codes;134}135136// --- accounts ----------------------------------------------------------------------------------------------137export async function createInvite(t: Db, o: { email: string; role: 'admin' | 'user'; quotaBytes?: number; createdBy?: string | null; hours?: number }): Promise<{ id: string; token: string }> {138  const token = newToken();139  const [r] = await t<{ id: string }[]>`insert into invites (token_hash, email, role, quota_bytes, created_by, expires_at)140    values (${tokenHash(token)}, ${o.email}, ${o.role}, ${o.quotaBytes ?? config.defaultQuotaBytes}, ${o.createdBy ?? null}, now() + ${`${o.hours ?? 72} hours`}::interval) returning id`;141  return { id: r!.id, token };142}143interface Invite { id: string; email: string; role: 'admin' | 'user'; quota_bytes: string }144const liveInvite = async (t: Db, token: string, lock = false): Promise<Invite> => {145  const [i] = await t<Invite[]>`select id, email, role, quota_bytes::text from invites where token_hash = ${tokenHash(token)} and used_at is null and expires_at > now() ${lock ? t`for update` : t``}`;146  if (!i) throw new UserError('invite_invalid', 'this invitation is invalid or expired', 403);147  return i;148};149150const Token = z.string().min(20).max(100);151const Label = z.string().trim().max(60).optional();152const strict = { config: { rateLimit: { max: 20, timeWindow: '1 minute' } } };153const veryStrict = { config: { rateLimit: { max: 8, timeWindow: '15 minutes' } } };154155export function authRoutes(app: FastifyInstance): void {156  const authed = { preHandler: requireUser };157  const authedOrRecovery = { preHandler: requireUser, config: { allowRecovery: true } };158  const recoveryStrict = { preHandler: requireUser, config: { allowRecovery: true, ...strict.config } };159160  app.get('/api/v1/auth/state', { config: { allowRecovery: true } }, async (req) => {161    const s = req.session;162    if (!s) return { authenticated: false };163    const [q] = await sql<{ limit_bytes: string }[]>`select limit_bytes::text from quotas where user_id = ${s.user.id}`;164    return { authenticated: true, recovery: s.method === 'recovery', user: s.user, quota: { limit: Number(q?.limit_bytes ?? 0), used: await usedBytes(sql, s.user.id) },165      freshAuth: !!s.reauth_at && Date.now() - s.reauth_at.getTime() < REAUTH_MIN * 60_000 };166  });167168  // --- registration by one-time invitation ---169  app.post('/api/v1/auth/invite', strict, async (req) => {170    const { invite } = z.object({ invite: Token }).parse(req.body);171    const i = await liveInvite(sql, invite);172    return { email: i.email };173  });174  app.post('/api/v1/auth/register/options', strict, async (req) => {175    const b = z.object({ invite: Token, displayName: z.string().trim().min(1).max(80) }).parse(req.body);176    const i = await liveInvite(sql, b.invite);177    const handle = randomBytes(32);178    const options = await regOptions({ handle, email: i.email, displayName: b.displayName }, []);179    return { challengeId: await putChallenge('register', options.challenge, null, { invite: i.id, handle: handle.toString('base64url'), displayName: b.displayName }), options };180  });181  app.post('/api/v1/auth/register/verify', strict, async (req, reply) => {182    const b = z.object({ invite: Token, challengeId: z.uuid(), response: z.any(), label: Label }).parse(req.body);183    const ch = await takeChallenge(b.challengeId, 'register');184    const cred = await verifyReg(b.response, ch.challenge);185    const recoveryCodes = await tx(async (t) => {186      const i = await liveInvite(t, b.invite, true);187      if (i.id !== ch.data.invite) throw new UserError('invite_invalid', 'invitation mismatch', 403);188      const [taken] = await t`select 1 from users where email = ${i.email}`;189      if (taken) throw new UserError('invite_invalid', 'this invitation is invalid or expired', 403);190      const [u] = await t<{ id: string }[]>`insert into users (email, display_name, role, webauthn_handle)191        values (${i.email}, ${String(ch.data.displayName)}, ${i.role}, ${Buffer.from(String(ch.data.handle), 'base64url')}) returning id`;192      const userId = u!.id;193      await t`insert into quotas (user_id, limit_bytes) values (${userId}, ${i.quota_bytes})`;194      await t`insert into folders (owner_id, parent_id, name, name_key) values (${userId}, null, '', '')`;195      await insertPasskey(t, userId, cred, b.label || 'Passkey');196      await t`update invites set used_at = now() where id = ${i.id}`;197      const codes = await issueRecoveryCodes(t, userId);198      await startSession(t, req, reply, userId, 'passkey');199      await audit(t, { ...actorOf(req), kind: 'user', userId }, 'account.created', { targetType: 'user', targetId: userId, meta: { role: i.role } });200      return codes;201    });202    return { ok: true, recoveryCodes };203  });204205  // --- sign-in ---206  app.post('/api/v1/auth/login/options', strict, async () => {207    const options = await generateAuthenticationOptions({ rpID: config.rpId, userVerification: 'required' });208    return { challengeId: await putChallenge('login', options.challenge), options };209  });210  app.post('/api/v1/auth/login/verify', strict, async (req, reply) => {211    const b = z.object({ challengeId: z.uuid(), response: z.any() }).parse(req.body);212    const ch = await takeChallenge(b.challengeId, 'login');213    let pk: Passkey;214    try { pk = await verifyAssertion(b.response, ch.challenge); } catch (e) {215      await audit(sql, actorOf(req), 'auth.login_failed', { meta: { method: 'passkey' } });216      throw e;217    }218    await tx(async (t) => {219      await startSession(t, req, reply, pk.user_id, 'passkey');220      await audit(t, { ...actorOf(req), kind: 'user', userId: pk.user_id }, 'auth.login', { meta: { method: 'passkey', passkey: pk.id } });221    });222    return { ok: true };223  });224  app.post('/api/v1/auth/logout', { config: { allowRecovery: true } }, async (req, reply) => {225    if (req.session) { await sql`update sessions set revoked_at = now() where id = ${req.session.id}`; await audit(sql, actorOf(req), 'auth.logout'); }226    reply.clearCookie(COOKIE, { path: '/' });227    return { ok: true };228  });229230  // --- recovery: a code (plus TOTP when enabled) opens a short session that can only enrol a new passkey ---231  app.post('/api/v1/auth/recovery', veryStrict, async (req, reply) => {232    const b = z.object({ email: z.string().trim().max(200), code: z.string().trim().min(8).max(40), totp: z.string().trim().max(10).optional() }).parse(req.body);233    const fail = new UserError('recovery_failed', 'email, code or verification code not accepted', 401);234    const [u] = await sql<{ id: string; totp_secret: Buffer | null; totp_enabled_at: Date | null; totp_last_step: string | null }[]>`235      select id, totp_secret, totp_enabled_at, totp_last_step::text from users where email = ${b.email} and disabled_at is null`;236    const actor = actorOf(req);237    if (!u) { await audit(sql, actor, 'auth.recovery_failed', { meta: { reason: 'unknown' } }); throw fail; }238    // account-level brake, independent of the caller's address239    const [f] = await sql<{ n: number }[]>`select count(*)::int as n from audit_events where owner_id = ${u.id} and action = 'auth.recovery_failed' and at > now() - interval '1 hour'`;240    if (f!.n >= 10) throw new UserError('recovery_locked', 'too many attempts, try again later', 429);241    const deny = async (reason: string) => { await audit(sql, actor, 'auth.recovery_failed', { ownerId: u.id, meta: { reason } }); return fail; };242    let step: number | null = null;243    if (u.totp_enabled_at && u.totp_secret) {244      step = totpVerify(unseal(masterKey(), u.totp_secret, `totp:${u.id}`), b.totp ?? '');245      if (step === null || (u.totp_last_step !== null && step <= Number(u.totp_last_step))) throw await deny('totp');246    }247    const ok = await tx(async (t) => {248      const [c] = await t`update recovery_codes set used_at = now() where user_id = ${u.id} and code_hash = ${codeHash(b.code)} and used_at is null returning id`;249      if (!c) return false;250      if (step !== null) await t`update users set totp_last_step = ${step} where id = ${u.id}`;251      await startSession(t, req, reply, u.id, 'recovery');252      await audit(t, { ...actor, kind: 'user', userId: u.id }, 'auth.recovery_used');253      await notify(t, u.id, 'security.recovery_used', { ip: req.ip });254      return true;255    });256    if (!ok) throw await deny('code');257    return { ok: true };258  });259260  // --- re-authentication ---261  app.post('/api/v1/auth/reauth/options', { ...authed, ...strict }, async (req) => {262    const u = userOf(req);263    const options = await generateAuthenticationOptions({ rpID: config.rpId, userVerification: 'required',264      allowCredentials: (await passkeysOf(u.id)).map((c) => ({ id: c.credential_id, transports: c.transports as never })) });265    return { challengeId: await putChallenge('reauth', options.challenge, u.id), options };266  });267  app.post('/api/v1/auth/reauth/verify', { ...authed, ...strict }, async (req) => {268    const b = z.object({ challengeId: z.uuid(), response: z.any() }).parse(req.body);269    const u = userOf(req);270    const ch = await takeChallenge(b.challengeId, 'reauth', u.id);271    await verifyAssertion(b.response, ch.challenge, u.id);272    await sql`update sessions set reauth_at = now() where id = ${req.session!.id}`;273    return { ok: true };274  });275276  // --- passkeys ---277  app.get('/api/v1/account/passkeys', authedOrRecovery, async (req) =>278    (await passkeysOf(userOf(req).id)).map((c) => ({ id: c.id, label: c.label, deviceType: c.device_type, backedUp: c.backed_up, transports: c.transports, createdAt: c.created_at, lastUsedAt: c.last_used_at })));279  app.post('/api/v1/account/passkeys/options', recoveryStrict, async (req) => {280    const u = userOf(req);281    if (req.session!.method !== 'recovery') requireFreshAuth(req);282    const [row] = await sql<{ webauthn_handle: Buffer }[]>`select webauthn_handle from users where id = ${u.id}`;283    const options = await regOptions({ handle: row!.webauthn_handle, email: u.email, displayName: u.display_name }, await passkeysOf(u.id));284    return { challengeId: await putChallenge('add-passkey', options.challenge, u.id), options };285  });286  app.post('/api/v1/account/passkeys/verify', recoveryStrict, async (req, reply) => {287    const b = z.object({ challengeId: z.uuid(), response: z.any(), label: Label }).parse(req.body);288    const u = userOf(req);289    if (req.session!.method !== 'recovery') requireFreshAuth(req);290    const ch = await takeChallenge(b.challengeId, 'add-passkey', u.id);291    const cred = await verifyReg(b.response, ch.challenge);292    await tx(async (t) => {293      await insertPasskey(t, u.id, cred, b.label || 'Passkey');294      await audit(t, actorOf(req), 'account.passkey_added', { meta: { viaRecovery: req.session!.method === 'recovery' } });295      await notify(t, u.id, 'security.passkey_added', { label: b.label || 'Passkey' });296      if (req.session!.method === 'recovery') { // the recovery session ends here; a normal one replaces it297        await t`update sessions set revoked_at = now() where id = ${req.session!.id}`;298        await startSession(t, req, reply, u.id, 'passkey');299      }300    });301    return { ok: true };302  });303  app.patch('/api/v1/account/passkeys/:id', authed, async (req) => {304    const { id } = z.object({ id: z.uuid() }).parse(req.params);305    const { label } = z.object({ label: z.string().trim().min(1).max(60) }).parse(req.body);306    const [r] = await sql`update passkeys set label = ${label} where id = ${id} and user_id = ${userOf(req).id} returning id`;307    if (!r) throw notFound();308    return { ok: true };309  });310  app.delete('/api/v1/account/passkeys/:id', authed, async (req) => {311    const { id } = z.object({ id: z.uuid() }).parse(req.params);312    const u = userOf(req);313    requireFreshAuth(req);314    await tx(async (t) => {315      const all = await t<{ id: string }[]>`select id from passkeys where user_id = ${u.id} for update`;316      if (!all.some((p) => p.id === id)) throw notFound();317      if (all.length <= 1) throw new UserError('last_passkey', 'the last passkey cannot be removed', 409);318      await t`delete from passkeys where id = ${id}`;319      await audit(t, actorOf(req), 'account.passkey_removed', { targetType: 'passkey', targetId: id });320      await notify(t, u.id, 'security.passkey_removed', {});321    });322    return { ok: true };323  });324325  // --- sessions / devices ---326  app.get('/api/v1/account/sessions', authed, async (req) =>327    (await sql<{ id: string; created_at: Date; last_seen_at: Date; ip: string; user_agent: string; method: string }[]>`select id, created_at, last_seen_at, ip, user_agent, method from sessions328      where user_id = ${userOf(req).id} and revoked_at is null and expires_at > now() order by last_seen_at desc`)329      .map((s) => ({ id: s.id, createdAt: s.created_at, lastSeenAt: s.last_seen_at, ip: s.ip, userAgent: s.user_agent, method: s.method, current: s.id === req.session!.id })));330  app.delete('/api/v1/account/sessions/:id', authed, async (req) => {331    const { id } = z.object({ id: z.uuid() }).parse(req.params);332    const [r] = await sql`update sessions set revoked_at = now() where id = ${id} and user_id = ${userOf(req).id} and revoked_at is null returning id`;333    if (!r) throw notFound();334    await audit(sql, actorOf(req), 'account.session_revoked', { targetType: 'session', targetId: id });335    return { ok: true };336  });337  app.post('/api/v1/account/sessions/revoke-others', authed, async (req) => {338    const r = await sql`update sessions set revoked_at = now() where user_id = ${userOf(req).id} and id <> ${req.session!.id} and revoked_at is null returning id`;339    await audit(sql, actorOf(req), 'account.sessions_revoked', { meta: { count: r.length } });340    return { revoked: r.length };341  });342343  // --- recovery codes and TOTP ---344  app.get('/api/v1/account/security', authed, async (req) => {345    const u = userOf(req);346    const [r] = await sql<{ n: number; totp: boolean }[]>`select (select count(*)::int from recovery_codes where user_id = ${u.id} and used_at is null) as n,347      (select totp_enabled_at is not null from users where id = ${u.id}) as totp`;348    return { recoveryCodesLeft: r!.n, totpEnabled: r!.totp };349  });350  app.post('/api/v1/account/recovery-codes', authed, async (req) => {351    requireFreshAuth(req);352    const codes = await tx(async (t) => { const c = await issueRecoveryCodes(t, userOf(req).id); await audit(t, actorOf(req), 'account.recovery_codes_regenerated'); return c; });353    return { recoveryCodes: codes };354  });355  app.post('/api/v1/account/totp/setup', authed, async (req) => {356    requireFreshAuth(req);357    const u = userOf(req);358    const secret = randomBytes(20);359    const challengeId = await putChallenge('totp', '-', u.id, { sealed: seal(masterKey(), secret, `totp:${u.id}`).toString('base64url') });360    const s = base32(secret);361    return { challengeId, secret: s, uri: `otpauth://totp/${encodeURIComponent(`SP Drive:${u.email}`)}?secret=${s}&issuer=${encodeURIComponent('SP Drive')}&algorithm=SHA1&digits=6&period=30` };362  });363  app.post('/api/v1/account/totp/enable', { ...authed, ...strict }, async (req) => {364    const b = z.object({ challengeId: z.uuid(), code: z.string().trim() }).parse(req.body);365    requireFreshAuth(req);366    const u = userOf(req);367    const [c] = await sql<{ data: { sealed: string } }[]>`select data from auth_challenges where id = ${b.challengeId} and kind = 'totp' and user_id = ${u.id} and expires_at > now()`;368    if (!c) throw new UserError('challenge_expired', 'setup expired, start again', 401);369    const sealed = Buffer.from(c.data.sealed, 'base64url');370    const step = totpVerify(unseal(masterKey(), sealed, `totp:${u.id}`), b.code);371    if (step === null) throw new UserError('totp_invalid', 'code not accepted', 400);372    await tx(async (t) => {373      await t`update users set totp_secret = ${sealed}, totp_enabled_at = now(), totp_last_step = ${step} where id = ${u.id}`;374      await t`delete from auth_challenges where id = ${b.challengeId}`;375      await audit(t, actorOf(req), 'account.totp_enabled');376    });377    return { ok: true };378  });379  app.delete('/api/v1/account/totp', authed, async (req) => {380    requireFreshAuth(req);381    await tx(async (t) => { await t`update users set totp_secret = null, totp_enabled_at = null, totp_last_step = null where id = ${userOf(req).id}`; await audit(t, actorOf(req), 'account.totp_disabled'); });382    return { ok: true };383  });384  app.patch('/api/v1/account', authed, async (req) => {385    const b = z.object({ displayName: z.string().trim().min(1).max(80).optional(), locale: z.enum(['fr', 'en']).optional() }).parse(req.body);386    await sql`update users set display_name = coalesce(${b.displayName ?? null}, display_name), locale = coalesce(${b.locale ?? null}, locale) where id = ${userOf(req).id}`;387    return { ok: true };388  });389  /** Sign-ins, failures and security changes of the account. */390  app.get('/api/v1/account/activity', authed, async (req) =>391    (await sql<{ id: string; at: Date; action: string; ip: string; user_agent: string; meta: unknown }[]>`select id::text, at, action, ip, user_agent, meta from audit_events392      where owner_id = ${userOf(req).id} and (action like 'auth.%' or action like 'account.%') order by at desc limit 100`)393      .map((e) => ({ id: e.id, at: e.at, action: e.action, ip: e.ip, userAgent: e.user_agent, meta: e.meta })));394395  // --- administration ---396  const admin = { preHandler: [requireUser, async (req: FastifyRequest) => requireAdmin(req)] };397  app.get('/api/v1/admin/users', admin, async () =>398    (await sql<{ id: string; email: string; display_name: string; role: string; disabled_at: Date | null; created_at: Date; limit_bytes: string; used: string; passkeys: number }[]>`399      select u.id, u.email, u.display_name, u.role, u.disabled_at, u.created_at, q.limit_bytes::text,400        (select coalesce(sum(size), 0)::text from storage_objects o where o.owner_id = u.id) as used,401        (select count(*)::int from passkeys p where p.user_id = u.id) as passkeys402      from users u join quotas q on q.user_id = u.id order by u.created_at`)403      .map((u) => ({ id: u.id, email: u.email, displayName: u.display_name, role: u.role, disabled: !!u.disabled_at, createdAt: u.created_at, quota: Number(u.limit_bytes), used: Number(u.used), passkeys: u.passkeys })));404  app.patch('/api/v1/admin/users/:id', admin, async (req) => {405    requireFreshAuth(req);406    const { id } = z.object({ id: z.uuid() }).parse(req.params);407    const b = z.object({ quotaBytes: z.number().int().min(0).max(1e15).optional(), disabled: z.boolean().optional(), role: z.enum(['admin', 'user']).optional() }).parse(req.body);408    if (id === userOf(req).id && (b.disabled || b.role === 'user')) throw new UserError('self_lockout', 'you cannot disable or demote your own account', 409);409    await tx(async (t) => {410      const [u] = await t`select id from users where id = ${id} for update`;411      if (!u) throw notFound();412      if (b.quotaBytes !== undefined) await t`update quotas set limit_bytes = ${b.quotaBytes}, updated_at = now() where user_id = ${id}`;413      if (b.role) await t`update users set role = ${b.role} where id = ${id}`;414      if (b.disabled !== undefined) {415        await t`update users set disabled_at = ${b.disabled ? new Date() : null} where id = ${id}`;416        if (b.disabled) await t`update sessions set revoked_at = now() where user_id = ${id} and revoked_at is null`;417      }418      await audit(t, actorOf(req), 'admin.user_updated', { ownerId: id, targetType: 'user', targetId: id, meta: b });419    });420    return { ok: true };421  });422  app.get('/api/v1/admin/invites', admin, async () =>423    (await sql<{ id: string; email: string; role: string; expires_at: Date; used_at: Date | null; created_at: Date }[]>`select id, email, role, expires_at, used_at, created_at from invites order by created_at desc limit 100`)424      .map((i) => ({ id: i.id, email: i.email, role: i.role, expiresAt: i.expires_at, usedAt: i.used_at, createdAt: i.created_at })));425  app.post('/api/v1/admin/invites', admin, async (req) => {426    requireFreshAuth(req);427    const b = z.object({ email: z.email().max(200), role: z.enum(['admin', 'user']).default('user'), quotaBytes: z.number().int().min(0).max(1e15).optional() }).parse(req.body);428    const [taken] = await sql`select 1 from users where email = ${b.email}`;429    if (taken) throw new UserError('email_taken', 'an account already uses this address', 409);430    const inv = await createInvite(sql, { ...b, createdBy: userOf(req).id });431    await audit(sql, actorOf(req), 'admin.invite_created', { targetType: 'invite', targetId: inv.id, meta: { role: b.role } });432    return { id: inv.id, url: `${config.origin}/invite/${inv.token}` };433  });434  app.delete('/api/v1/admin/invites/:id', admin, async (req) => {435    const { id } = z.object({ id: z.uuid() }).parse(req.params);436    const [r] = await sql`delete from invites where id = ${id} and used_at is null returning id`;437    if (!r) throw notFound();438    return { ok: true };439  });440}441
Files main
Menu

Theme