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

126 lines
3.7 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))
}
// 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) {
return Boolean(err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062))
}
// 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 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)
}
// 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,
getRawByUsername,
getById,
getRawById,
validatePassword,
list,
update,
invalidateSessions,
setSessionCutoff,
remove,
count,
countAdmins,
recordLogin,
setTotpSecret,
enableTotp,
disableTotp,
}