Files
website/server/src/router/v1/auth/auth.routes.js
wtclaude fc5255da99
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
feat(auth): role-agnostic self-service surface under /auth/me
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
2026-07-19 04:55:01 -05:00

204 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const express = require('express')
const { body, param } = require('express-validator')
const { login, register, loginTotp, logout, me, HONEYPOT_FIELD } = require('./auth.controller')
const { getInvite, acceptInvite } = require('./invite.controller')
const { requestReset, lookupReset, confirmReset } = require('./passwordReset.controller')
const { isLoggedIn } = require('../../../utils/auth')
const { attachSession } = require('../../../auth/session.middleware')
const {
loginLimiter,
registerLimiter,
passwordResetRequestLimiter,
passwordResetConfirmLimiter,
} = require('../../../middleware/rateLimit')
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()
// Native/Android bearer-token auth. Additive alongside the web cookie flow below.
authRouter.use('/mobile', mobileRouter)
// SSO discovery + OAuth redirect flow (/auth/providers, /auth/sso/:provider/*).
// 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
// loginLimiter → hard 10-per-15-min cap
const loginGuards = [backoffGuard, slowLogin, loginLimiter]
authRouter.post(
'/login',
// #swagger.tags = ['Auth']
// #swagger.summary = 'Log in with username and password'
// #swagger.description = 'On success sets the httpOnly session cookie. If the account has 2FA enabled, returns { totpRequired, challenge } instead and no cookie is set — complete login at POST /login/totp. Rate limited and behind bot/backoff guards.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/LoginRequest" } } } } */
/* #swagger.responses[200] = { description: 'Session issued, or TOTP challenge required', content: { "application/json": { schema: { $ref: "#/components/schemas/LoginResponse" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[401] = { description: 'Incorrect username or password', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many attempts (rate limited / backoff)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
...loginGuards,
body('username').isString().trim().notEmpty(),
body('password').isString().notEmpty(),
// Honeypot must be absent/empty for humans; bots that fill it are caught in
// the controller. Accept-but-ignore here so a filled value still reaches it.
body(HONEYPOT_FIELD).optional(),
validate,
login,
)
// Public self-registration (player accounts). Gated in the controller by the
// player_registration setting; here it reuses the login backoff/limiter stack
// plus its own per-IP cap, and accepts the honeypot field.
authRouter.post(
'/register',
// #swagger.tags = ['Auth']
// #swagger.summary = 'Register a player account'
// #swagger.description = 'Creates a self-service player account and logs it in (sets the session cookie). Available only when an admin has enabled password registration (player_registration = password|both); otherwise returns 403. Rate limited and behind bot/backoff guards; a hidden honeypot field must stay empty.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RegisterRequest" } } } } */
/* #swagger.responses[200] = { description: 'Account created and session issued', content: { "application/json": { schema: { $ref: "#/components/schemas/LoginResponse" } } } } */
/* #swagger.responses[400] = { description: 'Validation error or unavailable username', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[403] = { description: 'Registration is not open', 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 attempts (rate limited / backoff)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
...loginGuards,
registerLimiter,
body('username').isString().trim().isLength({ min: 3, max: 32 }),
body('password').isString().isLength({ min: 8, max: 64 }),
body('email').optional({ values: 'falsy' }).isEmail().isLength({ max: 255 }),
body(HONEYPOT_FIELD).optional(),
validate,
register,
)
// Second factor: same throttling, since it's a code-guessing surface too.
authRouter.post(
'/login/totp',
// #swagger.tags = ['Auth']
// #swagger.summary = 'Complete login with a TOTP code'
// #swagger.description = 'Second step for 2FA accounts. Exchange the challenge from /login plus the current authenticator code for a session cookie.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpLoginRequest" } } } } */
/* #swagger.responses[200] = { description: 'Session issued', content: { "application/json": { schema: { $ref: "#/components/schemas/LoginResponse" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[401] = { description: 'Invalid code or expired challenge', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many attempts (rate limited / backoff)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
...loginGuards,
body('challenge').isString().notEmpty(),
body('code').isString().trim().isLength({ min: 6, max: 8 }),
validate,
loginTotp,
)
// ── Email-invite acceptance (public, token-gated) ──────────────────────────
authRouter.get(
'/invite/:token',
// #swagger.tags = ['Auth']
// #swagger.summary = 'Look up an email invite by token'
// #swagger.description = 'Returns the pre-assigned email + role for a valid, pending, unexpired invite so the accept form can render. 404 for anything not currently acceptable.'
/* #swagger.responses[200] = { description: 'Invite details', content: { "application/json": { schema: { type: "object", properties: { email: { type: "string" }, role: { type: "string" } } } } } } */
/* #swagger.responses[404] = { description: 'Invalid or expired invite', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('token').isString().isLength({ min: 8, max: 128 }),
validate,
getInvite,
)
authRouter.post(
'/invite/:token/accept',
// #swagger.tags = ['Auth']
// #swagger.summary = 'Accept an email invite (creates the account at the invited role)'
// #swagger.description = 'Creates the website user at the invites pre-assigned role and logs them in (sets the session cookie). Bypasses the player_registration gate — the invite is its own authority. Rate limited + honeypot-guarded like registration.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["username","password"], properties: { username: { type: "string" }, password: { type: "string" } } } } } */
/* #swagger.responses[200] = { description: 'Account created and session issued', content: { "application/json": { schema: { $ref: "#/components/schemas/LoginResponse" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[404] = { description: 'Invalid or expired invite', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Username taken or invite already used', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
...loginGuards,
registerLimiter,
param('token').isString().isLength({ min: 8, max: 128 }),
body('username').isString().trim().isLength({ min: 3, max: 32 }),
body('password').isString().isLength({ min: 8, max: 64 }),
body(HONEYPOT_FIELD).optional(),
validate,
acceptInvite,
)
// ── Self-service password reset (public, token-gated) ──────────────────────
// Request → email a tokened link; then validate the link and set a new password.
// The request step never reveals whether an email exists (always 200, generic).
authRouter.post(
'/password/forgot',
// #swagger.tags = ['Auth']
// #swagger.summary = 'Request a password-reset link by email'
// #swagger.description = 'Emails a single-use, ~1h reset link to every active account on the address. Always returns the same generic 200 whether or not the email matches (no account enumeration). Email is non-unique, so multiple accounts may each receive a link naming their username. Rate limited per IP.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["email"], properties: { email: { type: "string", format: "email" } } } } } } */
/* #swagger.responses[200] = { description: 'Generic acknowledgement (sent if the account exists)', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[429] = { description: 'Too many requests', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
passwordResetRequestLimiter,
body('email').isString().trim().isEmail().isLength({ max: 255 }),
validate,
requestReset,
)
authRouter.get(
'/password/reset/:token',
// #swagger.tags = ['Auth']
// #swagger.summary = 'Validate a password-reset link'
// #swagger.description = 'Returns the target username for a valid, pending, unexpired reset link so the reset form can render. 404 for anything not currently usable (never distinguishes expired from used from never-existed).'
/* #swagger.responses[200] = { description: 'Reset link is valid', content: { "application/json": { schema: { type: "object", properties: { username: { type: "string" } } } } } } */
/* #swagger.responses[404] = { description: 'Invalid or expired reset link', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('token').isString().isLength({ min: 8, max: 128 }),
validate,
lookupReset,
)
authRouter.post(
'/password/reset/:token',
// #swagger.tags = ['Auth']
// #swagger.summary = 'Set a new password from a reset link'
// #swagger.description = 'Consumes the single-use link and sets the new password. Rotates the hash and revokes every existing session (web + mobile). Does NOT sign the user in — they log in fresh afterwards (so a 2FA account still passes TOTP). Rate limited per IP.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["password"], properties: { password: { type: "string", minLength: 8, maxLength: 64 } } } } } } */
/* #swagger.responses[200] = { description: 'Password changed', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[404] = { description: 'Invalid, expired, or already-used reset link', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many attempts', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
passwordResetConfirmLimiter,
param('token').isString().isLength({ min: 8, max: 128 }),
body('password').isString().isLength({ min: 8, max: 64 }),
validate,
confirmReset,
)
authRouter.post(
'/logout',
// #swagger.tags = ['Auth']
// #swagger.summary = 'Log out (clear the cookie and revoke this session)'
/* #swagger.responses[200] = { description: 'Logged out', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
// Best-effort attach (never rejects) so the controller can revoke this session's
// jti — logout stays a no-op for an already-anonymous caller.
attachSession,
logout,
)
authRouter.get(
'/me',
// #swagger.tags = ['Auth']
// #swagger.summary = 'Current authenticated user'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The signed-in user', content: { "application/json": { schema: { type: "object", properties: { user: { $ref: "#/components/schemas/User" } } } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
isLoggedIn,
me,
)
module.exports = authRouter