Files
jiang13-bbs/frontend/middleware.ts
freefire 055688a6cf refactor: 完成前后端认证体系重构与安全增强
本提交重构了全栈的认证与安全体系,包含以下核心变更:
1. **后端安全增强**:
   - 使用常量时间比较修复CSRF校验时序漏洞
   - 重构refresh token存储为哈希+密文,支持轮转宽限期与盗用检测
   - 新增cookie前缀自动配置,支持__Host-前缀强化生产环境安全
   - 优化登出逻辑为单设备登出,合并用户信息与未读通知接口
   - 增加数据库迁移脚本,兼容旧版refresh token数据升级
   - 新增定时清理过期refresh token任务

2. **前端安全与体验优化**:
   - 新增cookie工具库,统一管理认证cookie名与转发逻辑
   - 修复开放重定向漏洞,实现安全跳转校验
   - 重构SSR请求逻辑,仅转发必要的access token cookie
   - 优化middleware轮转逻辑,修复并发请求去重与边缘部署兼容性
   - 修复登录态静默校正逻辑,兼容旧版cookie路径与名称
   - 新增环境变量示例配置文件

3. **工程化改进**:
   修复Next.js类型文件导入路径,统一前后端配置与命名规范
2026-09-12 15:23:13 +08:00

182 lines
6.8 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()];
}
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 NextResponse.next();
}
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 = NextResponse.next({ 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 = NextResponse.next();
for (const sc of setCookies) res.headers.append("Set-Cookie", sc);
return res;
}
} catch {
// 后端不可达:降级匿名渲染,客户端 fetchWithRefresh 仍可兜底
}
return NextResponse.next();
}
export const config = {
// 仅拦截页面与 RSC 请求;/api 由客户端 fetchWithRefresh 处理,静态资源放行
matcher: [
"/((?!api/|_next/static/|_next/image/|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|txt|woff2?)$).*)",
],
};