news 2026/5/21 20:50:12

Spring Boot项目从Session迁移到Token认证,我踩过的那些坑(Shiro实战避坑指南)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot项目从Session迁移到Token认证,我踩过的那些坑(Shiro实战避坑指南)

Spring Boot项目从Session迁移到Token认证的实战避坑指南

当传统基于Session的认证系统遇上微服务架构和前后端分离趋势,改造升级成为必然。去年我们团队将一个运行三年的Spring Boot+Shiro项目从Session迁移到Token认证,整个过程堪称一部"血泪史"。本文将分享我们在改造过程中遇到的典型问题及解决方案,希望能为面临类似挑战的开发者提供参考。

1. 为什么需要从Session迁移到Token

Session机制在过去二十年里一直是Web应用认证的主流方案。它通过在服务端存储用户状态,客户端仅保留一个Session ID来实现身份识别。但随着系统架构演进,这种模式逐渐暴露出几个致命缺陷:

  • 扩展性问题:在集群环境下需要Session共享方案,增加了系统复杂度
  • 跨域限制:难以适应前后端分离和移动端混合开发的场景
  • CSRF风险:基于Cookie的机制天生存在安全漏洞
  • RESTful兼容性:违背了无状态API的设计原则

相比之下,Token认证(如JWT)具有以下优势:

  1. 无状态:服务端不需要存储会话信息
  2. 跨域友好:可通过Header轻松传递
  3. 移动端适配:天然适合APP等非浏览器环境
  4. 微服务友好:便于在分布式系统中传递身份信息
// 传统Session认证 vs Token认证流程对比 Session认证流程: 1. 用户登录 → 2. 服务端创建Session → 3. 返回Session ID → 4. 客户端存储Cookie → 5. 后续请求携带Cookie Token认证流程: 1. 用户登录 → 2. 服务端生成Token → 3. 返回Token → 4. 客户端存储Token → 5. 后续请求携带Token

2. 迁移方案设计与核心决策点

2.1 渐进式迁移还是全量替换

我们最终选择了渐进式迁移方案,主要考虑因素包括:

  • 系统已有稳定运行的业务逻辑
  • 部分老客户端仍依赖Session机制
  • 需要保证升级过程不影响线上用户

具体实施策略:

  1. 新功能统一采用Token认证
  2. 老功能分批次改造
  3. 设置过渡期同时支持两种机制
  4. 最终完全移除Session依赖

2.2 Token存储方案选型

方案优点缺点适用场景
内存存储实现简单,性能高重启丢失,无法集群开发环境
数据库存储持久化可靠,便于管理有IO开销,需维护中小规模系统
Redis存储高性能,支持集群需要额外中间件大规模系统
JWT自包含完全无状态,服务端零存储无法主动失效短期有效场景

我们选择了Redis存储方案,主要基于以下考虑:

  • 系统已有Redis基础设施
  • 需要支持Token主动失效
  • 预计用户量会持续增长
// Token服务接口设计示例 public interface TokenService { String createToken(User user); // 创建并存储Token boolean verifyToken(String token); // 验证Token有效性 void invalidateToken(String token); // 使Token失效 User getUserByToken(String token); // 通过Token获取用户信息 }

2.3 Cookie vs Header的传输方式

这个看似简单的选择实际上困扰了我们整整一周。最终方案是:

  • Web端:同时支持Cookie和Authorization Header
    • 保持对老浏览器的兼容性
    • 新代码统一使用Header
  • 移动端/API调用:强制使用Header
    • 避免CSRF风险
    • 更符合RESTful规范

提示:如果选择Cookie方案,务必设置SameSite属性为Lax或Strict,这是防范CSRF攻击的关键措施。

3. Shiro整合Token认证的实战细节

3.1 自定义Filter的实现陷阱

Shiro原生的BasicHttpAuthenticationFilter是为HTTP Basic认证设计的,直接继承它来实现Token认证会遇到几个坑:

  1. 依赖注入失效:Filter由Shiro创建,不受Spring管理
  2. 异常处理不友好:默认返回401页面而非JSON
  3. 跨域支持缺失:需要额外处理OPTIONS请求

我们的解决方案:

