React刷新令牌过期时无法自动登出并重定向至登录页问题
React Token刷新失败后无法立即登出的问题修复
问题概述
系统采用Token Refresh与Token Rotation机制,令牌过期时后端自动删除Cookie,前端需要同步删除localStorage中的令牌并将用户重定向到登录页。目前用Axios拦截器检测403状态码调用/refresh端点刷新令牌,但刷新失败(refresh token过期)时,无法立即删除令牌、跳转登录页,需要2-3次页面刷新才生效,期间无法加载数据,影响体验。
代码片段
axiosPrivate.js
import { setIsAuthenticated } from '../features/auth/authSlice'; import instance from "./axiosConfig"; import { memoizedRefreshToken } from "./axiosRefreshToken"; instance.interceptors.request.use( async (config) => { const authenticatedUser = JSON.parse(localStorage.getItem("authenticatedUser")); if (authenticatedUser?.accessToken) { config.headers = { ...config.headers, authorization: `Bearer ${authenticatedUser?.accessToken}`, }; } return config; }, (error) => Promise.reject(error) ); instance.interceptors.response.use( (response) => response, async (error) => { const config = error?.config; if (error?.response?.status === 403 && !config?.sent) { config.sent = true; console.log("Inside If: ", config); const result = await memoizedRefreshToken(); if (result?.accessToken) { console.log("Access Token Returned: ", result) config.headers = { ...config.headers, authorization: `Bearer ${result?.accessToken}`, }; } else { console.log("No Access Token ") store.dispatch(setIsAuthenticated(false)); } return instance(config); } console.log("Outside If: ", config); store.dispatch(setIsAuthenticated(false)); return Promise.reject(error); } ); export const axiosPrivate = instance;
axiosRefreshToken.js
import { store } from '../features/store'; import { setIsAuthenticated } from '../features/auth/authSlice'; import instance from "./axiosConfig"; const refreshTokenFn = async () => { try { const response = await instance.get("/auth/refresh"); const authenticatedUser = response.data; if (!authenticatedUser?.accessToken) { localStorage.removeItem("authenticatedUser"); store.dispatch(setIsAuthenticated(false)); } localStorage.setItem("authenticatedUser", JSON.stringify(authenticatedUser)); store.dispatch(setIsAuthenticated(true)); return authenticatedUser; } catch (error) { localStorage.removeItem("authenticatedUser"); store.dispatch(setIsAuthenticated(false)); } }; const maxAge = 10000; export const memoizedRefreshToken = mem(refreshTokenFn, { maxAge, });
问题根源
- 缓存导致逻辑延迟:
memoizedRefreshToken设置了10秒缓存,刷新失败后,失败结果会被缓存10秒,这段时间内的请求都会复用缓存的失败结果,无法触发登出逻辑,直到缓存过期。 - 错误分支无返回值:
refreshTokenFn的catch块只执行了删令牌和dispatch,但没有返回任何值,导致拦截器里的result是undefined,加上缓存,后续请求一直拿不到明确的失败信号。 - 拦截器循环请求:刷新失败后仍返回
instance(config)重新发起原请求,此时无有效令牌,会再次触发403,陷入循环,直到缓存过期才会走到外层的登出逻辑。 - 缺少重定向逻辑:现有代码只更新了Redux状态,没有触发页面跳转,用户停留在原页面,需要手动刷新才能触发路由守卫的登出逻辑。
修复方案
1. 移除Refresh Token的缓存
刷新失败意味着refresh token已过期,缓存失败结果没有意义,直接移除缓存:
修改axiosRefreshToken.js:
import { store } from '../features/store'; import { setIsAuthenticated } from '../features/auth/authSlice'; import instance from "./axiosConfig"; const refreshTokenFn = async () => { try { const response = await instance.get("/auth/refresh"); const authenticatedUser = response.data; if (!authenticatedUser?.accessToken) { localStorage.removeItem("authenticatedUser"); store.dispatch(setIsAuthenticated(false)); return null; // 返回明确的失败标识 } localStorage.setItem("authenticatedUser", JSON.stringify(authenticatedUser)); store.dispatch(setIsAuthenticated(true)); return authenticatedUser; } catch (error) { localStorage.removeItem("authenticatedUser"); store.dispatch(setIsAuthenticated(false)); return null; // 返回明确的失败标识 } }; // 直接导出非缓存版本 export const refreshToken = refreshTokenFn;
2. 修复Axios拦截器逻辑
当刷新失败时,直接终止原请求,触发登出和重定向:
修改axiosPrivate.js:
import { setIsAuthenticated } from '../features/auth/authSlice'; import instance from "./axiosConfig"; import { refreshToken } from "./axiosRefreshToken"; // 导入自定义路由history实例(需要提前创建,比如在utils/history.js中) import { history } from '../utils/history'; instance.interceptors.request.use( async (config) => { const authenticatedUser = JSON.parse(localStorage.getItem("authenticatedUser")); if (authenticatedUser?.accessToken) { config.headers.authorization = `Bearer ${authenticatedUser.accessToken}`; } return config; }, (error) => Promise.reject(error) ); instance.interceptors.response.use( (response) => response, async (error) => { const config = error?.config; if (error?.response?.status === 403 && !config?.sent) { config.sent = true; const result = await refreshToken(); if (result?.accessToken) { config.headers.authorization = `Bearer ${result.accessToken}`; return instance(config); } else { // 刷新失败,直接登出跳转 localStorage.removeItem("authenticatedUser"); store.dispatch(setIsAuthenticated(false)); history.push('/login'); return Promise.reject(new Error('Refresh token expired')); } } // 其他错误或已尝试过刷新的情况,直接登出跳转 localStorage.removeItem("authenticatedUser"); store.dispatch(setIsAuthenticated(false)); history.push('/login'); return Promise.reject(error); } ); export const axiosPrivate = instance;
3. 补充路由重定向逻辑
如果没有自定义history实例,可以在Redux的authSlice中监听状态变化,自动跳转:
修改authSlice.js:
import { createSlice } from '@reduxjs/toolkit'; import { history } from '../utils/history'; const initialState = { isAuthenticated: !!localStorage.getItem('authenticatedUser'), }; const authSlice = createSlice({ name: 'auth', initialState, reducers: { setIsAuthenticated: (state, action) => { state.isAuthenticated = action.payload; if (!action.payload) { // 状态变为未认证时,立即跳转登录页 history.push('/login'); } }, }, }); export const { setIsAuthenticated } = authSlice.actions; export default authSlice.reducer;
4. 自定义history实例创建(可选)
在utils/history.js中创建路由history实例:
import { createBrowserHistory } from 'history'; export const history = createBrowserHistory();
如果使用React Router v6,需要在路由配置中使用Router组件而非BrowserRouter,传入自定义history:
import { Router } from 'react-router-dom'; import { history } from './utils/history'; function App() { return ( <Router history={history}> {/* 路由配置 */} </Router> ); }
关键修复点
- 移除Refresh Token的缓存,避免失败结果被缓存延迟登出逻辑
- 在refreshToken的错误分支返回明确的失败标识,让拦截器能正确判断
- 拦截器中刷新失败时直接终止原请求,触发登出和重定向,避免循环请求
- 添加自动重定向逻辑,确保状态变为未认证时立即跳转登录页
内容的提问来源于stack exchange,提问作者japheth
相关产品推荐
相关产品推荐

