Repository Public
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