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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user