本提交重构了全栈的认证与安全体系,包含以下核心变更: 1. **后端安全增强**: - 使用常量时间比较修复CSRF校验时序漏洞 - 重构refresh token存储为哈希+密文,支持轮转宽限期与盗用检测 - 新增cookie前缀自动配置,支持__Host-前缀强化生产环境安全 - 优化登出逻辑为单设备登出,合并用户信息与未读通知接口 - 增加数据库迁移脚本,兼容旧版refresh token数据升级 - 新增定时清理过期refresh token任务 2. **前端安全与体验优化**: - 新增cookie工具库,统一管理认证cookie名与转发逻辑 - 修复开放重定向漏洞,实现安全跳转校验 - 重构SSR请求逻辑,仅转发必要的access token cookie - 优化middleware轮转逻辑,修复并发请求去重与边缘部署兼容性 - 修复登录态静默校正逻辑,兼容旧版cookie路径与名称 - 新增环境变量示例配置文件 3. **工程化改进**: 修复Next.js类型文件导入路径,统一前后端配置与命名规范
437 lines
12 KiB
TypeScript
437 lines
12 KiB
TypeScript
import { CSRF_COOKIE } from "./cookies";
|
||
|
||
// API 基础配置
|
||
// 注意:客户端请求使用相对路径 /api/*,走 Next.js rewrite 代理到后端,
|
||
// 这样浏览器视为同源,Cookie 自动携带,无需处理 CORS。
|
||
// 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 {
|
||
id: number;
|
||
username: string;
|
||
nickname: string;
|
||
avatar: string;
|
||
role: string;
|
||
}
|
||
|
||
// /api/me 响应:用户信息 + 未读通知数(SSR layout 一次请求直出)
|
||
export interface MeResponse {
|
||
user: User | null;
|
||
unread_count: number;
|
||
}
|
||
|
||
export interface Board {
|
||
id: number;
|
||
name: string;
|
||
description: string;
|
||
icon: string;
|
||
sort_order: number;
|
||
}
|
||
|
||
export interface PostListItem {
|
||
id: number;
|
||
board_id: number;
|
||
user_id: number;
|
||
title: string;
|
||
tags: string;
|
||
post_type: string;
|
||
pinned: number;
|
||
recommended: boolean;
|
||
like_count: number;
|
||
view_count: number;
|
||
comment_count: number;
|
||
liked: boolean;
|
||
created_at: string;
|
||
board: Board;
|
||
user: User;
|
||
}
|
||
|
||
export interface Post {
|
||
id: number;
|
||
board_id: number;
|
||
user_id: number;
|
||
title: string;
|
||
content: string;
|
||
tags: string;
|
||
post_type: string;
|
||
pinned: number;
|
||
recommended: boolean;
|
||
status: string;
|
||
like_count: number;
|
||
view_count: number;
|
||
comment_count: number;
|
||
liked: boolean;
|
||
created_at: string;
|
||
updated_at: string;
|
||
board: Board;
|
||
user: User;
|
||
}
|
||
|
||
export interface Comment {
|
||
id: number;
|
||
post_id: number;
|
||
user_id: number;
|
||
content: string;
|
||
status: string;
|
||
created_at: string;
|
||
user: User;
|
||
}
|
||
|
||
export interface PostsResponse {
|
||
posts: PostListItem[];
|
||
total: number;
|
||
page: number;
|
||
size: number;
|
||
}
|
||
|
||
export interface UserProfile {
|
||
user: {
|
||
id: number;
|
||
username: string;
|
||
nickname: string;
|
||
avatar: string;
|
||
role: string;
|
||
created_at: string;
|
||
};
|
||
stats: {
|
||
post_count: number;
|
||
comment_count: number;
|
||
};
|
||
posts: PostListItem[];
|
||
posts_total: number;
|
||
page: number;
|
||
size: number;
|
||
}
|
||
|
||
export interface UserCommentItem {
|
||
id: number;
|
||
post_id: number;
|
||
post_title: string;
|
||
content: string;
|
||
created_at: string;
|
||
}
|
||
|
||
export interface UserCommentsResponse {
|
||
comments: UserCommentItem[];
|
||
total: number;
|
||
page: number;
|
||
size: number;
|
||
}
|
||
|
||
export interface NotificationItem {
|
||
id: number;
|
||
user_id: number;
|
||
actor_id: number;
|
||
type: "comment" | "like";
|
||
post_id: number;
|
||
comment_id: number;
|
||
content: string;
|
||
is_read: boolean;
|
||
created_at: string;
|
||
actor: User;
|
||
post: { id: number; title: string };
|
||
}
|
||
|
||
export interface NotificationsResponse {
|
||
notifications: NotificationItem[];
|
||
total: number;
|
||
page: number;
|
||
size: number;
|
||
}
|
||
|
||
// 从 cookie 读取 CSRF token(j13_csrf 为非 HttpOnly,前端可读;生产名为 __Host- 前缀)
|
||
function getCSRFToken(): string {
|
||
if (typeof document === "undefined") return "";
|
||
const escaped = CSRF_COOKIE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${escaped}=([^;]+)`));
|
||
return match ? decodeURIComponent(match[1]) : "";
|
||
}
|
||
|
||
// 构造客户端 fetch 的通用 headers(含 CSRF token)
|
||
function clientHeaders(extra: Record<string, string> = {}): HeadersInit {
|
||
const headers: Record<string, string> = { ...extra };
|
||
const csrf = getCSRFToken();
|
||
if (csrf) headers["X-CSRF-Token"] = csrf;
|
||
return headers;
|
||
}
|
||
|
||
// ===== Refresh token 自动续期 =====
|
||
|
||
let refreshPromise: Promise<boolean> | null = null;
|
||
|
||
// 调用 /api/auth/refresh 刷新 access token(refresh cookie 由浏览器自动携带)
|
||
async function apiRefresh(): Promise<boolean> {
|
||
try {
|
||
const res = await fetch("/api/auth/refresh", {
|
||
method: "POST",
|
||
credentials: "include",
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.ok;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 带自动续期的 fetch:遇到 401 时尝试 refresh,成功后重试原请求
|
||
async function fetchWithRefresh(url: string, init: RequestInit): Promise<Response> {
|
||
let res = await fetch(url, { ...init, credentials: "include" });
|
||
|
||
if (res.status === 401) {
|
||
// 串行化 refresh:多个并发 401 只触发一次 refresh
|
||
if (!refreshPromise) {
|
||
refreshPromise = apiRefresh().finally(() => {
|
||
refreshPromise = null;
|
||
});
|
||
}
|
||
const refreshed = await refreshPromise;
|
||
if (refreshed) {
|
||
// refresh 成功,重试原请求(CSRF cookie 可能已更新)
|
||
const newHeaders = clientHeaders();
|
||
const mergedInit = { ...init, credentials: "include" as const, headers: newHeaders };
|
||
res = await fetch(url, mergedInit);
|
||
}
|
||
}
|
||
return res;
|
||
}
|
||
|
||
// ===== SSR 端 fetch(公开接口,直接请求后端) =====
|
||
|
||
// SSR 页面通过 (await cookies()).toString() 传入,后端据此识别登录用户并填充 liked 状态
|
||
function cookieHeaders(cookieHeader?: string): HeadersInit | undefined {
|
||
return cookieHeader ? { Cookie: cookieHeader } : undefined;
|
||
}
|
||
|
||
export async function fetchPosts(
|
||
page = 1,
|
||
size = 20,
|
||
boardId?: number,
|
||
sort = "latest",
|
||
keyword = "",
|
||
cookieHeader?: string
|
||
): Promise<PostsResponse> {
|
||
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}`, 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`, 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}`, 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`, 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}`, 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<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}`, ssrInit());
|
||
if (res.status === 404) throw new Error("用户不存在");
|
||
if (!res.ok) throw new Error("获取评论失败");
|
||
return res.json();
|
||
}
|
||
|
||
// ===== 客户端 API 调用(走 rewrite,携带 cookie + CSRF) =====
|
||
|
||
// 获取当前登录用户(依赖 OptionalAuth,未登录返回 { user: null, unread_count: 0 })
|
||
export async function apiMe(): Promise<MeResponse> {
|
||
const res = await fetch("/api/me", {
|
||
credentials: "include",
|
||
cache: "no-store",
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiLogin(username: string, password: string) {
|
||
const res = await fetch("/api/login", {
|
||
method: "POST",
|
||
credentials: "include",
|
||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||
body: JSON.stringify({ username, password }),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiRegister(username: string, email: string, password: string) {
|
||
const res = await fetch("/api/register", {
|
||
method: "POST",
|
||
credentials: "include",
|
||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||
body: JSON.stringify({ username, email, password }),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiLogout() {
|
||
const res = await fetchWithRefresh("/api/logout", {
|
||
method: "POST",
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiCreatePost(data: {
|
||
board_id: number;
|
||
title: string;
|
||
content: string;
|
||
tags?: string;
|
||
post_type?: string;
|
||
}) {
|
||
const res = await fetchWithRefresh("/api/posts", {
|
||
method: "POST",
|
||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||
body: JSON.stringify(data),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiCreateComment(postId: string, content: string) {
|
||
const res = await fetchWithRefresh(`/api/posts/${postId}/comments`, {
|
||
method: "POST",
|
||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||
body: JSON.stringify({ content }),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiUpdatePost(
|
||
postId: string,
|
||
data: { title?: string; content?: string; tags?: string }
|
||
) {
|
||
const res = await fetchWithRefresh(`/api/posts/${postId}`, {
|
||
method: "PUT",
|
||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||
body: JSON.stringify(data),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiDeletePost(postId: string) {
|
||
const res = await fetchWithRefresh(`/api/posts/${postId}`, {
|
||
method: "DELETE",
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiTogglePin(postId: string): Promise<{ pinned: number }> {
|
||
const res = await fetchWithRefresh(`/api/posts/${postId}/pin`, {
|
||
method: "PUT",
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiToggleRecommend(postId: string): Promise<{ recommended: boolean }> {
|
||
const res = await fetchWithRefresh(`/api/posts/${postId}/recommend`, {
|
||
method: "PUT",
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiChangePassword(oldPassword: string, newPassword: string) {
|
||
const res = await fetchWithRefresh("/api/change-password", {
|
||
method: "POST",
|
||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||
body: JSON.stringify({ old_password: oldPassword, new_password: newPassword }),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiToggleLike(postId: string) {
|
||
const res = await fetchWithRefresh(`/api/posts/${postId}/like`, {
|
||
method: "POST",
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiDeleteComment(postId: string, commentId: number) {
|
||
const res = await fetchWithRefresh(`/api/posts/${postId}/comments/${commentId}`, {
|
||
method: "DELETE",
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
// ===== 通知 =====
|
||
|
||
export async function apiFetchNotifications(page = 1): Promise<NotificationsResponse> {
|
||
const res = await fetchWithRefresh(`/api/notifications?page=${page}&size=20`, {
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiUnreadCount(): Promise<{ count: number }> {
|
||
const res = await fetchWithRefresh("/api/notifications/unread-count", {
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiMarkRead(id: number) {
|
||
const res = await fetchWithRefresh(`/api/notifications/${id}/read`, {
|
||
method: "PUT",
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
export async function apiMarkAllRead() {
|
||
const res = await fetchWithRefresh("/api/notifications/read-all", {
|
||
method: "PUT",
|
||
headers: clientHeaders(),
|
||
});
|
||
return res.json();
|
||
}
|