From f8bcc7f6a3bb61f4b682a1bce06dfa54eb6ddb62 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 01:36:51 -0500 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV --- server/db/schema.sql | 43 +++++- server/src/auth/session.middleware.js | 7 + server/src/auth/usernamePolicy.js | 111 ++++++++++++++ server/src/middleware/rateLimit.js | 29 +++- server/src/model/settings/settings.model.js | 41 ++++- server/src/model/users/users.db.js | 34 ++++- server/src/model/users/users.model.js | 32 +++- .../src/router/v1/admin/account.controller.js | 103 ++++++++++++- .../src/router/v1/admin/admin.controller.js | 33 +++- server/src/router/v1/admin/admin.routes.js | 8 +- server/src/router/v1/auth/auth.controller.js | 65 +++++++- server/src/router/v1/auth/auth.routes.js | 28 +++- server/src/router/v1/auth/sso.controller.js | 143 ++++++++++++++---- server/src/router/v1/player/player.routes.js | 132 ++++++++++++++++ server/src/router/v1/v1.router.js | 2 + server/test/playerAccounts.test.js | 89 +++++++++++ server/test/ssoCallback.test.js | 37 +++++ server/test/usernamePolicy.test.js | 52 +++++++ 18 files changed, 934 insertions(+), 55 deletions(-) create mode 100644 server/src/auth/usernamePolicy.js create mode 100644 server/src/router/v1/player/player.routes.js create mode 100644 server/test/playerAccounts.test.js create mode 100644 server/test/usernamePolicy.test.js diff --git a/server/db/schema.sql b/server/db/schema.sql index ae9e9e1..bcdcc62 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -4,16 +4,34 @@ CREATE TABLE IF NOT EXISTS users ( id INT AUTO_INCREMENT PRIMARY KEY, - username VARCHAR(32) NOT NULL UNIQUE, - password_hash VARCHAR(72) NOT NULL, - role ENUM('admin','editor','moderator') NOT NULL DEFAULT 'admin', + -- COLLATE is pinned to a case-insensitive (_ci) collation so uniqueness and + -- findByUsername lookups both fold case identically ('Foo' == 'foo'). This is + -- the atomic backstop for the username-uniqueness race (see the register / + -- change-username duplicate-key handling). + username VARCHAR(32) NOT NULL COLLATE utf8mb4_general_ci UNIQUE, + -- Nullable: SSO-provisioned players have no password until they choose to set + -- one. A NULL hash means password login is impossible for that account + -- (validatePassword returns false). + password_hash VARCHAR(72) NULL, + role ENUM('admin','editor','moderator','player') NOT NULL DEFAULT 'admin', + -- Optional contact email (players). Not unique — SSO emails may repeat. Used + -- only for display + a future self-serve reset. email_verified is wired now so + -- an eventual SMTP verification flow needs no schema change. + email VARCHAR(255) NULL, + email_verified TINYINT(1) NOT NULL DEFAULT 0, + -- Account lifecycle, independent of role: staff can disable/ban a player + -- without changing their role. active = normal; disabled = admin-locked; + -- banned = moderation ban; pending = reserved for future email-verify gating. + -- Enforced in requireAuth + login (non-active is rejected). + status ENUM('active','pending','disabled','banned') NOT NULL DEFAULT 'active', totp_secret VARCHAR(64) NULL, -- base32 TOTP secret (opt-in 2FA) totp_enabled TINYINT(1) NOT NULL DEFAULT 0, -- Any session token issued before this instant is rejected (see requireAuth). -- Bumped on password change / "log out everywhere". NULL = no cutoff yet. tokens_valid_after DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_login_at DATETIME NULL + last_login_at DATETIME NULL, + last_login_ip VARCHAR(45) NULL -- IPv6-capable; set on each login ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS posts ( @@ -458,7 +476,22 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS tokens_valid_after DATETIME NULL; -- Moderation dashboard (Phase 6): add the 'moderator' role to databases created -- before it. MODIFY has no IF NOT EXISTS form, but re-declaring the same ENUM is -- an idempotent no-op, so it is safe to run on every boot. -ALTER TABLE users MODIFY COLUMN role ENUM('admin','editor','moderator') NOT NULL DEFAULT 'admin'; +-- Player accounts: widen the enum again to include 'player' (self-service public +-- accounts). Same idempotent-MODIFY pattern. +ALTER TABLE users MODIFY COLUMN role ENUM('admin','editor','moderator','player') NOT NULL DEFAULT 'admin'; +-- Player accounts: make password_hash nullable (SSO-only players), pin the +-- username collation (case-insensitive uniqueness backstop), and add the player +-- columns to databases created before this. MODIFY is an idempotent no-op when +-- the column already matches; ADD COLUMN IF NOT EXISTS is safe to re-run. +ALTER TABLE users MODIFY COLUMN password_hash VARCHAR(72) NULL; +ALTER TABLE users MODIFY COLUMN username VARCHAR(32) NOT NULL COLLATE utf8mb4_general_ci; +ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255) NULL; +ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified TINYINT(1) NOT NULL DEFAULT 0; +ALTER TABLE users ADD COLUMN IF NOT EXISTS status ENUM('active','pending','disabled','banned') NOT NULL DEFAULT 'active'; +ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_ip VARCHAR(45) NULL; +-- Player self-registration mode: disabled | password | sso | both. Default off, +-- so the system behaves exactly as today until an admin opts in. +INSERT IGNORE INTO settings (`key`, value) VALUES ('player_registration', 'disabled'); ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL; ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL; diff --git a/server/src/auth/session.middleware.js b/server/src/auth/session.middleware.js index 9582127..1171858 100644 --- a/server/src/auth/session.middleware.js +++ b/server/src/auth/session.middleware.js @@ -51,6 +51,13 @@ async function requireAuth(req, res, next) { const user = await users.getById(session.userId) if (!user) return res.status(401).json({ message: 'Unauthorized' }) // deleted since token issued + // Status gate, enforced on every request (same immediacy as the cutoff + // below): a player disabled/banned by staff loses access on their very next + // request, not when their JWT eventually expires. + if (user.status && user.status !== 'active') { + return res.status(403).json({ message: 'Account disabled' }) + } + // Revocation, enforced here (not in stateless token verification): // 1. per-user cutoff — password change / "log out everywhere" bumps // tokens_valid_after; any token issued before it is dead. diff --git a/server/src/auth/usernamePolicy.js b/server/src/auth/usernamePolicy.js new file mode 100644 index 0000000..9ae3b9e --- /dev/null +++ b/server/src/auth/usernamePolicy.js @@ -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, +} diff --git a/server/src/middleware/rateLimit.js b/server/src/middleware/rateLimit.js index b7a0a0c..ca048c5 100644 --- a/server/src/middleware/rateLimit.js +++ b/server/src/middleware/rateLimit.js @@ -24,6 +24,26 @@ const loginLimiter = makeLimiter({ message: 'Too many login attempts. Please try again later.', }) +// Public self-registration. Mirrors the login cap: a handful of legitimate +// attempts per window, a flood is abuse. The global botScore guard + honeypot +// cover the rest. +const registerLimiter = makeLimiter({ + windowMs: 15 * 60 * 1000, + max: 10, + label: 'register', + message: 'Too many registration attempts. Please try again later.', +}) + +// Authenticated self-service credential changes (username / password). Tighter +// than login — a signed-in player rarely changes these, and the wrong-current- +// password path also feeds the shared login backoff (see the controller). +const accountChangeLimiter = makeLimiter({ + windowMs: 15 * 60 * 1000, + max: 10, + label: 'account-change', + message: 'Too many changes. Please try again later.', +}) + // Throttle the public contact form. const contactLimiter = makeLimiter({ windowMs: 60 * 60 * 1000, @@ -51,4 +71,11 @@ const ssoStartLimiter = makeLimiter({ message: 'Too many sign-in attempts. Please try again later.', }) -module.exports = { loginLimiter, contactLimiter, mobileRefreshLimiter, ssoStartLimiter } +module.exports = { + loginLimiter, + registerLimiter, + accountChangeLimiter, + contactLimiter, + mobileRefreshLimiter, + ssoStartLimiter, +} diff --git a/server/src/model/settings/settings.model.js b/server/src/model/settings/settings.model.js index 08a1fa9..3ab7b69 100644 --- a/server/src/model/settings/settings.model.js +++ b/server/src/model/settings/settings.model.js @@ -11,6 +11,27 @@ const PUBLIC_KEYS = [ 'hero_layout', // portal hero composition (JSON). Draft key stays admin-only. ] +// Player self-registration mode. Stored under the 'player_registration' key. +// NOTE: the raw value is never exposed publicly — getPublic() derives boolean +// availability flags from it instead (see below). +const REGISTRATION_KEY = 'player_registration' +const REGISTRATION_MODES = ['disabled', 'password', 'sso', 'both'] + +// Resolve the registration mode, defaulting to 'disabled' (and coercing any +// unexpected stored value back to 'disabled' so a bad row can't open sign-up). +async function getRegistrationMode() { + const value = await settingsDb.get(REGISTRATION_KEY) + return REGISTRATION_MODES.includes(value) ? value : 'disabled' +} + +// Derived, public-safe availability flags for the register page. +function registrationFlags(mode) { + return { + password: mode === 'password' || mode === 'both', + sso: mode === 'sso' || mode === 'both', + } +} + async function get(key) { return settingsDb.get(key) } @@ -35,10 +56,26 @@ async function getAll() { async function getPublic() { const all = await getAll() - return PUBLIC_KEYS.reduce((acc, key) => { + const out = PUBLIC_KEYS.reduce((acc, key) => { if (all[key] !== undefined) acc[key] = all[key] return acc }, {}) + // Derived registration availability (never the raw mode). Lets the register + // page show/hide the password form and SSO buttons. + const mode = REGISTRATION_MODES.includes(all[REGISTRATION_KEY]) ? all[REGISTRATION_KEY] : 'disabled' + out.registration = registrationFlags(mode) + return out } -module.exports = { get, set, setMany, getAll, getPublic, PUBLIC_KEYS } +module.exports = { + get, + set, + setMany, + getAll, + getPublic, + PUBLIC_KEYS, + REGISTRATION_KEY, + REGISTRATION_MODES, + getRegistrationMode, + registrationFlags, +} diff --git a/server/src/model/users/users.db.js b/server/src/model/users/users.db.js index 470469f..e506106 100644 --- a/server/src/model/users/users.db.js +++ b/server/src/model/users/users.db.js @@ -1,11 +1,22 @@ const { query } = require('../../utils/db') -const PUBLIC_COLS = 'id, username, role, totp_enabled, created_at, last_login_at' +const PUBLIC_COLS = + 'id, username, role, status, email, email_verified, totp_enabled, created_at, last_login_at' -async function insertUser({ username, passwordHash, role = 'admin' }) { +// passwordHash may be null (SSO-provisioned players who have not set one yet). +// email/status/emailVerified are optional so existing admin-create callers are +// unaffected. +async function insertUser({ + username, + passwordHash = null, + role = 'admin', + email = null, + status = 'active', + emailVerified = false, +}) { const res = await query( - 'INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)', - [username, passwordHash, role], + 'INSERT INTO users (username, password_hash, role, email, status, email_verified) VALUES (?, ?, ?, ?, ?, ?)', + [username, passwordHash, role, email, status, emailVerified ? 1 : 0], ) return res.insertId } @@ -50,8 +61,8 @@ async function countAdmins() { return Number(rows[0].c) } -async function touchLastLogin(id) { - return query('UPDATE users SET last_login_at = NOW() WHERE id = ?', [id]) +async function touchLastLogin(id, ip = null) { + return query('UPDATE users SET last_login_at = NOW(), last_login_ip = ? WHERE id = ?', [ip, id]) } // Move the "tokens valid after" cutoff to now, invalidating every session token @@ -61,6 +72,16 @@ async function bumpTokensValidAfter(id) { return query('UPDATE users SET tokens_valid_after = NOW() WHERE id = ?', [id]) } +// Set the cutoff to an explicit instant. Used when re-issuing the caller's own +// session right after a password change: the bump above revokes everything at +// NOW(), and requireAuth's cutoff test is inclusive (createdAt <= cutoff), so a +// freshly-minted token sharing that same wall-clock second would be revoked too. +// Rewinding the cutoff a hair below the new token's issued-at lets it survive +// while still revoking every older session. +async function setTokensValidAfter(id, when) { + return query('UPDATE users SET tokens_valid_after = ? WHERE id = ?', [when, id]) +} + // Store a (not-yet-enabled) TOTP secret for a user. Enabling is a separate step // so a secret is never trusted until the user has confirmed one code. async function setTotpSecret(id, secret) { @@ -86,6 +107,7 @@ module.exports = { countAdmins, touchLastLogin, bumpTokensValidAfter, + setTokensValidAfter, setTotpSecret, enableTotp, disableTotp, diff --git a/server/src/model/users/users.model.js b/server/src/model/users/users.model.js index de34b28..42322b1 100644 --- a/server/src/model/users/users.model.js +++ b/server/src/model/users/users.model.js @@ -10,12 +10,21 @@ function sanitize(user) { return safe } -async function createUser({ username, password, role = 'admin' }) { - const passwordHash = await bcrypt.hash(password, SALT_ROUNDS) - const id = await usersDb.insertUser({ username, passwordHash, role }) +// password may be omitted/null — an SSO-provisioned player has no password until +// they set one (a null hash makes password login impossible, see validatePassword). +async function createUser({ username, password, role = 'admin', email = null, status = 'active', emailVerified = false }) { + const passwordHash = password ? await bcrypt.hash(password, SALT_ROUNDS) : null + const id = await usersDb.insertUser({ username, passwordHash, role, email, status, emailVerified }) return sanitize(await usersDb.findById(id)) } +// True when a DB error is the unique-index violation on username (the atomic +// backstop for the uniqueness race). Callers translate this into a 409 rather +// than doing a check-then-write. +function isDuplicateUsername(err) { + return Boolean(err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) +} + // Returns the raw row (incl. hash) — used by login only. async function getRawByUsername(username) { return usersDb.findByUsername(username) @@ -52,10 +61,13 @@ async function list() { return usersDb.listUsers() } -async function update(id, { username, password, role }) { +async function update(id, { username, password, role, email, status, emailVerified }) { const fields = {} if (username !== undefined) fields.username = username if (role !== undefined) fields.role = role + if (email !== undefined) fields.email = email + if (status !== undefined) fields.status = status + if (emailVerified !== undefined) fields.email_verified = emailVerified ? 1 : 0 if (password) fields.password_hash = await bcrypt.hash(password, SALT_ROUNDS) await usersDb.updateUser(id, fields) // A password change must revoke existing sessions ("change password to log @@ -70,6 +82,12 @@ async function invalidateSessions(id) { return usersDb.bumpTokensValidAfter(id) } +// Set the session cutoff to an explicit instant. Used by the self password-change +// flow to keep the caller's freshly re-issued session alive (see users.db). +async function setSessionCutoff(id, when) { + return usersDb.setTokensValidAfter(id, when) +} + async function remove(id) { return usersDb.deleteUser(id) } @@ -82,12 +100,13 @@ async function countAdmins() { return usersDb.countAdmins() } -async function recordLogin(id) { - return usersDb.touchLastLogin(id) +async function recordLogin(id, ip = null) { + return usersDb.touchLastLogin(id, ip) } module.exports = { createUser, + isDuplicateUsername, getRawByUsername, getById, getRawById, @@ -95,6 +114,7 @@ module.exports = { list, update, invalidateSessions, + setSessionCutoff, remove, count, countAdmins, diff --git a/server/src/router/v1/admin/account.controller.js b/server/src/router/v1/admin/account.controller.js index bacd71e..1bf7dd5 100644 --- a/server/src/router/v1/admin/account.controller.js +++ b/server/src/router/v1/admin/account.controller.js @@ -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, +} diff --git a/server/src/router/v1/admin/admin.controller.js b/server/src/router/v1/admin/admin.controller.js index 9c4465b..4a3d394 100644 --- a/server/src/router/v1/admin/admin.controller.js +++ b/server/src/router/v1/admin/admin.controller.js @@ -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) diff --git a/server/src/router/v1/admin/admin.routes.js b/server/src/router/v1/admin/admin.routes.js index d46025c..117e0a9 100644 --- a/server/src/router/v1/admin/admin.routes.js +++ b/server/src/router/v1/admin/admin.routes.js @@ -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, ) diff --git a/server/src/router/v1/auth/auth.controller.js b/server/src/router/v1/auth/auth.controller.js index 089f32a..a301874 100644 --- a/server/src/router/v1/auth/auth.controller.js +++ b/server/src/router/v1/auth/auth.controller.js @@ -1,10 +1,12 @@ const users = require('../../../model/users/users.model') const activity = require('../../../model/activity/activity.model') +const settings = require('../../../model/settings/settings.model') const { setAuthCookie, clearAuthCookie } = require('../../../auth/token') const sessionService = require('../../../auth/session.service') const totp = require('../../../utils/totp') const botScore = require('../../../middleware/botScore') const loginProtection = require('../../../middleware/loginProtection') +const usernamePolicy = require('../../../auth/usernamePolicy') const log = require('../../../utils/logger')('auth') @@ -27,7 +29,7 @@ function needsTotp(user) { // the second factor) — carried in the session token for downstream visibility. async function issueSession(req, res, user, authMethod = 'local') { loginProtection.recordSuccess(req.ip) - await users.recordLogin(user.id) + await users.recordLogin(user.id, req.ip) const { token } = sessionService.createSession(user, authMethod) setAuthCookie(req, res, token) await activity.log({ req, userId: user.id, action: 'auth.login' }) @@ -57,6 +59,14 @@ async function login(req, res) { return res.status(401).json(GENERIC_FAIL) } + // Correct credentials, but the account is disabled/banned (or pending): do + // not issue a session or a TOTP challenge. A distinct, clear message here is + // fine — the caller already proved the password, so this leaks nothing. + if (user.status && user.status !== 'active') { + log.warn('login refused: inactive account', { username, status: user.status, ip: req.ip }) + return res.status(403).json({ message: 'This account is not active. Contact an administrator.' }) + } + // Password is correct. If this user has TOTP on, do NOT issue a session yet — // hand back a short-lived, signed "password verified" challenge and require // the code. If TOTP is off, log them straight in. @@ -73,6 +83,57 @@ async function login(req, res) { } } +// Public self-registration for a `player` account. Gated by the +// `player_registration` setting (must allow the password path) and hardened the +// same way as login: honeypot + registerLimiter + the global botScore guard. +// On success the new player is auto-logged-in (session cookie set). +async function register(req, res) { + // Honeypot: identical treatment to login — a filled hidden field is a bot. + if (req.body[HONEYPOT_FIELD]) { + botScore.recordHoneypot(req.ip) + loginProtection.recordFailure(req.ip) + log.warn('honeypot register hit', { ip: req.ip }) + return res.status(400).json({ message: 'Registration failed.' }) + } + + try { + const mode = await settings.getRegistrationMode() + // Password self-registration is only open when the mode includes it. + if (mode !== 'password' && mode !== 'both') { + return res.status(403).json({ message: 'Registration is not open.' }) + } + + const check = usernamePolicy.validateUsername(req.body.username) + if (!check.ok) return res.status(400).json({ message: check.message }) + const email = req.body.email ? String(req.body.email).trim() : null + + let user + try { + user = await users.createUser({ + username: check.name, + password: req.body.password, + email, + role: 'player', + }) + } catch (err) { + // The UNIQUE index is the source of truth for the uniqueness race — a + // concurrent duplicate loses here and gets a clean 409. + if (users.isDuplicateUsername(err)) { + return res.status(409).json({ message: 'That username is already taken.' }) + } + throw err + } + + await activity.log({ req, userId: user.id, action: 'auth.register', detail: { username: user.username } }) + log.info('player registered', { username: user.username, id: user.id, ip: req.ip }) + // New password accounts never have TOTP yet — log straight in. + return issueSession(req, res, user, 'local') + } catch (err) { + log.error('register error', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + // Second step for TOTP users: verify the challenge token + code, then issue the // session. A wrong code counts as a failed attempt (backoff + bot score). async function loginTotp(req, res) { @@ -127,4 +188,4 @@ async function me(req, res) { } } -module.exports = { login, loginTotp, logout, me, needsTotp, HONEYPOT_FIELD } +module.exports = { login, register, loginTotp, logout, me, needsTotp, HONEYPOT_FIELD } diff --git a/server/src/router/v1/auth/auth.routes.js b/server/src/router/v1/auth/auth.routes.js index 3f238db..d30f004 100644 --- a/server/src/router/v1/auth/auth.routes.js +++ b/server/src/router/v1/auth/auth.routes.js @@ -1,10 +1,10 @@ const express = require('express') const { body } = require('express-validator') -const { login, loginTotp, logout, me, HONEYPOT_FIELD } = require('./auth.controller') +const { login, register, loginTotp, logout, me, HONEYPOT_FIELD } = require('./auth.controller') const { isLoggedIn } = require('../../../utils/auth') const { attachSession } = require('../../../auth/session.middleware') -const { loginLimiter } = require('../../../middleware/rateLimit') +const { loginLimiter, registerLimiter } = require('../../../middleware/rateLimit') const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection') const validate = require('../../../middleware/validate') const mobileRouter = require('./mobile.routes') @@ -45,6 +45,30 @@ authRouter.post( login, ) +// Public self-registration (player accounts). Gated in the controller by the +// player_registration setting; here it reuses the login backoff/limiter stack +// plus its own per-IP cap, and accepts the honeypot field. +authRouter.post( + '/register', + // #swagger.tags = ['Auth'] + // #swagger.summary = 'Register a player account' + // #swagger.description = 'Creates a self-service player account and logs it in (sets the session cookie). Available only when an admin has enabled password registration (player_registration = password|both); otherwise returns 403. Rate limited and behind bot/backoff guards; a hidden honeypot field must stay empty.' + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RegisterRequest" } } } } */ + /* #swagger.responses[200] = { description: 'Account created and session issued', content: { "application/json": { schema: { $ref: "#/components/schemas/LoginResponse" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error or unavailable username', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ + /* #swagger.responses[403] = { description: 'Registration is not open', 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 attempts (rate limited / backoff)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + ...loginGuards, + registerLimiter, + body('username').isString().trim().isLength({ min: 3, max: 32 }), + body('password').isString().isLength({ min: 8, max: 64 }), + body('email').optional({ values: 'falsy' }).isEmail().isLength({ max: 255 }), + body(HONEYPOT_FIELD).optional(), + validate, + register, +) + // Second factor: same throttling, since it's a code-guessing surface too. authRouter.post( '/login/totp', diff --git a/server/src/router/v1/auth/sso.controller.js b/server/src/router/v1/auth/sso.controller.js index b10606b..6bc1509 100644 --- a/server/src/router/v1/auth/sso.controller.js +++ b/server/src/router/v1/auth/sso.controller.js @@ -15,11 +15,13 @@ const users = require('../../../model/users/users.model') const activity = require('../../../model/activity/activity.model') const authProviders = require('../../../model/authProviders/authProviders.model') const userIdentities = require('../../../model/userIdentities/userIdentities.model') +const settings = require('../../../model/settings/settings.model') const registry = require('../../../auth/providers/registry') const sessionService = require('../../../auth/session.service') const ssoState = require('../../../auth/ssoState') const token = require('../../../auth/token') const totp = require('../../../utils/totp') +const usernamePolicy = require('../../../auth/usernamePolicy') const botScore = require('../../../middleware/botScore') const loginProtection = require('../../../middleware/loginProtection') const { needsTotp } = require('./auth.controller') @@ -27,15 +29,32 @@ const { needsTotp } = require('./auth.controller') const log = require('../../../utils/logger')('sso') const PROVIDER_ID_RE = /^[a-z0-9-]+$/ +// How many username suffixes to try before giving up on auto-provision. +const PROVISION_MAX_TRIES = 25 + +// Which front-end area a flow belongs to, derived from its returnTo. Players +// drive SSO from /account*, staff from /admin*; defaults to admin. This is what +// makes error/TOTP/success redirects land the caller back in their own portal. +function portalFor(returnTo) { + return typeof returnTo === 'string' && /^\/account(?:[/?]|$)/.test(returnTo) ? 'account' : 'admin' +} +const loginPath = (portal) => (portal === 'account' ? '/account/login' : '/admin/login') +const accountPath = (portal) => (portal === 'account' ? '/account' : '/admin/account') +const homePath = (portal) => (portal === 'account' ? '/account' : '/admin') // Redirect targets (front-end routes). Errors surface as a query param the login -// / account pages can render. -const loginError = (code) => `/admin/login?sso_error=${code}` -const accountError = (code) => `/admin/account?link_error=${code}` +// / account pages can render. Portal-aware so a player flow stays in /account*. +const loginError = (code, portal = 'admin') => `${loginPath(portal)}?sso_error=${code}` +const accountError = (code, portal = 'admin') => `${accountPath(portal)}?link_error=${code}` -// Only allow returning to an internal /admin path (prevents open redirect). +// Only allow returning to an internal /admin or /account path (prevents open +// redirect). Both areas are first-party SPA routes. function sanitizeReturn(returnTo) { - if (typeof returnTo === 'string' && /^\/admin(?:[/?]|$)/.test(returnTo) && !returnTo.startsWith('//')) { + if ( + typeof returnTo === 'string' && + /^\/(admin|account)(?:[/?]|$)/.test(returnTo) && + !returnTo.startsWith('//') + ) { return returnTo } return null @@ -81,20 +100,24 @@ async function listProviders(req, res) { // requireAuth has already run so req.user is the account to attach the identity to. async function beginFlow(req, res, mode) { const providerId = req.params.provider - const failUrl = mode === 'link' ? accountError('error') : loginError('error') + const returnTo = sanitizeReturn(req.query.returnTo) + const portal = portalFor(returnTo) + const failUrl = mode === 'link' ? accountError('error', portal) : loginError('error', portal) try { if (!PROVIDER_ID_RE.test(providerId)) return res.redirect(failUrl) const row = await authProviders.getWithSecret(providerId) if (!row || !row.enabled || !registry.validateConfig(row).valid) { log.warn('sso start: provider unavailable', { provider: providerId, mode }) - return res.redirect(mode === 'link' ? accountError('unavailable') : loginError('unavailable')) + return res.redirect( + mode === 'link' ? accountError('unavailable', portal) : loginError('unavailable', portal), + ) } const provider = registry.instantiate(row) const tx = ssoState.createTx({ provider: providerId, mode, linkUserId: mode === 'link' ? req.user.id : undefined, - returnTo: sanitizeReturn(req.query.returnTo) || undefined, + returnTo: returnTo || undefined, }) res.cookie(ssoState.TX_COOKIE, tx.txToken, txCookieOptions(req)) const url = provider.getAuthorizationUrl(tx.nonce, { @@ -129,10 +152,13 @@ async function callback(req, res) { return res.redirect(loginError('bad_state')) } + // tx is verified — steer failures back to the portal (and page) the flow began in. + const portal = portalFor(tx.returnTo) + const failFor = (code) => (tx.mode === 'link' ? accountError(code, portal) : loginError(code, portal)) try { const row = await authProviders.getWithSecret(providerId) if (!row || !row.enabled || !registry.validateConfig(row).valid) { - return res.redirect(loginError('unavailable')) + return res.redirect(failFor('unavailable')) } const provider = registry.instantiate(row) const profile = await provider.handleCallback({ @@ -144,19 +170,75 @@ async function callback(req, res) { return finishLogin(req, res, providerId, row.kind, tx, profile) } catch (err) { log.error('sso callback', err) - return res.redirect(loginError('error')) + return res.redirect(failFor('error')) } } -// Link-only login: require an existing (provider, subject) identity → session. -async function finishLogin(req, res, providerId, kind, tx, profile) { - const identity = await userIdentities.findByProviderSubject(providerId, profile.subject) - if (!identity) { - log.warn('sso login refused: no linked account', { provider: providerId }) - return res.redirect(loginError('not_linked')) +// Auto-provision a `player` from an SSO profile when no identity is linked yet +// and registration allows SSO sign-up. Derives a unique username (reserved-name +// safe) with a bounded retry against the UNIQUE index, captures the provider +// email, links the identity, and audit-logs the provision. Returns the new user, +// or null if a unique username couldn't be found. +async function provisionSsoPlayer(req, providerId, profile) { + const base = usernamePolicy.deriveUsernameBase(profile) + for (let attempt = 0; attempt < PROVISION_MAX_TRIES; attempt++) { + const candidate = usernamePolicy.candidateUsername(base, attempt) + try { + const user = await users.createUser({ + username: candidate, + role: 'player', + email: profile.email || null, + // The built-in providers only return an email the IdP has verified, so + // treat a supplied address as verified (skips the eventual re-verify). + emailVerified: Boolean(profile.email), + }) + await userIdentities.link({ + userId: user.id, + provider: providerId, + subject: profile.subject, + email: profile.email, + }) + await activity.log({ req, userId: user.id, action: 'auth.sso.provision', detail: { provider: providerId } }) + log.info('sso player provisioned', { provider: providerId, id: user.id, username: user.username }) + return user + } catch (err) { + // Username collided with a concurrent/existing account — try the next + // suffix. Any other error is real; propagate it. + if (users.isDuplicateUsername(err)) continue + throw err + } + } + log.error('sso provision: exhausted username candidates', { provider: providerId, base }) + return null +} + +// SSO login. Normally link-only: a login succeeds only if the external identity +// is already linked. The one setting-gated relaxation is auto-provisioning a +// player when player_registration ∈ {sso, both} (see provisionSsoPlayer). +async function finishLogin(req, res, providerId, kind, tx, profile) { + const portal = portalFor(tx.returnTo) + let user + const identity = await userIdentities.findByProviderSubject(providerId, profile.subject) + if (identity) { + user = await users.getById(identity.user_id) + if (!user) return res.redirect(loginError('not_linked', portal)) + } else { + // Unknown identity: auto-provision only if registration opts into SSO sign-up. + const mode = await settings.getRegistrationMode() + if (mode !== 'sso' && mode !== 'both') { + log.warn('sso login refused: no linked account', { provider: providerId }) + return res.redirect(loginError('not_linked', portal)) + } + user = await provisionSsoPlayer(req, providerId, profile) + if (!user) return res.redirect(loginError('error', portal)) + } + + // Status gate (parity with local login): a disabled/banned account can't + // complete SSO login either. + if (user.status && user.status !== 'active') { + log.warn('sso login refused: inactive account', { provider: providerId, id: user.id, status: user.status }) + return res.redirect(loginError('disabled', portal)) } - const user = await users.getById(identity.user_id) - if (!user) return res.redirect(loginError('not_linked')) const authMethod = sessionService.AUTH_METHODS.includes(kind) ? kind : 'sso' @@ -173,15 +255,15 @@ async function finishLogin(req, res, providerId, kind, tx, profile) { }) res.cookie(ssoState.TOTP_COOKIE, pending, totpCookieOptions(req)) log.info('sso login: awaiting TOTP', { provider: providerId, id: user.id, ip: req.ip }) - return res.redirect('/admin/login?sso_totp=1') + return res.redirect(`${loginPath(portal)}?sso_totp=1`) } const { token: sessionToken } = sessionService.createSession(user, authMethod) token.setAuthCookie(req, res, sessionToken) - await users.recordLogin(user.id) + await users.recordLogin(user.id, req.ip) await activity.log({ req, userId: user.id, action: 'auth.sso.login', detail: { provider: providerId } }) log.info('sso login success', { provider: providerId, id: user.id, ip: req.ip }) - return res.redirect(sanitizeReturn(tx.returnTo) || '/admin') + return res.redirect(sanitizeReturn(tx.returnTo) || homePath(portal)) } // POST /auth/sso/totp — second factor for an SSO login whose account has TOTP on. @@ -203,18 +285,26 @@ async function finishSsoTotp(req, res) { return res.status(401).json({ message: 'Invalid verification code.' }) } + // Correct second factor, but the account is disabled/banned since the flow + // started — refuse and clear the staged cookie. + if (user.status && user.status !== 'active') { + res.clearCookie(ssoState.TOTP_COOKIE, token.cookieOptions(req)) + log.warn('sso TOTP refused: inactive account', { id: user.id, status: user.status }) + return res.status(403).json({ message: 'This account is not active. Contact an administrator.' }) + } + // Second factor satisfied — clear the staged cookie and issue the real session. res.clearCookie(ssoState.TOTP_COOKIE, token.cookieOptions(req)) loginProtection.recordSuccess(req.ip) const authMethod = sessionService.AUTH_METHODS.includes(pending.authMethod) ? pending.authMethod : 'sso' const { token: sessionToken } = sessionService.createSession(user, authMethod) token.setAuthCookie(req, res, sessionToken) - await users.recordLogin(user.id) + await users.recordLogin(user.id, req.ip) await activity.log({ req, userId: user.id, action: 'auth.sso.login', detail: { provider: pending.provider, totp: true } }) log.info('sso login success (2fa)', { provider: pending.provider, id: user.id, ip: req.ip }) return res.json({ user: { id: user.id, username: user.username, role: user.role }, - returnTo: sanitizeReturn(pending.returnTo) || '/admin', + returnTo: sanitizeReturn(pending.returnTo) || homePath(portalFor(pending.returnTo)), }) } catch (err) { log.error('sso totp error', err) @@ -225,18 +315,19 @@ async function finishSsoTotp(req, res) { // Attach the external identity to the account that initiated linking (tx.linkUserId // was captured behind requireAuth at /link start, so the signed tx authorizes it). async function finishLink(req, res, providerId, tx, profile) { + const portal = portalFor(tx.returnTo) const userId = tx.linkUserId - if (!userId) return res.redirect(loginError('error')) + if (!userId) return res.redirect(loginError('error', portal)) const existing = await userIdentities.findByProviderSubject(providerId, profile.subject) if (existing && existing.user_id !== userId) { - return res.redirect(accountError('in_use')) // that external identity belongs to another account + return res.redirect(accountError('in_use', portal)) // external identity belongs to another account } if (!existing) { await userIdentities.link({ userId, provider: providerId, subject: profile.subject, email: profile.email }) await activity.log({ req, userId, action: 'auth.sso.link', detail: { provider: providerId } }) log.info('sso account linked', { provider: providerId, userId }) } - return res.redirect(`/admin/account?linked=${providerId}`) + return res.redirect(`${accountPath(portal)}?linked=${providerId}`) } module.exports = { listProviders, start, linkStart, callback, beginFlow, finishLogin, finishSsoTotp, finishLink } diff --git a/server/src/router/v1/player/player.routes.js b/server/src/router/v1/player/player.routes.js new file mode 100644 index 0000000..4cfce19 --- /dev/null +++ b/server/src/router/v1/player/player.routes.js @@ -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 player’s 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 player’s 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 caller’s 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 diff --git a/server/src/router/v1/v1.router.js b/server/src/router/v1/v1.router.js index 73af4ef..4f9edf8 100644 --- a/server/src/router/v1/v1.router.js +++ b/server/src/router/v1/v1.router.js @@ -5,10 +5,12 @@ const v1Router = express.Router() const authRouter = require('./auth/auth.routes') const publicRouter = require('./public/public.routes') const adminRouter = require('./admin/admin.routes') +const playerRouter = require('./player/player.routes') v1Router.use('/auth', authRouter) v1Router.use('/public', publicRouter) v1Router.use('/admin', adminRouter) +v1Router.use('/player', playerRouter) // NOTE: /internal is intentionally NOT mounted here. Those routes return the // decrypted Discord bot token and must never share the public listener that // Pangolin proxies. They live on a separate, unpublished port via diff --git a/server/test/playerAccounts.test.js b/server/test/playerAccounts.test.js new file mode 100644 index 0000000..055759a --- /dev/null +++ b/server/test/playerAccounts.test.js @@ -0,0 +1,89 @@ +// Point the DB at a closed port BEFORE requiring anything that builds the pool, +// so the one branch that reaches the DB fails fast instead of hanging the runner. +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, beforeEach, after } = require('node:test') +const assert = require('node:assert/strict') +const bcrypt = require('bcryptjs') + +const authCtrl = require('../src/router/v1/auth/auth.controller') +const users = require('../src/model/users/users.model') +const settings = require('../src/model/settings/settings.model') +const botScore = require('../src/middleware/botScore') +const lp = require('../src/middleware/loginProtection') +const db = require('../src/utils/db') + +after(() => db.close()) + +function mockRes() { + return { + statusCode: 200, + body: null, + status(c) { + this.statusCode = c + return this + }, + json(b) { + this.body = b + return this + }, + set() { + return this + }, + cookie() { + return this + }, + } +} + +beforeEach(() => { + botScore._reset() + lp._reset() +}) + +// ── Derived public registration flags ───────────────────────────────────── +test('registrationFlags maps each mode to password/sso booleans', () => { + assert.deepEqual(settings.registrationFlags('disabled'), { password: false, sso: false }) + assert.deepEqual(settings.registrationFlags('password'), { password: true, sso: false }) + assert.deepEqual(settings.registrationFlags('sso'), { password: false, sso: true }) + assert.deepEqual(settings.registrationFlags('both'), { password: true, sso: true }) +}) + +test('REGISTRATION_MODES is the closed set of allowed values', () => { + assert.deepEqual(settings.REGISTRATION_MODES, ['disabled', 'password', 'sso', 'both']) +}) + +// ── Null-hash password rule ──────────────────────────────────────────────── +test('validatePassword rejects an SSO-only account with a null hash', async () => { + assert.equal(await users.validatePassword({ password_hash: null }, 'anything'), false) + assert.equal(await users.validatePassword(null, 'anything'), false) +}) + +test('validatePassword accepts a correct password against a real hash', async () => { + const password_hash = await bcrypt.hash('correct horse', 10) + assert.equal(await users.validatePassword({ password_hash }, 'correct horse'), true) + assert.equal(await users.validatePassword({ password_hash }, 'wrong'), false) +}) + +test('isDuplicateUsername recognizes the driver duplicate-key error', () => { + assert.equal(users.isDuplicateUsername({ code: 'ER_DUP_ENTRY' }), true) + assert.equal(users.isDuplicateUsername({ errno: 1062 }), true) + assert.equal(users.isDuplicateUsername({ code: 'ER_NO_SUCH_TABLE' }), false) + assert.equal(users.isDuplicateUsername(null), false) +}) + +// ── Registration honeypot (does not need the DB) ────────────────────────── +test('register with a filled honeypot fails and bans the IP before any DB hit', async () => { + const ip = '203.0.113.90' + const req = { + ip, + body: { username: 'newplayer', password: 'password123', [authCtrl.HONEYPOT_FIELD]: 'Acme' }, + } + const res = mockRes() + await authCtrl.register(req, res) + + assert.equal(res.statusCode, 400) + assert.doesNotMatch(res.body.message, /honeypot|bot|company/i) + assert.equal(botScore.isBanned(ip), true) +}) diff --git a/server/test/ssoCallback.test.js b/server/test/ssoCallback.test.js index ca46f74..ecb73dc 100644 --- a/server/test/ssoCallback.test.js +++ b/server/test/ssoCallback.test.js @@ -14,6 +14,7 @@ const users = require('../src/model/users/users.model') const activity = require('../src/model/activity/activity.model') const authProviders = require('../src/model/authProviders/authProviders.model') const userIdentities = require('../src/model/userIdentities/userIdentities.model') +const settings = require('../src/model/settings/settings.model') const registry = require('../src/auth/providers/registry') const totp = require('../src/utils/totp') const db = require('../src/utils/db') @@ -34,6 +35,9 @@ beforeEach(() => { userIdentities.link = async () => 1 users.getById = async (id) => ({ id, username: 'alice', role: 'admin' }) users.recordLogin = async () => {} // avoid the real DB on the success path + // Default: registration closed, so login stays strictly link-only unless a + // test opts into SSO sign-up. + settings.getRegistrationMode = async () => 'disabled' }) function mockRes() { @@ -87,6 +91,39 @@ test('UNLINKED identity → no session, redirect to not_linked (link-only policy assert.equal(logged.length, 0) }) +test('UNLINKED identity + SSO sign-up enabled → auto-provisions a player and logs in', async () => { + settings.getRegistrationMode = async () => 'both' + userIdentities.findByProviderSubject = async () => null + let created = null + users.createUser = async (args) => { + created = args + return { id: 42, username: args.username, role: 'player', status: 'active' } + } + let linkArgs = null + userIdentities.link = async (args) => { linkArgs = args; return 1 } + const tx = ssoState.createTx({ provider: 'google', mode: 'login' }) + const res = mockRes() + await ssoCtrl.callback(makeReq(tx), res) + + assert.equal(created.role, 'player') + assert.equal(created.email, 'alice@example.com') + assert.equal(linkArgs.userId, 42) + assert.ok(res.cookies[token.COOKIE_NAME], 'session cookie set for the new player') + assert.equal(res.redirectedTo, '/admin') + // Both the provision and the login are audited. + assert.deepEqual(logged.map((e) => e.action), ['auth.sso.provision', 'auth.sso.login']) +}) + +test('UNLINKED identity from the player portal lands back in /account', async () => { + settings.getRegistrationMode = async () => 'both' + userIdentities.findByProviderSubject = async () => null + users.createUser = async (args) => ({ id: 43, username: args.username, role: 'player', status: 'active' }) + const tx = ssoState.createTx({ provider: 'google', mode: 'login', returnTo: '/account' }) + const res = mockRes() + await ssoCtrl.callback(makeReq(tx), res) + assert.equal(res.redirectedTo, '/account') +}) + test('link mode → identity linked to the acting user, redirect to account', async () => { let linkArgs = null userIdentities.link = async (args) => { linkArgs = args; return 1 } diff --git a/server/test/usernamePolicy.test.js b/server/test/usernamePolicy.test.js new file mode 100644 index 0000000..aed0c81 --- /dev/null +++ b/server/test/usernamePolicy.test.js @@ -0,0 +1,52 @@ +// Unit tests for the pure username policy (no DB): validation, reserved-name +// blocklist, case normalization, SSO derivation + the dedup suffix loop. +const { test } = require('node:test') +const assert = require('node:assert/strict') + +const policy = require('../src/auth/usernamePolicy') + +test('validateUsername accepts a normal name and trims whitespace', () => { + const r = policy.validateUsername(' Frodo_99 ') + assert.equal(r.ok, true) + assert.equal(r.name, 'Frodo_99') // trimmed, case preserved +}) + +test('validateUsername rejects too-short / too-long / bad-charset names', () => { + assert.equal(policy.validateUsername('ab').ok, false) // < 3 + assert.equal(policy.validateUsername('x'.repeat(33)).ok, false) // > 32 + assert.equal(policy.validateUsername('has space').ok, false) + assert.equal(policy.validateUsername('emoji😀here').ok, false) +}) + +test('reserved names are rejected case-insensitively', () => { + for (const name of ['admin', 'ADMIN', 'Administrator', 'root', 'moderator', 'support', 'me']) { + assert.equal(policy.isReserved(name), true, `${name} should be reserved`) + assert.equal(policy.validateUsername(name).ok, false, `${name} should be rejected`) + } + assert.equal(policy.isReserved('frodo'), false) +}) + +test('sanitizeToUsername strips disallowed chars and leading punctuation', () => { + assert.equal(policy.sanitizeToUsername('Fró.do Baggins!'), 'Fro.doBaggins') + assert.equal(policy.sanitizeToUsername('...weird'), 'weird') + assert.equal(policy.sanitizeToUsername('a'.repeat(50)).length, policy.MAX_LEN) +}) + +test('deriveUsernameBase prefers display name, then email local-part, then player', () => { + assert.equal(policy.deriveUsernameBase({ name: 'Gandalf', email: 'g@x.com' }), 'Gandalf') + assert.equal(policy.deriveUsernameBase({ name: '💥', email: 'samwise@shire.net' }), 'samwise') + assert.equal(policy.deriveUsernameBase({ name: '', email: '' }), 'player') + // A reserved derived base is skipped in favor of the next candidate. + assert.equal(policy.deriveUsernameBase({ name: 'admin', email: 'realuser@x.com' }), 'realuser') +}) + +test('candidateUsername yields the base then increasing suffixes, clamped to length', () => { + assert.equal(policy.candidateUsername('bilbo', 0), 'bilbo') + assert.equal(policy.candidateUsername('bilbo', 1), 'bilbo2') + assert.equal(policy.candidateUsername('bilbo', 2), 'bilbo3') + // Long base: the numeric suffix must survive the MAX_LEN clamp. + const long = 'a'.repeat(policy.MAX_LEN) + const c = policy.candidateUsername(long, 10) + assert.ok(c.length <= policy.MAX_LEN) + assert.ok(c.endsWith('11')) +})