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

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