Files
website/server/src/model/users/users.db.js
Claude f8bcc7f6a3 Player accounts backend: schema, registration, self-service, SSO provision
- Widen users.role enum to include 'player'; make password_hash nullable;
  add email/email_verified/status/last_login_ip; pin username _ci collation.
- POST /auth/register (honeypot + registerLimiter + botScore, reserved-name
  blocklist, duplicate->409, auto-login). player_registration setting gates it.
- SSO auto-provision in finishLogin (setting-gated); return/portal-aware SSO
  redirects for the player portal; status refusal on login + requireAuth.
- New /player self-service group (account, change username/password, TOTP,
  identities), reusing account.controller; accountChangeLimiter.
- Admin: 'player' role + status/email on user create/update, role/status audit,
  player_registration enum validation, derived public registration flags.
- usernamePolicy module (reserved, sanitize, derive, dedup) + unit tests;
  extend SSO callback tests. 133 server tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-06 01:36:51 -05:00

115 lines
3.6 KiB
JavaScript

const { query } = require('../../utils/db')
const PUBLIC_COLS =
'id, username, role, status, email, email_verified, 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
// unaffected.
async function insertUser({
username,
passwordHash = null,
role = 'admin',
email = null,
status = 'active',
emailVerified = false,
}) {
const res = await query(
'INSERT INTO users (username, password_hash, role, email, status, email_verified) VALUES (?, ?, ?, ?, ?, ?)',
[username, passwordHash, role, email, status, emailVerified ? 1 : 0],
)
return res.insertId
}
async function findByUsername(username) {
const rows = await query('SELECT * FROM users WHERE username = ? LIMIT 1', [username])
return rows[0] || null
}
async function findById(id) {
const rows = await query('SELECT * FROM users WHERE id = ? LIMIT 1', [id])
return rows[0] || null
}
async function listUsers() {
return query(`SELECT ${PUBLIC_COLS} FROM users ORDER BY id ASC`)
}
async function updateUser(id, fields) {
const cols = []
const params = []
for (const [key, val] of Object.entries(fields)) {
cols.push(`${key} = ?`)
params.push(val)
}
if (cols.length === 0) return
params.push(id)
await query(`UPDATE users SET ${cols.join(', ')} WHERE id = ?`, params)
}
async function deleteUser(id) {
return query('DELETE FROM users WHERE id = ?', [id])
}
async function countUsers() {
const rows = await query('SELECT COUNT(*) AS c FROM users')
return Number(rows[0].c)
}
async function countAdmins() {
const rows = await query("SELECT COUNT(*) AS c FROM users WHERE role = 'admin'")
return Number(rows[0].c)
}
async function touchLastLogin(id, ip = null) {
return query('UPDATE users SET last_login_at = NOW(), last_login_ip = ? WHERE id = ?', [ip, id])
}
// Move the "tokens valid after" cutoff to now, invalidating every session token
// issued before this instant (password change / log out everywhere). requireAuth
// compares each session's issued-at against this column.
async function bumpTokensValidAfter(id) {
return query('UPDATE users SET tokens_valid_after = NOW() WHERE id = ?', [id])
}
// Set the cutoff to an explicit instant. Used when re-issuing the caller's own
// session right after a password change: the bump above revokes everything at
// NOW(), and requireAuth's cutoff test is inclusive (createdAt <= cutoff), so a
// freshly-minted token sharing that same wall-clock second would be revoked too.
// Rewinding the cutoff a hair below the new token's issued-at lets it survive
// while still revoking every older session.
async function setTokensValidAfter(id, when) {
return query('UPDATE users SET tokens_valid_after = ? WHERE id = ?', [when, id])
}
// Store a (not-yet-enabled) TOTP secret for a user. Enabling is a separate step
// so a secret is never trusted until the user has confirmed one code.
async function setTotpSecret(id, secret) {
return query('UPDATE users SET totp_secret = ?, totp_enabled = 0 WHERE id = ?', [secret, id])
}
async function enableTotp(id) {
return query('UPDATE users SET totp_enabled = 1 WHERE id = ?', [id])
}
async function disableTotp(id) {
return query('UPDATE users SET totp_secret = NULL, totp_enabled = 0 WHERE id = ?', [id])
}
module.exports = {
insertUser,
findByUsername,
findById,
listUsers,
updateUser,
deleteUser,
countUsers,
countAdmins,
touchLastLogin,
bumpTokensValidAfter,
setTokensValidAfter,
setTotpSecret,
enableTotp,
disableTotp,
}