Spring Boot项目从Session迁移到Token认证的实战避坑指南
当传统基于Session的认证系统遇上微服务架构和前后端分离趋势,改造升级成为必然。去年我们团队将一个运行三年的Spring Boot+Shiro项目从Session迁移到Token认证,整个过程堪称一部"血泪史"。本文将分享我们在改造过程中遇到的典型问题及解决方案,希望能为面临类似挑战的开发者提供参考。
1. 为什么需要从Session迁移到Token
Session机制在过去二十年里一直是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;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配置类需要特别注意以下几个点:
- 禁用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 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超时错误
- 用户登出后仍能访问某些资源
经过深入排查,发现问题根源在于:
- Shiro默认会尝试从请求中恢复Session
- 浏览器会自动携带已有的Session Cookie
- 某些Filter仍然依赖Session机制
最终解决方案:
- 在Shiro配置中完全禁用Session
((DefaultWebSecurityManager)securityManager).setSessionManager(null); - 添加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); } } - 审计所有自定义Filter,移除对Session的依赖
4.2 跨域与预检请求处理
在前后端分离架构下,跨域问题变得尤为突出。我们遇到了:
- 预检OPTIONS请求被Shiro拦截
- CORS头丢失导致前端无法获取错误信息
- 带凭证的跨域请求失败
解决方案组合:
- 在自定义Filter中放行OPTIONS请求
if (request.getMethod().equals("OPTIONS")) { return true; } - 添加专门的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); } } } - 确保错误响应也包含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 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会成为性能瓶颈。我们通过以下手段优化:
- 本地缓存:使用Caffeine缓存已验证的Token
@Bean public Cache<String, 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 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这种高度封装的安全框架中改造认证机制,更需要深入理解其内部工作原理。