Files
website/server/src/router/v1/auth/auth.routes.js
Claude f8bcc7f6a3 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
2026-07-06 01:36:51 -05:00

112 lines
7.0 KiB
JavaScript

const express = require('express')
const { body } = require('express-validator')
const { login, register, loginTotp, logout, me, HONEYPOT_FIELD } = require('./auth.controller')
const { isLoggedIn } = require('../../../utils/auth')
const { attachSession } = require('../../../auth/session.middleware')
const { loginLimiter, registerLimiter } = 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 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)
// 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,
)
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