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>
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
// 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,
|
||||
}
|
||||
Reference in New Issue
Block a user