feat(auth): role-agnostic self-service surface under /auth/me
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m27s
PR Checks / client-build (pull_request) Successful in 10m23s
PR Checks / bot-install (pull_request) Successful in 9m19s

Add /auth/me/account* — the canonical "me" endpoints for every authenticated
role (Android app §6.4/§8.1). Reuses the existing account.controller handlers
(getAccount, changeUsername, changePassword, TOTP setup/enable/disable, list/
unlink identities) verbatim behind requireAuth (any role) — no logic
duplication. The app gets one self surface and never has to touch /admin; the
old /player/account/* and /admin/account/* routes stay for web back-compat.

New routes (all bearer- or cookie-auth, any active role):
- GET    /auth/me/account
- PATCH  /auth/me/account/username
- PATCH  /auth/me/account/password
- POST   /auth/me/account/totp/setup|enable|disable
- GET    /auth/me/account/identities
- DELETE /auth/me/account/identities/:provider

Mounted as a sub-router; the bare GET /auth/me is unchanged. Swagger
regenerated with #swagger annotations. Adds test/authMe.test.js (the group
gate rejects unauthenticated callers with 401).

Verified end-to-end against MariaDB: a player and an editor both drive the
same surface (role-agnostic), username/password changes work, a password
change revokes the caller's old bearer token, and validation/401 paths behave.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
This commit is contained in:
2026-07-19 04:55:01 -05:00
parent 715eaedd74
commit fc5255da99
4 changed files with 693 additions and 2 deletions

View File

@@ -16,6 +16,7 @@ const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection
const validate = require('../../../middleware/validate')
const mobileRouter = require('./mobile.routes')
const ssoRouter = require('./sso.routes')
const meRouter = require('./me.routes')
const authRouter = express.Router()
@@ -26,6 +27,13 @@ authRouter.use('/mobile', mobileRouter)
// Additive; the web cookie + TOTP flow below is unchanged.
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. The bare GET /me below is unaffected (meRouter has no /account-
// free route, so /me falls through to its own handler).
authRouter.use('/me', meRouter)
// Login protection order (cheapest rejection first):
// backoffGuard → per-IP exponential lockout on repeated failures
// slowLogin → progressive per-request delay within the window

View File

@@ -0,0 +1,140 @@
// ── 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.
//
// 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.
//
// 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
// expect. Mounted at /me by auth.routes.js, so paths below are /auth/me/account*.
const express = require('express')
const { body, param } = require('express-validator')
const account = require('../admin/account.controller')
const { requireAuth } = require('../../../auth/session.middleware')
const noindex = require('../../../middleware/noindex')
const validate = require('../../../middleware/validate')
const { accountChangeLimiter } = require('../../../middleware/rateLimit')
const meRouter = express.Router()
// Group gate: authenticated + active (any role), and keep it out of search
// indexes. No requireRole — this surface is deliberately role-agnostic.
meRouter.use(noindex, requireAuth)
meRouter.get(
'/account',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Get the current account (self, any role)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The current account', content: { "application/json": { schema: { $ref: "#/components/schemas/PlayerAccount" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Account not active', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
account.getAccount,
)
meRouter.patch(
'/account/username',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Change the current accounts username (self, any role)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ChangeUsernameRequest" } } } } */
/* #swagger.responses[200] = { description: 'Updated username (session re-issued)', content: { "application/json": { schema: { type: "object", properties: { username: { type: "string" } } } } } } */
/* #swagger.responses[400] = { description: 'Validation error or unavailable username', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', 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 changes (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
accountChangeLimiter,
body('username').isString().trim().isLength({ min: 3, max: 32 }),
validate,
account.changeUsername,
)
meRouter.patch(
'/account/password',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Change or set the current accounts password (self, any role)'
// #swagger.description = 'If the account already has a password, currentPassword is required and verified. SSO-provisioned accounts with no password may set an initial one without a current password. On success the callers own session is re-issued (they stay logged in) while older web sessions are revoked.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ChangePasswordRequest" } } } } */
/* #swagger.responses[200] = { description: 'Password changed', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
/* #swagger.responses[400] = { description: 'Validation error or wrong current password', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many changes (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
accountChangeLimiter,
body('newPassword').isString().isLength({ min: 8, max: 64 }),
body('currentPassword').optional({ values: 'falsy' }).isString(),
validate,
account.changePassword,
)
// TOTP self-enrollment — identical to the player/admin account flow (disable
// requires a valid current code; it does not take a password).
meRouter.post(
'/account/totp/setup',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Begin 2FA enrollment (returns secret + QR)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'otpauth URL and QR data to scan', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpSetup" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
account.totpSetup,
)
meRouter.post(
'/account/totp/enable',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Enable 2FA by confirming a code'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */
/* #swagger.responses[200] = { description: '2FA enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpState" } } } } */
/* #swagger.responses[400] = { description: 'Setup not started, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
body('code').isString().trim().isLength({ min: 6, max: 8 }),
validate,
account.totpEnable,
)
meRouter.post(
'/account/totp/disable',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Disable 2FA by confirming a code'
// #swagger.description = 'Requires a valid current authenticator code (proves control of the authenticator); it does not take a password.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */
/* #swagger.responses[200] = { description: '2FA disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpState" } } } } */
/* #swagger.responses[400] = { description: 'Not enabled, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
body('code').isString().trim().isLength({ min: 6, max: 8 }),
validate,
account.totpDisable,
)
// Linked SSO identities (self-service). Linking itself starts at
// GET /auth/sso/:provider/link (already behind requireAuth; works for any role).
meRouter.get(
'/account/identities',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'List linked SSO identities (self)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Linked identities', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/LinkedIdentity" } } } } } */
account.listIdentities,
)
meRouter.delete(
'/account/identities/:provider',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Unlink an SSO identity (self)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { $ref: "#/components/schemas/UnlinkedFlag" } } } } */
/* #swagger.responses[404] = { description: 'No linked account for that provider', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('provider').matches(/^[a-z0-9-]+$/),
validate,
account.unlinkIdentity,
)
module.exports = meRouter