public class TokenAuthenticationFilter extends BasicHttpAuthenticationFilter { // 解决依赖注入问题 private final TokenService tokenService; public TokenAuthenticationFilter(TokenService tokenService) { this.tokenService = tokenService; } @Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { // 处理OPTIONS预检请求 if (((HttpServletRequest) request).getMethod().equals("OPTIONS")) { return true; } String token = getToken((HttpServletRequest) request); if (token == null) { return false; } try { User user = tokenService.verifyToken(token); // 构建Shiro Token AuthenticationToken shiroToken = new UsernamePasswordToken( user.getUsername(), user.getPasswordHash(), user.getRoles()); // 执行登录 getSubject(request, response).login(shiroToken); return true; } catch (AuthenticationException e) { sendChallenge(request, response); return false; } } private String getToken(HttpServletRequest request) { // 从Header获取 String header = request.getHeader("Authorization"); if (header != null && header.startsWith("Bearer ")) { return header.substring(7); } // 从Cookie获取(兼容老客户端) Cookie[] cookies = request.getCookies(); if (cookies != null) { for (Cookie cookie : cookies) { if ("token".equals(cookie.getName())) { return cookie.getValue(); } } } return null; } @Override protected boolean sendChallenge(ServletRequest request, ServletResponse response) { // 返回JSON格式的错误响应 HttpServletResponse httpResponse = (HttpServletResponse) response; httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED); httpResponse.setContentType("application/json;charset=UTF-8"); try (PrintWriter out = httpResponse.getWriter()) { out.write("{\"code\":401,\"message\":\"无效或过期的Token\"}"); } catch (IOException e) { log.error("发送认证挑战失败", e); } return false; } }

3.2 Shiro配置的关键调整

Shiro配置类需要特别注意以下几个点:

  1. 禁用Session管理器:避免创建无用的Session
  2. 正确注册自定义Filter:确保过滤链生效
  3. 注解支持配置:保持@RequiresRoles等注解可用
@Configuration public class ShiroConfig { @Bean public SecurityManager securityManager(UserRealm userRealm) { DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(); securityManager.setRealm(userRealm); // 禁用Session存储 DefaultSessionStorageEvaluator evaluator = new DefaultSessionStorageEvaluator(); evaluator.setSessionStorageEnabled(false); securityManager.setSessionStorageEvaluator(evaluator); return securityManager; } @Bean public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager, TokenService tokenService) { ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean(); factoryBean.setSecurityManager(securityManager); // 注册自定义Filter Map<String, Filter> filters = new HashMap<>(); filters.put("tokenAuth", new TokenAuthenticationFilter(tokenService)); factoryBean.setFilters(filters); // 配置过滤链 Map<String, String> filterChain = new LinkedHashMap<>(); filterChain.put("/api/login", "anon"); filterChain.put("/api/**", "tokenAuth"); factoryBean.setFilterChainDefinitionMap(filterChain); return factoryBean; } // 保持Shiro注解支持 @Bean public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor( SecurityManager securityManager) { AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor(); advisor.setSecurityManager(securityManager); return advisor; } }

4. 迁移过程中的典型问题及解决方案

4.1 Session残留导致的诡异问题

在过渡期间,我们遇到了几个难以解释的问题:

  • 有时Token验证通过后,请求仍然被重定向到登录页
  • 部分接口莫名其妙地返回Session超时错误
  • 用户登出后仍能访问某些资源

经过深入排查,发现问题根源在于:

  1. Shiro默认会尝试从请求中恢复Session
  2. 浏览器会自动携带已有的Session Cookie
  3. 某些Filter仍然依赖Session机制

最终解决方案

  1. 在Shiro配置中完全禁用Session
    ((DefaultWebSecurityManager)securityManager).setSessionManager(null);
  2. 添加Filter清除可能存在的Session Cookie
    public class SessionCleanupFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) { // 清除JSESSIONID Cookie Cookie cookie = new Cookie("JSESSIONID", null); cookie.setMaxAge(0); cookie.setPath("/"); response.addCookie(cookie); chain.doFilter(request, response); } }
  3. 审计所有自定义Filter,移除对Session的依赖

4.2 跨域与预检请求处理

在前后端分离架构下,跨域问题变得尤为突出。我们遇到了:

  • 预检OPTIONS请求被Shiro拦截
  • CORS头丢失导致前端无法获取错误信息
  • 带凭证的跨域请求失败

解决方案组合

  1. 在自定义Filter中放行OPTIONS请求
    if (request.getMethod().equals("OPTIONS")) { return true; }
  2. 添加专门的CORS Filter
    public class CorsFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) { response.setHeader("Access-Control-Allow-Origin", request.getHeader("Origin")); response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); response.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type"); response.setHeader("Access-Control-Allow-Credentials", "true"); response.setHeader("Access-Control-Max-Age", "3600"); if (!"OPTIONS".equals(request.getMethod())) { chain.doFilter(request, response); } } }
  3. 确保错误响应也包含CORS头

4.3 Token刷新机制的设计

如何平衡安全性和用户体验是Token刷新机制设计的核心难题。我们最终采用的方案:

