diff --git a/src/app/layout.tsx b/src/app/layout.tsx index beb03d7..f3f35d5 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,6 +13,7 @@ import { DownloadPanel } from '../components/DownloadPanel'; import { GlobalErrorIndicator } from '../components/GlobalErrorIndicator'; import { SiteProvider } from '../components/SiteProvider'; import { ThemeProvider } from '../components/ThemeProvider'; +import { TokenRefreshManager } from '../components/TokenRefreshManager'; import TopProgressBar from '../components/TopProgressBar'; import ChatFloatingWindow from '../components/watch-room/ChatFloatingWindow'; import { WatchRoomProvider } from '../components/WatchRoomProvider'; @@ -222,6 +223,7 @@ export default async function RootLayout({ disableTransitionOnChange > + diff --git a/src/components/TokenRefreshManager.tsx b/src/components/TokenRefreshManager.tsx new file mode 100644 index 0000000..ddad9ab --- /dev/null +++ b/src/components/TokenRefreshManager.tsx @@ -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 | null = null; + + // Token 刷新函数 + const refreshToken = async (): Promise => { + // 如果正在刷新,返回现有的 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 => { + // 跳过刷新 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; +} diff --git a/src/middleware.ts b/src/middleware.ts index 8862515..2ad737d 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -3,7 +3,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAuthInfoFromCookie } from '@/lib/auth'; -import { refreshAccessToken, shouldRenewToken } from '@/lib/middleware-auth'; import { TOKEN_CONFIG } from '@/lib/refresh-token'; export async function middleware(request: NextRequest) { @@ -54,37 +53,9 @@ export async function middleware(request: NextRequest) { const now = Date.now(); const age = now - authInfo.timestamp; - // Access Token 已过期,尝试使用 Refresh Token 刷新 + // Access Token 已过期,前端负责刷新 if (age > ACCESS_TOKEN_AGE) { - if (authInfo.refreshToken && authInfo.tokenId && authInfo.refreshExpires) { - // 检查 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 也过期或刷新失败,需要重新登录 + console.log(`Access token expired for ${authInfo.username}, redirecting to login`); return handleAuthFailure(request, pathname); } @@ -101,34 +72,8 @@ export async function middleware(request: NextRequest) { return handleAuthFailure(request, pathname); } - // 签名验证通过,检查是否需要续期 - if (shouldRenewToken(authInfo.timestamp)) { - // 快过期了,自动续期 - 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; - } - } - } - - // 正常通过 + // 签名验证通过 + // 注意:Token 续期由前端负责,Middleware 不再自动刷新 return NextResponse.next(); }