// ── Role-agnostic self-service ("me") under /auth/me ─────────────────────── // // The ONLY self surface, for every authenticated role (player and staff alike). // It gates on requireAuth ONLY (any authenticated, active account), never on a // specific role. // // Why it exists: the Android app wants one self surface it can call regardless of // role, and it must never touch /admin (docs/android/PLAN.md §6.4). // // It used to be the third of three URL surfaces onto account.controller, beside // /player/account/* and /admin/account/*. Those were deleted: both were strictly // smaller than this one (neither carried recovery codes, and /admin/account // carried no username or password change), so the web client already had to reach // in here for part of one screen. New self-service fields go here and only here. // // requireAuth sets req.user to the fresh DB row and enforces the status + session // cutoff/revocation checks on every request, exactly as the account handlers // expect. Mounted at /me by auth/index.js, so paths below are /auth/me/account*. const express = require('express') const { body, param } = require('express-validator') const account = require('./account.controller') const { requireAuth } = require('../../../auth/session.middleware') const noindex = require('../../../middleware/noindex') const validate = require('../../../middleware/validate') const { accountChangeLimiter } = require('../../../middleware/rateLimit') const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection') const meRouter = express.Router() // Group gate: authenticated + active (any role), and keep it out of search // indexes. No requireRole — this surface is deliberately role-agnostic. meRouter.use(noindex, requireAuth) meRouter.get( '/account', // #swagger.tags = ['Auth · Me'] // #swagger.summary = 'Get the current account (self, any role)' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.responses[200] = { description: 'The current account', content: { "application/json": { schema: { $ref: "#/components/schemas/PlayerAccount" } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[403] = { description: 'Account not active', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ account.getAccount, ) meRouter.patch( '/account/username', // #swagger.tags = ['Auth · Me'] // #swagger.summary = 'Change the current account’s username (self, any role)' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ChangeUsernameRequest" } } } } */ /* #swagger.responses[200] = { description: 'Updated username (session re-issued)', content: { "application/json": { schema: { type: "object", properties: { username: { type: "string" } } } } } } */ /* #swagger.responses[400] = { description: 'Validation error or unavailable username', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', 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 changes (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ accountChangeLimiter, body('username').isString().trim().isLength({ min: 3, max: 32 }), validate, account.changeUsername, ) meRouter.patch( '/account/password', // #swagger.tags = ['Auth · Me'] // #swagger.summary = 'Change or set the current account’s password (self, any role)' // #swagger.description = 'If the account already has a password, currentPassword is required and verified. SSO-provisioned accounts with no password may set an initial one without a current password. On success the caller’s own session is re-issued (they stay logged in) while older web sessions are revoked.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ChangePasswordRequest" } } } } */ /* #swagger.responses[200] = { description: 'Password changed', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */ /* #swagger.responses[400] = { description: 'Validation error or wrong current password', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[429] = { description: 'Too many changes (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ accountChangeLimiter, body('newPassword').isString().isLength({ min: 8, max: 64 }), body('currentPassword').optional({ values: 'falsy' }).isString(), validate, account.changePassword, ) // Email address (engagement Phase 1b). The change is STAGED and only a tokened // link installs it, so these three routes never alter the address that is // currently receiving mail. The confirm half is public and lives at // /auth/email/verify/:token, because the link is opened from a mailbox. meRouter.patch( '/account/email', // #swagger.tags = ['Auth · Me'] // #swagger.summary = 'Request a new email address (self, any role)' // #swagger.description = 'Stages the address and emails a confirmation link. The account keeps its current address until that link is used, so a mistyped address cannot redirect password-reset mail. Requires currentPassword when the account has a password; SSO-provisioned accounts with no password are exempt.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ChangeEmailRequest" } } } } */ /* #swagger.responses[200] = { description: 'Address staged; a confirmation link was sent', content: { "application/json": { schema: { $ref: "#/components/schemas/PendingEmail" } } } } */ /* #swagger.responses[400] = { description: 'Validation error, wrong current password, or already your address', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[429] = { description: 'Too many verification emails', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ accountChangeLimiter, body('email').isString().trim().isEmail().isLength({ max: 255 }), body('currentPassword').optional({ values: 'falsy' }).isString(), validate, account.changeEmail, ) meRouter.post( '/account/email/resend', // #swagger.tags = ['Auth · Me'] // #swagger.summary = 'Re-send the confirmation link for the pending address' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.responses[200] = { description: 'Confirmation link re-sent', content: { "application/json": { schema: { $ref: "#/components/schemas/PendingEmail" } } } } */ /* #swagger.responses[400] = { description: 'No address is awaiting confirmation', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[429] = { description: 'Too many verification emails', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ accountChangeLimiter, account.resendEmailVerification, ) meRouter.delete( '/account/email/pending', // #swagger.tags = ['Auth · Me'] // #swagger.summary = 'Abandon the pending email address' // #swagger.description = 'Clears the staged address and retires its outstanding links, so a confirmation email already delivered can no longer install it.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.responses[200] = { description: 'Pending address cleared', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ account.cancelEmailChange, ) // TOTP self-enrollment (disable requires a valid current code; it does not take // a password). meRouter.post( '/account/totp/setup', // #swagger.tags = ['Auth · Me'] // #swagger.summary = 'Begin 2FA enrollment (returns secret + QR)' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.responses[200] = { description: 'otpauth URL and QR data to scan', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpSetup" } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ account.totpSetup, ) meRouter.post( '/account/totp/enable', // #swagger.tags = ['Auth · Me'] // #swagger.summary = 'Enable 2FA by confirming a code' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */ /* #swagger.responses[200] = { description: '2FA enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpState" } } } } */ /* #swagger.responses[400] = { description: 'Setup not started, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ body('code').isString().trim().isLength({ min: 6, max: 8 }), validate, account.totpEnable, ) meRouter.post( '/account/totp/disable', // #swagger.tags = ['Auth · Me'] // #swagger.summary = 'Disable 2FA by confirming a code' // #swagger.description = 'Requires a valid current authenticator code (proves control of the authenticator); it does not take a password.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */ /* #swagger.responses[200] = { description: '2FA disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpState" } } } } */ /* #swagger.responses[400] = { description: 'Not enabled, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ body('code').isString().trim().isLength({ min: 6, max: 8 }), validate, account.totpDisable, ) // Linked SSO identities (self-service). Linking itself starts at // GET /auth/sso/:provider/link (already behind requireAuth; works for any role). meRouter.get( '/account/identities', // #swagger.tags = ['Auth · Me'] // #swagger.summary = 'List linked SSO identities (self)' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.responses[200] = { description: 'Linked identities', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/LinkedIdentity" } } } } } */ account.listIdentities, ) meRouter.delete( '/account/identities/:provider', // #swagger.tags = ['Auth · Me'] // #swagger.summary = 'Unlink an SSO identity (self)' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] // #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' } /* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { $ref: "#/components/schemas/UnlinkedFlag" } } } } */ /* #swagger.responses[404] = { description: 'No linked account for that provider', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ param('provider').matches(/^[a-z0-9-]+$/), validate, account.unlinkIdentity, ) // Active mobile device sessions (self-service). Distinct from /auth/me/devices, // which is push-notification endpoints — these are login sessions (M9). List the // active ones and revoke a single device without "log out everywhere". meRouter.get( '/sessions', // #swagger.tags = ['Auth · Me'] // #swagger.summary = 'List active mobile device sessions (self)' // #swagger.description = 'Active (unrevoked, unexpired) mobile bearer sessions — one per live device — for the Active Devices screen. Never returns tokens.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.responses[200] = { description: 'Active device sessions', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/DeviceSession" } } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ account.listSessions, ) meRouter.delete( '/sessions/:id', // #swagger.tags = ['Auth · Me'] // #swagger.summary = 'Revoke one mobile device session (self)' // #swagger.description = 'Revokes a single device by its session id (ownership-scoped). Revoking stops future token renewals; an already-issued access token remains valid until it expires (see the documented revocation-latency window).' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The session row id from GET /auth/me/sessions.' } /* #swagger.responses[200] = { description: 'Revoked (idempotent)', content: { "application/json": { schema: { type: "object", properties: { revoked: { type: "boolean" } } } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ param('id').isInt({ min: 1 }), validate, account.revokeSession, ) // ── Trusted devices (self-service, MFA "Trust this device") ──────────────── // Distinct from /sessions (mobile login sessions): these are the devices allowed // to SKIP the TOTP step at login. List, trust-current, revoke one, untrust all. meRouter.get( '/trusted-devices', // #swagger.tags = ['Auth · Me'] // #swagger.summary = 'List trusted devices (self)' // #swagger.description = 'Active (unrevoked, unexpired) trusted devices — the browsers/apps allowed to skip the TOTP step at login. Never returns tokens.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.responses[200] = { description: 'Active trusted devices', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/TrustedDevice" } } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ account.listTrustedDevices, ) meRouter.post( '/trusted-devices', // #swagger.tags = ['Auth · Me'] // #swagger.summary = 'Trust the current device (self)' // #swagger.description = 'Marks the current browser/app as trusted so future logins skip the TOTP step (30 days). Web receives an httpOnly trust cookie; native (bearer) sessions receive { trustToken } to store. Returns 409 { error: "trusted_device_limit", devices } when the per-user cap is reached — revoke one first, then retry.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", properties: { deviceName: { type: "string" } } } } } } */ /* #swagger.responses[200] = { description: 'Device trusted', content: { "application/json": { schema: { $ref: "#/components/schemas/TrustDeviceResult" } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[409] = { description: 'Trusted-device limit reached', content: { "application/json": { schema: { $ref: "#/components/schemas/TrustedDeviceLimit" } } } } */ accountChangeLimiter, body('deviceName').optional({ values: 'falsy' }).isString().trim().isLength({ max: 100 }), validate, account.trustThisDevice, ) meRouter.delete( '/trusted-devices', // #swagger.tags = ['Auth · Me'] // #swagger.summary = 'Revoke all trusted devices (self)' // #swagger.description = 'Untrust every device; future logins on all of them require the full TOTP step again. Also clears this browser’s trust cookie.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.responses[200] = { description: 'Revoked count', content: { "application/json": { schema: { type: "object", properties: { revoked: { type: "integer" } } } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ account.revokeAllTrustedDevices, ) meRouter.delete( '/trusted-devices/:id', // #swagger.tags = ['Auth · Me'] // #swagger.summary = 'Revoke one trusted device (self)' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Trusted-device id from GET /auth/me/trusted-devices.' } /* #swagger.responses[200] = { description: 'Revoked (idempotent)', content: { "application/json": { schema: { type: "object", properties: { revoked: { type: "boolean" } } } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ param('id').isInt({ min: 1 }), validate, account.revokeTrustedDevice, ) // ── Recovery (backup) codes (self-service) ───────────────────────────────── meRouter.get( '/account/recovery-codes/status', // #swagger.tags = ['Auth · Me'] // #swagger.summary = 'Remaining recovery-code count (self)' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.responses[200] = { description: 'Remaining unused codes', content: { "application/json": { schema: { type: "object", properties: { remaining: { type: "integer" } } } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ account.recoveryCodesStatus, ) meRouter.post( '/account/recovery-codes/generate', // #swagger.tags = ['Auth · Me'] // #swagger.summary = 'Regenerate recovery codes (self, password step-up)' // #swagger.description = 'Generates a fresh set of single-use recovery codes, invalidating any prior set, and returns them ONCE. Requires the current password (accounts that have one); refuses when two-factor is off. Behind the login backoff/bot guards since a wrong password is credential-guessing.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", properties: { currentPassword: { type: "string" } } } } } } */ /* #swagger.responses[200] = { description: 'New recovery codes (shown once)', content: { "application/json": { schema: { $ref: "#/components/schemas/RecoveryCodes" } } } } */ /* #swagger.responses[400] = { description: 'Wrong password, or two-factor not enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ backoffGuard, slowLogin, accountChangeLimiter, body('currentPassword').optional({ values: 'falsy' }).isString(), validate, account.generateRecoveryCodes, ) module.exports = meRouter