feat(auth): role-agnostic self-service surface under /auth/me
Add /auth/me/account* — the canonical "me" endpoints for every authenticated role (Android app §6.4/§8.1). Reuses the existing account.controller handlers (getAccount, changeUsername, changePassword, TOTP setup/enable/disable, list/ unlink identities) verbatim behind requireAuth (any role) — no logic duplication. The app gets one self surface and never has to touch /admin; the old /player/account/* and /admin/account/* routes stay for web back-compat. New routes (all bearer- or cookie-auth, any active role): - GET /auth/me/account - PATCH /auth/me/account/username - PATCH /auth/me/account/password - POST /auth/me/account/totp/setup|enable|disable - GET /auth/me/account/identities - DELETE /auth/me/account/identities/:provider Mounted as a sub-router; the bare GET /auth/me is unchanged. Swagger regenerated with #swagger annotations. Adds test/authMe.test.js (the group gate rejects unauthenticated callers with 401). Verified end-to-end against MariaDB: a player and an editor both drive the same surface (role-agnostic), username/password changes work, a password change revokes the caller's old bearer token, and validation/401 paths behave. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
This commit is contained in:
@@ -16,6 +16,7 @@ const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection
|
|||||||
const validate = require('../../../middleware/validate')
|
const validate = require('../../../middleware/validate')
|
||||||
const mobileRouter = require('./mobile.routes')
|
const mobileRouter = require('./mobile.routes')
|
||||||
const ssoRouter = require('./sso.routes')
|
const ssoRouter = require('./sso.routes')
|
||||||
|
const meRouter = require('./me.routes')
|
||||||
|
|
||||||
const authRouter = express.Router()
|
const authRouter = express.Router()
|
||||||
|
|
||||||
@@ -26,6 +27,13 @@ authRouter.use('/mobile', mobileRouter)
|
|||||||
// Additive; the web cookie + TOTP flow below is unchanged.
|
// Additive; the web cookie + TOTP flow below is unchanged.
|
||||||
authRouter.use(ssoRouter)
|
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):
|
// Login protection order (cheapest rejection first):
|
||||||
// backoffGuard → per-IP exponential lockout on repeated failures
|
// backoffGuard → per-IP exponential lockout on repeated failures
|
||||||
// slowLogin → progressive per-request delay within the window
|
// slowLogin → progressive per-request delay within the window
|
||||||
|
|||||||
140
server/src/router/v1/auth/me.routes.js
Normal file
140
server/src/router/v1/auth/me.routes.js
Normal file
@@ -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
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Runic Gateway API",
|
"title": "Runic Gateway API",
|
||||||
"version": "1.0.0",
|
"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": [
|
"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": {
|
"/api/v1/public/settings": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -9599,7 +10100,7 @@
|
|||||||
"cookieAuth": {
|
"cookieAuth": {
|
||||||
"type": "apiKey",
|
"type": "apiKey",
|
||||||
"in": "cookie",
|
"in": "cookie",
|
||||||
"name": "uomm_token",
|
"name": "rg_token",
|
||||||
"description": "Session JWT set as an httpOnly cookie by POST /api/v1/auth/login."
|
"description": "Session JWT set as an httpOnly cookie by POST /api/v1/auth/login."
|
||||||
},
|
},
|
||||||
"bearerAuth": {
|
"bearerAuth": {
|
||||||
|
|||||||
42
server/test/authMe.test.js
Normal file
42
server/test/authMe.test.js
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user