Makes `users.email` unique, de-duplicates the addresses an upgrade will find, and builds the self-service change-and-verify flow that did not exist. The uniqueness index is on a generated `email_norm AS (LOWER(email)) STORED` column under `utf8mb4_bin`, NOT on `email` under a `_ci` collation as the plan specified. Every case-insensitive collation this server offers is also accent-insensitive: `josé@x.com` and `jose@x.com` compare equal, and those are two different mailboxes. The plan's index would have refused the second address forever and the de-duplication would have nulled a legitimate account's. A requested address is STAGED in `email_pending` and only a tokened link installs it, so a typo cannot silently redirect account-recovery mail. `isDuplicateUsername()` now distinguishes the two indexes. All five call sites branch on it; each answers differently on purpose, because a public form, an IdP callback, a half-completed invite and an admin screen do not owe the same person the same amount of truth. SSO reads the IdP's actual `email_verified`/`verified` claim instead of inferring verification from an address merely being present. Co-Authored-By: Claude <noreply@anthropic.com>
295 lines
21 KiB
JavaScript
295 lines
21 KiB
JavaScript
// ── 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
|