import { batch, first, run, stmt } from "./db"; import { hashPassword, passwordNeedsRehash, randomToken, verifyPassword } from "./crypto"; import { consumeAuthToken, findAuthToken, storeAuthToken } from "./auth-tokens"; import { deleteUserSessions, sessionCreatedAt } from "./auth"; import { enforceRateLimit } from "./limits"; import { emailTemplates, queueEmail } from "./email"; import { publicUrl } from "./events/emit"; import { badRequest, conflict } from "./errors"; import { reclaimUnverifiedEmail } from "./accounts"; import { invalidateUserSignatures } from "./security/signatures"; const VERIFY_TTL_SECONDS = 60 * 60 * 24; const RESET_TTL_SECONDS = 60 * 60; type VerifyPayload = { email: string }; type SpentReason = "used" | "superseded"; /** `viaPrimary` is false when the link went to one of the user's other verified addresses. */ type ResetPayload = { passwordHash: string | null; viaPrimary: boolean }; function parseVerify(raw: string): VerifyPayload { const parsed: unknown = JSON.parse(raw); const email = typeof parsed === "object" && parsed !== null && "email" in parsed ? parsed.email : null; if (typeof email !== "string") throw badRequest("This verification link is invalid"); return { email }; } function parseReset(raw: string): ResetPayload { const parsed: unknown = JSON.parse(raw); const hash = typeof parsed === "object" && parsed !== null && "passwordHash" in parsed ? parsed.passwordHash : null; const viaPrimary = typeof parsed === "object" && parsed !== null && "viaPrimary" in parsed ? parsed.viaPrimary !== false : true; return { passwordHash: typeof hash === "string" ? hash : null, viaPrimary }; } async function displayNameFor(userId: string): Promise { return (await first<{ name: string }>("SELECT name FROM accounts WHERE id = ?", userId))?.name ?? "there"; } /** Sends a single-use verification link for the primary address or one of the user's other addresses. */ export async function sendEmailVerification(userId: string, email: string): Promise { const token = randomToken("verify"); await run( `UPDATE auth_tokens SET kind = 'email-verify-spent', payload = json_set(payload, '$.reason', 'superseded') WHERE kind = 'email-verify' AND user_id = ? AND json_extract(payload, '$.email') = ? COLLATE NOCASE`, userId, email, ); const payload: VerifyPayload = { email }; await storeAuthToken("email-verify", token, userId, JSON.stringify(payload), VERIFY_TTL_SECONDS); const url = publicUrl(`/verify-email?token=${encodeURIComponent(token)}`); const primary = await first<{ email: string }>("SELECT email FROM users WHERE account_id = ?", userId); const isPrimary = primary?.email.toLowerCase() === email.toLowerCase(); await queueEmail( email, emailTemplates.verifyEmail(await displayNameFor(userId), url, isPrimary ? "primary" : "added"), ); } /** Proving the inbox beats an unverified claim elsewhere only once that claim's reservation has lapsed. */ async function assertEmailClaimable(userId: string, email: string): Promise { const primaryOwner = await first<{ account_id: string }>("SELECT account_id FROM users WHERE email = ?", email); if (primaryOwner && primaryOwner.account_id !== userId && !(await reclaimUnverifiedEmail(email))) { throw conflict("That email address is already in use"); } const verifiedOwner = await first<{ user_id: string }>( "SELECT user_id FROM user_emails WHERE email = ? AND verified_at IS NOT NULL", email, ); if (verifiedOwner && verifiedOwner.user_id !== userId) throw conflict("That email address is already in use"); } async function isEmailVerified(userId: string, email: string): Promise { const row = await first<{ verified: number }>( `SELECT 1 AS verified FROM users WHERE account_id = ?1 AND email = ?2 COLLATE NOCASE AND email_verified_at IS NOT NULL UNION ALL SELECT 1 FROM user_emails WHERE user_id = ?1 AND email = ?2 COLLATE NOCASE AND verified_at IS NOT NULL`, userId, email, ); return row !== null; } /** Old links stay recognisable after use or resend so the page can say why they stopped working. */ async function explainSpentVerification(token: string): Promise<{ userId: string; email: string }> { const spent = await findAuthToken("email-verify-spent", token); if (!spent?.userId) { throw badRequest("This verification link is invalid or has expired. Request a new one from your settings."); } const { email } = parseVerify(spent.payload); if (await isEmailVerified(spent.userId, email)) return { userId: spent.userId, email }; const parsed: unknown = JSON.parse(spent.payload); const reason = typeof parsed === "object" && parsed !== null && "reason" in parsed ? parsed.reason : null; if (reason === "superseded") { throw badRequest(`A newer verification link was sent to ${email}. Use the link in the most recent email.`); } throw badRequest("This verification link was already used. Request a new one from your settings."); } export async function confirmEmailVerification(token: string): Promise<{ userId: string; email: string }> { const consumed = await consumeAuthToken("email-verify", token); if (!consumed?.userId) return explainSpentVerification(token); const userId = consumed.userId; const email = parseVerify(consumed.payload).email; const user = await first<{ email: string }>("SELECT email FROM users WHERE account_id = ?", userId); if (!user) throw badRequest("This verification link is invalid or has expired"); const now = Date.now(); if (user.email.toLowerCase() === email.toLowerCase()) { await run("UPDATE users SET email_verified_at = COALESCE(email_verified_at, ?) WHERE account_id = ?", now, userId); } else { const pending = await first<{ email: string }>( "SELECT email FROM user_emails WHERE user_id = ? AND email = ?", userId, email, ); if (!pending) throw badRequest("That address was removed from your account"); await assertEmailClaimable(userId, email); await batch([ stmt("DELETE FROM user_emails WHERE email = ? AND user_id != ? AND verified_at IS NULL", email, userId), stmt( "UPDATE user_emails SET verified_at = COALESCE(verified_at, ?) WHERE user_id = ? AND email = ?", now, userId, email, ), ]); } await invalidateUserSignatures(userId); const spentPayload: VerifyPayload & { reason: SpentReason } = { email, reason: "used" }; await storeAuthToken("email-verify-spent", token, userId, JSON.stringify(spentPayload), VERIFY_TTL_SECONDS); return { userId, email }; } /** Always succeeds from the caller's view so the endpoint can't be used to probe which emails exist. */ export async function requestPasswordReset(email: string): Promise { const user = await first<{ account_id: string; password_hash: string | null; email: string; via_primary: number; }>( `SELECT account_id, password_hash, email, 1 AS via_primary FROM users WHERE email = ?1 UNION ALL SELECT u.account_id, u.password_hash, e.email, 0 AS via_primary FROM user_emails e JOIN users u ON u.account_id = e.user_id WHERE e.email = ?1 AND e.verified_at IS NOT NULL LIMIT 1`, email, ); if (!user) return; const token = randomToken("reset"); const payload: ResetPayload = { passwordHash: user.password_hash, viaPrimary: user.via_primary === 1 }; await storeAuthToken("password-reset", token, user.account_id, JSON.stringify(payload), RESET_TTL_SECONDS); const url = publicUrl(`/reset-password?token=${encodeURIComponent(token)}`); await queueEmail(user.email, emailTemplates.passwordReset(await displayNameFor(user.account_id), url)); } export async function completePasswordReset(token: string, password: string): Promise { const consumed = await consumeAuthToken("password-reset", token); if (!consumed?.userId) throw badRequest("This reset link is invalid or has expired"); const payload = { userId: consumed.userId, ...parseReset(consumed.payload) }; const current = await first<{ password_hash: string | null; email: string; email_verified_at: number | null }>( "SELECT password_hash, email, email_verified_at FROM users WHERE account_id = ?", payload.userId, ); if (!current || current.password_hash !== payload.passwordHash) { throw badRequest("This reset link is no longer valid"); } const now = Date.now(); const statements = [ stmt( "UPDATE users SET password_hash = ?, sessions_revoked_at = ?, email_verified_at = COALESCE(email_verified_at, ?) WHERE account_id = ?", await hashPassword(password), now, payload.viaPrimary ? now : null, payload.userId, ), stmt("DELETE FROM auth_tokens WHERE kind = 'password-reset' AND user_id = ?", payload.userId), stmt("DELETE FROM sessions WHERE user_id = ?", payload.userId), stmt("DELETE FROM access_tokens WHERE user_id = ?", payload.userId), ]; // AUTH-A2: the inbox owner is taking over a row they never verified, which someone else created (and // could have enrolled 2FA, passkeys or signing keys on). Everything the creator set up goes. const handover = current.email_verified_at === null && payload.viaPrimary; if (handover) { statements.push( stmt( "UPDATE users SET totp_secret = NULL, totp_iv = NULL, totp_enabled_at = NULL, totp_last_step = NULL, github_id = NULL WHERE account_id = ?", payload.userId, ), stmt("DELETE FROM passkeys WHERE user_id = ?", payload.userId), stmt("DELETE FROM recovery_codes WHERE user_id = ?", payload.userId), stmt("DELETE FROM signing_keys WHERE user_id = ?", payload.userId), ); } await batch(statements); if (handover) await invalidateUserSignatures(payload.userId); await notifySecurityEvent( payload.userId, handover ? "your password was reset on an account whose email was never verified; its access tokens, two-factor methods, passkeys, recovery codes and signing keys were all removed" : "your password was reset and your personal access tokens were revoked", ); await run("DELETE FROM notification_unsubscribe_tokens WHERE user_id = ?", payload.userId); return payload.userId; } /** Signs out every existing session for the user (sessions created after this instant stay valid). */ export async function revokeAllSessions(userId: string): Promise { await run("UPDATE users SET sessions_revoked_at = ? WHERE account_id = ?", Date.now() - 1, userId); await deleteUserSessions(userId); } /** Account security notices always go out (no email budget) and never fail the action that triggered them. */ export async function notifySecurityEvent( userId: string, event: string, extraRecipients: string[] = [], ): Promise { try { const user = await first<{ email: string; email_verified_at: number | null }>( "SELECT email, email_verified_at FROM users WHERE account_id = ?", userId, ); if (!user) return; const recipients = new Map(extraRecipients.map((address) => [address.toLowerCase(), address])); if (user.email_verified_at) recipients.set(user.email.toLowerCase(), user.email); const content = emailTemplates.securityNotice(await displayNameFor(userId), event); for (const address of recipients.values()) await queueEmail(address, content); } catch (error) { console.error("Security notice failed", error); } } const RECENT_LOGIN_MS = 15 * 60 * 1000; /** Re-authentication for sensitive changes; password-less (OAuth-only) accounts need a fresh sign-in instead. */ export async function confirmPassword(userId: string, password: string): Promise { await enforceRateLimit("AUTH_LIMITER", `sudo:${userId}`); const user = await first<{ password_hash: string | null }>( "SELECT password_hash FROM users WHERE account_id = ?", userId, ); if (!user) throw badRequest("Account not found"); if (!user.password_hash) { const createdAt = await sessionCreatedAt(); if (createdAt === null || Date.now() - createdAt > RECENT_LOGIN_MS) { throw badRequest("Sign out and sign in again to confirm this change"); } return; } if (!(await verifyPassword(password, user.password_hash))) throw badRequest("Password is incorrect"); await rehashIfStale(userId, password, user.password_hash); } /** * Upgrades a hash that was made at a lower PBKDF2 cost now that the plaintext is in hand (AUTH-A4). * Conditional on the old hash so a concurrent password change is never clobbered. */ export async function rehashIfStale(userId: string, password: string, storedHash: string): Promise { if (!passwordNeedsRehash(storedHash)) return; await run( "UPDATE users SET password_hash = ? WHERE account_id = ? AND password_hash = ?", await hashPassword(password), userId, storedHash, ); }