diff --git a/.env.example b/.env.example index b19c28c7..4279381a 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,10 @@ 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 +# 生产推荐使用 Supabase Auth JWKS: +# AUTH_JWT_ISSUER=https://.supabase.co/auth/v1 +# AUTH_JWT_JWKS_URL=https://.supabase.co/auth/v1/.well-known/jwks.json +# 自托管或旧项目兼容才使用强随机 AUTH_JWT_SECRET;配置 JWKS 时必须同时配置 issuer。 AUTH_JWT_ISSUER= AUTH_JWT_AUDIENCE=authenticated AUTH_JWT_SECRET=development-jwt-secret-change-me diff --git a/apps/api/src/core/config.ts b/apps/api/src/core/config.ts index 57f1b6d7..d777115c 100644 --- a/apps/api/src/core/config.ts +++ b/apps/api/src/core/config.ts @@ -86,6 +86,9 @@ function validateProductionConfig(nextConfig: ApiConfig) { if (!nextConfig.authJwtJwksUrl && isUnsafeSecret(nextConfig.authJwtSecret, DEFAULT_AUTH_JWT_SECRET)) { failures.push('AUTH_JWT_SECRET or AUTH_JWT_JWKS_URL must be configured for production JWT verification'); } + if (nextConfig.authJwtJwksUrl && !nextConfig.authJwtIssuer.trim()) { + failures.push('AUTH_JWT_ISSUER is required when AUTH_JWT_JWKS_URL is configured'); + } 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'); } diff --git a/apps/api/src/features/commerce/adjustments.ts b/apps/api/src/features/commerce/adjustments.ts index 914bc2f3..3b3a1e52 100644 --- a/apps/api/src/features/commerce/adjustments.ts +++ b/apps/api/src/features/commerce/adjustments.ts @@ -783,7 +783,9 @@ export async function adjustmentVoucherReportRoute(ctx: RequestContext) { ` select status, count(*)::int as count, coalesce(sum(amount_cents), 0)::int as "amountCents" from public.commerce_adjustment_vouchers - where tenant_id = $1 and created_at >= $2::date and created_at < ($3::date + interval '1 day') + where tenant_id = $1 + and created_at >= ($2::date::timestamp at time zone 'Asia/Shanghai') + and created_at < (($3::date + interval '1 day')::timestamp at time zone 'Asia/Shanghai') group by status order by status `, @@ -794,7 +796,9 @@ export async function adjustmentVoucherReportRoute(ctx: RequestContext) { select adjustment_type as "adjustmentType", direction, count(*)::int as count, coalesce(sum(amount_cents), 0)::int as "amountCents" from public.commerce_adjustment_vouchers - where tenant_id = $1 and created_at >= $2::date and created_at < ($3::date + interval '1 day') + where tenant_id = $1 + and created_at >= ($2::date::timestamp at time zone 'Asia/Shanghai') + and created_at < (($3::date + interval '1 day')::timestamp at time zone 'Asia/Shanghai') group by adjustment_type, direction order by adjustment_type, direction `, @@ -805,7 +809,9 @@ export async function adjustmentVoucherReportRoute(ctx: RequestContext) { select source_type as "sourceType", count(*)::int as count, coalesce(sum(amount_cents), 0)::int as "amountCents" from public.commerce_adjustment_vouchers - where tenant_id = $1 and created_at >= $2::date and created_at < ($3::date + interval '1 day') + where tenant_id = $1 + and created_at >= ($2::date::timestamp at time zone 'Asia/Shanghai') + and created_at < (($3::date + interval '1 day')::timestamp at time zone 'Asia/Shanghai') group by source_type order by source_type `, @@ -813,11 +819,13 @@ export async function adjustmentVoucherReportRoute(ctx: RequestContext) { ), query>( ` - select created_at::date::text as date, status, count(*)::int as count, + select (created_at at time zone 'Asia/Shanghai')::date::text as date, status, count(*)::int as count, coalesce(sum(amount_cents), 0)::int as "amountCents" from public.commerce_adjustment_vouchers - where tenant_id = $1 and created_at >= $2::date and created_at < ($3::date + interval '1 day') - group by created_at::date, status + where tenant_id = $1 + and created_at >= ($2::date::timestamp at time zone 'Asia/Shanghai') + and created_at < (($3::date + interval '1 day')::timestamp at time zone 'Asia/Shanghai') + group by (created_at at time zone 'Asia/Shanghai')::date, status order by date asc, status `, [auth.tenantId, startDate, endDate], diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index 8a01124d..166c690a 100644 --- a/docs/refactor/backend-handoff-roadmap.md +++ b/docs/refactor/backend-handoff-roadmap.md @@ -75,7 +75,7 @@ ### P0:上云测试和前端主链路前必须处理 -- 生产鉴权:API 已支持 Supabase Auth JWT;继续做真实云端 Auth/JWKS 回归、RLS 深测,并在生产关闭 `x-user-id` 与 `x-platform-admin-key` 兼容入口。 +- 生产鉴权:API 已支持 Supabase Auth JWT、HS256 本地测试和 JWKS/RS256 集成回归;继续做真实云端 Auth/JWKS 回归、RLS 深测,并在生产关闭 `x-user-id` 与 `x-platform-admin-key` 兼容入口。生产配置 JWKS 时必须同时配置 `AUTH_JWT_ISSUER`。 - 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage;上传确认、PDF/图片预览签名、动态水印上下文、assets worker 复检、内置安全扫描、外部 HTTP scanner 接入层和题库导出 PDF/Word/每日一练 ZIP worker 已完成,继续补视频播放防盗链、真实 AV/内容安全服务联调和转码/CDN 级水印。 - 真实数据 dry-run:导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run`,再跑迁移和校验报告。 - 生产环境配置:`.env.example` 和 `npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。 diff --git a/docs/refactor/multitenant-auth-security-contract.md b/docs/refactor/multitenant-auth-security-contract.md index 9bfdb6c9..22ce4661 100644 --- a/docs/refactor/multitenant-auth-security-contract.md +++ b/docs/refactor/multitenant-auth-security-contract.md @@ -21,7 +21,7 @@ - `Authorization: Bearer ` 会优先解析 `app_private.auth_sessions`,并作为用户身份来源。 - `Authorization: Bearer ` 已支持服务端验签,后端通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射到业务用户和租户成员。 -- Supabase JWT 支持 `AUTH_JWT_SECRET` 或 `AUTH_JWT_JWKS_URL`;生产推荐优先配置 Supabase Auth JWKS,或在自托管兼容模式下配置强随机 JWT secret。 +- Supabase JWT 支持 `AUTH_JWT_SECRET` 或 `AUTH_JWT_JWKS_URL`;生产推荐优先配置 Supabase Auth JWKS 和 `AUTH_JWT_ISSUER`,或在自托管兼容模式下配置强随机 JWT secret。配置 JWKS 但缺少 issuer 会被生产 fail-fast 阻断。 - JWT 可以在 `app_metadata.tenant_id` 或请求租户上下文中确定当前租户;如果两者冲突,后端拒绝,不允许前端覆盖 token 中的租户声明。 - 登录后如果请求中的 `x-user-id`、query/body `userId` 与 session 用户不一致,后端返回 `AUTH_USER_MISMATCH`。 - 登录后如果请求中的 `x-tenant-id` 与 session 租户不一致,后端返回 `AUTH_TENANT_MISMATCH`。 @@ -42,6 +42,13 @@ Supabase 官方允许前端用 Data API 访问数据,但前提是 RLS、最小 1. 正式用户鉴权 - 已支持服务端 session 和 Supabase Auth JWT 解析可信 userId。 - 生产前必须用真实 Supabase Auth 项目或自托管 Auth 实例跑一轮云端 JWT 回归。 + - 生产推荐配置: + ```text + AUTH_JWT_JWKS_URL=https://.supabase.co/auth/v1/.well-known/jwks.json + AUTH_JWT_ISSUER=https://.supabase.co/auth/v1 + AUTH_JWT_AUDIENCE=authenticated + ``` + - `npm run test:api` 已覆盖本地 HS256 JWT 和本地 JWKS/RS256 验签路径;真实云端仍需用线上 Supabase access token 调 `GET /api/auth/me`、`GET /api/profile/me`、租户后台和平台后台各一条接口。 - 前端禁止通过 query/body/header 指定 userId。 - `GET /api/auth/me` 已支持 Supabase JWT;后续要补租户成员、角色、权限返回。 @@ -60,6 +67,7 @@ Supabase 官方允许前端用 Data API 访问数据,但前提是 RLS、最小 - `NODE_ENV=production` 时禁止默认 `AUTH_CODE_PEPPER`。 - 禁止默认 `AUTH_SESSION_SECRET`。 - 禁止默认 `AUTH_JWT_SECRET`,除非配置了 `AUTH_JWT_JWKS_URL`。 + - 配置 `AUTH_JWT_JWKS_URL` 时必须同时配置 `AUTH_JWT_ISSUER`。 - 禁止默认 `PLATFORM_ADMIN_API_KEY`。 - 禁止 `CORS_ORIGIN=*`。 - 禁止 `AUTH_SMS_PROVIDER=mock`。 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 50f12c62..45f356d3 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -4,7 +4,7 @@ import { spawn } from 'node:child_process'; import http from 'node:http'; import net from 'node:net'; import pg from 'pg'; -import { SignJWT } from 'jose'; +import { SignJWT, exportJWK } from 'jose'; import ExcelJS from 'exceljs'; const DEFAULT_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'; @@ -79,6 +79,9 @@ let serverProcess = null; let serverLogs = ''; let legacyDisabledServer = null; let legacyDisabledServerLogs = ''; +let jwksAuthServer = null; +let jwksAuthServerLogs = ''; +let jwksServer = null; let fakeWechatServer = null; let fakeQqServer = null; let fakeWechatPayServer = null; @@ -311,6 +314,64 @@ async function startLegacyDisabledServer() { return baseUrl; } +async function startJwksAuthServer(jwksUrl) { + const port = await getFreePort(); + const baseUrl = `http://127.0.0.1:${port}`; + jwksAuthServerLogs = ''; + jwksAuthServer = 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', + AUTH_JWT_JWKS_URL: jwksUrl, + AUTH_JWT_ISSUER: 'https://auth.gongxue100.test/auth/v1', + AUTH_JWT_AUDIENCE: 'authenticated', + ALLOW_LEGACY_AUTH_HEADERS: 'false', + ALLOW_PLATFORM_ADMIN_KEY: 'false', + }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + + jwksAuthServer.stdout.on('data', chunk => { + jwksAuthServerLogs += chunk.toString(); + }); + jwksAuthServer.stderr.on('data', chunk => { + jwksAuthServerLogs += chunk.toString(); + }); + + await waitForHealthAt(baseUrl, () => jwksAuthServerLogs); + return baseUrl; +} + +async function startLocalJwksServer(jwksPayload) { + const port = await getFreePort(); + const baseUrl = `http://127.0.0.1:${port}`; + jwksServer = http.createServer((req, res) => { + const url = new URL(req.url || '/', baseUrl); + if (url.pathname !== '/auth/v1/.well-known/jwks.json') { + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'not_found' })); + return; + } + res.writeHead(200, { + 'cache-control': 'public, max-age=600', + 'content-type': 'application/json', + }); + res.end(JSON.stringify(jwksPayload)); + }); + + await new Promise((resolve, reject) => { + jwksServer.once('error', reject); + jwksServer.listen(port, '127.0.0.1', resolve); + }); + + return `${baseUrl}/auth/v1/.well-known/jwks.json`; +} + async function startFakeWechatServer() { const port = await getFreePort(); const baseUrl = `http://127.0.0.1:${port}`; @@ -690,6 +751,30 @@ async function createSupabaseJwt(authUserId, options = {}) { .sign(secret); } +async function createSupabaseJwksJwt(authUserId, privateKey, options = {}) { + const now = Math.floor(Date.now() / 1000); + const claims = { + sub: authUserId, + aud: 'authenticated', + role: 'authenticated', + phone: options.phone || undefined, + app_metadata: { + provider: 'phone', + providers: ['phone'], + ...(options.appRole ? { app_role: options.appRole } : {}), + ...(options.tenantId === false ? {} : { tenant_id: options.tenantId || MAIN_TENANT_ID }), + }, + user_metadata: options.userMetadata || {}, + }; + + return new SignJWT(claims) + .setProtectedHeader({ alg: 'RS256', typ: 'JWT', kid: options.kid || 'local-jwks-key-1' }) + .setIssuer('https://auth.gongxue100.test/auth/v1') + .setIssuedAt(now) + .setExpirationTime(now + 60 * 60) + .sign(privateKey); +} + async function waitForProcessExit(child, timeoutMs = 5000) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -969,6 +1054,59 @@ async function testSupabaseJwtIdentity() { assert.equal(studentPlatformDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student Supabase JWT must not access platform APIs'); } +async function testSupabaseJwksIdentity() { + const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + const publicJwk = await exportJWK(publicKey); + publicJwk.kid = 'local-jwks-key-1'; + publicJwk.alg = 'RS256'; + publicJwk.use = 'sig'; + + const jwksUrl = await startLocalJwksServer({ keys: [publicJwk] }); + const baseUrl = await startJwksAuthServer(jwksUrl); + + const studentJwt = await createSupabaseJwksJwt(AUTH_USER_ID, privateKey, { phone: '13800000000' }); + const studentHeaders = { authorization: `Bearer ${studentJwt}` }; + const me = await requestAt(baseUrl, '/api/auth/me', { + userId: false, + headers: studentHeaders, + }); + assert.equal(me.user?.id, USER_ID, 'JWKS RS256 JWT should map Supabase auth user to platform user'); + assert.equal(me.session?.source, 'supabase_jwt', 'JWKS JWT auth/me should expose Supabase JWT session source'); + + const tenantHeaderJwt = await createSupabaseJwksJwt(AUTH_USER_ID, privateKey, { tenantId: false }); + const tenantHeaderProfile = await requestAt(baseUrl, '/api/profile/me', { + userId: false, + headers: { authorization: `Bearer ${tenantHeaderJwt}` }, + }); + assert.equal(tenantHeaderProfile.item?.userId, USER_ID, 'JWKS JWT without tenant claim should be scoped by x-tenant-id'); + + const tenantAdminJwt = await createSupabaseJwksJwt(AUTH_TENANT_ADMIN_USER_ID, privateKey, { phone: '13800000001' }); + const tenantOverview = await requestAt(baseUrl, '/api/tenant-admin/overview', { + userId: false, + headers: { authorization: `Bearer ${tenantAdminJwt}` }, + }); + assert.equal(tenantOverview.item?.id, MAIN_TENANT_ID, 'tenant admin should access own tenant through JWKS JWT'); + + const platformAdminJwt = await createSupabaseJwksJwt(AUTH_PLATFORM_ADMIN_USER_ID, privateKey, { + phone: '13999999999', + appRole: 'platform_admin', + }); + const platformOverview = await requestAt(baseUrl, '/api/platform-admin/overview', { + tenantId: false, + userId: false, + headers: { authorization: `Bearer ${platformAdminJwt}` }, + }); + assert.ok(platformOverview.item?.tenants?.total >= 1, 'platform admin should access platform overview through JWKS JWT'); + + const badKidJwt = await createSupabaseJwksJwt(AUTH_USER_ID, privateKey, { kid: 'unknown-key-id' }); + const badKid = await requestAt(baseUrl, '/api/profile/me', { + userId: false, + headers: { authorization: `Bearer ${badKidJwt}` }, + expectStatus: 401, + }); + assert.equal(badKid.code, 'AUTH_SESSION_INVALID', 'JWKS JWT with an unknown kid must be rejected'); +} + async function testLegacyAuthHeadersDisabled() { const login = await loginBySms('13800000006'); const baseUrl = await startLegacyDisabledServer(); @@ -999,6 +1137,13 @@ function stopServer() { if (legacyDisabledServer && !legacyDisabledServer.killed) { legacyDisabledServer.kill(); } + if (jwksAuthServer && !jwksAuthServer.killed) { + jwksAuthServer.kill(); + } + if (jwksServer) { + jwksServer.close(); + jwksServer = null; + } if (fakeWechatServer) { fakeWechatServer.close(); fakeWechatServer = null; @@ -7654,6 +7799,7 @@ async function main() { await check('trusted session identity', testTrustedSessionIdentity); await check('phone binding', testPhoneBinding); await check('Supabase JWT identity', testSupabaseJwtIdentity); + await check('Supabase JWKS JWT identity', testSupabaseJwksIdentity); await check('legacy auth headers disabled', testLegacyAuthHeadersDisabled); await check('catalog and learning', testCatalogAndLearning); await check('composite practice questions', testCompositePracticeQuestions); diff --git a/scripts/production-config-failfast-test.js b/scripts/production-config-failfast-test.js index 715b3d92..11a2307e 100644 --- a/scripts/production-config-failfast-test.js +++ b/scripts/production-config-failfast-test.js @@ -29,6 +29,7 @@ const safeApiEnv = { AUTH_CODE_PEPPER: 's3cure-prod-code-pepper-2026-06-30-abcdef', AUTH_SESSION_SECRET: 's3cure-prod-session-secret-2026-06-30-ghijkl', AUTH_JWT_JWKS_URL: 'https://auth.gongxue100.com/auth/v1/.well-known/jwks.json', + AUTH_JWT_ISSUER: 'https://auth.gongxue100.com/auth/v1', ALLOW_LEGACY_AUTH_HEADERS: 'false', ALLOW_PLATFORM_ADMIN_KEY: 'false', PLATFORM_ADMIN_API_KEY: 's3cure-platform-admin-key-2026-06-30-mnopqr', diff --git a/scripts/production-readiness-check-test.js b/scripts/production-readiness-check-test.js index 9e3b152c..78eaad8f 100644 --- a/scripts/production-readiness-check-test.js +++ b/scripts/production-readiness-check-test.js @@ -59,6 +59,7 @@ AUTH_SMS_PROVIDER=aliyun AUTH_CODE_PEPPER=${strongSecretA} AUTH_SESSION_SECRET=${strongSecretB} AUTH_JWT_JWKS_URL=https://auth.gongxue100.com/auth/v1/.well-known/jwks.json +AUTH_JWT_ISSUER=https://auth.gongxue100.com/auth/v1 ALLOW_LEGACY_AUTH_HEADERS=false ALLOW_PLATFORM_ADMIN_KEY=false PLATFORM_ADMIN_API_KEY=${strongSecretC} @@ -92,4 +93,34 @@ assert.ok( 'env-only readiness should explicitly warn that DB checks are skipped', ); +const missingJwksIssuer = runReadiness(` +NODE_ENV=production +DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku +CORS_ORIGIN=https://student.gongxue100.com +AUTH_SMS_PROVIDER=aliyun +AUTH_CODE_PEPPER=${strongSecretA} +AUTH_SESSION_SECRET=${strongSecretB} +AUTH_JWT_JWKS_URL=https://auth.gongxue100.com/auth/v1/.well-known/jwks.json +ALLOW_LEGACY_AUTH_HEADERS=false +ALLOW_PLATFORM_ADMIN_KEY=false +PLATFORM_ADMIN_API_KEY=${strongSecretC} +STORAGE_DEFAULT_PROVIDER=aliyun_oss +STORAGE_DEFAULT_BUCKET=tiku-assets +STORAGE_REQUIRE_TENANT_PREFIX=true +ALIYUN_OSS_REGION=cn-hangzhou +ALIYUN_OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com +ALIYUN_OSS_ACCESS_KEY_ID=LTAI_READINESS_TEST_ONLY +ALIYUN_OSS_ACCESS_KEY_SECRET=aliyun-readiness-secret-placeholder +WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http +WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=https://scanner.gongxue100.com/api/scan +WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=s3cure-asset-scanner-token-2026-06-29-stuvwx +WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false +`); + +assert.notEqual(missingJwksIssuer.status, 0, 'JWKS readiness without issuer should fail'); +assert.ok( + missingJwksIssuer.payload.checks?.some(item => item.id === 'env.auth_jwt_issuer' && item.status === 'blocker'), + 'JWKS readiness should block missing AUTH_JWT_ISSUER', +); + console.log('[PASS] production readiness check script'); diff --git a/scripts/production-readiness-check.js b/scripts/production-readiness-check.js index d2743600..78611e26 100644 --- a/scripts/production-readiness-check.js +++ b/scripts/production-readiness-check.js @@ -202,6 +202,11 @@ function validateEnv() { } else { pass('env.auth_jwt_jwks_url', 'AUTH_JWT_JWKS_URL is configured'); } + if (!env('AUTH_JWT_ISSUER', '').trim()) { + block('env.auth_jwt_issuer', 'AUTH_JWT_ISSUER is required when AUTH_JWT_JWKS_URL is configured'); + } else { + pass('env.auth_jwt_issuer', 'AUTH_JWT_ISSUER is configured for JWKS verification'); + } } else if (isUnsafeSecret(jwtSecret, DEFAULT_AUTH_JWT_SECRET)) { block('env.auth_jwt_secret', 'AUTH_JWT_SECRET or AUTH_JWT_JWKS_URL must be configured for production JWT verification'); } else {