Player accounts backend: schema, registration, self-service, SSO provision

- Widen users.role enum to include 'player'; make password_hash nullable;
  add email/email_verified/status/last_login_ip; pin username _ci collation.
- POST /auth/register (honeypot + registerLimiter + botScore, reserved-name
  blocklist, duplicate->409, auto-login). player_registration setting gates it.
- SSO auto-provision in finishLogin (setting-gated); return/portal-aware SSO
  redirects for the player portal; status refusal on login + requireAuth.
- New /player self-service group (account, change username/password, TOTP,
  identities), reusing account.controller; accountChangeLimiter.
- Admin: 'player' role + status/email on user create/update, role/status audit,
  player_registration enum validation, derived public registration flags.
- usernamePolicy module (reserved, sanitize, derive, dedup) + unit tests;
  extend SSO callback tests. 133 server tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
This commit is contained in:
2026-07-06 01:36:51 -05:00
parent cdd916e199
commit f8bcc7f6a3
18 changed files with 934 additions and 55 deletions

View File

@@ -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 }

View File

@@ -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',

View File

@@ -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 }