pw/email recovery

This commit is contained in:
Devin Zuczek
2026-10-26 22:09:50 -04:00
parent 619b4c772c
commit ad2b84b358
29 changed files with 3847 additions and 525 deletions
+27 -2
View File
@@ -32,12 +32,17 @@ export const SCHEMA_DDL: string[] = [
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL,
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL,
has_plus INTEGER GENERATED ALWAYS AS (json_extract(data, '$.hasPlus')) VIRTUAL,
is_influencer INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isInfluencer')) VIRTUAL
is_influencer INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isInfluencer')) VIRTUAL,
email_lower TEXT GENERATED ALWAYS AS (NULLIF(lower(json_extract(data, '$.email')), '')) VIRTUAL
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_account_id ON account (account_id)`,
`CREATE INDEX IF NOT EXISTS idx_accounts_username_lower ON account (username_lower)`,
`CREATE INDEX IF NOT EXISTS idx_account_has_plus ON account (has_plus) WHERE has_plus = 1`,
`CREATE INDEX IF NOT EXISTS idx_account_is_influencer ON account (is_influencer) WHERE is_influencer = 1`,
// An email address belongs to ONE account: password recovery (`auth`
// `POST /account/recoverpassword`) is handed nothing but the address and has to find the
// account from it. Case-insensitive, and an empty/absent email is NULL so it never collides.
`CREATE UNIQUE INDEX IF NOT EXISTS idx_account_email_lower ON account (email_lower) WHERE email_lower IS NOT NULL`,
]
/** Client-facing account shape (camelCase, exactly as the client's AccountDTO). */
@@ -105,7 +110,12 @@ export interface Account {
signupIp?: string
/** The client IP of the most recent successful login; refreshed on every login. */
lastLoginIp?: string
/** Set via POST /account/me/email; absent until the player provides one. */
/**
* Set via POST /account/me/email; absent until the player provides one. UNIQUE across
* accounts, case-insensitively (`idx_account_email_lower`): it is the one thing a password
* recovery is given to find the account by, so `accounts` refuses an address another account
* holds. Look an account up by it with {@link getAccountByEmail}.
*/
email?: string
/** Set via POST /account/me/phone; absent until the player provides one. */
phone?: string
@@ -308,6 +318,21 @@ export async function getAccountByUsername(
}
/** Default cap on how many matches `searchAccounts` returns. */
/**
* The account holding an email address, or null when none does. Case-insensitive, over the
* `email_lower` index, and an address is held by at most one account (see {@link Account.email}).
* Whitespace around the address is ignored; an empty address matches nothing.
*/
export async function getAccountByEmail(db: D1Database, email: string): Promise<Account | null> {
const needle = email.trim().toLowerCase()
if (needle === '') return null
const row = await db
.prepare('SELECT data FROM account WHERE email_lower = ?1')
.bind(needle)
.first<{ data: string }>()
return row ? (JSON.parse(row.data) as Account) : null
}
export const SEARCH_LIMIT = 20
/** Escape LIKE wildcards so user input is matched literally (using `\` as the escape char). */
+218
View File
@@ -0,0 +1,218 @@
/**
* One-shot codes sent by email, on the shared `recflare` D1 database (owned by the `auth`
* worker, migration 0014). Two things are confirmed this way, each a `purpose`:
*
* - `password_reset` — "Forgot password" (`auth` `/account/recoverpassword` issues the
* code, `/account/resetpassword` spends it). Payload: none.
* - `email_change` — the CURRENT address approving a change away from it (`accounts`
* `/account/me/email` issues it, `/account/me/email/confirm` spends it). Payload: the
* new address. Spending it issues the next:
* - `email_verify` — the NEW address confirming it is reachable, and the step that applies
* the change; the only step for an account with no address yet. Payload: the new address.
*
* The rules, all of which exist because a six-digit code is guessable:
*
* - ONE pending code per account and purpose; a new request replaces the old, so the latest
* email is the one that works.
* - ONE attempt per code. A wrong guess BURNS the row (`code_hash` goes NULL) rather than
* leaving it open to the next guess — the player has to request a fresh email, which is
* what "restart" means. The row itself stays, so the resend cooldown below still counts
* from the last email.
* - A code lapses after {@link EMAIL_CODE_TTL_SECONDS}.
* - A fresh email goes out at most once per {@link EMAIL_CODE_RESEND_COOLDOWN_SECONDS} per
* account and purpose, however often one is asked for — recovery can be requested by
* anyone who knows an address, and without this it would be a way to flood someone's
* inbox. Within the cooldown nothing is issued and nothing should be sent.
*
* Only a PBKDF2 hash of the code is stored (the same `hashPassword` as an account's
* password), never the code: a copy of the table shouldn't be a way into every account with
* a code in flight.
*/
import { hashPassword, verifyPassword } from './password'
/** What a code is for; one pending code per account and purpose. */
export type EmailCodePurpose = 'password_reset' | 'email_change' | 'email_verify'
/** A code is good for this long (s) after the email is sent. */
export const EMAIL_CODE_TTL_SECONDS = 30 * 60
/** Another email for the same purpose to the same account is refused within this long (s). */
export const EMAIL_CODE_RESEND_COOLDOWN_SECONDS = 2 * 60
/** The code's length in digits. The emails and the website's forms all print it this wide. */
export const EMAIL_CODE_LENGTH = 6
/** Schema DDL (mirror of apps/auth/migrations/0014_email_code.sql). */
export const EMAIL_CODE_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS email_code (
account_id INTEGER NOT NULL,
purpose TEXT NOT NULL,
code_hash TEXT,
payload TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
PRIMARY KEY (account_id, purpose)
)`,
]
/** A fresh code: {@link EMAIL_CODE_LENGTH} decimal digits, leading zeros included. */
export function newEmailCode(): string {
// Rejection-sampled from a uint32 so every code is equally likely — a bare modulo
// would favour the low codes slightly.
const limit = 10 ** EMAIL_CODE_LENGTH
const cap = Math.floor(2 ** 32 / limit) * limit
const buf = new Uint32Array(1)
let n: number
do {
crypto.getRandomValues(buf)
n = buf[0]!
} while (n >= cap)
return String(n % limit).padStart(EMAIL_CODE_LENGTH, '0')
}
/**
* Whether `code` is shaped like one of ours: exactly {@link EMAIL_CODE_LENGTH} digits. Checked
* before the hash so a malformed guess doesn't spend a PBKDF2 round — but a malformed guess
* still counts as a guess, see {@link completeEmailCode}.
*/
export const isEmailCodeShaped = (code: string): boolean =>
new RegExp(`^\\d{${EMAIL_CODE_LENGTH}}$`).test(code)
/**
* Issue a code for the account and purpose, replacing any pending one, and return it — the
* only moment it exists in plaintext. `payload` rides along to be handed back when the code
* is spent. Returns null when a code for this account and purpose was issued less than the
* cooldown ago (burned or not): nothing changed — the pending code and ITS payload stand —
* and nothing should be sent. `force` skips that check, for a caller that has its own reason
* to know the send is not a repeat (a code going to a DIFFERENT address than the last one,
* or a step a previous code has just unlocked).
*/
export async function beginEmailCode(
db: D1Database,
purpose: EmailCodePurpose,
accountId: number,
payload: string | null = null,
{ force = false }: { force?: boolean } = {}
): Promise<string | null> {
const now = Math.floor(Date.now() / 1000)
if (!force) {
const recent = await db
.prepare(
'SELECT created_at AS createdAt FROM email_code WHERE account_id = ?1 AND purpose = ?2'
)
.bind(accountId, purpose)
.first<{ createdAt: number }>()
if (recent && now - recent.createdAt < EMAIL_CODE_RESEND_COOLDOWN_SECONDS) return null
}
const code = newEmailCode()
await db
.prepare(
`INSERT INTO email_code (account_id, purpose, code_hash, payload, created_at, expires_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT (account_id, purpose) DO UPDATE SET
code_hash = excluded.code_hash,
payload = excluded.payload,
created_at = excluded.created_at,
expires_at = excluded.expires_at`
)
.bind(accountId, purpose, await hashPassword(code), payload, now, now + EMAIL_CODE_TTL_SECONDS)
.run()
return code
}
/**
* The payload of the account's LIVE code for a purpose — issued, not burned, not lapsed — or
* null when there is none. What a page asks to show "a code is on its way to …" after a
* reload. Says nothing about the code itself.
*/
export async function pendingEmailCode(
db: D1Database,
purpose: EmailCodePurpose,
accountId: number
): Promise<{ payload: string | null; expiresAt: number } | null> {
const now = Math.floor(Date.now() / 1000)
const row = await db
.prepare(
`SELECT payload, expires_at AS expiresAt FROM email_code
WHERE account_id = ?1 AND purpose = ?2 AND code_hash IS NOT NULL AND expires_at >= ?3`
)
.bind(accountId, purpose, now)
.first<{ payload: string | null; expiresAt: number }>()
return row ?? null
}
/**
* Drop the account's pending code for a purpose, cooldown and all — for when the email it
* was issued for never went out, so the player can ask again straight away.
*/
export async function cancelEmailCode(
db: D1Database,
purpose: EmailCodePurpose,
accountId: number
): Promise<void> {
await db
.prepare('DELETE FROM email_code WHERE account_id = ?1 AND purpose = ?2')
.bind(accountId, purpose)
.run()
}
export type EmailCodeOutcome =
{ outcome: 'ok'; payload: string | null } | { outcome: 'wrong' } | { outcome: 'none' }
/**
* Spend the account's pending code for a purpose. `ok` deletes the row and hands back the
* payload it was issued with. `wrong` means the code didn't match — the row is BURNED, and
* the player has to request a new email. `none` means there was nothing to match against:
* no request, a lapsed one (deleted here), or one already burned. The two failures are told
* apart for the log only; the player is told the same thing either way, so a guesser learns
* nothing from the answer about whether a code is in flight.
*/
export async function completeEmailCode(
db: D1Database,
purpose: EmailCodePurpose,
accountId: number,
code: string
): Promise<EmailCodeOutcome> {
const now = Math.floor(Date.now() / 1000)
const row = await db
.prepare(
`SELECT code_hash AS codeHash, payload, expires_at AS expiresAt FROM email_code
WHERE account_id = ?1 AND purpose = ?2`
)
.bind(accountId, purpose)
.first<{ codeHash: string | null; payload: string | null; expiresAt: number }>()
if (!row || row.codeHash === null) return { outcome: 'none' }
if (row.expiresAt < now) {
await cancelEmailCode(db, purpose, accountId)
return { outcome: 'none' }
}
const matches = isEmailCodeShaped(code) && (await verifyPassword(code, row.codeHash))
if (!matches) {
// Burn, don't delete: the row's `created_at` is what the resend cooldown counts from.
await db
.prepare('UPDATE email_code SET code_hash = NULL WHERE account_id = ?1 AND purpose = ?2')
.bind(accountId, purpose)
.run()
return { outcome: 'wrong' }
}
await cancelEmailCode(db, purpose, accountId)
return { outcome: 'ok', payload: row.payload }
}
/**
* The address a worker's emails come from: `EMAIL_FROM` (set for every worker from the root
* `.env` as `RECFLARE_EMAIL_FROM`), else `no-reply@<DOMAIN>`. Its domain must be one Email
* Sending has been enabled on (`wrangler email sending enable`, see DEPLOYING.md) — Cloudflare
* refuses a send from any other.
*/
export function emailSender(env: { DOMAIN: string; EMAIL_FROM?: string }): string {
return env.EMAIL_FROM?.trim() || `no-reply@${env.DOMAIN}`
}
/** Escape text for the HTML half of an email. */
export const escapeHtml = (s: string): string =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
+1
View File
@@ -13,6 +13,7 @@ export * from './audit-db'
export * from './clubs-db'
export * from './d1-binds'
export * from './images-db'
export * from './email-code-db'
export * from './password'
export * from './player-settings-kv'
export * from './rooms-db'