
1. 为什么要把Shiro和JWT放在一起先聊聊项目的真实诉求接手过一个典型的业务系统改造老版本的权限模块用的是一套标准的Session Cookie方案Shiro负责认证和授权逻辑清晰、资料又多开发起来并不费劲。但后来前端要改成前后端分离架构APP端也要接入同一套接口问题就冒出来了——Session依赖服务端存储分布式部署时要么做Session共享要么引入集中存储中间件移动端对Cookie的支持又很别扭经常出现登录状态丢失的情况。这时候我第一反应就是引入JWT用无状态令牌替代Session把认证状态从服务端搬到客户端。Shiro整合JWT本质上是保留Shiro成熟的“认证授权注解”这套权限管控能力把身份凭证从“服务端保存的Session”换成“客户端持有的Token”。我最终落地的那套方案核心改动只有三个地方自定义Realm识别JWT、自定义Filter拦截请求并解析Token、关闭原生Session管理。这套组合跑下来演讲逻辑已经足够清晰后续要加多端登录、单点退出也不会伤筋动骨。如果你的系统也存在类似痛点——服务端无法存Session、接口需要被多端调用、又不想放弃Shiro的权限注解——那这篇文章的思路可以直接参考。接下来我会把依赖配置、工具类、Realm、Filter、配置类一条线讲完顺便把我在改造中踩过的坑和排查方法都交代清楚。1.1 前后端分离时代Session方案越来越力不从心传统单体应用里Session方案是顺理成章的用户在登录页输入账号密码服务端校验成功后把用户信息丢进Session再写一个JSESSIONID到Cookie里。浏览器每次请求自动带上Cookie服务端根据Session里的数据判断“你是谁、你能干什么”。这套机制在服务端渲染时代非常好用Spring Boot集成Shiro默认就是走这条链路。但前后端分离之后前端部署在独立域名甚至CDN上接口调用变成跨域请求Cookie的携带规则开始变得麻烦。更麻烦的是移动端原生APP对Cookie的处理各不相同有的HTTP客户端默认根本不存Cookie。就算解决了Cookie存取问题分布式部署下Session也不好办——同一个用户的请求被负载均衡转发到不同节点Session不在同一台机器上登录状态就丢了。常见解法是Session共享把Session集中存到中间件里但这意味着每次请求都要额外访问一次共享存储还引入了新的故障点。JWT方案的核心思路完全不同用户登录成功后服务端生成一段经过签名的令牌返回给客户端令牌里直接编码了用户标识、过期时间、自定义声明等信息。客户端后续请求在Header里带上这段令牌服务端通过验签和解码就能确认用户身份全程不需要在服务端保存任何状态。这就是“无状态认证”——服务端不用管“谁登录过”只管“这张令牌是否有效”。1.2 Shiro做权限JWT做身份它们并不冲突很多开发者第一次接触这个组合时会有疑问Shiro自己不是有完整的认证授权体系吗为什么还要引入JWT其实两者负责的层次完全不同。Shiro管的是“用户是什么角色、有哪些权限、能不能访问这个接口”这是一套RBAC权限模型JWT管的是“这条请求凭什么证明你是你”这是一套身份凭证标准。两者不是替代关系而是可以叠加的组合关系。具体到代码层面Shiro的扩展点非常明确Realm负责从数据源中获取认证和授权信息Filter负责拦截请求并触发认证流程。默认情况下Shiro的Filter会从Session里取身份信息我要做的就是让它改成从请求头的Token里取。实现方式就是写一个自定义JwtToken类、一个JwtRealm、一个JwtFilter再通过配置把这三者塞进Shiro的链路中。至于权限注解RequiresRoles、RequiresPermissions这些高层封装完全不受影响它们依赖的还是Subject与Realm之间的交互逻辑。2. 环境准备与基础技术选型先把地基打牢2.1 依赖清单与版本选择我采用的组合是Spring Boot 2.7.x Shiro 1.9.1 jjwt 0.11.5这套版本搭配在实际项目中跑了一年多没有遇到明显的兼容性问题。Apache Shiro 1.9.x修复了多个低版本漏洞处理CORS和自定义Filter时的行为也更规范jjwt 0.11.x是JJWT库从0.9.x升到0.11.x后相对稳定的版本API设计和链式调用体验都不错。dependency groupIdorg.apache.shiro/groupId artifactIdshiro-spring-boot-web-starter/artifactId version1.9.1/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency有一点特别提醒jjwt 0.11.x把API、实现、序列化器拆成了三个模块只引入jjwt-api会在运行报NoClassDefFoundError。很多新手踩了这个坑后误以为是版本冲突其实是缺少jjwt-impl和jjwt-jackson这两个运行时依赖。另外groupId从io.jsonwebtoken变成了io.jsonwebtoken如果你之前参考过老文章用的是com.auth0:java-jwt那就走的是另一条路不要混用两种JWT库代码风格和解析方式完全不同。2.2 目录结构与代码框架规划改造之前先把包结构规划好避免后续代码乱糟糟的。我习惯把和认证安全相关的类集中放在一个独立包下方便排查和复用。比如我用的结构大致是这样的com.example.project ├── config │ ├── ShiroConfig.java │ └── CorsConfig.java ├── security │ ├── JwtUtil.java │ ├── JwtToken.java │ ├── JwtFilter.java │ └── JwtRealm.java ├── controller │ └── AuthController.java ├── service │ └── UserService.java └── entity └── User.java把JwtUtil放在security包里是因为它不依赖Spring容器是纯粹的静态工具类后续在Filter、Realm、Controller里都能直接调用。JwtFilter和JwtRealm虽然标注了Component或通过Bean注册但逻辑上只和Shiro生命周期挂钩和业务代码解耦。Controller层里只保留一个登录接口负责发Token以及登出接口负责清理本地状态——注意JWT是无状态的服务端无法主动注销Token登出只是客户端丢弃Token这个点后面我会专门展开。3. 核心开发JWT工具类整个方案的发动机3.1 生成Token的逻辑要点JWT工具类是整套方案最基础的部分所有Token的产生和校验都靠它。生成Token时我做了三件事把用户名写进subject、设置签发时间和过期时间、用签名密钥对Token签名。如果业务上有额外需求比如记录用户ID、设备类型或登录来源都可以塞进自定义声明里。Component public class JwtUtil { private static final String SECRET_KEY 你的应用专属密钥建议32字节以上的随机字符串; private static final long EXPIRE_TIME 7 * 24 * 60 * 60 * 1000L; private static final String CLAIM_USER_ID userId; public String createToken(String username, Long userId) { long nowMillis System.currentTimeMillis(); Date now new Date(nowMillis); Date expireDate new Date(nowMillis EXPIRE_TIME); return Jwts.builder() .setHeaderParam(typ, JWT) .setSubject(username) .claim(CLAIM_USER_ID, userId) .setIssuedAt(now) .setExpiration(expireDate) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); } public String getUsername(String token) { return parseToken(token).getSubject(); } public Long getUserId(String token) { return parseToken(token).get(CLAIM_USER_ID, Long.class); } public boolean validateToken(String token) { try { parseToken(token); return true; } catch (ExpiredJwtException e) { return false; } catch (JwtException e) { return false; } catch (IllegalArgumentException e) { return false; } } private Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET_KEY) .parseClaimsJws(token) .getBody(); } }密钥管理是很多人忽略的环节。有些代码里直接把密钥硬编码成“abc123”这样的短字符串一旦被反编译或者通过配置文件泄露整个Token体系就形同虚设。我实际项目里至少要求32字节以上的随机串并把密钥放到配置中心或环境变量里不同环境下用不同的密钥避免开发环境的Token打到生产环境也能通过校验。过期时间的选择也要结合业务场景。对于内部管理后台7天算比较合适的对于用户端APP很多人喜欢用2小时短期Token加RefreshToken的做法但这样实现复杂度会上升一个级别。如果你刚开始整合JWT先用一个固定过期时间把主链路跑通后续再考虑续签和刷新机制。3.2 解析与校验Token的细节校验Token时有一件事要重点说不要只调用一次parser就当作万事大吉。签名验证这一关JJWT会在parseClaimsJws阶段自动校验签名和过期时间一旦过期就会抛出ExpiredJwtException签名不匹配则抛出SignatureException。我上面的代码里把异常分开捕获是为了后续能区分“Token过期”和“Token无效”这两个场景在接口返回时应该有不同提示。详细说一下异常捕获这是我后来优化过的版本public JwtCheckResult checkToken(String token) { if (token null || token.isEmpty()) { return JwtCheckResult.invalid(Token为空); } try { Claims claims Jwts.parser() .setSigningKey(SECRET_KEY) .parseClaimsJws(token) .getBody(); return JwtCheckResult.valid(claims); } catch (ExpiredJwtException e) { return JwtCheckResult.expired(Token已过期); } catch (SignatureException e) { return JwtCheckResult.invalid(Token签名非法); } catch (MalformedJwtException e) { return JwtCheckResult.invalid(Token格式错误); } }这里我封装了一个JwtCheckResult类用来区分不同的失败原因。在Filter层返回401时如果原因是过期响应体里就明确写“登录已过期请重新登录”如果是无效就返回“认证失败”。前端拿到响应后可以根据不同的code走不同的跳转逻辑。这个细节看似不大但实际用户体验差别很明显——实时提醒“请重新登录”比笼统的“请求失败”友好得多。3.3 续签与注销的设计JWT的无状态特性带来了一个天然的短板Token一旦签发在过期时间之前都是有效的服务端拿它没办法。例如用户修改密码后想立刻让所有旧Token失效或者管理员封禁某个用户后想立刻禁止其访问仅靠JWT自身是做不了的。我在项目里采取了几种补偿方案。第一种是引入“Token版本号”用户表里增加一个token_version字段JWT的claim里带上这个版本号。登录或者修改密码时把版本号1Realm校验Token时对比版本号不一致就拒绝认证。第二种是维护一张“黑名单表”把需要提前失效的Token的jtiJWT唯一ID存起来Filter里先查黑名单再校验签名。这两种方案各有取舍版本号方案简单可靠适合“单点登录或强制下线”场景黑名单方案粒度更细可以指定某个Token失效但需要额外存储。至于“续签”业界有两种常见做法。一种是每次请求时判断Token剩余有效期如果低于某个阈值就重新签发新Token并放在响应Header里返回前端拿到后用新Token覆盖本地存储。另一种是双Token方案用RefreshToken换取AccessToken复杂度更高。我个人建议第一步先不做续签固定过期时间加“过期后重新登录”的体验已经能覆盖大部分业务场景。4. 改造Shiro核心配置与Realm4.1 自定义Realm的编写Realm是Shiro连接业务数据源的桥梁它负责回答两个问题这个用户是否合法这个用户有什么权限默认情况下Shiro的Realm需要查询数据库比对用户名密码但我们的场景里用户身份已经通过JWT验证过了所以Realm里的认证逻辑不是再查一次密码而是直接信任Token中解析出来的用户名。Component public class JwtRealm extends AuthorizingRealm { Autowired private UserService userService; Override public boolean supports(AuthenticationToken token) { return token instanceof JwtToken; } Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { String username (String) token.getPrincipal(); User user userService.findByUsername(username); if (user null) { throw new UnknownAccountException(用户不存在); } if (user.getStatus() ! 1) { throw new DisabledAccountException(账号被禁用); } // JWT令牌本身作为凭证这里不再做二次密码校验 return new SimpleAuthenticationInfo(username, token.getCredentials(), getName()); } Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { String username (String) principals.getPrimaryPrincipal(); User user userService.findByUsername(username); if (user null) { return null; } ListString roles userService.findRolesByUserId(user.getId()); SetString permissions userService.findPermissionsByUserId(user.getId()); SimpleAuthorizationInfo info new SimpleAuthorizationInfo(); info.addRoles(roles); info.addStringPermissions(permissions); return info; } }这里有几个关键点需要特别留意。supports方法决定了这个Realm能处理哪种Token如果返回falseShiro会跳过这个Realm我们的JwtToken就永远得不到认证。doGetAuthenticationInfo里返回的principal我用的是username字符串后面doGetAuthorizationInfo里也是根据这个username去查角色权限。如果用user对象作为principal那么权限查询时取用户ID反而要强转不如直接用username来得统一。4.2 自定义JwtToken类自定义Token类本身很简单它只是包装了令牌和用户名的载体。但它的存在让Realm的supports判断变得有意义。我也见过有人直接在Filter里手动调用Realm不走Shiro的Token机制那样反而破坏了Shiro的架构Session同步、注解鉴权都会受影响。public class JwtToken implements AuthenticationToken { private final String token; private final String username; public JwtToken(String token, String username) { this.token token; this.username username; } Override public Object getPrincipal() { return username; } Override public Object getCredentials() { return token; } }getPrincipal返回的是身份标识getCredentials返回的是凭证。在原生账号密码登录中principal一般是用户名credentials一般是密码在我们这个组合里principal还是用户名credentials是JWT字符串本身。这样设计的好处是Realm里doGetAuthenticationInfo返回的SimpleAuthenticationInfo的第一个参数会变成主身份后续doGetAuthorizationInfo通过principals.getPrimaryPrincipal()拿到的就是这个username。4.3 JwtFilter的编写与拦截链路JwtFilter是整个方案里技术含量最高的一环。它继承自BasicHttpAuthenticationFilter重写isAccessAllowed和onAccessDenied两个方法。isAccessAllowed里做Token的提取、校验和登录onAccessDenied里统一返回401 JSON响应。这里有一个非常常见的误区拿到Token就直接在isAccessAllowed里调用subject.login()这个操作本身是对的但要确保不要在这里创建Session。Component public class JwtFilter extends BasicHttpAuthenticationFilter { Autowired private JwtUtil jwtUtil; Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { HttpServletRequest httpRequest (HttpServletRequest) request; // 放行OPTIONS预检请求 if (OPTIONS.equalsIgnoreCase(httpRequest.getMethod())) { return true; } String token httpRequest.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { return false; } token token.substring(7); if (!jwtUtil.validateToken(token)) { return false; } String username jwtUtil.getUsername(token); JwtToken jwtToken new JwtToken(token, username); try { getSubject(request, response).login(jwtToken); return true; } catch (AuthenticationException e) { return false; } } Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { HttpServletResponse httpResponse (HttpServletResponse) response; httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED); httpResponse.setCharacterEncoding(UTF-8); httpResponse.setContentType(application/json;charsetUTF-8); httpResponse.getWriter().write({\code\:401,\message\:\认证失败或登录已过期\}); return false; } }很多教程在这里只会简单地返回false导致前端收到一个空白401响应连错误原因都看不到。把错误JSON写进响应体是我强烈建议的做法排查联调问题时能少走很多弯路。还有一点容易被坑BasicHttpAuthenticationFilter的executeLogin方法在内部也会尝试调用doCreateToken等钩子如果你完全自定义了Token解析逻辑直接在isAccessAllowed里处理反而最干净。另外每次请求都调用jwtUtil.validateToken意味着每次都要做一次JWT解码和签名验证性能上没问题HMAC验签是纯内存计算微秒级不用过度担心。4.4 ShiroConfig中的关键配置与坑点ShiroConfig是整个方案的组装车间很多细碎的问题都出在这一步。给出我认为最稳妥的配置然后逐个解释Configuration public class ShiroConfig { Autowired private JwtRealm jwtRealm; Autowired private JwtFilter jwtFilter; Bean public DefaultWebSecurityManager securityManager() { DefaultWebSecurityManager manager new DefaultWebSecurityManager(); manager.setRealm(jwtRealm); // 关键1禁用Session DefaultSubjectDAO subjectDAO new DefaultSubjectDAO(); DefaultSessionStorageEvaluator sessionStorageEvaluator new DefaultSessionStorageEvaluator(); sessionStorageEvaluator.setSessionStorageEnabled(false); subjectDAO.setSessionStorageEvaluator(sessionStorageEvaluator); manager.setSubjectDAO(subjectDAO); // 关键2自定义SubjectFactory禁止创建Session manager.setSubjectFactory(subjectFactory()); return manager; } Bean public SubjectFactory subjectFactory() { return new DefaultSubjectFactory() { Override public Subject createSubject(SubjectContext context) { context.setSessionCreationEnabled(false); return super.createSubject(context); } }; } Bean public ShiroFilterFactoryBean shiroFilterFactoryBean() { ShiroFilterFactoryBean factoryBean new ShiroFilterFactoryBean(); factoryBean.setSecurityManager(securityManager()); // 注册自定义Filter MapString, Filter filters new HashMap(); filters.put(jwt, jwtFilter); factoryBean.setFilters(filters); // 配置拦截链 MapString, String filterChainDefinitionMap new LinkedHashMap(); filterChainDefinitionMap.put(/auth/login, anon); filterChainDefinitionMap.put(/auth/logout, logout); filterChainDefinitionMap.put(/static/**, anon); filterChainDefinitionMap.put(/favicon.ico, anon); filterChainDefinitionMap.put(/**, jwt); factoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap); return factoryBean; } }第一个坑是Session。Shiro默认开启Session机制即使我们自己写了JWT鉴权如果不显式关闭Session每次请求仍可能创建服务端Session导致内存膨胀严重背离无状态设计初衷。上面配置里我用了两条线来关闭SessionSubjectFactory让Subject不感知SessionSubjectDAO关闭Session存储能力。两者同时使用逻辑上更彻底。第二个坑是拦截链顺序。Shiro的Filter链是按LinkedHashMap的插入顺序匹配的先匹配先生效所以/auth/login这种白名单必须放在/**之前。如果你把/**写在login规则前面登录接口也会被JWT拦截永远无法登录。第三个坑是拦截接口路径的写法。某个接口的Swagger文档路径或监控端点如果不需要鉴权也要用anon白名单显式排除。我在实际项目里习惯把健康检查、静态资源、登录接口都先列清楚宁可多放行几个非敏感接口也不要把隐私接口漏到匿名区。5. 登录认证与令牌下发流程5.1 登录接口的真实实现登录接口是整个认证链路的入口它本身不走Shiro的Filter拦截配置了anon而是自己在Controller层校验账号密码成功后签发JWT并返回给客户端。这里我稍微展开一下登录接口的完整逻辑因为很多文章只讲到生成Token就结束了没有说清楚密码校验、状态校验和异常返回。RestController RequestMapping(/auth) public class AuthController { Autowired private UserService userService; Autowired private JwtUtil jwtUtil; PostMapping(/login) public Result login(RequestBody LoginRequest request) { // 参数校验 if (request null || StringUtils.isBlank(request.getUsername()) || StringUtils.isBlank(request.getPassword())) { return Result.error(用户名和密码不能为空); } // 查询用户 User user userService.findByUsername(request.getUsername()); if (user null) { return Result.error(用户名或密码错误); } // 密码校验这里用加密后的摘要比对 String encodedPassword DigestUtils.md5DigestAsHex(request.getPassword().getBytes()); if (!encodedPassword.equals(user.getPassword())) { return Result.error(用户名或密码错误); } // 账号状态校验 if (user.getStatus() ! 1) { return Result.error(账号已被禁用); } // 签发Token返回客户端 String token jwtUtil.createToken(user.getUsername(), user.getId()); MapString, Object data new HashMap(); data.put(token, token); data.put(tokenType, Bearer); data.put(expiresIn, 7 * 24 * 3600); data.put(username, user.getUsername()); return Result.success(data); } }密码加密这块生产环境建议用BCrypt这类自适应哈希算法Spring Security自带BCryptPasswordEncoder即使不用Spring Security也可以单独引入spring-security-crypto这个模块来使用。MD5加不加盐都容易被彩虹表撞出来我在老项目里见过太多明文或MD5密码出于稳妥考虑至少也要用加盐多次摘要的方案。登录成功后返回的数据结构里我特意加了tokenType为Bearer。这不是随手写的前端会在请求头里拼成Authorization: Bearer xxxxx后端Filter里也正好按这个格式解析前后一致就不会出问题。expiresIn字段用于前端做定时刷新提示如果Token快过期了前端可以提前弹窗让用户重新登录。5.2 从请求到认证的完整链路搞清楚一次带Token的业务请求在系统里经过了哪些环节才算真正理解了Shiro整合JWT。我画一条线性的流程描述不用图也能说清楚客户端把Token放进Header发起请求请求经过CORS过滤器处理跨域然后进入Shiro的Filter链。ShiroFilterFactoryBean根据URL匹配到对应Filter比如访问/user/info时匹配到jwt过滤器。JwtFilter先从Header里取Token调用JwtUtil校验签名和过期时间。校验通过后把Token和用户名封装成JwtToken调用subject.login(jwtToken)。Shiro的SecurityManager接收到登录请求后把Token交给JwtRealm。JwtRealm里的supports确认是自己能处理的TokendoGetAuthenticationInfo根据用户名查询用户状态确认有效后返回SimpleAuthenticationInfo认证完成。认证完成后Subject对象被视为已认证状态后续代码里如果使用RequiresRoles或RequiresPermissions注解SecurityManager会再次调用JwtRealm的doGetAuthorizationInfo获取角色和权限集合进行授权判断。整个过程中服务端不创建Session、不存储用户状态、不依赖Cookie每个请求都是独立验签。这个链路里有一个细节值得注意JwtFilter每拦截一个请求都会做一次login也就是说每个请求都会查询一次用户信息。这在数据量小的时候没有问题但如果用户表很大或者接口并发上来了Redis缓存用户信息会是一个明显的优化点。我在后来的项目里加了本地缓存用户状态一分钟内不重复查库QPS提升很明显。6. 权限校验与无状态会话配置6.1 权限注解的用法与AuthorizationInfoShiro最吸引人的地方之一就是权限注解。在Controller或Service方法上标注权限要求写起来非常优雅RestController RequestMapping(/user) public class UserController { RequiresRoles(admin) GetMapping(/list) public Result listUsers() { // 只有admin角色才能访问 return Result.success(userService.listUsers()); } RequiresPermissions(user:add) PostMapping(/add) public Result addUser(RequestBody User user) { // 只有拥有user:add权限的用户才能访问 userService.addUser(user); return Result.success(); } }RequiresRoles表示必须包含某个角色RequiresPermissions表示必须具备某项权限。这些注解默认是精确匹配比如RequiresRoles(admin)要求当前用户角色集合里必须包含admin。权限字符串可以自己定义通常采用“资源:操作”的规范比如user:add、order:delete、report:export这样在数据库里也好维护。使用注解时要注意两个前置条件。第一Shiro的注解功能默认由Spring AOP代理实现所以在配置类里需要开启注解支持。shiro-spring-boot-web-starter默认会注册DefaultAdvisorAutoProxyCreator但如果你同时引入了其他AOP配置可能会造成代理冲突需要手动指定。Bean ConditionalOnMissingBean public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() { DefaultAdvisorAutoProxyCreator creator new DefaultAdvisorAutoProxyCreator(); creator.setProxyTargetClass(true); return creator; }第二注解不生效通常是Filter没有把当前线程绑定到Subject上。实际工作中如果发现加了注解但接口还是能被无权限访问先查一下是不是某个Filter链直接把请求放行了或者ShiroFilter没能正确注册到Spring Boot的Servlet容器里。6.2 无状态会话的配置细节前面的ShiroConfig里我用两处配置关闭了Session这里单独展开说一下为什么这样做以及会带来什么连锁反应。第一处是SubjectFactory。Shiro的Subject是一个门面对象它绑定了一套SubjectContextSubjectContext里有一个sessionCreationEnabled标志。设置为false后Shiro在创建Subject时就不会再去获取或创建Session。如果不改这里即使不调用session方法某些内部逻辑也可能触发Session初始化。第二处是SessionStorageEvaluator。它是SubjectDAO的一个组件决定认证成功后是否要把Subject的状态持久化到Session中。默认值是允许在无状态方案里必须设为false否则每次登录后Shiro会把认证信息写进Session还是会产生服务端状态。关闭Session后有一个连锁反应shiro-spring-boot-web-starter自带的AuthenticationFilter可能会在跳转登录页时依赖Session不过因为我们自定义了JwtFilter这个影响可以忽略。另一个反应是如果你想在业务代码里通过SecurityUtils.getSubject().getSession()来存临时数据就会拿不到Session这在改造早期容易让习惯了Session存数据的同事懵一下我在项目里专门在说明文档里标注了这一点提醒大家改用Token携带用户信息或走Redis缓存。7. 常见问题与排查技巧实录7.1 一申请就返回401从Filter链开始排查这是最常见的故障。我先给出一个排错顺序参考现象可能原因排查动作所有接口都401Filter链没放行登录接口查看shiroFilterFactoryBean中的anon配置确认/auth/login在/**之前登录接口也401拦截链顺序错误检查LinkedHashMap中anon规则的插入顺序带Token的请求仍然401Token解析失败或签名密钥不一致在JwtUtil.validateToken里加日志打印异常类型跨域请求401浏览器发送OPTIONS预检请求被拦截确认JwtFilter放行OPTIONS请求并配置CORS过滤器我实际遇到过最隐蔽的一个情况前端在请求头里带的字段名写错了。后端明明要求Authorization前端代码里却写成了authentication首字母没大写导致Header取不到Token。排查方法是在Filter入口用日志打印所有的Header名称一眼就能看出问题。还有一个经典问题JWT的Bearer前缀。我见过前端把整个Header值设成Bearer token也见过设成token本身而后端只适配了其中一种。规范起见前后端约定一个统一的格式并写死前端依赖后端返回的tokenType字段拼接后端严格按前缀解析两边都不留灵活空间反而不会出错。7.2 Token过期与刷新策略Token过期后的默认行为是返回401前端收到401后引导用户重新登录。但在Web端用户正填着的表单被一个登录过期弹窗打断体验很差。我在实际项目中做了一种轻量级续签方案前端在请求拦截器里检测当前Token的剩余有效时间如果不足半小时就调用一次刷新接口获取新Token。刷新接口的原理因为JWT自带过期时间无法在过期后继续解析所以刷新接口必须在Token过期前调用。我在登录时除了返回AccessToken还会返回一个refreshToken它也是一个JWT但过期时间设置得更长比如30天并携带一个tokenTyperefresh的自定义claim。服务端提供一个POST /auth/refresh接口接收refreshToken校验其格式和类型后签发新的AccessToken。PostMapping(/refresh) public Result refresh(RequestBody RefreshRequest request) { String refreshToken request.getRefreshToken(); // 校验refreshToken的签名和类型 if (!jwtUtil.validateRefreshToken(refreshToken)) { return Result.error(刷新令牌无效或已过期); } String username jwtUtil.getUsername(refreshToken); Long userId jwtUtil.getUserId(refreshToken); // 可选查库确认用户仍然有效 String newToken jwtUtil.createToken(username, userId); return Result.success(newToken); }这套方案的复杂度不算太高但需要约定好refreshToken只能用于刷新接口不参与业务接口认证。最简单的方式是在JWT里加一个type字段业务接口校验时要求type必须等于access刷新接口校验时要求type必须等于refresh两条链路互不串用。7.3 用户登出与Token失效的前端配合JWT的登出在服务端是比较尴尬的——没有Session可以销毁Token本身又无法单方面撤销。我一开始给登出接口写的是return success清空不了任何东西。后来前端同事反馈登出后再用旧Token居然还能调接口我才意识到需要在前端做好Token销毁。正确做法是前端登出时调用本地存储删除接口把localStorage或内存里的Token彻底清除。如果需要服务端配合可以做黑名单方案但常规业务场景只需要前端销毁。至于“管理员强制用户下线”这类需求就用前面提到的Token版本号方案在Realm里比对版本号不一致就抛异常。7.4 权限注解不生效的排查权限注解不生效有三种常见情况。第一种是没有开启AOP支持在Spring Boot Shiro starter组合下一般没问题但引入自定义AOP切面后可能产生干扰通过在启动日志里搜索ShiroAnnotationProcessor相关的初始化信息可以辅助判断。第二种是Controller里的方法不是public的或者类没有被Spring管理AOP代理覆盖不到。第三种是同一个类内部方法调用比如Controller里的方法A调用同类中的方法BB上的RequiresPermissions不会生效因为内部调用不走代理。这种问题排查时先写一段测试代码验证Realm的doGetAuthorizationInfo有没有被调用再看SecurityUtils.getSubject()是否处于已认证状态。如果能获取到用户但注解仍不生效大概率是AOP配置问题如果连认证状态都没有说明前面的Filter链路没走通需要回到401排查那块。8. 实际踩坑记录与个人体会整套方案改造下来我最大的体会是Shiro整合JWT的技术难度并不高真正的难点在于理解无状态认证的思维方式。传统Session方案里服务端对一切了如指掌切换到JWT之后服务端变成了一个“验票员”只看你的票是否真的、是否在有效期内而不在乎你从哪里来。这个思维的转变会影响到很多设计决策比如登录态校验的时机、Token存储的方式、以及异常处理的粒度。有几个具体踩过的坑我最后再交代一次关于Realm缓存Shiro默认开启了授权信息的缓存AuthorizationCache也就是说一次查询权限后会缓存一段时间。这带来一个隐患用户权限修改后旧的权限缓存不会立刻失效。我在项目里把这个缓存显式关掉了或者设置很短的过期时间。对于权限变更不频繁的管理系统五分钟内的延迟是可以接受的但如果你的系统对权限实时性要求很高记得在修改权限的业务代码里手动清理缓存。关于ThreadLocal绑定Shiro的SecurityUtils.getSubject()依赖于ThreadLocal上下文。在Spring的Controller层调用是正常的因为同一个线程处理一个请求。但如果你在线程池里跑了异步任务比如Async方法里做权限判断会因为ShiroContext没有被传递而拿不到认证信息。我在一个异步导出的功能里就遇到过这个坑——导出任务明明是在用户登录后触发的但异步线程里判断权限时直接抛了异常。解决方案有两种在异步方法里手动绑定Subject或者把权限判断放在调用异步方法之前的同步代码段里完成。关于CORS配置前后端分离项目几乎一定会遇到跨域问题。浏览器在发起POST、PUT这类请求前会先发送一个OPTIONS预检请求这个请求不带业务参数也没有自定义Header。如果JwtFilter拦截了OPTIONS请求并尝试解析Token就会直接返回401导致前端报跨域错误。所以我在JwtFilter里加了OPTIONS直接放行的逻辑同时单独配置了CorsFilter允许前端域名访问。这两个过滤器在Spring Boot里的执行顺序也很重要CorsFilter需要在ShiroFilter之前执行否则预检请求被ShiroFilter吃掉了CORS依然失败。最后聊一下Token存哪里的问题。Web端常见的存储方案有localStorage、sessionStorage和Cookie。localStorage能跨标签页共享但存在XSS风险Cookie可以设置HttpOnly属性来降低XSS风险但又会回到CSRF的讨论。我的建议是如果是纯前后端分离系统且对XSS防护有基本认知Token放localStorage是效率和便利性都比较高的选择——前提是前端代码要做好输入输出过滤避免恶意脚本注入。如果你实在担心安全问题可以把Token放在内存变量里但刷新页面后就需要重新登录很多交互场景会因此变得繁琐。我采用的是localStorage加敏感操作二次验证的组合登录后Token存localStorage涉及修改密码、支付或删除的操作时要求用户再次输入密码确认这比单纯纠结Token存储位置要实用得多。整个改造从开始到稳定运行我大概花了两天。第一天搭框架踩完了上面提到的大部分坑第二天补了权限缓存、跨域、续签这些边角。如果你也在做类似改造先把“Login接口能发Token、带Token能访问、不带Token被拦截”这条主链路跑通再逐步完善细枝末节这个顺序能让排查问题简单很多。