import { first, run } from "./db"; import { hashPassword, randomToken, verifyPassword } from "./crypto"; import { consumeAuthToken, revokeUserAuthTokens, storeAuthToken } from "./auth-tokens"; import { sessionCreatedAt } from "./auth"; import { enforceRateLimit } from "./limits"; import { emailTemplates, queueEmail } from "./email"; import { publicUrl } from "./events/emit"; import { badRequest, conflict } from "./errors"; const VERIFY_TTL_SECONDS = 60 * 60 * 24; const RESET_TTL_SECONDS = 60 * 60; type VerifyPayload = { email: string }; type ResetPayload = { passwordHash: string | null }; 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; return { passwordHash: typeof hash === "string" ? hash : null }; } 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; confirming it sets (or changes to) this address. */ export async function sendEmailVerification(userId: string, email: string): Promise { const token = randomToken("verify"); await revokeUserAuthTokens("email-verify", userId); const payload: VerifyPayload = { email }; await storeAuthToken("email-verify", token, userId, JSON.stringify(payload), VERIFY_TTL_SECONDS); const url = publicUrl(`/verify-email?token=${encodeURIComponent(token)}`); await queueEmail(email, emailTemplates.verifyEmail(await displayNameFor(userId), url)); } export async function confirmEmailVerification(token: string): Promise<{ userId: string; email: string }> { const consumed = await consumeAuthToken("email-verify", token); if (!consumed?.userId) throw badRequest("This verification link is invalid or has expired"); const payload = { userId: consumed.userId, email: parseVerify(consumed.payload).email }; const owner = await first<{ account_id: string }>("SELECT account_id FROM users WHERE email = ?", payload.email); if (owner && owner.account_id !== payload.userId) throw conflict("That email address is already in use"); await run( "UPDATE users SET email = ?, email_verified_at = ? WHERE account_id = ?", payload.email, Date.now(), payload.userId, ); return payload; } /** 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 }>( "SELECT account_id, password_hash, email FROM users WHERE email = ?", email, ); if (!user) return; const token = randomToken("reset"); const payload: ResetPayload = { passwordHash: user.password_hash }; 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, passwordHash: parseReset(consumed.payload).passwordHash }; const current = await first<{ password_hash: string | null; email: string }>( "SELECT password_hash, email FROM users WHERE account_id = ?", payload.userId, ); if (!current || current.password_hash !== payload.passwordHash) { throw badRequest("This reset link is no longer valid"); } await run( "UPDATE users SET password_hash = ?, sessions_revoked_at = ?, email_verified_at = COALESCE(email_verified_at, ?) WHERE account_id = ?", await hashPassword(password), Date.now(), Date.now(), payload.userId, ); await revokeUserAuthTokens("password-reset", payload.userId); await run("DELETE FROM access_tokens WHERE user_id = ?", payload.userId); await notifySecurityEvent(payload.userId, "your password was reset and your personal access tokens were revoked"); 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); } export async function notifySecurityEvent(userId: string, event: string): Promise { const user = await first<{ email: string; email_verified_at: number | null }>( "SELECT email, email_verified_at FROM users WHERE account_id = ?", userId, ); if (!user?.email_verified_at) return; await queueEmail(user.email, emailTemplates.securityNotice(await displayNameFor(userId), event)); } 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"); }