diff --git a/.env.example b/.env.example index 014ed192..2b67cec7 100644 --- a/.env.example +++ b/.env.example @@ -40,5 +40,9 @@ AUTH_CODE_TTL_SECONDS=300 AUTH_SMS_COOLDOWN_SECONDS=60 AUTH_SESSION_TTL_SECONDS=604800 -# 迁移期平台管理 API Key。生产环境应替换为平台管理员 JWT/服务端会话。 +# 安全目标默认关闭迁移期身份头。仅本地兼容旧测试/旧前端时可临时设为 true。 +ALLOW_LEGACY_AUTH_HEADERS=false +ALLOW_PLATFORM_ADMIN_KEY=false + +# 迁移期平台管理 API Key。生产环境必须使用平台管理员 JWT/服务端会话,不能开启 ALLOW_PLATFORM_ADMIN_KEY。 PLATFORM_ADMIN_API_KEY=replace_with_platform_admin_key diff --git a/apps/api/.env.example b/apps/api/.env.example index 07422316..dd70a0d9 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -4,3 +4,10 @@ DEFAULT_TENANT_SLUG=master CORS_ORIGIN=http://127.0.0.1:5173,http://localhost:5173 MAX_JSON_BODY_BYTES=1048576 MAX_IMPORT_JSON_BODY_BYTES=10485760 +AUTH_SMS_PROVIDER=mock +AUTH_CODE_PEPPER=replace_with_a_long_random_secret +AUTH_SESSION_SECRET=replace_with_another_long_random_secret +# 安全目标默认关闭迁移期身份头。仅本地兼容旧测试/旧前端时可临时设为 true。 +ALLOW_LEGACY_AUTH_HEADERS=false +ALLOW_PLATFORM_ADMIN_KEY=false +PLATFORM_ADMIN_API_KEY=replace_with_platform_admin_key diff --git a/apps/api/src/core/auth-context.ts b/apps/api/src/core/auth-context.ts new file mode 100644 index 00000000..2d4a59f1 --- /dev/null +++ b/apps/api/src/core/auth-context.ts @@ -0,0 +1,73 @@ +import crypto from 'node:crypto'; +import { config } from './config.js'; +import { queryOne } from './db.js'; +import { getHeader, type RequestContext } from './http.js'; + +export interface SessionIdentity { + id: string; + username: string | null; + phone: string | null; + name: string | null; + avatarUrl: string | null; + primaryRole: string; + createdAt: string; + tenantId: string; + sessionId: string; + sessionExpiresAt: string; +} + +interface RequestAuthState { + bearerToken: string; + session: SessionIdentity | null; + sessionResolved: boolean; +} + +const requestAuthState = new WeakMap(); + +export function bearerTokenFrom(ctx: RequestContext) { + const authorization = getHeader(ctx.req, 'authorization'); + const match = authorization.match(/^Bearer\s+(.+)$/i); + return match?.[1]?.trim() || ''; +} + +export function hashSessionToken(token: string) { + return crypto.createHmac('sha256', config.authSessionSecret).update(token).digest('hex'); +} + +export async function findUserBySessionToken(token: string) { + const tokenHash = hashSessionToken(token); + return queryOne( + ` + select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl", + u.primary_role as "primaryRole", u.created_at as "createdAt", + s.tenant_id as "tenantId", s.id as "sessionId", s.expires_at as "sessionExpiresAt" + from app_private.auth_sessions s + join public.platform_users u on u.id = s.user_id + where s.token_hash = $1 + and s.revoked_at is null + and s.expires_at > now() + limit 1 + `, + [tokenHash], + ); +} + +export async function hydrateRequestAuth(ctx: RequestContext) { + const cached = requestAuthState.get(ctx); + if (cached?.sessionResolved) return cached; + + const bearerToken = bearerTokenFrom(ctx); + const session = bearerToken ? await findUserBySessionToken(bearerToken) : null; + const state = { bearerToken, session, sessionResolved: true }; + requestAuthState.set(ctx, state); + return state; +} + +export function currentSessionFromContext(ctx: RequestContext) { + return requestAuthState.get(ctx)?.session || null; +} + +export function hasInvalidBearerSession(ctx: RequestContext) { + const state = requestAuthState.get(ctx); + return Boolean(state?.bearerToken && !state.session); +} diff --git a/apps/api/src/core/config.ts b/apps/api/src/core/config.ts index 895ff7eb..040d9463 100644 --- a/apps/api/src/core/config.ts +++ b/apps/api/src/core/config.ts @@ -14,6 +14,8 @@ export interface ApiConfig { authCodeTtlSeconds: number; authSmsCooldownSeconds: number; authSessionTtlSeconds: number; + allowLegacyAuthHeaders: boolean; + allowPlatformAdminKey: boolean; platformAdminApiKey: string; storageDefaultProvider: string; storageDefaultBucket: string; @@ -79,6 +81,12 @@ function validateProductionConfig(nextConfig: ApiConfig) { if (isUnsafeSecret(nextConfig.platformAdminApiKey, DEFAULT_PLATFORM_ADMIN_API_KEY)) { failures.push('PLATFORM_ADMIN_API_KEY must be a strong production secret until platform JWT is implemented'); } + if (nextConfig.allowLegacyAuthHeaders) { + failures.push('ALLOW_LEGACY_AUTH_HEADERS=true is not allowed in production'); + } + if (nextConfig.allowPlatformAdminKey) { + failures.push('ALLOW_PLATFORM_ADMIN_KEY=true is not allowed in production'); + } if (failures.length > 0) { throw new Error(`Invalid production API configuration: ${failures.join('; ')}`); @@ -102,6 +110,8 @@ const loadedConfig: ApiConfig = { authCodeTtlSeconds: envNumber('AUTH_CODE_TTL_SECONDS', 300), authSmsCooldownSeconds: envNumber('AUTH_SMS_COOLDOWN_SECONDS', 60), authSessionTtlSeconds: envNumber('AUTH_SESSION_TTL_SECONDS', 60 * 60 * 24 * 7), + allowLegacyAuthHeaders: envBoolean('ALLOW_LEGACY_AUTH_HEADERS', !isProduction), + allowPlatformAdminKey: envBoolean('ALLOW_PLATFORM_ADMIN_KEY', !isProduction), platformAdminApiKey: envString('PLATFORM_ADMIN_API_KEY', DEFAULT_PLATFORM_ADMIN_API_KEY), storageDefaultProvider: envString('STORAGE_DEFAULT_PROVIDER', 'local_dev'), storageDefaultBucket: envString('STORAGE_DEFAULT_BUCKET', 'tenant-assets'), diff --git a/apps/api/src/core/request.ts b/apps/api/src/core/request.ts index d4816e25..f7a5869e 100644 --- a/apps/api/src/core/request.ts +++ b/apps/api/src/core/request.ts @@ -1,28 +1,76 @@ import { config } from './config.js'; +import { hydrateRequestAuth } from './auth-context.js'; import { getHeader, HttpError, type RequestContext } from './http.js'; export type JsonObject = Record; -export function tenantIdFrom(ctx: RequestContext) { - const tenantId = getHeader(ctx.req, 'x-tenant-id') || ctx.url.searchParams.get('tenantId'); - if (!tenantId) { - throw new HttpError(400, 'x-tenant-id header or tenantId query is required', 'TENANT_ID_REQUIRED'); - } - return tenantId; +function legacyTenantIdFrom(ctx: RequestContext) { + return getHeader(ctx.req, 'x-tenant-id') || ctx.url.searchParams.get('tenantId'); } -export function userIdFrom(ctx: RequestContext, body?: JsonObject) { - const userId = +function legacyUserIdFrom(ctx: RequestContext, body?: JsonObject) { + return ( getHeader(ctx.req, 'x-user-id') || ctx.url.searchParams.get('userId') || - (typeof body?.userId === 'string' ? body.userId : ''); + (typeof body?.userId === 'string' ? body.userId : '') + ); +} +export async function tenantIdFrom(ctx: RequestContext) { + const auth = await hydrateRequestAuth(ctx); + const legacyTenantId = legacyTenantIdFrom(ctx); + if (auth.session) { + if (legacyTenantId && legacyTenantId !== auth.session.tenantId) { + throw new HttpError(403, 'Request tenant does not match the authenticated session', 'AUTH_TENANT_MISMATCH'); + } + return auth.session.tenantId; + } + + if (!legacyTenantId) { + throw new HttpError(400, 'x-tenant-id header or tenantId query is required', 'TENANT_ID_REQUIRED'); + } + return legacyTenantId; +} + +export async function userIdFrom(ctx: RequestContext, body?: JsonObject) { + const auth = await hydrateRequestAuth(ctx); + if (auth.session) { + const legacyUserId = legacyUserIdFrom(ctx, body); + if (legacyUserId && legacyUserId !== auth.session.id) { + throw new HttpError(403, 'Request user identity does not match the authenticated session', 'AUTH_USER_MISMATCH'); + } + return auth.session.id; + } + if (auth.bearerToken) { + throw new HttpError(401, 'Invalid or expired session', 'AUTH_SESSION_INVALID'); + } + + const userId = legacyUserIdFrom(ctx, body); if (!userId) { throw new HttpError(400, 'x-user-id header, userId query, or userId body is required', 'USER_ID_REQUIRED'); } + if (!config.allowLegacyAuthHeaders) { + throw new HttpError(401, 'User identity must be resolved from a trusted session in this environment', 'TRUSTED_USER_REQUIRED'); + } return userId; } +export async function optionalUserIdFrom(ctx: RequestContext, body?: JsonObject) { + const auth = await hydrateRequestAuth(ctx); + if (auth.session) { + const legacyUserId = legacyUserIdFrom(ctx, body); + if (legacyUserId && legacyUserId !== auth.session.id) { + throw new HttpError(403, 'Request user identity does not match the authenticated session', 'AUTH_USER_MISMATCH'); + } + return auth.session.id; + } + if (auth.bearerToken) { + throw new HttpError(401, 'Invalid or expired session', 'AUTH_SESSION_INVALID'); + } + if (!config.allowLegacyAuthHeaders) return ''; + return legacyUserIdFrom(ctx, body); +} + export function intParam(ctx: RequestContext, name: string, fallback: number, max = 500) { const value = Number(ctx.url.searchParams.get(name) || fallback); if (!Number.isFinite(value) || value <= 0) return fallback; @@ -96,8 +144,17 @@ export function optionalStringArray(body: JsonObject, key: string): string[] { return value.map(item => String(item)).filter(Boolean); } -export function requirePlatformAdmin(ctx: RequestContext) { +export async function requirePlatformAdmin(ctx: RequestContext) { + const auth = await hydrateRequestAuth(ctx); + if (auth.session?.primaryRole === 'platform_admin') return; + if (auth.bearerToken) { + throw new HttpError(401, 'Invalid or expired platform admin session', 'AUTH_SESSION_INVALID'); + } + const provided = getHeader(ctx.req, 'x-platform-admin-key'); + if (provided && !config.allowPlatformAdminKey) { + throw new HttpError(401, 'Platform admin API key is disabled in this environment', 'PLATFORM_ADMIN_KEY_DISABLED'); + } if (!provided || provided !== config.platformAdminApiKey) { throw new HttpError(403, 'Platform admin access is required', 'PLATFORM_ADMIN_REQUIRED'); } diff --git a/apps/api/src/features/auth/routes.ts b/apps/api/src/features/auth/routes.ts index 8f17e0be..4653c7f4 100644 --- a/apps/api/src/features/auth/routes.ts +++ b/apps/api/src/features/auth/routes.ts @@ -1,15 +1,14 @@ import crypto from 'node:crypto'; import { config } from '../../core/config.js'; +import { bearerTokenFrom, findUserBySessionToken } from '../../core/auth-context.js'; import { query, transaction } from '../../core/db.js'; import { HttpError, type RequestContext } from '../../core/http.js'; import { optionalString, readJsonBody, requiredString, tenantIdFrom } from '../../core/request.js'; import { createSmsProvider } from './providers.js'; import { assertChinaPhone, - bearerTokenFrom, clientIpFrom, createLoginSession, - findUserBySessionToken, generateSmsCode, hashSmsCode, normalizePurpose, @@ -77,7 +76,7 @@ async function activeSmsProviderName(tenantId: string) { } export async function sendSmsCodeRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const body = await readJsonBody(ctx); const phone = assertChinaPhone(requiredString(body, 'phone')); const purpose = normalizePurpose(optionalString(body, 'purpose') || 'login'); @@ -168,7 +167,7 @@ export async function sendSmsCodeRoute(ctx: RequestContext) { } export async function verifySmsCodeRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const body = await readJsonBody(ctx); const phone = assertChinaPhone(requiredString(body, 'phone')); const code = requiredString(body, 'code'); @@ -316,7 +315,7 @@ export async function verifySmsCodeRoute(ctx: RequestContext) { } export async function meRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const token = bearerTokenFrom(ctx); if (!token) { throw new HttpError(401, 'Bearer token is required', 'AUTH_TOKEN_REQUIRED'); @@ -348,7 +347,7 @@ export async function meRoute(ctx: RequestContext) { } export async function logoutRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const token = bearerTokenFrom(ctx); if (!token) return { ok: true }; diff --git a/apps/api/src/features/auth/service.ts b/apps/api/src/features/auth/service.ts index 21f74235..b707a913 100644 --- a/apps/api/src/features/auth/service.ts +++ b/apps/api/src/features/auth/service.ts @@ -1,8 +1,8 @@ import crypto from 'node:crypto'; import type pg from 'pg'; +import { hashSessionToken } from '../../core/auth-context.js'; import { config } from '../../core/config.js'; import { HttpError, getHeader, type RequestContext } from '../../core/http.js'; -import { queryOne } from '../../core/db.js'; export interface PlatformUserSummary { id: string; @@ -60,40 +60,6 @@ export function createSessionToken() { return `tk_${crypto.randomBytes(32).toString('base64url')}`; } -export function hashSessionToken(token: string) { - return crypto.createHmac('sha256', config.authSessionSecret).update(token).digest('hex'); -} - -export async function findUserBySessionToken(token: string) { - const tokenHash = hashSessionToken(token); - return queryOne< - PlatformUserSummary & { - tenantId: string; - sessionId: string; - sessionExpiresAt: string; - } - >( - ` - select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl", - u.primary_role as "primaryRole", u.created_at as "createdAt", - s.tenant_id as "tenantId", s.id as "sessionId", s.expires_at as "sessionExpiresAt" - from app_private.auth_sessions s - join public.platform_users u on u.id = s.user_id - where s.token_hash = $1 - and s.revoked_at is null - and s.expires_at > now() - limit 1 - `, - [tokenHash], - ); -} - -export function bearerTokenFrom(ctx: RequestContext) { - const authorization = getHeader(ctx.req, 'authorization'); - const match = authorization.match(/^Bearer\s+(.+)$/i); - return match?.[1]?.trim() || ''; -} - export async function createLoginSession( client: pg.PoolClient, input: { diff --git a/apps/api/src/features/catalog/assets.ts b/apps/api/src/features/catalog/assets.ts index 127a9a8f..ac37219e 100644 --- a/apps/api/src/features/catalog/assets.ts +++ b/apps/api/src/features/catalog/assets.ts @@ -1,5 +1,5 @@ -import { getHeader, HttpError, type RequestContext } from '../../core/http.js'; -import { intParam, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js'; +import { HttpError, type RequestContext } from '../../core/http.js'; +import { intParam, optionalUserIdFrom, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js'; import { query, queryOne } from '../../core/db.js'; import { signStorageDownload, type StorageProviderName } from '../storage/service.js'; @@ -18,14 +18,6 @@ interface CatalogAssetRow { subjectId: string | null; } -function optionalUserId(ctx: RequestContext) { - return getHeader(ctx.req, 'x-user-id') || ctx.url.searchParams.get('userId') || ''; -} - -function hasUserContext(ctx: RequestContext) { - return !!optionalUserId(ctx); -} - async function hasActiveMembership(tenantId: string, userId: string) { const row = await queryOne<{ id: string }>( ` @@ -64,13 +56,15 @@ async function hasSvipAccess(tenantId: string, userId: string, asset: CatalogAss } async function assertAssetAccess(ctx: RequestContext, asset: CatalogAssetRow) { - if (asset.visibility === 'public' || asset.visibility === 'tenant') return { userId: optionalUserId(ctx), svip: false }; + if (asset.visibility === 'public' || asset.visibility === 'tenant') { + return { userId: await optionalUserIdFrom(ctx), svip: false }; + } if (asset.visibility === 'private') { throw new HttpError(403, 'Asset is private', 'ASSET_PRIVATE'); } - const tenantId = tenantIdFrom(ctx); - const userId = userIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx); const member = await hasActiveMembership(tenantId, userId); if (!member) { throw new HttpError(403, 'Tenant membership is required for this asset', 'ASSET_MEMBERSHIP_REQUIRED'); @@ -86,7 +80,7 @@ async function assertAssetAccess(ctx: RequestContext, asset: CatalogAssetRow) { } export async function assetsRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const limit = intParam(ctx, 'limit', 100, 500); const assetType = stringParam(ctx, 'assetType'); const regionId = stringParam(ctx, 'regionId'); @@ -95,7 +89,7 @@ export async function assetsRoute(ctx: RequestContext) { const entryId = stringParam(ctx, 'entryId'); const contentNodeId = stringParam(ctx, 'contentNodeId'); const includeLocked = stringParam(ctx, 'includeLocked') === 'true'; - const userPresent = hasUserContext(ctx); + const userPresent = Boolean(await optionalUserIdFrom(ctx)); const params: unknown[] = [tenantId]; const filters = [`tenant_id = $1`, `status = 'active'`, `visibility <> 'private'`]; @@ -151,7 +145,7 @@ export async function assetsRoute(ctx: RequestContext) { } export async function assetDownloadRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const assetId = stringParam(ctx, 'assetId') || ctx.url.searchParams.get('id') || ''; if (!assetId) { throw new HttpError(400, 'assetId is required', 'REQUIRED_FIELD'); diff --git a/apps/api/src/features/catalog/navigation.ts b/apps/api/src/features/catalog/navigation.ts index 9d8c5d30..72b9bf3b 100644 --- a/apps/api/src/features/catalog/navigation.ts +++ b/apps/api/src/features/catalog/navigation.ts @@ -8,7 +8,7 @@ function optionalUuidParam(ctx: RequestContext, name: string) { } export async function contentEntriesRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const regionId = optionalUuidParam(ctx, 'regionId'); const entryType = stringParam(ctx, 'entryType'); const includeHidden = stringParam(ctx, 'includeHidden') === 'true'; @@ -44,7 +44,7 @@ export async function contentEntriesRoute(ctx: RequestContext) { } export async function contentNodesRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const entryId = stringParam(ctx, 'entryId'); if (!entryId) { throw new HttpError(400, 'entryId is required', 'REQUIRED_FIELD'); @@ -97,7 +97,7 @@ export async function contentNodesRoute(ctx: RequestContext) { } export async function questionCollectionsRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const regionId = optionalUuidParam(ctx, 'regionId'); const entryId = optionalUuidParam(ctx, 'entryId'); const nodeId = optionalUuidParam(ctx, 'nodeId'); @@ -146,7 +146,7 @@ export async function questionCollectionsRoute(ctx: RequestContext) { } export async function practiceBlueprintsRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const entryId = optionalUuidParam(ctx, 'entryId'); const nodeId = optionalUuidParam(ctx, 'nodeId'); const collectionId = optionalUuidParam(ctx, 'collectionId'); @@ -195,7 +195,7 @@ export async function practiceBlueprintsRoute(ctx: RequestContext) { } export async function collectionQuestionsRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const collectionId = stringParam(ctx, 'collectionId'); if (!collectionId) { throw new HttpError(400, 'collectionId is required', 'REQUIRED_FIELD'); diff --git a/apps/api/src/features/catalog/routes.ts b/apps/api/src/features/catalog/routes.ts index c7ddd689..c8971ca0 100644 --- a/apps/api/src/features/catalog/routes.ts +++ b/apps/api/src/features/catalog/routes.ts @@ -1,22 +1,9 @@ -import { getHeader, HttpError, type RequestContext } from '../../core/http.js'; +import type { RequestContext } from '../../core/http.js'; import { query } from '../../core/db.js'; - -function tenantIdFrom(ctx: RequestContext) { - const tenantId = getHeader(ctx.req, 'x-tenant-id') || ctx.url.searchParams.get('tenantId'); - if (!tenantId) { - throw new HttpError(400, 'x-tenant-id header or tenantId query is required', 'TENANT_ID_REQUIRED'); - } - return tenantId; -} - -function intParam(ctx: RequestContext, name: string, fallback: number, max = 500) { - const value = Number(ctx.url.searchParams.get(name) || fallback); - if (!Number.isFinite(value) || value <= 0) return fallback; - return Math.min(Math.trunc(value), max); -} +import { intParam, tenantIdFrom } from '../../core/request.js'; export async function regionsRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const items = await query( ` @@ -34,7 +21,7 @@ export async function regionsRoute(ctx: RequestContext) { } export async function regionModulesRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const regionId = ctx.url.searchParams.get('regionId'); const params: unknown[] = [tenantId]; @@ -61,7 +48,7 @@ export async function regionModulesRoute(ctx: RequestContext) { } export async function moduleNodesRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const regionId = ctx.url.searchParams.get('regionId'); const moduleId = ctx.url.searchParams.get('moduleId'); const parentId = ctx.url.searchParams.get('parentId'); @@ -98,7 +85,7 @@ export async function moduleNodesRoute(ctx: RequestContext) { } export async function schoolsRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const regionId = ctx.url.searchParams.get('regionId'); const params: unknown[] = [tenantId]; const filters = ['tenant_id = $1']; @@ -124,7 +111,7 @@ export async function schoolsRoute(ctx: RequestContext) { } export async function majorsRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const regionId = ctx.url.searchParams.get('regionId'); const schoolId = ctx.url.searchParams.get('schoolId'); const params: unknown[] = [tenantId]; @@ -155,7 +142,7 @@ export async function majorsRoute(ctx: RequestContext) { } export async function subjectsRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const regionId = ctx.url.searchParams.get('regionId'); const schoolId = ctx.url.searchParams.get('schoolId'); const majorId = ctx.url.searchParams.get('majorId'); @@ -203,7 +190,7 @@ export async function subjectsRoute(ctx: RequestContext) { } export async function categoriesRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const subjectId = ctx.url.searchParams.get('subjectId'); const nodeId = ctx.url.searchParams.get('nodeId'); const params: unknown[] = [tenantId]; @@ -235,7 +222,7 @@ export async function categoriesRoute(ctx: RequestContext) { } export async function questionsRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const subjectId = ctx.url.searchParams.get('subjectId'); const categoryId = ctx.url.searchParams.get('categoryId'); const nodeId = ctx.url.searchParams.get('nodeId'); @@ -309,7 +296,7 @@ export async function questionsRoute(ctx: RequestContext) { } export async function vocabularyUnitsRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const regionId = ctx.url.searchParams.get('regionId'); const params: unknown[] = [tenantId]; const filters = ['tenant_id = $1', 'is_active = true']; @@ -336,7 +323,7 @@ export async function vocabularyUnitsRoute(ctx: RequestContext) { } export async function vocabularyWordsRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const unitId = ctx.url.searchParams.get('unitId'); const limit = intParam(ctx, 'limit', 1000, 2000); const params: unknown[] = [tenantId]; @@ -368,7 +355,7 @@ export async function vocabularyWordsRoute(ctx: RequestContext) { } export async function handbookSubjectsRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const regionId = ctx.url.searchParams.get('regionId'); const params: unknown[] = [tenantId]; const filters = ['tenant_id = $1', 'is_active = true']; @@ -395,7 +382,7 @@ export async function handbookSubjectsRoute(ctx: RequestContext) { } export async function handbookChaptersRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const subjectId = ctx.url.searchParams.get('subjectId'); const params: unknown[] = [tenantId]; const filters = ['tenant_id = $1', 'is_active = true']; @@ -422,7 +409,7 @@ export async function handbookChaptersRoute(ctx: RequestContext) { } export async function handbookEntriesRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const chapterId = ctx.url.searchParams.get('chapterId'); const includeContent = ctx.url.searchParams.get('includeContent') === 'true'; const params: unknown[] = [tenantId]; @@ -450,7 +437,7 @@ export async function handbookEntriesRoute(ctx: RequestContext) { } export async function bannersRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const regionId = ctx.url.searchParams.get('regionId'); const params: unknown[] = [tenantId]; const filters = ['tenant_id = $1', 'is_active = true']; @@ -478,7 +465,7 @@ export async function bannersRoute(ctx: RequestContext) { } export async function faqsRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const regionId = ctx.url.searchParams.get('regionId'); const params: unknown[] = [tenantId]; const filters = ['tenant_id = $1', 'is_active = true']; @@ -503,7 +490,7 @@ export async function faqsRoute(ctx: RequestContext) { } export async function announcementsRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const items = await query( ` select id, legacy_id as "legacyId", content, link, @@ -521,7 +508,7 @@ export async function announcementsRoute(ctx: RequestContext) { } export async function productsRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const regionId = ctx.url.searchParams.get('regionId'); const params: unknown[] = [tenantId]; const filters = ['tenant_id = $1', `status = 'active'`]; @@ -548,7 +535,7 @@ export async function productsRoute(ctx: RequestContext) { } export async function timelinesRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const regionId = ctx.url.searchParams.get('regionId'); const schoolId = ctx.url.searchParams.get('schoolId'); const params: unknown[] = [tenantId]; @@ -580,7 +567,7 @@ export async function timelinesRoute(ctx: RequestContext) { } export async function svipPlansRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const regionId = ctx.url.searchParams.get('regionId'); const params: unknown[] = [tenantId]; const filters = ['tenant_id = $1', 'is_active = true']; diff --git a/apps/api/src/features/commerce/routes.ts b/apps/api/src/features/commerce/routes.ts index c04b23ef..79d08a01 100644 --- a/apps/api/src/features/commerce/routes.ts +++ b/apps/api/src/features/commerce/routes.ts @@ -45,8 +45,8 @@ interface EntitlementRow { export async function createOrderRoute(ctx: RequestContext) { const body = await readJsonBody(ctx); - const tenantId = tenantIdFrom(ctx); - const userId = userIdFrom(ctx, body); + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx, body); const planId = requiredString(body, 'planId'); const quantity = Math.max(1, optionalInteger(body, 'quantity', 1)); const payMethod = optionalString(body, 'payMethod') || 'manual'; @@ -133,8 +133,8 @@ export async function createOrderRoute(ctx: RequestContext) { } export async function ordersRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); - const userId = userIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx); const limit = intParam(ctx, 'limit', 50, 200); const items = await query( @@ -157,8 +157,8 @@ export async function ordersRoute(ctx: RequestContext) { } export async function entitlementsRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); - const userId = userIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx); const now = new Date().toISOString(); const items: EntitlementRow[] = await query( @@ -217,8 +217,8 @@ export async function entitlementsRoute(ctx: RequestContext) { } export async function entitlementCheckRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); - const userId = userIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx); const regionId = ctx.url.searchParams.get('regionId') || ''; const now = new Date().toISOString(); @@ -255,7 +255,7 @@ export async function entitlementCheckRoute(ctx: RequestContext) { export async function confirmManualPaymentRoute(ctx: RequestContext) { const body = await readJsonBody(ctx); - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const orderNo = requiredString(body, 'orderNo'); const providerTradeNo = optionalString(body, 'providerTradeNo') || `manual-${orderNo}`; const amountCents = optionalInteger(body, 'amountCents', -1); @@ -324,8 +324,8 @@ export async function confirmManualPaymentRoute(ctx: RequestContext) { export async function redeemActivationCodeRoute(ctx: RequestContext) { const body = await readJsonBody(ctx); - const tenantId = tenantIdFrom(ctx); - const userId = userIdFrom(ctx, body); + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx, body); const code = requiredString(body, 'code'); const regionId = optionalString(body, 'regionId') || null; diff --git a/apps/api/src/features/learning/routes.ts b/apps/api/src/features/learning/routes.ts index 46747abf..7738c08c 100644 --- a/apps/api/src/features/learning/routes.ts +++ b/apps/api/src/features/learning/routes.ts @@ -342,8 +342,8 @@ async function assembleQuestionIds(tenantId: string, assembly: PracticeAssembly) export async function createPracticeSessionRoute(ctx: RequestContext) { const body = await readJsonBody(ctx); - const tenantId = tenantIdFrom(ctx); - const userId = userIdFrom(ctx, body); + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx, body); const assembly = await buildPracticeAssembly(tenantId, body); const questionIds = await assembleQuestionIds(tenantId, assembly); @@ -404,8 +404,8 @@ export async function createPracticeSessionRoute(ctx: RequestContext) { export async function submitAnswerRoute(ctx: RequestContext) { const body = await readJsonBody(ctx); - const tenantId = tenantIdFrom(ctx); - const userId = userIdFrom(ctx, body); + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx, body); const questionId = requiredString(body, 'questionId'); const selectedOptions = optionalStringArray(body, 'selectedOptions'); const answerText = optionalString(body, 'answerText'); @@ -489,8 +489,8 @@ export async function submitAnswerRoute(ctx: RequestContext) { } export async function favoriteQuestionsRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); - const userId = userIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx); const limit = intParam(ctx, 'limit', 100, 500); const items = await query( @@ -513,8 +513,8 @@ export async function favoriteQuestionsRoute(ctx: RequestContext) { export async function toggleFavoriteQuestionRoute(ctx: RequestContext) { const body = await readJsonBody(ctx); - const tenantId = tenantIdFrom(ctx); - const userId = userIdFrom(ctx, body); + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx, body); const questionId = requiredString(body, 'questionId'); const favorite = body.favorite !== false; @@ -541,8 +541,8 @@ export async function toggleFavoriteQuestionRoute(ctx: RequestContext) { } export async function wrongQuestionsRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); - const userId = userIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx); const unresolvedOnly = stringParam(ctx, 'status') !== 'all'; const limit = intParam(ctx, 'limit', 100, 500); @@ -568,8 +568,8 @@ export async function wrongQuestionsRoute(ctx: RequestContext) { export async function resolveWrongQuestionRoute(ctx: RequestContext) { const body = await readJsonBody(ctx); - const tenantId = tenantIdFrom(ctx); - const userId = userIdFrom(ctx, body); + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx, body); const questionId = requiredString(body, 'questionId'); await query( @@ -590,8 +590,8 @@ function normalizeWordStatus(value: string) { } export async function wordProgressRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); - const userId = userIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx); const unitId = stringParam(ctx, 'unitId'); const status = stringParam(ctx, 'status'); const limit = intParam(ctx, 'limit', 500, 2000); @@ -619,8 +619,8 @@ export async function wordProgressRoute(ctx: RequestContext) { export async function updateWordProgressRoute(ctx: RequestContext) { const body = await readJsonBody(ctx); - const tenantId = tenantIdFrom(ctx); - const userId = userIdFrom(ctx, body); + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx, body); const wordId = requiredString(body, 'wordId'); const status = normalizeWordStatus(optionalString(body, 'status') || 'learning'); const correctDelta = Math.max(0, optionalInteger(body, 'correctDelta', status === 'mastered' ? 1 : 0)); @@ -680,8 +680,8 @@ export async function updateWordProgressRoute(ctx: RequestContext) { } export async function favoriteWordsRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); - const userId = userIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx); const unitId = stringParam(ctx, 'unitId'); const limit = intParam(ctx, 'limit', 500, 2000); @@ -707,8 +707,8 @@ export async function favoriteWordsRoute(ctx: RequestContext) { export async function toggleFavoriteWordRoute(ctx: RequestContext) { const body = await readJsonBody(ctx); - const tenantId = tenantIdFrom(ctx); - const userId = userIdFrom(ctx, body); + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx, body); const wordId = requiredString(body, 'wordId'); const favorite = body.favorite !== false; const note = optionalString(body, 'note') || null; @@ -739,8 +739,8 @@ export async function toggleFavoriteWordRoute(ctx: RequestContext) { } export async function wordStatsRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); - const userId = userIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx); const unitId = stringParam(ctx, 'unitId'); const item = await queryOne( diff --git a/apps/api/src/features/platform-admin/routes.ts b/apps/api/src/features/platform-admin/routes.ts index 4f746755..a949c235 100644 --- a/apps/api/src/features/platform-admin/routes.ts +++ b/apps/api/src/features/platform-admin/routes.ts @@ -33,7 +33,7 @@ function toDateText(value: unknown) { } export async function platformOverviewRoute(ctx: RequestContext) { - requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx); const [tenantStats, invoiceStats, subscriptionStats, usageStats] = await Promise.all([ queryOne<{ @@ -110,7 +110,7 @@ export async function platformOverviewRoute(ctx: RequestContext) { } export async function platformPlansRoute(ctx: RequestContext) { - requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx); const includeArchived = listQuery(ctx, 'includeArchived') === 'true'; const items = await query( @@ -130,7 +130,7 @@ export async function platformPlansRoute(ctx: RequestContext) { } export async function tenantsRoute(ctx: RequestContext) { - requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx); const status = listQuery(ctx, 'status'); const billingStatus = listQuery(ctx, 'billingStatus'); @@ -178,7 +178,7 @@ export async function tenantsRoute(ctx: RequestContext) { } export async function tenantDetailRoute(ctx: RequestContext) { - requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx); const tenantId = ctx.url.searchParams.get('tenantId') || ''; if (!tenantId) throw new HttpError(400, 'tenantId is required', 'TENANT_ID_REQUIRED'); @@ -259,7 +259,7 @@ export async function tenantDetailRoute(ctx: RequestContext) { } export async function createTenantRoute(ctx: RequestContext) { - requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx); const body = await readJsonBody(ctx); const slug = normalizeSlug(requiredString(body, 'slug')); @@ -388,7 +388,7 @@ export async function createTenantRoute(ctx: RequestContext) { } export async function updateTenantStatusRoute(ctx: RequestContext) { - requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx); const body = await readJsonBody(ctx); const tenantId = requiredString(body, 'tenantId'); @@ -415,7 +415,7 @@ export async function updateTenantStatusRoute(ctx: RequestContext) { } export async function upsertBillingProfileRoute(ctx: RequestContext) { - requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx); const body = await readJsonBody(ctx); const tenantId = requiredString(body, 'tenantId'); @@ -467,7 +467,7 @@ export async function upsertBillingProfileRoute(ctx: RequestContext) { } export async function createSubscriptionRoute(ctx: RequestContext) { - requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx); const body = await readJsonBody(ctx); const tenantId = requiredString(body, 'tenantId'); @@ -530,7 +530,7 @@ export async function createSubscriptionRoute(ctx: RequestContext) { } export async function tenantInvoicesRoute(ctx: RequestContext) { - requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx); const tenantId = ctx.url.searchParams.get('tenantId') || ''; const status = listQuery(ctx, 'status'); @@ -650,7 +650,7 @@ async function createInvoiceRecord(input: CreateInvoiceInput) { } export async function createInvoiceRoute(ctx: RequestContext) { - requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx); const body = await readJsonBody(ctx); const tenantId = requiredString(body, 'tenantId'); @@ -676,7 +676,7 @@ export async function createInvoiceRoute(ctx: RequestContext) { } export async function confirmInvoicePaymentRoute(ctx: RequestContext) { - requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx); const body = await readJsonBody(ctx); const tenantId = requiredString(body, 'tenantId'); @@ -761,7 +761,7 @@ export async function confirmInvoicePaymentRoute(ctx: RequestContext) { } export async function recordUsageRoute(ctx: RequestContext) { - requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx); const body = await readJsonBody(ctx); const tenantId = requiredString(body, 'tenantId'); @@ -788,7 +788,7 @@ export async function recordUsageRoute(ctx: RequestContext) { } export async function tenantUsageRoute(ctx: RequestContext) { - requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx); const tenantId = ctx.url.searchParams.get('tenantId') || ''; const limit = intParam(ctx, 'limit', 100, 500); @@ -812,7 +812,7 @@ export async function tenantUsageRoute(ctx: RequestContext) { } export async function createTenantInvoiceFromSubscriptionRoute(ctx: RequestContext) { - requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx); const body = await readJsonBody(ctx); const tenantId = requiredString(body, 'tenantId'); diff --git a/apps/api/src/features/profile/routes.ts b/apps/api/src/features/profile/routes.ts index d4e4d36e..b15fbd83 100644 --- a/apps/api/src/features/profile/routes.ts +++ b/apps/api/src/features/profile/routes.ts @@ -40,8 +40,8 @@ function jsonArrayBodyValue(value: unknown) { } export async function profileMeRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); - const userId = userIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx); const limit = intParam(ctx, 'recentLimit', 8, 50); const profile = await queryOne( @@ -198,8 +198,8 @@ export async function profileMeRoute(ctx: RequestContext) { export async function updateProfileMeRoute(ctx: RequestContext) { const body = await readJsonBody(ctx); - const tenantId = tenantIdFrom(ctx); - const userId = userIdFrom(ctx, body); + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx, body); const name = optionalString(body, 'name') || null; const avatarUrl = optionalString(body, 'avatarUrl') || null; diff --git a/apps/api/src/features/referral/routes.ts b/apps/api/src/features/referral/routes.ts index 5808b40b..12f7aaa4 100644 --- a/apps/api/src/features/referral/routes.ts +++ b/apps/api/src/features/referral/routes.ts @@ -1,7 +1,7 @@ import { randomBytes } from 'node:crypto'; import type pg from 'pg'; -import { HttpError, getHeader, type RequestContext } from '../../core/http.js'; -import { intParam, optionalString, readJsonBody, requiredString, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js'; +import { HttpError, type RequestContext } from '../../core/http.js'; +import { intParam, optionalString, optionalUserIdFrom, readJsonBody, requiredString, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js'; import { query, queryOne, transaction } from '../../core/db.js'; import { clientIpFrom, userAgentFrom } from '../auth/service.js'; import { hasTenantPermission, requireTenantAdmin, requireTenantPermission, type TenantAdminAuth } from '../tenant-admin/auth.js'; @@ -334,8 +334,8 @@ function crmPermission(auth: TenantAdminAuth) { } export async function referralInviteCodeRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); - const userId = userIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx); const item = await transaction(async client => { const activeMember = await userHasTenantMembership(client, tenantId, userId); @@ -350,7 +350,7 @@ export async function referralInviteCodeRoute(ctx: RequestContext) { } export async function referralResolveRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const body = await readJsonBody(ctx); const code = optionalString(body, 'code'); if (!code) return { valid: false }; @@ -368,12 +368,14 @@ export async function referralResolveRoute(ctx: RequestContext) { } export async function referralTrackEventRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const body = await readJsonBody(ctx); const eventType = optionalChoice(body.eventType, EVENT_TYPES, 'enter'); const source = optionalChoice(body.source, TRACK_SOURCES, 'unknown'); const refCode = normalizeCode(requiredString(body, 'refCode')); - const targetUserId = nullableString(body.targetUserId) || nullableString(body.userId) || getHeader(ctx.req, 'x-user-id') || null; + const targetUserId = await optionalUserIdFrom(ctx, { + userId: nullableString(body.targetUserId) || nullableString(body.userId) || undefined, + }) || null; const referrer = await resolveReferralCode(tenantId, refCode); const result = await transaction(async client => { @@ -431,9 +433,9 @@ export async function referralTrackEventRoute(ctx: RequestContext) { } export async function referralBindRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const body = await readJsonBody(ctx); - const studentUserId = userIdFrom(ctx, body); + const studentUserId = await userIdFrom(ctx, body); const refCode = normalizeCode(requiredString(body, 'refCode')); const referrer = await resolveReferralCode(tenantId, refCode); if (!referrer) throw new HttpError(404, 'Referral code not found', 'REFERRAL_CODE_NOT_FOUND'); @@ -772,8 +774,8 @@ export async function upsertReferralTeamRoute(ctx: RequestContext) { } export async function referralQrcodeRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); - const userId = userIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx); const body = await readJsonBody(ctx); const page = optionalString(body, 'page') || 'pages/index/index'; const provider = optionalString(body, 'provider') || 'wechat-miniapp'; diff --git a/apps/api/src/features/scoreline/routes.ts b/apps/api/src/features/scoreline/routes.ts index 27464c07..56edb8e4 100644 --- a/apps/api/src/features/scoreline/routes.ts +++ b/apps/api/src/features/scoreline/routes.ts @@ -3,7 +3,7 @@ import { intParam, stringParam, tenantIdFrom } from '../../core/request.js'; import { query, queryOne } from '../../core/db.js'; export async function scorelineFieldsRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const regionId = stringParam(ctx, 'regionId'); const items = await query( @@ -26,7 +26,7 @@ export async function scorelineFieldsRoute(ctx: RequestContext) { } export async function scorelineSchoolsRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const regionId = stringParam(ctx, 'regionId'); const q = stringParam(ctx, 'q'); const limit = intParam(ctx, 'limit', 200, 1000); @@ -51,7 +51,7 @@ export async function scorelineSchoolsRoute(ctx: RequestContext) { } export async function scorelineMajorsRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const regionId = stringParam(ctx, 'regionId'); const schoolId = stringParam(ctx, 'schoolId'); const limit = intParam(ctx, 'limit', 500, 2000); @@ -76,7 +76,7 @@ export async function scorelineMajorsRoute(ctx: RequestContext) { } export async function scorelineRecordsRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const regionId = stringParam(ctx, 'regionId'); const schoolId = stringParam(ctx, 'schoolId'); const majorId = stringParam(ctx, 'majorId'); @@ -121,7 +121,7 @@ export async function scorelineRecordsRoute(ctx: RequestContext) { } export async function scorelineTrendRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const regionId = stringParam(ctx, 'regionId'); const schoolId = stringParam(ctx, 'schoolId'); const majorId = stringParam(ctx, 'majorId'); @@ -147,7 +147,7 @@ export async function scorelineTrendRoute(ctx: RequestContext) { } export async function scorelineYearsRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const regionId = stringParam(ctx, 'regionId'); const items = await query<{ year: number }>( diff --git a/apps/api/src/features/tenant-admin/auth.ts b/apps/api/src/features/tenant-admin/auth.ts index 473e7d30..c79b571d 100644 --- a/apps/api/src/features/tenant-admin/auth.ts +++ b/apps/api/src/features/tenant-admin/auth.ts @@ -101,8 +101,8 @@ export function tenantPermissionCatalog() { } export async function requireTenantAdmin(ctx: RequestContext): Promise { - const tenantId = tenantIdFrom(ctx); - const userId = userIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx); const membership = await queryOne<{ role: string; permissions: Record }>( ` diff --git a/apps/api/src/features/tenant-content/auth.ts b/apps/api/src/features/tenant-content/auth.ts index b6fd37a8..9b3b84c3 100644 --- a/apps/api/src/features/tenant-content/auth.ts +++ b/apps/api/src/features/tenant-content/auth.ts @@ -12,8 +12,8 @@ export interface TenantContentAuth { } export async function requireTenantContentEditor(ctx: RequestContext): Promise { - const tenantId = tenantIdFrom(ctx); - const userId = userIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx); const membership = await queryOne<{ role: string; permissions: Record }>( ` diff --git a/apps/api/src/features/video/routes.ts b/apps/api/src/features/video/routes.ts index e84b3db9..8123d150 100644 --- a/apps/api/src/features/video/routes.ts +++ b/apps/api/src/features/video/routes.ts @@ -34,7 +34,7 @@ function videoSelectSql() { } export async function questionVideosRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const questionId = stringParam(ctx, 'questionId'); if (!questionId) { throw new HttpError(400, 'questionId is required', 'QUESTION_ID_REQUIRED'); @@ -54,7 +54,7 @@ export async function questionVideosRoute(ctx: RequestContext) { export async function questionVideosBatchRoute(ctx: RequestContext) { const body = await readJsonBody(ctx); - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const questionIds = optionalStringArray(body, 'questionIds').slice(0, 50); if (!questionIds.length) { @@ -80,7 +80,7 @@ export async function questionVideosBatchRoute(ctx: RequestContext) { } export async function videoSearchRoute(ctx: RequestContext) { - const tenantId = tenantIdFrom(ctx); + const tenantId = await tenantIdFrom(ctx); const subjectId = stringParam(ctx, 'subjectId'); const tags = stringParam(ctx, 'tags') .split(',') diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 9e14f3d3..3a11f74d 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -15,7 +15,7 @@ | 模块 | 状态 | 说明 | | --- | --- | --- | | Supabase/PostgreSQL schema | 可联调 | `supabase/migrations` 已包含多租户、题库、学习、订单、内容、CRM、平台账务等表 | -| RLS/租户隔离 | 迁移期 | 表层普遍有 `tenant_id` 和 RLS 策略,但 API 目前使用服务端连接,生产前要补真实 JWT/RLS 回归 | +| RLS/租户隔离 | 迁移期 | 表层普遍有 `tenant_id` 和 RLS 策略,API 已接入 session 优先身份上下文;生产前继续补 Supabase JWT/RLS 回归 | | API 分层 | 可联调 | `apps/api/src/core` + `apps/api/src/features/*` | | Docker API | 可联调 | `docker-compose.api.yml` 和 `apps/api/Dockerfile` 可用 | | 测试 | 可联调 | `npm run check:refactor` 覆盖 TS 检查、导入校验、seed、API 集成测试 | @@ -36,9 +36,9 @@ | 能力 | 状态 | 说明 | | --- | --- | --- | | 短信验证码登录 | 迁移期 | 已有验证码、冷却、hash、登录事件;mock provider 可本地联调 | -| 迁移期 session | 迁移期 | `tk_` token hash 存在 `app_private.auth_sessions` | +| 迁移期 session | 迁移期 | `tk_` token hash 存在 `app_private.auth_sessions`,用户态接口已优先解析 bearer session 并拒绝伪造 userId/tenantId | | 微信/QQ OAuth | 待补齐 | 目前是 placeholder | -| 平台管理员鉴权 | 迁移期 | 当前用 `x-platform-admin-key`,生产前必须换 JWT/服务端会话 | +| 平台管理员鉴权 | 迁移期 | `x-platform-admin-key` 已可通过配置禁用;生产前必须换平台管理员 JWT/服务端会话 | | 租户角色权限 | 可联调 | `tenant_memberships.role + permissions`,接口有权限点校验 | | 自定义角色模板 | 待补齐 | 当前有权限 JSON 覆盖,缺角色模板、菜单/模块/字段级权限配置 UI/API | @@ -138,7 +138,7 @@ ## 当前验证 -最近已通过: +最近需通过: ```bash npm audit @@ -153,4 +153,3 @@ npm run check:refactor - smoke seed - API build - API integration tests - diff --git a/docs/refactor/multitenant-auth-security-contract.md b/docs/refactor/multitenant-auth-security-contract.md index 671a2860..fb23746d 100644 --- a/docs/refactor/multitenant-auth-security-contract.md +++ b/docs/refactor/multitenant-auth-security-contract.md @@ -17,13 +17,16 @@ ## 当前迁移期状态 -当前后端仍存在这些迁移期实现: +当前后端已经进入“session 优先、迁移头受控兼容”的状态: -- `x-tenant-id` 用于租户上下文。 -- `x-user-id` 或 body/query 的 `userId` 用于用户上下文。 -- `x-platform-admin-key` 用于平台管理员接口。 +- `Authorization: Bearer ` 会优先解析 `app_private.auth_sessions`,并作为用户身份来源。 +- 登录后如果请求中的 `x-user-id`、query/body `userId` 与 session 用户不一致,后端返回 `AUTH_USER_MISMATCH`。 +- 登录后如果请求中的 `x-tenant-id` 与 session 租户不一致,后端返回 `AUTH_TENANT_MISMATCH`。 +- 带了无效 bearer token 的用户态接口不会回退到 `x-user-id`。 +- `x-user-id` 或 body/query 的 `userId` 只允许在 `ALLOW_LEGACY_AUTH_HEADERS=true` 的本地/迁移期环境使用。 +- `x-platform-admin-key` 只允许在 `ALLOW_PLATFORM_ADMIN_KEY=true` 的本地/迁移期环境使用。 - 本地短信 provider 可使用 `mock`。 -- 默认开发密钥存在于 `.env.example` 和 config fallback。 +- `NODE_ENV=production` 下禁止 `ALLOW_LEGACY_AUTH_HEADERS=true`、`ALLOW_PLATFORM_ADMIN_KEY=true`、`AUTH_SMS_PROVIDER=mock`、默认/弱密钥和 `CORS_ORIGIN=*`。 这些只允许用于本地开发和内网联调,不允许作为正式云端验收方案。 @@ -32,9 +35,10 @@ Supabase 官方允许前端用 Data API 访问数据,但前提是 RLS、最小 ## P0:正式云端测试前必须完成 1. 正式用户鉴权 - - 使用 Supabase Auth/JWT 或服务端 session 解析可信 userId。 - - 禁止前端通过 query/body/header 指定 userId。 - - `GET /api/auth/me` 返回当前用户、租户成员、角色、权限。 + - 已支持服务端 session 解析可信 userId。 + - 生产前继续接 Supabase Auth/JWT,或将现有 server session 明确作为正式方案。 + - 前端禁止通过 query/body/header 指定 userId。 + - `GET /api/auth/me` 后续要补租户成员、角色、权限返回。 2. 正式租户上下文 - H5 可由域名解析租户。 @@ -43,7 +47,8 @@ Supabase 官方允许前端用 Data API 访问数据,但前提是 RLS、最小 - 跨租户请求必须返回 403 或 404。 3. 平台管理员鉴权 - - 替换 `x-platform-admin-key`。 + - `x-platform-admin-key` 已可通过 `ALLOW_PLATFORM_ADMIN_KEY=false` 禁用。 + - 生产前仍需替换为平台管理员 JWT/session 和审计日志。 - 平台管理员也要有 JWT/session、角色、审计日志。 4. 生产配置 fail-fast @@ -52,6 +57,8 @@ Supabase 官方允许前端用 Data API 访问数据,但前提是 RLS、最小 - 禁止默认 `PLATFORM_ADMIN_API_KEY`。 - 禁止 `CORS_ORIGIN=*`。 - 禁止 `AUTH_SMS_PROVIDER=mock`。 + - 禁止 `ALLOW_LEGACY_AUTH_HEADERS=true`。 + - 禁止 `ALLOW_PLATFORM_ADMIN_KEY=true`。 5. 请求体大小限制 - 普通 JSON API 必须有默认上限。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 125bd2b2..f1ece8e0 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -55,12 +55,11 @@ F:\project\参考\旧题库项目\src 前端应封装一个统一 API client,所有页面禁止直接散写 `Taro.request`。 -迁移期请求头: +本地迁移期仍可兼容旧请求头,但新的 Taro 请求封装必须按下面目标实现: ```text Authorization: Bearer -x-tenant-id: -x-user-id: +x-tenant-id: # 仅作为登录前/公开目录租户上下文;登录后必须与 session 租户一致 ``` 生产目标: @@ -69,7 +68,16 @@ x-user-id: Authorization: Bearer ``` -生产后不应再由前端传 `x-user-id`。租户可以由可信 JWT claim、服务端 session、域名解析结果共同确定;前端传入的租户参数只能作为路由/展示上下文,不能作为安全依据。 +前端不应再传 `x-user-id`、query/body `userId` 来表示当前用户。后端已经实现 session 优先解析:如果 Authorization 存在,用户态接口以 session 用户为准;如果请求里伪造了不同的 `userId` 会返回 `AUTH_USER_MISMATCH`,伪造不同租户会返回 `AUTH_TENANT_MISMATCH`。 + +生产或云端测试建议设置: + +```text +ALLOW_LEGACY_AUTH_HEADERS=false +ALLOW_PLATFORM_ADMIN_KEY=false +``` + +这样旧式 `x-user-id` 和平台管理 key 会被拒绝,前端可以提前发现未按 session 接入的页面。 前端环境变量只允许包含: diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 8ea5ed5a..314c7d99 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -34,9 +34,15 @@ const ids = { let apiBase = process.env.API_BASE || 'http://127.0.0.1:8787'; let serverProcess = null; let serverLogs = ''; +let legacyDisabledServer = null; +let legacyDisabledServerLogs = ''; function buildUrl(path, query = {}) { - const target = new URL(path, apiBase); + return buildUrlAt(apiBase, path, query); +} + +function buildUrlAt(baseUrl, path, query = {}) { + const target = new URL(path, baseUrl); for (const [key, value] of Object.entries(query)) { if (value !== undefined && value !== null && value !== '') { target.searchParams.set(key, String(value)); @@ -68,6 +74,29 @@ async function request(path, options = {}) { return payload; } +async function requestAt(baseUrl, path, options = {}) { + const response = await fetch(buildUrlAt(baseUrl, path, options.query), { + method: options.method || 'GET', + headers: { + 'content-type': 'application/json', + ...(options.tenantId === false ? {} : { 'x-tenant-id': options.tenantId || MAIN_TENANT_ID }), + ...(options.userId === false ? {} : { 'x-user-id': options.userId || USER_ID }), + ...(options.headers || {}), + }, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + + const payload = await response.json().catch(() => ({})); + if (options.expectStatus) { + assert.equal(response.status, options.expectStatus, `${options.method || 'GET'} ${path} should return ${options.expectStatus}`); + return payload; + } + if (!response.ok) { + throw new Error(`${options.method || 'GET'} ${path} failed: ${response.status} ${JSON.stringify(payload)}`); + } + return payload; +} + async function check(name, fn) { await fn(); console.log(`[PASS] ${name}`); @@ -99,6 +128,21 @@ async function waitForHealth(timeoutMs = 12_000) { throw new Error(`API server did not become healthy. ${lastError?.message || ''}\n${serverLogs}`); } +async function waitForHealthAt(baseUrl, logsRef, timeoutMs = 12_000) { + const started = Date.now(); + let lastError = null; + while (Date.now() - started < timeoutMs) { + try { + const payload = await requestAt(baseUrl, '/health', { userId: false }); + if (payload.ok) return; + } catch (error) { + lastError = error; + } + await new Promise(resolve => setTimeout(resolve, 250)); + } + throw new Error(`API server did not become healthy. ${lastError?.message || ''}\n${logsRef()}`); +} + async function startServerIfNeeded() { if (!START_SERVER) return; const port = Number(process.env.TEST_API_PORT || 0) || await getFreePort(); @@ -126,6 +170,36 @@ async function startServerIfNeeded() { await waitForHealth(); } +async function startLegacyDisabledServer() { + const port = await getFreePort(); + const baseUrl = `http://127.0.0.1:${port}`; + legacyDisabledServerLogs = ''; + legacyDisabledServer = spawn(process.execPath, ['apps/api/dist/apps/api/src/server.js'], { + cwd: process.cwd(), + env: { + ...process.env, + PORT: String(port), + DATABASE_URL: process.env.DATABASE_URL || DEFAULT_DATABASE_URL, + MAX_JSON_BODY_BYTES: process.env.MAX_JSON_BODY_BYTES || '8192', + MAX_IMPORT_JSON_BODY_BYTES: process.env.MAX_IMPORT_JSON_BODY_BYTES || '65536', + ALLOW_LEGACY_AUTH_HEADERS: 'false', + ALLOW_PLATFORM_ADMIN_KEY: 'false', + }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + + legacyDisabledServer.stdout.on('data', chunk => { + legacyDisabledServerLogs += chunk.toString(); + }); + legacyDisabledServer.stderr.on('data', chunk => { + legacyDisabledServerLogs += chunk.toString(); + }); + + await waitForHealthAt(baseUrl, () => legacyDisabledServerLogs); + return baseUrl; +} + async function waitForProcessExit(child, timeoutMs = 5000) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -176,10 +250,122 @@ async function testProductionConfigFailFast() { assert.match(logs, /Invalid production API configuration/, 'production fail-fast should explain unsafe config'); } +async function loginBySms(phone = '13800000000') { + const sent = await request('/api/auth/sms/send', { + userId: false, + method: 'POST', + body: { phone, purpose: 'login' }, + }); + assert.ok(sent.debugCode, 'mock SMS login should expose debugCode in local tests'); + + const verified = await request('/api/auth/sms/verify', { + userId: false, + method: 'POST', + body: { phone, code: sent.debugCode, purpose: 'login' }, + }); + assert.ok(verified.session?.token, 'SMS verify should issue a session token'); + return verified; +} + +async function testTrustedSessionIdentity() { + const login = await loginBySms(); + assert.equal(login.user?.id, USER_ID, 'smoke phone should log in as smoke user'); + const authHeaders = { authorization: `Bearer ${login.session.token}` }; + + const me = await request('/api/auth/me', { + userId: false, + headers: authHeaders, + }); + assert.equal(me.user?.id, USER_ID, 'auth/me should resolve user from bearer session'); + + const profile = await request('/api/profile/me', { + userId: false, + headers: authHeaders, + }); + assert.equal(profile.item?.userId, USER_ID, 'profile should resolve user from bearer session without x-user-id'); + + const vocabularyProgress = await request('/api/learning/vocabulary/progress', { + userId: false, + headers: authHeaders, + method: 'POST', + body: { wordId: ids.vocabularyWord, status: 'learning', correctDelta: 0 }, + }); + assert.equal(vocabularyProgress.item?.wordId, ids.vocabularyWord, 'learning APIs should accept bearer session identity'); + const vocabularyProgressList = await request('/api/learning/vocabulary/progress', { + userId: false, + headers: authHeaders, + query: { unitId: ids.vocabularyUnit }, + }); + assert.ok( + vocabularyProgressList.items?.some(item => item.wordId === ids.vocabularyWord), + 'learning APIs should read progress through bearer session identity', + ); + + const spoofedUser = await request('/api/profile/me', { + userId: TENANT_ADMIN_USER_ID, + headers: authHeaders, + expectStatus: 403, + }); + assert.equal(spoofedUser.code, 'AUTH_USER_MISMATCH', 'session requests must reject spoofed x-user-id'); + + const spoofedBodyUser = await request('/api/learning/vocabulary/progress', { + userId: false, + headers: authHeaders, + method: 'POST', + body: { + userId: TENANT_ADMIN_USER_ID, + wordId: ids.vocabularyWord, + status: 'learning', + }, + expectStatus: 403, + }); + assert.equal(spoofedBodyUser.code, 'AUTH_USER_MISMATCH', 'session requests must reject spoofed body userId'); + + const spoofedTenant = await request('/api/profile/me', { + tenantId: PARTNER_TENANT_ID, + userId: false, + headers: authHeaders, + expectStatus: 403, + }); + assert.equal(spoofedTenant.code, 'AUTH_TENANT_MISMATCH', 'session requests must reject spoofed tenant'); + + const invalidSession = await request('/api/profile/me', { + headers: { authorization: 'Bearer tk_invalid_session_token' }, + expectStatus: 401, + }); + assert.equal(invalidSession.code, 'AUTH_SESSION_INVALID', 'invalid bearer token must not fall back to legacy user headers'); +} + +async function testLegacyAuthHeadersDisabled() { + const login = await loginBySms('13800000006'); + const baseUrl = await startLegacyDisabledServer(); + const authHeaders = { authorization: `Bearer ${login.session.token}` }; + + const legacyProfile = await requestAt(baseUrl, '/api/profile/me', { + expectStatus: 401, + }); + assert.equal(legacyProfile.code, 'TRUSTED_USER_REQUIRED', 'legacy x-user-id should be disabled when configured off'); + + const trustedProfile = await requestAt(baseUrl, '/api/profile/me', { + userId: false, + headers: authHeaders, + }); + assert.equal(trustedProfile.item?.userId, login.user.id, 'trusted session should still work when legacy headers are disabled'); + + const legacyAdminKey = await requestAt(baseUrl, '/api/platform-admin/overview', { + headers: { 'x-platform-admin-key': 'local-platform-admin-key' }, + expectStatus: 401, + }); + assert.equal(legacyAdminKey.code, 'PLATFORM_ADMIN_KEY_DISABLED', 'platform admin key should be disabled when configured off'); +} + function stopServer() { if (serverProcess && !serverProcess.killed) { serverProcess.kill(); } + if (legacyDisabledServer && !legacyDisabledServer.killed) { + legacyDisabledServer.kill(); + } } async function testCatalogAndLearning() { @@ -1769,6 +1955,8 @@ async function main() { console.log(`[INFO] API integration target: ${apiBase}`); await check('health', () => request('/health', { userId: false }).then(payload => assert.equal(payload.ok, true))); + await check('trusted session identity', testTrustedSessionIdentity); + await check('legacy auth headers disabled', testLegacyAuthHeadersDisabled); await check('catalog and learning', testCatalogAndLearning); await check('profile', testProfile); await check('scoreline', testScoreline);