React中access token过期刷新、无活动跳转登录页方案咨询
React + DRF 架构下Token自动刷新与无操作登出实现方案
活跃状态下Access Token自动刷新实现
核心逻辑基于axios拦截器统一处理,避免每个业务请求重复写鉴权逻辑,同时解决并发请求触发重复刷新的问题:
- 登录成功后,除
access token外,需同时将refresh token、access token的过期时间戳存入localStorage。过期时间可以直接从JWT的payload中解析exp字段获取,无需后端额外返回。 - 配置axios请求拦截器:所有业务请求发起前,先检查access token是否距离过期不足30秒(提前刷新阈值,可自行调整),若未过期则直接在请求头携带
Authorization: Bearer ${accessToken}正常发送。 - 配置axios响应拦截器捕获"access deny"(对应401状态码)错误,触发刷新逻辑时加全局锁防止重复请求:
- 维护
isRefreshing布尔标记和pendingRequests请求队列,第一个触发401的请求拿到锁后发起refresh请求,其余并发的过期请求全部加入队列等待 - refresh请求调用DRF提供的token刷新接口(如simplejwt默认的
/api/token/refresh/),请求体携带本地存储的refresh token,拿到新token后更新本地存储的凭证信息 - 新token获取成功后,依次执行队列中挂起的请求回调,用新token重发所有挂起请求,再清空队列、释放锁
- 如果refresh请求本身失败(refresh token过期、被拉黑等),直接清空本地所有凭证,跳转至登录页
核心实现代码参考:
- 维护
import axios from 'axios' const request = axios.create({ baseURL: '/api' }) let isRefreshing = false let pendingRequests = [] // 解析JWT过期时间 function getTokenExpire(token) { return JSON.parse(atob(token.split('.')[1])).exp * 1000 } // 统一刷新token逻辑 async function refreshToken() { const refresh = localStorage.getItem('refreshToken') const { data } = await axios.post('/api/token/refresh/', { refresh }) localStorage.setItem('accessToken', data.access) if (data.refresh) localStorage.setItem('refreshToken', data.refresh) // 适配refresh轮换配置 const expire = getTokenExpire(data.access) localStorage.setItem('accessTokenExpire', expire) return data.access } // 请求拦截器:提前检查token状态 request.interceptors.request.use(async config => { const accessToken = localStorage.getItem('accessToken') if (!accessToken) return config const expire = Number(localStorage.getItem('accessTokenExpire')) // 跳过刷新接口本身的检查 if (config.url.includes('/token/refresh/')) return config // 即将过期,提前触发刷新 if (Date.now() > expire - 30 * 1000) { if (!isRefreshing) { isRefreshing = true try { const newToken = await refreshToken() config.headers.Authorization = `Bearer ${newToken}` return config } catch (err) { localStorage.clear() window.location.href = '/login' return Promise.reject(err) } finally { isRefreshing = false } } else { // 正在刷新,加入队列等待 return new Promise(resolve => { pendingRequests.push(token => { config.headers.Authorization = `Bearer ${token}` resolve(config) }) }) } } config.headers.Authorization = `Bearer ${accessToken}` return config }) // 响应拦截器:兜底处理漏网的401错误 request.interceptors.response.use( res => res.data, async err => { const originalReq = err.config if (err.response?.data?.detail === 'access deny' && !originalReq._retry) { originalReq._retry = true if (!isRefreshing) { isRefreshing = true try { const newToken = await refreshToken() // 重发所有挂起请求 pendingRequests.forEach(cb => cb(newToken)) pendingRequests = [] originalReq.headers.Authorization = `Bearer ${newToken}` return request(originalReq) } catch (refreshErr) { localStorage.clear() window.location.href = '/login' return Promise.reject(refreshErr) } finally { isRefreshing = false } } else { return new Promise(resolve => { pendingRequests.push(token => { originalReq.headers.Authorization = `Bearer ${token}` resolve(request(originalReq)) }) }) } } return Promise.reject(err) } ) export default request
5分钟无操作自动登出实现
不推荐使用定时发后台探测请求的方案:一是平白增加后端无效请求压力,二是探测请求属于后台自动触发行为,会干扰无操作状态判定,容易出现用户早已离开页面、但探测请求持续续期token的bug。
前端完全可以独立完成活跃状态判定,实现逻辑如下:
- 维护内存级变量
lastActiveTime记录用户最后一次主动操作的时间戳,无需持久化存储 - 监听window上的用户主动操作事件:
click、keydown、mousemove、scroll、touchstart,事件触发时做防抖处理,更新lastActiveTime为当前时间 - 启动间隔1分钟的轮询定时器,每次执行时计算当前时间与
lastActiveTime的差值,差值超过5分钟则执行登出逻辑:清空本地凭证、移除事件监听、清除定时器、跳转登录页 - 注意联动规则:后台自动触发的token刷新请求不能更新
lastActiveTime,避免无操作状态被后台逻辑重置
核心实现代码参考:
let lastActiveTime = Date.now() const INACTIVE_DURATION = 5 * 60 * 1000 // 5分钟超时 const CHECK_GAP = 60 * 1000 // 1分钟检查一次 let checkTimer = null // 防抖工具 function debounce(fn, delay) { let timer = null return (...args) => { clearTimeout(timer) timer = setTimeout(() => fn.apply(this, args), delay) } } // 更新活跃时间 const updateActive = debounce(() => { lastActiveTime = Date.now() }, 1000) // 绑定全局事件 const activeEvents = ['click', 'keydown', 'mousemove', 'scroll', 'touchstart'] activeEvents.forEach(evt => { window.addEventListener(evt, updateActive, { passive: true }) }) // 启动检查 checkTimer = setInterval(() => { if (Date.now() - lastActiveTime >= INACTIVE_DURATION) { // 执行登出 localStorage.clear() activeEvents.forEach(evt => window.removeEventListener(evt, updateActive)) clearInterval(checkTimer) window.location.href = '/login' } }, CHECK_GAP)
补充注意事项
- 如果项目使用React Router,可以将硬跳转
window.location.href替换为路由的navigate方法,把登出逻辑抽成全局可调用的工具函数即可 - 若对安全性要求更高,建议将refresh token存储在HttpOnly、Secure属性的Cookie中,避免XSS攻击窃取凭证
- DRF端如果使用simplejwt,建议开启
ROTATE_REFRESH_TOKENS = True和BLACKLIST_AFTER_ROTATION = True配置,每次刷新时轮换refresh token并将旧token加入黑名单,降低凭证泄露风险
内容的提问来源于stack exchange,提问作者Denis Morejón
相关产品推荐
相关产品推荐

