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

张开发
2026/6/17 8:41:19 15 分钟阅读
Spring Boot项目从Session迁移到Token认证,我踩过的那些坑(Shiro实战避坑指南)
Spring Boot项目从Session迁移到Token认证的实战避坑指南当传统基于Session的认证系统遇上微服务架构和前后端分离趋势改造升级成为必然。去年我们团队将一个运行三年的Spring BootShiro项目从Session迁移到Token认证整个过程堪称一部血泪史。本文将分享我们在改造过程中遇到的典型问题及解决方案希望能为面临类似挑战的开发者提供参考。1. 为什么需要从Session迁移到TokenSession机制在过去二十年里一直是Web应用认证的主流方案。它通过在服务端存储用户状态客户端仅保留一个Session ID来实现身份识别。但随着系统架构演进这种模式逐渐暴露出几个致命缺陷扩展性问题在集群环境下需要Session共享方案增加了系统复杂度跨域限制难以适应前后端分离和移动端混合开发的场景CSRF风险基于Cookie的机制天生存在安全漏洞RESTful兼容性违背了无状态API的设计原则相比之下Token认证如JWT具有以下优势无状态服务端不需要存储会话信息跨域友好可通过Header轻松传递移动端适配天然适合APP等非浏览器环境微服务友好便于在分布式系统中传递身份信息// 传统Session认证 vs Token认证流程对比 Session认证流程 1. 用户登录 → 2. 服务端创建Session → 3. 返回Session ID → 4. 客户端存储Cookie → 5. 后续请求携带Cookie Token认证流程 1. 用户登录 → 2. 服务端生成Token → 3. 返回Token → 4. 客户端存储Token → 5. 后续请求携带Token2. 迁移方案设计与核心决策点2.1 渐进式迁移还是全量替换我们最终选择了渐进式迁移方案主要考虑因素包括系统已有稳定运行的业务逻辑部分老客户端仍依赖Session机制需要保证升级过程不影响线上用户具体实施策略新功能统一采用Token认证老功能分批次改造设置过渡期同时支持两种机制最终完全移除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认证会遇到几个坑依赖注入失效Filter由Shiro创建不受Spring管理异常处理不友好默认返回401页面而非JSON跨域支持缺失需要额外处理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;charsetUTF-8); try (PrintWriter out httpResponse.getWriter()) { out.write({\code\:401,\message\:\无效或过期的Token\}); } catch (IOException e) { log.error(发送认证挑战失败, e); } return false; } }3.2 Shiro配置的关键调整Shiro配置类需要特别注意以下几个点禁用Session管理器避免创建无用的Session正确注册自定义Filter确保过滤链生效注解支持配置保持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 MapString, Filter filters new HashMap(); filters.put(tokenAuth, new TokenAuthenticationFilter(tokenService)); factoryBean.setFilters(filters); // 配置过滤链 MapString, 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超时错误用户登出后仍能访问某些资源经过深入排查发现问题根源在于Shiro默认会尝试从请求中恢复Session浏览器会自动携带已有的Session Cookie某些Filter仍然依赖Session机制最终解决方案在Shiro配置中完全禁用Session((DefaultWebSecurityManager)securityManager).setSessionManager(null);添加Filter清除可能存在的Session Cookiepublic 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); } }审计所有自定义Filter移除对Session的依赖4.2 跨域与预检请求处理在前后端分离架构下跨域问题变得尤为突出。我们遇到了预检OPTIONS请求被Shiro拦截CORS头丢失导致前端无法获取错误信息带凭证的跨域请求失败解决方案组合在自定义Filter中放行OPTIONS请求if (request.getMethod().equals(OPTIONS)) { return true; }添加专门的CORS Filterpublic 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); } } }确保错误响应也包含CORS头4.3 Token刷新机制的设计如何平衡安全性和用户体验是Token刷新机制设计的核心难题。我们最终采用的方案双Token机制Access Token短期有效如2小时用于API访问Refresh Token长期有效如7天仅用于获取新Access Token刷新流程graph TD A[客户端检测到Access Token过期] -- B[使用Refresh Token请求新Access Token] B -- C{服务端验证Refresh Token} C --|有效| D[颁发新Access Token] C --|无效| E[要求重新登录]安全措施Refresh Token一次有效使用后立即作废记录设备指纹防止Token盗用提供主动撤销所有Token的接口实现代码示例PostMapping(/refresh-token) public ResponseEntityApiResponse 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会成为性能瓶颈。我们通过以下手段优化本地缓存使用Caffeine缓存已验证的TokenBean public CacheString, User tokenCache() { return Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(5, TimeUnit.MINUTES) .build(); }批量验证支持一次验证多个Token黑名单优化使用布隆过滤器快速判断Token是否可能失效5.2 安全防护措施Token防篡改使用HMAC签名敏感操作二次验证关键操作需要重新输入密码异常行为检测如频繁更换设备、异地登录等限流防护防止暴力破解Refresh Token安全加固后的Token服务实现Service public class EnhancedTokenService implements TokenService { Autowired private StringRedisTemplate redisTemplate; Autowired private CacheString, 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这种高度封装的安全框架中改造认证机制更需要深入理解其内部工作原理。

更多文章