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>
77 lines
3.1 KiB
JavaScript
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,
|
|
}
|