Files
website/server/src/model/users/users.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

196 lines
7.0 KiB
JavaScript

const bcrypt = require('bcryptjs')
const usersDb = require('./users.db')
const SALT_ROUNDS = 10
// Strip secrets (password hash, TOTP secret) before sending a user anywhere.
function sanitize(user) {
if (!user) return null
const { password_hash, totp_secret, ...safe } = user
return safe
}
// password may be omitted/null — an SSO-provisioned player has no password until
// they set one (a null hash makes password login impossible, see validatePassword).
async function createUser({ username, password, role = 'admin', email = null, status = 'active', emailVerified = false }) {
const passwordHash = password ? await bcrypt.hash(password, SALT_ROUNDS) : null
const id = await usersDb.insertUser({ username, passwordHash, role, email, status, emailVerified })
return sanitize(await usersDb.findById(id))
}
// ── 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)
}
async function getById(id) {
return sanitize(await usersDb.findById(id))
}
// Raw rows (incl. email/status) for every active account on an email address.
// 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 []
return usersDb.findActiveByEmail(String(email).trim())
}
// Raw row incl. totp_secret — server-side only (TOTP setup/verify). Never sent
// to a client; sanitize() strips the secret from anything user-facing.
async function getRawById(id) {
return usersDb.findById(id)
}
async function setTotpSecret(id, secret) {
return usersDb.setTotpSecret(id, secret)
}
async function enableTotp(id) {
return usersDb.enableTotp(id)
}
async function disableTotp(id) {
return usersDb.disableTotp(id)
}
async function validatePassword(user, password) {
if (!user || !user.password_hash) return false
return bcrypt.compare(password, user.password_hash)
}
async function list() {
return usersDb.listUsers()
}
async function update(id, { username, password, role, email, status, emailVerified }) {
const fields = {}
if (username !== undefined) fields.username = username
if (role !== undefined) fields.role = role
if (email !== undefined) fields.email = email
if (status !== undefined) fields.status = status
if (emailVerified !== undefined) fields.email_verified = emailVerified ? 1 : 0
if (password) fields.password_hash = await bcrypt.hash(password, SALT_ROUNDS)
await usersDb.updateUser(id, fields)
// A password change must revoke existing sessions ("change password to log
// everyone out"), so bump the cutoff whenever the hash was rotated.
if (password) await usersDb.bumpTokensValidAfter(id)
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) {
return usersDb.bumpTokensValidAfter(id)
}
// Set the session cutoff to an explicit instant. Used by the self password-change
// flow to keep the caller's freshly re-issued session alive (see users.db).
async function setSessionCutoff(id, when) {
return usersDb.setTokensValidAfter(id, when)
}
async function remove(id) {
return usersDb.deleteUser(id)
}
async function count() {
return usersDb.countUsers()
}
async function countAdmins() {
return usersDb.countAdmins()
}
async function recordLogin(id, ip = null) {
return usersDb.touchLastLogin(id, ip)
}
module.exports = {
createUser,
isDuplicateUsername,
isDuplicateEmail,
duplicateKey,
EMAIL_UNIQUE_KEY,
getRawByUsername,
getById,
getActiveByEmail,
setPendingEmail,
clearPendingEmail,
promotePendingEmail,
getRawById,
validatePassword,
list,
update,
invalidateSessions,
setSessionCutoff,
remove,
count,
countAdmins,
recordLogin,
setTotpSecret,
enableTotp,
disableTotp,
}