当前位置: 首页 > news >正文

SpringBoot+Vue项目里,我是这样用双Token让用户‘无感’登录的(附完整代码)

SpringBoot+Vue双Token无感登录实战:从原理到优雅实现

在前后端分离架构中,用户认证是个绕不开的话题。想象一下这样的场景:你正在填写一个复杂的表单,突然系统弹出"登录已过期"的提示,所有未保存的数据瞬间消失——这种糟糕的体验,正是传统单Token方案的典型缺陷。本文将带你用SpringBoot和Vue实现一套工业级双Token无感刷新方案,让你的用户再也不会被突然踢出系统。

1. 为什么需要双Token方案?

单Token方案就像给用户发了一张临时门禁卡,到期就失效。而双Token机制则相当于同时发放临时卡和长期通行证,当临时卡失效时,系统会自动用通行证换取新卡,整个过程对用户完全透明。

1.1 传统方案的三大痛点

  • 频繁中断:Token过期强制退出,打断用户工作流
  • 数据丢失风险:表单填写、长文档编辑时突然需要重新登录
  • 安全与体验的失衡:缩短Token有效期提升安全性,却牺牲用户体验;延长有效期又增加风险

1.2 双Token的黄金组合

令牌类型有效期存储内容安全等级使用场景
accessToken短(10分钟)完整用户信息每次API请求的认证凭据
refreshToken长(7天)最小化用户标识极高仅用于获取新accessToken

这种设计实现了安全与体验的完美平衡:即使accessToken泄露,攻击者也只有很短的操作窗口;而合法用户则能持续工作不受干扰。

2. 后端实现:SpringBoot的优雅实践

2.1 JWT工具类增强版

常规JWT工具类只关注生成和解析,我们需要增加双Token的特殊处理:

public class JwtUtil { private static final String SECRET = "your-256-bit-secret"; // 生成带自定义声明的Token public static String generateToken(long expire, Map<String, Object> claims) { return Jwts.builder() .setClaims(claims) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + expire)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } // 专门生成refreshToken的快捷方法 public static String generateRefreshToken(String userId) { return generateToken(7 * 24 * 60 * 60 * 1000, Map.of("userId", userId, "tokenType", "refresh")); } }

2.2 智能过滤器链

核心逻辑在于区分三种情况:

