feat(frontend+backend): 实现SSR登录态优化与字体自托管
1. 后端调整refresh cookie路径与注释,完善认证cookie处理逻辑 2. 前端全局layout新增next/font自托管字体,消除FOUT闪动 3. 实现SSR直出登录态与未读通知数,避免页面加载闪动 4. 新增中间件处理access token静默轮转,保障SSR登录态 5. 优化登录、改密流程,添加页面刷新同步登录态 6. 重构通知铃铛组件支持初始未读数传入
This commit is contained in:
@@ -16,7 +16,8 @@ var (
|
|||||||
|
|
||||||
// setAuthCookies 设置认证 cookie:
|
// setAuthCookies 设置认证 cookie:
|
||||||
// - j13_token: access token(HttpOnly,15min)
|
// - j13_token: access token(HttpOnly,15min)
|
||||||
// - j13_refresh: refresh token(HttpOnly,7天,仅 /api/auth 路径)
|
// - j13_refresh: refresh token(HttpOnly,7天,Path=/,供 Next middleware 在
|
||||||
|
// 页面/RSC 请求中读取并静默轮转;HttpOnly+SameSite=Strict+CSRF 校验已足够防护)
|
||||||
// - j13_csrf: CSRF token(可读,7天,与 refresh 同生命周期,确保 refresh 流程可用)
|
// - j13_csrf: CSRF token(可读,7天,与 refresh 同生命周期,确保 refresh 流程可用)
|
||||||
func setAuthCookies(c *gin.Context, accessToken, refreshToken string, secure bool) {
|
func setAuthCookies(c *gin.Context, accessToken, refreshToken string, secure bool) {
|
||||||
csrfToken := service.GenerateCSRFToken()
|
csrfToken := service.GenerateCSRFToken()
|
||||||
@@ -31,11 +32,12 @@ func setAuthCookies(c *gin.Context, accessToken, refreshToken string, secure boo
|
|||||||
Secure: secure,
|
Secure: secure,
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: http.SameSiteStrictMode,
|
||||||
})
|
})
|
||||||
// Refresh token cookie(仅 /api/auth 路径使用,缩小攻击面)
|
// Refresh token cookie:Path=/ 以支持 SSR middleware 续期;
|
||||||
|
// 仅 /api/auth/refresh 端点消费,其他路径不读取
|
||||||
http.SetCookie(c.Writer, &http.Cookie{
|
http.SetCookie(c.Writer, &http.Cookie{
|
||||||
Name: service.RefreshCookieName,
|
Name: service.RefreshCookieName,
|
||||||
Value: refreshToken,
|
Value: refreshToken,
|
||||||
Path: "/api/auth",
|
Path: "/",
|
||||||
MaxAge: refreshCookieMaxAge,
|
MaxAge: refreshCookieMaxAge,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: secure,
|
Secure: secure,
|
||||||
@@ -55,8 +57,10 @@ func setAuthCookies(c *gin.Context, accessToken, refreshToken string, secure boo
|
|||||||
|
|
||||||
// clearAuthCookies 清除所有认证 cookie
|
// clearAuthCookies 清除所有认证 cookie
|
||||||
func clearAuthCookies(c *gin.Context) {
|
func clearAuthCookies(c *gin.Context) {
|
||||||
|
// refresh cookie 同时清除两个路径:Path=/api/auth 为旧版兼容
|
||||||
cookies := []struct{ name, path string }{
|
cookies := []struct{ name, path string }{
|
||||||
{service.CookieName, "/"},
|
{service.CookieName, "/"},
|
||||||
|
{service.RefreshCookieName, "/"},
|
||||||
{service.RefreshCookieName, "/api/auth"},
|
{service.RefreshCookieName, "/api/auth"},
|
||||||
{service.CSRFCookieName, "/"},
|
{service.CSRFCookieName, "/"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,12 +36,14 @@
|
|||||||
--shadow: 0 2px 8px rgba(87, 66, 36, 0.05), 0 10px 28px rgba(87, 66, 36, 0.05);
|
--shadow: 0 2px 8px rgba(87, 66, 36, 0.05), 0 10px 28px rgba(87, 66, 36, 0.05);
|
||||||
--shadow-lg: 0 6px 16px rgba(87, 66, 36, 0.07), 0 20px 48px rgba(87, 66, 36, 0.1);
|
--shadow-lg: 0 6px 16px rgba(87, 66, 36, 0.07), 0 20px 48px rgba(87, 66, 36, 0.1);
|
||||||
|
|
||||||
--font-display: "Plus Jakarta Sans", "Noto Sans SC", -apple-system, "PingFang SC",
|
/* next/font 构建期自托管,变量由 layout.tsx 注入;
|
||||||
"Microsoft YaHei", system-ui, sans-serif;
|
本地系统字体仅作网络字体分片就绪前的回退(PingFang/雅黑与 Noto 观感接近) */
|
||||||
--font-sans: "Plus Jakarta Sans", "Noto Sans SC", -apple-system, "PingFang SC",
|
--font-display: var(--font-jakarta), var(--font-noto-sc), -apple-system,
|
||||||
"Microsoft YaHei", system-ui, sans-serif;
|
"PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||||
--font-mono: "JetBrains Mono", "Noto Sans SC", ui-monospace, "Cascadia Code",
|
--font-sans: var(--font-jakarta), var(--font-noto-sc), -apple-system,
|
||||||
Consolas, monospace;
|
"PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||||
|
--font-mono: var(--font-jetbrains), var(--font-noto-sc), ui-monospace,
|
||||||
|
"Cascadia Code", Consolas, monospace;
|
||||||
|
|
||||||
--r: 18px;
|
--r: 18px;
|
||||||
--r-sm: 12px;
|
--r-sm: 12px;
|
||||||
|
|||||||
@@ -1,9 +1,33 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
import { Plus_Jakarta_Sans, Noto_Sans_SC, JetBrains_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import Header from "@/components/Header";
|
import Header from "@/components/Header";
|
||||||
import Footer from "@/components/Footer";
|
import Footer from "@/components/Footer";
|
||||||
import Toaster from "@/components/Toaster";
|
import Toaster from "@/components/Toaster";
|
||||||
|
import { fetchMe, fetchUnreadCount, type User } from "@/lib/api";
|
||||||
|
|
||||||
|
// 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正,
|
||||||
|
// 消除 F5 时 Google CDN 字体到达后整页"由粗变细"的 FOUT 闪动
|
||||||
|
const jakarta = Plus_Jakarta_Sans({
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["400", "500", "600", "700", "800"],
|
||||||
|
variable: "--font-jakarta",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
|
const notoSC = Noto_Sans_SC({
|
||||||
|
weight: ["400", "500", "700", "900"],
|
||||||
|
variable: "--font-noto-sc",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
|
const jetbrainsMono = JetBrains_Mono({
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["400", "500", "700"],
|
||||||
|
variable: "--font-jetbrains",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
@@ -27,21 +51,34 @@ export default async function RootLayout({
|
|||||||
// 浅色优先的设计系统,仅在显式选择暗色时挂 .dark
|
// 浅色优先的设计系统,仅在显式选择暗色时挂 .dark
|
||||||
const theme = cookieStore.get("j13-theme")?.value === "dark" ? "dark" : "light";
|
const theme = cookieStore.get("j13-theme")?.value === "dark" ? "dark" : "light";
|
||||||
|
|
||||||
|
// SSR 直出登录态(access 过期时 middleware 已先行轮转,见 middleware.ts),
|
||||||
|
// 保证 F5 初始 HTML 中的右上角就是正确的用户区,而非先闪"登录/注册"
|
||||||
|
const cookieHeader = cookieStore.toString();
|
||||||
|
let user: User | null = null;
|
||||||
|
let unread = 0;
|
||||||
|
if (cookieStore.get("j13_refresh")?.value) {
|
||||||
|
try {
|
||||||
|
const [me, count] = await Promise.all([
|
||||||
|
fetchMe(cookieHeader),
|
||||||
|
fetchUnreadCount(cookieHeader),
|
||||||
|
]);
|
||||||
|
user = me.user;
|
||||||
|
unread = count;
|
||||||
|
} catch {
|
||||||
|
// 后端不可用时降级为游客视图,不阻塞页面渲染
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="zh-CN" className={theme === "dark" ? "dark" : ""}>
|
<html
|
||||||
<head>
|
lang="zh-CN"
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
className={`${theme === "dark" ? "dark" : ""} ${jakarta.variable} ${notoSC.variable} ${jetbrainsMono.variable}`}
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
>
|
||||||
<link
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&family=Noto+Sans+SC:wght@400;500;700;900&display=swap"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
</head>
|
|
||||||
<body className="min-h-screen flex flex-col">
|
<body className="min-h-screen flex flex-col">
|
||||||
<a href="#main" className="skip-link">
|
<a href="#main" className="skip-link">
|
||||||
跳到主要内容
|
跳到主要内容
|
||||||
</a>
|
</a>
|
||||||
<Header />
|
<Header initialUser={user} initialUnread={unread} initialTheme={theme} />
|
||||||
<main
|
<main
|
||||||
id="main"
|
id="main"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ export default function LoginPage() {
|
|||||||
const res = await apiLogin(username, password);
|
const res = await apiLogin(username, password);
|
||||||
// 登录成功后后端通过 Set-Cookie 写入 HttpOnly JWT,前端无需存储 token
|
// 登录成功后后端通过 Set-Cookie 写入 HttpOnly JWT,前端无需存储 token
|
||||||
if (res.user) {
|
if (res.user) {
|
||||||
|
// root layout 在客户端导航中持久化,push 后再 refresh 让其重新取登录态
|
||||||
router.push(redirect);
|
router.push(redirect);
|
||||||
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
setError(res.error || "登录失败");
|
setError(res.error || "登录失败");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ export default function SettingsPage() {
|
|||||||
const res = await apiChangePassword(oldPassword, newPassword);
|
const res = await apiChangePassword(oldPassword, newPassword);
|
||||||
if (res.message) {
|
if (res.message) {
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
|
// 后端已使全部 token 失效并清 cookie,刷新 layout 让 Header 回到游客态
|
||||||
|
router.refresh();
|
||||||
setTimeout(() => router.push("/login"), 1500);
|
setTimeout(() => router.push("/login"), 1500);
|
||||||
} else {
|
} else {
|
||||||
setError(res.error || "修改失败");
|
setError(res.error || "修改失败");
|
||||||
|
|||||||
@@ -17,19 +17,33 @@ import {
|
|||||||
Bell,
|
Bell,
|
||||||
Home,
|
Home,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { apiMe, apiLogout, type User } from "@/lib/api";
|
import { apiLogout, type User } from "@/lib/api";
|
||||||
import NotificationBell from "./NotificationBell";
|
import NotificationBell from "./NotificationBell";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header({
|
||||||
|
initialUser,
|
||||||
|
initialUnread = 0,
|
||||||
|
initialTheme = "light",
|
||||||
|
}: {
|
||||||
|
initialUser: User | null;
|
||||||
|
initialUnread?: number;
|
||||||
|
initialTheme?: "light" | "dark";
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [theme, setTheme] = useState<"light" | "dark">("light");
|
const [theme, setTheme] = useState<"light" | "dark">(initialTheme);
|
||||||
const [user, setUser] = useState<User | null>(null);
|
// 登录态以 SSR 直出为初始值,F5 无切换闪动;router.refresh() 后随 props 校正
|
||||||
|
const [user, setUser] = useState<User | null>(initialUser);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// 登录/登出触发 router.refresh() 后,服务端 layout 重新取态并下发新 props
|
||||||
|
useEffect(() => {
|
||||||
|
setUser(initialUser);
|
||||||
|
}, [initialUser]);
|
||||||
|
|
||||||
const handleSearch = (e: React.FormEvent) => {
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const q = searchQuery.trim();
|
const q = searchQuery.trim();
|
||||||
@@ -45,9 +59,6 @@ export default function Header() {
|
|||||||
document.documentElement.classList.toggle("dark", savedTheme === "dark");
|
document.documentElement.classList.toggle("dark", savedTheme === "dark");
|
||||||
setTheme(savedTheme);
|
setTheme(savedTheme);
|
||||||
setSearchQuery(new URLSearchParams(window.location.search).get("q")?.trim() || "");
|
setSearchQuery(new URLSearchParams(window.location.search).get("q")?.trim() || "");
|
||||||
apiMe()
|
|
||||||
.then((res) => setUser(res.user || null))
|
|
||||||
.catch(() => setUser(null));
|
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
// "/" 快速聚焦搜索
|
// "/" 快速聚焦搜索
|
||||||
@@ -173,7 +184,7 @@ export default function Header() {
|
|||||||
<button onClick={toggleTheme} className={iconBtn} style={iconBtnStyle} aria-label="切换主题">
|
<button onClick={toggleTheme} className={iconBtn} style={iconBtnStyle} aria-label="切换主题">
|
||||||
{theme === "dark" ? <Sun size={17} /> : <Moon size={17} />}
|
{theme === "dark" ? <Sun size={17} /> : <Moon size={17} />}
|
||||||
</button>
|
</button>
|
||||||
{user && <NotificationBell />}
|
{user && <NotificationBell initialUnread={initialUnread} />}
|
||||||
{user ? (
|
{user ? (
|
||||||
<>
|
<>
|
||||||
<Link href="/compose" className="btn btn-primary btn-sm ml-1">
|
<Link href="/compose" className="btn btn-primary btn-sm ml-1">
|
||||||
@@ -210,7 +221,7 @@ export default function Header() {
|
|||||||
|
|
||||||
{/* 移动端按钮 */}
|
{/* 移动端按钮 */}
|
||||||
<div className="flex items-center gap-2 lg:hidden">
|
<div className="flex items-center gap-2 lg:hidden">
|
||||||
{user && <NotificationBell />}
|
{user && <NotificationBell initialUnread={initialUnread} />}
|
||||||
<button
|
<button
|
||||||
onClick={() => setMenuOpen(!menuOpen)}
|
onClick={() => setMenuOpen(!menuOpen)}
|
||||||
className="w-10 h-10 flex items-center justify-center rounded-full"
|
className="w-10 h-10 flex items-center justify-center rounded-full"
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ import {
|
|||||||
type NotificationItem,
|
type NotificationItem,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
|
||||||
export default function NotificationBell() {
|
export default function NotificationBell({ initialUnread = 0 }: { initialUnread?: number }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [unread, setUnread] = useState(0);
|
// SSR 直出未读数,红点不再挂载后才弹出;挂载后轮询自然校正
|
||||||
|
const [unread, setUnread] = useState(initialUnread);
|
||||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|||||||
@@ -245,6 +245,27 @@ export async function fetchUserProfile(id: string, page = 1, cookieHeader?: stri
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SSR 端获取当前登录用户(layout 直出登录态,避免客户端水合后再切换)
|
||||||
|
export async function fetchMe(cookieHeader?: string): Promise<{ user: User | null }> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/me`, {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: cookieHeaders(cookieHeader),
|
||||||
|
});
|
||||||
|
if (!res.ok) return { user: null };
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSR 端获取未读通知数(Header 铃铛红点直出)
|
||||||
|
export async function fetchUnreadCount(cookieHeader?: string): Promise<number> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/notifications/unread-count`, {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: cookieHeaders(cookieHeader),
|
||||||
|
});
|
||||||
|
if (!res.ok) return 0;
|
||||||
|
const data = await res.json();
|
||||||
|
return data.count || 0;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchUserComments(id: string, page = 1): Promise<UserCommentsResponse> {
|
export async function fetchUserComments(id: string, page = 1): Promise<UserCommentsResponse> {
|
||||||
const params = new URLSearchParams({ page: String(page), size: "20" });
|
const params = new URLSearchParams({ page: String(page), size: "20" });
|
||||||
const res = await fetch(`${API_BASE}/api/users/${id}/comments?${params}`, { cache: "no-store" });
|
const res = await fetch(`${API_BASE}/api/users/${id}/comments?${params}`, { cache: "no-store" });
|
||||||
|
|||||||
116
frontend/middleware.ts
Normal file
116
frontend/middleware.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
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?)$).*)",
|
||||||
|
],
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user