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

@@ -0,0 +1,132 @@
// ── Player self-service (role: 'player') ───────────────────────────────────
//
// The player-gated surface. Every route here requires an authenticated session
// whose fresh DB role is 'player' (staff use /admin/account for the same self-
// service). Handlers are shared with the admin account view (account.controller)
// — the same TOTP / identity logic, plus the net-new self-scoped credential
// changes. Future player-only endpoints (profile, etc.) hang off this group.
const express = require('express')
const { body, param } = require('express-validator')
const account = require('../admin/account.controller')
const { requireAuth, requireRole } = require('../../../auth/session.middleware')
const noindex = require('../../../middleware/noindex')
const validate = require('../../../middleware/validate')
const { accountChangeLimiter } = require('../../../middleware/rateLimit')
const playerRouter = express.Router()
// Group gate: authenticated + fresh role must be 'player', and keep it out of
// search indexes. requireAuth also enforces the account status check (a
// disabled/banned player is rejected here with 403 before any handler runs).
playerRouter.use(noindex, requireAuth, requireRole('player'))
playerRouter.get(
'/account',
// #swagger.tags = ['Player']
// #swagger.summary = 'Get the current player account (self)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The player account', content: { "application/json": { schema: { $ref: "#/components/schemas/PlayerAccount" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Player role required, or account not active', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
account.getAccount,
)
playerRouter.patch(
'/account/username',
// #swagger.tags = ['Player']
// #swagger.summary = 'Change the current players username'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ChangeUsernameRequest" } } } } */
/* #swagger.responses[200] = { description: 'Updated username (session cookie re-issued)', content: { "application/json": { schema: { type: "object", properties: { username: { type: "string" } } } } } } */
/* #swagger.responses[400] = { description: 'Validation error or unavailable username', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[403] = { description: 'Player role required, or account not active', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many changes (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
accountChangeLimiter,
body('username').isString().trim().isLength({ min: 3, max: 32 }),
validate,
account.changeUsername,
)
playerRouter.patch(
'/account/password',
// #swagger.tags = ['Player']
// #swagger.summary = 'Change or set the current players password'
// #swagger.description = 'If the account already has a password, currentPassword is required and verified. SSO-provisioned accounts with no password may set an initial one without a current password. On success the callers session is re-issued (they stay logged in) while all other sessions are revoked.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ChangePasswordRequest" } } } } */
/* #swagger.responses[200] = { description: 'Password changed', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
/* #swagger.responses[400] = { description: 'Validation error or wrong current password', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Player role required, or account not active', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many changes (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
accountChangeLimiter,
body('newPassword').isString().isLength({ min: 8, max: 64 }),
body('currentPassword').optional({ values: 'falsy' }).isString(),
validate,
account.changePassword,
)
// TOTP self-enrollment — identical to the admin account flow (disable requires a
// valid current code; it does not take a password).
playerRouter.post(
'/account/totp/setup',
// #swagger.tags = ['Player']
// #swagger.summary = 'Begin 2FA enrollment (returns secret + QR)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'otpauth URL and QR data to scan', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpSetup" } } } } */
/* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
account.totpSetup,
)
playerRouter.post(
'/account/totp/enable',
// #swagger.tags = ['Player']
// #swagger.summary = 'Enable 2FA by confirming a code'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */
/* #swagger.responses[200] = { description: '2FA enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpState" } } } } */
/* #swagger.responses[400] = { description: 'Setup not started, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
body('code').isString().trim().isLength({ min: 6, max: 8 }),
validate,
account.totpEnable,
)
playerRouter.post(
'/account/totp/disable',
// #swagger.tags = ['Player']
// #swagger.summary = 'Disable 2FA by confirming a code'
// #swagger.description = 'Requires a valid current authenticator code (proves control of the authenticator); it does not take a password.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */
/* #swagger.responses[200] = { description: '2FA disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpState" } } } } */
/* #swagger.responses[400] = { description: 'Not enabled, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
body('code').isString().trim().isLength({ min: 6, max: 8 }),
validate,
account.totpDisable,
)
// Linked SSO identities (self-service). Linking itself starts at
// GET /auth/sso/:provider/link (already behind requireAuth; works for players).
playerRouter.get(
'/account/identities',
// #swagger.tags = ['Player']
// #swagger.summary = 'List linked SSO identities (self)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Linked identities', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/LinkedIdentity" } } } } } */
account.listIdentities,
)
playerRouter.delete(
'/account/identities/:provider',
// #swagger.tags = ['Player']
// #swagger.summary = 'Unlink an SSO identity (self)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { $ref: "#/components/schemas/UnlinkedFlag" } } } } */
/* #swagger.responses[404] = { description: 'No linked account for that provider', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('provider').matches(/^[a-z0-9-]+$/),
validate,
account.unlinkIdentity,
)
module.exports = playerRouter