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