Files
jiang13-bbs/frontend/middleware.ts
freefire d784b0ea7a feat: 交付官方 Docker 运行时,外观改背景图并下线自定义 CSS/JS
站点/后台分轨背景与用户列表排序一并落地;生产 CORS 改走 SITE_URL,健康检查带版本号。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 05:27:33 +08:00

189 lines
7.2 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.
import { NextResponse, type NextRequest } from "next/server";
import { TOKEN_COOKIE, REFRESH_COOKIE, CSRF_COOKIE } from "@/lib/cookies";
// SSR 登录态保障:
// access token(j13_token,15 分钟)过期后,页面/RSC 请求到达时先在此静默轮转,
// 新 cookie 同时①注入本次请求头供 layout 的 /api/me 使用 ②透传给浏览器。
// refresh token 是一次性轮转(旧的立即吊销),而 RSC 预取与真实导航、甚至多
// 运行时实例可能几乎同时发起轮转:
// - 同一实例内用 in-flight Promise 去重(只合并进行中的请求,失败不缓存);
// - 跨实例的重复请求由后端宽限期兜底(返回同一个新 token 对)。
// 服务端专用地址;边缘部署(如 Cloudflare Workers)必须通过 BACKEND_URL /
// NEXT_PUBLIC_API_URL 显式配置(生产构建不保留 localhost 兜底,避免在边缘
// 环境发起必然失败的请求;未配置时放行,由客户端 fetchWithRefresh 兜底)。
const API_BASE =
process.env.BACKEND_URL ||
process.env.NEXT_PUBLIC_API_URL ||
(process.env.NODE_ENV === "production" ? "" : "http://localhost:3001");
const EXP_SKEW_SECONDS = 30; // 提前 30s 视为过期,规避服务端时钟差
const REFRESH_TIMEOUT_MS = 8000;
type RefreshResult = { ok: boolean; setCookies: string[] };
// 进行中的轮转表:key 为 refresh token 的 SHA-256(不持有明文),
// 请求结束即删除,失败结果绝不缓存
const inflight = new Map<string, Promise<RefreshResult>>();
function isAccessTokenExpired(token: string | undefined): boolean {
if (!token) return true;
try {
const seg = token.split(".")[1]?.replace(/-/g, "+").replace(/_/g, "/");
if (!seg) return true;
const payload = JSON.parse(atob(seg)) as { exp?: number };
// 只读取过期时间,验签由后端负责
return typeof payload.exp !== "number"
? true
: Date.now() >= (payload.exp - EXP_SKEW_SECONDS) * 1000;
} catch {
return true;
}
}
async function hashToken(token: string): Promise<string> {
const data = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
return Array.from(new Uint8Array(data), (b) => b.toString(16).padStart(2, "0")).join("");
}
// 从合并形式的 set-cookie 头中拆出多条(部分运行时没有 headers.getSetCookie())。
// Expires 属性格式含 ", ",不能直接按逗号切分:只有顶层段是 "非属性名=..."
// 才是一条新 cookie 的开始,其余片段拼回上一条。
const COOKIE_ATTR_NAMES = new Set([
"expires",
"max-age",
"domain",
"path",
"samesite",
"secure",
"httponly",
"priority",
]);
function splitCombinedSetCookie(raw: string): string[] {
const segments = raw.split(", ");
const cookies: string[] = [];
for (const seg of segments) {
const pair = seg.split(";", 1)[0] ?? "";
const eq = pair.indexOf("=");
const name = eq > 0 ? pair.slice(0, eq).trim().toLowerCase() : "";
if (eq > 0 && name && !COOKIE_ATTR_NAMES.has(name)) {
cookies.push(seg);
} else if (cookies.length > 0) {
cookies[cookies.length - 1] += ", " + seg;
}
}
return cookies;
}
function readSetCookies(res: Response): string[] {
const headers = res.headers as unknown as {
getSetCookie?: () => string[];
};
if (typeof headers.getSetCookie === "function") {
try {
const list = headers.getSetCookie();
if (list.length > 0) return list;
} catch {
// 落到手动解析
}
}
const raw = res.headers.get("set-cookie");
return raw ? splitCombinedSetCookie(raw) : [];
}
async function doRotate(refreshToken: string, csrf: string): Promise<RefreshResult> {
const res = await fetch(`${API_BASE}/api/auth/refresh`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrf,
Cookie: `${REFRESH_COOKIE}=${refreshToken}; ${CSRF_COOKIE}=${csrf}`,
},
cache: "no-store",
signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS),
}).catch(() => null);
if (!res) return { ok: false, setCookies: [] };
// 成功=新三枚 cookie;失败(refresh 过期/被吊销)=后端下发的清除指令
const setCookies = readSetCookies(res);
return { ok: res.ok && setCookies.length > 0, setCookies };
}
// 同一 refresh token 的并发请求共享同一个进行中的 Promise(完成即删除,不缓存结果)
async function rotateRefreshToken(refreshToken: string, csrf: string): Promise<RefreshResult> {
const key = await hashToken(refreshToken);
const existing = inflight.get(key);
if (existing) return existing;
const p = doRotate(refreshToken, csrf).finally(() => {
inflight.delete(key);
});
inflight.set(key, p);
return p;
}
// 从 Set-Cookie 头提取 name=value
function parseCookiePair(setCookie: string): [string, string] | null {
const pair = setCookie.split(";", 1)[0] ?? "";
const eq = pair.indexOf("=");
if (eq <= 0) return null;
return [pair.slice(0, eq).trim(), pair.slice(eq + 1).trim()];
}
/** 透传 pathname 给根布局,用于 /admin 与公开站 chrome 分叉 */
function withPathname(req: NextRequest, init?: { request?: { headers: Headers } }) {
const headers = new Headers(init?.request?.headers ?? req.headers);
headers.set("x-pathname", req.nextUrl.pathname);
return NextResponse.next({ request: { headers } });
}
export async function middleware(req: NextRequest) {
const refreshToken = req.cookies.get(REFRESH_COOKIE)?.value;
const accessToken = req.cookies.get(TOKEN_COOKIE)?.value;
// 游客、access 仍有效、或服务端地址未配置:直接放行
if (!refreshToken || !isAccessTokenExpired(accessToken) || !API_BASE) {
return withPathname(req);
}
const csrf = req.cookies.get(CSRF_COOKIE)?.value ?? "";
try {
const { ok, setCookies } = await rotateRefreshToken(refreshToken, csrf);
if (ok) {
// 以旧 jar 为基础覆盖轮转结果,保证本次 SSR 的 cookies() 读到新 access
const jar = new Map<string, string>();
req.cookies.getAll().forEach((c) => jar.set(c.name, c.value));
for (const sc of setCookies) {
const parsed = parseCookiePair(sc);
if (parsed) jar.set(parsed[0], parsed[1]);
}
const headers = new Headers(req.headers);
headers.set("Cookie", [...jar].map(([k, v]) => `${k}=${v}`).join("; "));
const res = withPathname(req, { request: { headers } });
// 原样透传,HttpOnly/Path/SameSite/Secure 等属性全部以后端为准
for (const sc of setCookies) res.headers.append("Set-Cookie", sc);
return res;
}
if (setCookies.length > 0) {
// refresh 已失效:透传后端的清 cookie 指令,避免之后每次请求都白轮转
const res = withPathname(req);
for (const sc of setCookies) res.headers.append("Set-Cookie", sc);
return res;
}
} catch {
// 后端不可达:降级匿名渲染,客户端 fetchWithRefresh 仍可兜底
}
return withPathname(req);
}
export const config = {
// 仅拦截页面与 RSC 请求;/api 由客户端 fetchWithRefresh 处理,静态资源放行
matcher: [
"/((?!api/|healthz|_next/static/|_next/image/|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|txt|woff2?)$).*)",
],
};