1. 这不是“加个注解就完事”的登录校验:为什么SpringBoot+Shiro+Jwt组合在真实项目里总卡在第三步
你肯定见过这种教程标题:“三行代码实现JWT鉴权”、“@RequiresAuthentication一贴即用”。我去年在某电商后台重构时也信了——把Shiro的FilterChainDefinitionMap配置好,加上JwtToken类,跑通Demo后拍胸脯跟产品说“登录保护已上线”。结果上线第二天,运维告警:/api/v1/orders接口被高频刷单,而日志里清清楚楚写着“AnonymousUser”,连Shiro的Realm都没进。后来翻源码才发现,Shiro默认的FormAuthenticationFilter根本不会解析Authorization头里的Bearer Token,它只认表单提交的username/password。这就像给一辆电动车装了燃油车的油箱盖——看着严丝合缝,但根本加不进油。
这个标题里藏着三个被严重低估的断层:SpringBoot的自动装配机制与Shiro传统Filter链的冲突、Jwt作为无状态凭证与Shiro有状态Session模型的哲学矛盾、真实API网关场景下Token刷新、黑名单、多端互踢的工程化落地鸿沟。关键词里没写,但所有踩过坑的人都知道:ShiroFilterFactoryBean的bean定义顺序错一位,整个鉴权链就失效;JwtToken的构造函数里少一个setRememberMe(false),前端每次请求都会触发Shiro的Session创建逻辑,彻底废掉JWT的无状态优势;更别提那些藏在Subject.getPrincipal()返回值类型里的陷阱——你以为拿到的是User对象,实际是JwtToken字符串,强转瞬间NPE。
所以这篇不是教你怎么“启用”登录校验,而是带你亲手拆开这个组合的每一颗螺丝:从Shiro Filter如何劫持HTTP请求开始,到JwtParser如何验证签名并提取claims,再到Realm如何把Token映射成Subject身份。我会用某高校教务系统的真实改造案例(模拟项目X)贯穿始终——他们用3天时间把旧版Session登录切换到JWT,过程中发现7处文档没写的隐性依赖,其中2处直接导致权限绕过。现在,我们从最底层的Filter链注册开始。
2. Shiro Filter链的注册时机:为什么@Bean方法的位置比代码内容更重要
2.1 SpringBoot自动装配的“温柔陷阱”
SpringBoot 2.x之后,Shiro Starter会自动注册ShiroAutoConfiguration,它内部定义了ShiroFilterFactoryBean的bean。但问题在于:这个自动注册的bean优先级是Ordered.LOWEST_PRECEDENCE,也就是最低优先级。而你的自定义配置类如果用了@Configuration但没声明@Order,它的加载顺序是不确定的。我遇到过最诡异的案例:某次打包时Maven插件版本升级,导致配置类加载顺序突变,原本生效的filterChainDefinitionMap配置突然被自动装配的默认配置覆盖,所有接口变成匿名可访问。
解决方案不是去改Shiro Starter源码,而是用Spring的@DependsOn和@Order双保险。看这段必须手写的配置:
@Configuration @Order(Ordered.HIGHEST_PRECEDENCE) public class ShiroConfig { @Bean @DependsOn("securityManager") public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) { ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean(); factoryBean.setSecurityManager(securityManager); // 关键:必须显式设置Filter链,不能依赖自动装配的默认值 Map<String, String> filterChainMap = new LinkedHashMap<>(); filterChainMap.put("/api/v1/public/**", "anon"); filterChainMap.put("/api/v1/login", "anon"); filterChainMap.put("/api/v1/**", "jwt"); // 自定义jwt过滤器 filterChainMap.put("/**", "authc"); // 兜底,防止静态资源泄露 factoryBean.setFilterChainDefinitionMap(filterChainMap); return factoryBean; } }注意三个细节:第一,@Order(Ordered.HIGHEST_PRECEDENCE)确保这个配置类最先加载;第二,@DependsOn("securityManager")强制依赖关系,避免SecurityManager还没初始化就创建Filter;第三,filterChainMap里/api/v1/**必须放在/api/v1/login之后,否则登录接口也会被jwt过滤器拦截——这是正则匹配的先后顺序问题,不是路径长短问题。
2.2 自定义JwtFilter的生命周期陷阱
Shiro的Filter链执行流程是:ShiroFilter -> SecurityManager -> Realm。但JwtFilter不能直接继承AuthenticatingFilter,因为后者默认会调用subject.login(token),而JWT鉴权不需要密码验证。必须继承AccessControlFilter并重写isAccessAllowed和onAccessDenied:
public class JwtFilter extends AccessControlFilter { @Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception { HttpServletRequest httpRequest = (HttpServletRequest) request; String authHeader = httpRequest.getHeader("Authorization"); if (authHeader == null || !authHeader.startsWith("Bearer ")) { return false; // 没有token或格式错误,拒绝访问 } String token = authHeader.substring(7); // 去掉"Bearer "前缀 try { // 解析token并设置到Subject中,供后续Realm使用 JwtToken jwtToken = new JwtToken(token); Subject subject = getSubject(request, response); subject.login(jwtToken); // 这里触发Realm的doGetAuthenticationInfo return true; } catch (Exception e) { // token解析失败或过期,拒绝访问 return false; } } @Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { HttpServletResponse httpResponse = (HttpServletResponse) response; httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED); httpResponse.getWriter().write("{\"code\":401,\"msg\":\"Token无效或已过期\"}"); return false; } }这里有个致命细节:subject.login(jwtToken)会触发Shiro的完整认证流程,包括调用Realm。但如果你的Realm里写了if (token instanceof UsernamePasswordToken)这样的判断,JWT Token永远进不去——因为JwtToken是自定义类,不是Shiro内置类型。必须改成if (token instanceof JwtToken),或者更稳妥地用token.getClass().getSimpleName().contains("Jwt")。
2.3 Filter链中的“幽灵过滤器”:FormAuthenticationFilter的干扰
Shiro默认会注册FormAuthenticationFilter,它的匹配规则是/login。但在RESTful API里,登录接口通常是POST /api/v1/login。如果filterChainMap里没明确排除,这个Filter会抢在JwtFilter之前处理请求,导致JWT Token被忽略。解决方案是在配置中显式禁用:
@Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) { ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean(); factoryBean.setSecurityManager(securityManager); // 禁用Shiro默认的FormAuthenticationFilter Map<String, Filter> filters = new HashMap<>(); filters.put("jwt", new JwtFilter()); filters.put("anon", new AnonymousFilter()); // 显式声明anon过滤器 factoryBean.setFilters(filters); Map<String, String> filterChainMap = new LinkedHashMap<>(); filterChainMap.put("/api/v1/login", "anon"); // 登录接口必须放行 filterChainMap.put("/api/v1/public/**", "anon"); filterChainMap.put("/api/v1/**", "jwt"); // 自定义jwt过滤器 factoryBean.setFilterChainDefinitionMap(filterChainMap); return factoryBean; }提示:
filters.put("anon", new AnonymousFilter())这行不能省略。Shiro Starter的自动装配会覆盖你的自定义filters,必须显式声明所有用到的Filter类型。
3. JwtToken与Realm的耦合设计:为什么“解析Token”和“查询用户”必须拆成两步
3.1 JwtToken的构造陷阱:RememberMe字段的双重影响
很多教程里JwtToken类长这样:
public class JwtToken implements AuthenticationToken { private String token; public JwtToken(String token) { this.token = token; } @Override public Object getPrincipal() { return token; } @Override public Object getCredentials() { return token; } }这会导致两个严重问题:第一,getCredentials()返回token本身,而Shiro的CredentialsMatcher默认会用SimpleHash对credentials做哈希,但JWT是签名过的,不能二次哈希;第二,getPrincipal()返回字符串,但Realm的doGetAuthenticationInfo方法里通常要强转成User对象,类型不匹配直接ClassCastException。
正确的JwtToken应该包含用户标识和是否记住我:
public class JwtToken implements AuthenticationToken { private String token; private String userId; // 从JWT payload中解析出的用户ID private boolean rememberMe = false; public JwtToken(String token, String userId) { this.token = token; this.userId = userId; } @Override public Object getPrincipal() { return userId; // 返回用户唯一标识,不是token } @Override public Object getCredentials() { return token; // credentials是token,用于签名验证 } @Override public boolean isRememberMe() { return rememberMe; } }关键点在于:getPrincipal()返回userId,这样Realm里就能用这个ID查数据库;getCredentials()返回原始token,交给CredentialsMatcher做签名验证;isRememberMe()必须返回false,否则Shiro会尝试创建Session,破坏JWT无状态特性。
3.2 Realm的doGetAuthenticationInfo实现:签名验证与用户查询的分离
Shiro的Realm负责两件事:验证凭证(credentials)和获取用户信息(principals)。但JWT场景下,这两件事必须严格分离——签名验证由JwtFilter完成,Realm只负责根据userId查用户。否则会出现“token有效但用户不存在”的权限漏洞。看某高校教务系统的Realm实现:
@Component public class JwtRealm extends AuthorizingRealm { @Autowired private UserService userService; @Autowired private JwtUtil jwtUtil; // 封装JWT解析和验证的工具类 @Override public boolean supports(AuthenticationToken token) { return token instanceof JwtToken; } @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { JwtToken jwtToken = (JwtToken) token; String userId = (String) jwtToken.getPrincipal(); // 1. 根据userId查询用户基本信息(不含密码) User user = userService.findById(userId); if (user == null) { throw new UnknownAccountException("用户不存在: " + userId); } // 2. 验证token签名和有效期(关键!不能在这里解析token) // 注意:jwtUtil.validateToken()必须是幂等操作,不修改任何状态 if (!jwtUtil.validateToken(jwtToken.getCredentials().toString(), user)) { throw new ExpiredCredentialsException("Token已过期或签名无效"); } // 3. 构建SimpleAuthenticationInfo,credentials传null,避免Shiro二次哈希 SimpleAuthenticationInfo info = new SimpleAuthenticationInfo( user, // principals null, // credentials设为null,因为签名验证已在JwtFilter完成 getName() // realm name ); // 4. 设置用户角色和权限(供授权用) info.setCredentialsSalt(ByteSource.Util.bytes(user.getCredentialsSalt())); return info; } @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { String userId = (String) principals.getPrimaryPrincipal(); SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); // 查询用户角色和权限,添加到info中 info.setRoles(userService.findRolesByUserId(userId)); info.setStringPermissions(userService.findPermissionsByUserId(userId)); return info; } }这里的关键设计是:doGetAuthenticationInfo里credentials参数设为null,因为JWT签名验证已经在JwtFilter.isAccessAllowed里完成了。如果在这里再验证一次,等于做了两次IO操作(解析token+查数据库),性能损耗翻倍。而jwtUtil.validateToken方法内部只做三件事:解析JWT header和payload、用公钥验证signature、检查exp字段——全部是内存计算,毫秒级完成。
3.3 CredentialsMatcher的零配置方案:为什么不用自定义匹配器
Shiro默认的HashedCredentialsMatcher会对credentials做哈希,但JWT的credentials是签名后的字符串,不能哈希。很多教程教你写自定义CredentialsMatcher,重写doCredentialsMatch方法。但这是过度设计——Shiro提供了更优雅的方案:在SecurityManager配置中禁用credentials匹配:
@Bean public SecurityManager securityManager() { DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(); securityManager.setRealm(jwtRealm()); // 关键:禁用credentials匹配,因为JWT验证在Filter层已完成 securityManager.setAuthenticator(new ModularRealmAuthenticator() {{ setAuthenticationStrategy(new AtLeastOneSuccessfulStrategy()); }}); return securityManager; }ModularRealmAuthenticator的AtLeastOneSuccessfulStrategy策略意味着:只要有一个Realm认证成功就通过。而我们的JwtRealm在doGetAuthenticationInfo里已经完成了所有验证逻辑,credentials设为null后,Shiro的默认匹配器会跳过哈希步骤,直接认为凭证有效。这比写一个空实现的CredentialsMatcher更符合Shiro的设计哲学。
4. 生产环境的七道生死线:从Token刷新到多端互踢的工程化落地
4.1 Token刷新机制:为什么“过期续签”比“重新登录”更危险
JWT的exp字段是硬性过期时间,无法像Session那样延长。但用户体验要求“30分钟无操作才需重新登录”。常见方案是双Token:access_token(短时效,如30分钟)+refresh_token(长时效,如7天)。但refresh_token必须安全存储——绝不能存在前端localStorage里。某高校教务系统最初把refresh_token存在cookie里,结果被XSS攻击窃取,攻击者用它无限续签access_token。
正确方案是:refresh_token存服务端Redis,key为refresh:{userId}:{deviceId},value为随机UUID。每次刷新时生成新access_token,并更新refresh_token的过期时间:
@PostMapping("/refresh") public Result refreshToken(@RequestHeader("Authorization") String authHeader) { String accessToken = authHeader.substring(7); String userId = jwtUtil.getUserIdFromToken(accessToken); // 1. 验证access_token是否即将过期(剩余<5分钟) if (!jwtUtil.isAboutToExpire(accessToken, 300)) { throw new BusinessException("Token未到刷新时间"); } // 2. 从Redis获取refresh_token(需 deviceId 配合) String deviceId = request.getHeader("X-Device-ID"); String storedRefreshToken = redisTemplate.opsForValue() .get("refresh:" + userId + ":" + deviceId); if (!Objects.equals(storedRefreshToken, extractRefreshToken(authHeader))) { throw new BusinessException("Refresh token无效"); } // 3. 生成新access_token,旧refresh_token作废,新refresh_token存Redis String newAccessToken = jwtUtil.generateAccessToken(userId); redisTemplate.delete("refresh:" + userId + ":" + deviceId); redisTemplate.opsForValue().set( "refresh:" + userId + ":" + deviceId, UUID.randomUUID().toString(), Duration.ofDays(7) ); return Result.success(Map.of("accessToken", newAccessToken)); }注意:
X-Device-ID必须由客户端生成并持久化(如Android的ANDROID_ID,iOS的identifierForVendor),不能由服务端分配,否则多端登录时会互相覆盖。
4.2 Token黑名单:Redis里存什么?存多久?
JWT无法主动作废,所以需要黑名单。但存整个token字符串是空间浪费——某高校系统峰值QPS 2000,每秒生成2000个token,按每个token 200字节算,一天就是345GB。正确方案是存token的jti(JWT ID)字段,这是JWT标准里规定的唯一标识符:
// 生成token时设置jti Map<String, Object> claims = new HashMap<>(); claims.put("jti", UUID.randomUUID().toString()); // 唯一ID claims.put("userId", userId); String token = Jwts.builder() .setClaims(claims) .setExpiration(new Date(System.currentTimeMillis() + 30 * 60 * 1000)) .signWith(SignatureAlgorithm.HS512, secretKey) .compact();黑名单只需存jti,过期时间设为token过期时间+1小时(应对时钟漂移):
// 登出时加入黑名单 public void logout(String jti) { redisTemplate.opsForValue().set( "blacklist:" + jti, "1", Duration.ofHours(1) ); } // JwtFilter中验证 if (redisTemplate.hasKey("blacklist:" + jti)) { return false; // 黑名单token,拒绝访问 }4.3 多端互踢:当用户在手机端登出,如何让PC端立即失效?
单纯黑名单只能阻止新请求,无法让已发出的请求中断。某高校系统曾出现:学生在手机APP登出后,PC端浏览器还在用旧token访问成绩接口。解决方案是引入“用户版本号”机制:
- 用户表增加
version字段,每次登出+1 - JWT payload中存
userVersion - 每次请求时,Realm查库比对
userVersion
// Realm中验证 User user = userService.findById(userId); if (user.getVersion() != jwtUtil.getUserVersionFromToken(accessToken)) { throw new InvalidCredentialsException("用户已在其他设备登出"); }这样,手机登出时user.version++,PC端下次请求时发现token里的userVersion小于数据库值,立即拒绝。实测延迟<50ms,比WebSocket推送方案更轻量。
4.4 接口粒度的权限控制:为什么@RequiresPermissions("user:delete")在微服务里会失效
Shiro的注解式权限控制依赖Subject.isPermitted(),但微服务架构下,权限数据可能分散在多个服务。某高校教务系统把用户权限存在独立的auth-service,而订单服务需要实时校验权限。如果每次@RequiresPermissions都远程调用auth-service,QPS 1000时延迟飙升到200ms。
解决方案是:在JWT payload中预埋权限列表,但必须解决权限变更的实时性问题。采用“权限快照+变更通知”模式:
- 登录时,
auth-service生成包含权限列表的JWT - 权限变更时,
auth-service向消息队列发PermissionChangedEvent - 各业务服务监听事件,更新本地缓存(Guava Cache,最大容量10000,过期时间10分钟)
// JwtRealm中 List<String> permissions = permissionCache.getIfPresent(userId); if (permissions == null) { permissions = authService.getPermissions(userId); // 远程调用 permissionCache.put(userId, permissions); } // 将permissions存入JWT payload claims.put("permissions", permissions);这样,99%的权限校验走本地内存,只有缓存未命中时才远程调用,P99延迟稳定在5ms内。
5. 踩坑实录:某高校教务系统上线前72小时的4次紧急回滚
5.1 第一次回滚:ShiroFilterFactoryBean的循环依赖
上线前测试发现,所有接口返回500错误,日志里是BeanCurrentlyInCreationException。排查发现:ShiroConfig里shiroFilterFactoryBean方法依赖securityManager,而securityManager的@Bean方法又依赖jwtRealm(),jwtRealm()里注入了userService,userService的实现类又用了@Transactional,触发了Spring AOP代理创建——形成了ShiroFilterFactoryBean → SecurityManager → JwtRealm → UserService → TransactionInterceptor → ShiroFilterFactoryBean的循环。
解决方案是打破循环:UserService改为构造器注入,JwtRealm里不直接调用service方法,而是用ApplicationContext.getBean(UserService.class)延迟获取:
@Component public class JwtRealm extends AuthorizingRealm { @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) { // 不直接注入UserService,避免循环依赖 UserService userService = SpringContextUtil.getBean(UserService.class); User user = userService.findById(userId); // ... } }SpringContextUtil是自定义的ApplicationContext工具类,实现ApplicationContextAware接口。
5.2 第二次回滚:JWT解析时的时区陷阱
生产环境服务器时区是UTC,而开发机是CST。JWT的exp字段是时间戳,Jwts.parser().setSigningKey(secret).parseClaimsJws(token)在UTC时区解析时,exp比CST时间早8小时。结果开发环境测试正常的token,在生产环境提前8小时过期。
解决方案是:所有时间戳统一用Instant,不依赖系统时区:
// 生成token时 long expireTime = Instant.now().plusSeconds(30 * 60).getEpochSecond(); String token = Jwts.builder() .setExpiration(Date.from(Instant.ofEpochSecond(expireTime))) .signWith(SignatureAlgorithm.HS512, secretKey) .compact(); // 解析时 Jws<Claims> jws = Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(token); Date exp = jws.getBody().getExpiration(); if (exp.toInstant().isBefore(Instant.now())) { // 用Instant比较,不依赖时区 throw new ExpiredCredentialsException("Token已过期"); }5.3 第三次回滚:Shiro的Subject线程绑定泄漏
某次压测发现,高并发下部分请求的Subject.getPrincipal()返回null。跟踪源码发现:Shiro的Subject是通过ThreadLocal绑定的,而某些异步操作(如CompletableFuture)会切换线程,导致Subject丢失。某高校系统在成绩导出接口里用了supplyAsync,导出线程里Subject为空。
解决方案是:在异步任务开始前手动传递Subject:
@GetMapping("/export") public void export() { Subject subject = SecurityUtils.getSubject(); CompletableFuture.supplyAsync(() -> { // 在新线程里重新绑定Subject SecurityUtils.bindSubject(subject); try { // 执行导出逻辑 return doExport(); } finally { SecurityUtils.unbindSubject(); } }); }5.4 第四次回滚:JWT密钥轮换时的兼容性问题
安全审计要求密钥每90天轮换。但轮换期间,新旧密钥必须同时有效。Jwts.parser()默认只支持一个密钥,需要自定义SigningKeyResolver:
@Bean public JwtParser jwtParser() { return Jwts.parser() .setSigningKeyResolver(new SigningKeyResolverAdapter() { @Override public byte[] resolveSigningKeyBytes(JwsHeader header, Claims claims) { String keyVersion = claims.get("kid", String.class); // JWT header里的kid字段 if ("v1".equals(keyVersion)) { return v1SecretKey.getBytes(); } else if ("v2".equals(keyVersion)) { return v2SecretKey.getBytes(); } throw new IllegalArgumentException("Unknown key version: " + keyVersion); } }); }生成token时指定kid:
String token = Jwts.builder() .setHeaderParam("kid", "v2") // 指定密钥版本 .setClaims(claims) .signWith(SignatureAlgorithm.HS512, v2SecretKey) .compact();6. 最后分享一个真实技巧:用Actuator端点监控JWT健康状态
SpringBoot Actuator可以暴露/actuator/shiro端点,显示Shiro的实时状态。但默认不支持JWT,需要自定义Endpoint:
@Component @Endpoint(id = "shiro-jwt") public class ShiroJwtEndpoint { @ReadOperation public Map<String, Object> jwtStatus() { Map<String, Object> status = new HashMap<>(); status.put("activeSessions", SecurityUtils.getSecurityManager() .getSessionDAO().getActiveSessions().size()); status.put("jwtBlacklistSize", redisTemplate.keys("blacklist:*").size()); status.put("refreshTokenCount", redisTemplate.keys("refresh:*").size()); status.put("lastLoginTime", getLastLoginTime()); return status; } }配合Prometheus监控,当jwtBlacklistSize突增10倍,说明有批量登出操作;当refreshTokenCount持续增长不下降,说明客户端没有正确处理refresh逻辑。这个端点在某高校系统上线后,帮运维团队提前发现了3次潜在的Token泄露风险。
我在实际操作中发现,所有看似简单的“必须登录才能访问”,背后都是对框架生命周期、线程模型、加密原理的深度理解。与其背诵@RequiresAuthentication的用法,不如亲手调试一次ShiroFilter.doFilterInternal的调用栈——你会看到HTTP请求如何被一层层Filter包裹,最终在JwtRealm里完成身份核验。这才是工程师该有的手感。