import "server-only"; import { db, findById, withLock, type RecordModel } from "./db"; import { hashRecoveryCode, matchTotp, newRecoveryCodes, newTotpSecret, normalizeCode } from "./totp"; // Two-factor authentication with an authenticator app. With it on, signing in in the browser takes // a code from the app (or a recovery code) after the password. Git, the registry and agents don't // use passwords (they use SSH keys, API tokens and OAuth), so they aren't affected. export type TwoFactorStatus = { enabled: boolean; enabledAt: number | null; recoveryCodesLeft: number; /** A secret being set up, not confirmed with a code yet. */ pendingSecret: string | null; }; export class TwoFactorError extends Error {} const recoveryHashes = (r: RecordModel) => (Array.isArray(r.recovery_codes) ? (r.recovery_codes as string[]) : []); async function userRecord(userId: string) { const record = await findById("users", userId); if (!record) throw new TwoFactorError("Account not found."); return record; } export async function twoFactorStatus(userId: string): Promise { const r = await userRecord(userId); return { enabled: !!r.totp_secret, enabledAt: r.totp_enabled_at || null, recoveryCodesLeft: recoveryHashes(r).length, pendingSecret: (!r.totp_secret && r.totp_pending) || null, }; } export async function hasTwoFactor(userId: string) { return !!(await findById("users", userId))?.totp_secret; } const update = async (userId: string, data: Record) => (await db()).collection("users").update(userId, data); const lock = (userId: string, fn: () => Promise) => withLock(`2fa:${userId}`, fn); /** Starts setting up an app: a new secret, kept aside until a code from the app confirms it. */ export function startTwoFactorSetup(userId: string) { return lock(userId, async () => { if ((await userRecord(userId)).totp_secret) throw new TwoFactorError("Two-factor authentication is already on."); await update(userId, { totp_pending: newTotpSecret() }); }); } export function cancelTwoFactorSetup(userId: string) { return lock(userId, () => update(userId, { totp_pending: "" })); } /** Turns 2FA on once a code from the app matches the secret being set up. Returns the recovery codes. */ export function enableTwoFactor(userId: string, code: string) { return lock(userId, async () => { const r = await userRecord(userId); if (r.totp_secret) throw new TwoFactorError("Two-factor authentication is already on."); if (!r.totp_pending) throw new TwoFactorError("Start the setup again."); const step = matchTotp(r.totp_pending, normalizeCode(code)); if (step === null) throw new TwoFactorError("That code didn't match. Check your device's time and try the newest code."); const codes = newRecoveryCodes(); await update(userId, { totp_secret: r.totp_pending, totp_pending: "", totp_enabled_at: Date.now(), totp_last_step: step, recovery_codes: codes.map(hashRecoveryCode), }); return codes; }); } /** * Checks a code from the app or a recovery code, and uses it up: an app code can't be used again * (nor an older one), a recovery code is deleted. */ export function verifySecondFactor(userId: string, input: string) { return lock(userId, async () => { const r = await findById("users", userId); if (!r?.totp_secret) return false; const code = normalizeCode(input); if (/^\d{6}$/.test(code)) { const step = matchTotp(r.totp_secret, code); if (step === null || step <= (r.totp_last_step || 0)) return false; await update(userId, { totp_last_step: step }); return true; } const hashes = recoveryHashes(r); const hash = hashRecoveryCode(code); if (!code || !hashes.includes(hash)) return false; await update(userId, { recovery_codes: hashes.filter((h) => h !== hash) }); return true; }); } export async function disableTwoFactor(userId: string, code: string) { if (!(await verifySecondFactor(userId, code))) throw new TwoFactorError("That code didn't match."); await lock(userId, () => update(userId, { totp_secret: "", totp_pending: "", totp_enabled_at: 0, totp_last_step: 0, recovery_codes: [] })); } /** New recovery codes in place of the old ones, after a code from the app. */ export async function regenerateRecoveryCodes(userId: string, code: string) { if (!/^\d{6}$/.test(normalizeCode(code))) throw new TwoFactorError("Enter a code from your authenticator app."); if (!(await verifySecondFactor(userId, code))) throw new TwoFactorError("That code didn't match."); const codes = newRecoveryCodes(); await lock(userId, () => update(userId, { recovery_codes: codes.map(hashRecoveryCode) })); return codes; }