在前后端分离项目里做登录认证,shiro和jwt这一对组合大概率是绕不开的。前年我接手一个内部管理系统改版,后端是单体Spring Boot应用,前端从JSP这种服务端渲染正在往Vue独立部署迁移,第一个绕不过去的就是登录态怎么维持。老方案用的Session加Cookie,一换成跨域部署和接口化之后,Session缺失、跨域带Cookie这些事轮流折磨人。后来定了shiro整合jwt的方案,把权限模型继续留在Shiro上,登录态改用JWT这种无状态令牌承载,项目才真正跑顺。这套组合在线上稳定运行了三个多月,期间整理了不少笔记,这篇就按“怎么设计、怎么搭、会踩哪些坑”的路线一次讲清楚,适合正在做前后端分离改造、或者准备自建轻量级认证体系的Java后端同学参考。
1. 先从两个名词说起:shiro管什么,jwt管什么
1.1 Shiro在项目里扮演的角色
Shiro在Java安全领域属于老牌框架,功能模块拆开看就是认证、授权、会话管理、加密这四块。用在Web项目里,它要解决的是“你是谁、你能干什么、你的登录态暂存在哪、密码怎么安全比对”这几个基础问题。很多团队一直用它而不是Spring Security,原因很实际:Shiro学习曲线比Spring Security缓很多,配置量更少,对中小型项目来说性价比非常高,不存在“为了一个简单后台系统背一整套安全规范”的负担。
Shiro内部三个核心对象:Subject、SecurityManager、Realm。Subject可以理解成当前用户的一个抽象句柄,你在Controller里能通过SecurityUtils.getSubject()拿到它;SecurityManager是整个安全框架的总入口,认证、授权、会话管理都被它串起来;Realm则是安全数据和业务数据的桥梁,Shiro不关心你的用户存在MySQL还是Redis,它只负责把Realm返回的用户信息和权限信息规整成一个安全上下文。实际的认证流程就是:用户把账号密码交给Subject,Subject把凭证转给SecurityManager,SecurityManager让认证器去找匹配的Realm,Realm查到用户后做凭据比对,比对成功就创建带角色和权限的Subject,失败就抛认证异常。
1.2 JWT的定位与优势
JWT全称JSON Web Token,格式看起来就是一串三段式字符串,三段之间用点号隔开:Header、Payload、Signature。Header里写明签名算法,Payload里塞业务声明,比如用户ID、签发时间、过期时间这些key-value对,Signature则用服务端密钥对前两段做签名。它最大的两个特点是自包含与无状态,服务端不需要保存任何会话记录,拿到Token验一下签名和有效期,通过就认为是合法请求。
类比一下这张票更好理解:Session方案类似商场存包柜,顾客把包存进去,服务端把一个柜号夹在手里,下次凭柜号取包,但柜子本身不记录顾客是谁,且只能在存包的那个商场用。JWT像一张盖了骑缝章的入场券,入场时服务端验章,章是真的就直接放行,票面上还印着入场人名字和有效期。这个特性在前后端分离、接口集群部署时尤其值钱,任何一个服务节点都能独立完成用户身份验证,不需要共享Session,也就没有Session同步和粘性会话这类架构包袱。
1.3 为什么非得“整合”
既然Shiro功能完整、JWT又简单易用,为什么不直接用其中一个?原因在于两者的边界不重叠。Shiro默认的登录态是绑定Session的,而JWT本身又完全没有权限模型,不解决“用户有什么角色、哪些接口能碰”这件事。它们刚好互补:Shiro管“权限结构”,JWT管“无状态身份”。
所谓整合,不是让Shiro去理解JWT格式,而是做两个切入点。第一,把Shiro获取用户信息的地方从Session改成解析JWT:Realm收到Token后,解析出用户名,再去数据库查用户和权限。第二,把Shiro从请求中读取登录态的地方改成从HTTP请求头读取Bearer Token:自定义Filter把请求头里的Token取出,交给Subject完成登录。权限判断继续用Shiro的注解和URL规则,改动被约束在这两个点上,项目原有的角色继承、权限注解、接口拦截图腾都不会被破坏,这就是整合方案的核心理由。
2. 整合设计的核心思路:把登录的“状态”从会话挪到令牌
2.1 传统Session方案的痛点
做这套整合之前,系统里Web登录就是标准Session流程:登录成功后在服务端内存创建Session,把JSESSIONID写进浏览器Cookie,后续请求凭Cookie找到对应Session。单机跑没有任何问题,但项目一旦拆成多实例部署,麻烦立刻出现。负载均衡把同一个用户的两次请求分到不同节点,A节点存的Session在B节点上根本不存在,用户马上被判定未登录。解决方案要么引入Redis集中化Session,要么做粘性会话,两种方式都增加了运维和架构复杂度。
前后端分离之后问题更明确。前端部署在另一个域名或端口,跨域请求要带Cookie不是简单在Ajax里加个withCredentials就行,还牵扯CSRF防护、SameSite策略、新浏览器对第三方Cookie的限制等等。移动端调用同一个后端接口时,Cookie机制和浏览器的行为还不完全一致。一个登录态方案要同时适配Web、H5、App,Session这套就显得有些笨重,无状态令牌几乎是接口化改造必然被推到台前的选择。
2.2 无状态认证的请求链路
整合完成后的认证链路,我习惯画成六步记忆。第一步,用户登录,拿用户名和密码访问登录接口,服务端校验通过后生成JWT并返回给前端。第二步,前端把Token存在本地存储或内存中,每次请求在请求头加Authorization的Bearer串。第三步,后端自定义的JwtFilter在请求进入业务Controller之前完成拦截,从请求头解析出Token。第四步,JwtFilter调用Subject.login(),把Token包装成自定义Token传给Shiro。第五步,Shiro把Token交给JwtRealm,Realm负责验签、查用户、组装权限信息。第六步,登录成功放行,Controller层再通过Subject或注解拿到当前用户身份做业务处理。
这六步里最核心的是第四、第五步。不要另起炉灶在Filter里直接解析Token然后ThreadLocal存用户,那样等于把Shiro架空了,后续所有权限注解和URL拦截都失去作用。正确姿势是把Token解析后迅速交还给Shiro的认证流程,让Shiro基于解析结果建立Subject,这样后续Shiro的授权能力才能继续生效。
2.3 方案选型:继承式还是替换式
做整合时有两个方向可选。方向一是在Shiro的Filter链中间插入一个自定义Token过滤器,继承BasicHttpAuthenticationFilter或OncePerRequestFilter,拦截所有请求后强制走Subject登录。方向二是绕开Shiro的Filter链,在网关或者Spring Interceptor层统一解析JWT,Shiro只用来做后台RBAC校验。第二种解耦更彻底,但用起来你会发现Shiro的URL拦截配置、注解拦截这些基础能力基本用不上,等于退化成一个纯工具库。
我建议中小型项目首选方向一。Shiro的Filter链在设计的时候本来就考虑了自定义认证逻辑的扩展,把JWT解析放进这个位置,代码量小、侵入性低,前端后端都不需要感知太多改动。如果项目前面还有网关,网关侧做一次轻量级JWT校验,进入服务后再由Shiro Filter验一次,两边互不冲突,只是多一次验签的开销。安全相关的系统,多一次校验一般可以接受。
3. 实操落地:从零搭建shiro整合jwt完整项目
3.1 第一步:引入依赖,版本搭配别踩坑
我用的技术栈是Spring Boot 2.7.x加Shiro 1.11.0加jjwt 0.11.5。Maven依赖这样加:
<dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-spring</artifactId> <version>1.11.0</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency>这里有个容易忽略的细节:jjwt从0.11.x开始拆成了API包和实现包,编译代码只依赖jjwt-api,但运行期必须把jjwt-impl和jjwt-jackson一起引入,否则报错提示非常隐晦,像是缺少某个签名相关类。有人喜欢用java-jwt或者hutool的JWTUtil代替,都行,我习惯jjwt的原因是它的Builder和Parser链式调用写起来规整,自定义Claims、过期时间处理顺手,遇到问题搜索资料也多一些。
选择依赖版本时还要注意Shiro和Spring Boot之间的兼容性。Shiro 1.x比较稳,跟着Spring Boot 2.x使用没有大坑;如果项目用到Spring Boot 3.x,建议直接选Shiro 2.x或者考虑其他框架,不然Servlet API不兼容会让整个Filter链启动时报错,排查起来很费劲。
3.2 第二步:JWT工具类与自定义Token类
JWT工具类负责三个职责:生成Token、解析Token、校验Token。Secret、过期时间、签发者都放在application.yml里,方便不同环境切换。基本实现如下:
@Component public class JwtUtil { @Value("${jwt.secret}") private String secret; @Value("${jwt.expiration}") private Long expiration; @Value("${jwt.issuer}") private String issuer; public String generateToken(String username, Long userId) { return Jwts.builder() .setSubject(username) .claim("userId", userId) .setIssuer(issuer) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + expiration * 1000)) .signWith(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)), SignatureAlgorithm.HS256) .compact(); } public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8))) .build() .parseClaimsJws(token) .getBody(); } public boolean isTokenExpired(String token) { return parseToken(token).getExpiration().before(new Date()); } }生成Token时,我把用户名放在Subject里,把用户ID放在自定义Claim里,这样接口里要取用户ID或用户名都很方便。Secret引用的字符串长度不能低于32字节,这是HS256算法的硬性要求,配置短了会在签名时直接抛异常,所以我会用一个UUID类型的随机字符串写进配置文件。过期时间我习惯设置2小时,配合后面会提到的刷新机制使用。
自定义Token类也不能缺,它的作用是把字符串Token包装成Shiro认识的AuthenticationToken。为了让Shiro正确识别,还不破坏JWT的“无密码”特性,getPrincipal和getCredentials都返回Token字符串:
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; } }3.3 第三步:编写JwtRealm,认证与授权都在这
Realm是Shiro连接业务数据的桥梁,整合JWT后,doGetAuthenticationInfo方法变成“解析Token并查用户”,doGetAuthorizationInfo方法负责“组装权限集合”。代码如下:
public class JwtRealm extends AuthorizingRealm { @Autowired private JwtUtil jwtUtil; @Autowired private UserMapper userMapper; @Override public boolean supports(AuthenticationToken token) { return token instanceof JwtToken; } @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { String username = (String) principals.getPrimaryPrincipal(); Set<String> roles = new HashSet<>(); Set<String> permissions = new HashSet<>(); // 真实项目在这里从数据库或权限服务查询 roles.add("admin"); permissions.add("user:list"); SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); info.setRoles(roles); info.setStringPermissions(permissions); return info; } @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { String jwt = (String) token.getPrincipal(); try { Claims claims = jwtUtil.parseToken(jwt); String username = claims.getSubject(); User user = userMapper.selectByUsername(username); if (user == null || user.getStatus() == 0) { throw new UnknownAccountException("用户不存在或已禁用"); } return new SimpleAuthenticationInfo(username, jwt, getName()); } catch (ExpiredJwtException e) { throw new AuthenticationException("token已过期"); } catch (Exception e) { throw new AuthenticationException("token解析失败"); } } }supports()这条方法特别值得留意。当系统存在多个Realm时,Shiro的认证器需要靠它判断Token应该交给哪个Realm。以前直接把所有Realm都配置进去不加区分,就会出现账号密码的认证器尝试处理JWT Token的情况,报错极难排查。另外SimpleAuthenticationInfo的第三个参数是Realm的名字,getName()默认返回当前Realm的类名,如果你重写了Realm的getName,第三个参数也要保持一致。
3.4 第四步:自定义JwtFilter认证过滤器
过滤器是整合链路里承上启下的组件:上承HTTP请求头里的Token,下启Shiro的Subject登录流程。我继承BasicHttpAuthenticationFilter,重写isLoginAttempt和isAccessAllowed:
public class JwtFilter extends BasicHttpAuthenticationFilter { @Override protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) { String authzHeader = getAuthzHeader(request); return authzHeader != null && authzHeader.startsWith("Bearer "); } @Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { if (isLoginAttempt(request, response)) { try { executeLogin(request, response); return true; } catch (Exception e) { writeUnauthorizedResponse(response); return false; } } // 没有携带Bearer Token的请求直接放行,由后续Shiro权限判断决定能否访问 return true; } @Override protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception { String authzHeader = getAuthzHeader(request); String token = authzHeader.substring(7); JwtToken jwtToken = new JwtToken(token); getSubject(request, response).login(jwtToken); return true; } }isLoginAttempt的作用是判断这次请求是不是一次“登录尝试”,如果请求头里根本没有Bearer Token,就把它当成匿名请求放行,Shiro会再通过anon和jwt这些规则决定它能访问什么。这种设计的好处是登录接口、静态资源、白名单接口不需要额外接入自定义逻辑,规则在配置层就能搞定。
这里必须强调一点:isLoginAttempt只检查前缀,不负责Token内容是否合法。Token合法性的校验在executeLogin和Realm里完成。所以子串截取时authzHeader.substring(7)这个“7”是Bearer加空格的固定长度,如果前端传的格式不规范,后面解析一定会报错,在日志里看到类似SignatureException的时候就该检查请求头格式了。
3.5 第五步:ShiroConfig配置与拦截规则
配置类要把Realm、SecurityManager、ShiroFilterFactoryBean全部串起来。整合JWT时还多一步:显式关闭Shiro自带的Session存储,因为登录态已经交给JWT了,继续创建Session属于画蛇添足:
@Configuration public class ShiroConfig { @Bean public JwtRealm jwtRealm() { return new JwtRealm(); } @Bean public DefaultWebSecurityManager securityManager() { DefaultWebSecurityManager manager = new DefaultWebSecurityManager(); manager.setRealm(jwtRealm()); DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO(); DefaultSessionStorageEvaluator sessionStorageEvaluator = new DefaultSessionStorageEvaluator(); sessionStorageEvaluator.setSessionStorageEnabled(false); subjectDAO.setSessionStorageEvaluator(sessionStorageEvaluator); manager.setSubjectDAO(subjectDAO); return manager; } @Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager) { ShiroFilterFactoryBean filterFactoryBean = new ShiroFilterFactoryBean(); filterFactoryBean.setSecurityManager(securityManager); filterFactoryBean.setFilters(Collections.singletonMap("jwt", new JwtFilter())); Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>(); filterChainDefinitionMap.put("/login", "anon"); filterChainDefinitionMap.put("/favicon.ico", "anon"); filterChainDefinitionMap.put("/**", "jwt"); filterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap); filterFactoryBean.setUnauthorizedUrl("/unauthorized"); return filterFactoryBean; } }拦截规则这里必须用LinkedHashMap,因为Shiro按顺序从上到下匹配URL,第一条命中就生效。我习惯把需要放行的anon规则全部写在前面,把/**这种通配规则放在最后。以前排过一个低级问题:有人把/**放在第一位,导致登录接口也被jwt过滤器拦截,用户永远登录不了,这类问题定位很容易被误导。
关闭Session存储这一步不要忽略。如果不清掉Session管理器,Shiro在某些请求里还是会尝试创建Session甚至写Cookie,无状态方案就名存实亡。很多网上文章只给Realm和Filter的代码,这个细节往往被跳过了,但它直接影响方案是否够纯粹。
3.6 第六步:登录接口与Controller获取用户信息
登录接口实现相对简单,密码校验我放在Service层完成。注意JWT模式下Shiro不会主动帮忙做密码比对,因为JwtToken的credentials和principal都是Token字符串,Shiro无法从Token里拿到原始密码,所以登录接口自己负责校验账号密码,校验通过后签发Token:
@RestController public class AuthController { @Autowired private UserService userService; @Autowired private JwtUtil jwtUtil; @PostMapping("/login") public Result login(@RequestBody LoginRequest request) { User user = userService.authenticate(request.getUsername(), request.getPassword()); if (user == null) { return Result.error("账号或密码错误"); } String token = jwtUtil.generateToken(user.getUsername(), user.getId()); return Result.success(new TokenResponse(token)); } }业务接口里获取当前用户的方式有两种。第一种是直接在方法里通过SecurityUtils.getSubject().getPrincipal()拿到用户名,然后查用户信息。第二种是配合Shiro的注解做权限控制,比如在查询列表接口上加@RequiresPermissions("user:list"),这个注解需要开启代理支持。
AuthorizationAttributeSourceAdvisor也必须注册成Bean,否则注解不生效。我把它一并配在ShiroConfig里:
@Bean public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(DefaultWebSecurityManager securityManager) { AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor(); advisor.setSecurityManager(securityManager); return advisor; }前端对接时的请求头格式是固定的:Authorization: Bearer 。这个格式看起来简单,但Bearer和Token之间那个空格非常容易在拼接时漏掉,尤其是当前端随手从文档复制拼接代码时。如果你发现“登录了但拿不到用户信息”这样的症状,第一个要检查的就是这个空格和Header名字大小写。
4. 常见问题与排查技巧实录
方案落地三个月,整理出几份排查笔记,下面这些是按出现频率排的实战问题,有的在网上一搜一堆答案,但真正能对号入座的细节不多。
4.1 问题一:所有请求都被401拦下
症状:登录成功后带着Token访问业务接口,依然返回401。排查步骤是有顺序的:先用Postman或curl发请求看是否能排除前端缓存干扰,再在JwtFilter的isAccessAllowed里打断点确认过滤器是否进入,最后看Nginx或网关是否把Authorization请求头剥离了。
两个高频原因值得重点说。第一,Filter链配置顺序不对,/**的通配规则排在白名单前面,导致登录接口也被拦截。解决办法是把anon规则统一放在最前。第二,请求头格式出错,业务请求没有按“Bearer空格Token”的格式带Authorization,Filter的isLoginAttempt判断会认为这不是登录尝试,直接放行,然后业务代码因为没有登录主体而返回401。这类问题定位时优先看请求和响应头,用浏览器开发者工具比对一次请求头,问题基本上就浮出水面了。
4.2 问题二:Filter被注册两次,Token被重复解析
症状:一个请求进入JwtFilter两次,代码里的日志重复打印,业务上表现为Token解析执行了两遍,效率下降。原因通常是把自定义JwtFilter既交给了ShiroFilterFactoryBean的setFilters管理,又用@WebFilter或者FilterRegistrationBean在Spring Boot注册了一次。两个容器都持有这个Filter,请求自然被过滤两遍。
解法是把自定义JwtFilter上的@Component或@WebFilter注解去掉,不让Spring容器额外接管,只通过ShiroFilterFactoryBean维护。如果项目里确实需要同时对JwtFilter做排序或URL匹配,优先用FilterRegistrationBean并在里面setEnabled(false)来避免重复注册。检查代码时先全局搜索一下JwtFilter的实例创建,基本一眼就能看出是否被重复引用了。
4.3 问题三:Token过期时返回的是HTML而不是友好JSON
Shiro在认证失败时默认跳转登录页或返回未授权视图,这在接口项目里很尴尬,前端收到一段HTML之后直接蒙圈。我的做法是在JwtFilter里统一处理异常,所有认证失败场景都输出固定结构的JSON响应:
private void writeUnauthorizedResponse(ServletResponse response) throws IOException { response.setContentType("application/json;charset=UTF-8"); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); Map<String, Object> body = new HashMap<>(); body.put("code", 401); body.put("message", "登录状态已过期或非法"); response.getWriter().write(new ObjectMapper().writeValueAsString(body)); }这里有个小坑是写响应时得用response.getWriter(),不要用response.getOutputStream(),否则在部分Web容器里会和Shiro后续的返回状态处理产生冲突,响应体写不完整。另外可以顺手在ShiroConfig里设置setLoginUrl和setUnauthorizedUrl,让这些URL返回JSON视图,但如果你已经在Filter里统一写JSON了,它们的作用就变成兜底了。
4.4 问题四:跨域配置不生效
前后端分离项目跨域是标配,后端配置了CorsFilter却总感觉没生效,这通常是Filter执行顺序的问题。Shiro的Filter链默认排在最前,它的过滤行为可能发生在CorsFilter之前,预检的OPTIONS请求还没到CorsFilter就被Shiro拦截了,自然返回不了CORS相关响应头。
解决办法是把CorsFilter的注册顺序调到最前面,FilterRegistrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE),同时确保ShiroFilter的order在它后面。另外跨域的请求头要覆盖Authorization这个自定义Header,如果你在CORS配置里只写了Content-Type和X-Requested-With,请求带Authorization时依然过不了预检。写CORS配置时把allowedHeaders直接设为*或者显式包含Authorization,能少很多折腾。
4.5 问题五:用户权限更新后仍然有旧权限
这个问题的表现是:管理员改了某个用户的角色或权限,该用户下一次请求还是按老权限判断。如果项目给Shiro配了Redis作为缓存管理器,授权信息可能在Redis里存了缓存,key没被同步失效,用户就拿着缓存权限访问到当前已撤销的接口。
处理建议分两级。第一优先级是在修改角色权限的代码里主动清理该用户的授权缓存,调用Subject的principals.clearCache(),或者把缓存Manager切回本地并设置极短过期时间。第二优先级是权限敏感度高的系统,不要依赖Shiro的AuthorizationCache,直接在doGetAuthorizationInfo里查库。简单来说,权限这种数据,宁可让每次请求多查一次库,也不要让用户拿到过期的“已撤销权限”。
4.6 问题六:无权限访问时返回了302重定向
Shiro默认在授权失败时会重定向到未授权页面,这在纯接口服务里是灾难。浏览器收到302,跳到登录页或一个不存在的页面,前端根本无法感知具体是什么权限错误。配置了setUnauthorizedUrl以后,重定向行为依然存在,只是目标地址换了。
推荐的做法是自定义一个统一的授权异常处理。结合Shiro的注解方案,在全局异常处理器里捕获AuthorizationException这种授权异常,统一返回403和错误信息。同时配合JwtFilter里已经写好的401逻辑,认证失败401、授权失败403,状态码清晰,前端拦截器可以根据状态码做跳转或提示,联调体验会舒服很多。需要注意全局异常处理器对AuthorizationException的捕获不能只拦截RuntimeException,否则Shiro抛出的授权异常会漏出去并变成500错误,前端看到的就是服务器内部错误而不是清晰的权限不足提示。
5. 尾声:一点个人体会
这套shiro整合jwt的方案开发起来并不复杂,难的是“能跑通”和“能稳定地跑”之间的距离。Session方案里只要会话没失效,请求过来基本畅通无阻;换成JWT方案后,时间戳偏差、密钥不一致、请求头格式不标准、过滤器顺序不对,任何一环出问题都是联调杀手。我踩过几次坑之后的经验是:先统一前后端约定,Authorization格式和过期时间这类契约尽早定下来,比事后排查要省事十倍。
最后分享一个我实际在用的扩展思路:在JWT的Payload里加一个“登录版本号”字段,用户改密码或管理员踢人时把版本号加一,Realm解析Token后拿版本号和数据库当前值比对,版本不一致直接拒绝。这等于给无状态的JWT补了一块近实时的吊销能力,比单纯依赖过期时间可靠得多。再往上做,还可以引入Refresh Token的双Token机制,一个短时效的Access Token负责业务请求,一个长效Refresh Token负责续签,体验和安全性可以同时兼顾。这个方向值得每一个做了基础整合的朋友继续深挖。