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 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