Spring Boot整合Shiro:从Session到Token的无缝迁移实战

张开发
2026/4/15 23:40:01 15 分钟阅读

分享文章

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完成原始认证认证通过后才生成业务TokenToken需要存储Redis或数据库返回方式可以是Header、Body或Cookie3.2 自定义Token拦截器这是整个改造最核心的部分。我们需要继承BasicHttpAuthenticationFilterpublic 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认证流程但不创建Session3.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); // 添加自定义过滤器 MapString, Filter filters new HashMap(); filters.put(token, new TokenFilter()); factory.setFilters(filters); // 配置过滤规则 MapString, 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 跨域问题处理如果前端是独立部署需要在拦截器中处理CORSOverride 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;charsetutf-8); httpResponse.getWriter().write(JSON.toJSONString(Result.error(message))); } }5.3 解决Filter注入问题自定义Filter默认无法使用Autowired需要通过以下方式解决Bean public FilterRegistrationBeanTokenFilter filterRegistrationBean( Autowired UserService userService) { FilterRegistrationBeanTokenFilter registration new FilterRegistrationBean(); TokenFilter filter new TokenFilter(); filter.setUserService(userService); // 手动注入 registration.setFilter(filter); registration.setEnabled(false); // 不由Servlet容器管理 return registration; }然后在ShiroConfig中Bean public TokenFilter tokenFilter(FilterRegistrationBeanTokenFilter registration) { return registration.getFilter(); }6. 测试与验证6.1 测试登录接口使用Postman测试POST /login{ username: admin, password: 123456 }检查返回的Token6.2 测试权限控制不带Token访问受限接口 - 应返回401带有效Token访问有权限的接口 - 返回200无权限的接口 - 返回4036.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分钟时自动刷新这种方案既保证了安全性又避免了频繁登录。

更多文章