import type { FastifyInstance, FastifyReply } from 'fastify'; import type { Locale, SessionResponse } from '@wellformed/shared'; import { badRequest, conflict, forbidden, notFound, tooManyRequests, unauthorized } from '../errors.js'; import { config } from '../config.js'; import { transactControl } from '../control/db.js'; import { createAccount, getAccountByEmail, getAccountById, getAccountWithHash, normaliseEmail, setAccountName, setAccountPassword, setAccountVerified, } from '../control/accounts.js'; import { createSession, destroyAllSessions, destroySession, SESSION_COOKIE, } from '../control/sessions.js'; import { claimToken, getLiveToken, issueToken } from '../control/tokens.js'; import { listWorkspacesForAccount } from '../control/workspaces.js'; import { attachAccount } from '../voice/store.js'; import { checkPasswordStrength, hashPassword, verifyPassword } from '../auth/password.js'; import { LoginThrottle } from '../auth/throttle.js'; import { emailEnabled, publicLink, resetEmail, sendMail, verifyEmail } from '../mail/mailer.js'; import { localeFromHeader } from '../locale.js'; import { changePasswordSchema, forgotSchema, loginSchema, resetPasswordSchema, signupSchema, tokenParamSchema, updateAccountSchema, } from './schemas.js'; export const throttle = new LoginThrottle(); /** Signup is cheaper to abuse than login, so it gets its own, tighter budget. */ export const signupThrottle = new LoginThrottle(5, 60 * 60_000, 60 * 60_000); /** * Asking for a reset costs an outbound message to an address the caller does * not have to prove they own, so the budget is tighter still. */ export const recoveryThrottle = new LoginThrottle(5, 60 * 60_000, 60 * 60_000); function setSessionCookie(reply: FastifyReply, token: string, expiresAt: Date): void { reply.setCookie(SESSION_COOKIE, token, { httpOnly: true, sameSite: 'lax', secure: config.isProd, path: '/', expires: expiresAt, }); } export async function authRoutes(app: FastifyInstance): Promise { app.post('/api/auth/login', async (request, reply) => { const body = loginSchema.parse(request.body); const email = normaliseEmail(body.email); // Throttle on the address as well as the account, so neither spraying one // password across many accounts nor guessing one account gets a free pass. const keys = [`email:${email}`, `ip:${request.ip}`]; for (const key of keys) { const waitMs = throttle.check(key); if (waitMs > 0) { throw tooManyRequests( `Too many sign-in attempts. Try again in ${Math.ceil(waitMs / 60_000)} minute(s).`, ); } } const account = getAccountWithHash(app.control, email); const ok = account && !account.disabled && (await verifyPassword(body.password, account.passwordHash)); if (!account || !ok) { for (const key of keys) throttle.fail(key); // Deliberately identical for unknown email, wrong password and disabled // account: the response must not enumerate who has an account here. throw unauthorized('That email and password combination is not recognised.'); } for (const key of keys) throttle.succeed(key); const session = createSession( app.control, account.id, config.sessionTtlMs, request.headers['user-agent'], ); setSessionCookie(reply, session.token, session.expiresAt); return sessionResponse(app, account.id); }); /** * Self-serve signup: one call creates the person, their first workspace and * their session, because an account with nowhere to go is not a signup. */ app.post('/api/auth/signup', async (request, reply) => { if (!config.signupsOpen) { throw forbidden('This installation is invitation-only. Ask someone here to invite you.'); } const body = signupSchema.parse(request.body); const email = normaliseEmail(body.email); const waitMs = signupThrottle.check(`ip:${request.ip}`); if (waitMs > 0) { throw tooManyRequests( `Too many accounts created from this address. Try again in ${Math.ceil(waitMs / 60_000)} minute(s).`, ); } if (getAccountByEmail(app.control, email)) { throw conflict('That email already has an account. Sign in instead.'); } const problem = checkPasswordStrength(body.password); if (problem) throw badRequest(problem.message); const hash = await hashPassword(body.password); // An account and nothing else. The workspace comes after, from the picker // — which is also where the demo choice and the trial live. const account = createAccount(app.control, { email, name: body.name, passwordHash: hash }); signupThrottle.fail(`ip:${request.ip}`); // Best effort, and after the account exists: the landing page's measurement // is never allowed to be a reason a signup fails. if (body.voice) { try { attachAccount(app.control, body.voice, account.id); } catch (error) { request.log.warn({ err: error }, 'could not attribute signup to a landing page'); } } // Not awaited: signup should not wait on someone else's SMTP, and the // account works unconfirmed. The client can ask for another one. if (emailEnabled()) { void sendVerification(app, account.id, localeFromHeader(request.headers['accept-language'])).catch( (error: unknown) => { request.log.error({ err: error }, 'verification email not sent at signup'); }, ); } const session = createSession( app.control, account.id, config.sessionTtlMs, request.headers['user-agent'], ); setSessionCookie(reply, session.token, session.expiresAt); reply.code(201); return sessionResponse(app, account.id); }); app.post('/api/auth/logout', async (request, reply) => { const token = request.cookies[SESSION_COOKIE]; if (token) destroySession(app.control, token); reply.clearCookie(SESSION_COOKIE, { path: '/' }); return { ok: true }; }); /** Who you are and every workspace you can open — the client's whole bootstrap. */ app.get('/api/auth/me', async (request): Promise => { if (!request.account) return { account: null, workspaces: [] }; return sessionResponse(app, request.account.id); }); app.patch('/api/auth/me', async (request) => { const account = request.requireAccount(); const body = updateAccountSchema.parse(request.body); setAccountName(app.control, account.id, body.name); return sessionResponse(app, account.id); }); app.post('/api/auth/password', async (request, reply) => { const account = request.requireAccount(); const body = changePasswordSchema.parse(request.body); const record = getAccountWithHash(app.control, account.email); if (!record || !(await verifyPassword(body.current, record.passwordHash))) { throw unauthorized('Your current password is not correct.'); } if (body.current === body.next) { throw badRequest('The new password must differ from the current one.'); } const problem = checkPasswordStrength(body.next); if (problem) throw badRequest(problem.message); const hash = await hashPassword(body.next); transactControl(app.control, () => { setAccountPassword(app.control, account.id, hash); // Every other session belonged to the old password. destroyAllSessions(app.control, account.id); }); const session = createSession( app.control, account.id, config.sessionTtlMs, request.headers['user-agent'], ); setSessionCookie(reply, session.token, session.expiresAt); return { ok: true }; }); // ---- locked out --------------------------------------------------------- /** * Ask for a reset link. * * Always 202, always the same body, whether or not the address is here. The * login route already refuses to enumerate accounts; an endpoint that says * "no such account" would hand that back for free. * * When email is off the link cannot be delivered, and the response says so * plainly rather than pretending something is on its way. `npm run recover` * prints the link on the server instead — see `tenancy/recover-cli.ts`. */ app.post('/api/auth/forgot', async (request, reply) => { const body = forgotSchema.parse(request.body); const email = normaliseEmail(body.email); const waitMs = recoveryThrottle.check(`ip:${request.ip}`); if (waitMs > 0) { throw tooManyRequests( `Too many reset requests from this address. Try again in ${Math.ceil(waitMs / 60_000)} minute(s).`, ); } recoveryThrottle.fail(`ip:${request.ip}`); const account = getAccountByEmail(app.control, email); if (account && !account.disabled) { const { token } = issueToken(app.control, account.id, 'reset'); const link = publicLink(`/reset/${token}`); if (emailEnabled()) { // The account has no language of its own — the workspace does — so the // mail follows the browser that asked for it, which is the same person. const result = await sendMail( resetEmail( account.email, account.name, link, localeFromHeader(request.headers['accept-language']), ), ); // Logged, never returned: whether delivery worked is not the caller's // business, and saying so would answer "does this address exist?". if (!result.sent) request.log.error({ err: result.error }, 'reset email not sent'); } else if (!config.isProd) { request.log.warn(`[wellformed] email is off; reset link for ${account.email}: ${link}`); } } reply.code(202); return { ok: true, delivery: emailEnabled() ? ('email' as const) : ('none' as const), }; }); /** What a reset link is for, so the form can say whose password it changes. */ app.get('/api/auth/reset/:token', async (request) => { const { token } = tokenParamSchema.parse(request.params); const record = getLiveToken(app.control, token, 'reset'); const account = record && getAccountById(app.control, record.accountId); if (!record || !account || account.disabled) { throw notFound('This reset link has expired or has already been used.'); } return { email: account.email, name: account.name, expiresAt: record.expiresAt }; }); /** * Spend a reset link. * * Claiming the token, writing the password and ending every existing session * happen in one transaction: a reset that half-applied would leave whoever * took the account over still signed in. */ app.post('/api/auth/reset/:token', async (request, reply) => { const { token } = tokenParamSchema.parse(request.params); const body = resetPasswordSchema.parse(request.body); const record = getLiveToken(app.control, token, 'reset'); const account = record && getAccountById(app.control, record.accountId); if (!record || !account || account.disabled) { throw notFound('This reset link has expired or has already been used.'); } const problem = checkPasswordStrength(body.password); if (problem) throw badRequest(problem.message); const hash = await hashPassword(body.password); const claimed = transactControl(app.control, () => { if (!claimToken(app.control, record.id)) return false; setAccountPassword(app.control, account.id, hash); destroyAllSessions(app.control, account.id); // Reading a link sent to the address proves they hold it. setAccountVerified(app.control, account.id); return true; }); if (!claimed) throw conflict('This reset link has already been used.'); const session = createSession( app.control, account.id, config.sessionTtlMs, request.headers['user-agent'], ); setSessionCookie(reply, session.token, session.expiresAt); return sessionResponse(app, account.id); }); // ---- confirming the address --------------------------------------------- /** Send (or resend) a confirmation link to the signed-in account's address. */ app.post('/api/auth/verify', async (request) => { const account = request.requireAccount(); if (account.verified) return { ok: true, delivery: 'already' as const }; const waitMs = recoveryThrottle.check(`verify:${account.id}`); if (waitMs > 0) { throw tooManyRequests( `Too many confirmation messages. Try again in ${Math.ceil(waitMs / 60_000)} minute(s).`, ); } recoveryThrottle.fail(`verify:${account.id}`); if (!emailEnabled()) return { ok: true, delivery: 'none' as const }; const error = await sendVerification( app, account.id, localeFromHeader(request.headers['accept-language']), ); if (error) { request.log.error({ err: error }, 'verification email not sent'); throw badRequest('Could not send the confirmation message. Try again shortly.'); } return { ok: true, delivery: 'email' as const }; }); /** * Spend a confirmation link. Deliberately open to anyone holding it: people * read mail in a browser that is not signed in, and the token names exactly * one account, so no session is needed to say "yes, this address works". */ app.post('/api/auth/verify/:token', async (request) => { const { token } = tokenParamSchema.parse(request.params); const record = getLiveToken(app.control, token, 'verify'); const account = record && getAccountById(app.control, record.accountId); if (!record || !account) { throw notFound('This confirmation link has expired or has already been used.'); } const claimed = transactControl(app.control, () => { if (!claimToken(app.control, record.id)) return false; setAccountVerified(app.control, account.id); return true; }); if (!claimed) throw conflict('This confirmation link has already been used.'); return { ok: true, email: account.email }; }); } /** Issue and send a confirmation link. Returns a reason on failure, else null. */ async function sendVerification( app: FastifyInstance, accountId: string, locale?: Locale, ): Promise { const account = getAccountById(app.control, accountId); if (!account || account.verified) return null; const { token } = issueToken(app.control, account.id, 'verify'); const result = await sendMail( verifyEmail(account.email, account.name, publicLink(`/verify/${token}`), locale), ); return result.sent ? null : (result.error ?? 'unknown error'); } function sessionResponse(app: FastifyInstance, accountId: string): SessionResponse { const account = getAccountById(app.control, accountId); if (!account) return { account: null, workspaces: [] }; return { account, workspaces: listWorkspacesForAccount(app.control, accountId) }; }