feat(portal-shell): v2.1 P0 auth + middleware + login + graphql proxy
- 新增 ARCHITECTURE.md v3.0:portal-shell 架构权威文档 涵盖 §3.4 V3-A2/A3 认证链、§4 GraphQL 联邦、§5 安全、 §6 部署、§10 P0-P3 验收清单 - 新增 middleware.ts:认证 + 路由门禁 httpOnly cookie edu_session(JWT)读取 DEV_MODE 合成 dev-user/teacher 身份(NODE_ENV!=production && NEXT_PUBLIC_DEV_MODE=true) 生产模式 jose JWKS RS256 验签(iss/aud 校验) 路由权限位图注入 x-user-id/x-user-role/x-user-permissions 头 /shell/** 强制 checkRoutePermission,拒绝跳 /shell/forbidden - 新增 instrumentation.ts:生产环境 DEV_MODE 强制 false 防止生产环境误开 DEV_MODE 合成身份 - 新增 app/api/auth/login/route.ts + logout/route.ts 登录走 api-gateway /v1/iam/login 设置 httpOnly + Secure + SameSite=Strict cookie - 新增 app/api/graphql/route.ts:同域 GraphQL 代理 转发到 apollo-router,注入 router-authorization 头 - 新增 app/login/page.tsx + login-form.tsx zod 表单校验,next 参数支持 - 新增 app/shell/forbidden/page.tsx:403 页面 - 更新 route-permissions.ts:补全 P0 路由权限映射 - 更新 permission-bitmap.ts(shared-ts):位图编码/解码 - 更新 apollo-client.ts:DEV_MODE APQ 关闭,错误处理 - 更新 config-fetcher.ts:config-service 直连降级 - 更新 ApolloProvider.tsx:SSR/RSC 兼容 - 更新 eslint.config.js:design-tokens/no-hardcoded-fonts 白名单调整
This commit is contained in:
@@ -1,12 +1,9 @@
|
||||
/**
|
||||
* Apollo Client(v2.1 M8 验收点)
|
||||
*
|
||||
* 所有 portal-shell 查询走 apollo-router(GraphQL 联邦入口):
|
||||
* portal-shell → apollo-router :3000/graphql → 各子图(iam/core-edu/content/msg/data-ana/ai/config-service)
|
||||
* Apollo Client(v3.0 P0-3,ARCHITECTURE.md §3.4 V3-A2 / §5.1 / §5.2)
|
||||
*
|
||||
* 双端使用:
|
||||
* - 服务端(RSC):createApolloClient() 每次请求新建实例,ssrMode=true
|
||||
* - 客户端:getApolloClient() 单例,复用 InMemoryCache
|
||||
* - 客户端:HttpLink 指向同域 `/api/graphql`,JWT 由 httpOnly cookie 经代理注入
|
||||
* - 服务端(RSC):直连 `APOLLO_ROUTER_URL`(内网,由 middleware 注入身份头)
|
||||
*
|
||||
* APQ(Automatic Persisted Queries,v2.1 M3 安全加固):
|
||||
* - 生产环境前端只发 query hash(sha256),不发明文 query
|
||||
@@ -14,17 +11,29 @@
|
||||
* - 防止攻击者通过 DevTools 构造任意查询探测 schema
|
||||
* - 开发模式可设 NEXT_PUBLIC_APOLLO_APQ=false 关闭 APQ 便于调试
|
||||
*
|
||||
* 关联:portal-shell spec §4.1 APQ、§5.5 RSC 预取、§5.6 统一 Hook、M8 验收标准
|
||||
* 安全(V3-A2):
|
||||
* - 客户端不再注入 Authorization 头(token 全程不出 httpOnly cookie)
|
||||
* - 客户端不再读 localStorage.edu_token(方案已废止)
|
||||
* - 客户端 credentials:"include" 让 cookie 流向同域 /api/graphql
|
||||
*
|
||||
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A2、§5.1、§5.2
|
||||
*/
|
||||
import { ApolloClient, InMemoryCache, HttpLink, from } from "@apollo/client";
|
||||
import { setContext } from "@apollo/client/link/context";
|
||||
import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries";
|
||||
import { sha256 } from "crypto-hash";
|
||||
|
||||
const APOLLO_ROUTER_URL =
|
||||
process.env.NEXT_PUBLIC_APOLLO_ROUTER_URL ||
|
||||
process.env.APOLLO_ROUTER_URL ||
|
||||
"http://localhost:3000/graphql";
|
||||
/**
|
||||
* 服务端 RSC 直连 apollo-router URL(仅服务端可用,浏览器走同域代理)。
|
||||
* 不挂 NEXT_PUBLIC_ 前缀 → 不打包进客户端 bundle。
|
||||
*/
|
||||
const SERVER_APOLLO_ROUTER_URL =
|
||||
process.env.APOLLO_ROUTER_URL || "http://localhost:3000/graphql";
|
||||
|
||||
/**
|
||||
* 客户端同域代理路径(V3-A2):浏览器只发同域请求,
|
||||
* 由 /api/graphql Route Handler 取 httpOnly cookie 中的 JWT 并转发。
|
||||
*/
|
||||
const CLIENT_PROXY_URL = "/api/graphql";
|
||||
|
||||
// 开发模式可关闭 APQ 便于调试(NEXT_PUBLIC_APOLLO_APQ=false)
|
||||
// 生产环境默认启用(未设置或设置为 true 均启用)
|
||||
@@ -33,42 +42,39 @@ const APQ_ENABLED = process.env.NEXT_PUBLIC_APOLLO_APQ !== "false";
|
||||
/**
|
||||
* 创建 Apollo Client 实例。
|
||||
*
|
||||
* @param getAuthToken 可选,返回 JWT 用于注入 Authorization 头(客户端从 cookie/localStorage 读取)
|
||||
* @param options 可选:
|
||||
* - serverSide: true 表示服务端 RSC 模式(直连 router),false/省略表示客户端(走 /api/graphql)
|
||||
*
|
||||
* 客户端不再接受 getAuthToken 参数:JWT 已迁至 httpOnly cookie,
|
||||
* JS 永远拿不到 token(ARCHITECTURE.md §3.4 V3-A2 / §11.7 红线 #2)。
|
||||
*/
|
||||
export function createApolloClient(
|
||||
getAuthToken?: () => string | null,
|
||||
options: { serverSide?: boolean } = {},
|
||||
): ApolloClient<unknown> {
|
||||
const isServer = options.serverSide ?? typeof window === "undefined";
|
||||
|
||||
const httpLink = new HttpLink({
|
||||
uri: APOLLO_ROUTER_URL,
|
||||
credentials: "include",
|
||||
uri: isServer ? SERVER_APOLLO_ROUTER_URL : CLIENT_PROXY_URL,
|
||||
// 客户端:同域请求,cookie 自动随行;服务端:直连 router 不需要 cookie
|
||||
credentials: isServer ? "omit" : "include",
|
||||
});
|
||||
|
||||
const authLink = setContext((_, { headers }) => {
|
||||
const token = getAuthToken?.() ?? null;
|
||||
return {
|
||||
headers: {
|
||||
...headers,
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Link 链顺序:authLink → pqLink → httpLink
|
||||
// - authLink 注入 Authorization 头
|
||||
// Link 链顺序:pqLink → httpLink
|
||||
// - pqLink 将 query 替换为 hash(启用时)
|
||||
// - httpLink 发送请求
|
||||
// - httpLink 发送请求(含 cookie)
|
||||
// 客户端不再需要 authLink(token 注入由 /api/graphql 代理负责)
|
||||
const link = APQ_ENABLED
|
||||
? from([authLink, createPersistedQueryLink({ sha256 }), httpLink])
|
||||
: from([authLink, httpLink]);
|
||||
? from([createPersistedQueryLink({ sha256 }), httpLink])
|
||||
: from([httpLink]);
|
||||
|
||||
return new ApolloClient({
|
||||
link,
|
||||
cache: new InMemoryCache(),
|
||||
ssrMode: typeof window === "undefined",
|
||||
ssrMode: isServer,
|
||||
defaultOptions: {
|
||||
query: {
|
||||
errorPolicy: "all",
|
||||
fetchPolicy: typeof window === "undefined" ? "no-cache" : "cache-first",
|
||||
fetchPolicy: isServer ? "no-cache" : "cache-first",
|
||||
},
|
||||
watchQuery: {
|
||||
errorPolicy: "all",
|
||||
@@ -81,15 +87,15 @@ let clientSingleton: ApolloClient<unknown> | null = null;
|
||||
|
||||
/**
|
||||
* 获取客户端 Apollo Client 单例(浏览器侧复用缓存)。
|
||||
*
|
||||
* 注意:不再接受 getAuthToken 参数(V3-A2 移除 localStorage 方案)。
|
||||
*/
|
||||
export function getApolloClient(
|
||||
getAuthToken?: () => string | null,
|
||||
): ApolloClient<unknown> {
|
||||
export function getApolloClient(): ApolloClient<unknown> {
|
||||
if (typeof window === "undefined") {
|
||||
return createApolloClient(getAuthToken);
|
||||
return createApolloClient({ serverSide: true });
|
||||
}
|
||||
if (!clientSingleton) {
|
||||
clientSingleton = createApolloClient(getAuthToken);
|
||||
clientSingleton = createApolloClient({ serverSide: false });
|
||||
}
|
||||
return clientSingleton;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user