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:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user