diff --git a/README.md b/README.md index ebfe728c..874eeff7 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ 目前已经完成并在本地验证通过的内容: - Supabase/PostgreSQL 多租户数据库 schema、RLS、索引、触发器。 -- `apps/api` 独立业务 API,后续供 H5、Taro 小程序、管理后台统一调用。 +- `apps/api` 独立业务 API,后续供 H5、Taro 小程序、管理后台统一调用;已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口。 - 租户后台能力:品牌、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、激活码、优惠券、成员权限、审计日志。 - 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册 JSON 批量导入。 - 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、分数线、题目视频、订单、权益、激活码兑换、资料下载。 @@ -22,7 +22,7 @@ 还没有达到生产交付的部分: -- 正式 Supabase Auth/JWT 鉴权还没替换迁移期请求头。 +- Supabase Auth/JWT 已可联调;生产前还要做真实云端 Auth/JWKS 回归、RLS 深测和自定义角色权限细化。 - 真实短信、微信登录、QQ 登录、微信支付、支付宝等 provider adapter 还没接完。 - OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF 预览、防盗链和视频水印还没完成。 - Excel/CSV 导入、分数线/视频批量导入和异步 worker 还没完成。 @@ -143,13 +143,13 @@ apps/api/src/features/ video/ 题目视频讲解 ``` -迁移期 API 上下文目前使用: +API 身份上下文: -- `x-tenant-id` -- `x-user-id` -- `x-platform-admin-key` +- 推荐:`Authorization: Bearer `,可配合 `x-tenant-id` 提供当前租户上下文。 +- 本地/迁移期:`Authorization: Bearer `。 +- 兼容旧测试:`x-user-id`、`x-platform-admin-key` 仅允许在 `ALLOW_LEGACY_AUTH_HEADERS=true`、`ALLOW_PLATFORM_ADMIN_KEY=true` 的非生产环境使用。 -生产环境必须替换为 Supabase Auth/JWT 或服务端 session。 +生产环境必须设置 `ALLOW_LEGACY_AUTH_HEADERS=false` 和 `ALLOW_PLATFORM_ADMIN_KEY=false`,前端不能再传 `x-user-id` 代表当前用户。 ## 重要安全约定 @@ -174,8 +174,8 @@ npm run check:refactor 优先继续补: -1. Supabase Auth/JWT 正式鉴权、生产配置 fail-fast、请求体大小限制和租户隔离回归测试。 +1. 真实云端 Auth/JWKS 回归、RLS 深测、自定义角色模板和菜单/模块/字段级权限。 2. Taro 前端 scaffold,让 H5 和小程序共用同一套 API。 3. 对象存储上传后校验、PDF 预览、防盗链和视频水印。 4. Excel/CSV 以及分数线、视频批量导入;把现有 JSON 导入升级为可排队异步执行。 -5. 微信/QQ 登录、短信、微信支付、支付宝支付 adapter。 +5. 微信网页/QQ 登录、退款对账、CRM worker、公共题库授权和租户采纳。 diff --git a/apps/api/package.json b/apps/api/package.json index a57b5cbc..d90f76b9 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,6 +12,7 @@ "dependencies": { "@supabase/storage-js": "^2.108.2", "ali-oss": "^6.23.0", + "jose": "^6.2.3", "pg": "^8.16.3" }, "devDependencies": { diff --git a/apps/api/src/core/auth-context.ts b/apps/api/src/core/auth-context.ts index 2d4a59f1..dfb3342e 100644 --- a/apps/api/src/core/auth-context.ts +++ b/apps/api/src/core/auth-context.ts @@ -1,4 +1,5 @@ import crypto from 'node:crypto'; +import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'; import { config } from './config.js'; import { queryOne } from './db.js'; import { getHeader, type RequestContext } from './http.js'; @@ -11,9 +12,11 @@ export interface SessionIdentity { avatarUrl: string | null; primaryRole: string; createdAt: string; - tenantId: string; + tenantId: string | null; sessionId: string; sessionExpiresAt: string; + authSource: 'app_session' | 'supabase_jwt'; + authUserId: string | null; } interface RequestAuthState { @@ -23,6 +26,7 @@ interface RequestAuthState { } const requestAuthState = new WeakMap(); +let remoteJwks: ReturnType | null = null; export function bearerTokenFrom(ctx: RequestContext) { const authorization = getHeader(ctx.req, 'authorization'); @@ -30,6 +34,10 @@ export function bearerTokenFrom(ctx: RequestContext) { return match?.[1]?.trim() || ''; } +function tenantContextFrom(ctx: RequestContext) { + return getHeader(ctx.req, 'x-tenant-id') || ctx.url.searchParams.get('tenantId') || ''; +} + export function hashSessionToken(token: string) { return crypto.createHmac('sha256', config.authSessionSecret).update(token).digest('hex'); } @@ -40,7 +48,9 @@ export async function findUserBySessionToken(token: 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" + s.tenant_id as "tenantId", s.id as "sessionId", s.expires_at as "sessionExpiresAt", + 'app_session'::text as "authSource", + u.auth_user_id as "authUserId" from app_private.auth_sessions s join public.platform_users u on u.id = s.user_id where s.token_hash = $1 @@ -52,12 +62,140 @@ export async function findUserBySessionToken(token: string) { ); } +function expectedIssuer() { + return config.authJwtIssuer || undefined; +} + +function expectedAudience() { + return config.authJwtAudience || undefined; +} + +function symmetricJwtSecret() { + return new TextEncoder().encode(config.authJwtSecret); +} + +function jwksKeySet() { + if (!config.authJwtJwksUrl) return null; + if (!remoteJwks) remoteJwks = createRemoteJWKSet(new URL(config.authJwtJwksUrl)); + return remoteJwks; +} + +async function verifySupabaseJwt(token: string) { + const verifyOptions = { + issuer: expectedIssuer(), + audience: expectedAudience(), + }; + const jwks = jwksKeySet(); + const verified = jwks + ? await jwtVerify(token, jwks, verifyOptions) + : await jwtVerify(token, symmetricJwtSecret(), verifyOptions); + return verified.payload; +} + +function objectClaim(payload: JWTPayload, key: string) { + const value = payload[key]; + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} + +function tenantClaimFrom(payload: JWTPayload) { + const claim = payload.tenant_id || objectClaim(payload, 'app_metadata').tenant_id || objectClaim(payload, 'user_metadata').tenant_id; + return typeof claim === 'string' && claim.trim() ? claim.trim() : ''; +} + +function appRoleClaimFrom(payload: JWTPayload) { + const claim = payload.app_role || objectClaim(payload, 'app_metadata').app_role || payload.role; + return typeof claim === 'string' && claim.trim() ? claim.trim() : ''; +} + +function sessionExpiryFrom(payload: JWTPayload) { + return payload.exp ? new Date(payload.exp * 1000).toISOString() : new Date(Date.now() + 60_000).toISOString(); +} + +export async function findUserBySupabaseJwt(token: string, requestedTenantContext = '') { + let payload: JWTPayload; + try { + payload = await verifySupabaseJwt(token); + } catch { + return null; + } + + const authUserId = typeof payload.sub === 'string' && payload.sub ? payload.sub : ''; + if (!authUserId) return null; + + const tenantClaim = tenantClaimFrom(payload); + if (tenantClaim && requestedTenantContext && tenantClaim !== requestedTenantContext) return null; + const requestedTenantId = tenantClaim || requestedTenantContext; + const appRole = appRoleClaimFrom(payload); + + const platformUser = await 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", + coalesce($2::uuid, tm.tenant_id) as "tenantId", + $1::text as "sessionId", + $3::timestamptz as "sessionExpiresAt", + 'supabase_jwt'::text as "authSource", + u.auth_user_id as "authUserId" + from public.platform_users u + left join public.tenant_memberships tm on tm.user_id = u.id and tm.status = 'active' + where u.auth_user_id = $1::uuid + and u.primary_role = 'platform_admin' + and ($2::uuid is null or exists ( + select 1 + from public.tenant_memberships scoped_tm + where scoped_tm.user_id = u.id + and scoped_tm.tenant_id = $2::uuid + and scoped_tm.status = 'active' + )) + order by tm.created_at asc nulls last + limit 1 + `, + [authUserId, requestedTenantId || null, sessionExpiryFrom(payload)], + ); + + if (platformUser && (!appRole || appRole === 'platform_admin' || appRole === 'service_role')) { + return platformUser; + } + + if (!requestedTenantId) return null; + + 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", + tm.tenant_id as "tenantId", + $1::text as "sessionId", + $3::timestamptz as "sessionExpiresAt", + 'supabase_jwt'::text as "authSource", + u.auth_user_id as "authUserId" + from public.platform_users u + join public.tenant_memberships tm on tm.user_id = u.id + where u.auth_user_id = $1::uuid + and tm.status = 'active' + and tm.tenant_id = $2::uuid + order by case + when u.primary_role = 'platform_admin' then 0 + when tm.role = 'tenant_owner' then 1 + when tm.role = 'tenant_admin' then 2 + else 9 + end, tm.created_at asc + limit 1 + `, + [authUserId, requestedTenantId, sessionExpiryFrom(payload)], + ); +} + 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; + let session: SessionIdentity | null = null; + if (bearerToken) { + session = bearerToken.startsWith('tk_') + ? await findUserBySessionToken(bearerToken) + : await findUserBySupabaseJwt(bearerToken, tenantContextFrom(ctx)); + } const state = { bearerToken, session, sessionResolved: true }; requestAuthState.set(ctx, state); return state; diff --git a/apps/api/src/core/config.ts b/apps/api/src/core/config.ts index 040d9463..fa578b79 100644 --- a/apps/api/src/core/config.ts +++ b/apps/api/src/core/config.ts @@ -11,6 +11,10 @@ export interface ApiConfig { authCodePepper: string; authSessionSecret: string; authSmsProvider: string; + authJwtIssuer: string; + authJwtAudience: string; + authJwtSecret: string; + authJwtJwksUrl: string; authCodeTtlSeconds: number; authSmsCooldownSeconds: number; authSessionTtlSeconds: number; @@ -44,6 +48,7 @@ loadDotenv(); const DEFAULT_AUTH_CODE_PEPPER = 'development-code-pepper-change-me'; const DEFAULT_AUTH_SESSION_SECRET = 'development-session-secret-change-me'; +const DEFAULT_AUTH_JWT_SECRET = 'development-jwt-secret-change-me'; const DEFAULT_PLATFORM_ADMIN_API_KEY = 'local-platform-admin-key'; const DEFAULT_MAX_JSON_BODY_BYTES = 1024 * 1024; const DEFAULT_MAX_IMPORT_JSON_BODY_BYTES = 10 * 1024 * 1024; @@ -78,6 +83,9 @@ function validateProductionConfig(nextConfig: ApiConfig) { if (isUnsafeSecret(nextConfig.authSessionSecret, DEFAULT_AUTH_SESSION_SECRET)) { failures.push('AUTH_SESSION_SECRET must be a strong production secret'); } + 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 (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'); } @@ -107,6 +115,10 @@ const loadedConfig: ApiConfig = { authCodePepper: envString('AUTH_CODE_PEPPER', DEFAULT_AUTH_CODE_PEPPER), authSessionSecret: envString('AUTH_SESSION_SECRET', DEFAULT_AUTH_SESSION_SECRET), authSmsProvider: envString('AUTH_SMS_PROVIDER', 'mock'), + authJwtIssuer: envString('AUTH_JWT_ISSUER', ''), + authJwtAudience: envString('AUTH_JWT_AUDIENCE', 'authenticated'), + authJwtSecret: envString('AUTH_JWT_SECRET', DEFAULT_AUTH_JWT_SECRET), + authJwtJwksUrl: envString('AUTH_JWT_JWKS_URL', ''), authCodeTtlSeconds: envNumber('AUTH_CODE_TTL_SECONDS', 300), authSmsCooldownSeconds: envNumber('AUTH_SMS_COOLDOWN_SECONDS', 60), authSessionTtlSeconds: envNumber('AUTH_SESSION_TTL_SECONDS', 60 * 60 * 24 * 7), diff --git a/apps/api/src/core/request.ts b/apps/api/src/core/request.ts index f7a5869e..2bcfa9ab 100644 --- a/apps/api/src/core/request.ts +++ b/apps/api/src/core/request.ts @@ -23,6 +23,9 @@ export async function tenantIdFrom(ctx: RequestContext) { if (legacyTenantId && legacyTenantId !== auth.session.tenantId) { throw new HttpError(403, 'Request tenant does not match the authenticated session', 'AUTH_TENANT_MISMATCH'); } + if (!auth.session.tenantId) { + throw new HttpError(400, 'Tenant context is required for this authenticated request', 'TENANT_ID_REQUIRED'); + } return auth.session.tenantId; } @@ -148,7 +151,7 @@ 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'); + throw new HttpError(403, 'Platform admin access is required', 'PLATFORM_ADMIN_REQUIRED'); } const provided = getHeader(ctx.req, 'x-platform-admin-key'); diff --git a/apps/api/src/features/auth/routes.ts b/apps/api/src/features/auth/routes.ts index 40757e14..be076736 100644 --- a/apps/api/src/features/auth/routes.ts +++ b/apps/api/src/features/auth/routes.ts @@ -1,6 +1,6 @@ import crypto from 'node:crypto'; import { config } from '../../core/config.js'; -import { bearerTokenFrom, findUserBySessionToken } from '../../core/auth-context.js'; +import { currentSessionFromContext, hydrateRequestAuth } 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'; @@ -352,12 +352,9 @@ export async function verifySmsCodeRoute(ctx: RequestContext) { export async function meRoute(ctx: RequestContext) { const tenantId = await tenantIdFrom(ctx); - const token = bearerTokenFrom(ctx); - if (!token) { - throw new HttpError(401, 'Bearer token is required', 'AUTH_TOKEN_REQUIRED'); - } + await hydrateRequestAuth(ctx); - const session = await findUserBySessionToken(token); + const session = currentSessionFromContext(ctx); if (!session) { throw new HttpError(401, 'Invalid or expired session', 'AUTH_SESSION_INVALID'); } @@ -378,17 +375,16 @@ export async function meRoute(ctx: RequestContext) { session: { id: session.sessionId, expiresAt: session.sessionExpiresAt, + source: session.authSource, }, }; } export async function logoutRoute(ctx: RequestContext) { const tenantId = await tenantIdFrom(ctx); - const token = bearerTokenFrom(ctx); - if (!token) return { ok: true }; - - const session = await findUserBySessionToken(token); - if (session && session.tenantId === tenantId) { + await hydrateRequestAuth(ctx); + const session = currentSessionFromContext(ctx); + if (session?.authSource === 'app_session' && session.tenantId === tenantId) { await query( ` update app_private.auth_sessions diff --git a/docs/refactor/api-structure.md b/docs/refactor/api-structure.md index a5ce05d9..ed2ee3ca 100644 --- a/docs/refactor/api-structure.md +++ b/docs/refactor/api-structure.md @@ -62,9 +62,9 @@ types.ts 仅本领域使用的类型 - 可预期错误用 `HttpError`,生产环境不向前端暴露内部异常。 - 写接口必须考虑幂等、审计和租户隔离;支付 webhook 必须先设计幂等键。 - 不要为了少写 API 而让前端直写复杂业务表。新增前端直连 table/view/RPC 必须先满足 RLS、最小 grant、跨租户测试、权限测试和索引要求。 -- 迁移期接口可用 `x-user-id` 标识学生用户;接 Supabase Auth 后统一替换为 JWT 解析。 -- 登录类接口先使用 `Authorization: Bearer tk_*` 迁移期 session;session 明文只返回客户端,数据库只保存 hash。 -- 平台运营接口使用 `x-platform-admin-key` 作为临时保护;正式上线前要迁到平台管理员 JWT 和审计日志。 +- 新接口优先使用 `Authorization: Bearer `;后端通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务身份。 +- 迁移期仍支持 `Authorization: Bearer tk_*` session;session 明文只返回客户端,数据库只保存 hash。 +- `x-user-id` 和 `x-platform-admin-key` 只允许在非生产兼容模式使用;生产必须关闭 `ALLOW_LEGACY_AUTH_HEADERS` 和 `ALLOW_PLATFORM_ADMIN_KEY`。 - `platform-admin` 管平台与合作商之间的 SaaS 账务,`tenant-admin` 管合作商自己的品牌、域名、公开配置、登录/商户配置、活动和兑换码,`tenant-content` 管合作商自己的题库和学习内容维护。 - `tenant-admin` 的敏感配置必须拆分:公开字段进入 `config_public`,商户密钥、短信密钥、OAuth app secret 进入 `app_private.tenant_secrets` 或生产 KMS/Vault;对前端只返回 `secretRef` 和掩码状态。 - `tenant-admin` 权限由 `tenant_memberships.role` 的默认权限和 `permissions` JSON 覆盖共同决定;后端接口必须校验具体权限点,不能只依赖前端菜单隐藏。 diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 6d2828e7..613b7e73 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 已接入 session 优先身份上下文;生产前继续补 Supabase JWT/RLS 回归 | +| RLS/租户隔离 | 可联调 | 表层普遍有 `tenant_id` 和 RLS 策略;API 已支持 `tk_` 迁移 session 与 Supabase Auth JWT 双入口,并覆盖跨租户/伪造身份集成测试;生产前继续补真实云端 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 集成测试 | @@ -37,9 +37,10 @@ | --- | --- | --- | | 短信验证码登录 | 可联调 | 已有验证码、冷却、hash、登录事件;支持 mock、阿里云短信、腾讯云短信 provider,生产仍需真实账号联调 | | 迁移期 session | 迁移期 | `tk_` token hash 存在 `app_private.auth_sessions`,用户态接口已优先解析 bearer session 并拒绝伪造 userId/tenantId | +| Supabase Auth JWT | 可联调 | API 已用 Bearer JWT 验签并通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务身份;支持 HS256 JWT secret 或 JWKS;测试覆盖学生、租户管理员、平台管理员、错租户、坏签名 | | 微信小程序登录 | 可联调 | `/api/auth/oauth/wechat-miniapp` 已接 `code2Session`、openid/unionid 身份、session 签发和登录审计 | | 微信网页/QQ OAuth | 待补齐 | 目前仍是 placeholder,需要 code 换 token、回调域名、账号合并和审计 | -| 平台管理员鉴权 | 迁移期 | `x-platform-admin-key` 已可通过配置禁用;生产前必须换平台管理员 JWT/服务端会话 | +| 平台管理员鉴权 | 可联调 | 已支持平台管理员 Supabase JWT;`x-platform-admin-key` 仅作本地/迁移期兼容且可通过配置禁用 | | 租户角色权限 | 可联调 | `tenant_memberships.role + permissions`,接口有权限点校验 | | 自定义角色模板 | 待补齐 | 当前有权限 JSON 覆盖,缺角色模板、菜单/模块/字段级权限配置 UI/API | diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index 9768d063..b5d25812 100644 --- a/docs/refactor/backend-handoff-roadmap.md +++ b/docs/refactor/backend-handoff-roadmap.md @@ -20,7 +20,7 @@ | 模块 | 当前状态 | 已经具备 | 上线前还要补 | | --- | --- | --- | --- | -| 多租户底座 | 基础完成 | 租户、域名、品牌、设置、RLS 基础、审计 | Supabase Auth/JWT 替换迁移期请求头,生产 RLS 回归 | +| 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射 | 真实云端 Auth/JWKS 回归、生产 RLS 深测 | | 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量 | 自动计费、平台审计、公共题库披露策略 | | 租户后台 | 基础完成 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、成员权限 | 自定义角色模板、菜单/模块可见性 UI、字段级权限 | | 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、答题、错题、收藏 | 完整模考交卷报告、专项策略、公题库采纳/授权、Excel 导入 | @@ -75,7 +75,7 @@ ### P0:上云测试和前端主链路前必须处理 -- 生产鉴权:用 Supabase Auth/JWT 或服务端 session 替换 `x-tenant-id`、`x-user-id`、`x-platform-admin-key`。 +- 生产鉴权:API 已支持 Supabase Auth JWT;继续做真实云端 Auth/JWKS 回归、RLS 深测,并在生产关闭 `x-user-id` 与 `x-platform-admin-key` 兼容入口。 - 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage;继续完成上传后对象校验、PDF 预览、视频播放签名、防盗链和水印。 - 真实数据 dry-run:导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,跑迁移和校验报告。 - 生产环境配置:补 `.env` 模板、数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。 diff --git a/docs/refactor/backend-progress.md b/docs/refactor/backend-progress.md index 23a99620..a0e11248 100644 --- a/docs/refactor/backend-progress.md +++ b/docs/refactor/backend-progress.md @@ -17,6 +17,7 @@ - `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、激活码批次、优惠券、成员管理、权限矩阵、审计查询。 - `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册 JSON 导入维护。 - `tenant`:域名/租户解析。 +- 鉴权上下文已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口,JWT 通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务用户和租户;平台管理员 JWT 已可访问平台后台。 - `learning` 已接入商用访问控制:免费用户每日题量、SVIP 范围、SVIP-only 内容、答题 session 快照保护由后端强制执行。 - `src/services/supabaseApi.ts` 已加入新 API 客户端方法,供旧 Web 逐步替换和后续 Taro 复用。 - 已新增 `npm run db:smoke-seed`,用于 `supabase:reset` 后恢复最小烟测数据。 @@ -165,10 +166,11 @@ GET /api/tenant-admin/audit-logs ## 迁移期约定 -- 当前写接口用 `x-tenant-id` 和 `x-user-id` 做迁移期上下文。 -- `auth` 当前签发迁移期 `tk_` session,token hash 存在 `app_private.auth_sessions`;后续接 Supabase Auth 后,`x-user-id` 要替换为 JWT 用户身份解析。 +- 当前 API 已支持 Supabase Auth JWT 和迁移期 `tk_` session;新前端应优先使用 `Authorization: Bearer `。 +- `x-tenant-id` 只作为租户上下文,后端会校验 JWT/session 用户确实属于该租户;`x-user-id` 只允许在非生产兼容测试中使用。 +- `auth` 当前仍可签发迁移期 `tk_` session,token hash 存在 `app_private.auth_sessions`,用于旧数据迁移和本地联调。 - 短信验证码只保存 HMAC hash,不保存明文;本地 `mock` provider 才会返回 `debugCode`。 -- `platform-admin` 当前用 `x-platform-admin-key` 做迁移期保护,生产后必须替换为平台管理员 JWT/服务端会话。 +- `platform-admin` 已支持平台管理员 Supabase JWT;`x-platform-admin-key` 只作为非生产兼容保护。 - B 端合作商年费/服务费使用 `tenant_invoices`、`tenant_invoice_items`、`tenant_invoice_payments`,不与 C 端学生订单混表。 - 订单金额以后端套餐价格为准,不信任前端传价。 - 激活码兑换和支付成功都走同一套 `grantSvipEntitlement` 权益开通逻辑。 diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index baa71585..b5f7e840 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -33,12 +33,13 @@ - `question_collections` - `practice_blueprints` - 可以接入迁移期短信登录和 `tk_` session,用于本地/内网联调。 +- H5 可以直接用 Supabase Auth access token 调 `apps/api`;后端已支持 JWT 验签和业务用户映射。 - H5 可以优先验证 `@supabase/supabase-js` 管理 Auth session;微信小程序端先验证运行时兼容性,业务数据默认仍走 `apps/api`。 - 可以接入租户品牌、主题、功能开关和域名/小程序参数解析。 ## 不能误认为已商用完成的部分 -- 生产鉴权尚未完成:当前很多接口仍用 `x-tenant-id`、`x-user-id`、`x-platform-admin-key` 作为迁移期上下文。 +- 生产鉴权已具备 Supabase JWT API 入口,但仍要做真实云端 Auth/JWKS 回归、RLS 深测和自定义角色权限细化;前端不要继续使用 `x-user-id`。 - 不要把“Supabase 支持前端 Data API”误解为“本项目所有业务表都由 Taro 直写”;订单、支付、权益、租户后台、导入、CRM、私有资源必须走 RPC、`apps/api`、Edge Function 或 worker 这类后端命令层。 - 真实短信、微信登录、QQ 登录、微信支付、支付宝支付 provider 还未正式接完。 - 对象存储已完成签名 provider,但 PDF 预览、防盗链、视频水印、上传后校验还要补。 diff --git a/docs/refactor/implementation-status.md b/docs/refactor/implementation-status.md index 662ef8d7..31f8e7c2 100644 --- a/docs/refactor/implementation-status.md +++ b/docs/refactor/implementation-status.md @@ -37,8 +37,8 @@ | 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告等基础表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码兑换、激活码批次、批量生成激活码、优惠券维护已实现 | 核心 API 集成测试 | 基础运营后台可用,复杂活动规则、营销自动化、核销报表待补 | | 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列已实现 | 核心 API 集成测试 | 增长链路基础可用,真实微信小程序码、分佣结算单、CRM worker 推送待补 | | 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目 JSON 导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用,前端权限 UI、Excel 导入、真实对象存储签名待补 | -| 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量 | 不适用 | 租户管理、账单、收款确认、用量记录已实现 | 仅烟测 | 平台收费链路骨架可用,正式鉴权/审计/自动计费未完成 | -| 登录认证 | 已建短信验证码、会话、OAuth provider 配置表 | 旧用户映射已预留 | 短信 mock 登录、迁移期 session、OAuth 占位已实现 | 仅烟测 | 本地可测,真实短信/微信/QQ 登录未完成 | +| 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量 | 不适用 | 租户管理、账单、收款确认、用量记录、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试 | 平台收费链路骨架可用,平台审计报表/自动计费待补 | +| 登录认证 | 已建短信验证码、会话、OAuth provider 配置表,并支持 `auth_user_id` 映射 | 旧用户映射已预留 | 短信 mock 登录、迁移期 session、Supabase JWT 验签映射、微信小程序登录主链路已实现 | API 集成测试 | H5 Supabase Auth 可联调;真实短信/微信网页/QQ 登录生产联调待补 | | 数据导入 | 已建立 importer、risk report、validate | 已覆盖多类旧集合 | 命令行导入/校验 | `pb:import:validate` | 基础工具可用,需用真实完整数据做多轮 dry-run | | 测试体系 | 不适用 | 不适用 | 不适用 | 已新增核心 API 集成测试、租户隔离测试、权限矩阵测试、资源/题目导入测试、导入校验 | 还不是完整覆盖,支付幂等、真实导入回归、前端端到端测试仍需补 | @@ -238,7 +238,7 @@ platform-admin: 上线前至少还需要完成: -1. 正式鉴权:迁移期 `x-tenant-id`、`x-user-id`、`x-platform-admin-key` 要替换为 Supabase Auth/JWT/服务端 session,并逐表验证 RLS。 +1. 正式鉴权:API 已支持 Supabase Auth JWT;生产前继续做真实云端 Auth/JWKS 回归、RLS 深测,并关闭 `x-user-id`、`x-platform-admin-key` 兼容入口。 2. 国内能力接入:短信、微信登录、微信小程序登录、QQ 登录、微信支付、支付宝支付的租户级配置入口已具备,但真实 provider adapter、回调验签和 webhook 幂等仍需实现。 3. 核心缺口 API:学生端个人中心、分数线、题目视频详情、背单词进度/收藏已补基础 API;下一步重点是后台维护、权限、统计和真实业务验收。 4. 后台能力:题库录入、题目/单词/知识手册 JSON 批量导入、资源台账、视频绑定、知识手册维护、分数线维护、品牌/商户/登录/活动/兑换码配置、销售客资、CRM 队列、成员权限、审计查询已补 API;Excel 导入、分数线/视频导入、真实对象存储签名和前端操作台待补。 diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index 500f625d..99f98cda 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -88,7 +88,7 @@ ### P0:前端联调到云端前 -1. 正式鉴权:Supabase Auth/JWT 或服务端 session,替换迁移期请求头。 +1. 正式鉴权:API 已支持 Supabase Auth JWT;继续补真实云端 Auth/JWKS 回归、RLS 深测和自定义角色权限细化。 2. 生产配置 fail-fast:默认密钥、`CORS=*`、mock SMS、平台默认 key 必须禁止。 3. JSON body size limit:导入接口可配置更大限制,但必须有上限。 4. 真实租户隔离测试:跨租户读写、角色越权、资源下载越权。 diff --git a/docs/refactor/multitenant-auth-security-contract.md b/docs/refactor/multitenant-auth-security-contract.md index da381ba8..03981cdf 100644 --- a/docs/refactor/multitenant-auth-security-contract.md +++ b/docs/refactor/multitenant-auth-security-contract.md @@ -20,6 +20,9 @@ 当前后端已经进入“session 优先、迁移头受控兼容”的状态: - `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。 +- JWT 可以在 `app_metadata.tenant_id` 或请求租户上下文中确定当前租户;如果两者冲突,后端拒绝,不允许前端覆盖 token 中的租户声明。 - 登录后如果请求中的 `x-user-id`、query/body `userId` 与 session 用户不一致,后端返回 `AUTH_USER_MISMATCH`。 - 登录后如果请求中的 `x-tenant-id` 与 session 租户不一致,后端返回 `AUTH_TENANT_MISMATCH`。 - 带了无效 bearer token 的用户态接口不会回退到 `x-user-id`。 @@ -37,10 +40,10 @@ Supabase 官方允许前端用 Data API 访问数据,但前提是 RLS、最小 ## P0:正式云端测试前必须完成 1. 正式用户鉴权 - - 已支持服务端 session 解析可信 userId。 - - 生产前继续接 Supabase Auth/JWT,或将现有 server session 明确作为正式方案。 + - 已支持服务端 session 和 Supabase Auth JWT 解析可信 userId。 + - 生产前必须用真实 Supabase Auth 项目或自托管 Auth 实例跑一轮云端 JWT 回归。 - 前端禁止通过 query/body/header 指定 userId。 - - `GET /api/auth/me` 后续要补租户成员、角色、权限返回。 + - `GET /api/auth/me` 已支持 Supabase JWT;后续要补租户成员、角色、权限返回。 2. 正式租户上下文 - H5 可由域名解析租户。 @@ -50,12 +53,13 @@ Supabase 官方允许前端用 Data API 访问数据,但前提是 RLS、最小 3. 平台管理员鉴权 - `x-platform-admin-key` 已可通过 `ALLOW_PLATFORM_ADMIN_KEY=false` 禁用。 - - 生产前仍需替换为平台管理员 JWT/session 和审计日志。 - - 平台管理员也要有 JWT/session、角色、审计日志。 + - 已支持平台管理员 Supabase JWT,且以后端 `platform_users.primary_role='platform_admin'` 为准,不只信 JWT claim。 + - 生产前继续补平台后台关键操作审计报表和更细权限点。 4. 生产配置 fail-fast - `NODE_ENV=production` 时禁止默认 `AUTH_CODE_PEPPER`。 - 禁止默认 `AUTH_SESSION_SECRET`。 + - 禁止默认 `AUTH_JWT_SECRET`,除非配置了 `AUTH_JWT_JWKS_URL`。 - 禁止默认 `PLATFORM_ADMIN_API_KEY`。 - 禁止 `CORS_ORIGIN=*`。 - 禁止 `AUTH_SMS_PROVIDER=mock`。 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 8ca669d3..21638f0b 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -28,10 +28,10 @@ ### P0 上云测试前必须补齐 1. 生产鉴权 - - 用 Supabase Auth/JWT 或服务端 session 替换迁移期 `x-tenant-id`、`x-user-id`、`x-platform-admin-key`。 - - 校验平台管理员、租户管理员、运营、教师、销售、代理、学生的访问边界。 - - 做一轮真实 JWT + RLS 回归测试。 - - 已补生产配置 fail-fast 和 JSON body size limit;后续继续补正式身份上下文。 + - 已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口;JWT 通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务身份。 + - 已覆盖学生、租户管理员、平台管理员、错租户、坏签名、禁用 legacy header 的 API 集成测试。 + - 继续补真实云端 Auth/JWKS 回归、RLS 深测、自定义角色模板和菜单/模块/字段级权限。 + - 前端联调时禁止继续使用 `x-user-id`;`x-tenant-id` 只作为租户上下文,不能作为身份依据。 2. 对象存储 - 已接阿里云 OSS、腾讯云 COS、Supabase Storage 的上传/下载签名 provider。 diff --git a/docs/refactor/supabase-frontend-access-strategy.md b/docs/refactor/supabase-frontend-access-strategy.md index 74a68de9..3b7bd8c6 100644 --- a/docs/refactor/supabase-frontend-access-strategy.md +++ b/docs/refactor/supabase-frontend-access-strategy.md @@ -147,20 +147,34 @@ x-user-id: 生产目标: ```text -Authorization: Bearer +Authorization: Bearer +x-tenant-id: # 可选租户上下文;不是身份来源,必须与 JWT tenant claim 或 membership 匹配 ``` +当前 `apps/api` 已支持 Supabase Auth JWT 验签,并通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射到业务身份。H5/Taro 登录后可以直接把 Supabase access token 放到 `Authorization`。如果 JWT 内没有 `tenant_id` claim,前端仍要根据域名/小程序码解析后的租户传 `x-tenant-id`,后端会校验该用户确实属于该租户。 + 生产时后端负责: - 验证 JWT。 -- 从 JWT/session 获取 userId。 -- 根据 host/tenantCode/JWT claims 解析 tenant。 +- 从 JWT 映射业务 userId。 +- 根据 host/tenantCode/JWT claims/请求上下文解析 tenant。 - 校验用户属于该租户。 - 校验角色和权限。 - 执行业务逻辑。 前端不再传 `x-user-id`,也不能靠传 `tenantId` 获得跨租户数据。 +生产 API 环境变量至少要配置: + +```text +AUTH_JWT_JWKS_URL=https:///auth/v1/.well-known/jwks.json +# 或自托管/兼容模式下使用强随机 secret +AUTH_JWT_SECRET= +AUTH_JWT_AUDIENCE=authenticated +ALLOW_LEGACY_AUTH_HEADERS=false +ALLOW_PLATFORM_ADMIN_KEY=false +``` + ## 对后续 AI/开发者的硬性约束 - 不要把 Supabase-first 误解成前端直写所有表。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 239f60fb..5df9f56e 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -65,10 +65,27 @@ x-tenant-id: # 仅作为登录前/公开目录租户上下文;登 生产目标: ```text -Authorization: Bearer +Authorization: Bearer +x-tenant-id: # 作为租户上下文,不能作为身份依据 ``` -前端不应再传 `x-user-id`、query/body `userId` 来表示当前用户。后端已经实现 session 优先解析:如果 Authorization 存在,用户态接口以 session 用户为准;如果请求里伪造了不同的 `userId` 会返回 `AUTH_USER_MISMATCH`,伪造不同租户会返回 `AUTH_TENANT_MISMATCH`。 +前端不应再传 `x-user-id`、query/body `userId` 来表示当前用户。后端已经实现 Supabase JWT 和迁移 session 优先解析:如果 Authorization 存在,用户态接口以 token 映射出的业务用户为准;如果请求里伪造了不同的 `userId` 会返回 `AUTH_USER_MISMATCH`,伪造不同租户会返回 `AUTH_TENANT_MISMATCH` 或 `AUTH_SESSION_INVALID`。 + +H5 使用 Supabase Auth 时,推荐请求流程: + +```ts +const { data } = await supabase.auth.getSession(); +const accessToken = data.session?.access_token; + +await api.request('/api/profile/me', { + headers: { + Authorization: `Bearer ${accessToken}`, + 'x-tenant-id': tenantStore.tenantId, + }, +}); +``` + +后端会通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射用户身份。`x-tenant-id` 只能帮助确定当前租户上下文,不能让用户访问自己没有 membership 的租户。 生产或云端测试建议设置: diff --git a/package-lock.json b/package-lock.json index e7461c19..66b33b8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "dependencies": { "@supabase/storage-js": "^2.108.2", "ali-oss": "^6.23.0", + "jose": "^6.2.3", "pg": "^8.16.3" }, "devDependencies": { @@ -730,6 +731,15 @@ "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", "license": "MIT" }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-base64": { "version": "2.6.4", "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.6.4.tgz", diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 2b7f07a8..7741666f 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -3,6 +3,7 @@ import crypto from 'node:crypto'; import { spawn } from 'node:child_process'; import http from 'node:http'; import net from 'node:net'; +import { SignJWT } from 'jose'; const DEFAULT_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'; const MAIN_TENANT_ID = process.env.TENANT_ID || '00000000-0000-0000-0000-000000000001'; @@ -12,6 +13,10 @@ const TENANT_ADMIN_USER_ID = process.env.TENANT_ADMIN_USER_ID || '00000000-0000- const TENANT_OPERATOR_USER_ID = '00000000-0000-0000-0000-000000000103'; const TENANT_SALES_USER_ID = '00000000-0000-0000-0000-000000000104'; const TENANT_AGENT_USER_ID = '00000000-0000-0000-0000-000000000105'; +const AUTH_USER_ID = '00000000-0000-0000-0000-00000000a101'; +const AUTH_TENANT_ADMIN_USER_ID = '00000000-0000-0000-0000-00000000a102'; +const AUTH_PLATFORM_ADMIN_USER_ID = '00000000-0000-0000-0000-00000000a999'; +const AUTH_JWT_SECRET = 'development-jwt-secret-change-me'; const START_SERVER = process.argv.includes('--start-server'); const ENABLE_REAL_STORAGE_SIGN_TESTS = process.env.ENABLE_REAL_STORAGE_SIGN_TESTS === 'true'; @@ -346,6 +351,30 @@ function signAlipayParams(params) { return crypto.createSign('RSA-SHA256').update(canonical).sign(paymentFixture.alipayPlatformPrivateKey, 'base64'); } +async function createSupabaseJwt(authUserId, options = {}) { + const secret = new TextEncoder().encode(options.secret || AUTH_JWT_SECRET); + 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: 'HS256', typ: 'JWT' }) + .setIssuedAt(now) + .setExpirationTime(now + 60 * 60) + .sign(secret); +} + async function waitForProcessExit(child, timeoutMs = 5000) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -482,6 +511,88 @@ async function testTrustedSessionIdentity() { assert.equal(invalidSession.code, 'AUTH_SESSION_INVALID', 'invalid bearer token must not fall back to legacy user headers'); } +async function testSupabaseJwtIdentity() { + const studentJwt = await createSupabaseJwt(AUTH_USER_ID, { phone: '13800000000' }); + const studentHeaders = { authorization: `Bearer ${studentJwt}` }; + + const me = await request('/api/auth/me', { + userId: false, + headers: studentHeaders, + }); + assert.equal(me.user?.id, USER_ID, 'auth/me should map Supabase auth user to platform user'); + assert.equal(me.session?.source, 'supabase_jwt', 'auth/me should expose Supabase JWT session source'); + + const profile = await request('/api/profile/me', { + userId: false, + headers: studentHeaders, + }); + assert.equal(profile.item?.userId, USER_ID, 'profile should accept Supabase JWT identity'); + + const tenantHeaderJwt = await createSupabaseJwt(AUTH_USER_ID, { tenantId: false }); + const tenantHeaderProfile = await request('/api/profile/me', { + userId: false, + headers: { authorization: `Bearer ${tenantHeaderJwt}` }, + }); + assert.equal(tenantHeaderProfile.item?.userId, USER_ID, 'tenant header should scope Supabase JWT when token has no tenant claim'); + + const missingTenant = await request('/api/profile/me', { + tenantId: false, + userId: false, + headers: { authorization: `Bearer ${tenantHeaderJwt}` }, + expectStatus: 400, + }); + assert.equal(missingTenant.code, 'TENANT_ID_REQUIRED', 'Supabase JWT without tenant context must not guess a tenant'); + + const mismatchedTenant = await request('/api/profile/me', { + tenantId: PARTNER_TENANT_ID, + userId: false, + headers: studentHeaders, + expectStatus: 401, + }); + assert.equal(mismatchedTenant.code, 'AUTH_SESSION_INVALID', 'Supabase JWT tenant claim must not be overwritten by request tenant'); + + const badSignatureJwt = await createSupabaseJwt(AUTH_USER_ID, { secret: 'wrong-development-jwt-secret-change-me' }); + const badSignature = await request('/api/profile/me', { + userId: false, + headers: { authorization: `Bearer ${badSignatureJwt}` }, + expectStatus: 401, + }); + assert.equal(badSignature.code, 'AUTH_SESSION_INVALID', 'bad Supabase JWT signatures must be rejected'); + + const tenantAdminJwt = await createSupabaseJwt(AUTH_TENANT_ADMIN_USER_ID, { phone: '13800000001' }); + const tenantOverview = await request('/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 Supabase JWT'); + + const studentTenantAdminDenied = await request('/api/tenant-admin/overview', { + userId: false, + headers: studentHeaders, + expectStatus: 403, + }); + assert.equal(studentTenantAdminDenied.code, 'TENANT_ADMIN_REQUIRED', 'student Supabase JWT must not access tenant admin APIs'); + + const platformAdminJwt = await createSupabaseJwt(AUTH_PLATFORM_ADMIN_USER_ID, { + phone: '13999999999', + appRole: 'platform_admin', + }); + const platformOverview = await request('/api/platform-admin/overview', { + tenantId: false, + userId: false, + headers: { authorization: `Bearer ${platformAdminJwt}` }, + }); + assert.ok(platformOverview.item?.tenants?.total >= 1, 'platform admin Supabase JWT should access platform overview'); + + const studentPlatformDenied = await request('/api/platform-admin/overview', { + tenantId: false, + userId: false, + headers: studentHeaders, + expectStatus: 403, + }); + assert.equal(studentPlatformDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student Supabase JWT must not access platform APIs'); +} + async function testLegacyAuthHeadersDisabled() { const login = await loginBySms('13800000006'); const baseUrl = await startLegacyDisabledServer(); @@ -2554,6 +2665,7 @@ async function main() { await check('health', () => request('/health', { userId: false }).then(payload => assert.equal(payload.ok, true))); await check('trusted session identity', testTrustedSessionIdentity); + await check('Supabase JWT identity', testSupabaseJwtIdentity); await check('legacy auth headers disabled', testLegacyAuthHeadersDisabled); await check('catalog and learning', testCatalogAndLearning); await check('profile', testProfile); diff --git a/scripts/smoke-seed.js b/scripts/smoke-seed.js index fdde2505..e92ad611 100644 --- a/scripts/smoke-seed.js +++ b/scripts/smoke-seed.js @@ -6,6 +6,10 @@ const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@ const tenantId = process.env.TENANT_ID || '00000000-0000-0000-0000-000000000001'; const ids = { + authUser: '00000000-0000-0000-0000-00000000a101', + authTenantAdminUser: '00000000-0000-0000-0000-00000000a102', + authTenantOperatorUser: '00000000-0000-0000-0000-00000000a103', + authPlatformAdminUser: '00000000-0000-0000-0000-00000000a999', user: '00000000-0000-0000-0000-000000000101', tenantAdminUser: '00000000-0000-0000-0000-000000000102', tenantOperatorUser: '00000000-0000-0000-0000-000000000103', @@ -51,6 +55,7 @@ const ids = { partnerInvoice: '00000000-0000-0000-0000-000000000903', partnerInvoiceItem: '00000000-0000-0000-0000-000000000904', partnerInvoicePayment: '00000000-0000-0000-0000-000000000905', + platformAdminUser: '00000000-0000-0000-0000-000000000999', }; const pool = new Pool({ connectionString: databaseUrl }); @@ -212,46 +217,83 @@ async function main() { await client.query( ` - insert into public.platform_users (id, username, phone, name, primary_role, raw_profile) - values ($1, 'smoke_student', '13800000000', 'Smoke Student', 'student', '{"source":"smoke-seed"}'::jsonb) - on conflict (id) - do update set username = excluded.username, - phone = excluded.phone, - name = excluded.name, - updated_at = now() - `, - [ids.user], - ); - - await client.query( - ` - insert into public.platform_users (id, username, phone, name, primary_role, raw_profile) - values ($1, 'smoke_tenant_admin', '13800000001', 'Smoke Tenant Admin', 'tenant_admin', '{"source":"smoke-seed"}'::jsonb) - on conflict (id) - do update set username = excluded.username, - phone = excluded.phone, - name = excluded.name, - primary_role = excluded.primary_role, - updated_at = now() - `, - [ids.tenantAdminUser], - ); - - await client.query( - ` - insert into public.platform_users (id, username, phone, name, primary_role, raw_profile) + insert into auth.users ( + id, aud, role, phone, phone_confirmed_at, raw_app_meta_data, raw_user_meta_data, + created_at, updated_at + ) values - ($1, 'smoke_tenant_operator', '13800000003', 'Smoke Tenant Operator', 'tenant_operator', '{"source":"smoke-seed"}'::jsonb), - ($2, 'smoke_tenant_sales', '13800000004', 'Smoke Tenant Sales', 'sales', '{"source":"smoke-seed"}'::jsonb), - ($3, 'smoke_tenant_agent', '13800000005', 'Smoke Tenant Agent', 'agent', '{"source":"smoke-seed"}'::jsonb) + ($1, 'authenticated', 'authenticated', '13800000000', now(), '{"provider":"phone","providers":["phone"]}'::jsonb, '{}'::jsonb, now(), now()), + ($2, 'authenticated', 'authenticated', '13800000001', now(), '{"provider":"phone","providers":["phone"]}'::jsonb, '{}'::jsonb, now(), now()), + ($3, 'authenticated', 'authenticated', '13800000003', now(), '{"provider":"phone","providers":["phone"]}'::jsonb, '{}'::jsonb, now(), now()), + ($4, 'authenticated', 'authenticated', '13999999999', now(), '{"provider":"phone","providers":["phone"],"app_role":"platform_admin"}'::jsonb, '{}'::jsonb, now(), now()) + on conflict (id) + do update set phone = excluded.phone, + raw_app_meta_data = excluded.raw_app_meta_data, + updated_at = now() + `, + [ids.authUser, ids.authTenantAdminUser, ids.authTenantOperatorUser, ids.authPlatformAdminUser], + ); + + await client.query( + ` + insert into public.platform_users (id, auth_user_id, username, phone, name, primary_role, raw_profile) + values ($1, $2, 'smoke_student', '13800000000', 'Smoke Student', 'student', '{"source":"smoke-seed"}'::jsonb) on conflict (id) do update set username = excluded.username, + auth_user_id = excluded.auth_user_id, + phone = excluded.phone, + name = excluded.name, + updated_at = now() + `, + [ids.user, ids.authUser], + ); + + await client.query( + ` + insert into public.platform_users (id, auth_user_id, username, phone, name, primary_role, raw_profile) + values ($1, $2, 'smoke_tenant_admin', '13800000001', 'Smoke Tenant Admin', 'tenant_admin', '{"source":"smoke-seed"}'::jsonb) + on conflict (id) + do update set username = excluded.username, + auth_user_id = excluded.auth_user_id, phone = excluded.phone, name = excluded.name, primary_role = excluded.primary_role, updated_at = now() `, - [ids.tenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser], + [ids.tenantAdminUser, ids.authTenantAdminUser], + ); + + await client.query( + ` + insert into public.platform_users (id, auth_user_id, username, phone, name, primary_role, raw_profile) + values + ($1, $2, 'smoke_tenant_operator', '13800000003', 'Smoke Tenant Operator', 'tenant_operator', '{"source":"smoke-seed"}'::jsonb), + ($3, null, 'smoke_tenant_sales', '13800000004', 'Smoke Tenant Sales', 'sales', '{"source":"smoke-seed"}'::jsonb), + ($4, null, 'smoke_tenant_agent', '13800000005', 'Smoke Tenant Agent', 'agent', '{"source":"smoke-seed"}'::jsonb) + on conflict (id) + do update set username = excluded.username, + auth_user_id = excluded.auth_user_id, + phone = excluded.phone, + name = excluded.name, + primary_role = excluded.primary_role, + updated_at = now() + `, + [ids.tenantOperatorUser, ids.authTenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser], + ); + + await client.query( + ` + insert into public.platform_users (id, auth_user_id, username, phone, name, primary_role, raw_profile) + values ($1, $2, 'smoke_platform_admin', '13999999999', 'Smoke Platform Admin', 'platform_admin', '{"source":"smoke-seed"}'::jsonb) + on conflict (id) + do update set username = excluded.username, + auth_user_id = excluded.auth_user_id, + phone = excluded.phone, + name = excluded.name, + primary_role = excluded.primary_role, + updated_at = now() + `, + [ids.platformAdminUser, ids.authPlatformAdminUser], ); await client.query( @@ -288,6 +330,20 @@ async function main() { [tenantId, ids.tenantAdminUser], ); + await client.query( + ` + insert into public.tenant_memberships (tenant_id, user_id, role, status, permissions) + values + ($1, $2, 'tenant_operator', 'active', '{"marketing:read":true}'::jsonb), + ($1, $3, 'platform_admin', 'active', '{"*":true}'::jsonb) + on conflict (tenant_id, user_id, role) + do update set status = 'active', + permissions = excluded.permissions, + updated_at = now() + `, + [tenantId, ids.tenantOperatorUser, ids.platformAdminUser], + ); + await client.query( ` insert into public.student_profiles (tenant_id, user_id, stats, progress)