forked from wangziqi/gongxue-base
feat: add supabase jwt auth context
This commit is contained in:
18
README.md
18
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 <supabase_access_token>`,可配合 `x-tenant-id` 提供当前租户上下文。
|
||||
- 本地/迁移期:`Authorization: Bearer <tk_session>`。
|
||||
- 兼容旧测试:`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、公共题库授权和租户采纳。
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<RequestContext, RequestAuthState>();
|
||||
let remoteJwks: ReturnType<typeof createRemoteJWKSet> | 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<string, unknown> : {};
|
||||
}
|
||||
|
||||
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<SessionIdentity>(
|
||||
`
|
||||
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<SessionIdentity>(
|
||||
`
|
||||
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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <supabase_access_token>`;后端通过 `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 覆盖共同决定;后端接口必须校验具体权限点,不能只依赖前端菜单隐藏。
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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 容器部署说明。
|
||||
|
||||
@@ -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 <supabase_access_token>`。
|
||||
- `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` 权益开通逻辑。
|
||||
|
||||
@@ -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 预览、防盗链、视频水印、上传后校验还要补。
|
||||
|
||||
@@ -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 导入、分数线/视频导入、真实对象存储签名和前端操作台待补。
|
||||
|
||||
@@ -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. 真实租户隔离测试:跨租户读写、角色越权、资源下载越权。
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
当前后端已经进入“session 优先、迁移头受控兼容”的状态:
|
||||
|
||||
- `Authorization: Bearer <tk_session>` 会优先解析 `app_private.auth_sessions`,并作为用户身份来源。
|
||||
- `Authorization: Bearer <supabase_access_token>` 已支持服务端验签,后端通过 `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`。
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -147,20 +147,34 @@ x-user-id: <userId>
|
||||
生产目标:
|
||||
|
||||
```text
|
||||
Authorization: Bearer <supabase_access_token_or_server_session>
|
||||
Authorization: Bearer <supabase_access_token>
|
||||
x-tenant-id: <tenantId> # 可选租户上下文;不是身份来源,必须与 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://<supabase-auth-host>/auth/v1/.well-known/jwks.json
|
||||
# 或自托管/兼容模式下使用强随机 secret
|
||||
AUTH_JWT_SECRET=<strong-jwt-secret>
|
||||
AUTH_JWT_AUDIENCE=authenticated
|
||||
ALLOW_LEGACY_AUTH_HEADERS=false
|
||||
ALLOW_PLATFORM_ADMIN_KEY=false
|
||||
```
|
||||
|
||||
## 对后续 AI/开发者的硬性约束
|
||||
|
||||
- 不要把 Supabase-first 误解成前端直写所有表。
|
||||
|
||||
@@ -65,10 +65,27 @@ x-tenant-id: <tenantId> # 仅作为登录前/公开目录租户上下文;登
|
||||
生产目标:
|
||||
|
||||
```text
|
||||
Authorization: Bearer <supabase_access_token_or_server_session>
|
||||
Authorization: Bearer <supabase_access_token>
|
||||
x-tenant-id: <tenantId> # 作为租户上下文,不能作为身份依据
|
||||
```
|
||||
|
||||
前端不应再传 `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 的租户。
|
||||
|
||||
生产或云端测试建议设置:
|
||||
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user