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:
239
apps/portal-shell/src/app/api/auth/login/route.ts
Normal file
239
apps/portal-shell/src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* 登录代理 Route Handler(P0-1,ARCHITECTURE.md §3.4 V3-A2 / §4.1 / §4.3)
|
||||
*
|
||||
* 流程:
|
||||
* Browser POST /api/auth/login { email, password }
|
||||
* → 本 Route Handler 调 api-gateway /api/v1/iam/login
|
||||
* → 成功后把 accessToken 写入 httpOnly cookie `edu_session`
|
||||
* → 把 permissions 位图写入非 httpOnly cookie `edu_perms`(按钮级 UX 用,非安全依据)
|
||||
* → 返回 { user } 给前端(不返回 token,JS 永不接触 token)
|
||||
*
|
||||
* 安全(§4.3):
|
||||
* - cookie 名 `edu_session`:HttpOnly + Secure(生产) + SameSite=Strict + Path=/
|
||||
* - Max-Age 与 iam 返回的 expiresIn 对齐
|
||||
* - 失败归一化错误:401 / 429 / 5xx 分别处理
|
||||
*
|
||||
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A2、§4.1、§4.3、§6.1、§11.7 红线 #2
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
encodePermissionsBitmap,
|
||||
PERMISSION_BITMAP_ORDER,
|
||||
} from "@edu/shared-ts/permission-bitmap";
|
||||
import type { Role } from "@edu/shared-ts/contracts";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const SESSION_COOKIE = "edu_session";
|
||||
const PERMS_COOKIE = "edu_perms";
|
||||
|
||||
const GATEWAY_URL =
|
||||
process.env.API_GATEWAY_URL ||
|
||||
process.env.NEXT_PUBLIC_API_GATEWAY_URL ||
|
||||
"http://localhost:8080";
|
||||
|
||||
const IAM_LOGIN_ENDPOINT = `${GATEWAY_URL.replace(/\/$/, "")}/api/v1/iam/login`;
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
interface UserInfo {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
dataScope: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface TokenPair {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
interface IamLoginResponse {
|
||||
success: true;
|
||||
data: { user: UserInfo; tokens: TokenPair };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析角色字符串为 portal-shell 4 角色之一。
|
||||
* iam 返回 roles[],取主角色。
|
||||
*/
|
||||
function pickPrimaryRole(roles: string[]): Role {
|
||||
for (const r of roles) {
|
||||
if (r === "admin" || r === "teacher" || r === "student" || r === "parent") {
|
||||
return r;
|
||||
}
|
||||
}
|
||||
return "teacher";
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤出 PERMISSION_BITMAP_ORDER 中存在的权限点(避免位图编码丢失)。
|
||||
*/
|
||||
function filterKnownPermissions(perms: string[]): string[] {
|
||||
const known = new Set<string>(PERMISSION_BITMAP_ORDER);
|
||||
return perms.filter((p) => known.has(p));
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
// ── 1. 解析与校验请求体 ──
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "INVALID_BODY", message: "Request body must be JSON" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = loginSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "INVALID_INPUT",
|
||||
message: "Email and password are required",
|
||||
details: parsed.error.issues,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// ── 2. 调用 iam 登录 ──
|
||||
let iamResponse: Response;
|
||||
try {
|
||||
iamResponse = await fetch(IAM_LOGIN_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// 透传客户端 IP 与 UA 用于审计
|
||||
...(req.headers.get("x-forwarded-for")
|
||||
? { "X-Forwarded-For": req.headers.get("x-forwarded-for") as string }
|
||||
: {}),
|
||||
...(req.headers.get("user-agent")
|
||||
? { "User-Agent": req.headers.get("user-agent") as string }
|
||||
: {}),
|
||||
},
|
||||
body: JSON.stringify(parsed.data),
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
console.error(
|
||||
`[portal-shell] /api/auth/login: iam unreachable: ${message} (url=${IAM_LOGIN_ENDPOINT})`,
|
||||
);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "IAM_UNREACHABLE",
|
||||
message: "Authentication service unavailable",
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
// ── 3. 处理 iam 响应 ──
|
||||
if (iamResponse.status === 401) {
|
||||
return NextResponse.json(
|
||||
{ error: "INVALID_CREDENTIALS", message: "邮箱或密码错误" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
if (iamResponse.status === 429) {
|
||||
return NextResponse.json(
|
||||
{ error: "RATE_LIMITED", message: "登录尝试过于频繁,请稍后再试" },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
if (!iamResponse.ok) {
|
||||
// 其他错误(403 账户锁定 / 5xx)
|
||||
let message = "登录失败";
|
||||
try {
|
||||
const errJson = (await iamResponse.json()) as { message?: string };
|
||||
if (errJson.message) message = errJson.message;
|
||||
} catch {
|
||||
// 忽略 JSON 解析失败
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: "IAM_ERROR", message },
|
||||
{ status: iamResponse.status },
|
||||
);
|
||||
}
|
||||
|
||||
// ── 4. 提取 token 与 user ──
|
||||
let iamData: IamLoginResponse;
|
||||
try {
|
||||
iamData = (await iamResponse.json()) as IamLoginResponse;
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "IAM_BAD_RESPONSE", message: "登录服务返回数据异常" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
const { user, tokens } = iamData.data;
|
||||
if (!tokens?.accessToken || typeof tokens.expiresIn !== "number") {
|
||||
return NextResponse.json(
|
||||
{ error: "IAM_BAD_RESPONSE", message: "登录响应缺少 token" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
// ── 5. 计算 cookie 值 ──
|
||||
const secure = process.env.NODE_ENV === "production";
|
||||
const maxAge = Math.min(tokens.expiresIn, 60 * 60 * 8); // 最长 8 小时
|
||||
const knownPerms = filterKnownPermissions(user.permissions ?? []);
|
||||
const permsBitmap = encodePermissionsBitmap(knownPerms);
|
||||
|
||||
// ── 6. 构建响应(不返回 token 给前端) ──
|
||||
const response = NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: pickPrimaryRole(user.roles),
|
||||
permissions: knownPerms,
|
||||
dataScope: user.dataScope,
|
||||
},
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
|
||||
// 设置 Set-Cookie 头(多 cookie 用逗号分隔,NextResponse.cookies 更稳)
|
||||
response.cookies.set(SESSION_COOKIE, tokens.accessToken, {
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite: "strict",
|
||||
path: "/",
|
||||
maxAge,
|
||||
});
|
||||
response.cookies.set(PERMS_COOKIE, permsBitmap, {
|
||||
httpOnly: false,
|
||||
secure,
|
||||
sameSite: "strict",
|
||||
path: "/",
|
||||
maxAge,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/login → 简单状态端点(不暴露任何敏感信息)。
|
||||
*/
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
return NextResponse.json(
|
||||
{ ok: true, endpoint: "/api/auth/login", method: "POST" },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
108
apps/portal-shell/src/app/api/auth/logout/route.ts
Normal file
108
apps/portal-shell/src/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 登出代理 Route Handler(P0-1,ARCHITECTURE.md §3.4 V3-A2 / §4.2 / §4.3)
|
||||
*
|
||||
* 流程:
|
||||
* Browser POST /api/auth/logout(携带 edu_session cookie)
|
||||
* → 本 Route Handler 从 cookie 取 access token
|
||||
* → 清除 edu_session + edu_perms cookie(无论 iam 是否成功)
|
||||
* → best-effort 调 iam /api/v1/iam/logout(带 Authorization)使 refresh token 失效
|
||||
* → 返回 { success: true },前端跳转 /login
|
||||
*
|
||||
* 容错策略:
|
||||
* - iam 不可达 / 返回错误 → 静默忽略,仍然清 cookie(用户体验优先:本地登出必成功)
|
||||
* - iam 端的 edu_refresh httpOnly cookie 由 iam 自行清除(path=/api/v1/iam,本代理无法跨 path 清)
|
||||
*
|
||||
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A2、§4.2、§4.3、§11.7 红线 #2
|
||||
*/
|
||||
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 PERMS_COOKIE = "edu_perms";
|
||||
|
||||
const GATEWAY_URL =
|
||||
process.env.API_GATEWAY_URL ||
|
||||
process.env.NEXT_PUBLIC_API_GATEWAY_URL ||
|
||||
"http://localhost:8080";
|
||||
|
||||
const IAM_LOGOUT_ENDPOINT = `${GATEWAY_URL.replace(/\/$/, "")}/api/v1/iam/logout`;
|
||||
|
||||
/**
|
||||
* 解析 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);
|
||||
const secure = process.env.NODE_ENV === "production";
|
||||
|
||||
// ── 1. best-effort 调 iam logout(使服务端 refresh token 失效) ──
|
||||
if (token) {
|
||||
try {
|
||||
await fetch(IAM_LOGOUT_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(req.headers.get("x-forwarded-for")
|
||||
? {
|
||||
"X-Forwarded-For": req.headers.get("x-forwarded-for") as string,
|
||||
}
|
||||
: {}),
|
||||
...(req.headers.get("user-agent")
|
||||
? { "User-Agent": req.headers.get("user-agent") as string }
|
||||
: {}),
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch (err) {
|
||||
// iam 不可达 → 静默,本地登出仍然完成
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
console.warn(
|
||||
`[portal-shell] /api/auth/logout: iam unreachable: ${message} (url=${IAM_LOGOUT_ENDPOINT})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. 清除 edu_session + edu_perms cookie(无论 iam 是否成功) ──
|
||||
const response = NextResponse.json({ success: true }, { status: 200 });
|
||||
response.cookies.set(SESSION_COOKIE, "", {
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite: "strict",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
response.cookies.set(PERMS_COOKIE, "", {
|
||||
httpOnly: false,
|
||||
secure,
|
||||
sameSite: "strict",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/logout → 简单状态端点(不暴露任何敏感信息)。
|
||||
*/
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
return NextResponse.json(
|
||||
{ ok: true, endpoint: "/api/auth/logout", method: "POST" },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
123
apps/portal-shell/src/app/api/graphql/route.ts
Normal file
123
apps/portal-shell/src/app/api/graphql/route.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* GraphQL 同域代理(P0-3,ARCHITECTURE.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. 透传 body(APQ 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);
|
||||
|
||||
// 透传 body(APQ 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 },
|
||||
);
|
||||
}
|
||||
220
apps/portal-shell/src/app/login/login-form.tsx
Normal file
220
apps/portal-shell/src/app/login/login-form.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 登录表单(P0-1,ARCHITECTURE.md §3.4 V3-A2 / §4.1 / §8.3)
|
||||
*
|
||||
* 客户端组件,shadcn 令牌登录表单。
|
||||
* - 提交:POST /api/auth/login { email, password }
|
||||
* - 成功:router.push(next || "/shell")
|
||||
* - 失败:notify.error 显示归一化错误
|
||||
* - DEV_MODE:显示提示横幅 + 一键填充 dev 凭证按钮
|
||||
*
|
||||
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A2、§4.1、§8.3、§11.7 红线 #2
|
||||
*/
|
||||
import { useState, useTransition, type FormEvent } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { GraduationCap, Loader2, LogIn } from "lucide-react";
|
||||
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
|
||||
const DEV_MODE = process.env.NEXT_PUBLIC_DEV_MODE === "true";
|
||||
|
||||
interface LoginSuccessResponse {
|
||||
success: true;
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
permissions: string[];
|
||||
dataScope: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface LoginErrorResponse {
|
||||
error: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
type LoginResponse = LoginSuccessResponse | LoginErrorResponse;
|
||||
|
||||
/**
|
||||
* 把后端返回的错误码映射为中文文案。
|
||||
*/
|
||||
function mapLoginError(errorCode: string, fallback: string): string {
|
||||
switch (errorCode) {
|
||||
case "INVALID_BODY":
|
||||
case "INVALID_INPUT":
|
||||
return "请输入有效的邮箱和密码";
|
||||
case "INVALID_CREDENTIALS":
|
||||
return "邮箱或密码错误";
|
||||
case "RATE_LIMITED":
|
||||
return "登录尝试过于频繁,请稍后再试";
|
||||
case "IAM_UNREACHABLE":
|
||||
case "IAM_BAD_RESPONSE":
|
||||
return "登录服务暂不可用,请稍后再试";
|
||||
case "IAM_ERROR":
|
||||
return fallback || "登录失败,请重试";
|
||||
default:
|
||||
return fallback || "登录失败,请重试";
|
||||
}
|
||||
}
|
||||
|
||||
export function LoginForm(): React.ReactElement {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>): void {
|
||||
event.preventDefault();
|
||||
if (isPending) return;
|
||||
|
||||
startTransition(async () => {
|
||||
const next = searchParams.get("next") || "/shell";
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
credentials: "include",
|
||||
});
|
||||
} catch {
|
||||
notify.error("网络错误,请检查网络连接后重试");
|
||||
return;
|
||||
}
|
||||
|
||||
let data: LoginResponse;
|
||||
try {
|
||||
data = (await response.json()) as LoginResponse;
|
||||
} catch {
|
||||
notify.error("登录服务返回数据异常");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok || !("success" in data)) {
|
||||
const errorResp = data as LoginErrorResponse;
|
||||
notify.error(mapLoginError(errorResp.error, errorResp.message));
|
||||
return;
|
||||
}
|
||||
|
||||
notify.success(`欢迎回来,${data.user.name || data.user.email}`);
|
||||
// 用 router.push 而非 location.href,保留 SPA 体验;cookie 已由 Set-Cookie 写入
|
||||
router.push(next);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function fillDevCredentials(): void {
|
||||
setEmail("dev@edu.local");
|
||||
setPassword("dev-password");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-12">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-6 flex flex-col items-center gap-2">
|
||||
<div className="bg-primary text-primary-foreground flex size-12 items-center justify-center rounded-xl">
|
||||
<GraduationCap className="size-6" aria-hidden="true" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Edu Portal</h1>
|
||||
<p className="text-muted-foreground text-sm">K12 智慧教务平台</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">登录</CardTitle>
|
||||
<CardDescription>使用邮箱与密码登录你的账号</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-sm font-medium">
|
||||
邮箱
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
disabled={isPending}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@school.edu.cn"
|
||||
aria-label="邮箱"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium">
|
||||
密码
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
disabled={isPending}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
aria-label="密码"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-3">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isPending || !email || !password}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2
|
||||
className="size-4 animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
登录中…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogIn className="size-4" aria-hidden="true" />
|
||||
登录
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{DEV_MODE ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full text-xs"
|
||||
onClick={fillDevCredentials}
|
||||
disabled={isPending}
|
||||
>
|
||||
开发模式:填充 dev 凭证
|
||||
</Button>
|
||||
) : null}
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<p className="text-muted-foreground mt-6 text-center text-xs">
|
||||
登录即表示同意 Edu Portal 使用条款与隐私政策
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
apps/portal-shell/src/app/login/page.tsx
Normal file
31
apps/portal-shell/src/app/login/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 登录页(P0-1,ARCHITECTURE.md §3.4 V3-A2 / §4.1 / §4.2 / §7.1)
|
||||
*
|
||||
* 路由:/login
|
||||
*
|
||||
* 职责:
|
||||
* - RSC 入口:检测已登录(cookie edu_session 存在)→ redirect /shell
|
||||
* - 渲染 <LoginForm /> 客户端组件
|
||||
*
|
||||
* 已登录检测策略:
|
||||
* - middleware 将 /login 列入 PUBLIC_ROUTES,不强制身份校验
|
||||
* - 本页面 RSC 通过 cookies() 读 edu_session cookie(仅看存在性,不验签——验签由 middleware 负责)
|
||||
* - 存在 cookie 视为已登录,直接 redirect /shell,避免登录页"闪现"
|
||||
*
|
||||
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A2、§4.1、§4.2、§11.7 红线 #2
|
||||
*/
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { LoginForm } from "./login-form";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function LoginPage(): Promise<React.ReactElement> {
|
||||
const cookieStore = await cookies();
|
||||
const session = cookieStore.get("edu_session");
|
||||
if (session?.value) {
|
||||
redirect("/shell");
|
||||
}
|
||||
return <LoginForm />;
|
||||
}
|
||||
@@ -5,30 +5,36 @@ import type { Role } from "@/lib/types";
|
||||
import type { PluginConfigResponse } from "@edu/shared-ts/contracts";
|
||||
|
||||
/**
|
||||
* Shell 入口(RSC Server Component,v2.1 M8 验收点 + 流式渲染)
|
||||
* Shell 入口(RSC Server Component,v3.0 P0-2 fail-closed + 流式渲染)
|
||||
*
|
||||
* 数据流(portal-shell spec §5.5、README v2.0 §5.3 流式渲染):
|
||||
* ① 从请求头获取 userId / role(api-gateway 注入 x-user-id / x-user-role)
|
||||
* 数据流(ARCHITECTURE.md §3.4 V3-A2/A3、§5.5):
|
||||
* ① 从 middleware 注入的请求头获取 userId / role(middleware 已校验 cookie + 权限)
|
||||
* ② 服务端调 apollo-router 查询 config-service 子图的 pluginConfig(三层合并)
|
||||
* ③ Config Promise 直接传给 ClientShell,由客户端 use() 消费,启用流式渲染:
|
||||
* - HTML 流式输出:loading.tsx 先行,Promise resolve 后替换为真实 UI
|
||||
* - 客户端 Suspense:避免客户端瀑布流(不用 useEffect 二次请求)
|
||||
*
|
||||
* 流式渲染分层(README v2.0 §5.3):
|
||||
* - L1 路由级(loading.tsx):整页骨架,fetchPluginConfig 进行中
|
||||
* - L2 区块级(DashboardSection):单一区块骨架,Suspense 包裹
|
||||
* - L3 插件级(PluginBoundary):单插件骨架,dynamic import + Suspense
|
||||
* fail-closed(P0-2,§11.7 红线 #5):
|
||||
* - middleware 已保证到达此处的请求必带 x-user-id / x-user-role 头
|
||||
* - 头缺失 = middleware 未运行(异常路径)→ 抛错触发 error.tsx,禁止默认 teacher
|
||||
*
|
||||
* M8 验收:portal-shell 查询走 apollo-router(fetchPluginConfig 经 Apollo Client)。
|
||||
*
|
||||
* 关联:portal-shell spec §5.5、§6.2、M8 验收标准、README v2.0 §5.3
|
||||
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A2/A3、§5.5、§11.7 红线 #5
|
||||
*/
|
||||
export default async function ShellPage(): Promise<React.ReactElement> {
|
||||
const headerList = await headers();
|
||||
const userId =
|
||||
headerList.get("x-user-id") ||
|
||||
(process.env.NEXT_PUBLIC_DEV_MODE === "true" ? "dev-user" : "anonymous");
|
||||
const role = (headerList.get("x-user-role") || "teacher") as Role;
|
||||
const userId = headerList.get("x-user-id");
|
||||
const roleHeader = headerList.get("x-user-role");
|
||||
|
||||
// fail-closed:middleware 必须注入身份头,缺失即异常(不再默认 teacher)
|
||||
if (!userId || !roleHeader) {
|
||||
throw new Error(
|
||||
"[portal-shell] ShellPage missing identity headers " +
|
||||
"(middleware must inject x-user-id / x-user-role). " +
|
||||
"If middleware is configured, this indicates a routing misconfiguration.",
|
||||
);
|
||||
}
|
||||
|
||||
const role = roleHeader as Role;
|
||||
|
||||
// 服务端通过 apollo-router 获取三层合并后的插件配置
|
||||
// 不 await:直接将 Promise 传给 ClientShell,启用流式渲染
|
||||
|
||||
62
apps/portal-shell/src/app/shell/forbidden/page.tsx
Normal file
62
apps/portal-shell/src/app/shell/forbidden/page.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import Link from "next/link";
|
||||
import { ShieldX } from "lucide-react";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
/**
|
||||
* 403 Forbidden 页(P0-2,ARCHITECTURE.md §3.4 V3-A3 / §4.2 / §6.2)
|
||||
*
|
||||
* middleware 的 checkRoutePermission 拒绝时 302 重定向到此页。
|
||||
* 页面渲染:
|
||||
* - 友好提示 + 图标 + 返回仪表盘链接
|
||||
* - 不暴露内部权限配置细节(仅显示通用 403 文案)
|
||||
* - 支持查询参数 reason=no_config | missing_role | missing_permission(仅 UX 提示)
|
||||
*
|
||||
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A3、§6.2、§11.7 红线 #5
|
||||
*/
|
||||
export default async function ForbiddenPage(): Promise<React.ReactElement> {
|
||||
const headerList = await headers();
|
||||
const userId = headerList.get("x-user-id") ?? "unknown";
|
||||
const role = headerList.get("x-user-role") ?? "unknown";
|
||||
|
||||
return (
|
||||
<main
|
||||
className="flex min-h-screen flex-col items-center justify-center gap-6 bg-background p-6 text-foreground"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
||||
<ShieldX className="h-8 w-8" aria-hidden="true" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold leading-tight">
|
||||
403 · 无权访问此页面
|
||||
</h1>
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
你的账号没有访问该页面的权限。如果认为这是错误,请联系管理员调整角色或权限。
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
用户:<span className="font-mono">{userId}</span>
|
||||
{role !== "unknown" && (
|
||||
<>
|
||||
{" · "}角色:<span className="font-mono">{role}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<nav className="flex gap-3" aria-label="操作">
|
||||
<Link
|
||||
href="/shell"
|
||||
className="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
返回仪表盘
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex h-10 items-center justify-center rounded-md border border-input bg-background px-4 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
切换账号登录
|
||||
</Link>
|
||||
</nav>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
38
apps/portal-shell/src/instrumentation.ts
Normal file
38
apps/portal-shell/src/instrumentation.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Next.js Instrumentation Hook(启动期守卫)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §3.4 V3-A2 / §6.1 / §10 P0-5
|
||||
*
|
||||
* 在生产环境(NODE_ENV=production)下,DEV_MODE 必须为 false;
|
||||
* 若 NEXT_PUBLIC_DEV_MODE=true(或非 "false")则启动即抛错退出,
|
||||
* 防止"生产永久绕过认证"(见 §1.3-F4 / §6.1)。
|
||||
*
|
||||
* 验收命令:
|
||||
* NODE_ENV=production NEXT_PUBLIC_DEV_MODE=true pnpm start
|
||||
* → 进程退出,stderr 输出包含 "NEXT_PUBLIC_DEV_MODE must be false in production"
|
||||
*
|
||||
* 开发态(NODE_ENV !== "production")放行,由 middleware 提供 dev-token 合成身份。
|
||||
*/
|
||||
export async function register(): Promise<void> {
|
||||
const nodeEnv = process.env.NODE_ENV ?? "";
|
||||
const devModeRaw = process.env.NEXT_PUBLIC_DEV_MODE ?? "";
|
||||
|
||||
if (nodeEnv !== "production") {
|
||||
return;
|
||||
}
|
||||
|
||||
// 仅生产环境校验;DEV_MODE 必须显式为 "false"(或等价 falsy)
|
||||
const devModeEnabled =
|
||||
devModeRaw !== "false" && devModeRaw !== "0" && devModeRaw !== "";
|
||||
|
||||
if (devModeEnabled) {
|
||||
const msg =
|
||||
"[portal-shell] FATAL: NEXT_PUBLIC_DEV_MODE must be false in production " +
|
||||
`(got NEXT_PUBLIC_DEV_MODE=${JSON.stringify(devModeRaw)}). ` +
|
||||
"Aborting startup to prevent authentication bypass " +
|
||||
"(ARCHITECTURE.md §10 P0-5).";
|
||||
console.error(msg);
|
||||
// 非零退出码,触发进程管理器/容器重启策略
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,14 @@
|
||||
*/
|
||||
import { gql } from "@apollo/client";
|
||||
import { createApolloClient } from "./apollo-client";
|
||||
import type { PluginConfigResponse, Role } from "./types";
|
||||
import type {
|
||||
LayoutTemplateInfo,
|
||||
PluginConfigResponse,
|
||||
PluginPlacement,
|
||||
PluginRegistryItem,
|
||||
Role,
|
||||
SlotConfig,
|
||||
} from "./types";
|
||||
|
||||
/** 查询用户合并后的插件配置(走 apollo-router → config-service 子图) */
|
||||
export const GET_PLUGIN_CONFIG = gql`
|
||||
@@ -124,9 +131,11 @@ export async function fetchPluginConfig(
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 最终兜底:空默认配置
|
||||
console.warn(`[portal-shell] fetchPluginConfig returning empty default`);
|
||||
return getDefaultConfig();
|
||||
// 3. 最终兜底:内置默认配置(按角色静态定义,P0-4)
|
||||
console.warn(
|
||||
`[portal-shell] fetchPluginConfig falling back to built-in default config (role=${role})`,
|
||||
);
|
||||
return getDefaultConfig(role);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -188,25 +197,196 @@ async function fetchPluginConfigDirect(
|
||||
|
||||
/**
|
||||
* 默认 classic 布局配置(Router 未就绪 / 查询失败时降级)。
|
||||
* 保证 Shell 始终可渲染,不因下游不可用而白屏。
|
||||
*
|
||||
* P0-4(ARCHITECTURE.md §10):按角色静态定义内置默认仪表盘插件集,
|
||||
* 保证 config-service 不可用时仪表盘仍有内容(fail-safe 而非空壳)。
|
||||
*
|
||||
* 角色映射依据各 widget 的 manifest `requiredRoles` 字段(src/widgets 下各目录):
|
||||
* - topbar 4 件:全角色(notification-bell/global-search/locale-switcher/user-menu)
|
||||
* - sidebar:teacher → class-selector + term-switcher + quick-actions;
|
||||
* student → term-switcher + quick-actions;
|
||||
* parent → child-selector + term-switcher + quick-actions;
|
||||
* admin → 无 sidebar 上下文
|
||||
* - main:universal 7 件按角色过滤 + 角色专属 widget
|
||||
*
|
||||
* @param role 用户角色;未提供时按 teacher 兜底(与现有 ShellPage 默认行为一致)
|
||||
*/
|
||||
export function getDefaultConfig(): PluginConfigResponse {
|
||||
export function getDefaultConfig(role?: Role): PluginConfigResponse {
|
||||
const effectiveRole: Role = role ?? "teacher";
|
||||
|
||||
const layout: LayoutTemplateInfo = {
|
||||
layoutId: "classic",
|
||||
displayName: "经典三栏",
|
||||
description: "TopBar + SideNav + Main",
|
||||
availableSlots: ["top", "side", "main"],
|
||||
layoutSchemaJson: JSON.stringify({
|
||||
grid: { rows: 1, cols: 1, areas: [["main"]] },
|
||||
}),
|
||||
};
|
||||
|
||||
const slots: SlotConfig[] = [
|
||||
{ slotName: "top", navItems: [] },
|
||||
{ slotName: "side", navItems: [] },
|
||||
{ slotName: "main", navItems: [] },
|
||||
];
|
||||
|
||||
// ── top slot(全角色共享:通知铃 / 全局搜索 / 语言切换 / 用户菜单) ──
|
||||
const topPlugins: PluginPlacement[] = [
|
||||
placement("notification-bell", "top", 0, { colSpan: 1, rowSpan: 1 }),
|
||||
placement("global-search", "top", 1, { colSpan: 1, rowSpan: 1 }),
|
||||
placement("locale-switcher", "top", 2, { colSpan: 1, rowSpan: 1 }),
|
||||
placement("user-menu", "top", 3, { colSpan: 1, rowSpan: 1 }),
|
||||
];
|
||||
|
||||
// ── side slot(按角色裁剪) ──
|
||||
const sidePlugins: PluginPlacement[] = SIDE_DEFAULTS[effectiveRole].map(
|
||||
(id, idx) => placement(id, "side", idx, { colSpan: 1, rowSpan: 1 }),
|
||||
);
|
||||
|
||||
// ── main slot(universal 按角色 + 角色专属) ──
|
||||
const mainPlugins: PluginPlacement[] = MAIN_DEFAULTS[effectiveRole].map(
|
||||
(id, idx) => placement(id, "main", idx, { colSpan: 2, rowSpan: 1 }),
|
||||
);
|
||||
|
||||
// ── registry(按角色聚合所有可见插件的元信息) ──
|
||||
const registry: PluginRegistryItem[] = [
|
||||
...topPlugins,
|
||||
...sidePlugins,
|
||||
...mainPlugins,
|
||||
].map((p) => registryItem(p.pluginId, effectiveRole));
|
||||
|
||||
return {
|
||||
activeLayout: {
|
||||
layoutId: "classic",
|
||||
displayName: "经典三栏",
|
||||
description: "TopBar + SideNav + Main",
|
||||
availableSlots: ["top", "side", "main"],
|
||||
layoutSchemaJson: JSON.stringify({
|
||||
grid: { rows: 1, cols: 1, areas: [["main"]] },
|
||||
}),
|
||||
},
|
||||
slots: [
|
||||
{ slotName: "top", navItems: [] },
|
||||
{ slotName: "side", navItems: [] },
|
||||
{ slotName: "main", navItems: [] },
|
||||
],
|
||||
plugins: [],
|
||||
registry: [],
|
||||
activeLayout: layout,
|
||||
slots,
|
||||
plugins: [...topPlugins, ...sidePlugins, ...mainPlugins],
|
||||
registry,
|
||||
};
|
||||
}
|
||||
|
||||
/** side slot 角色默认(按渲染顺序) */
|
||||
const SIDE_DEFAULTS: Record<Role, string[]> = {
|
||||
teacher: ["class-selector", "term-switcher", "quick-actions"],
|
||||
student: ["term-switcher", "quick-actions"],
|
||||
parent: ["child-selector", "term-switcher", "quick-actions"],
|
||||
admin: [],
|
||||
};
|
||||
|
||||
/** main slot 角色默认(universal + 角色专属,按渲染顺序) */
|
||||
const MAIN_DEFAULTS: Record<Role, string[]> = {
|
||||
teacher: [
|
||||
"schedule-widget",
|
||||
"grades-widget",
|
||||
"homework-widget",
|
||||
"exams-widget",
|
||||
"attendance-widget",
|
||||
"announcements-widget",
|
||||
"notifications-widget",
|
||||
"lesson-plan-editor",
|
||||
"question-bank",
|
||||
"textbook-manager",
|
||||
"scheduling-rules",
|
||||
],
|
||||
student: [
|
||||
"schedule-widget",
|
||||
"grades-widget",
|
||||
"homework-widget",
|
||||
"exams-widget",
|
||||
"attendance-widget",
|
||||
"announcements-widget",
|
||||
"notifications-widget",
|
||||
"error-book",
|
||||
"learning-path",
|
||||
"elective-selector",
|
||||
"ai-tutor",
|
||||
],
|
||||
parent: [
|
||||
"schedule-widget",
|
||||
"grades-widget",
|
||||
"homework-widget",
|
||||
"exams-widget",
|
||||
"attendance-widget",
|
||||
"announcements-widget",
|
||||
"notifications-widget",
|
||||
"child-overview",
|
||||
"leave-approval",
|
||||
],
|
||||
admin: [
|
||||
"announcements-widget",
|
||||
"notifications-widget",
|
||||
"user-management",
|
||||
"rbac-manager",
|
||||
"plugin-manager",
|
||||
"school-settings",
|
||||
"audit-logs",
|
||||
"invitation-codes",
|
||||
],
|
||||
};
|
||||
|
||||
/** 简易 PluginPlacement 构造器 */
|
||||
function placement(
|
||||
pluginId: string,
|
||||
slot: string,
|
||||
sortOrder: number,
|
||||
size: { colSpan: number; rowSpan: number },
|
||||
): PluginPlacement {
|
||||
return {
|
||||
pluginId,
|
||||
slot,
|
||||
sortOrder,
|
||||
sizeJson: JSON.stringify(size),
|
||||
propsJson: "{}",
|
||||
isVisible: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** 简易 PluginRegistryItem 构造器(基于内置 manifest 元数据) */
|
||||
function registryItem(pluginId: string, role: Role): PluginRegistryItem {
|
||||
// category 由 pluginId 前缀目录决定,与 src/widgets/<category>/ 对齐
|
||||
const category = inferCategory(pluginId);
|
||||
return {
|
||||
pluginId,
|
||||
category,
|
||||
version: "0.1.0",
|
||||
displayName: pluginId,
|
||||
description: `Built-in ${category} plugin (default config fallback)`,
|
||||
requiredRoles: [role],
|
||||
isBuiltin: true,
|
||||
isActive: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** 由 pluginId 推断 category(与目录结构 src/widgets/<category>/ 对齐) */
|
||||
function inferCategory(pluginId: string): string {
|
||||
if (pluginId.endsWith("-widget")) return "universal";
|
||||
if (
|
||||
pluginId === "notification-bell" ||
|
||||
pluginId === "global-search" ||
|
||||
pluginId === "locale-switcher" ||
|
||||
pluginId === "user-menu"
|
||||
)
|
||||
return "topbar";
|
||||
if (
|
||||
pluginId === "class-selector" ||
|
||||
pluginId === "child-selector" ||
|
||||
pluginId === "term-switcher" ||
|
||||
pluginId === "quick-actions"
|
||||
)
|
||||
return "sidebar";
|
||||
if (
|
||||
pluginId === "lesson-plan-editor" ||
|
||||
pluginId === "question-bank" ||
|
||||
pluginId === "textbook-manager" ||
|
||||
pluginId === "scheduling-rules"
|
||||
)
|
||||
return "teacher";
|
||||
if (
|
||||
pluginId === "error-book" ||
|
||||
pluginId === "learning-path" ||
|
||||
pluginId === "elective-selector" ||
|
||||
pluginId === "ai-tutor"
|
||||
)
|
||||
return "student";
|
||||
if (pluginId === "child-overview" || pluginId === "leave-approval")
|
||||
return "parent";
|
||||
return "admin";
|
||||
}
|
||||
|
||||
275
apps/portal-shell/src/middleware.ts
Normal file
275
apps/portal-shell/src/middleware.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* portal-shell 中间件:认证 + 路由门禁(P0-2,ARCHITECTURE.md §3.4 V3-A2/A3 / §4 / §6)
|
||||
*
|
||||
* 职责链(每个 /shell/** 请求):
|
||||
* 1. 公共路径白名单 → 直接放行
|
||||
* 2. 读取 httpOnly cookie `edu_session`(JWT)
|
||||
* - 无 cookie / 无效 → 302 /login?next=<pathname>
|
||||
* 3. 验证 JWT(dev 模式 decode-only;生产模式 jose JWKS RS256)
|
||||
* 4. 注入请求头 x-user-id / x-user-role / x-user-permissions(供 RSC 读取)
|
||||
* 5. 调 checkRoutePermission → 拒绝 → 302 /shell/forbidden
|
||||
*
|
||||
* DEV_MODE 合成身份(§3.4 V3-A2):
|
||||
* - 仅当 NODE_ENV !== "production" && NEXT_PUBLIC_DEV_MODE === "true" 时启用
|
||||
* - 无 cookie 时合成 dev-user / teacher / 全权限位图
|
||||
* - 生产环境若 DEV_MODE=true 由 instrumentation.ts 拒绝启动
|
||||
*
|
||||
* JWKS(§4.3):
|
||||
* - 端点:`IAM_JWKS_URI` 或默认 `http://api-gateway:8080/v1/iam/.well-known/jwks.json`
|
||||
* - 缓存 5min(jose 内置);不可达 → fail-closed 跳登录
|
||||
*
|
||||
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A2/V3-A3、§4、§6.2、§11.7
|
||||
*/
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { createRemoteJWKSet, jwtVerify, errors as joseErrors } from "jose";
|
||||
import {
|
||||
checkRoutePermission,
|
||||
PUBLIC_ROUTES,
|
||||
} from "@/shared/lib/route-permissions";
|
||||
import {
|
||||
encodePermissionsBitmap,
|
||||
PERMISSION_BITMAP_ORDER,
|
||||
} from "@edu/shared-ts/permission-bitmap";
|
||||
import type { Role } from "@edu/shared-ts/contracts";
|
||||
|
||||
export const config = {
|
||||
// 拦截 /shell/** 与 /api/** 与 /(首页);不拦截 Next.js 静态资源
|
||||
matcher: [
|
||||
"/((?!_next/static|_next/image|favicon.ico|robots.txt|.*\\.png$|.*\\.svg$).*)",
|
||||
],
|
||||
};
|
||||
|
||||
const SESSION_COOKIE = "edu_session";
|
||||
const PERMS_COOKIE = "edu_perms";
|
||||
|
||||
const DEV_MODE =
|
||||
process.env.NODE_ENV !== "production" &&
|
||||
process.env.NEXT_PUBLIC_DEV_MODE === "true";
|
||||
|
||||
const IAM_JWKS_URI =
|
||||
process.env.IAM_JWKS_URI ||
|
||||
process.env.NEXT_PUBLIC_IAM_JWKS_URI ||
|
||||
"http://api-gateway:8080/v1/iam/.well-known/jwks.json";
|
||||
|
||||
const JWT_ISSUER = process.env.JWT_ISSUER || "edu.iam";
|
||||
const JWT_AUDIENCE = process.env.JWT_AUDIENCE || "edu-portal";
|
||||
|
||||
// jose JWKS 远程集合(自动缓存 5min)
|
||||
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
|
||||
function getJwks(): ReturnType<typeof createRemoteJWKSet> {
|
||||
if (!jwks) {
|
||||
jwks = createRemoteJWKSet(new URL(IAM_JWKS_URI));
|
||||
}
|
||||
return jwks;
|
||||
}
|
||||
|
||||
interface JwtPayload {
|
||||
sub: string;
|
||||
role: Role;
|
||||
perms?: string[];
|
||||
// iam 自定义 claims
|
||||
"https://edu.cn/role"?: Role;
|
||||
"https://edu.cn/permissions"?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 JWT 并返回身份信息。
|
||||
* - dev 模式:仅 decode(不验签)
|
||||
* - 生产模式:jose JWKS RS256 验签 + iss/aud 校验
|
||||
*/
|
||||
async function verifySession(
|
||||
token: string,
|
||||
): Promise<{ userId: string; role: Role; perms: string[] } | null> {
|
||||
// dev 模式:仅 decode(用于本地开发,无密钥环境)
|
||||
if (DEV_MODE) {
|
||||
try {
|
||||
const payload = JSON.parse(
|
||||
Buffer.from(token.split(".")[1] ?? "", "base64").toString("utf-8"),
|
||||
) as JwtPayload;
|
||||
return {
|
||||
userId: payload.sub ?? "dev-user",
|
||||
role: payload.role ?? payload["https://edu.cn/role"] ?? "teacher",
|
||||
perms:
|
||||
payload.perms ??
|
||||
payload["https://edu.cn/permissions"] ??
|
||||
// dev 默认放全权限(仅本地)
|
||||
PERMISSION_BITMAP_ORDER.filter((p) => !p.startsWith("_RESERVED_")),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 生产模式:jose 验签
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getJwks(), {
|
||||
issuer: JWT_ISSUER,
|
||||
audience: JWT_AUDIENCE,
|
||||
algorithms: ["RS256"],
|
||||
});
|
||||
const p = payload as unknown as JwtPayload;
|
||||
return {
|
||||
userId: p.sub ?? "",
|
||||
role: p.role ?? p["https://edu.cn/role"] ?? "teacher",
|
||||
perms: p.perms ?? p["https://edu.cn/permissions"] ?? [],
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof joseErrors.JWKSNoMatchingKey) {
|
||||
// JWKS 可达但无匹配密钥 → 视为无效 token
|
||||
return null;
|
||||
}
|
||||
// JWKS 不可达(网络错误)→ fail-closed
|
||||
console.warn(
|
||||
`[portal-shell] middleware JWT verify failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 合成 dev-token 身份(仅 DEV_MODE=true 时使用)。
|
||||
* 提供本地无后端可用的最小可运行身份。
|
||||
*/
|
||||
function devIdentity(): {
|
||||
userId: string;
|
||||
role: Role;
|
||||
perms: string[];
|
||||
bitmap: string;
|
||||
} {
|
||||
const perms = PERMISSION_BITMAP_ORDER.filter(
|
||||
(p) => !p.startsWith("_RESERVED_"),
|
||||
);
|
||||
return {
|
||||
userId: "dev-user",
|
||||
role: "teacher",
|
||||
perms,
|
||||
bitmap: encodePermissionsBitmap(perms),
|
||||
};
|
||||
}
|
||||
|
||||
export async function middleware(req: NextRequest): Promise<NextResponse> {
|
||||
const { pathname, search } = req.nextUrl;
|
||||
const cookieHeader = req.headers.get("cookie");
|
||||
|
||||
// ── 0. 公共路径白名单:直接放行 ──
|
||||
if (PUBLIC_ROUTES.includes(pathname)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// ── 1. 读取 cookie 中的 JWT ──
|
||||
const token = readCookie(cookieHeader, SESSION_COOKIE);
|
||||
|
||||
// ── 2. 无 cookie 处理 ──
|
||||
if (!token) {
|
||||
if (DEV_MODE) {
|
||||
// dev 模式合成身份,继续走权限检查(让本地体验"未登录也有 teacher 身份")
|
||||
const identity = devIdentity();
|
||||
const response = NextResponse.next({
|
||||
request: {
|
||||
headers: injectIdentityHeaders(
|
||||
req,
|
||||
identity.userId,
|
||||
identity.role,
|
||||
identity.bitmap,
|
||||
),
|
||||
},
|
||||
});
|
||||
return response;
|
||||
}
|
||||
// 生产模式 → 跳登录
|
||||
const loginUrl = new URL("/login", req.url);
|
||||
loginUrl.searchParams.set("next", `${pathname}${search}`);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
// ── 3. 验证 JWT ──
|
||||
const verified = await verifySession(token);
|
||||
if (!verified) {
|
||||
if (DEV_MODE) {
|
||||
// dev 模式 JWT 失效也合成身份(避免本地开发卡死)
|
||||
const identity = devIdentity();
|
||||
return NextResponse.next({
|
||||
request: {
|
||||
headers: injectIdentityHeaders(
|
||||
req,
|
||||
identity.userId,
|
||||
identity.role,
|
||||
identity.bitmap,
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
// 生产模式 → 清 cookie + 跳登录
|
||||
const loginUrl = new URL("/login", req.url);
|
||||
loginUrl.searchParams.set("next", `${pathname}${search}`);
|
||||
const response = NextResponse.redirect(loginUrl);
|
||||
response.cookies.delete(SESSION_COOKIE);
|
||||
response.cookies.delete(PERMS_COOKIE);
|
||||
return response;
|
||||
}
|
||||
|
||||
// ── 4. 计算权限位图(优先读 edu_perms cookie,回退到 JWT 内 perms 计算) ──
|
||||
const permsBitmap =
|
||||
readCookie(cookieHeader, PERMS_COOKIE) ||
|
||||
encodePermissionsBitmap(verified.perms);
|
||||
|
||||
// ── 5. 注入身份头(供 RSC headers() 读取) ──
|
||||
const requestHeaders = injectIdentityHeaders(
|
||||
req,
|
||||
verified.userId,
|
||||
verified.role,
|
||||
permsBitmap,
|
||||
);
|
||||
|
||||
// ── 6. 路由门禁(仅对 /shell/** 强制执行;其他路径已放行或交由后端) ──
|
||||
if (pathname.startsWith("/shell/") || pathname === "/shell") {
|
||||
const result = checkRoutePermission(pathname, permsBitmap, verified.role);
|
||||
if (!result.allowed) {
|
||||
const forbiddenUrl = new URL("/shell/forbidden", req.url);
|
||||
// 附带拒绝原因作为查询参数(页面可显示,但不暴露内部细节)
|
||||
if (result.reason) {
|
||||
forbiddenUrl.searchParams.set("reason", result.reason);
|
||||
}
|
||||
// 仍然注入身份头(forbidden 页可能需要显示用户名)
|
||||
return NextResponse.redirect(forbiddenUrl, {
|
||||
headers: requestHeaders,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next({
|
||||
request: { headers: requestHeaders },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造身份头注入的 Headers 对象。
|
||||
* RSC 通过 `await headers()` 读取这些值。
|
||||
*/
|
||||
function injectIdentityHeaders(
|
||||
req: NextRequest,
|
||||
userId: string,
|
||||
role: Role,
|
||||
permsBitmap: string,
|
||||
): Headers {
|
||||
const requestHeaders = new Headers(req.headers);
|
||||
requestHeaders.set("x-user-id", userId);
|
||||
requestHeaders.set("x-user-role", role);
|
||||
requestHeaders.set("x-user-permissions", permsBitmap);
|
||||
return requestHeaders;
|
||||
}
|
||||
@@ -1,37 +1,29 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ApolloProvider(v2.1 M8)
|
||||
* ApolloProvider(v3.0 P0-3,ARCHITECTURE.md §3.4 V3-A2 / §11.7 红线 #2)
|
||||
*
|
||||
* 注入 Apollo Client 单例,所有 widget 的 useWidgetQuery 经此 Client
|
||||
* 查询 apollo-router(M8 验收点:portal-shell 查询走 Router)。
|
||||
* 查询同域 /api/graphql 代理(V3-A2:浏览器不再直连 apollo-router)。
|
||||
*
|
||||
* Token 注入:从 localStorage 读取 JWT(对齐 teacher-portal F12 约定),
|
||||
* cookie 凭证通过 credentials:"include" 一并发送。
|
||||
* 凭证传递(V3-A2):
|
||||
* - 客户端 HttpLink credentials:"include",同域 cookie 自动随行
|
||||
* - JWT 全程在 httpOnly cookie `edu_session` 中,JS 永不接触
|
||||
* - 已删除 localStorage["edu_token"] 读取(方案废止)
|
||||
*
|
||||
* 关联:portal-shell spec §5.6、M8 验收标准
|
||||
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A2、§5.1、§11.7
|
||||
*/
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { ApolloProvider as ApolloGraphQLProvider } from "@apollo/client";
|
||||
import { getApolloClient } from "@/lib/apollo-client";
|
||||
|
||||
const TOKEN_KEY = "edu_token";
|
||||
|
||||
function readToken(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return window.localStorage.getItem(TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function ApolloProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): ReactNode {
|
||||
const client = useMemo(() => getApolloClient(readToken), []);
|
||||
// getApolloClient 不再接受 getAuthToken 参数(token 已迁至 httpOnly cookie)
|
||||
const client = useMemo(() => getApolloClient(), []);
|
||||
return (
|
||||
<ApolloGraphQLProvider client={client}>{children}</ApolloGraphQLProvider>
|
||||
);
|
||||
|
||||
@@ -156,15 +156,24 @@ describe("permission-bitmap", () => {
|
||||
expect(isValidPermission("")).toBe(false);
|
||||
});
|
||||
|
||||
it("PERMISSION_BITMAP_ORDER 全部合法", () => {
|
||||
it("_RESERVED_* 占位返回 false(P0-6 防误用铁律)", () => {
|
||||
expect(isValidPermission("_RESERVED_31")).toBe(false);
|
||||
});
|
||||
|
||||
it("PERMISSION_BITMAP_ORDER 全部合法(_RESERVED_* 占位除外)", () => {
|
||||
for (const perm of PERMISSION_BITMAP_ORDER) {
|
||||
expect(isValidPermission(perm)).toBe(true);
|
||||
if (perm.startsWith("_RESERVED_")) {
|
||||
// 占位不应视为合法权限点
|
||||
expect(isValidPermission(perm)).toBe(false);
|
||||
} else {
|
||||
expect(isValidPermission(perm)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("全量编解码压力测试", () => {
|
||||
it("全部权限点编码后解码应还原(去重比较,已知 GRADE_READ 在 ORDER 中重复)", () => {
|
||||
it("全部权限点编码后解码应还原(含 _RESERVED_31 占位,P0-6 去重后无 GRADE_READ 重复)", () => {
|
||||
const allPerms = [...new Set(PERMISSION_BITMAP_ORDER)];
|
||||
const encoded = encodePermissionsBitmap(allPerms);
|
||||
const decoded = decodePermissionsBitmap(encoded);
|
||||
@@ -177,5 +186,19 @@ describe("permission-bitmap", () => {
|
||||
// 67 bit → base36 约 14 字符
|
||||
expect(encoded.length).toBeLessThan(20);
|
||||
});
|
||||
|
||||
it("GRADE_READ 在 PERMISSION_BITMAP_ORDER 中仅出现一次(P0-6 去重铁律)", () => {
|
||||
const occurrences = PERMISSION_BITMAP_ORDER.filter(
|
||||
(p) => p === "GRADE_READ",
|
||||
).length;
|
||||
expect(occurrences).toBe(1);
|
||||
});
|
||||
|
||||
it("_RESERVED_31 占位存在且永不作为有效权限点(P0-6 占位铁律)", () => {
|
||||
// 占位存在于 ORDER(保留 bit 位语义)
|
||||
expect(PERMISSION_BITMAP_ORDER).toContain("_RESERVED_31");
|
||||
// 但 isValidPermission 不应将其视为合法权限点(防误用)
|
||||
expect(isValidPermission("_RESERVED_31")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -267,14 +267,44 @@ export const DASHBOARD_ROUTE_PERMISSIONS: Record<
|
||||
*
|
||||
* 用于 /api/* 路径的权限校验。
|
||||
* 注意:API Route 通常需要更严格的权限校验,因为它们直接操作数据。
|
||||
*
|
||||
* P0-2(ARCHITECTURE.md §6.2 §3.4 V3-A3):新增 auth/graphql 公开端点。
|
||||
*/
|
||||
export const API_ROUTE_PERMISSIONS: Record<string, RoutePermissionConfig> = {
|
||||
// 错误上报端点:所有登录用户可访问
|
||||
// 错误上报端点:所有登录用户可访问(空 config 表示登录即可)
|
||||
"/api/log": {},
|
||||
// 健康检查:公开
|
||||
"/api/healthz": {},
|
||||
"/api/health": {},
|
||||
"/api/ready": {},
|
||||
// 认证端点:公开(未登录也要能调登录接口)
|
||||
"/api/auth/login": {},
|
||||
"/api/auth/logout": {},
|
||||
// GraphQL 同域代理:登录即可(细粒度由后端 resolver 把关)
|
||||
"/api/graphql": {},
|
||||
};
|
||||
|
||||
/**
|
||||
* 5. 公共路由白名单(P0-2,ARCHITECTURE.md §6.2 §3.4 V3-A3 / §11.7 红线 #5)
|
||||
*
|
||||
* 这些路由允许匿名访问(登录前/无身份也能访问)。
|
||||
* middleware 对白名单路由跳过身份校验,直接放行。
|
||||
*
|
||||
* 注意:白名单外的路由,未登录访问 → middleware 重定向到 /login。
|
||||
*/
|
||||
export const PUBLIC_ROUTES: readonly string[] = [
|
||||
"/",
|
||||
"/login",
|
||||
"/shell/forbidden",
|
||||
"/api/health",
|
||||
"/api/healthz",
|
||||
"/api/ready",
|
||||
"/api/log",
|
||||
"/api/auth/login",
|
||||
"/api/auth/logout",
|
||||
"/api/graphql",
|
||||
];
|
||||
|
||||
/**
|
||||
* 校验权限配置的合法性(开发时辅助)
|
||||
*
|
||||
@@ -343,6 +373,11 @@ export function checkRoutePermission(
|
||||
userBitmap: string,
|
||||
userRole: Role,
|
||||
): RoutePermissionResult {
|
||||
// 0. 公共路由白名单优先(含 /shell/forbidden 自身,避免循环重定向)
|
||||
if (PUBLIC_ROUTES.includes(pathname)) {
|
||||
return { allowed: true, matchedPath: "PUBLIC" };
|
||||
}
|
||||
|
||||
// 1. 匹配精确路由
|
||||
const exactConfig = EXACT_ROUTE_PERMISSIONS[pathname];
|
||||
if (exactConfig) {
|
||||
@@ -368,14 +403,23 @@ export function checkRoutePermission(
|
||||
if (apiConfig) {
|
||||
return evaluateConfig(apiConfig, userBitmap, userRole, pathname);
|
||||
}
|
||||
// 未配置的 API 路由默认拒绝
|
||||
// 未配置的 API 路由默认拒绝(fail-closed,§11.7 红线 #5)
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "no_config",
|
||||
};
|
||||
}
|
||||
|
||||
// 5. 未匹配任何配置:默认放行(如 / /login /shell/forbidden 等公共路由)
|
||||
// 5. /shell/** 下未登记路由 → 默认拒绝(fail-closed,§3.4 V3-A3 / §11.7 红线 #5)
|
||||
// 防止"幽灵路由"绕过门禁;新增路由必须显式登记到 EXACT/PREFIX/DASHBOARD 表
|
||||
if (pathname.startsWith("/shell/") || pathname === "/shell") {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "no_config",
|
||||
};
|
||||
}
|
||||
|
||||
// 6. 其他未匹配路由(如 /favicon.ico / 静态资源)默认放行
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user