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:
@@ -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.
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
|
||||
132
server/src/router/v1/player/player.routes.js
Normal file
132
server/src/router/v1/player/player.routes.js
Normal 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 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user