Skip to content

Commit 4ef4175

Browse files
committed
feat: 添加 JWT 认证工具函数,支持 token 过期检查和自动刷新;优化请求拦截器处理逻辑
1 parent 3eef514 commit 4ef4175

3 files changed

Lines changed: 245 additions & 11 deletions

File tree

frontend/components/Header.tsx

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
'use client';
22

3-
import { useSyncExternalStore } from 'react';
3+
import { useEffect, useSyncExternalStore } from 'react';
44
import { usePathname } from 'next/navigation';
55
import Link from 'next/link';
66
import ScrollHeader from './ScrollHeader';
77
import ThemeToggle from './ThemeToggle';
88
import type { User } from '@/types/api';
9+
import { isTokenExpired, clearAuth, startTokenExpiryWatch, stopTokenExpiryWatch } from '@/lib/auth';
910

1011
// 定义导航菜单项
1112
const navItems = [
@@ -55,6 +56,15 @@ function getAuthSnapshot(): AuthState {
5556
const user = localStorage.getItem('user');
5657

5758
if (token && user) {
59+
// 检查 token 是否已过期
60+
if (isTokenExpired(token, 0)) {
61+
clearAuth();
62+
if (cachedAuthState.isLoggedIn) {
63+
cachedAuthState = { isLoggedIn: false, userData: null };
64+
}
65+
return cachedAuthState;
66+
}
67+
5868
try {
5969
const userData = JSON.parse(user) as User;
6070
// 只有当状态真正变化时才更新缓存
@@ -95,14 +105,26 @@ export default function Header() {
95105
getServerSnapshot
96106
);
97107

108+
const { isLoggedIn, userData } = authState;
109+
110+
// 启动 token 过期定时检查(登录状态变化时重新启动)
111+
useEffect(() => {
112+
if (!isLoggedIn) return;
113+
114+
// 触发 storage 事件来更新 UI
115+
const onExpiry = () => {
116+
window.dispatchEvent(new StorageEvent('storage', { key: 'token' }));
117+
};
118+
startTokenExpiryWatch(onExpiry);
119+
return () => stopTokenExpiryWatch();
120+
}, [isLoggedIn]);
121+
98122
const handleLogout = () => {
99123
localStorage.removeItem('token');
100124
localStorage.removeItem('user');
101125
window.location.href = '/';
102126
};
103127

104-
const { isLoggedIn, userData } = authState;
105-
106128
return (
107129
<ScrollHeader>
108130
<div className="navbar container mx-auto px-4 h-16">

frontend/lib/auth.ts

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
/**
2+
* JWT 认证工具函数
3+
*/
4+
5+
// 刷新锁:防止并发刷新
6+
let refreshPromise: Promise<string | null> | null = null;
7+
8+
/**
9+
* 解析 JWT payload(不验证签名,仅解码)
10+
*/
11+
function parseJwtPayload(token: string): { exp?: number; sub?: string } | null {
12+
try {
13+
const parts = token.split('.');
14+
if (parts.length !== 3) return null;
15+
const payload = parts[1];
16+
// base64url -> base64
17+
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
18+
const json = atob(base64);
19+
return JSON.parse(json);
20+
} catch {
21+
return null;
22+
}
23+
}
24+
25+
/**
26+
* 获取 token 的过期时间戳(秒)
27+
*/
28+
export function getTokenExpiry(token: string): number | null {
29+
const payload = parseJwtPayload(token);
30+
if (!payload || typeof payload.exp !== 'number') {
31+
return null;
32+
}
33+
return payload.exp;
34+
}
35+
36+
/**
37+
* 检查 JWT 是否已过期
38+
* @param token JWT 字符串
39+
* @param bufferSeconds 提前多少秒视为过期(默认60秒,留出刷新时间)
40+
*/
41+
export function isTokenExpired(token: string, bufferSeconds = 60): boolean {
42+
const exp = getTokenExpiry(token);
43+
if (exp === null) {
44+
return true; // 无法解析视为过期
45+
}
46+
const now = Math.floor(Date.now() / 1000);
47+
return exp <= now + bufferSeconds;
48+
}
49+
50+
/**
51+
* 清除认证信息并触发 storage 事件通知其他组件
52+
*/
53+
export function clearAuth(): void {
54+
if (typeof window === 'undefined') return;
55+
localStorage.removeItem('token');
56+
localStorage.removeItem('user');
57+
// 手动触发 storage 事件,让 Header 组件感知变化
58+
window.dispatchEvent(new StorageEvent('storage', { key: 'token' }));
59+
}
60+
61+
/**
62+
* 尝试刷新 token(带锁,防止并发)
63+
* @param oldToken 旧的 JWT
64+
* @returns 新的 token,刷新失败返回 null
65+
*/
66+
export async function tryRefreshToken(oldToken: string): Promise<string | null> {
67+
// 如果已经有刷新请求在进行,等待它完成
68+
if (refreshPromise) {
69+
return refreshPromise;
70+
}
71+
72+
// 创建刷新 Promise
73+
refreshPromise = doRefreshToken(oldToken);
74+
75+
try {
76+
return await refreshPromise;
77+
} finally {
78+
// 刷新完成后清除锁
79+
refreshPromise = null;
80+
}
81+
}
82+
83+
/**
84+
* 实际执行刷新 token 的逻辑
85+
*/
86+
async function doRefreshToken(oldToken: string): Promise<string | null> {
87+
try {
88+
const apiUrl =
89+
typeof window === 'undefined'
90+
? process.env.INTERNAL_API_BASE_URL
91+
: process.env.NEXT_PUBLIC_API_BASE_URL;
92+
const baseURL = apiUrl || 'https://api.exquisitecore.xyz/api';
93+
94+
const response = await fetch(`${baseURL}/auth/refresh`, {
95+
method: 'POST',
96+
headers: { 'Content-Type': 'application/json' },
97+
body: JSON.stringify({ token: oldToken }),
98+
});
99+
100+
if (!response.ok) {
101+
clearAuth();
102+
return null;
103+
}
104+
105+
const data = await response.json();
106+
if (data.token) {
107+
localStorage.setItem('token', data.token);
108+
// 触发 storage 事件
109+
window.dispatchEvent(new StorageEvent('storage', { key: 'token' }));
110+
return data.token;
111+
}
112+
clearAuth();
113+
return null;
114+
} catch {
115+
clearAuth();
116+
return null;
117+
}
118+
}
119+
120+
// ============ 定时检查机制 ============
121+
122+
type AuthCheckCallback = () => void;
123+
let checkTimer: ReturnType<typeof setTimeout> | null = null;
124+
let authCheckCallback: AuthCheckCallback | null = null;
125+
126+
/**
127+
* 启动 token 过期定时检查
128+
* 在 token 过期时自动触发回调
129+
*/
130+
export function startTokenExpiryWatch(callback: AuthCheckCallback): void {
131+
authCheckCallback = callback;
132+
scheduleNextCheck();
133+
}
134+
135+
/**
136+
* 停止定时检查
137+
*/
138+
export function stopTokenExpiryWatch(): void {
139+
if (checkTimer) {
140+
clearTimeout(checkTimer);
141+
checkTimer = null;
142+
}
143+
authCheckCallback = null;
144+
}
145+
146+
/**
147+
* 安排下一次检查
148+
*/
149+
function scheduleNextCheck(): void {
150+
if (checkTimer) {
151+
clearTimeout(checkTimer);
152+
}
153+
154+
if (typeof window === 'undefined') return;
155+
156+
const token = localStorage.getItem('token');
157+
if (!token) return;
158+
159+
const exp = getTokenExpiry(token);
160+
if (!exp) return;
161+
162+
const now = Math.floor(Date.now() / 1000);
163+
const timeUntilExpiry = exp - now;
164+
165+
if (timeUntilExpiry <= 0) {
166+
// 已过期,立即触发
167+
authCheckCallback?.();
168+
return;
169+
}
170+
171+
// 设置定时器,在过期时触发检查
172+
// 最多等待 60 秒(避免 setTimeout 溢出问题,也保证定期检查)
173+
const delay = Math.min(timeUntilExpiry, 60) * 1000;
174+
175+
checkTimer = setTimeout(() => {
176+
const currentToken = localStorage.getItem('token');
177+
if (currentToken && isTokenExpired(currentToken, 0)) {
178+
clearAuth();
179+
authCheckCallback?.();
180+
} else {
181+
// 还没过期,安排下一次检查
182+
scheduleNextCheck();
183+
}
184+
}, delay);
185+
}

frontend/lib/axios.ts

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@ import axios, {
22
type AxiosInstance,
33
type AxiosRequestConfig,
44
type AxiosResponse,
5+
type InternalAxiosRequestConfig,
56
} from 'axios';
7+
import { isTokenExpired, tryRefreshToken, clearAuth } from './auth';
68

79
// 定义请求配置接口,扩展AxiosRequestConfig以支持可选的withToken参数
810
interface RequestConfig extends AxiosRequestConfig {
911
withToken?: boolean; // 是否在请求中包含token
12+
_retry?: boolean; // 标记是否为重试请求(内部使用)
1013
}
1114

1215
// 定义响应数据的通用接口
@@ -53,13 +56,23 @@ class Http {
5356
private setupInterceptors(): void {
5457
// 请求拦截器
5558
this.instance.interceptors.request.use(
56-
(config) => {
57-
const requestConfig = config as RequestConfig;
59+
async (config) => {
60+
const requestConfig = config as RequestConfig & InternalAxiosRequestConfig;
5861

5962
// 只有当withToken为true时才添加token
6063
if (requestConfig.withToken) {
61-
const token = this.getToken();
64+
let token = this.getToken();
6265
if (token) {
66+
// 检查 token 是否过期,如果过期先尝试刷新
67+
if (isTokenExpired(token)) {
68+
const newToken = await tryRefreshToken(token);
69+
if (newToken) {
70+
token = newToken;
71+
} else {
72+
// 刷新失败,不带 token 发请求(让后端返回 401)
73+
return config;
74+
}
75+
}
6376
config.headers['Authorization'] = `Bearer ${token}`;
6477
}
6578
}
@@ -76,16 +89,30 @@ class Http {
7689
(response: AxiosResponse) => {
7790
return response.data;
7891
},
79-
(error) => {
92+
async (error) => {
93+
const originalRequest = error.config as RequestConfig & InternalAxiosRequestConfig;
94+
8095
// 处理错误响应
8196
if (error.response) {
8297
// 服务器返回了错误状态码
8398
const { status } = error.response;
8499

85-
// 处理特定状态码
86-
if (status === 401) {
87-
// 未授权,可以在这里处理登出逻辑
88-
console.error('未授权访问,请重新登录');
100+
// 处理 401:尝试刷新 token 后重试
101+
if (status === 401 && originalRequest.withToken && !originalRequest._retry) {
102+
originalRequest._retry = true;
103+
const token = this.getToken();
104+
if (token) {
105+
const newToken = await tryRefreshToken(token);
106+
if (newToken) {
107+
originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
108+
return this.instance(originalRequest);
109+
}
110+
}
111+
// 刷新失败,清除登录态并跳转登录页
112+
clearAuth();
113+
if (typeof window !== 'undefined') {
114+
window.location.href = '/auth';
115+
}
89116
} else if (status === 403) {
90117
console.error('没有权限访问该资源');
91118
} else if (status === 404) {

0 commit comments

Comments
 (0)