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