1. 为什么在2024年还要用Shiro做JWT认证——一个被低估的“老派”组合
很多人看到标题第一反应是:“Shiro不是早被Spring Security取代了吗?JWT不都配Spring Boot Starter了?”我去年重构一个金融类后台系统时,也这么想。结果在压测阶段发现,Spring Security默认的JWT解析链路在高并发下CPU占用率飙升37%,而我们用Shiro+自定义Filter重写的认证模块,同等QPS下GC次数减少52%,线程阻塞时间从平均86ms压到12ms以内。这不是怀旧,而是权衡——Shiro的职责边界极其清晰:它只管“你是谁”和“你能干啥”,不碰HTTP生命周期、不卷进WebFlux响应式流、不强制你写一堆@PreAuthorize注解。它像一把瑞士军刀里的主刃,轻、准、可控。而JWT恰恰需要这种“不越界”的容器:它只负责把用户身份、角色、过期时间、签发方这些信息安全地打包、签名、传输,至于怎么验、验完怎么塞进上下文、怎么和数据库权限表联动——这些决策权,得交还给业务开发者。我们项目里,一个JWT token里只放user_id和tenant_id,所有菜单权限、按钮权限、数据行级权限,全靠Shiro的Realm从MySQL+Redis双写缓存中实时加载。这样做的好处是:token体积小(<300B)、签发快(单机QPS 12,000+)、权限变更即时生效(缓存TTL设为30秒,比JWT过期时间短得多)。如果你正在维护一个已有Shiro基础的老系统,或者需要细粒度控制认证流程(比如多租户隔离、动态权限开关、登录态续期逻辑),这个组合不是技术债,而是精准手术刀。
2. JWT与Shiro的底层耦合点:不是“集成”,而是“接管”
很多教程说“Shiro支持JWT”,这说法有误导性。Shiro本身根本不认识JWT——它只认Subject、AuthenticationToken、Realm这三个核心接口。所谓“集成”,本质是用自定义的AuthenticationToken替代UsernamePasswordToken,再用自定义Filter拦截请求头中的Authorization字段,把JWT字符串解析成Shiro能理解的Token对象。这个过程没有魔法,只有三处必须动手的地方:
2.1 自定义JwtToken:让Shiro“看懂”JWT字符串
public class JwtToken implements AuthenticationToken { private final String token; public JwtToken(String token) { this.token = token; } @Override public Object getPrincipal() { // 这里不能直接返回token字符串! // 必须解析出业务主键,比如user_id,供Realm查询 return JwtUtil.parseUserId(token); // 内部调用JJWT库解析claims } @Override public Object getCredentials() { // 返回原始token字符串,供CredentialsMatcher校验签名 return token; } }关键点在于getPrincipal()的实现。新手常犯的错误是直接return token,导致Realm里拿到的是乱码字符串而非用户ID。Shiro的认证流程是:Filter → 创建JwtToken → Subject.login(jwtToken) → SecurityManager调用Realm.doGetAuthenticationInfo() → Realm根据getPrincipal()返回值查数据库。所以getPrincipal()必须是可查的业务标识,getCredentials()才是原始token。我们实测发现,如果这里返回null,Shiro会抛出UnknownAccountException,但错误日志里根本看不出是JWT解析失败还是数据库查不到——这是第一个深坑。
2.2 自定义JwtFilter:在Shiro生命周期前端“截胡”请求
Shiro的Filter链默认只处理Form提交和Basic Auth。要让JWT走通,必须写一个继承AccessControlFilter的过滤器:
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 ")) { // 未携带token,放行给后续Filter(比如登录接口) return true; } String token = authHeader.substring(7); // 去掉"Bearer "前缀 try { // 验证token格式和签名 JwtUtil.verify(token); // 将token存入ThreadLocal,供后续Realm使用 JwtUtil.setCurrentToken(token); return true; } catch (ExpiredJwtException e) { // 过期token单独处理,返回401+自定义错误码 HttpServletResponse httpResponse = (HttpServletResponse) response; httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED); httpResponse.getWriter().write("{\"code\":40101,\"msg\":\"token已过期\"}"); return false; } catch (Exception e) { // 签名无效、格式错误等,统一拦截 HttpServletResponse httpResponse = (HttpServletResponse) response; httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED); httpResponse.getWriter().write("{\"code\":40102,\"msg\":\"非法token\"}"); return false; } } @Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { // 此方法在isAccessAllowed返回false时触发 // 但我们已在isAccessAllowed里写了响应体,这里直接返回false阻止后续流程 return false; } }注意两个细节:第一,isAccessAllowed里做了签名验证,而不是等到Realm里才验——这是性能关键。JJWT的Jwts.parser().setSigningKey(...).parseClaimsJws(token)是CPU密集型操作,放在Filter里提前拦截,避免无效token进入Shiro完整的认证流程(那会触发Subject创建、Session管理等开销)。第二,onAccessDenied里不写响应体,因为isAccessAllowed已经写了。如果在这里重复写,会触发IllegalStateException: getWriter() has already been called。我们踩过这个坑,日志里全是java.lang.IllegalStateException,但前端只看到空白响应。
2.3 自定义JwtRealm:把JWT“翻译”成Shiro的认证信息
public class JwtRealm extends AuthorizingRealm { @Override public boolean supports(AuthenticationToken token) { // 只支持我们自定义的JwtToken return token instanceof JwtToken; } @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { String userId = (String) token.getPrincipal(); if (userId == null) { throw new AccountException("token中未包含user_id"); } // 从Redis缓存查用户基础信息(含salt、password_hash) UserCache userCache = redisService.get("user:" + userId, UserCache.class); if (userCache == null) { throw new UnknownAccountException("用户不存在或已注销"); } // 构建SimpleAuthenticationInfo // 参数1:用户唯一标识(principal) // 参数2:密码hash值(credentials,用于后续CredentialsMatcher比对) // 参数3:盐值(用于加盐校验) // 参数4:realm名称(必须和ini配置里一致) return new SimpleAuthenticationInfo( userId, userCache.getPasswordHash(), ByteSource.Util.bytes(userCache.getSalt()), getName() ); } @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { String userId = (String) principals.getPrimaryPrincipal(); // 查询用户角色和权限(从DB或缓存) List<String> roles = permissionService.getUserRoles(userId); List<String> permissions = permissionService.getUserPermissions(userId); SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); info.addRoles(new HashSet<>(roles)); info.addStringPermissions(new HashSet<>(permissions)); return info; } }这里的关键是doGetAuthenticationInfo的返回值。Shiro的CredentialsMatcher会用userCache.getPasswordHash()和ByteSource.Util.bytes(userCache.getSalt())去校验JWT签名是否有效——等等,JWT签名不是用密钥验的吗?没错,但Shiro的设计哲学是:认证信息(AuthenticationInfo)必须包含足以完成校验的所有要素。所以我们把用户密码hash当“密钥”用,把salt当“签名密钥”,而JWT本身的签名验证已在Filter里完成。这样设计的好处是:Shiro的认证流程保持完整,你可以复用HashedCredentialsMatcher,不用重写整个校验逻辑;坏处是,如果用户改了密码,JWT不会自动失效(因为签名密钥没变)。我们的解决方案是在JWT payload里加一个pwd_version字段,每次改密时递增,Realm查到版本不匹配就拒绝登录。这个细节90%的教程都漏掉了。
3. 权限控制的三层穿透:从URL到按钮再到数据行
Shiro的权限模型常被简化为“角色-权限”二维表,但在真实业务中,权限是立体的。我们系统里一个采购专员登录后,能看到“采购管理”菜单,但只能操作自己部门的采购单;点击“编辑”按钮时,要校验他是否有该单据的编辑权限(可能被上级临时授权);保存时,还要检查他修改的金额是否超过部门预算额度。这需要Shiro的三层拦截能力:
3.1 URL级拦截:用ShiroFilterFactoryBean配置路径规则
@Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) { ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean(); factoryBean.setSecurityManager(securityManager); // 定义URL规则链 Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>(); // 静态资源放行 filterChainDefinitionMap.put("/static/**", "anon"); filterChainDefinitionMap.put("/favicon.ico", "anon"); // 登录接口放行 filterChainDefinitionMap.put("/api/auth/login", "anon"); filterChainDefinitionMap.put("/api/auth/refresh", "anon"); // JWT认证接口(需token且未过期) filterChainDefinitionMap.put("/api/**", "jwt"); // 对应我们自定义的JwtFilter // 角色级控制(如管理员才能访问系统设置) filterChainDefinitionMap.put("/api/sys/**", "roles[admin]"); // 权限级控制(如需'purchase:edit'权限才能调用) filterChainDefinitionMap.put("/api/purchase/edit", "perms[purchase:edit]"); factoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap); return factoryBean; }注意/api/**和/api/purchase/edit的顺序。Shiro按定义顺序匹配,所以更具体的路径必须写在通配符/api/**之前,否则永远走不到。我们曾因顺序写反,导致所有采购接口都返回401,排查了3小时才发现是配置顺序问题。
3.2 方法级注解:用@RequiresPermissions控制业务逻辑入口
@RestController @RequestMapping("/api/purchase") public class PurchaseController { @PostMapping("/submit") @RequiresPermissions("purchase:submit") // Shiro自动校验当前Subject是否有此权限 public Result submit(@RequestBody PurchaseOrder order) { // 业务逻辑 return Result.success(purchaseService.submit(order)); } @GetMapping("/list") @RequiresPermissions("purchase:view") public Result list(@RequestParam String deptId) { // 关键:这里deptId是前端传的,但Shiro不校验参数! // 必须在service层做数据行级校验 return Result.success(purchaseService.listByDept(deptId)); } }@RequiresPermissions的陷阱在于:它只校验Subject是否拥有该字符串权限,不关心参数内容。比如用户A有purchase:view权限,他调用/api/purchase/list?deptId=finance时,Shiro放行;但如果他恶意改成deptId=admin,后端必须在purchaseService.listByDept()里校验deptId是否属于用户所在部门。我们用AOP切面统一处理:所有带@RequiresPermissions的方法,自动注入@CurrentUser注解,从Subject里提取用户ID和部门ID,在DAO层拼接AND dept_id = ?条件。这个切面代码我们封装成了公共starter,所有新项目直接引用。
3.3 数据行级控制:用Shiro的AuthorizationInfo动态注入数据范围
真正的难点在数据行级。比如财务总监能看到所有部门采购单,但采购员只能看自己部门的。如果在每个SQL里手写WHERE dept_id = ?,维护成本极高。我们的方案是:在doGetAuthorizationInfo里,不仅返回权限字符串,还返回一个DataScope对象:
@Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { String userId = (String) principals.getPrimaryPrincipal(); User user = userService.getById(userId); SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); info.addRoles(user.getRoles()); info.addStringPermissions(user.getPermissions()); // 动态注入数据范围 DataScope scope = new DataScope(); if ("director".equals(user.getRole())) { scope.setAll(true); // 全局可见 } else { scope.setDeptIds(Collections.singletonList(user.getDeptId())); } info.setAttribute("dataScope", scope); return info; }然后在MyBatis拦截器里读取这个属性:
@Intercepts(@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})) public class DataScopeInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { Object[] args = invocation.getArgs(); MappedStatement ms = (MappedStatement) args[0]; Object parameter = args[1]; // 获取当前用户的dataScope Subject subject = SecurityUtils.getSubject(); DataScope scope = (DataScope) subject.getPrincipals().getPrimaryPrincipal().getAttribute("dataScope"); if (scope != null && !scope.isAll()) { // 给parameter添加deptIds条件 if (parameter instanceof Map) { ((Map) parameter).put("deptIds", scope.getDeptIds()); } } return invocation.proceed(); } }这样,所有带<if test="deptIds != null">AND dept_id IN ...</if>的SQL都会自动加上部门过滤。我们测试过,这个拦截器对QPS影响小于0.3%,比在每个Service里手动拼条件靠谱得多。
4. 生产环境的七处致命细节与避坑清单
在K8s集群上跑了一年多,我们整理出JWT+Shiro在生产中最容易翻车的七个点,每个都附带真实故障案例和修复方案:
4.1 Token刷新机制:别让前端自己拼Bearer头
问题现象:用户登录后2小时无操作,token过期,前端自动调用/api/auth/refresh获取新token,但新token返回后,后续请求仍401。
根因分析:前端JS代码里,fetch请求的headers是{Authorization: 'Bearer ' + oldToken},但刷新后没更新全局token变量,所有请求还在用旧token。
解决方案:我们强制要求所有API请求必须通过统一的apiClient封装,内部自动读取localStorage.getItem('access_token'),并在每次refresh成功后自动更新。同时在JwtFilter里加日志:log.warn("token过期,但refresh接口被绕过,IP:{}", request.getRemoteAddr()),监控到异常调用量突增就告警。
4.2 密钥轮换:RSA私钥不能硬编码在代码里
问题现象:安全审计发现,JWT签名密钥privateKey.pem文件被提交到Git仓库,且所有节点用同一份密钥。
根因分析:开发图省事,把密钥文件放在src/main/resources下,打包进jar。一旦泄露,所有token都可伪造。
解决方案:密钥必须由运维通过K8s Secret挂载到容器的/etc/shiro/keys/目录,应用启动时读取。我们用@PostConstruct方法校验密钥有效性:
@PostConstruct public void init() { try { String keyPath = System.getProperty("shiro.jwt.key.path", "/etc/shiro/keys/privateKey.pem"); privateKey = RsaUtil.loadPrivateKey(new FileInputStream(keyPath)); log.info("JWT私钥加载成功,模长:{} bits", privateKey.getModulus().bitLength()); } catch (Exception e) { log.error("JWT私钥加载失败", e); throw new RuntimeException("密钥初始化失败", e); } }4.3 时间漂移:服务器时间不同步导致token频繁过期
问题现象:测试环境一切正常,上线后大量用户反馈“刚登录就过期”。
根因分析:K8s集群中某台Node节点NTP服务异常,时间比标准时间快3分钟,而JWT的exp字段是服务端生成的,客户端校验时发现exp < now,直接拒绝。
解决方案:在JwtUtil.verify()里加入时间容错:
Jws<Claims> jws = Jwts.parser() .setSigningKey(publicKey) .requireIssuer("our-system") .setAllowedClockSkewSeconds(180) // 允许3分钟误差 .parseClaimsJws(token);同时,运维必须监控所有节点的NTP偏移量,超过500ms自动告警。
4.4 Redis缓存击穿:用户信息缓存雪崩
问题现象:凌晨3点,大量用户集中登录,Shiro Realm频繁查DB,数据库CPU飙到95%。
根因分析:用户信息缓存key为user:{id},过期时间设为30分钟,但没加互斥锁。当缓存失效时,100个并发请求同时查DB,全部回源。
解决方案:用Redis的SETNX指令实现分布式锁:
public UserCache getUserCache(String userId) { String cacheKey = "user:" + userId; UserCache cache = redisService.get(cacheKey, UserCache.class); if (cache != null) return cache; // 尝试获取锁 String lockKey = "lock:user:" + userId; String requestId = UUID.randomUUID().toString(); Boolean locked = redisService.setNx(lockKey, requestId, 30); // 锁30秒 if (locked) { try { cache = loadFromDb(userId); // 查DB redisService.set(cacheKey, cache, 1800); // 缓存30分钟 } finally { // 释放锁(Lua脚本保证原子性) redisService.eval("if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end", Collections.singletonList(lockKey), Collections.singletonList(requestId)); } } else { // 等待100ms后重试(避免所有请求同时重试) Thread.sleep(100); return getUserCache(userId); } return cache; }4.5 权限变更延迟:用户刚被授予权限,立即调用却401
问题现象:管理员给用户A分配了purchase:edit权限,A立刻点击编辑按钮,返回401。
根因分析:Shiro的AuthorizationInfo默认缓存30分钟(AuthorizationInfo对象被CacheManager缓存),权限变更后缓存未及时清理。
解决方案:在权限变更接口里,主动清除缓存:
@Service public class PermissionService { @Autowired private CacheManager cacheManager; public void updatePermissions(String userId, List<String> permissions) { // 更新DB permissionMapper.updateByUserId(userId, permissions); // 清除Shiro缓存 Cache<Object, AuthorizationInfo> authorizationCache = cacheManager.getCache("authorizationCache"); authorizationCache.remove(userId); // 同时清除认证缓存(因为权限变更常伴随角色变更) Cache<Object, AuthenticationInfo> authenticationCache = cacheManager.getCache("authenticationCache"); authenticationCache.remove(userId); } }4.6 多租户隔离:JWT里tenant_id被恶意篡改
问题现象:用户A登录后,手动修改JWT payload里的tenant_id为B公司的ID,成功访问B公司数据。
根因分析:JWT签名只保护payload不被篡改,但tenant_id是业务字段,如果签名密钥泄露或算法被降级(如HS256被强制为none),就可伪造。
解决方案:双重校验。第一,在JwtFilter里解析token后,立即从Subject中取出用户所属租户ID,与JWT中的tenant_id比对;第二,在doGetAuthenticationInfo里,用tenant_id作为查询条件的一部分:
// JwtFilter中 String tenantIdInToken = JwtUtil.getClaim(token, "tenant_id", String.class); String tenantIdInSubject = (String) SecurityUtils.getSubject().getPrincipal(); // 实际是user_id,需查库 User user = userService.getById(tenantIdInSubject); if (!Objects.equals(user.getTenantId(), tenantIdInToken)) { throw new InvalidRequestException("tenant_id不匹配"); }4.7 日志脱敏:JWT token明文打印引发安全漏洞
问题现象:线上日志里出现大量token=eyJhbGciOiJIUzI1NiIsInR5c...,被安全团队通报为高危风险。
根因分析:开发在debug日志里写了log.debug("token: {}", token),而logback配置未对敏感字段脱敏。
解决方案:自定义logback转换器:
<!-- logback-spring.xml --> <conversionRule conversionWord="jwtToken" converterClass="com.example.log.JwtTokenConverter"/> <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg %jwtToken%n</pattern> </encoder> </appender>public class JwtTokenConverter extends ClassicConverter { @Override public String convert(ILoggingEvent event) { String message = event.getFormattedMessage(); // 匹配JWT token模式(base64url开头,含.分隔) return message.replaceAll("eyJ[a-zA-Z0-9_-]{10,}\\.[a-zA-Z0-9_-]{10,}\\.[a-zA-Z0-9_-]{10,}", "******"); } }这个正则能覆盖99%的JWT格式,且不影响其他日志内容。我们上线后,安全扫描工具的“敏感信息泄露”告警下降了100%。
5. 性能压测对比:Shiro vs Spring Security JWT方案
为了验证选择合理性,我们在相同硬件(4核8G,MySQL 8.0,Redis 6.2)上做了全链路压测。测试场景:1000并发用户,持续5分钟,请求/api/purchase/list(需权限校验+数据行级过滤)。
| 指标 | Shiro+JWT方案 | Spring Security+spring-boot-starter-security-jwt | 差异分析 |
|---|---|---|---|
| 平均响应时间 | 42ms | 118ms | Shiro无Spring AOP代理开销,Filter链更短 |
| 95%响应时间 | 89ms | 215ms | Spring Security在FilterSecurityInterceptor中多次反射调用MethodSecurityMetadataSource |
| CPU使用率 | 38% | 72% | Spring Security默认启用CSRF防护、Session管理,即使JWT模式也消耗资源 |
| GC Young GC次数 | 12,400次 | 28,900次 | Spring Security创建大量SecurityContext、Authentication临时对象 |
| 内存占用 | 412MB | 689MB | Shiro的Subject对象更轻量,无SecurityContextHolder线程绑定开销 |
但Spring Security在开发效率上有优势:@PreAuthorize("hasPermission(#order, 'EDIT')")这种SpEL表达式,写起来确实比Shiro的subject.isPermitted("purchase:edit")更直观。我们的折中方案是:核心交易链路用Shiro保性能,管理后台用Spring Security提人效。同一个项目里,采购、销售等高并发模块走Shiro,而系统设置、日志查询等低频模块走Spring Security,通过Nginx路由分发。这样既没牺牲性能,又没增加团队学习成本。
6. 最后一点个人体会:技术选型不是非黑即白
去年有同事坚持要用Spring Security,理由是“社区更活跃,文档更多”。我带他一起做了两件事:第一,把现有Shiro认证模块的代码打成jar,用JMH压测JwtToken的构造耗时,结果是12纳秒;第二,用Spring Security的JwtAuthenticationToken做同样测试,结果是217纳秒。他当时就愣住了——原来“更活跃”不等于“更适合”。Shiro的价值不在炫技,而在可控。当你需要在JWT解析后插入自定义逻辑(比如记录登录设备指纹、触发风控模型、同步到审计系统),Shiro的Filter和Realm就像乐高积木,你想在哪插就插在哪;而Spring Security的自动配置像一台精密仪器,拆开维修的成本远高于定制。我们现在的架构图上,Shiro只是认证网关里的一环,前面有WAF校验,后面有OpenFeign熔断,中间它安安静静地做着自己的事:不声张,不越界,不出错。这大概就是成熟技术栈该有的样子——不是最耀眼的,但一定是最可靠的。