1. 后端调整refresh cookie路径与注释,完善认证cookie处理逻辑 2. 前端全局layout新增next/font自托管字体,消除FOUT闪动 3. 实现SSR直出登录态与未读通知数,避免页面加载闪动 4. 新增中间件处理access token静默轮转,保障SSR登录态 5. 优化登录、改密流程,添加页面刷新同步登录态 6. 重构通知铃铛组件支持初始未读数传入
117 lines
4.4 KiB
TypeScript
117 lines
4.4 KiB
TypeScript
import { NextResponse, type NextRequest } from "next/server";
|
||
|
||
// SSR 登录态保障:
|
||
// access token(j13_token,15 分钟)过期后,页面/RSC 请求到达时先在此静默轮转,
|
||
// 新 cookie 同时①注入本次请求头供 layout 的 /api/me 使用 ②透传给浏览器。
|
||
// 注意 refresh token 是一次性轮转(旧的立即吊销),而 RSC 预取与真实导航可能
|
||
// 几乎同时发生,故对同一 refresh token 的并发请求做短窗口去重,避免第二次轮转
|
||
// 被后端判为重用而清空全部登录态。
|
||
|
||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
|
||
const EXP_SKEW_SECONDS = 30; // 提前 30s 视为过期,规避服务端时钟差
|
||
const DEDUP_TTL_MS = 10_000;
|
||
|
||
type RefreshResult = { ok: boolean; setCookies: string[] };
|
||
|
||
// 模块级去重表:同一 refresh token 在短窗口内只轮转一次
|
||
const inflight = new Map<string, { at: number; p: 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 rotateRefreshToken(refreshToken: string, csrf: string): Promise<RefreshResult> {
|
||
const cached = inflight.get(refreshToken);
|
||
if (cached && Date.now() - cached.at < DEDUP_TTL_MS) return cached.p;
|
||
|
||
const p = (async (): Promise<RefreshResult> => {
|
||
const res = await fetch(`${API_BASE}/api/auth/refresh`, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"X-CSRF-Token": csrf,
|
||
Cookie: `j13_refresh=${refreshToken}; j13_csrf=${csrf}`,
|
||
},
|
||
cache: "no-store",
|
||
}).catch(() => null);
|
||
|
||
if (!res) return { ok: false, setCookies: [] };
|
||
// 成功=新三枚 cookie;失败(refresh 过期/被吊销)=后端下发的清除指令
|
||
const setCookies = res.headers.getSetCookie();
|
||
return { ok: res.ok && setCookies.length > 0, setCookies };
|
||
})();
|
||
|
||
inflight.set(refreshToken, { at: Date.now(), 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("j13_refresh")?.value;
|
||
const accessToken = req.cookies.get("j13_token")?.value;
|
||
|
||
// 游客,或 access 仍然有效:直接放行
|
||
if (!refreshToken || !isAccessTokenExpired(accessToken)) {
|
||
return NextResponse.next();
|
||
}
|
||
|
||
const csrf = req.cookies.get("j13_csrf")?.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?)$).*)",
|
||
],
|
||
};
|