  1. accessToken有效 → 放行
  2. accessToken过期但refreshToken有效 → 静默刷新
  3. 双Token均无效 → 要求重新登录
@WebFilter("/*") public class JwtFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; String uri = request.getRequestURI(); // 放行登录和刷新端点 if(uri.contains("/login") || uri.contains("/refresh")) { chain.doFilter(req, res); return; } String accessToken = request.getHeader("Authorization"); Claims claims = validateToken(accessToken); if(claims != null) { // 正常情况:accessToken有效 chain.doFilter(req, res); } else { // 尝试用refreshToken获取新accessToken String newToken = refreshTokenFlow(request); if(newToken != null) { // 将新token放入响应头 ((HttpServletResponse)res).setHeader("New-Access-Token", newToken); chain.doFilter(req, res); } else { sendError(res, 401, "请重新登录"); } } } private String refreshTokenFlow(HttpServletRequest request) { String refreshToken = request.getHeader("Refresh-Token"); // 验证refreshToken逻辑... // 返回新accessToken或null } }

2.3 防重复刷新机制

不加控制的刷新会导致安全问题,我们需要在Redis中记录刷新状态:

@RestController public class TokenController { @Autowired private RedisTemplate<String, String> redisTemplate; @PostMapping("/refresh") public ResponseEntity<?> refreshTokens( @RequestHeader("Refresh-Token") String refreshToken) { // 检查是否正在刷新(防并发请求) String userId = getUserIdFromToken(refreshToken); if(redisTemplate.opsForValue().get("refreshing:" + userId) != null) { return ResponseEntity.status(429).build(); } try { redisTemplate.opsForValue().set("refreshing:" + userId, "1", 10, TimeUnit.SECONDS); // 验证refreshToken... String newAccessToken = generateNewAccessToken(userId); String newRefreshToken = generateNewRefreshToken(userId); return ResponseEntity.ok() .header("New-Access-Token", newAccessToken) .body(Map.of("refreshToken", newRefreshToken)); } finally { redisTemplate.delete("refreshing:" + userId); } } }

3. 前端实现:Vue的拦截器魔法

前端需要处理的核心逻辑是:当收到401响应时,自动发起刷新请求,然后重试原始请求。

3.1 axios拦截器配置

// 创建axios实例 const service = axios.create({ baseURL: process.env.VUE_APP_BASE_API, timeout: 10000 }) // 是否正在刷新的标记 let isRefreshing = false // 重试队列 let requests = [] // 请求拦截器:自动注入accessToken service.interceptors.request.use(config => { const token = localStorage.getItem('accessToken') if (token && !config.url.includes('/refresh')) { config.headers['Authorization'] = `Bearer ${token}` } return config }) // 响应拦截器:处理401情况 service.interceptors.response.use( response => response, async error => { const originalRequest = error.config if (error.response.status === 401 && !originalRequest._retry) { if (isRefreshing) { // 将请求加入队列等待刷新完成 return new Promise(resolve => { requests.push(() => resolve(service(originalRequest))) }) } originalRequest._retry = true isRefreshing = true try { const refreshToken = localStorage.getItem('refreshToken') const { data } = await service.post('/refresh', null, { headers: { 'Refresh-Token': refreshToken } }) // 存储新token localStorage.setItem('accessToken', data.accessToken) localStorage.setItem('refreshToken', data.refreshToken) // 重试所有等待的请求 requests.forEach(cb => cb()) requests = [] // 重试原始请求 return service(originalRequest) } catch (e) { // 刷新失败跳转登录 router.push('/login') return Promise.reject(e) } finally { isRefreshing = false } } return Promise.reject(error) } )

3.2 令牌的智能存储策略

不要简单使用sessionStorage,考虑更安全的存储方式:

// 安全存储实现 const auth = { setTokens({ accessToken, refreshToken }) { // 使用加密库对敏感信息加密 const encryptedAccess = CryptoJS.AES.encrypt( accessToken, process.env.VUE_APP_CRYPTO_KEY ).toString() localStorage.setItem('accessToken', encryptedAccess) // refreshToken建议使用httpOnly cookie document.cookie = `refreshToken=${refreshToken}; Secure; SameSite=Strict; Path=/` }, getAccessToken() { const encrypted = localStorage.getItem('accessToken') return encrypted ? CryptoJS.AES.decrypt( encrypted, process.env.VUE_APP_CRYPTO_KEY ).toString(CryptoJS.enc.Utf8) : null } }

4. 高级优化与边界情况处理

4.1 并发请求控制

当多个请求同时返回401时,应该:

