Files
Edu/apps/portal-shell/src/app/api/graphql/route.ts
SpecialX cfb7b005fd 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
  白名单调整
2026-07-22 11:35:36 +08:00

124 lines
3.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* GraphQL 同域代理P0-3ARCHITECTURE.md §3.4 V3-A2 / §4 / §5.2
*
* 浏览器 Apollo Client 一律走同域 `/api/graphql`
* Browser → /api/graphql (本 Route Handler) → apollo-router :3000
*
* 职责:
* 1. 从 httpOnly cookie `edu_session` 取 JWT注入 `Authorization: Bearer`
* 2. 透传 bodyAPQ hash 或 query与 Apollo 相关头
* 3. 响应 status / JSON 原样回传,不缓存
* 4. 错误归一化:网络错误 → `{ errors: [{ message: "UPSTREAM_UNAVAILABLE" }] }`
*
* 安全收益§4.2
* - JWT 全程不出 httpOnly cookie消除 XSS 窃取凭证面
* - 修复"浏览器绕过 api-gateway"问题(代理在服务端调 router
*
* 验收命令:
* DevTools Network 面板无 `localhost:3000` 直连;所有 GraphQL 请求走 `/api/graphql`
*/
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const SESSION_COOKIE = "edu_session";
const UPSTREAM_URL =
process.env.APOLLO_ROUTER_URL ||
process.env.NEXT_PUBLIC_APOLLO_ROUTER_URL ||
"http://localhost:3000/graphql";
/**
* 从 Cookie 头解析指定 cookie 值。
*/
function readCookie(cookieHeader: string | null, name: string): string | null {
if (!cookieHeader) return null;
const match = cookieHeader
.split(";")
.map((p) => p.trim())
.find((p) => p.startsWith(`${name}=`));
if (!match) return null;
return decodeURIComponent(match.slice(name.length + 1));
}
export async function POST(req: NextRequest): Promise<NextResponse> {
const cookieHeader = req.headers.get("cookie");
const token = readCookie(cookieHeader, SESSION_COOKIE);
// 透传 bodyAPQ hash 请求或完整 query不解析不修改
const body = await req.text();
const upstreamHeaders: Record<string, string> = {
"Content-Type": req.headers.get("content-type") ?? "application/json",
Accept: req.headers.get("accept") ?? "application/json",
// Apollo Persisted Query 协议头透传
"X-APQ": req.headers.get("x-apq") ?? "1",
// 服务端追踪:透传客户端 X-Request-Id若有
...(req.headers.get("x-request-id")
? { "X-Request-Id": req.headers.get("x-request-id") as string }
: {}),
};
// 注入 Authorization若 cookie 中有 JWT
if (token) {
upstreamHeaders.Authorization = `Bearer ${token}`;
}
try {
const upstream = await fetch(UPSTREAM_URL, {
method: "POST",
headers: upstreamHeaders,
body,
cache: "no-store",
});
const responseText = await upstream.text();
return new NextResponse(responseText, {
status: upstream.status,
headers: {
"Content-Type":
upstream.headers.get("content-type") ?? "application/json",
// 不缓存GraphQL 响应可能因身份/变量而异
"Cache-Control": "no-store, no-cache, must-revalidate",
},
});
} catch (err) {
const message =
err instanceof Error ? err.message : "Unknown upstream error";
console.error(
`[portal-shell] /api/graphql upstream error: ${message} (url=${UPSTREAM_URL})`,
);
return NextResponse.json(
{
errors: [
{
message: "UPSTREAM_UNAVAILABLE",
extensions: {
code: "UPSTREAM_UNAVAILABLE",
reason: message,
},
},
],
},
{ status: 502 },
);
}
}
/**
* GET /api/graphql → 简单健康标识,便于排查路由是否挂载。
* Apollo Router 自身的 health 在 :8088/health。
*/
export async function GET(): Promise<NextResponse> {
return NextResponse.json(
{
ok: true,
proxy: "/api/graphql",
upstream: UPSTREAM_URL,
},
{ status: 200 },
);
}