Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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<void> {
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<SessionResponse> => {
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<string | null> {
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) };
}