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
This commit is contained in:
2026-07-06 01:36:51 -05:00
parent cdd916e199
commit f8bcc7f6a3
18 changed files with 934 additions and 55 deletions

View File

@@ -5,20 +5,110 @@
const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const userIdentities = require('../../../model/userIdentities/userIdentities.model')
const sessionService = require('../../../auth/session.service')
const { setAuthCookie } = require('../../../auth/token')
const usernamePolicy = require('../../../auth/usernamePolicy')
const loginProtection = require('../../../middleware/loginProtection')
const botScore = require('../../../middleware/botScore')
const totp = require('../../../utils/totp')
const log = require('../../../utils/logger')('account')
// Current user's security status (does not expose the secret).
// Current user's security status (does not expose the secret). has_password lets
// the player portal tell an SSO-only account (must *set* a password, no current
// one required) apart from one that already has a usable password.
async function getAccount(req, res) {
return res.json({
id: req.user.id,
username: req.user.username,
role: req.user.role,
email: req.user.email || null,
status: req.user.status || 'active',
totp_enabled: Boolean(req.user.totp_enabled),
has_password: Boolean(req.user.password_hash),
})
}
// Re-mint this caller's session and refresh their cookie so a self-service change
// (username/password) doesn't log them out. Returns the new Session object.
function reissueSession(req, res, user) {
const { token: sessionToken, session } = sessionService.createSession(user, req.authMethod || 'local')
setAuthCookie(req, res, sessionToken)
return session
}
// PATCH /account/username — change the caller's own username. The DB UNIQUE index
// is the source of truth for collisions (case-insensitive via the column's _ci
// collation): attempt the write and translate a duplicate-key error into 409.
async function changeUsername(req, res) {
const check = usernamePolicy.validateUsername(req.body.username)
if (!check.ok) return res.status(400).json({ message: check.message })
try {
if (check.name === req.user.username) {
return res.status(400).json({ message: 'That is already your username.' })
}
let updated
try {
updated = await users.update(req.user.id, { username: check.name })
} catch (err) {
if (users.isDuplicateUsername(err)) {
return res.status(409).json({ message: 'That username is already taken.' })
}
throw err
}
// The JWT embeds username; authz always uses the fresh DB row, but re-issue
// the cookie so nothing downstream renders a stale name. No global revocation
// — a username isn't a secret.
reissueSession(req, res, updated)
await activity.log({ req, action: 'account.username.change', detail: { username: updated.username } })
log.info('account username changed', { id: req.user.id, username: updated.username })
return res.json({ username: updated.username })
} catch (err) {
log.error('changeUsername', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// PATCH /account/password — change (or set) the caller's own password.
// • Account already has a password: require currentPassword and verify it.
// • SSO-provisioned account with a null hash: allow setting an initial password
// with no current password required.
// users.update rotates the hash and revokes existing sessions; we then re-issue
// this caller's session so their own change doesn't log them out.
async function changePassword(req, res) {
try {
const raw = await users.getRawById(req.user.id)
if (!raw) return res.status(401).json({ message: 'Unauthorized' })
if (raw.password_hash) {
const ok = await users.validatePassword(raw, req.body.currentPassword || '')
if (!ok) {
// A wrong current password is credential-guessing — trip the same
// backoff + bot scoring as a failed login.
loginProtection.recordFailure(req.ip)
botScore.recordLoginFailure(req.ip)
log.warn('changePassword wrong current password', { id: req.user.id, ip: req.ip })
return res.status(400).json({ message: 'Your current password is incorrect.' })
}
}
// Rotate the hash + revoke every existing session (users.update bumps the cutoff).
const updated = await users.update(req.user.id, { password: req.body.newPassword })
// Re-issue this caller's session, then rewind the cutoff just below the new
// token's issued-at so the inclusive cutoff test doesn't catch it (see users.db).
const session = reissueSession(req, res, updated)
if (session && session.createdAt) {
await users.setSessionCutoff(req.user.id, new Date(session.createdAt - 1000))
}
await activity.log({ req, action: 'account.password.change' })
log.info('account password changed', { id: req.user.id })
return res.json({ ok: true })
} catch (err) {
log.error('changePassword', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// Step 1: generate a fresh secret (stored but not yet enabled) and return the
// otpauth URL + a QR data URL for the user to scan. Overwrites any pending,
// not-yet-confirmed secret. Refuses if TOTP is already enabled.
@@ -110,4 +200,13 @@ async function unlinkIdentity(req, res) {
}
}
module.exports = { getAccount, totpSetup, totpEnable, totpDisable, listIdentities, unlinkIdentity }
module.exports = {
getAccount,
changeUsername,
changePassword,
totpSetup,
totpEnable,
totpDisable,
listIdentities,
unlinkIdentity,
}