mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-28 00:18:58 -07:00
[www] password/email recovery (#146)
* pw/email recovery * fix migration
This commit is contained in:
@@ -36,7 +36,8 @@ export const SCHEMA_DDL: string[] = [
|
||||
has_plus INTEGER GENERATED ALWAYS AS (json_extract(data, '$.hasPlus')) VIRTUAL,
|
||||
is_influencer INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isInfluencer')) VIRTUAL,
|
||||
signup_ip TEXT GENERATED ALWAYS AS (json_extract(data, '$.signupIp')) VIRTUAL,
|
||||
device_id TEXT GENERATED ALWAYS AS (json_extract(data, '$.deviceId')) VIRTUAL
|
||||
device_id TEXT GENERATED ALWAYS AS (json_extract(data, '$.deviceId')) 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)`,
|
||||
@@ -44,6 +45,10 @@ export const SCHEMA_DDL: string[] = [
|
||||
`CREATE INDEX IF NOT EXISTS idx_account_is_influencer ON account (is_influencer) WHERE is_influencer = 1`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_account_signup_ip ON account (signup_ip)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_account_device_id ON account (device_id)`,
|
||||
// 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). */
|
||||
@@ -111,7 +116,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
|
||||
@@ -314,6 +324,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). */
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* 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, and at most {@link EMAIL_CODE_DAILY_LIMIT} times a day, 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 (a cooldown alone still allows
|
||||
* hundreds a day). Refused, 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'
|
||||
import { takeRateLimit } from './rate-limit-db'
|
||||
|
||||
/** 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
|
||||
|
||||
/**
|
||||
* Codes sent per account and purpose per {@link EMAIL_CODE_DAILY_WINDOW_SECONDS}, counting
|
||||
* only emails that were actually issued (not refused repeats). Enough for anyone genuinely
|
||||
* struggling with a code; a loop pointed at someone's address gets this many and then
|
||||
* silence until tomorrow.
|
||||
*/
|
||||
export const EMAIL_CODE_DAILY_LIMIT = 5
|
||||
export const EMAIL_CODE_DAILY_WINDOW_SECONDS = 24 * 60 * 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)
|
||||
|
||||
/** Why {@link beginEmailCode} issued nothing. The player is told about the first; the second too. */
|
||||
export type EmailCodeRefusal = 'cooldown' | 'quota'
|
||||
|
||||
export type EmailCodeIssue =
|
||||
{ code: string; refused?: never } | { code?: never; refused: EmailCodeRefusal }
|
||||
|
||||
/**
|
||||
* 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. Refuses — nothing changed, the pending code and ITS payload stand, and nothing
|
||||
* should be sent — with `cooldown` when a code for this account and purpose was issued less
|
||||
* than the cooldown ago (burned or not), and with `quota` when {@link EMAIL_CODE_DAILY_LIMIT}
|
||||
* have been issued today. `force` skips the cooldown only, 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); the daily cap always applies.
|
||||
*/
|
||||
export async function beginEmailCode(
|
||||
db: D1Database,
|
||||
purpose: EmailCodePurpose,
|
||||
accountId: number,
|
||||
payload: string | null = null,
|
||||
{ force = false }: { force?: boolean } = {}
|
||||
): Promise<EmailCodeIssue> {
|
||||
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 { refused: 'cooldown' }
|
||||
}
|
||||
}
|
||||
// Counted after the cooldown, so only codes that would actually go out spend the day's
|
||||
// allowance — a loop re-asking inside the cooldown gets nothing and costs the account's
|
||||
// real owner nothing.
|
||||
const quota = await takeRateLimit(
|
||||
db,
|
||||
`email_code:${purpose}:${accountId}`,
|
||||
EMAIL_CODE_DAILY_LIMIT,
|
||||
EMAIL_CODE_DAILY_WINDOW_SECONDS
|
||||
)
|
||||
if (!quota.allowed) return { refused: 'quota' }
|
||||
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
@@ -13,6 +13,8 @@ export * from './audit-db'
|
||||
export * from './clubs-db'
|
||||
export * from './d1-binds'
|
||||
export * from './images-db'
|
||||
export * from './email-code-db'
|
||||
export * from './rate-limit-db'
|
||||
export * from './password'
|
||||
export * from './player-settings-kv'
|
||||
export * from './rooms-db'
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Fixed-window counters on the shared `recflare` D1 database (owned by the `auth` worker,
|
||||
* migration 0015): "at most N of these per key per window". What stands between the two
|
||||
* things this server emails — password-recovery codes (`auth`) and email-change codes
|
||||
* (`accounts`) — and someone who has an address and a loop: keyed by the target account,
|
||||
* so a mailbox gets a handful of codes a day however many are asked for, and by the
|
||||
* requesting IP, so one source can't spray requests across every address it knows.
|
||||
*
|
||||
* A fixed window rather than a sliding one: the first hit in a window starts it, the
|
||||
* count resets when it lapses. Coarse, but one UPSERT with no history to keep, and the
|
||||
* caps here are loose enough that the edges don't matter. Rows for keys nobody has touched
|
||||
* in a week are purged as a side effect of the next take, so the table stays the size of
|
||||
* recent activity.
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of apps/auth/migrations/0015_rate_limit.sql). */
|
||||
export const RATE_LIMIT_SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS rate_limit (
|
||||
key TEXT PRIMARY KEY,
|
||||
window_started_at INTEGER NOT NULL,
|
||||
count INTEGER NOT NULL
|
||||
)`,
|
||||
]
|
||||
|
||||
/** Rows idle this long (s) are dropped on the next take. Longer than any window in use. */
|
||||
const RATE_LIMIT_PURGE_AFTER_SECONDS = 7 * 24 * 60 * 60
|
||||
|
||||
/**
|
||||
* Count one hit against `key` and say whether it is within `limit` per `windowSeconds`.
|
||||
* The hit is counted whether or not it is allowed, so a caller that keeps hammering a
|
||||
* capped key stays capped. `count` is the window's total after this hit, for the log.
|
||||
*/
|
||||
export async function takeRateLimit(
|
||||
db: D1Database,
|
||||
key: string,
|
||||
limit: number,
|
||||
windowSeconds: number
|
||||
): Promise<{ allowed: boolean; count: number }> {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const [, taken] = await db.batch<{ count: number }>([
|
||||
db
|
||||
.prepare('DELETE FROM rate_limit WHERE window_started_at < ?1')
|
||||
.bind(now - RATE_LIMIT_PURGE_AFTER_SECONDS),
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO rate_limit (key, window_started_at, count) VALUES (?1, ?2, 1)
|
||||
ON CONFLICT (key) DO UPDATE SET
|
||||
count = CASE WHEN window_started_at + ?3 <= ?2 THEN 1 ELSE count + 1 END,
|
||||
window_started_at = CASE WHEN window_started_at + ?3 <= ?2 THEN ?2 ELSE window_started_at END
|
||||
RETURNING count`
|
||||
)
|
||||
.bind(key, now, windowSeconds),
|
||||
])
|
||||
const count = taken!.results[0]?.count ?? 1
|
||||
return { allowed: count <= limit, count }
|
||||
}
|
||||
Reference in New Issue
Block a user