Vectrum
Sign In
187 lines · 8.7 KB · TypeScript
import "server-only";
import { createHash, randomBytes } from "node:crypto";
import { db, filter, findFirst, withLock } from "./db";
import { linkTo, MAIL_ENABLED, sendMail, sendMailLater, type Message } from "./mail";
import { ROLE_INFO, type OrgRole } from "./org-roles";
import { getPersonByLogin, getUserById, setPassword, type User } from "./users";

/*
 * The emails Vectrum sends (through lib/mail.ts), and the links in them that confirm an address
 * or set a new password. A link carries a random token; only its hash is kept (in email_tokens),
 * with the address it went to, so it stops working once the account's email changes.
 *
 * Notifications (invitations, access, security notices) only go to confirmed addresses: anyone can
 * sign up with someone else's.
 */

const LIFETIME_MS = { verify: 3 * 86400_000, reset: 60 * 60_000 };
/** At most one link of a kind per account in this time, so nobody can flood an inbox with them. */
const RESEND_MS = 60_000;

type Purpose = keyof typeof LIFETIME_MS;

const hash = (token: string) => createHash("sha256").update(token).digest("hex");
const tokens = async () => (await db()).collection("email_tokens");

/** A new token for a link, or null if one went out too recently. */
async function issueToken(user: User, purpose: Purpose) {
  return withLock(`email-tokens:${user.id}`, async () => {
    const now = Date.now();
    const since = now - RESEND_MS;
    if (await findFirst("email_tokens", filter("user = {:id} && purpose = {:purpose} && created_at > {:since}", { id: user.id, purpose, since }), { fields: "id" })) {
      return null;
    }
    const collection = await tokens();
    const expired = await collection.getFullList({ filter: filter("user = {:id} && expires_at < {:now}", { id: user.id, now }), fields: "id" });
    await Promise.all(expired.map((r) => collection.delete(r.id)));
    const token = randomBytes(32).toString("base64url");
    await collection.create({ user: user.id, purpose, email: user.email, token_hash: hash(token), expires_at: now + LIFETIME_MS[purpose], created_at: now });
    return token;
  });
}

/** Sends an email with a new link, or returns false if one went out too recently. A link that couldn't be sent doesn't count. */
async function sendLink(user: User, purpose: Purpose, message: (url: string) => Message) {
  const token = await issueToken(user, purpose);
  if (!token) return false;
  try {
    await sendMail(message(linkTo(`/login/${purpose === "verify" ? "verify-email" : "reset-password"}?token=${token}`)));
  } catch (e) {
    const row = await findFirst("email_tokens", filter("token_hash = {:hash}", { hash: hash(token) }), { fields: "id" });
    if (row) await (await tokens()).delete(row.id);
    throw e;
  }
  return true;
}

/** The account a link is for, while it's good. */
async function tokenUser(token: string, purpose: Purpose) {
  if (!token || token.length > 100) return null;
  const row = await findFirst("email_tokens", filter("token_hash = {:hash} && purpose = {:purpose}", { hash: hash(token), purpose }));
  if (!row || row.expires_at < Date.now()) return null;
  const user = await getUserById(row.user);
  if (!user || user.email.toLowerCase() !== String(row.email).toLowerCase()) return null;
  return user;
}

const markVerified = async (userId: string) => void (await (await db()).collection("users").update(userId, { verified: true }));

const logFailure = (what: string) => (e: unknown) => console.error(`[mail] couldn't send ${what}:`, e instanceof Error ? e.message : e);

// ---------------------------------------------------------------------------
// Confirming an address

/** Emails a link that confirms the user's address. "wait" if one went out less than a minute ago. */
export async function sendVerificationEmail(user: User): Promise<"sent" | "wait" | "verified"> {
  if (user.verified) return "verified";
  const sent = await sendLink(user, "verify", (url) => ({
    to: user.email,
    subject: "Confirm your email address",
    body: [`Hi ${user.name || user.username},`, `Confirm that ${user.email} is the email address of your account, @${user.username}.`],
    action: { label: "Confirm email address", url },
    footer: "The link works for 3 days. If you didn't sign up or change your email address, ignore this email.",
  }));
  return sent ? "sent" : "wait";
}

