refactor(api): collapse /admin/account and /player/account onto /auth/me/account
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 26s
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / server-tests (pull_request) Successful in 10m32s

Self-service account security had three URL surfaces onto one controller. All
three mounted the same `admin/account.controller.js` handlers; each of the three
router files carried a header comment apologising for the arrangement.

`/auth/me/account` was already a strict superset, which settles which to keep:

  /admin/account   6 routes  noindex, isLoggedIn, staffOnly
  /player/account  8 routes  noindex, requireAuth
  /auth/me/account 10 routes noindex, requireAuth

Neither of the deleted surfaces carried recovery codes, and /admin/account
carried no username or password change at all — so client.js already called
/auth/me/account/recovery-codes/* for two operations on a screen it otherwise
served from /admin/account. The split was leaking before this change.

Gating is equivalent where it overlapped: /player and /auth/me apply identical
`noindex, requireAuth`, and `staffOnly` on /admin/account was strictly narrower
while buying nothing, since every handler is self-scoped to req.user.id. There
is no CSRF layer to differ.

  - 14 routes deleted, 0 added, no handler changed.
  - account.controller.js moves router/v1/admin/ -> router/v1/auth/, beside the
    one router that still reaches it.
  - Web client: 14 call sites move onto a root-level api.myAccount /
    api.changeUsername / ... group, matching the /auth/me methods already there.
  - Android app: no change. MeApi.kt was already 100% /auth/me/account/*.
  - Two swagger tags, `Admin · Account` and `Player`, were declared only by the
    deleted routes and go with them. The orphaned `AccountStatus` schema goes
    too; `PlayerAccount` is re-described as the any-role /auth/me/account shape
    (the name is kept so existing $refs resolve).

Breaking to the published OpenAPI surface, accepted deliberately: both consumers
are in this org, and deprecate-then-delete would leave the next phase deciding
whether to add routes to surfaces already marked for removal.

Verification: routes.manifest.json shows exactly 14 deletions and 0 additions.
The OpenAPI spec loses the same 14 paths with zero surviving path definitions
changed; its large textual diff is pure reordering, because removing the
first-mounted router shifts every later path. 1203 server tests, 288 client
tests, 53 bot tests green; check:modules, check:hosts and routes:manifest
--check all pass.

Design of record: docs/website/ENGAGEMENT.md Phase 1a. This lands ahead of
engagement Phase 1b, which adds a self-service email field — written once here
rather than three times.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-29 00:49:25 -05:00
parent f5aa32e0ed
commit 6e61146678
20 changed files with 89 additions and 1428 deletions

View File

@@ -0,0 +1,415 @@
// Self-service account security for the logged-in user (any role): username,
// password, TOTP, linked identities, device sessions, trusted devices and
// recovery codes.
//
// Reached through exactly one router — me.routes.js at /auth/me — which applies
// `noindex, requireAuth`, so req.user is the fresh DB row and the status +
// session-cutoff checks have already run. Every handler keys off req.user.id and
// none of them consults a role: this file lived under router/v1/admin/ while it
// also served /admin/account/* and /player/account/*, and moved here when those
// two surfaces were deleted.
const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const userIdentities = require('../../../model/userIdentities/userIdentities.model')
const mobileSessions = require('../../../model/mobileSessions/mobileSessions.model')
const trustedDevices = require('../../../model/trustedDevices/trustedDevices.model')
const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
const sessionService = require('../../../auth/session.service')
const { establishTrust } = require('./trustDevice.helper')
const { setAuthCookie, setTrustCookie, clearTrustCookie } = 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). 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. req.user is the
// sanitized row (password_hash stripped), so read the raw row for that one flag.
async function getAccount(req, res) {
try {
const raw = await users.getRawById(req.user.id)
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(raw && raw.password_hash),
})
} catch (err) {
log.error('getAccount', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// 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))
}
// A password change is a security event: drop every trusted device and every
// recovery code so a compromised-then-changed account can't be re-entered with
// a stale second-factor bypass. Clear this browser's trust cookie too.
await trustedDevices.revokeAllForUser(req.user.id)
await recoveryCodes.clearForUser(req.user.id)
clearTrustCookie(req, res)
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.
async function totpSetup(req, res) {
try {
if (req.user.totp_enabled) {
return res.status(409).json({ message: 'Two-factor is already enabled. Disable it first to re-enroll.' })
}
const { base32, otpauthUrl } = totp.generateSecret(req.user.username)
await users.setTotpSecret(req.user.id, base32)
const qr = await totp.qrDataUrl(otpauthUrl)
log.info('totp setup started', { id: req.user.id, username: req.user.username })
return res.json({ otpauthUrl, qr })
} catch (err) {
log.error('totpSetup', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// Step 2: confirm one code against the pending secret, then flip totp_enabled on.
async function totpEnable(req, res) {
try {
const user = await users.getRawById(req.user.id)
if (!user || !user.totp_secret) {
return res.status(400).json({ message: 'Start setup before enabling two-factor.' })
}
if (user.totp_enabled) {
return res.status(409).json({ message: 'Two-factor is already enabled.' })
}
if (!totp.verifyCode(user.totp_secret, req.body.code)) {
return res.status(400).json({ message: 'That code is not valid. Try again.' })
}
await users.enableTotp(user.id)
// Issue the initial batch of single-use recovery codes, shown to the user ONCE
// right here (the only time they leave the server in the clear). Generation
// replaces any prior set, so re-enrolling always starts clean.
const codes = await recoveryCodes.generateForUser(user.id)
await activity.log({ req, action: 'account.totp.enable' })
await activity.log({ req, action: 'account.recovery_codes.generate', detail: { count: codes.length } })
log.info('totp enabled', { id: user.id, username: user.username })
return res.json({ totp_enabled: true, recoveryCodes: codes })
} catch (err) {
log.error('totpEnable', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// Turn TOTP off. Require a current code to prove the requester still controls the
// authenticator (so a walk-up on an open session can't quietly remove 2FA).
async function totpDisable(req, res) {
try {
const user = await users.getRawById(req.user.id)
if (!user || !user.totp_enabled) {
return res.status(400).json({ message: 'Two-factor is not enabled.' })
}
if (!totp.verifyCode(user.totp_secret, req.body.code)) {
return res.status(400).json({ message: 'That code is not valid. Try again.' })
}
await users.disableTotp(user.id)
// With 2FA off, both the trusted-device bypass and recovery codes are moot and
// must not linger — drop them so re-enabling later starts from a clean slate.
await trustedDevices.revokeAllForUser(user.id)
await recoveryCodes.clearForUser(user.id)
clearTrustCookie(req, res)
await activity.log({ req, action: 'account.totp.disable' })
log.info('totp disabled', { id: user.id, username: user.username })
return res.json({ totp_enabled: false })
} catch (err) {
log.error('totpDisable', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// ── Linked SSO identities (self-service) ──────────────────────────────────
// List the external accounts (Google/Discord/…) linked to the current user.
// Linking itself happens via the SSO redirect flow (/auth/sso/:provider/link).
async function listIdentities(req, res) {
try {
const rows = await userIdentities.listForUser(req.user.id)
return res.json(rows.map((r) => ({ provider: r.provider, email: r.email, linked_at: r.created_at })))
} catch (err) {
log.error('listIdentities', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// Remove a linked SSO identity from the current user's account.
async function unlinkIdentity(req, res) {
const { provider } = req.params
try {
const removed = await userIdentities.unlink(req.user.id, provider)
if (!removed) return res.status(404).json({ message: 'No linked account for that provider.' })
await activity.log({ req, action: 'auth.sso.unlink', detail: { provider } })
log.info('sso identity unlinked', { provider, id: req.user.id })
return res.json({ unlinked: true })
} catch (err) {
log.error('unlinkIdentity', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// List the current user's active mobile device sessions (the "Active Devices"
// surface). Never exposes the token hash — only labels + timestamps.
async function listSessions(req, res) {
try {
const rows = await mobileSessions.listActiveForUser(req.user.id)
return res.json(
rows.map((r) => ({
id: r.id,
deviceName: r.device_name || null,
userAgent: r.user_agent || null,
createdAt: r.created_at,
lastUsedAt: r.last_used_at || r.created_at,
expiresAt: r.expires_at,
})),
)
} catch (err) {
log.error('listSessions', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// Revoke one of the current user's mobile device sessions by id (ownership-scoped
// in the query so a user can only revoke their own). Idempotent.
async function revokeSession(req, res) {
const id = Number(req.params.id)
try {
const n = await mobileSessions.revokeByIdForUser(id, req.user.id)
if (n) {
await activity.log({ req, action: 'auth.mobile.session.revoke', detail: { sessionRowId: id } })
log.info('mobile session revoked (self)', { id, userId: req.user.id })
}
return res.json({ revoked: n > 0 })
} catch (err) {
log.error('revokeSession', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// ── Trusted devices (self-service) ─────────────────────────────────────────
// Shape a trusted_devices row for the client (never the token hash).
function toTrustedDevice(r) {
return {
id: r.id,
platform: r.platform,
deviceName: r.device_name || null,
userAgent: r.user_agent || null,
createdAt: r.created_at,
lastUsedAt: r.last_used_at || r.created_at,
expiresAt: r.expires_at,
}
}
// List the current user's active trusted devices (Trusted Devices screen).
async function listTrustedDevices(req, res) {
try {
const rows = await trustedDevices.listActiveForUser(req.user.id)
return res.json(rows.map(toTrustedDevice))
} catch (err) {
log.error('listTrustedDevices', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// Trust the CURRENT device/browser from an authenticated session. This is the
// "revoke one, then retry" completion after a cap-reached prompt, and a general
// self-service way to trust the device you're on. Web receives the token as the
// httpOnly rg_trust cookie; native (bearer) sessions get it in the JSON body.
async function trustThisDevice(req, res) {
try {
const isMobile = (req.session?.authMethod || req.authMethod) === 'mobile'
const result = await establishTrust(req, req.user, {
platform: isMobile ? 'mobile' : 'web',
deviceName: req.body.deviceName || null,
})
if (!result.ok && result.capReached) {
return res.status(409).json({ error: 'trusted_device_limit', devices: result.devices.map(toTrustedDevice) })
}
if (!isMobile) {
setTrustCookie(req, res, result.trustToken)
return res.json({ trusted: true })
}
return res.json({ trusted: true, trustToken: result.trustToken })
} catch (err) {
log.error('trustThisDevice', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// Revoke one of the current user's trusted devices by id (ownership-scoped).
async function revokeTrustedDevice(req, res) {
const id = Number(req.params.id)
try {
const n = await trustedDevices.revokeByIdForUser(id, req.user.id)
if (n) {
await activity.log({ req, action: 'account.trusted_device.revoke', detail: { deviceId: id } })
log.info('trusted device revoked (self)', { id, userId: req.user.id })
}
return res.json({ revoked: n > 0 })
} catch (err) {
log.error('revokeTrustedDevice', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// Revoke ALL of the current user's trusted devices ("untrust everywhere"), and
// clear this browser's trust cookie.
async function revokeAllTrustedDevices(req, res) {
try {
const n = await trustedDevices.revokeAllForUser(req.user.id)
clearTrustCookie(req, res)
await activity.log({ req, action: 'account.trusted_device.revoke_all', detail: { count: n } })
log.info('all trusted devices revoked (self)', { userId: req.user.id, count: n })
return res.json({ revoked: n })
} catch (err) {
log.error('revokeAllTrustedDevices', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// ── Recovery codes (self-service) ──────────────────────────────────────────
// Remaining (unused) code count — never the codes themselves.
async function recoveryCodesStatus(req, res) {
try {
const remaining = await recoveryCodes.remainingForUser(req.user.id)
return res.json({ remaining })
} catch (err) {
log.error('recoveryCodesStatus', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// Regenerate the recovery-code set, returning the new codes ONCE. Password
// step-up: an account that has a password must supply and match currentPassword
// (SSO-only accounts with no password may proceed while authenticated, mirroring
// changePassword). Refuses when 2FA is off (codes only exist alongside TOTP).
async function generateRecoveryCodes(req, res) {
try {
const raw = await users.getRawById(req.user.id)
if (!raw) return res.status(401).json({ message: 'Unauthorized' })
if (!raw.totp_enabled) {
return res.status(400).json({ message: 'Enable two-factor before generating recovery codes.' })
}
if (raw.password_hash) {
const ok = await users.validatePassword(raw, req.body.currentPassword || '')
if (!ok) {
loginProtection.recordFailure(req.ip)
botScore.recordLoginFailure(req.ip)
log.warn('generateRecoveryCodes wrong current password', { id: req.user.id, ip: req.ip })
return res.status(400).json({ message: 'Your current password is incorrect.' })
}
}
const codes = await recoveryCodes.generateForUser(req.user.id)
await activity.log({ req, action: 'account.recovery_codes.generate', detail: { count: codes.length } })
log.info('recovery codes regenerated', { id: req.user.id })
return res.json({ recoveryCodes: codes })
} catch (err) {
log.error('generateRecoveryCodes', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = {
getAccount,
changeUsername,
changePassword,
totpSetup,
totpEnable,
totpDisable,
listIdentities,
unlinkIdentity,
listSessions,
revokeSession,
listTrustedDevices,
trustThisDevice,
revokeTrustedDevice,
revokeAllTrustedDevices,
recoveryCodesStatus,
generateRecoveryCodes,
}

View File

@@ -38,10 +38,10 @@ authRouter.use('/mobile', mobileRouter)
// middleware, so passing through it is a no-op for every other route.
authRouter.use(ssoRouter)
// Role-agnostic self-service ("me") — /auth/me/account*, reusing the same
// account.controller handlers as /player/account/* and /admin/account/* behind
// requireAuth (any role). Additive; gives the app one self surface that never
// touches /admin.
// Role-agnostic self-service ("me") — /auth/me/account*, behind requireAuth (any
// role). The single self surface: /player/account/* and /admin/account/* were
// deleted in favour of it, so the app and the web client share one set of URLs
// and neither has to touch /admin.
authRouter.use('/me', meRouter)
// Push-notification self-service — /auth/me/devices*, /auth/me/notifications/*.

View File

@@ -1,14 +1,17 @@
// ── Role-agnostic self-service ("me") under /auth/me ───────────────────────
//
// The canonical self surface for EVERY authenticated role (player and staff
// alike). It reuses the exact same account.controller handlers as
// /player/account/* and /admin/account/* — no logic duplication — but gates on
// requireAuth ONLY (any authenticated, active account), never on a specific role.
// The ONLY self surface, for every authenticated role (player and staff alike).
// It gates on requireAuth ONLY (any authenticated, active account), never on a
// specific role.
//
// Why it exists: the Android app wants one self surface it can call regardless of
// role, and it must never touch /admin (docs/android/PLAN.md §6.4). The older
// /player/account/* and /admin/account/* routes stay for web back-compat; these
// /auth/me/* routes are the additive, role-agnostic canonical form.
// role, and it must never touch /admin (docs/android/PLAN.md §6.4).
//
// It used to be the third of three URL surfaces onto account.controller, beside
// /player/account/* and /admin/account/*. Those were deleted: both were strictly
// smaller than this one (neither carried recovery codes, and /admin/account
// carried no username or password change), so the web client already had to reach
// in here for part of one screen. New self-service fields go here and only here.
//
// requireAuth sets req.user to the fresh DB row and enforces the status + session
// cutoff/revocation checks on every request, exactly as the account handlers
@@ -17,7 +20,7 @@
const express = require('express')
const { body, param } = require('express-validator')
const account = require('../admin/account.controller')
const account = require('./account.controller')
const { requireAuth } = require('../../../auth/session.middleware')
const noindex = require('../../../middleware/noindex')
const validate = require('../../../middleware/validate')
@@ -76,8 +79,8 @@ meRouter.patch(
account.changePassword,
)
// TOTP self-enrollment — identical to the player/admin account flow (disable
// requires a valid current code; it does not take a password).
// TOTP self-enrollment (disable requires a valid current code; it does not take
// a password).
meRouter.post(
'/account/totp/setup',
// #swagger.tags = ['Auth · Me']

View File

@@ -11,7 +11,7 @@
// limiters below are what stop the endpoints being used as an oracle by volume.
//
// Changing a password while signed in is a different route —
// PATCH /player/account/password (and its /auth/me and /admin twins).
// PATCH /auth/me/account/password.
const express = require('express')
const { body, param } = require('express-validator')