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

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",