refactor: 完成前后端认证体系重构与安全增强

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

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

3. **工程化改进**:
   修复Next.js类型文件导入路径,统一前后端配置与命名规范
This commit is contained in:
2026-09-12 15:23:13 +08:00
parent 995e0b2c1f
commit 055688a6cf
17 changed files with 665 additions and 188 deletions

11
frontend/.env.example Normal file
View File

@@ -0,0 +1,11 @@
# 前端环境变量示例:复制为 .env.local(本地)或在部署平台配置
# 客户端交互请求走同源相对路径 /api/*(由 next.config.ts rewrite 代理),
# 以下地址仅用于 SSR 服务端渲染与 middleware 静默轮转直连后端。
# SSR/浏览器构建均可读;本地开发默认值即 http://localhost:3001
NEXT_PUBLIC_API_URL=http://localhost:3001
# 仅服务端可见(middleware 与 SSR 优先使用)。
# 部署到 Cloudflare Workers 等边缘环境时【必须】显式配置为后端 HTTPS 地址,
# 边缘运行时不存在 localhost,留空会导致 SSR 登录态轮转直接放行失效。
# BACKEND_URL=https://your-backend.example.com

View File

@@ -3,6 +3,7 @@ import type { Metadata } from "next";
import { cookies } from "next/headers";
import { PenSquare, MessagesSquare, ChevronRight } from "lucide-react";
import { fetchPosts, fetchBoards } from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import Pagination from "@/components/Pagination";
import PostRow from "@/components/PostRow";
import PinnedPosts from "@/components/PinnedPosts";
@@ -26,7 +27,7 @@ export default async function BoardPage({ params, searchParams }: PageProps) {
const { id } = await params;
const { page: pageStr } = await searchParams;
const page = Math.max(1, parseInt(pageStr || "1", 10) || 1);
const cookie = (await cookies()).toString();
const cookie = authCookieHeader(await cookies());
const [postsData, boardsData] = await Promise.all([
fetchPosts(page, 20, Number(id), "latest", "", cookie),
fetchBoards(),

View File

@@ -5,7 +5,8 @@ import "./globals.css";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import Toaster from "@/components/Toaster";
import { fetchMe, fetchUnreadCount, type User } from "@/lib/api";
import { fetchMe, type User } from "@/lib/api";
import { TOKEN_COOKIE, REFRESH_COOKIE, authCookieHeader } from "@/lib/cookies";
// 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正,
// 消除 F5 时 Google CDN 字体到达后整页"由粗变细"的 FOUT 闪动
@@ -52,21 +53,19 @@ export default async function RootLayout({
const theme = cookieStore.get("j13-theme")?.value === "dark" ? "dark" : "light";
// SSR 直出登录态(access 过期时 middleware 已先行轮转,见 middleware.ts),
// 保证 F5 初始 HTML 中的右上角就是正确的用户区,而非先闪"登录/注册"
const cookieHeader = cookieStore.toString();
// 保证 F5 初始 HTML 中的右上角就是正确的用户区,而非先闪"登录/注册"。
// 旧版 refresh cookie 可能是 Path=/api/auth(页面请求不带),因此只要
// token 或 refresh 任一存在就向后端确认一次,避免半登录态(列表点赞态与
// Header 游客态不一致),并兼容 access 15 分钟内的自然窗口。
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 {
// 后端不可用时降级为游客视图,不阻塞页面渲染
}
const hasAuthCookie =
!!cookieStore.get(TOKEN_COOKIE)?.value || !!cookieStore.get(REFRESH_COOKIE)?.value;
if (hasAuthCookie) {
// 用户信息与未读数合并为一次请求;只转发最小化的 access cookie
const me = await fetchMe(authCookieHeader(cookieStore) || undefined);
user = me.user;
unread = me.unread_count;
}
return (

View File

@@ -6,10 +6,18 @@ import Link from "next/link";
import { MessagesSquare } from "lucide-react";
import { apiLogin } from "@/lib/api";
// 仅允许同源相对路径跳转,拦截 //host、/\\host 与绝对 URL 形式的开放重定向
function safeRedirect(to: string | null): string {
if (!to || !to.startsWith("/") || to.startsWith("//") || to.startsWith("/\\")) {
return "/";
}
return to;
}
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const redirect = searchParams.get("redirect") || "/";
const redirect = safeRedirect(searchParams.get("redirect"));
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");

View File

@@ -3,6 +3,7 @@ import type { Metadata } from "next";
import { cookies } from "next/headers";
import { PenSquare, SearchX, MessagesSquare, Check, ArrowRight } from "lucide-react";
import { fetchPosts, fetchBoards } from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import Pagination from "@/components/Pagination";
import PostRow from "@/components/PostRow";
import PinnedPosts from "@/components/PinnedPosts";
@@ -20,7 +21,8 @@ export default async function HomePage({ searchParams }: HomePageProps) {
const { q, page: pageStr } = await searchParams;
const keyword = q?.trim() || "";
const page = Math.max(1, parseInt(pageStr || "1", 10) || 1);
const cookie = (await cookies()).toString();
// 仅转发最小化的 access cookie 供后端填充 liked 状态(不携带 refresh 等)
const cookie = authCookieHeader(await cookies());
const [postsData, boardsData] = await Promise.all([
fetchPosts(page, 20, undefined, "latest", keyword, cookie),
fetchBoards(),

View File

@@ -3,6 +3,7 @@ import Link from "next/link";
import { cookies } from "next/headers";
import ReactMarkdown from "react-markdown";
import { fetchPostDetail, fetchComments } from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import CommentSection from "@/components/CommentSection";
import PostActions from "@/components/PostActions";
import LikeButton from "@/components/LikeButton";
@@ -35,7 +36,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
export default async function PostDetailPage({ params }: PageProps) {
const { id } = await params;
const cookie = (await cookies()).toString();
const cookie = authCookieHeader(await cookies());
const { post } = await fetchPostDetail(id, cookie);
const { comments } = await fetchComments(id);

View File

@@ -17,7 +17,8 @@ import {
Bell,
Home,
} from "lucide-react";
import { apiLogout, type User } from "@/lib/api";
import { apiLogout, apiMe, type User } from "@/lib/api";
import { hasAuthCookieHint } from "@/lib/cookies";
import NotificationBell from "./NotificationBell";
import Avatar from "./Avatar";
@@ -36,6 +37,7 @@ export default function Header({
const [theme, setTheme] = useState<"light" | "dark">(initialTheme);
// 登录态以 SSR 直出为初始值,F5 无切换闪动;router.refresh() 后随 props 校正
const [user, setUser] = useState<User | null>(initialUser);
const [unread, setUnread] = useState(initialUnread);
const [searchQuery, setSearchQuery] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null);
@@ -44,6 +46,23 @@ export default function Header({
setUser(initialUser);
}, [initialUser]);
useEffect(() => {
setUnread(initialUnread);
}, [initialUnread]);
// 挂载后静默校正一次(仅一次):覆盖 SSR 期间后端不可用、旧 Path refresh
// 半登录等边缘情况。正常 F5 下结果与 SSR 一致,不产生可见闪动;
// 无可读 CSRF cookie 说明必然是游客,跳过请求
useEffect(() => {
if (!hasAuthCookieHint()) return;
apiMe()
.then((res) => {
setUser(res.user);
if (typeof res.unread_count === "number") setUnread(res.unread_count);
})
.catch(() => {});
}, []);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
const q = searchQuery.trim();
@@ -184,7 +203,7 @@ export default function Header({
<button onClick={toggleTheme} className={iconBtn} style={iconBtnStyle} aria-label="切换主题">
{theme === "dark" ? <Sun size={17} /> : <Moon size={17} />}
</button>
{user && <NotificationBell initialUnread={initialUnread} />}
{user && <NotificationBell initialUnread={unread} />}
{user ? (
<>
<Link href="/compose" className="btn btn-primary btn-sm ml-1">
@@ -221,7 +240,7 @@ export default function Header({
{/* 移动端按钮 */}
<div className="flex items-center gap-2 lg:hidden">
{user && <NotificationBell initialUnread={initialUnread} />}
{user && <NotificationBell initialUnread={unread} />}
<button
onClick={() => setMenuOpen(!menuOpen)}
className="w-10 h-10 flex items-center justify-center rounded-full"

View File

@@ -1,8 +1,23 @@
import { CSRF_COOKIE } from "./cookies";
// API 基础配置
// 注意:客户端请求使用相对路径 /api/*,走 Next.js rewrite 代理到后端,
// 这样浏览器视为同源,Cookie 自动携带,无需处理 CORS。
// SSR 端(fetchPosts 等公开接口)直接请求后端 API_BASE。
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
// SSR/middleware 直连后端:优先服务端专用 BACKEND_URL(边缘部署必须显式配置,
// localhost 兜底只在本地开发有效),回退到 NEXT_PUBLIC_API_URL。
const API_BASE =
process.env.BACKEND_URL || process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
// SSR 请求超时:后端不可用时快速降级为游客视图,不阻塞页面渲染
const SSR_TIMEOUT_MS = 8000;
function ssrInit(headers?: HeadersInit): RequestInit {
return {
cache: "no-store",
headers,
signal: AbortSignal.timeout(SSR_TIMEOUT_MS),
};
}
// 类型定义
export interface User {
@@ -13,6 +28,12 @@ export interface User {
role: string;
}
// /api/me 响应:用户信息 + 未读通知数(SSR layout 一次请求直出)
export interface MeResponse {
user: User | null;
unread_count: number;
}
export interface Board {
id: number;
name: string;
@@ -132,10 +153,11 @@ export interface NotificationsResponse {
size: number;
}
// 从 cookie 读取 CSRF token(j13_csrf 为非 HttpOnly,前端可读)
// 从 cookie 读取 CSRF token(j13_csrf 为非 HttpOnly,前端可读;生产名为 __Host- 前缀)
function getCSRFToken(): string {
if (typeof document === "undefined") return "";
const match = document.cookie.match(/(?:^|;\s*)j13_csrf=([^;]+)/);
const escaped = CSRF_COOKIE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${escaped}=([^;]+)`));
return match ? decodeURIComponent(match[1]) : "";
}
@@ -205,70 +227,53 @@ export async function fetchPosts(
const params = new URLSearchParams({ page: String(page), size: String(size), sort });
if (boardId) params.set("board_id", String(boardId));
if (keyword) params.set("keyword", keyword);
const res = await fetch(`${API_BASE}/api/posts?${params}`, {
cache: "no-store",
headers: cookieHeaders(cookieHeader),
});
const res = await fetch(`${API_BASE}/api/posts?${params}`, ssrInit(cookieHeaders(cookieHeader)));
if (!res.ok) throw new Error("获取帖子失败");
return res.json();
}
export async function fetchBoards(): Promise<{ boards: Board[] }> {
const res = await fetch(`${API_BASE}/api/boards`, { cache: "no-store" });
const res = await fetch(`${API_BASE}/api/boards`, ssrInit());
if (!res.ok) throw new Error("获取板块失败");
return res.json();
}
export async function fetchPostDetail(id: string, cookieHeader?: string): Promise<{ post: Post }> {
const res = await fetch(`${API_BASE}/api/posts/${id}`, {
cache: "no-store",
headers: cookieHeaders(cookieHeader),
});
const res = await fetch(`${API_BASE}/api/posts/${id}`, ssrInit(cookieHeaders(cookieHeader)));
if (!res.ok) throw new Error("获取帖子失败");
return res.json();
}
export async function fetchComments(postId: string): Promise<{ comments: Comment[] }> {
const res = await fetch(`${API_BASE}/api/posts/${postId}/comments`, { cache: "no-store" });
const res = await fetch(`${API_BASE}/api/posts/${postId}/comments`, ssrInit());
if (!res.ok) throw new Error("获取评论失败");
return res.json();
}
export async function fetchUserProfile(id: string, page = 1, cookieHeader?: string): Promise<UserProfile> {
const params = new URLSearchParams({ page: String(page), size: "20" });
const res = await fetch(`${API_BASE}/api/users/${id}?${params}`, {
cache: "no-store",
headers: cookieHeaders(cookieHeader),
});
const res = await fetch(`${API_BASE}/api/users/${id}?${params}`, ssrInit(cookieHeaders(cookieHeader)));
if (res.status === 404) throw new Error("用户不存在");
if (!res.ok) throw new Error("获取用户资料失败");
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;
// SSR 端获取当前登录态(用户信息 + 未读通知数合并为一次请求),
// 供 layout 直出右上角用户区与铃铛红点,避免客户端水合后再切换
export async function fetchMe(cookieHeader?: string): Promise<MeResponse> {
try {
const res = await fetch(`${API_BASE}/api/me`, ssrInit(cookieHeaders(cookieHeader)));
if (!res.ok) return { user: null, unread_count: 0 };
return res.json();
} catch {
// 超时/后端不可用:降级游客视图,不阻塞页面渲染
return { user: null, unread_count: 0 };
}
}
export async function fetchUserComments(id: string, page = 1): Promise<UserCommentsResponse> {
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}`, ssrInit());
if (res.status === 404) throw new Error("用户不存在");
if (!res.ok) throw new Error("获取评论失败");
return res.json();
@@ -276,8 +281,8 @@ export async function fetchUserComments(id: string, page = 1): Promise<UserComme
// ===== 客户端 API 调用(走 rewrite,携带 cookie + CSRF) =====
// 获取当前登录用户(依赖 OptionalAuth,未登录返回 { user: null })
export async function apiMe(): Promise<{ user: User | null }> {
// 获取当前登录用户(依赖 OptionalAuth,未登录返回 { user: null, unread_count: 0 })
export async function apiMe(): Promise<MeResponse> {
const res = await fetch("/api/me", {
credentials: "include",
cache: "no-store",

28
frontend/lib/cookies.ts Normal file
View File

@@ -0,0 +1,28 @@
// 认证 cookie 名(前后端必须严格一致):
// 生产构建(HTTPS)启用 __Host- 前缀,浏览器强制 Secure + Path=/ + 无 Domain;
// 后端按 !DevMode 同步启用(见 backend/service/auth.go ConfigureCookieNames)。
const HOST_PREFIX = process.env.NODE_ENV === "production" ? "__Host-" : "";
export const TOKEN_COOKIE = `${HOST_PREFIX}j13_token`;
export const REFRESH_COOKIE = `${HOST_PREFIX}j13_refresh`;
export const CSRF_COOKIE = `${HOST_PREFIX}j13_csrf`;
type CookieReader = {
get(name: string): { value: string } | undefined;
};
// 构造转发给后端的最小 Cookie 头:SSR 公开/鉴权接口只需要 access token 来
// 识别用户(填充 liked 等状态),不应把 refresh token、主题等无关 cookie 全量转发。
// 返回空串表示当前无登录凭据,调用方应省略 Cookie 头。
export function authCookieHeader(store: CookieReader): string {
const token = store.get(TOKEN_COOKIE);
return token ? `${TOKEN_COOKIE}=${token.value}` : "";
}
// 客户端判断浏览器是否可能持有登录态:j13_csrf 非 HttpOnly 可读,
// 生命周期与 refresh 一致,适合作为挂载后静默校正的触发信号。
export function hasAuthCookieHint(): boolean {
if (typeof document === "undefined") return false;
const escaped = CSRF_COOKIE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`(?:^|;\\s*)${escaped}=`).test(document.cookie);
}

View File

@@ -1,20 +1,29 @@
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 预取与真实导航可能
// 几乎同时发生,故对同一 refresh token 的并发请求做短窗口去重,避免第二次轮转
// 被后端判为重用而清空全部登录态。
// refresh token 是一次性轮转(旧的立即吊销),而 RSC 预取与真实导航、甚至多
// 运行时实例可能几乎同时发起轮转:
// - 同一实例内用 in-flight Promise 去重(只合并进行中的请求,失败不缓存);
// - 跨实例的重复请求由后端宽限期兜底(返回同一个新 token 对)。
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
// 服务端专用地址;边缘部署(如 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 DEDUP_TTL_MS = 10_000;
const REFRESH_TIMEOUT_MS = 8000;
type RefreshResult = { ok: boolean; setCookies: string[] };
// 模块级去重表:同一 refresh token 在短窗口内只轮转一次
const inflight = new Map<string, { at: number; p: Promise<RefreshResult> }>();
// 进行中的轮转表:key 为 refresh token 的 SHA-256(不持有明文),
// 请求结束即删除,失败结果绝不缓存
const inflight = new Map<string, Promise<RefreshResult>>();
function isAccessTokenExpired(token: string | undefined): boolean {
if (!token) return true;
@@ -31,28 +40,84 @@ function isAccessTokenExpired(token: string | undefined): boolean {
}
}
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 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 });
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;
}
@@ -65,15 +130,15 @@ function parseCookiePair(setCookie: string): [string, string] | null {
}
export async function middleware(req: NextRequest) {
const refreshToken = req.cookies.get("j13_refresh")?.value;
const accessToken = req.cookies.get("j13_token")?.value;
const refreshToken = req.cookies.get(REFRESH_COOKIE)?.value;
const accessToken = req.cookies.get(TOKEN_COOKIE)?.value;
// 游客,或 access 仍然有效:直接放行
if (!refreshToken || !isAccessTokenExpired(accessToken)) {
// 游客、access 仍有效、或服务端地址未配置:直接放行
if (!refreshToken || !isAccessTokenExpired(accessToken) || !API_BASE) {
return NextResponse.next();
}
const csrf = req.cookies.get("j13_csrf")?.value ?? "";
const csrf = req.cookies.get(CSRF_COOKIE)?.value ?? "";
try {
const { ok, setCookies } = await rotateRefreshToken(refreshToken, csrf);

View File

@@ -1,7 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
import "./.next/dev/types/routes.d.ts";
import "./.next/dev/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.