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

@@ -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);