Files
website/server/src/router/v1/auth/auth.routes.js
wtclaude 60ebacff2c
All checks were successful
PR Checks / bot-install (pull_request) Successful in 19s
PR Checks / server-tests (pull_request) Successful in 42s
PR Checks / client-build (pull_request) Successful in 9m24s
feat(auth): trusted devices, recovery codes, and admin MFA management
Add opt-in "Trust this device" so a browser/app skips the TOTP step (never
the password) for 30 days, single-use bcrypt recovery codes as a 2FA-lockout
fallback, and admin trusted-device/MFA-reset management — backend, web UI,
OpenAPI spec, and tests.

- Schema: trusted_devices (sha256 token hash, looked up by unique index) and
  recovery_codes (bcrypt, single-use). Both additive/idempotent.
- Session service: trust-token mint/hash/resolve + cap helpers; new rg_trust
  httpOnly cookie (survives logout, revoked on untrust/password change/reset/
  TOTP disable). JWTs stay stateless — trust is a server-side row, not a claim.
- Web + mobile login accept a trusted-device token / recovery code; login/totp
  gains trustDevice + recoveryCode. Cap of 10/user with NO silent pruning — an
  over-cap trust returns 409/trustLimitReached and the client prompts to revoke.
- Self-service /auth/me/trusted-devices* + recovery-codes*; admin
  /admin/users/:id/trusted-devices* + /mfa/reset. All actions audit-logged.
- Client: "Trust this device" + recovery-code login options, one-time recovery
  code display, Trusted Devices + Recovery Codes account panels, a TOTP-styled
  revoke-to-continue cap modal, and admin per-user security controls.
- OpenAPI regenerated; 33 new server tests (all suites green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 23:38:48 -05:00

215 lines
15 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 notifRouter = require('./notifications.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)
// Push-notification self-service — /auth/me/devices*, /auth/me/notifications/*.
// A second sub-router at /me (Express allows multiple), same requireAuth gate,
// keeping the notification surface separate from the account/identity handlers.
authRouter.use('/me', notifRouter)
// 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 or recovery code'
// #swagger.description = 'Second step for 2FA accounts. Exchange the challenge from /login plus either the current authenticator code OR a single-use recovery code for a session cookie. Set trustDevice to remember this browser and skip TOTP on future logins (30 days); if the trusted-device limit is reached the session is still issued and the response carries { trustLimitReached, devices } so the user can revoke one first.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpLoginRequest" } } } } */
/* #swagger.responses[200] = { description: 'Session issued (optionally with a trusted-device-limit prompt)', 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(),
// Either a TOTP code or a recovery code satisfies the second factor; the
// controller rejects the request when neither verifies.
body('code').optional({ values: 'falsy' }).isString().trim().isLength({ min: 6, max: 8 }),
body('recoveryCode').optional({ values: 'falsy' }).isString().trim().isLength({ min: 8, max: 32 }),
body('trustDevice').optional().isBoolean(),
body('deviceName').optional({ values: 'falsy' }).isString().trim().isLength({ max: 100 }),
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