Files
website/server/src/model/emailVerifications/emailVerifications.model.js
wtclaude fbb4b0bd91
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 10m34s
feat(auth): unique, changeable, verifiable email addresses (engagement Phase 1b)
Makes `users.email` unique, de-duplicates the addresses an upgrade will find,
and builds the self-service change-and-verify flow that did not exist.

The uniqueness index is on a generated `email_norm AS (LOWER(email)) STORED`
column under `utf8mb4_bin`, NOT on `email` under a `_ci` collation as the plan
specified. Every case-insensitive collation this server offers is also
accent-insensitive: `josé@x.com` and `jose@x.com` compare equal, and those are
two different mailboxes. The plan's index would have refused the second address
forever and the de-duplication would have nulled a legitimate account's.

A requested address is STAGED in `email_pending` and only a tokened link
installs it, so a typo cannot silently redirect account-recovery mail.

`isDuplicateUsername()` now distinguishes the two indexes. All five call sites
branch on it; each answers differently on purpose, because a public form, an
IdP callback, a half-completed invite and an admin screen do not owe the same
person the same amount of truth.

SSO reads the IdP's actual `email_verified`/`verified` claim instead of
inferring verification from an address merely being present.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 01:53:50 -05:00

77 lines
3.1 KiB
JavaScript

// Self-service email verification (engagement Phase 1b). A user asks to set or
// change their address; a tokened link goes to the address they typed, and only
// opening that link installs it. The opaque token lives only in the emailed link —
// the DB stores its sha256 — so a DB read never yields a usable link. Same shape
// as password_resets and user_invites, deliberately: the design of record calls
// this link "signed", but every comparable flow here uses a hashed random token,
// and matching them beats adding a second token mechanism for one caller.
//
// The address is stored ON THE ROW rather than read from the user at confirm
// time, because a token proves control of the address it was mailed to and
// nothing else.
const crypto = require('crypto')
const db = require('./emailVerifications.db')
// A day, not an hour. Unlike a password reset this is not a live credential-reset
// capability — the worst a leaked token does is attach an address its holder
// already controls — and a verification mail is routinely opened on another
// device, hours later.
const DEFAULT_TTL_MINUTES = 24 * 60
// Per-user ceiling on verification sends, independent of the per-IP limiter: the
// mail goes to an address the RECIPIENT did not choose to hear from, so an
// attacker with one account must not be able to use it to pester a mailbox.
const MAX_SENDS_PER_WINDOW = 5
const SEND_WINDOW_MINUTES = 60
function hashToken(raw) {
return crypto.createHash('sha256').update(String(raw)).digest('hex')
}
// Create a verification for one user + address. Returns { id, token } — the
// plaintext token is returned ONCE, for the link, and is never recoverable after.
async function create({ userId, email, requestedIp, ttlMinutes = DEFAULT_TTL_MINUTES }) {
const token = crypto.randomBytes(32).toString('base64url')
const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000)
const id = await db.insert({ tokenHash: hashToken(token), userId, email, requestedIp, expiresAt })
return { id, token }
}
// Resolve a pending, unexpired verification from its plaintext token, else null.
// Returns the RAW row (incl. user_id and the address it proves).
async function findValidByToken(token) {
if (!token) return null
const row = await db.findByTokenHash(hashToken(token))
if (!row || row.status !== 'pending') return null
if (new Date(row.expires_at).getTime() < Date.now()) return null
return row
}
// Atomically consume a pending verification (double-use-safe). True if this call
// won the race.
async function consume(id) {
return (await db.markUsed(id)) === 1
}
const invalidatePendingForUser = (userId) => db.invalidatePendingForUser(userId)
// True when this user has already asked for as many verification mails as the
// window allows.
async function sendQuotaExhausted(userId) {
const since = new Date(Date.now() - SEND_WINDOW_MINUTES * 60 * 1000)
return (await db.countRecentForUser(userId, since)) >= MAX_SENDS_PER_WINDOW
}
module.exports = {
create,
findValidByToken,
consume,
invalidatePendingForUser,
sendQuotaExhausted,
hashToken,
DEFAULT_TTL_MINUTES,
MAX_SENDS_PER_WINDOW,
SEND_WINDOW_MINUTES,
}