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