Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions src/web/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<string, number>();

/** 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<OtpBegin> {
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<AccountRecord | null> {
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<AccountRecord | null> {
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;

Expand Down
91 changes: 82 additions & 9 deletions src/web/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ export const LOGIN_HTML = `<!doctype 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; }
</style>
</head>
<body>
Expand All @@ -67,43 +70,113 @@ export const LOGIN_HTML = `<!doctype html>
<form id="f">
<label for="email">Email</label>
<input id="email" type="email" autocomplete="username" required>
<label for="pw">Password</label>
<label for="pw" id="pw-label">Password</label>
<input id="pw" type="password" autocomplete="current-password" minlength="8" required>
<label for="code" id="code-label" style="display:none">6-digit code</label>
<input id="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code" style="display:none">
<label for="newpw" id="newpw-label" style="display:none">New password</label>
<input id="newpw" type="password" autocomplete="new-password" minlength="8" style="display:none">
<button id="sendcode" type="button" class="secondary" style="display:none">Email me a code</button>
<button id="login" type="submit">Sign in</button>
<button id="register" type="button" class="secondary">Create an account</button>
<div class="err" id="err"></div>
<div class="ok" id="ok"></div>
</form>
<div class="hint">
<a href="#" id="mode-code">Sign in with a code instead</a> &nbsp;·&nbsp;
<a href="#" id="mode-reset">Forgot password?</a>
<a href="#" id="mode-pw" style="display:none">Back to password sign-in</a>
</div>
<div class="hint">Self-hosted with a shared token? Open this page as /?token=&lt;your token&gt;.</div>
</div>
<script>
const f = document.getElementById("f");
const err = document.getElementById("err");
const okMsg = document.getElementById("ok");
const email = document.getElementById("email");
const pw = document.getElementById("pw");
const codeIn = document.getElementById("code");
const newpw = document.getElementById("newpw");
const MSG = {
bad_credentials: "Wrong email or password.",
email_taken: "That email already has an account — use Sign in.",
weak_password: "Use at least 8 characters.",
invalid_email: "That doesn't look like an email address.",
throttled: "Too many attempts — wait 15 minutes.",
rate_limited: "Too many attempts from this network — try again later.",
bad_code: "Wrong or expired code — request a fresh one if needed.",
otp_cooldown: "Code already sent — wait a minute before asking again.",
};
async function auth(path) {
err.textContent = "";
async function post(path, body) {
err.textContent = ""; okMsg.textContent = "";
const res = await fetch(path, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: email.value.trim(), password: pw.value }),
body: JSON.stringify(body),
}).catch(() => null);
if (!res) { err.textContent = "Network error — try again."; return; }
if (res.ok) { location.replace("/"); return; }
if (!res) { err.textContent = "Network error — try again."; return null; }
return res;
}
async function showError(res, fallback) {
let code = "";
try { code = (await res.json()).error || ""; } catch {}
err.textContent = MSG[code] || ("Sign-in failed (" + res.status + ").");
err.textContent = MSG[code] || (fallback + " (" + res.status + ").");
}
f.addEventListener("submit", (e) => { e.preventDefault(); auth("/api/auth/login"); });
async function auth(path, body) {
const res = await post(path, body);
if (!res) return;
if (res.ok) { location.replace("/"); return; }
await showError(res, "Sign-in failed");
}

// Three form modes (S2): password (default) / code (passwordless) / reset.
let mode = "pw";
function show(el, on) { el.style.display = on ? "" : "none"; }
function setMode(m) {
mode = m;
err.textContent = ""; okMsg.textContent = "";
const isPw = m === "pw", isCode = m === "code", isReset = m === "reset";
show(document.getElementById("pw-label"), isPw); show(pw, isPw);
pw.required = isPw;
show(document.getElementById("code-label"), !isPw); show(codeIn, !isPw);
codeIn.required = !isPw;
show(document.getElementById("newpw-label"), isReset); show(newpw, isReset);
newpw.required = isReset;
show(document.getElementById("sendcode"), !isPw);
show(document.getElementById("register"), isPw);
show(document.getElementById("mode-code"), isPw);
show(document.getElementById("mode-reset"), isPw);
show(document.getElementById("mode-pw"), !isPw);
document.getElementById("login").textContent =
isReset ? "Reset password" : isCode ? "Sign in with code" : "Sign in";
}
document.getElementById("mode-code").addEventListener("click", (e) => { e.preventDefault(); setMode("code"); });
document.getElementById("mode-reset").addEventListener("click", (e) => { e.preventDefault(); setMode("reset"); });
document.getElementById("mode-pw").addEventListener("click", (e) => { e.preventDefault(); setMode("pw"); });

document.getElementById("sendcode").addEventListener("click", async () => {
if (!email.reportValidity()) return;
const res = await post("/api/auth/email/code", {
email: email.value.trim(),
purpose: mode === "reset" ? "reset" : "login",
});
if (!res) return;
if (res.ok) { okMsg.textContent = "Code sent — check your email."; return; }
await showError(res, "Couldn't send the code");
});

f.addEventListener("submit", (e) => {
e.preventDefault();
const addr = email.value.trim();
if (mode === "code") { auth("/api/auth/email/login", { email: addr, code: codeIn.value.trim() }); return; }
if (mode === "reset") {
auth("/api/auth/password/reset", { email: addr, code: codeIn.value.trim(), newPassword: newpw.value });
return;
}
auth("/api/auth/login", { email: addr, password: pw.value });
});
document.getElementById("register").addEventListener("click", () => {
if (f.reportValidity()) auth("/api/auth/register");
if (f.reportValidity()) auth("/api/auth/register", { email: email.value.trim(), password: pw.value });
});

// Fresh random nonce, or null when this context can't hash one (#261).
Expand Down
55 changes: 46 additions & 9 deletions src/web/mailer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,27 +30,54 @@ export function mailerConfig(env: Record<string, string | undefined> = process.e
};
}

/** Compose the verification mail (pure — unit-testable). */
export function verificationEmail(link: string): { subject: string; text: string } {
export interface ComposedMail {
subject: string;
text: string;
}

/**
* Compose the verification mail (pure — unit-testable). With a `code` (S2) the
* 6-digit code leads and the link stays as the fallback for mail clients where
* typing a code beats tapping a link that an in-app browser may hijack.
*/
export function verificationEmail(link: string, code?: string): ComposedMail {
const codePart = code
? `Your verification code is: ${code}\n\n` +
`Enter it in LISA, or open the link below instead:\n\n`
: `Confirm this email address for your LISA account by opening the link below:\n\n`;
return {
subject: "Verify your LISA account email",
subject: code ? `${code} is your LISA verification code` : "Verify your LISA account email",
text:
`Confirm this email address for your LISA account by opening the link below:\n\n` +
codePart +
`${link}\n\n` +
`Verifying raises your free session allowance to the full amount. The link ` +
`Verifying raises your free session allowance to the full amount. ` +
`${code ? "The code expires in 10 minutes; the link" : "The link"} ` +
`expires in 24 hours. If you didn't create a LISA account, ignore this mail.`,
};
}

export async function sendVerificationEmail(
/** Compose a sign-in / password-reset code mail (S2; pure). */
export function otpEmail(code: string, purpose: "login" | "reset"): ComposedMail {
const what = purpose === "login" ? "sign-in" : "password reset";
return {
subject: `${code} is your LISA ${what} code`,
text:
`Your LISA ${what} code is: ${code}\n\n` +
`It expires in 10 minutes. If you didn't request it, ignore this mail — ` +
`nothing happens without the code.`,
};
}

/** Send any composed mail through Resend; degrades to a loud server log without a key. */
export async function sendMail(
to: string,
link: string,
mail: ComposedMail,
cfg: MailerConfig = mailerConfig(),
fetchFn: typeof fetch = fetch,
): Promise<MailResult> {
const mail = verificationEmail(link);
if (!cfg.apiKey) {
console.error(`[mail] RESEND_API_KEY unset — verification link for ${to}: ${link}`);
// The operator can hand-forward the secret; flatten so one log line has it.
console.error(`[mail] RESEND_API_KEY unset — for ${to}: ${mail.text.replace(/\n+/g, " | ").slice(0, 300)}`);
return { sent: false, detail: "no_api_key" };
}
try {
Expand All @@ -74,3 +101,13 @@ export async function sendVerificationEmail(
return { sent: false, detail: "network_error" };
}
}

/** Back-compat wrapper: verification mail without a code. */
export async function sendVerificationEmail(
to: string,
link: string,
cfg: MailerConfig = mailerConfig(),
fetchFn: typeof fetch = fetch,
): Promise<MailResult> {
return sendMail(to, verificationEmail(link), cfg, fetchFn);
}
Loading