Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

14.6 kB Ruw bestand
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) };
}