feat(auth): unique, changeable, verifiable email addresses (engagement Phase 1b)
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

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:
2026-08-29 01:53:50 -05:00
parent c2e4df5b3d
commit fbb4b0bd91
44 changed files with 3024 additions and 59 deletions

View File

@@ -0,0 +1,22 @@
const { query } = require('../../utils/db')
const COLS = 'id, user_id, username, lost_address, cleared_at, acknowledged_at'
// Accounts cleared by the Phase 1b de-duplication, newest first.
async function list() {
return query(`SELECT ${COLS} FROM email_dedupe_report ORDER BY cleared_at DESC, id DESC`)
}
async function countUnacknowledged() {
const rows = await query('SELECT COUNT(*) AS n FROM email_dedupe_report WHERE acknowledged_at IS NULL')
return Number(rows[0] ? rows[0].n : 0)
}
// Dismiss the whole report. Idempotent — an already-acknowledged row is skipped
// so a second dismissal cannot rewrite when it happened.
async function acknowledgeAll() {
const res = await query('UPDATE email_dedupe_report SET acknowledged_at = NOW() WHERE acknowledged_at IS NULL')
return res.affectedRows || 0
}
module.exports = { list, countUnacknowledged, acknowledgeAll }

View File

@@ -0,0 +1,18 @@
// The Phase 1b de-duplication report: who lost an email address when the UNIQUE
// index went on, and what they lost.
//
// The rows are written by schema.sql's migration in pure SQL — ensureSchema()
// executes that file statement-by-statement and there is no JS migration hook —
// so this model only ever READS and acknowledges. Nothing here creates a row.
//
// It matters because these accounts are exactly the ones an operator must
// contact: each can still log in, but has no contact address, so password-reset
// and engagement mail have nowhere to go until its owner sets a new one.
const db = require('./emailDedupe.db')
const list = () => db.list()
const countUnacknowledged = () => db.countUnacknowledged()
const acknowledgeAll = () => db.acknowledgeAll()
module.exports = { list, countUnacknowledged, acknowledgeAll }

View File

@@ -0,0 +1,52 @@
const { query } = require('../../utils/db')
const COLS = 'id, token_hash, user_id, email, status, requested_ip, expires_at, created_at, used_at'
async function insert({ tokenHash, userId, email, requestedIp, expiresAt }) {
const res = await query(
`INSERT INTO email_verifications (token_hash, user_id, email, requested_ip, expires_at)
VALUES (?, ?, ?, ?, ?)`,
[tokenHash, userId, email, requestedIp ?? null, expiresAt],
)
return res.insertId
}
async function findByTokenHash(tokenHash) {
const rows = await query(`SELECT ${COLS} FROM email_verifications WHERE token_hash = ? LIMIT 1`, [tokenHash])
return rows[0] || null
}
// Mark used only if still pending (atomic guard against a double-use race).
// Returns rows changed (1 = we won, 0 = already used).
async function markUsed(id) {
const res = await query(
`UPDATE email_verifications SET status = 'used', used_at = NOW()
WHERE id = ? AND status = 'pending'`,
[id],
)
return res.affectedRows || 0
}
// Retire every still-pending verification for a user. Called when a fresh request
// supersedes older links and after a successful verification, so an address the
// user changed their mind about can never be installed by an old email.
async function invalidatePendingForUser(userId) {
const res = await query(
`UPDATE email_verifications SET status = 'used', used_at = NOW()
WHERE user_id = ? AND status = 'pending'`,
[userId],
)
return res.affectedRows || 0
}
// How many verification mails this user has asked for since `since`. Backs the
// per-user resend ceiling, which the IP rate limiter cannot provide on its own.
async function countRecentForUser(userId, since) {
const rows = await query(
'SELECT COUNT(*) AS n FROM email_verifications WHERE user_id = ? AND created_at >= ?',
[userId, since],
)
return Number(rows[0] ? rows[0].n : 0)
}
module.exports = { insert, findByTokenHash, markUsed, invalidatePendingForUser, countRecentForUser }

View File

@@ -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,
}

View File

