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:
111
server/src/auth/usernamePolicy.js
Normal file
111
server/src/auth/usernamePolicy.js
Normal 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 3–32 (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 3–32 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,
|
||||
}
|
||||
Reference in New Issue
Block a user