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,111 @@
// ── Username policy ────────────────────────────────────────────────────────
//
// Pure helpers shared by public registration and SSO auto-provisioning:
// - a reserved-name blocklist (staff-impersonating / system names),
// - normalization (trim; case is preserved for display, uniqueness folds case
// at the DB via the column's _ci collation), and
// - deriving a valid username from an external SSO profile.
//
// No I/O — the DB UNIQUE index is the source of truth for collisions; these
// helpers only shape/validate candidate names and pick suffixes to retry with.
// Allowed characters in a stored username: letters, digits, dot, underscore,
// dash. Length 332 (matches the register validator + the column width).
const USERNAME_RE = /^[A-Za-z0-9_.-]{3,32}$/
const MIN_LEN = 3
const MAX_LEN = 32
// Names that must never belong to a self-registered account because they imply
// staff/system authority or are otherwise confusing. Compared case-insensitively.
const RESERVED_USERNAMES = new Set([
'admin',
'administrator',
'root',
'system',
'staff',
'mod',
'moderator',
'owner',
'support',
'help',
'null',
'undefined',
'me',
'anonymous',
'everyone',
'here',
])
// Trim surrounding whitespace. Case is preserved (stored as entered); the DB's
// _ci collation folds case for uniqueness + lookup.
function normalizeUsername(raw) {
return typeof raw === 'string' ? raw.trim() : ''
}
function isReserved(name) {
return RESERVED_USERNAMES.has(String(name || '').trim().toLowerCase())
}
function isValidFormat(name) {
return USERNAME_RE.test(name)
}
// Validate a user-chosen username for registration. Returns { ok, message }.
function validateUsername(raw) {
const name = normalizeUsername(raw)
if (!isValidFormat(name)) {
return { ok: false, message: 'Username must be 332 characters (letters, numbers, . _ -).' }
}
if (isReserved(name)) {
return { ok: false, message: 'That username is not available.' }
}
return { ok: true, name }
}
// Reduce an arbitrary string to the allowed charset, clamped to MAX_LEN. Used as
// the base for SSO-derived usernames before uniqueness suffixing.
function sanitizeToUsername(raw) {
let s = String(raw || '')
.normalize('NFKD')
.replace(/[^A-Za-z0-9_.-]/g, '')
.replace(/^[._-]+/, '') // don't start with punctuation
.slice(0, MAX_LEN)
return s
}
// Derive a base username from a normalized SSO profile ({ name, email, subject }).
// Tries display name, then the email local-part, then a generic 'player' base.
// The result is always a valid *base* (>= MIN_LEN, sanitized) but is NOT
// guaranteed unique — the caller suffixes + retries against the UNIQUE index.
function deriveUsernameBase(profile) {
const candidates = [profile && profile.name, profile && (profile.email || '').split('@')[0]]
for (const c of candidates) {
const s = sanitizeToUsername(c)
if (s.length >= MIN_LEN && !isReserved(s)) return s
}
return 'player'
}
// Build the Nth candidate username for the dedup retry loop: attempt 0 is the
// bare base (padded if short), later attempts append an increasing numeric
// suffix, always clamped to MAX_LEN so the suffix survives truncation.
function candidateUsername(base, attempt) {
const safeBase = base.length >= MIN_LEN ? base : `${base}player`.slice(0, MAX_LEN)
if (attempt === 0) return safeBase
const suffix = String(attempt + 1) // 2, 3, 4, …
return `${safeBase.slice(0, MAX_LEN - suffix.length)}${suffix}`
}
module.exports = {
USERNAME_RE,
MIN_LEN,
MAX_LEN,
RESERVED_USERNAMES,
normalizeUsername,
isReserved,
isValidFormat,
validateUsername,
sanitizeToUsername,
deriveUsernameBase,
candidateUsername,
}