token刷新改为前端
This commit is contained in:
@@ -13,6 +13,7 @@ import { DownloadPanel } from '../components/DownloadPanel';
|
|||||||
import { GlobalErrorIndicator } from '../components/GlobalErrorIndicator';
|
import { GlobalErrorIndicator } from '../components/GlobalErrorIndicator';
|
||||||
import { SiteProvider } from '../components/SiteProvider';
|
import { SiteProvider } from '../components/SiteProvider';
|
||||||
import { ThemeProvider } from '../components/ThemeProvider';
|
import { ThemeProvider } from '../components/ThemeProvider';
|
||||||
|
import { TokenRefreshManager } from '../components/TokenRefreshManager';
|
||||||
import TopProgressBar from '../components/TopProgressBar';
|
import TopProgressBar from '../components/TopProgressBar';
|
||||||
import ChatFloatingWindow from '../components/watch-room/ChatFloatingWindow';
|
import ChatFloatingWindow from '../components/watch-room/ChatFloatingWindow';
|
||||||
import { WatchRoomProvider } from '../components/WatchRoomProvider';
|
import { WatchRoomProvider } from '../components/WatchRoomProvider';
|
||||||
@@ -222,6 +223,7 @@ export default async function RootLayout({
|
|||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<TopProgressBar />
|
<TopProgressBar />
|
||||||
|
<TokenRefreshManager />
|
||||||
<SiteProvider siteName={siteName} announcement={announcement} tmdbApiKey={tmdbApiKey}>
|
<SiteProvider siteName={siteName} announcement={announcement} tmdbApiKey={tmdbApiKey}>
|
||||||
<WatchRoomProvider>
|
<WatchRoomProvider>
|
||||||
<DownloadProvider>
|
<DownloadProvider>
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token 自动刷新管理器
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* 1. 拦截所有 fetch 请求
|
||||||
|
* 2. 检测到 401 错误时自动刷新 Token 并重试
|
||||||
|
* 3. 在请求前检查 Token 是否即将过期,主动刷新
|
||||||
|
*
|
||||||
|
* 策略:
|
||||||
|
* - 响应拦截:401 错误 → 刷新 Token → 重试请求
|
||||||
|
* - 请求拦截:剩余时间 < 10 分钟 → 主动刷新
|
||||||
|
*/
|
||||||
|
export function TokenRefreshManager() {
|
||||||
|
useEffect(() => {
|
||||||
|
// localStorage 模式不需要刷新
|
||||||
|
const storageType = (window as any).RUNTIME_CONFIG?.STORAGE_TYPE || 'localstorage';
|
||||||
|
if (storageType === 'localstorage') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新状态管理
|
||||||
|
let isRefreshing = false;
|
||||||
|
let refreshPromise: Promise<boolean> | null = null;
|
||||||
|
|
||||||
|
// Token 刷新函数
|
||||||
|
const refreshToken = async (): Promise<boolean> => {
|
||||||
|
// 如果正在刷新,返回现有的 Promise
|
||||||
|
if (isRefreshing && refreshPromise) {
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRefreshing = true;
|
||||||
|
refreshPromise = (async () => {
|
||||||
|
try {
|
||||||
|
// 使用原始 fetch 避免递归
|
||||||
|
const response = await window.fetch('/api/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
console.log('[Token] Refreshed successfully');
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.error('[Token] Refresh failed:', response.status);
|
||||||
|
|
||||||
|
// 刷新失败,跳转登录
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
window.location.href = `/login?redirect=${encodeURIComponent(window.location.pathname + window.location.search)}`;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Token] Refresh error:', error);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false;
|
||||||
|
refreshPromise = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return refreshPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查 Token 是否需要刷新
|
||||||
|
const shouldRefreshToken = (): boolean => {
|
||||||
|
const authInfo = getAuthInfoFromBrowserCookie();
|
||||||
|
if (!authInfo || !authInfo.timestamp || !authInfo.refreshExpires) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Refresh Token 已过期
|
||||||
|
if (now >= authInfo.refreshExpires) {
|
||||||
|
console.log('[Token] Refresh token expired, redirecting to login');
|
||||||
|
window.location.href = `/login?redirect=${encodeURIComponent(window.location.pathname + window.location.search)}`;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算 Access Token 剩余时间
|
||||||
|
const ACCESS_TOKEN_AGE = 4 * 60 * 60 * 1000; // 4 小时
|
||||||
|
const age = now - authInfo.timestamp;
|
||||||
|
const remaining = ACCESS_TOKEN_AGE - age;
|
||||||
|
|
||||||
|
// 剩余时间 < 10 分钟时需要刷新
|
||||||
|
const REFRESH_THRESHOLD = 10 * 60 * 1000; // 10 分钟
|
||||||
|
return remaining < REFRESH_THRESHOLD && remaining > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存原始 fetch
|
||||||
|
const originalFetch = window.fetch;
|
||||||
|
|
||||||
|
// 拦截 fetch
|
||||||
|
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||||
|
// 跳过刷新 API 本身
|
||||||
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||||
|
|
||||||
|
if (url.includes('/api/auth/refresh')) {
|
||||||
|
return originalFetch(input, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 请求前检查:Token 即将过期时主动刷新
|
||||||
|
if (shouldRefreshToken() && !isRefreshing) {
|
||||||
|
console.log('[Token] Expiring soon, refreshing proactively...');
|
||||||
|
await refreshToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送请求
|
||||||
|
let response = await originalFetch(input, init);
|
||||||
|
|
||||||
|
// 响应拦截:401 错误时刷新 Token 并重试
|
||||||
|
if (response.status === 401 && !isRefreshing) {
|
||||||
|
console.log('[Token] Received 401, attempting refresh and retry...');
|
||||||
|
|
||||||
|
const refreshed = await refreshToken();
|
||||||
|
|
||||||
|
if (refreshed) {
|
||||||
|
// 刷新成功,重试原请求
|
||||||
|
response = await originalFetch(input, init);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清理:恢复原始 fetch
|
||||||
|
return () => {
|
||||||
|
window.fetch = originalFetch;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 这是一个纯逻辑组件,不渲染任何内容
|
||||||
|
return null;
|
||||||
|
}
|
||||||
+4
-59
@@ -3,7 +3,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { refreshAccessToken, shouldRenewToken } from '@/lib/middleware-auth';
|
|
||||||
import { TOKEN_CONFIG } from '@/lib/refresh-token';
|
import { TOKEN_CONFIG } from '@/lib/refresh-token';
|
||||||
|
|
||||||
export async function middleware(request: NextRequest) {
|
export async function middleware(request: NextRequest) {
|
||||||
@@ -54,37 +53,9 @@ export async function middleware(request: NextRequest) {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const age = now - authInfo.timestamp;
|
const age = now - authInfo.timestamp;
|
||||||
|
|
||||||
// Access Token 已过期,尝试使用 Refresh Token 刷新
|
// Access Token 已过期,前端负责刷新
|
||||||
if (age > ACCESS_TOKEN_AGE) {
|
if (age > ACCESS_TOKEN_AGE) {
|
||||||
if (authInfo.refreshToken && authInfo.tokenId && authInfo.refreshExpires) {
|
console.log(`Access token expired for ${authInfo.username}, redirecting to login`);
|
||||||
// 检查 Refresh Token 是否过期
|
|
||||||
if (now < authInfo.refreshExpires) {
|
|
||||||
// 尝试刷新 Access Token
|
|
||||||
const newAuthData = await refreshAccessToken(
|
|
||||||
authInfo.username,
|
|
||||||
authInfo.role,
|
|
||||||
authInfo.tokenId,
|
|
||||||
authInfo.refreshToken,
|
|
||||||
authInfo.refreshExpires
|
|
||||||
);
|
|
||||||
|
|
||||||
if (newAuthData) {
|
|
||||||
// 刷新成功,设置新 Cookie
|
|
||||||
const response = NextResponse.next();
|
|
||||||
const expires = new Date(authInfo.refreshExpires);
|
|
||||||
response.cookies.set('auth', newAuthData, {
|
|
||||||
path: '/',
|
|
||||||
expires,
|
|
||||||
sameSite: 'lax',
|
|
||||||
httpOnly: false,
|
|
||||||
secure: false,
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh Token 也过期或刷新失败,需要重新登录
|
|
||||||
return handleAuthFailure(request, pathname);
|
return handleAuthFailure(request, pathname);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,34 +72,8 @@ export async function middleware(request: NextRequest) {
|
|||||||
return handleAuthFailure(request, pathname);
|
return handleAuthFailure(request, pathname);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 签名验证通过,检查是否需要续期
|
// 签名验证通过
|
||||||
if (shouldRenewToken(authInfo.timestamp)) {
|
// 注意:Token 续期由前端负责,Middleware 不再自动刷新
|
||||||
// 快过期了,自动续期
|
|
||||||
if (authInfo.refreshToken && authInfo.tokenId && authInfo.refreshExpires) {
|
|
||||||
const newAuthData = await refreshAccessToken(
|
|
||||||
authInfo.username,
|
|
||||||
authInfo.role,
|
|
||||||
authInfo.tokenId,
|
|
||||||
authInfo.refreshToken,
|
|
||||||
authInfo.refreshExpires
|
|
||||||
);
|
|
||||||
|
|
||||||
if (newAuthData) {
|
|
||||||
const response = NextResponse.next();
|
|
||||||
const expires = new Date(authInfo.refreshExpires);
|
|
||||||
response.cookies.set('auth', newAuthData, {
|
|
||||||
path: '/',
|
|
||||||
expires,
|
|
||||||
sameSite: 'lax',
|
|
||||||
httpOnly: false,
|
|
||||||
secure: false,
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 正常通过
|
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user