ACCOUNT_DELETION.md phase 2 (core): a member deletes their own account on
the website, and the admin route runs the same procedure.
- DELETE /api/v1/auth/me/account: typed username, password always (AD7),
and a 2FA or recovery code when 2FA is on. A wrong password or code is
400, not 401 (AD12), so the app's refresh-on-401 never replays it.
409 password_required and last_admin; the demo's shared logins are
locked.
- auth/accountDeletion.js: module hooks first, then one transaction that
reserves a staff username (reserved_usernames, AD9) and deletes the row,
then a Teams reconcile, the audit entry (no username) and a notice to
the old address (new protected template auth.account-deleted).
- MODULE_API 1.20.0: api.onUserDeleting({ userId, deletedBy }), before the
delete, 10 s limit, cannot veto (AD10, AD11).
- utils/displayAuthor.js: a deleted account's username snapshots read
"[deleted player]" to members and the stored name, marked, to staff
(AD5, AD6). Used by the forum, the Reports queue, the guest list and
the Team digest. A deleted account drops off Forum guests (AD13).
- Reserved names refuse at every username write, answered as "taken".
- Web: Delete my account on the player and staff Account pages; staff
views mark "(deleted account)".
- OpenAPI regenerated; route manifest and engagement manifest regenerated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
239 lines
8.9 KiB
JavaScript
239 lines
8.9 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 }) {
|
|
await refuseReserved(username)
|
|
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) {
|
|
if (err && err.code === USERNAME_RESERVED) return true
|
|
return isDuplicateKeyError(err) && !isDuplicateEmail(err)
|
|
}
|
|
|
|
// ── Reserved usernames (ACCOUNT_DELETION.md AD9) ───────────────────────────
|
|
//
|
|
// A deleted staff account's name can never be taken again. The refusal is
|
|
// raised HERE, in the two writes every username passes through, rather than in
|
|
// each of the six routes that create or rename an account: registration, an
|
|
// invite's acceptance, SSO provisioning, an admin creating or renaming a user,
|
|
// and a self-service change. Each of them already answers a duplicate username,
|
|
// and `isDuplicateUsername` above reports a reserved one as exactly that, so a
|
|
// reserved name reads as a taken one everywhere — the same 409, the same words,
|
|
// and SSO provisioning moves on to its next candidate.
|
|
//
|
|
// A check-then-write, so not atomic against a deletion committing in between.
|
|
// That window is one request wide and needs a staff account deleted at the same
|
|
// instant someone registers its name; the UNIQUE index still holds until the
|
|
// deletion commits.
|
|
const USERNAME_RESERVED = 'USERNAME_RESERVED'
|
|
|
|
async function refuseReserved(username) {
|
|
if (typeof username !== 'string' || !username) return
|
|
if (await usersDb.isUsernameReserved(username.trim())) {
|
|
const err = new Error('username is reserved')
|
|
err.code = USERNAME_RESERVED
|
|
throw 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) {
|
|
await refuseReserved(username)
|
|
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)
|
|
}
|
|
|
|
// The deletion's own write: the row goes, and a staff name is reserved in the
|
|
// same transaction. Only auth/accountDeletion.js calls this; `remove` above is
|
|
// for undoing an account this request itself just created (a lost invite race).
|
|
async function removeReserving(id, reserveName = null) {
|
|
return usersDb.deleteUserReserving(id, reserveName)
|
|
}
|
|
|
|
const isUsernameReserved = (username) => usersDb.isUsernameReserved(username)
|
|
|
|
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,
|
|
removeReserving,
|
|
isUsernameReserved,
|
|
USERNAME_RESERVED,
|
|
count,
|
|
countAdmins,
|
|
recordLogin,
|
|
setTotpSecret,
|
|
enableTotp,
|
|
disableTotp,
|
|
}
|