diff --git a/server/src/router/v1/auth/auth.routes.js b/server/src/router/v1/auth/auth.routes.js index 9ea8095..e3cb027 100644 --- a/server/src/router/v1/auth/auth.routes.js +++ b/server/src/router/v1/auth/auth.routes.js @@ -16,6 +16,7 @@ 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() @@ -26,6 +27,13 @@ authRouter.use('/mobile', mobileRouter) // 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 diff --git a/server/src/router/v1/auth/me.routes.js b/server/src/router/v1/auth/me.routes.js new file mode 100644 index 0000000..357f3a3 --- /dev/null +++ b/server/src/router/v1/auth/me.routes.js @@ -0,0 +1,140 @@ +// ── Role-agnostic self-service ("me") under /auth/me ─────────────────────── +// +// The canonical self surface for EVERY authenticated role (player and staff +// alike). It reuses the exact same account.controller handlers as +// /player/account/* and /admin/account/* — no logic duplication — but 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). The older +// /player/account/* and /admin/account/* routes stay for web back-compat; these +// /auth/me/* routes are the additive, role-agnostic canonical form. +// +// 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.routes.js, so paths below are /auth/me/account*. + +const express = require('express') +const { body, param } = require('express-validator') + +const account = require('../admin/account.controller') +const { requireAuth } = require('../../../auth/session.middleware') +const noindex = require('../../../middleware/noindex') +const validate = require('../../../middleware/validate') +const { accountChangeLimiter } = require('../../../middleware/rateLimit') + +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, +) + +// TOTP self-enrollment — identical to the player/admin account flow (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, +) + +module.exports = meRouter diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 84f1295..ddbeb4d 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -3,7 +3,7 @@ "info": { "title": "Runic Gateway API", "version": "1.0.0", - "description": "REST API for the Runic Gateway website, wiki and admin panel — a private Ultima Online shard.\n\n### Authentication\n- **Web / admin panel** uses an httpOnly session cookie (`uomm_token`) issued by `POST /api/v1/auth/login` (plus `/login/totp` when 2FA is enabled).\n- **Native / mobile clients** use bearer access tokens from `POST /api/v1/auth/mobile/login`, refreshed via `/auth/mobile/refresh`.\n\nEndpoints under `/api/v1/admin/**` require a valid session; some are further restricted to the `admin` role (editors are limited to content)." + "description": "REST API for the Runic Gateway website, wiki and admin panel — a private Ultima Online shard.\n\n### Authentication\n- **Web / admin panel** uses an httpOnly session cookie (`rg_token`) issued by `POST /api/v1/auth/login` (plus `/login/totp` when 2FA is enabled).\n- **Native / mobile clients** use bearer access tokens from `POST /api/v1/auth/mobile/login`, refreshed via `/auth/mobile/refresh`.\n\nEndpoints under `/api/v1/admin/**` require a valid session; some are further restricted to the `admin` role (editors are limited to content)." }, "servers": [ { @@ -1130,6 +1130,507 @@ } } }, + "/api/v1/auth/me/account": { + "get": { + "tags": [ + "Auth · Me" + ], + "summary": "Get the current account (self, any role)", + "description": "", + "responses": { + "200": { + "description": "The current account", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlayerAccount" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Account not active", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/auth/me/account/username": { + "patch": { + "tags": [ + "Auth · Me" + ], + "summary": "Change the current account’s username (self, any role)", + "description": "", + "responses": { + "200": { + "description": "Updated username (session re-issued)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Validation error or unavailable username", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "409": { + "description": "Username already taken", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Too many changes (rate limited)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangeUsernameRequest" + } + } + } + } + } + }, + "/api/v1/auth/me/account/password": { + "patch": { + "tags": [ + "Auth · Me" + ], + "summary": "Change or set the current account’s password (self, any role)", + "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.", + "responses": { + "200": { + "description": "Password changed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkFlag" + } + } + } + }, + "400": { + "description": "Validation error or wrong current password", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "429": { + "description": "Too many changes (rate limited)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangePasswordRequest" + } + } + } + } + } + }, + "/api/v1/auth/me/account/totp/setup": { + "post": { + "tags": [ + "Auth · Me" + ], + "summary": "Begin 2FA enrollment (returns secret + QR)", + "description": "", + "responses": { + "200": { + "description": "otpauth URL and QR data to scan", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TotpSetup" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "409": { + "description": "Two-factor already enabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/auth/me/account/totp/enable": { + "post": { + "tags": [ + "Auth · Me" + ], + "summary": "Enable 2FA by confirming a code", + "description": "", + "responses": { + "200": { + "description": "2FA enabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TotpState" + } + } + } + }, + "400": { + "description": "Setup not started, or invalid code", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "409": { + "description": "Two-factor already enabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TotpCodeRequest" + } + } + } + } + } + }, + "/api/v1/auth/me/account/totp/disable": { + "post": { + "tags": [ + "Auth · Me" + ], + "summary": "Disable 2FA by confirming a code", + "description": "Requires a valid current authenticator code (proves control of the authenticator); it does not take a password.", + "responses": { + "200": { + "description": "2FA disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TotpState" + } + } + } + }, + "400": { + "description": "Not enabled, or invalid code", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TotpCodeRequest" + } + } + } + } + } + }, + "/api/v1/auth/me/account/identities": { + "get": { + "tags": [ + "Auth · Me" + ], + "summary": "List linked SSO identities (self)", + "description": "", + "responses": { + "200": { + "description": "Linked identities", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LinkedIdentity" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/auth/me/account/identities/{provider}": { + "delete": { + "tags": [ + "Auth · Me" + ], + "summary": "Unlink an SSO identity (self)", + "description": "", + "parameters": [ + { + "name": "provider", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Provider id." + } + ], + "responses": { + "200": { + "description": "Unlinked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnlinkedFlag" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "No linked account for that provider", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, "/api/v1/public/settings": { "get": { "tags": [ @@ -9599,7 +10100,7 @@ "cookieAuth": { "type": "apiKey", "in": "cookie", - "name": "uomm_token", + "name": "rg_token", "description": "Session JWT set as an httpOnly cookie by POST /api/v1/auth/login." }, "bearerAuth": { diff --git a/server/test/authMe.test.js b/server/test/authMe.test.js new file mode 100644 index 0000000..f834b4b --- /dev/null +++ b/server/test/authMe.test.js @@ -0,0 +1,42 @@ +// Point the DB at a closed port BEFORE requiring anything that builds the pool, +// so any stray DB path fails fast instead of holding the process open. The cases +// here reject at requireAuth (no session token) before any query runs. +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, after } = require('node:test') +const assert = require('node:assert/strict') + +const { startApp } = require('./_helper') +const authRouter = require('../src/router/v1/auth/auth.routes') +const db = require('../src/utils/db') + +after(() => db.close()) + +// The role-agnostic /auth/me/* self surface must be gated: every route sits behind +// requireAuth (any role), so an unauthenticated caller gets 401 — never a 404 +// (which would mean the route isn't mounted) and never a 200. +test('/auth/me/account* rejects unauthenticated callers with 401', async () => { + const app = await startApp((a) => a.use('/api/v1/auth', authRouter)) + try { + const calls = [ + ['GET', '/api/v1/auth/me/account'], + ['GET', '/api/v1/auth/me/account/identities'], + ['PATCH', '/api/v1/auth/me/account/username', { username: 'someone' }], + ['PATCH', '/api/v1/auth/me/account/password', { newPassword: 'abcd1234' }], + ['POST', '/api/v1/auth/me/account/totp/setup'], + ['POST', '/api/v1/auth/me/account/totp/enable', { code: '123456' }], + ['DELETE', '/api/v1/auth/me/account/identities/google'], + ] + for (const [method, path, body] of calls) { + const res = await fetch(app.url + path, { + method, + headers: body ? { 'Content-Type': 'application/json' } : {}, + body: body ? JSON.stringify(body) : undefined, + }) + assert.equal(res.status, 401, `${method} ${path} should be 401, got ${res.status}`) + } + } finally { + await app.close() + } +})