Spring Boot整合Shiro:从Session到Token的无缝迁移实战
1. 为什么需要从Session迁移到Token
在传统的Spring Boot项目中整合Shiro时,Session是最常用的认证方式。但随着业务发展,特别是需要支持单点登录或微服务架构时,Session的局限性就暴露出来了。我去年接手的一个电商项目就遇到了这个问题:当系统需要接入多个子系统时,Session共享成了大麻烦。
Session机制最大的问题是有状态性。服务器需要维护每个用户的Session信息,这在分布式环境下会导致:
- 需要Session共享方案(如Redis)
- 跨域访问困难
- 移动端支持不友好
- 服务器内存压力大
相比之下,Token认证(如JWT)具有明显优势:
- 无状态:服务器不需要存储会话信息
- 跨域友好:通过HTTP Header传输
- 移动端适配:天然适合APP开发
- 微服务友好:各服务可独立验证Token
但改造的最大挑战在于:如何在不破坏原有Shiro权限体系的前提下,将认证方式从Session切换为Token。这正是本文要解决的核心问题。
2. 改造前的准备工作
2.1 理解Shiro的核心流程
在动手改造前,必须清楚Shiro的工作机制。我画过无数次的流程图,总结下来就三个关键点:
- Subject:当前用户主体
- SecurityManager:安全管理的核心
- Realm:权限数据来源
当我们调用subject.login()时,Shiro会:
- 委托SecurityManager处理
- SecurityManager调用配置的Realm验证凭证
- 验证成功后创建Session(默认行为)
2.2 确定改造范围
根据我的项目经验,改造主要涉及四个部分:
- 登录接口:生成Token替代创建Session
- 拦截器:验证Token替代Session检查
- Shiro配置:替换默认的Session管理器
- Token管理:生成、存储、刷新机制
建议先备份原有代码,我吃过没备份的亏,改出问题时回退特别麻烦。
3. 核心改造步骤详解
3.1 登录接口改造
原来的Session登录方式是这样的:
@PostMapping("/login") public Result login(@RequestBody LoginDTO dto) { Subject subject = SecurityUtils.getSubject(); UsernamePasswordToken token = new UsernamePasswordToken(dto.getUsername(), dto.getPassword()); subject.login(token); // 这里会创建Session return Result.success(); }改造后的Token版本:
@PostMapping("/login") public Result login(HttpServletResponse response, @RequestBody LoginDTO dto) { // 1. 原始Shiro认证 Subject subject = SecurityUtils.getSubject(); UsernamePasswordToken shiroToken = new UsernamePasswordToken(dto.getUsername(), dto.getPassword()); subject.login(shiroToken); // 2. 生成业务Token(示例使用JWT) String jwt = JwtUtil.generateToken(dto.getUsername()); // 3. 存储Token(根据业务选择存储方式) tokenService.saveToken(userId, jwt); // 4. 返回Token给客户端 response.setHeader("Authorization", jwt); return Result.success(jwt); }这里有几个关键点:
- 仍然使用Shiro完成原始认证
- 认证通过后才生成业务Token
- Token需要存储(Redis或数据库)
- 返回方式可以是Header、Body或Cookie
3.2 自定义Token拦截器
这是整个改造最核心的部分。我们需要继承BasicHttpAuthenticationFilter:
public class TokenFilter extends BasicHttpAuthenticationFilter { @Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { // 1. 获取Token HttpServletRequest httpRequest = (HttpServletRequest) request; String token = httpRequest.getHeader("Authorization"); // 2. Token不存在直接拒绝 if(StringUtils.isBlank(token)) { return false; } try { // 3. 验证Token有效性 Claims claims = JwtUtil.parseToken(token); String username = claims.getSubject(); // 4. 构造ShiroToken(关键步骤!) UsernamePasswordToken shiroToken = new UsernamePasswordToken(username, null); // 5. 交给Shiro认证(此时不会创建Session) SecurityUtils.getSubject().login(shiroToken); return true; } catch (Exception e) { return false; } } @Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { HttpServletResponse httpResponse = (HttpServletResponse) response; httpResponse.setStatus(HttpStatus.UNAUTHORIZED.value()); httpResponse.getWriter().write("Invalid token"); return false; } }这个拦截器做了几件重要的事:
- 从Header提取Token
- 验证Token有效性
- 构造Shiro认识的Token对象
- 触发Shiro认证流程但不创建Session
3.3 Shiro配置调整
在ShiroConfig中需要做三处关键修改:
@Configuration public class ShiroConfig { @Bean public SessionManager sessionManager() { // 禁用Session DefaultWebSessionManager manager = new DefaultWebSessionManager(); manager.setSessionValidationSchedulerEnabled(false); manager.setSessionIdUrlRewritingEnabled(false); return manager; } @Bean public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) { ShiroFilterFactoryBean factory = new ShiroFilterFactoryBean(); factory.setSecurityManager(securityManager); // 添加自定义过滤器 Map<String, Filter> filters = new HashMap<>(); filters.put("token", new TokenFilter()); factory.setFilters(filters); // 配置过滤规则 Map<String, String> filterMap = new LinkedHashMap<>(); filterMap.put("/login", "anon"); filterMap.put("/**", "token"); // 所有请求走Token验证 factory.setFilterChainDefinitionMap(filterMap); return factory; } }特别注意:
- 禁用了Session管理器
- 注册了我们的TokenFilter
- 配置了拦截规则
4. 关键问题与解决方案
4.1 如何保持权限体系不变
这是很多开发者的疑问:改用Token后,Shiro的@RequiresRoles等注解还能用吗?答案是肯定的。因为在我们的拦截器中,仍然调用了subject.login(),Shiro的权限体系完全不受影响。
实测代码:
@RequiresRoles("admin") @GetMapping("/admin") public Result adminPage() { // 只有admin角色能访问 return Result.success(); }4.2 Token管理策略
根据项目需求,Token管理可以有不同的实现方式:
简单JWT:
- 优点:无需存储
- 缺点:无法主动失效
Redis存储:
// 存储示例 redisTemplate.opsForValue().set("token:"+username, token, 2, TimeUnit.HOURS); // 验证时检查 String storedToken = redisTemplate.opsForValue().get("token:"+username); if(!token.equals(storedToken)) { throw new AuthenticationException(); }数据库存储:
- 适合需要记录详细登录信息的场景
- 可以方便实现多端登录管理
4.3 跨域问题处理
如果前端是独立部署,需要在拦截器中处理CORS:
@Override protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception { HttpServletResponse httpResponse = (HttpServletResponse) response; httpResponse.setHeader("Access-Control-Allow-Origin", "*"); httpResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS"); httpResponse.setHeader("Access-Control-Allow-Headers", "Authorization"); return true; }5. 完整代码示例
5.1 JWT工具类
public class JwtUtil { private static final String SECRET = "your-secret-key"; private static final long EXPIRE = 3600L; // 1小时 public static String generateToken(String username) { Date now = new Date(); Date expire = new Date(now.getTime() + EXPIRE * 1000); return Jwts.builder() .setSubject(username) .setIssuedAt(now) .setExpiration(expire) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET) .parseClaimsJws(token) .getBody(); } }5.2 增强版Token拦截器
public class TokenFilter extends BasicHttpAuthenticationFilter { @Autowired private UserService userService; // 如何注入看下文 @Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { if (isLoginAttempt(request, response)) { try { return executeLogin(request, response); } catch (Exception e) { responseError(response, "Token验证失败"); } } return false; } @Override protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) { HttpServletRequest req = (HttpServletRequest) request; String token = req.getHeader("Authorization"); return token != null; } @Override protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception { HttpServletRequest httpRequest = (HttpServletRequest) request; String token = httpRequest.getHeader("Authorization"); // 验证Token Claims claims = JwtUtil.parseToken(token); String username = claims.getSubject(); // 获取用户详情(可根据需要) User user = userService.findByUsername(username); // 构造ShiroToken UsernamePasswordToken shiroToken = new UsernamePasswordToken( username, null, user.getRoles(), // 传入角色信息 user.getPermissions() // 传入权限信息 ); SecurityUtils.getSubject().login(shiroToken); return true; } private void responseError(ServletResponse response, String message) { HttpServletResponse httpResponse = (HttpServletResponse) response; httpResponse.setStatus(HttpStatus.UNAUTHORIZED.value()); httpResponse.setContentType("application/json;charset=utf-8"); httpResponse.getWriter().write(JSON.toJSONString(Result.error(message))); } }5.3 解决Filter注入问题
自定义Filter默认无法使用@Autowired,需要通过以下方式解决:
@Bean public FilterRegistrationBean<TokenFilter> filterRegistrationBean( @Autowired UserService userService) { FilterRegistrationBean<TokenFilter> registration = new FilterRegistrationBean<>(); TokenFilter filter = new TokenFilter(); filter.setUserService(userService); // 手动注入 registration.setFilter(filter); registration.setEnabled(false); // 不由Servlet容器管理 return registration; }然后在ShiroConfig中:
@Bean public TokenFilter tokenFilter(FilterRegistrationBean<TokenFilter> registration) { return registration.getFilter(); }6. 测试与验证
6.1 测试登录接口
使用Postman测试:
- POST /login
{ "username": "admin", "password": "123456" } - 检查返回的Token
6.2 测试权限控制
- 不带Token访问受限接口 - 应返回401
- 带有效Token访问:
- 有权限的接口 - 返回200
- 无权限的接口 - 返回403
6.3 性能测试建议
使用JMeter模拟:
- 100并发持续请求
- 对比改造前后的内存占用
- 重点关注Token解析耗时
7. 进阶优化方向
7.1 Token刷新机制
实现无感刷新:
@PostMapping("/refresh") public Result refreshToken(@RequestHeader("Authorization") String oldToken) { Claims claims = JwtUtil.parseToken(oldToken); if(claims.getExpiration().before(new Date())) { throw new BusinessException("Token已过期"); } String newToken = JwtUtil.generateToken(claims.getSubject()); return Result.success(newToken); }7.2 多端登录管理
在Token中增加客户端标识:
public static String generateToken(String username, String clientType) { return Jwts.builder() .setSubject(username) .claim("client", clientType) // 添加客户端标识 .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); }7.3 黑名单机制
对于需要主动注销的场景:
// 注销时 redisTemplate.opsForValue().set("blacklist:"+token, "1", EXPIRE, TimeUnit.SECONDS); // 拦截器中检查 if(redisTemplate.hasKey("blacklist:"+token)) { throw new AuthenticationException("Token已注销"); }8. 项目中的实际坑点
- 时间同步问题:JWT校验依赖服务器时间,集群环境下务必保证时间同步
- Token泄露处理:建议结合IP识别、UA指纹等增强安全性
- 性能瓶颈:Redis存储Token时,要注意合理设置过期时间
- 移动端适配:Android的OkHttp默认不会携带Authorization头,需要特殊处理
我在实际项目中遇到一个典型问题:当Token过期时间设置过长时,会导致安全性下降;设置过短又影响用户体验。最终采用的方案是:
- 访问Token有效期2小时
- 刷新Token有效期7天
- 每次请求检查剩余有效期小于30分钟时自动刷新
这种方案既保证了安全性,又避免了频繁登录。