@@ -67,6 +67,30 @@ function registrationFlags(mode) {
}
}
// Engagement Phase 1b — may an UNVERIFIED address receive opt-in engagement mail?
// Stored as 'on'/'off'. Seeded by schema.sql ASYMMETRICALLY on purpose: 'on' for a
// fresh install, 'off' for an upgrade. Turning it on retroactively would silently
// stop mailing every already-opted-in user on the day the operator upgraded, which
// is the G22 mistake — a safe default must not be applied backwards to a running
// system without telling anyone.
//
// Nothing CONSUMES this yet: the engine that would honour it arrives in Phase 4
// and the deliverability rules in Phase 9. It is seeded and editable here because
// the fresh-vs-upgrade distinction is only knowable at the migration that adds it,
// and reconstructing "was this install fresh?" later is guesswork.
const EMAIL_VERIFICATION_KEY = 'email_verification_required'
// Fail-safe direction is 'off': an unreadable or missing value must not silently
// suppress mail an operator believes is going out. The loud failure mode (mail
// reaching an unverified address) is recoverable; the quiet one is not.
async function isEmailVerificationRequired() {
try {
return String(await settingsDb.get(EMAIL_VERIFICATION_KEY)) === 'on'
} catch {
return false
}
}
// Android App Links opt-in (M9 follow-up). When on, the shard auto-serves
// /.well-known/assetlinks.json and the mobile SSO bridge additionally accepts the
// self-origin https://<host>/mobile/callback redirect. Stored as the string
@@ -256,6 +280,8 @@ module.exports = {
REGISTRATION_KEY,
REGISTRATION_MODES,
getRegistrationMode,
EMAIL_VERIFICATION_KEY,
isEmailVerificationRequired,
registrationFlags,
MOBILE_APP_LINKS_KEY,
isMobileAppLinksEnabled,

View File

@@ -1,7 +1,7 @@
const { query } = require('../../utils/db')
const PUBLIC_COLS =
'id, username, role, status, email, email_verified, totp_enabled, created_at, last_login_at'
'id, username, role, status, email, email_verified, email_pending, totp_enabled, created_at, last_login_at'
// passwordHash may be null (SSO-provisioned players who have not set one yet).
// email/status/emailVerified are optional so existing admin-create callers are
@@ -31,17 +31,47 @@ async function findById(id) {
return rows[0] || null
}
// All ACTIVE accounts on an email address. Email is intentionally non-unique
// (SSO emails may repeat), so a reset request can legitimately match several
// The ACTIVE account on an email address, as a list. Unique since engagement
// Phase 1b, so this returns at most one row — the array shape is kept because the
// password-reset caller iterates and there is nothing to gain from making it care.
// accounts; the caller issues one reset link per row. Case-insensitive to match
// however the address was stored. Excludes disabled/banned accounts.
// Active accounts on an address. Matches on email_norm, the same generated column
// the UNIQUE index uses, so a lookup folds case exactly the way uniqueness does —
// LOWER() here and LOWER() there can never drift apart. Since Phase 1b this
// returns at most one row; it still returns an array because the password-reset
// caller iterates and there is no value in making that caller care.
async function findActiveByEmail(email) {
return query(
"SELECT * FROM users WHERE email = ? AND status = 'active'",
"SELECT * FROM users WHERE email_norm = LOWER(?) AND status = 'active'",
[email],
)
}
// Stage an address the user has asked for but not yet proved. Does not touch
// `email`, so their current address keeps receiving mail until the link is used.
async function setPendingEmail(id, email) {
const res = await query('UPDATE users SET email_pending = ? WHERE id = ?', [email, id])
return res.affectedRows || 0
}
// Promote a proved address into place. Guarded on email_pending still matching, so
// a stale link (the user asked again for a different address) cannot install the
// address it was minted for. Returns rows changed — 0 means the guard rejected it.
async function promotePendingEmail(id, email) {
const res = await query(
`UPDATE users SET email = ?, email_verified = 1, email_pending = NULL
WHERE id = ? AND email_pending = ?`,
[email, id, email],
)
return res.affectedRows || 0
}
async function clearPendingEmail(id) {
const res = await query('UPDATE users SET email_pending = NULL WHERE id = ?', [id])
return res.affectedRows || 0
}
async function listUsers() {
return query(`SELECT ${PUBLIC_COLS} FROM users ORDER BY id ASC`)
}
@@ -112,6 +142,9 @@ module.exports = {
findByUsername,
findById,
findActiveByEmail,
setPendingEmail,
promotePendingEmail,
clearPendingEmail,
listUsers,
updateUser,
deleteUser,

View File

@@ -18,13 +18,54 @@ async function createUser({ username, password, role = 'admin', email = null, st
return sanitize(await usersDb.findById(id))
}
// True when a DB error is the unique-index violation on username (the atomic
// backstop for the uniqueness race). Callers translate this into a 409 rather
// than doing a check-then-write.
function isDuplicateUsername(err) {
// ── Telling the two unique constraints apart ───────────────────────────────
//
// `users` has had one unique index (username) for its whole life, so a bare
// "is this a duplicate-key error" test was enough. Engagement Phase 1b adds a
// second (email, via the generated email_norm column), and the moment it exists
// an undiscriminating test starts LYING: a duplicate email would be reported to
// the user as a taken username, and SSO provisioning would retry usernames
// forever against a conflict no username can clear (§0.6 finding 2).
//
// The violated index name is available ONLY in the driver's message text — the
// mariadb connector exposes no structured field for it — so this reads it back
// out. Verified against MariaDB 11.8:
// "(conn:60, no: 1062, SQLState: 23000) Duplicate entry 'x' for key 'username'"
//
// NOTE the message also embeds the bound parameters, so on an email collision it
// contains the address. That is fine in a server log and is exactly why these
// errors must never be echoed to a client (the anti-enumeration rule below).
const EMAIL_UNIQUE_KEY = 'uq_users_email_norm'
function isDuplicateKeyError(err) {
return Boolean(err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062))
}
// The name of the unique index that was violated, or null if this is not a
// duplicate-key error (or the driver phrased it in a way we do not recognise).
function duplicateKey(err) {
if (!isDuplicateKeyError(err)) return null
const m = /for key '([^']+)'/.exec(err.sqlMessage || err.message || '')
return m ? m[1] : null
}
// True when the collision was on the email uniqueness index.
function isDuplicateEmail(err) {
return duplicateKey(err) === EMAIL_UNIQUE_KEY
}
// True when a DB error is a unique-index violation that is NOT the email one (the
// atomic backstop for the username-uniqueness race). Callers translate this into
// a 409 rather than doing a check-then-write.
//
// Deliberately "not email" rather than "is username": on a database whose index
// happens to carry a different name, the old permissive behaviour is preserved
// and nothing newly falls through to a 500. Only the case we can positively
// identify — email — is carved out.
function isDuplicateUsername(err) {
return isDuplicateKeyError(err) && !isDuplicateEmail(err)
}
// Returns the raw row (incl. hash) — used by login only.
async function getRawByUsername(username) {
return usersDb.findByUsername(username)
@@ -35,7 +76,7 @@ async function getById(id) {
}
// Raw rows (incl. email/status) for every active account on an email address.
// Server-side only (password-reset request); email is non-unique so this may
// Server-side only (password-reset request). Unique since Phase 1b, so this
// return several. Never sent to a client.
async function getActiveByEmail(email) {
if (!email) return []
@@ -84,6 +125,20 @@ async function update(id, { username, password, role, email, status, emailVerifi
return getById(id)
}
// ── Pending email address (engagement Phase 1b) ────────────────────────────
// A requested address is staged rather than installed: `email` keeps working
// until the verification link proves the new one. See the users table comments.
const setPendingEmail = (id, email) => usersDb.setPendingEmail(id, email)
const clearPendingEmail = (id) => usersDb.clearPendingEmail(id)
// Promote a proved address. Returns true only if it actually landed; false means
// the guard rejected it (the user has since asked for a different address, so the
// token in hand is stale). Throws the duplicate-key error if the address was
// claimed by someone else in the meantime — the caller answers that generically.
async function promotePendingEmail(id, email) {
return (await usersDb.promotePendingEmail(id, email)) === 1
}
// Invalidate every session token this user currently holds ("log out everywhere")
// by advancing their tokens_valid_after cutoff to now.
async function invalidateSessions(id) {
@@ -115,9 +170,15 @@ async function recordLogin(id, ip = null) {
module.exports = {
createUser,
isDuplicateUsername,
isDuplicateEmail,
duplicateKey,
EMAIL_UNIQUE_KEY,
getRawByUsername,
getById,
getActiveByEmail,
setPendingEmail,
clearPendingEmail,
promotePendingEmail,
getRawById,
validatePassword,
list,