From dad77cd12424c5e924d74d43b822d2fa2a1f9552 Mon Sep 17 00:00:00 2001 From: oratis Date: Fri, 24 Jul 2026 23:38:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20email=20one-time=20codes=20?= =?UTF-8?q?=E2=80=94=20verification,=20passwordless=20login,=20password=20?= =?UTF-8?q?reset=20(S2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One OTP infra, three uses (PLAN_WEB_SIGNUP S2): - accounts.ts: 6-digit codes (crypto.randomInt), sha256-at-rest, 10 min TTL, 5-attempt burn, purpose-scoped (verify|login|reset), 60s send-cooldown applied uniformly to unknown emails so nothing enumerates accounts. - resetPasswordWithOtp fills the missing forgot-password hole: atomic code-consume + scrypt install, sessionVersion bump (all old sessions die), and the code doubles as ownership proof so the account levels to verified. - mailer.ts: generic sendMail + otpEmail template; the verification mail now leads with the code and keeps the 24h link as fallback. - server.ts: POST /api/auth/email/code | /api/auth/email/login | /api/auth/password/reset (pre-gate, IP rate-bucketed) and session-authed POST /api/auth/verify/confirm; register + verify/resend mails carry codes. - login page: three-mode form (password / code / reset) with send-code flow. Co-Authored-By: Claude Fable 5 --- src/web/accounts.ts | 157 +++++++++++++++++++++++++++++++++ src/web/login.ts | 91 ++++++++++++++++++-- src/web/mailer.ts | 55 ++++++++++-- src/web/server.ts | 162 ++++++++++++++++++++++++++++++++++- src/web/verification.test.ts | 120 +++++++++++++++++++++++++- 5 files changed, 560 insertions(+), 25 deletions(-) diff --git a/src/web/accounts.ts b/src/web/accounts.ts index a2061db..4bd3b75 100644 --- a/src/web/accounts.ts +++ b/src/web/accounts.ts @@ -56,8 +56,24 @@ export interface AccountRecord { * the user keeps one uid (one Lisa) across providers. */ googleSub?: string; + /** SHA-256 hex of the outstanding one-time code (S2). One OTP at a time. */ + otpHash?: string; + /** What the outstanding code is FOR — a login code never resets a password. */ + otpPurpose?: OtpPurpose; + /** Code expiry, ms epoch (10 minutes from mint). */ + otpExpiresAt?: number; + /** Wrong guesses so far; the code burns itself after OTP_MAX_ATTEMPTS. */ + otpAttempts?: number; } +/** + * One-time-code purposes (S2). One infra, three uses: + * - "verify": prove email ownership after signup (levels the free window) + * - "login": passwordless sign-in for email accounts + * - "reset": forgot-password — proves ownership, then sets a new password + */ +export type OtpPurpose = "verify" | "login" | "reset"; + export class AccountError extends Error { constructor(public code: "invalid_email" | "weak_password" | "email_taken" | "throttled") { super(code); @@ -428,6 +444,147 @@ export async function ensureSeededAccount(email: string, password: string, now: }); } +// ── one-time codes (S2): verify / passwordless login / password reset ─────── +const OTP_TTL_MS = 10 * 60 * 1000; +const OTP_MAX_ATTEMPTS = 5; +const OTP_RESEND_COOLDOWN_MS = 60 * 1000; + +// Send-cooldown is keyed by (purpose, email) and applied BEFORE the account +// lookup, uniformly for unknown emails too — so the 200-vs-429 pattern never +// leaks whether an address has an account. In-memory is correct for the +// single-instance cloud (same stance as the login throttle above). +const otpCooldowns = new Map(); + +/** Test seam. */ +export function resetOtpCooldowns(): void { + otpCooldowns.clear(); +} + +function clearOtp(acct: AccountRecord): void { + delete acct.otpHash; + delete acct.otpPurpose; + delete acct.otpExpiresAt; + delete acct.otpAttempts; +} + +/** + * Try to consume the record's outstanding code (mutates in place; call inside + * mutateAccounts). Every try counts an attempt; the code burns itself past + * OTP_MAX_ATTEMPTS — a 6-digit space is only safe because guessing is capped. + */ +function takeOtp(acct: AccountRecord, code: string, purpose: OtpPurpose, now: number): boolean { + if (!acct.otpHash || acct.otpPurpose !== purpose) return false; + if (!acct.otpExpiresAt || acct.otpExpiresAt < now) return false; + const attempts = (acct.otpAttempts ?? 0) + 1; + acct.otpAttempts = attempts; + if (attempts > OTP_MAX_ATTEMPTS) { + clearOtp(acct); + return false; + } + const presented = crypto.createHash("sha256").update(code).digest(); + let stored: Buffer; + try { + stored = Buffer.from(acct.otpHash, "hex"); + } catch { + return false; + } + if (stored.length !== presented.length || !crypto.timingSafeEqual(stored, presented)) return false; + clearOtp(acct); + return true; +} + +export type OtpBegin = + | { status: "ok"; code: string; uid: string } + | { status: "cooldown"; retryAfterSec: number } + | { status: "none" }; + +/** + * Mint a 6-digit code for (email, purpose). Returns the RAW code once — it + * goes into the mail — with only its hash persisted. "none" means no eligible + * account; callers still answer 200 so the endpoint can't be used to probe + * which emails exist. + */ +export async function beginAccountOtp( + emailRaw: string, + purpose: OtpPurpose, + now: number = Date.now(), +): Promise { + const email = normalizeEmail(emailRaw); + const key = `${purpose}:${email}`; + const until = otpCooldowns.get(key) ?? 0; + if (until > now) return { status: "cooldown", retryAfterSec: Math.ceil((until - now) / 1000) }; + otpCooldowns.set(key, now + OTP_RESEND_COOLDOWN_MS); + const code = String(crypto.randomInt(0, 1_000_000)).padStart(6, "0"); + const hash = crypto.createHash("sha256").update(code).digest("hex"); + return mutateAccounts((list) => { + const acct = list.find((a) => a.kind === "email" && a.email === email); + if (!acct) return { status: "none" } as const; + if (purpose === "verify" && acct.verified) return { status: "none" } as const; + acct.otpHash = hash; + acct.otpPurpose = purpose; + acct.otpExpiresAt = now + OTP_TTL_MS; + acct.otpAttempts = 0; + return { status: "ok", code, uid: acct.uid } as const; + }); +} + +/** + * Consume a code. Returns the account on success, null on any failure (wrong/ + * expired/burned code, unknown email). "verify" marks the account verified; + * "login" stamps lastLoginAt — the caller mints the session. + */ +export async function consumeAccountOtp( + emailRaw: string, + code: string, + purpose: OtpPurpose, + now: number = Date.now(), +): Promise { + const email = normalizeEmail(emailRaw); + if (!code || !/^\d{6}$/.test(code)) return null; + return mutateAccounts((list) => { + const acct = list.find((a) => a.kind === "email" && a.email === email); + if (!acct || !takeOtp(acct, code, purpose, now)) return null; + if (purpose === "verify") { + acct.verified = true; + delete acct.verifyTokenHash; + delete acct.verifyExpiresAt; + } + if (purpose === "login") acct.lastLoginAt = now; + return acct; + }); +} + +/** + * Forgot-password (S2): consume a "reset" code and install the new password in + * one atomic mutation. Bumps sessionVersion (every outstanding session dies), + * marks the account verified (the code just proved email ownership), and + * clears the login throttle so a locked-out owner can get back in. + */ +export async function resetPasswordWithOtp( + emailRaw: string, + code: string, + newPassword: string, + now: number = Date.now(), +): Promise { + if (!validPassword(newPassword)) throw new AccountError("weak_password"); + const email = normalizeEmail(emailRaw); + if (!code || !/^\d{6}$/.test(code)) return null; + const scrypt = await hashPassword(newPassword); // CPU work outside the CAS loop + const acct = await mutateAccounts((list) => { + const live = list.find((a) => a.kind === "email" && a.email === email); + if (!live || !takeOtp(live, code, "reset", now)) return null; + live.scrypt = scrypt; + live.sessionVersion += 1; + live.verified = true; + delete live.verifyTokenHash; + delete live.verifyExpiresAt; + live.lastLoginAt = now; + return live; + }); + if (acct) clearThrottle(email); + return acct; +} + // ── email-ownership verification (B8a) ────────────────────────────────────── const VERIFY_TTL_MS = 24 * 60 * 60 * 1000; diff --git a/src/web/login.ts b/src/web/login.ts index 6af1a00..6a38cf0 100644 --- a/src/web/login.ts +++ b/src/web/login.ts @@ -50,7 +50,10 @@ export const LOGIN_HTML = ` .divider { display: none; align-items: center; gap: 10px; color: #5d6575; font-size: 12px; margin-bottom: 4px; } .divider::before, .divider::after { content: ""; flex: 1; height: 1px; background: #232a36; } .err { color: #ff7a7a; font-size: 13px; margin-top: 12px; min-height: 1.2em; } + .ok { color: #7ad48a; font-size: 13px; min-height: 1.2em; } .hint { color: #5d6575; font-size: 12px; margin-top: 18px; } + .hint a { color: #8a93a5; text-decoration: none; } + .hint a:hover { color: #e6e9ef; } @@ -67,19 +70,33 @@ export const LOGIN_HTML = `
- + + + + + +
+
+
Self-hosted with a shared token? Open this page as /?token=<your token>.