/** After signing up or changing the address: sends the link without holding up the request. */
export function sendVerificationEmailLater(user: User) {
  if (MAIL_ENABLED) sendVerificationEmail(user).catch(logFailure("the verification email"));
}

/**
 * Confirms the address a link was sent to. The link keeps working until it expires: mail
 * scanners open links before people do.
 */
export async function confirmEmail(token: string) {
  const user = await tokenUser(token, "verify");
  if (user && !user.verified) await markVerified(user.id);
  return user;
}

// ---------------------------------------------------------------------------
// Resetting a forgotten password

/**
 * Emails a link to set a new password if `login` (a username or email address) is someone's
 * account. It doesn't tell whether it is, and doesn't wait for the email, as how long that takes
 * would tell too.
 */
export async function requestPasswordReset(login: string) {
  const user = await getPersonByLogin(login);
  if (!user?.email) return;
  sendLink(user, "reset", (url) => ({
    to: user.email,
    subject: "Reset your password",
    body: [`Hi ${user.name || user.username},`, `Someone asked to reset the password of your account, @${user.username}. Choose a new one here:`],
    action: { label: "Choose a new password", url },
    footer: "The link works for one hour, once. If you didn't ask for it, ignore this email: your password stays the same.",
  })).catch(logFailure("the password reset email"));
}

/** The account a password reset link is for, while it's good. */
export const passwordResetUser = (token: string) => tokenUser(token, "reset");

/** Sets a new password with a reset link, which then stops working (as do the account's sessions). */
export async function resetPassword(token: string, password: string) {
  const user = await passwordResetUser(token);
  if (!user) return false;
  await setPassword(user.id, password);
  // The link reached them, so the address is theirs.
  if (!user.verified) await markVerified(user.id);
  notifyPasswordChanged({ ...user, verified: true });
  return true;
}

// ---------------------------------------------------------------------------
// Notifications

function notify(user: User, message: Omit<Message, "to">) {
  if (MAIL_ENABLED && user.verified && user.email) sendMailLater({ ...message, to: user.email });
}

export function notifyInvitation(user: User, org: User, inviter: User, role: OrgRole) {
  notify(user, {
    subject: `@${inviter.username} invited you to join ${org.username}`,
    body: [
      `Hi ${user.name || user.username},`,
      `@${inviter.username} invited you to join the organization ${org.name || org.username}, with the ${ROLE_INFO[role].title} role.`,
      "Accept or decline the invitation on your dashboard.",
    ],
    action: { label: "View invitation", url: linkTo("/settings/organizations") },
    footer: `You're getting this because @${inviter.username} invited your account, @${user.username}.`,
  });
}

export function notifyCollaborator(user: User, repo: { owner: string; name: string }, by: User) {
  notify(user, {
    subject: `You can now push to ${repo.owner}/${repo.name}`,
    body: [`Hi ${user.name || user.username},`, `@${by.username} added you as a collaborator to ${repo.owner}/${repo.name}. You can now push to it.`],
    action: { label: "Open repository", url: linkTo(`/${repo.owner}/${repo.name}`) },
    footer: `You're getting this because @${by.username} gave your account, @${user.username}, access.`,
  });
}

export function notifyPasswordChanged(user: User) {
  notify(user, {
    subject: "Your password was changed",
    body: [
      `Hi ${user.name || user.username},`,
      `The password of your account, @${user.username}, was just changed, and its other sessions were signed out.`,
      "If that wasn't you, reset your password right away.",
    ],
    action: { label: "Reset password", url: linkTo("/login/forgot-password") },
  });
}

/** Tells the old address (`before`) that the account's email changed, in case someone else did it. */
export function notifyEmailChanged(before: User, newEmail: string) {
  notify(before, {
    subject: "Your email address was changed",
    body: [
      `Hi ${before.name || before.username},`,
      `The email address of your account, @${before.username}, was changed from ${before.email} to ${newEmail}. Emails go to the new address from now on.`,
      "If that wasn't you, someone else has your account: tell whoever runs this server.",
    ],
  });
}