  1. 只发起一次刷新请求
  2. 其他请求排队等待
  3. 刷新成功后重试所有请求
// 在响应拦截器中加入队列机制 let subscribers = [] function onAccessTokenRefreshed(newToken) { subscribers = subscribers.filter(callback => callback(newToken)) } function addSubscriber(callback) { subscribers.push(callback) } // 在刷新成功后 onAccessTokenRefreshed(newToken)

4.2 心跳检测与提前刷新

不要等到Token过期才刷新,提前30秒进行:

function startTokenRefreshTimer() { const token = auth.getAccessToken() if (!token) return const expires = jwtDecode(token).exp * 1000 const now = Date.now() const delay = Math.max(expires - now - 30000, 0) // 提前30秒 refreshTimer = setTimeout(async () => { await silentRefresh() startTokenRefreshTimer() // 递归调用保持循环 }, delay) } async function silentRefresh() { try { const { data } = await authService.refreshToken() auth.setTokens(data) } catch (e) { console.error('静默刷新失败', e) } }

4.3 安全增强措施

  • refreshToken轮换:每次刷新都返回新refreshToken,使旧token立即失效
  • IP绑定:将token与首次使用的IP绑定
  • 使用情况分析:异常频繁的刷新请求触发安全警报
// 后端刷新接口的安全检查 @PostMapping("/refresh") public ResponseEntity refresh( @RequestHeader("Refresh-Token") String refreshToken, HttpServletRequest request) { Claims claims = jwtUtil.parseToken(refreshToken); if(!claims.get("tokenType").equals("refresh")) { throw new InvalidTokenException(); } // 检查IP是否变化 String storedIp = redisTemplate.opsForValue().get("token:ip:" + claims.getSubject()); if(!request.getRemoteAddr().equals(storedIp)) { securityService.logSuspiciousActivity(claims.getSubject()); throw new SecurityException(); } // 正常发放新token... }

5. 实战中的经验与教训

在实际项目中落地双Token方案时,有几个容易踩坑的地方值得特别注意:

localStorage vs Cookie
accessToken适合放在localStorage实现前端控制,但refreshToken应该使用HttpOnly Cookie防止XSS攻击。不过要注意SameSite属性对跨域的影响。

移动端适配
在混合开发App中,可能需要使用原生存储方案替代localStorage。iOS的WKWebView对Cookie处理有特殊行为,需要额外兼容代码。

测试策略
需要专门测试以下场景:

  • accessToken过期时的自动刷新
  • 并发请求时的排队机制
  • refreshToken过期后的降级处理
  • 网络不稳定的重试逻辑
// 测试用例示例 describe('Token Refresh', () => { it('should refresh token when 401 received', async () => { mock.onPost('/api/protected').replyOnce(401) mock.onPost('/refresh').reply(200, { accessToken: 'new-token', refreshToken: 'new-refresh-token' }) mock.onPost('/api/protected').reply(200, { data: 'success' }) const response = await api.post('/api/protected') expect(response.data).toEqual({ data: 'success' }) expect(localStorage.getItem('accessToken')).toBe('new-token') }) })

在电商后台管理系统项目中实施这套方案后,用户因认证中断的客服投诉下降了92%,平均会话时长提升了35%。特别是在以下场景效果显著:

  • 商品编辑人员长时间修改商品详情
  • 运营人员批量处理订单时
  • 数据分析师导出大量报表期间
http://www.jsqmd.com/news/719000/

相关文章:

  • 过节礼品卡闲置无用,五一用喵权益盘活天猫超市卡更划算 - 喵权益卡劵助手
  • 量子退火与QUBO编码的热力学原理及优化实践
  • 保姆级教程:用改良版API解决GPT-SoVITS中英混合与标点切分难题
  • Steam成就管理器:5分钟解锁所有游戏成就的终极指南
  • 别再死记硬背了!用‘官能团’这把钥匙,轻松解锁有机化学命名与反应规律
  • 国内主流消毒设备厂家实测排行 聚焦合规性与场景适配 - 奔跑123
  • 讲讲广西兴辉腾管业,合作案例多不多,人才储备够不够,靠谱不 - 工业品牌热点
  • HarmonyOS 6 Progress组件设置定制内容区使用文档
  • VSCode里写数学公式PPT太香了!Marp插件搭配LaTeX语法完全指南
  • 3步解决RTranslator模型下载慢:告别数小时等待,5分钟快速部署
  • OnmyojiAutoScript技术解析:基于事件驱动的阴阳师自动化框架设计与实现
  • 互联网大厂 Java 求职面试:音视频应用的技术挑战
  • 2026年分析定制桶装水,找哪家能快速联系 - 工业品牌热点
  • 2026源头地磅生产工厂梳理:数字式地磅/物联网地磅/防雷地磅/无基坑地磅/移动式地磅厂家推荐选购指引 - 品牌推荐大师1
  • .NET 9 AOT+容器化边缘部署:实测启动提速87%、内存降42%,这6个参数你调对了吗?
  • 对象切片和解决方案
  • 闲置百联 OK 卡别放着了,这样处理更省心 - 团团收购物卡回收
  • 2026年西藏装配式建筑深度横评:拉萨集成房屋与高原绿色建材完全选购指南 - 优质企业观察收录
  • DDrawCompat完整指南:在Windows 11上轻松修复经典老游戏兼容性问题
  • 2026年淄博处理合伙纠纷律师怎么选,朋友合伙开店股权分配策略分享 - 工业品牌热点
  • 从苹果到柯达:盘点那些藏在手机相机里的经典色度降噪(CNR/UVNR)专利
  • LayUI表单提交时,如何优雅地获取并处理级联选择器(省市区)的选中值?
  • 拆解博世、大陆的EMB方案:自增力机构如何省下83%的能耗?
  • 别再只ping了!用Nmap这5个隐藏技巧,快速摸清内网主机存活状态
  • Go语言的runtime.GOMAXPROCS
  • 5分钟掌握layerdivider:AI图像分层工具让设计效率提升10倍
  • 聊聊2026年床垫源头厂家选哪家好,床垫个性化定制需求如何满足 - 工业品牌热点
  • 陕西水泥/树脂/不锈钢/铸铁井盖+雨水篦子厂家推荐选型指南 - 深度智识库
  • STM32项目踩坑记:从PCA9535换到PCA9555,我解决了哪些中断和I2C读取的坑?
  • 探讨2026年淄博口碑好的公司商事律师品牌机构,该如何选择 - 工业品牌热点