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>
215 lines
15 KiB
JavaScript
215 lines
15 KiB
JavaScript
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 invite’s 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
|