本提交重构了全栈的认证与安全体系,包含以下核心变更: 1. **后端安全增强**: - 使用常量时间比较修复CSRF校验时序漏洞 - 重构refresh token存储为哈希+密文,支持轮转宽限期与盗用检测 - 新增cookie前缀自动配置,支持__Host-前缀强化生产环境安全 - 优化登出逻辑为单设备登出,合并用户信息与未读通知接口 - 增加数据库迁移脚本,兼容旧版refresh token数据升级 - 新增定时清理过期refresh token任务 2. **前端安全与体验优化**: - 新增cookie工具库,统一管理认证cookie名与转发逻辑 - 修复开放重定向漏洞,实现安全跳转校验 - 重构SSR请求逻辑,仅转发必要的access token cookie - 优化middleware轮转逻辑,修复并发请求去重与边缘部署兼容性 - 修复登录态静默校正逻辑,兼容旧版cookie路径与名称 - 新增环境变量示例配置文件 3. **工程化改进**: 修复Next.js类型文件导入路径,统一前后端配置与命名规范
182 lines
6.8 KiB
TypeScript
182 lines
6.8 KiB
TypeScript
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?)$).*)",
|
||
],
|
||
};
|