  1. 双Token机制
    • Access Token:短期有效(如2小时),用于API访问
    • Refresh Token:长期有效(如7天),仅用于获取新Access Token
  2. 刷新流程
    graph TD A[客户端检测到Access Token过期] --> B[使用Refresh Token请求新Access Token] B --> C{服务端验证Refresh Token} C -->|有效| D[颁发新Access Token] C -->|无效| E[要求重新登录]
  3. 安全措施
    • Refresh Token一次有效(使用后立即作废)
    • 记录设备指纹,防止Token盗用
    • 提供主动撤销所有Token的接口

实现代码示例:

@PostMapping("/refresh-token") public ResponseEntity<ApiResponse> refreshToken( @RequestHeader("X-Refresh-Token") String refreshToken, @RequestHeader("X-Device-Id") String deviceId) { // 验证Refresh Token与设备匹配 if (!tokenService.isValidRefreshToken(refreshToken, deviceId)) { return ResponseEntity.status(401).build(); } // 获取关联用户 User user = tokenService.getUserByRefreshToken(refreshToken); // 生成新Token对 TokenPair newTokens = tokenService.generateTokenPair(user, deviceId); // 使旧Refresh Token失效 tokenService.invalidateRefreshToken(refreshToken); return ResponseEntity.ok(ApiResponse.success(newTokens)); }

5. 性能优化与安全加固

5.1 Token验证的性能瓶颈

在高并发场景下,每次请求都查询Redis验证Token会成为性能瓶颈。我们通过以下手段优化:

  1. 本地缓存:使用Caffeine缓存已验证的Token
    @Bean public Cache<String, User> tokenCache() { return Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(5, TimeUnit.MINUTES) .build(); }
  2. 批量验证:支持一次验证多个Token
  3. 黑名单优化:使用布隆过滤器快速判断Token是否可能失效

5.2 安全防护措施

  • Token防篡改:使用HMAC签名
  • 敏感操作二次验证:关键操作需要重新输入密码
  • 异常行为检测:如频繁更换设备、异地登录等
  • 限流防护:防止暴力破解Refresh Token

安全加固后的Token服务实现:

@Service public class EnhancedTokenService implements TokenService { @Autowired private StringRedisTemplate redisTemplate; @Autowired private Cache<String, User> tokenCache; // 使用不同密钥签名不同类型的Token @Value("${token.access-key}") private String accessKey; @Value("${token.refresh-key}") private String refreshKey; @Override public String createAccessToken(User user) { String tokenId = UUID.randomUUID().toString(); String token = Jwts.builder() .setId(tokenId) .setSubject(user.getUsername()) .claim("roles", user.getRoles()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 2 * 60 * 60 * 1000)) // 2小时 .signWith(SignatureAlgorithm.HS256, accessKey.getBytes()) .compact(); // 存储Token与用户关系 redisTemplate.opsForValue().set( "access:" + tokenId, user.getId(), 2, TimeUnit.HOURS); return token; } @Override public boolean verifyAccessToken(String token) { try { String tokenId = Jwts.parser() .setSigningKey(accessKey.getBytes()) .parseClaimsJws(token) .getBody() .getId(); // 先检查本地缓存 if (tokenCache.getIfPresent(token) != null) { return true; } // 检查Redis中是否存在 Boolean exists = redisTemplate.hasKey("access:" + tokenId); if (exists != null && exists) { // 缓存验证结果 User user = getUserByToken(token); if (user != null) { tokenCache.put(token, user); } return true; } return false; } catch (JwtException e) { return false; } } // 其他方法实现... }

迁移到Token认证不是简单的技术替换,而是认证范式的转变。经过三个月的实战,我们总结出最重要的经验是:设计时要考虑全链路,实现时要关注细节,测试时要模拟极端场景。特别是在Shiro这种高度封装的安全框架中改造认证机制,更需要深入理解其内部工作原理。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/1 18:47:59

OpenSubdiv GPU加速实战:CUDA、OpenCL、Metal性能对比终极指南

OpenSubdiv GPU加速实战&#xff1a;CUDA、OpenCL、Metal性能对比终极指南 【免费下载链接】OpenSubdiv An Open-Source subdivision surface library. 项目地址: https://gitcode.com/gh_mirrors/op/OpenSubdiv OpenSubdiv是Pixar开源的细分曲面库&#xff0c;为3D建模…

作者头像 李华
网站建设 2026/4/1 18:40:26

BilibiliDown高效音频提取实战指南:从问题解决到场景落地

BilibiliDown高效音频提取实战指南&#xff1a;从问题解决到场景落地 【免费下载链接】BilibiliDown (GUI-多平台支持) B站 哔哩哔哩 视频下载器。支持稍后再看、收藏夹、UP主视频批量下载|Bilibili Video Downloader &#x1f633; 项目地址: https://gitcode.com/gh_mirror…

作者头像 李华
网站建设 2026/4/1 18:40:18

解锁3大核心优势:PPTist如何重塑在线演示文稿创作体验

解锁3大核心优势&#xff1a;PPTist如何重塑在线演示文稿创作体验 【免费下载链接】PPTist PowerPoint-ist&#xff08;/pauəpɔintist/&#xff09;, An online presentation application that replicates most of the commonly used features of MS PowerPoint, allowing fo…

作者头像 李华
网站建设 2026/4/1 18:39:41

2025届最火的六大降重复率网站实际效果

Ai论文网站排名&#xff08;开题报告、文献综述、降aigc率、降重综合对比&#xff09; TOP1. 千笔AI TOP2. aipasspaper TOP3. 清北论文 TOP4. 豆包 TOP5. kimi TOP6. deepseek 让AIGC也就是人工智能生成内容的检测率得以降低&#xff0c;其核心要点在于把文本所具有的统…

作者头像 李华