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,
}

View File

@@ -454,6 +454,13 @@ async function updateSettings(req, res) {
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
return res.status(400).json({ message: 'Expected an object of key/value settings' })
}
// Enum-constrained keys are validated here (the store itself is schemaless).
if (
settings.REGISTRATION_KEY in updates &&
!settings.REGISTRATION_MODES.includes(updates[settings.REGISTRATION_KEY])
) {
return res.status(400).json({ message: 'Invalid player_registration value' })
}
try {
await settings.setMany(updates, req.user.id)
await activity.log({ req, action: 'settings.update', detail: { keys: Object.keys(updates) } })
@@ -493,8 +500,14 @@ async function createUser(req, res) {
username: req.body.username,
password: req.body.password,
role: req.body.role || 'admin',
email: req.body.email || null,
status: req.body.status || 'active',
})
await activity.log({
req,
action: 'user.create',
detail: { id: user.id, username: user.username, role: user.role },
})
await activity.log({ req, action: 'user.create', detail: { id: user.id, username: user.username } })
return res.status(201).json(user)
} catch (err) {
log.error('createUser', err)
@@ -525,8 +538,26 @@ async function updateUser(req, res) {
username: req.body.username,
password: req.body.password,
role: req.body.role,
email: req.body.email,
status: req.body.status,
})
await activity.log({ req, action: 'user.update', detail: { id } })
// Distinct audit trail for the security-sensitive fields (role & status),
// so a promotion/ban is greppable beyond the generic user.update entry.
if (req.body.role && req.body.role !== target.role) {
await activity.log({
req,
action: 'admin.user.role_change',
detail: { id, from: target.role, to: req.body.role },
})
}
if (req.body.status && req.body.status !== target.status) {
await activity.log({
req,
action: 'admin.user.status_change',
detail: { id, from: target.status, to: req.body.status },
})
}
return res.json(user)
} catch (err) {
log.error('updateUser', err)

View File

@@ -763,7 +763,9 @@ adminRouter.post(
/* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
body('username').isString().trim().isLength({ min: 3, max: 32 }),
body('password').isString().isLength({ min: 8, max: 64 }),
body('role').optional().isIn(['admin', 'editor', 'moderator']),
body('role').optional().isIn(['admin', 'editor', 'moderator', 'player']),
body('status').optional().isIn(['active', 'disabled', 'banned', 'pending']),
body('email').optional({ values: 'falsy' }).isEmail().isLength({ max: 255 }),
validate,
ctrl.createUser,
)
@@ -783,7 +785,9 @@ adminRouter.put(
param('id').isInt(),
body('username').optional().isString().trim().isLength({ min: 3, max: 32 }),
body('password').optional().isString().isLength({ min: 8, max: 64 }),
body('role').optional().isIn(['admin', 'editor', 'moderator']),
body('role').optional().isIn(['admin', 'editor', 'moderator', 'player']),
body('status').optional().isIn(['active', 'disabled', 'banned', 'pending']),
body('email').optional({ values: 'null' }).isEmail().isLength({ max: 255 }),
validate,
ctrl.updateUser,
)