Shiro与JWT整合:无状态权限认证方案详解与实战
简介shiro_jwt.rar 是一份基于 Spring Boot、Shiro 与 JWT 的认证授权整合示例面向需要搭建无状态登录、令牌鉴权与权限管理的 Java 开发者及 Spring Boot 初学者。压缩包体积仅 26KB共 26 个文件内含 8 个 Java 源码、8 个编译后类文件、9 个配置型文件XML/YAML及 1 个工程模块文件源码负责业务与鉴权逻辑编译产物可核对构建结果配置文件承担框架与运行环境参数导入开发工具后即可阅读。代码覆盖 Shiro 过滤器链、JWT 工具类、Token 实现、控制器与服务层等关键部分能够清楚看到 JWT 的生成、解析、过期校验以及 Shiro 无状态认证的接入方式。项目结构完整可作为 Spring Boot 整合 Shiro 与 JWT 的快速起步模板也适合复盘权限控制和登录流程。资料目录层次分明源码目录与编译输出目录可对照阅读便于本地运行后逐段调试理解过滤器与令牌校验的先后顺序。已有 544 人学习适合想用 JWT 改造会话管理、理解无状态认证和令牌机制的读者。1. 先说清楚shiro_jwt.rar 到底帮你解决了什么问题如果你手里刚好有一个叫 shiro_jwt.rar 的压缩包那它大概率是一个把 Apache Shiro 和 JWT 整合在一起的前后端分离权限认证示例工程。这类包在技术社区流传很广核心就一句话让原本依赖 Session 的 Shiro在无状态接口场景下靠 JWT 完成身份认证和权限校验。很多开发者拿到包后第一反应是“跑起来看看”结果发现不是缺这个依赖就是过滤器链顺序不对折腾两天还在登录接口打转。其实这个整合方案的套路非常固定理解四个核心类的分工后自己都能徒手写一个出来。我用实际经验告诉你这个方向解决的是典型痛点老项目用 Shiro 管登录和权限现在要接App、小程序或者前后端彻底分离服务端不能依赖 Session 保存登录态了。JWT 负责把身份信息交给服务端验签Shiro 负责把验签通过的人纳入自己的认证授权体系。适合谁适合正在做老系统改造的 Java 工程师也适合想搞清楚无状态权限怎么落地的新手。这个包不是给你直接用的黑匣子而是一张可以照着抄的图纸。2. 为什么默认的 Shiro Session 方案会翻车而 JWT 正好补位2.1 前后端分离场景下Session 机制的三个硬伤传统 Shiro 的默认工作方式是用户登录成功后服务端创建 Session把 SessionId 写进 Cookie 返回给浏览器。浏览器后续请求带着 CookieShiro 从 Session 里还原用户身份。这个模型在同一个域名下的服务端渲染页面里没有问题但在前后端分离以后开始失控。第一个硬伤是跨域。前端页面和接口不在同一个域名甚至不在同一个服务器时Cookie 的携带和跨域策略会变成一场噩梦你得处理 CORS、SameSite、域名白名单每个环节都可能让浏览器悄悄丢掉凭证。第二个硬伤是服务端状态膨胀。每个登录用户都占一份 Session 内存在线用户一多要么内存吃紧要么就得引入 Session 集群同步或者把 Session 丢进 Redis这等于把无状态接口重新做成了有状态。第三个硬伤是移动端不认 Cookie。App 的 HTTP 客户端对 Cookie 的支持和浏览器行为不一致你没法保证每个客户端都规规矩矩地存 Cookie、带 Cookie。这三个硬伤叠加起来你就不难理解为什么越来越多团队在 Shiro 项目里硬塞 JWT。JWT 本身就是一串加密签名的 JSON 字符串客户端把它放在请求头里带来就行不需要服务端保存任何会话状态自然也没有跨域、内存、移动端适配这些问题。它看起来是“无状态”的完美答案但现实没这么简单JWT 单独上场也撑不住一个完整的权限体系。2.2 JWT 的短板它只解决“我是谁”不解决“我能干什么”如果只用 JWT 自己做认证你很快会发现两个棘手的点。第一服务端无法主动让一个 token 失效。用户改了密码或者被管理员封号你只能等 token 自然过期修改密码前签发的 token 在过期前依然有效。第二权限模型从零搭建。你得自己设计角色、权限、菜单的关系表自己写拦截器判断每个接口需要什么权限自己处理“这个用户是否有权访问”的逻辑这一套做下来工作量不比重新写个登录系统小。而 Shiro 早就把这些东西给你备好了它有成熟的 Realm 抽象、过滤器链、权限注解和标签你只需要让它认得 JWT 这个“新凭证格式”。所以正解不是二选一而是分工协作。JWT 负责解决“客户端拿什么东西来证明身份”Shiro 负责解决“这个身份在系统里拥有哪些权限、能不能访问当前资源”。JWT 的 token 字符串作为 Shiro 的 AuthenticationToken 凭证传入Shiro 的 Realm 负责验签和查询权限过滤器链负责拦截请求并触发认证流程。这样一来你等于把无状态凭证和成熟的权限模型焊在了一起。2.3 整合方案的核心模型四个类撑起认证链路这个整合方案反复出现是因为它的结构高度固定。只要理解了下面这张分工表你拿到任何类似的包都能快速定位问题。组件职责对应到代码JWT 工具类生成 token、解析 token、校验签名提供 create / verify / getUsername 三个核心方法JwtToken包装 JWT 字符串实现 Shiro 的 AuthenticationToken 接口让 Shiro 认证流程能接收 JWT 作为凭证JwtRealm继承 AuthorizingRealm执行真实的身份校验和权限查询验签通过后返回 SimpleAuthenticationInfo查库填充角色权限JwtFilter继承过滤器从请求头取 token 并触发 subject.login()把 JWT 字符串翻译成 Shiro 能理解的登录动作这四个类缺一不可。有人会问Shiro 不是自带 BearerToken 之类的支持吗网上的说法很杂但以我接手的几个项目来看Shiro 对 JWT 的开箱即用支持并不完整多数生产实战都是按照上面这套自定义策略来的。理解了这个模型后面写代码和排错就有了地图。2.4 一个常见的错误预期以为下载下来就能跑很多人在 IDEA 里打开这个工程以后第一反应是直接启动然后发现编译报错或者启动后请求 404。原因多半不是代码的问题而是这个包默认你已经有 Java 环境和 Maven 基础。我在实际项目里见过太多次这类“包没问题环境有问题”的场面后面第三章我会专门理一遍跑通的最小步骤。3. 把 shiro_jwt 工程跑起来环境准备与最小验证命令3.1 解压后先认目录别急着点运行这类整合工程的结构非常典型主体是一个 Maven 工程通常是src/main/java下面按包名分层放工具类、Realm、过滤器、Configsrc/main/resources放着application.yml或shiro.ini之类的配置文件pom.xml里引入了 Shiro、JWT 库和相关 web 依赖。你在动手之前先在目录里找这几个东西JWT 工具类、Realm 实现类、Shiro 配置类、一个登录接口和一个受保护接口。找到这四个文件你就已经掌握了这个工程的主干。如果解压后目录结构是乱的或者缺了某些文件别慌大多数情况下是压缩包本身的整理问题你按后面第四章节的代码重建一遍也能复原。我用一个标准命令帮你自查环境# 检查 JDK 版本这类工程一般要求 8 以上 java -version # 检查 Maven 版本 mvn -v # 如果有 Maven 在本地仓库里找不到依赖先强制更新一次快照 mvn clean install -U -DskipTests这段命令的逻辑是先确认你在用什么版本的工具链再让 Maven 把依赖拉到本地。JDK 版本如果低于 8很多依赖直接编不过Maven 版本太旧可能无法解析较新的pom.xml写法。-U参数是强制检查远程仓库是否有新快照-DskipTests跳过测试加速构建。这里踩过坑的朋友应该懂很多所谓“包跑不起来”其实就是本地仓库缓存了坏依赖更新一次就正常了。3.2 最少依赖清单只有 Shiro 和 JWT 还不够一个能够编译通过的工程依赖通常不止 Shiro 和一个 JWT 库。我列一份最典型的依赖组合方便你对号入座。依赖作用说明shiro-springShiro 与 Spring 整合提供 ShiroFilterFactoryBean 和 SecurityManager 的 Spring 配置支持shiro-webShiro 的 Web 过滤器支持处理过滤链、Session 管理JWT 过滤器要基于它来扩展jjwt 或 java-jwtJWT 的生成与解析选一个稳定的实现即可不要同时引入两套spring-boot-starter-webWeb 容器提供 API 接口的 web 环境数据库驱动和连接池查询用户和权限用具体选型取决于工程里配置的数据库很多新手只引 Shiro 和 JWT 两个依赖结果启动时缺少 shiro-springSpring 容器里根本没有 Shiro 的过滤器自然接口全 404。这类依赖的选择没有统一标准以你自己项目里能成功编译的最小集合为准。核心逻辑其实不复杂Shiro 要能跟 Spring 容器对话JWT 库要能可靠地处理签名和过期时间。3.3 启动后别急着调业务接口先测认证链路工程跑起来以后我最推荐的做法是先用 curl 把登录和鉴权这条链路打通。你不需要打开浏览器点点点命令行三条指令就能验证整个体系是否正确工作。# 1. 调登录接口拿到 token curl -X POST http://localhost:8080/api/login \ -H Content-Type: application/json \ -d {username:admin,password:123456} # 2. 携带 token 访问一个需要登录的接口 curl -X GET http://localhost:8080/api/user/info \ -H Authorization: 上一步返回的token字符串 # 3. 不带 token 访问同一个接口预期返回 401 或未认证提示 curl -X GET http://localhost:8080/api/user/info第一条命令是正常登录成功后响应体里会有一个 token第二条命令模拟客户端带着 token 来访问资源如果返回正常数据说明 JWT 过滤器已经能从请求头里解析凭证并完成认证第三条命令是负向测试不带 token 访问如果返回 401 JSON 而不是 302 重定向说明过滤器配置正确。这里的Authorization请求头名称在后面代码里是写死的你完全可以改成token或X-Auth-Token但前后端必须保持一致。3.4 验证过程中的三个检查点如果你在跑通过程中卡住对照下面三个点排查。第一登录接口是否在过滤器链中被放行许多工程把/api/login配置成匿名访问如果你发现登录接口自己都被拦了就要去配置里看匿名路径是否正确。第二过滤器是否真的生效最常见的问题是自定义 JWT 过滤器写好了但没有装配进 Shiro 的过滤链等于白写这个后面第四章会展开。第三数据库里有没有预设的用户和密码很多示例包会附带 SQL 脚本如果你没有执行初始化脚本登录自然失败。我在给一个模拟项目X做改造时就出现过环境齐全但始终提示用户不存在的情况最后发现是数据库连接配置指向了本地开发库而不是包里的初始化脚本。这类问题没什么玄学纯粹是配置文件没仔细看。4. 手写一个可用的 Shiro JWT 最小实现代码与参数解读4.1 先写 JWT 工具类生成、解析、校验三件事别指望一个库全包网上流传的 JWT 工具类写法五花八门核心逻辑其实只有三个方法createToken、getUsername、verify。我习惯提供一个统一入口后续其他地方只面向这个类编程不直接操作 JWT 库的 API。public class JwtUtil { // 密钥生产环境从配置文件或环境变量读取不要硬编码在代码里 private static final String SECRET your-secret-key-change-me-at-least-32-bytes-long; // token 有效期默认 2 小时 private static final long EXPIRE_MILLIS 2 * 60 * 60 * 1000L; public static String createToken(String username) { return Jwts.builder() .setSubject(username) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() EXPIRE_MILLIS)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static String getUsername(String token) { return Jwts.parser() .setSigningKey(SECRET) .parseClaimsJws(token) .getBody() .getSubject(); } public static boolean verify(String token) { try { Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token); return true; } catch (Exception e) { return false; } } }这个类做了三件事生成 token 时将用户名塞进 subject解析 token 时取出用户名校验时捕获所有异常并返回布尔值。这里有个关键参数SECRET 字符串长度必须足够长使用 HS256 签名时密钥太短会被部分 JWT 库直接拒绝或者被轻松暴力破解EXPIRE_MILLIS 决定了用户无感操作的最长间隔。分享一个经验不要把过期时间设成 7 天或一个月时间越长密码泄露后的风险窗口越大后面第六章会讲怎么用续期机制解决“频繁登录”的体验问题。4.2 自定义 JwtToken让 Shiro 的认证流程认识 JWT 字符串Shiro 的认证入口是subject.login(token)token 可以是用户名密码组合也可以是任何实现了 AuthenticationToken 接口的对象。为了让 JWT 字符串进入 Shiro 体系需要写一个轻量包装类这是我见过的最容易被省略的一个环节省掉它的后果是 Realm 里无法区分“用户名密码登录”和“JWT 登录”两种凭证。public class JwtToken implements AuthenticationToken { private final String token; public JwtToken(String token) { this.token token; } Override public Object getPrincipal() { return token; } Override public Object getCredentials() { return token; } }这里没有复杂的业务逻辑只是实现了接口的两个方法。getPrincipal 返回身份标识按 Shiro 的语义一般是用户名但 JWT 模式下我们返回整个 token因为 Realm 需要从里面验签并解析出用户名getCredentials 返回凭证同样返回 token 字符串本身。这样设计是因为 JWT 既是“身份的证明”又是“身份的凭证”二者合一这正是无状态认证和传统 Session 的直观区别。4.3 自定义 Realm验签成功后把权限从库里搬出来Realm 是 Shiro 里连接“认证信息”和“业务数据”的桥。JWT 模式下的 Realm 要做两件事在 doGetAuthenticationInfo 里校验 token 是否合法在 doGetAuthorizationInfo 里查询角色和权限。这里要专门强调 supports 方法它决定当前 Realm 能处理哪种 Token 类型很多人漏掉这步导致自定义 Realm 永远不执行。public class JwtRealm extends AuthorizingRealm { Autowired private UserService userService; Override public boolean supports(AuthenticationToken token) { return token instanceof JwtToken; } Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { String username (String) principals.getPrimaryPrincipal(); SimpleAuthorizationInfo info new SimpleAuthorizationInfo(); // 这两行是查询数据库的过程 info.addRoles(userService.findRolesByUsername(username)); info.addStringPermissions(userService.findPermissionsByUsername(username)); return info; } Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { String jwt (String) token.getPrincipal(); if (!JwtUtil.verify(jwt)) { throw new AuthenticationException(token 无效或已过期); } String username JwtUtil.getUsername(jwt); if (username null) { throw new AuthenticationException(token 中缺少用户名); } User user userService.findByUsername(username); if (user null) { throw new AuthenticationException(用户不存在); } return new SimpleAuthenticationInfo(username, jwt, getName()); } }主流程很清晰supports 先判断是不是 JWT 类型认证方法里先验签、再查库、最后把用户名放回 principal授权方法里从库里拿到角色和权限集合。关于 getPrimaryPrincipal 返回的值这里有一点容易搞混SimpleAuthenticationInfo 的第一个参数username决定了授权时 getPrimaryPrincipal 的结果如果你在认证方法里传的是 token 字符串那授权方法拿到的就不是用户名而是乱码。我在第一次写这段代码时踩过这个坑当时授权方法里拿到的 principal 是一长串 token查权限直接返回空页面按钮全灰排查了好久才发现是构造 SimpleAuthenticationInfo 时传参顺序问题。4.4 自定义 JWT 过滤器把“无 Session 请求”翻译成 Shiro 能懂的动作过滤器是整个链路的入口。它从 HTTP 请求头里取出 token封装成 JwtToken然后调用 subject.login 交给 Realm。这里最容易犯的错误是直接写一个普通的 Spring OncePerRequestFilter然后在整个过滤链的最前端执行结果 Shiro 的 SecurityManager 还没初始化subject 里面什么都没有。正确做法是继承 Shiro 提供的 BasicHttpAuthenticationFilter让它接入 Shiro 的过滤链。public class JwtFilter extends BasicHttpAuthenticationFilter { Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { HttpServletRequest req (HttpServletRequest) request; // 放行跨域预检请求否则浏览器 OPTIONS 请求会被拦截 if (HttpMethod.OPTIONS.name().equalsIgnoreCase(req.getMethod())) { return true; } String token req.getHeader(Authorization); if (token null || !JwtUtil.verify(token)) { return false; } JwtToken jwtToken new JwtToken(token); try { getSubject(request, response).login(jwtToken); return true; } catch (Exception e) { return false; } } Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws IOException { response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\未认证或token已失效\}); return false; } }这段代码有一个细节很值得玩味isAccessAllowed 里主动调用了 login这看起来有点违反直觉因为“访问允许”的判断通常不需要主动登录。但在无状态模式下login 是完成身份解析的手段token 合法就登录成功并返回 truetoken 非法就抛出异常并返回 false。另一个细节是 OPTIONS 请求的处理前后端分离项目中只要请求带了自定义请求头浏览器就会先发一次 OPTIONS 预检如果你在过滤链里禁止预检前端控制台会报 CORS 错误而真实接口请求根本没发出。4.5 ShiroConfig 装配过滤器链的顺序是命门最后是配置类把上面三个类串起来。这一步出现问题的概率最高因为 Shiro 的过滤器链是顺序匹配的声明的顺序直接决定了请求命中哪个过滤器。Configuration public class ShiroConfig { Bean public JwtRealm jwtRealm() { return new JwtRealm(); } Bean public DefaultWebSecurityManager securityManager(JwtRealm jwtRealm) { DefaultWebSecurityManager manager new DefaultWebSecurityManager(); manager.setRealm(jwtRealm); return manager; } Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager) { ShiroFilterFactoryBean factoryBean new ShiroFilterFactoryBean(); factoryBean.setSecurityManager(securityManager); // 注册自定义过滤器名为 jwt MapString, Filter filters new HashMap(); filters.put(jwt, new JwtFilter()); factoryBean.setFilters(filters); // 过滤链定义注意顺序 MapString, String chain new LinkedHashMap(); chain.put(/api/login, anon); chain.put(/api/register, anon); chain.put(/api/**, jwt); chain.put(/**, jwt); factoryBean.setFilterChainDefinitionMap(chain); // 关闭默认登录跳转 factoryBean.setLoginUrl(); factoryBean.setUnauthorizedUrl(); return factoryBean; } }这里要特别强调 LinkedHashMap因为 Shiro 按声明顺序匹配 URL 模式/**如果写在/api/login前面那登录接口也会被 jwt 过滤器拦截导致匿名访问失效。登录和注册路径要用 anon 表示匿名可访问其余接口全部交给我们自定义的 jwt 过滤器。另一个参数是 loginUrl 和 unauthorizedUrl在前后端分离场景必须设置为空否则 Shiro 会把未认证请求重定向到一个登录页面你在 Ajax 调用时就会收到莫名其妙的 302 而不是 401 JSON这个问题在第五章还会展开讲。4.6 注解方式控制权限让接口粒度从“登录”细化到“角色”过滤链只能控制“要不要登录”控制不了“谁能访问”。Shiro 提供了RequiresRoles和RequiresPermissions注解配合 Spring AOP 使用。在 Controller 方法上加注解后授权拦截会在进入方法前校验当前用户是否拥有指定角色或权限这是 Shiro 相对自研 JWT 方案省心的关键原因。RestController RequestMapping(/api/admin) public class AdminController { GetMapping(/list) RequiresRoles(admin) public Result list() { return Result.ok(); } GetMapping(/config) RequiresPermissions(system:config:edit) public Result config() { return Result.ok(); } }使用注解时有一个前提你的 SpringBoot 启动类上需要有EnableAspectJAutoProxy并且配置里要开启 Shiro 的注解支持。如果直接复制注解但没开支持你会发现方法可以被任何已登录用户访问权限注解形同虚设。我在一次项目联调时前端说普通用户能打开管理员页面我第一反应是授权 Realm 查询有问题查了大半天才发现是漏了注解开关像这种低级问题排查起来真是血泪教训。5. 避坑与排查Shiro JWT 整合中最容易翻车的 5 个场景5.1 过滤器不生效请求能通但 SecurityUtils.getSubject() 拿不到用户现象接口返回正常但代码里调用SecurityUtils.getSubject().getPrincipal()得到的是 null或者始终是匿名用户。原因自定义 JWT 过滤器没有被装配进 Shiro 的过滤链而是被当成普通 Spring 过滤器放在了别处。解决确认过滤器是通过ShiroFilterFactoryBean.setFilters()注册的并且setFilterChainDefinitionMap()里有路径指向这个过滤器名。很多工程用了 SpringBoot 的Component标注过滤器类结果 Spring Boot 自动把它注入了全局过滤链执行时机跑到了 Shiro 之外。这个时候 SecurityManager 还没准备好请求就先进了你的过滤器你做的事只是“大致看了一眼 token”根本没有触发 Shiro 的认证流程。最终表现就是业务代码里拿不到登录用户。5.2 401 变成了 302 重定向前端一脸懵现象带 token 访问接口响应里不是一个 JSON 状态码而是跳转到了一个登录页面浏览器地址栏都变了。原因Shiro 默认在未认证的情况下会重定向到 loginUrl而前后端分离项目里前端期望的是 JSON 401。解决在 ShiroConfig 里把 loginUrl 设置为空串或者自定义 user 过滤器覆盖默认的跳转逻辑。这个问题在新老项目混合开发时非常容易踩。老的模板项目可能本来就配了一个 HTML 登录页你在改造时没注意继承回来的application.yml里配置了跳转地址JWT 模式根本没用到 Session但这个重定向逻辑还留着。5.3 token 过期后“突然掉线”用户没有收到任何提示现象用户正在操作突然下一个请求返回 401前端没有统一处理页面直接白屏或报错。原因前端没有统一的 401 拦截逻辑而且 token 过期时间设得太短用户操作间隙一长就过期。解决前端在 HTTP 响应拦截器里统一捕获 401 并跳转登录页同时后端设置更合理的过期时间或配合第六章的续期机制。这个坑本质上不是技术问题而是工程配合问题。JWT 模式本来就可以做到用户完全无感的体验前提是前端把 401 当成“需要重新登录”的统一信号而不是当成普通接口报错。5.4 跨域预检请求被过滤器拦截接口在浏览器里永远不通现象前端用 fetch 或 axios 发起带 Authorization 头的请求浏览器控制台报 CORS 错误后端却说接口测试正常。原因浏览器先发了一个 OPTIONS 预检请求你的过滤器没放行 OPTIONS预检直接返回未认证。解决在过滤器 isAccessAllowed 里对 OPTIONS 直接返回 true另外确保 Spring 的跨域配置允许 Authorization 请求头。这个坑隐蔽在“后端测试正常”这句假象里。你用 Postman 调接口完全没有问题因为 Postman 不发预检请求浏览器端却始终失败。我第一次遇到时花了一个下午最后在浏览器 Network 面板看到那个粉色的 OPTIONS 请求才反应过来。5.5 退出登录没有真正“退出”旧 token 还能继续使用现象用户点击退出登录后拿着旧 token 还能继续访问接口。原因JWT 无状态模式下服务端不保存 token退出登录只是客户端丢弃 token服务器无法让旧 token 失效。解决引入 Redis 黑名单机制在 Filter 里校验 token 是否在注销名单中这一步的简化实现见下一章节。这个现象让很多第一次接触无状态认证的开发者怀疑人生——不是说无状态吗怎么连退出都做不干净。其实无状态与主动失效并不矛盾只是需要额外引入一个共享存储来记录“已被吊销的凭证指纹”属于无状态认证的常见补偿方案。6. 进阶技巧用会话续期与主动失效把无状态认证做成“有手感”的方案无状态认证做得再好如果用户两小时必须重新登录一次体验上还是会被吐槽“怎么又掉了”。我的做法是引入滑动续期机制在 JWT 过滤器里每次请求进来后先看剩余有效期如果已经用掉超过一半时间就重新签发一个新 token 并把 token 放到响应头里返回给前端前端下次请求自动替换本地存储的旧 token。这样用户只要在活跃使用就永远不会感到登录态失效真正的过期只发生在用户完全离开之后。代码实现很简单在 isAccessAllowed 里通过 JwtUtil 拿到过期时间计算剩余有效期如果小于一半就生成新 token 并在返回前写入响应头。主动失效这个需求可以用 Redis 存一个“token 指纹黑名单”来实现。给每个 token 加一个随机 jti 字段登录时以 jti 为 key 存入 Redis退出登录时把 jti 加入黑名单并设置与 token 一致的过期时间。JWT 过滤器里在验签通过后先查一次 Redis如果在黑名单里就拒绝访问。思路不复杂但解决了前面不能主动踢人的最大痛点也让修改密码、封禁用户这类操作在无状态体系里成为可能。我现在做这类无状态权限改造拿到需求后的第一件事不是写代码而是打开画图工具把请求从浏览器到 Controller 要经过哪些过滤器画一遍标清楚哪个环节验签、哪个环节查库、哪个环节放行 OPTIONS再开始动手。这个习惯帮我避开了大半的返工也让我拿到任何类似的整合包时能快速定位它在哪一层出了问题。希望帮到你。本文还有配套的精品资源点击获取