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 智能过滤器链
核心逻辑在于区分三种情况:
- accessToken有效 → 放行
- accessToken过期但refreshToken有效 → 静默刷新
- 双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时,应该:
- 只发起一次刷新请求
- 其他请求排队等待
- 刷新成功后重试所有请求
// 在响应拦截器中加入队列机制 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%。特别是在以下场景效果显著:
- 商品编辑人员长时间修改商品详情
- 运营人员批量处理订单时
- 数据分析师导出大量报表期间
