资源服务器如何对 JWT 进行验签

资源服务器如何对 JWT 进行验签

🎯 核心结论

资源服务器对 JWT 的校验分两层,且几乎不需要频繁调用 Auth 接口:

  • 签名验证 :通过 Auth Server 公开的 JWKS 端点 (/oauth2/jwks)拉取公钥,拉取后本地缓存,后续验签完全在本地完成
  • 业务校验 :exp 过期时间、iss 签发者、aud 受众、scope/authorities 等 claims,全部在资源服务器本地解析 JWT 后校验 ,不经过任何网络调用
    也就是说:资源服务器只有"第一次"或"公钥轮换"时才会真正请求 Auth 接口,日常请求的校验是纯本地操作------这是 JWT 无状态认证性能优势的根本来源。

⚠️ 先纠正一个常见误解

"资源服务器每次收到 Token 都要调 Auth 接口验签" ------ 错 。

如果是这样,Auth Server 会成为整个系统的性能瓶颈和单点故障,JWT 的"自包含"特性也就没有意义了。Spring Security 的实际做法是:

  1. 资源服务器启动或首次收到 JWT 时,向 Auth Server 的 JWKS 端点发起 一次 HTTP 请求,拉取公钥集合(JWK Set)
  2. 公钥缓存在本地(默认内存缓存,可配置 TTL)
  3. 后续所有请求都拿缓存的公钥在本地做签名校验
  4. 当 JWT 的 kid 在缓存中找不到(说明 Auth Server 已换新钥),才会触发一次 JWKS 重新拉取
    Auth Server 真正被调用的场景只剩三类:用户登录签发 Token、用 refresh_token 刷新、Token 吊销(introspection 端点,可选)。

🛠️ 基础配置:两种方式

方式一:issuer-uri(推荐,自动发现)

yaml 复制代码
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.example.com

Spring Boot 启动时会请求 https://auth.example.com/.well-known/openid-configuration,从返回的 OIDC 发现文档中自动读取 jwks_uri 字段 ,据此构建 NimbusJwtDecoder。

额外好处 :decoder 会同时校验 JWT 的 iss claim 必须等于配置的 issuer-uri,防止跨环境令牌误用。

⚠️ 启动时会做一次连通性检查(JwtDecoder 的 Supplier 是懒加载,首次使用才真正发起发现请求),若 Auth Server 不可达会在第一次请求时报错。

方式二:jwk-set-uri(直接指定)

yaml 复制代码
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: https://auth.example.com/oauth2/jwks
          # 可选:显式指定 issuer 校验
          issuer-uri: https://auth.example.com

跳过发现文档步骤,直接指向 JWKS 端点。Auth Server 未实现 OIDC Discovery 时使用 ;缺点是 iss 校验需要手动加 validator。

两种方式对比

维度 issuer-uri jwk-set-uri
配置复杂度 低(只写域名) 中(要自己拼出 JWKS 路径)
自动校验 iss ✅ 是 ❌ 需手动加 validator
依赖 OIDC Discovery ✅ 是 ❌ 否
适用场景 标准 Spring Authorization Server、Keycloak 简化版 Auth Server、内网直连

🔄 完整校验时序

Auth Server JWKS JWK 缓存 NimbusJwtDecoder JwtAuthenticationProvider BearerTokenAuthenticationFilter 用户请求 Auth Server JWKS JWK 缓存 NimbusJwtDecoder JwtAuthenticationProvider BearerTokenAuthenticationFilter 用户请求 #mermaid-svg-aJcjG3NmcKg9dUaX{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-aJcjG3NmcKg9dUaX .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-aJcjG3NmcKg9dUaX .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-aJcjG3NmcKg9dUaX .error-icon{fill:#552222;}#mermaid-svg-aJcjG3NmcKg9dUaX .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-aJcjG3NmcKg9dUaX .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-aJcjG3NmcKg9dUaX .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-aJcjG3NmcKg9dUaX .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-aJcjG3NmcKg9dUaX .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-aJcjG3NmcKg9dUaX .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-aJcjG3NmcKg9dUaX .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-aJcjG3NmcKg9dUaX .marker{fill:#333333;stroke:#333333;}#mermaid-svg-aJcjG3NmcKg9dUaX .marker.cross{stroke:#333333;}#mermaid-svg-aJcjG3NmcKg9dUaX svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-aJcjG3NmcKg9dUaX p{margin:0;}#mermaid-svg-aJcjG3NmcKg9dUaX .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-aJcjG3NmcKg9dUaX text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-aJcjG3NmcKg9dUaX .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-aJcjG3NmcKg9dUaX .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-aJcjG3NmcKg9dUaX .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-aJcjG3NmcKg9dUaX .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-aJcjG3NmcKg9dUaX #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-aJcjG3NmcKg9dUaX .sequenceNumber{fill:white;}#mermaid-svg-aJcjG3NmcKg9dUaX #sequencenumber{fill:#333;}#mermaid-svg-aJcjG3NmcKg9dUaX #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-aJcjG3NmcKg9dUaX .messageText{fill:#333;stroke:none;}#mermaid-svg-aJcjG3NmcKg9dUaX .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-aJcjG3NmcKg9dUaX .labelText,#mermaid-svg-aJcjG3NmcKg9dUaX .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-aJcjG3NmcKg9dUaX .loopText,#mermaid-svg-aJcjG3NmcKg9dUaX .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-aJcjG3NmcKg9dUaX .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-aJcjG3NmcKg9dUaX .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-aJcjG3NmcKg9dUaX .noteText,#mermaid-svg-aJcjG3NmcKg9dUaX .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-aJcjG3NmcKg9dUaX .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-aJcjG3NmcKg9dUaX .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-aJcjG3NmcKg9dUaX .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-aJcjG3NmcKg9dUaX .actorPopupMenu{position:absolute;}#mermaid-svg-aJcjG3NmcKg9dUaX .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-aJcjG3NmcKg9dUaX .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-aJcjG3NmcKg9dUaX .actor-man circle,#mermaid-svg-aJcjG3NmcKg9dUaX line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-aJcjG3NmcKg9dUaX :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt缓存命中缓存未命中(首次或轮换) 1. Authorization: Bearer eyJhbGc...2. 交给 JwtAuthenticationProvider3. decoder.decode(token)4. Base64 解析 Header,读取 kid / alg5. 根据 kid 查找公钥6a. 返回缓存的公钥(本地,零网络)6b. GET /oauth2/jwks7. 返回 JWK Set8. 返回新公钥9. 用公钥验签 RSA/ECDSA10. 校验 exp / nbf / iss / aud11. 跑自定义 OAuth2TokenValidator12. 返回 Jwt 对象13. claims → GrantedAuthority14. 返回 Authentication15. 放入 SecurityContext,请求放行

任何一个环节失败(签名不匹配、过期、issuer 不符、audience 不符),NimbusJwtDecoder 都会抛出 JwtValidationException 或 BadJwtException,由 BearerTokenAuthenticationEntryPoint 统一返回 401 ,并在 WWW-Authenticate: Bearer error="invalid_token" 头中给出原因。

📐 代码层自定义:校验规则与缓存

Spring Boot 的自动配置已足够日常使用,但要精细控制 aud/scope 校验或 JWKS 缓存策略,需要显式定义 JwtDecoder:

java 复制代码
@Configuration
public class ResourceServerConfig {
    /**
     * 自定义 JwtDecoder:显式指定 JWKS 地址 + 缓存 + 校验链
     */
    @Bean
    public JwtDecoder jwtDecoder() {
        // 1. 基于 JWKS 端点构建 decoder(底层是 Nimbus 的 JWKSource,自带缓存)
        NimbusJwtDecoder decoder = NimbusJwtDecoder
                .withJwkSetUri("https://auth.example.com/oauth2/jwks")
                .jwsAlgorithm(SignatureAlgorithm.RS256)   // 明确签名算法,拒绝 alg 混淆攻击
                .build();
        // 2. 组合校验器:时间戳(默认) + issuer + audience + 自定义
        OAuth2TokenValidator<Jwt> validator = new DelegatingOAuth2TokenValidator<>(
                new JwtTimestampValidator(Duration.ofSeconds(30)),  // 允许30秒时钟偏移
                new JwtIssuerValidator("https://auth.example.com"), // iss 必须匹配
                new JwtAudienceValidator("admin-bff"),              // aud 必须包含
                jwt -> {                                            // 自定义:必须有 admin.read scope
                    Collection<String> scopes = jwt.getClaimAsStringList("scope");
                    return (scopes != null && scopes.contains("admin.read"))
                            ? OAuth2TokenValidatorResult.success()
                            : OAuth2TokenValidatorResult.failure(
                                new OAuth2Error("invalid_scope", "缺少 admin.read 权限", null));
                }
        );
        decoder.setJwtValidator(validator);
        return decoder;
    }
    /**
     * SecurityFilterChain:启用资源服务器模式
     */
    @Bean
    public SecurityFilterChain resourceServerChain(ServerHttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health").permitAll()
                .requestMatchers("/api/**").hasAuthority("SCOPE_admin.read")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(Customizer.withDefaults())   // 使用上面的 JwtDecoder Bean
                // 自定义 401 响应体
                .authenticationEntryPoint((request, response, ex) -> {
                    response.setStatus(401);
                    response.setContentType("application/json;charset=UTF-8");
                    response.getWriter().write(
                        "{\"code\":401,\"msg\":\"令牌无效或已过期\"}");
                })
            );
        return http.build();
    }
}

JWKS 缓存与密钥轮换的实战要点

Spring Security 默认行为 :NimbusJwtDecoder 内部使用 Nimbus 的 JWKSource,首次调用会拉取一次 JWKS,之后始终用缓存 ------这意味着如果你在 Auth Server 轮换了密钥,资源服务器不会自动感知 ,会出现 kid 找不到导致的 401 风暴。

生产环境三种解决方案:

方案 A:定期刷新(最简单)

java 复制代码
// Nimbus 提供 RefreshableJWKSource,按时间窗口自动重拉
JWKSetSource<SecurityContext> remote = new RemoteJWKSet<>(url, config);
JWKSource<SecurityContext> refreshable = 
    new RefreshableJWKSource<>(remote, Duration.ofMinutes(30));  // 30分钟刷新一次

方案 B:缓存 TTL + kid 未命中时触发刷新

java 复制代码
// 通过 NimbusJwtDecoder.JwkSetUriJwtDecoderBuilder.cache() 挂接 Caffeine
NimbusJwtDecoder.withJwkSetUri(jwksUri)
    .cache(caffeineCache)  // 配合过期时间
    .build();

方案 C:Auth Server 轮换时保留旧钥 (这是 Auth 侧的责任)

按最佳实践,Auth Server 轮换签名密钥时,JWK Set 中必须同时保留新旧两把公钥,给资源服务器留 1-2 天的缓存刷新窗口,等旧 Token 全部过期后再下线旧钥。

🔍 资源服务器到底校验了什么

一张表看清 JWT 在资源服务器侧经历的全部检查:

校验项 数据来源 失败后果 是否需网络
签名 JWKS 公钥(缓存) InvalidSignatureException → 401 仅首次/轮换
算法一致性 JWT Header alg vs 配置 拒绝解析 → 401 否
exp 过期 JWT Payload JwtValidationException → 401 否
nbf 生效时间 JWT Payload 同上 → 401 否
iss 签发者 JWT Payload vs 配置 校验失败 → 401 否
aud 受众 JWT Payload vs 配置 校验失败 → 401 否
scope/authorities JWT Payload → GrantedAuthority 403(认证通过但无权限) 否
Token 吊销(可选) Auth /oauth2/introspect 401 ✅ 每次请求(代价大)
注意最后一行 :JWT 默认不检查吊销状态 ,签名有效期内即使 Auth Server 已"拉黑"该 Token,资源服务器也会放行。如果业务要求实时吊销,需要改用不透明令牌(Opaque Token)+ introspection 端点 模式------那种模式下资源服务器每次请求都要调 Auth 接口,这是与 JWT 的根本取舍。

💡 回到本项目

本项目中 Admin API 作为资源服务器的接入方式:

yaml 复制代码
# admin-api 的 application.yml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.example.com
  • 首次 请求到达时,自动请求 https://auth.example.com/.well-known/openid-configuration,发现 jwks_uri
  • 首次 从 /oauth2/jwks 拉取 Gateway 令牌对应的 RSA 公钥,缓存到本地
  • 后续 所有请求:Gateway 透传来的 Authorization: Bearer xxx 在本地完成签名校验 + claims 校验,性能开销可忽略
  • Auth Server 重启/密钥轮换 时:只要 JWK Set 里保留了旧钥,已缓存公钥继续有效,无感知切换;否则需重启资源服务或配置定时刷新
    一句话总结:资源服务器的"验签"本质上是"本地用缓存的 Auth 公钥验签名 + 本地校验 claims",调用 Auth 接口只是首次发现端点和拉取公钥时的初始化动作,绝非常规路径。
相关推荐
hljqwb36 分钟前
java服务异常日志只打印异常类型,没有堆栈定位分析(十)
java·开发语言
云贝贝贝40 分钟前
【无标题】TDSQL 分片键怎么选?选错等于数据倾斜加跨分片慢查询
运维·服务器·数据库·腾讯云
ym hyd 11140 分钟前
生鲜超市库存管理信息系统源码 Java+SpringBoot+Vue3 前后分离
java·开发语言·vue.js·spring boot·毕设
Rain的Java大神之路1 小时前
别再瞎装 RabbitMQ 了!从 0 到 1 部署到 Spring Boot 全链路实战,生产级坑全填平
java·后端·面试
浪子明X1 小时前
AI 日报自动化:先把事实、时间和引用做成可检查的编辑流水线
运维·人工智能·自动化
hzxpaipai1 小时前
AI把网站页面做出来了,谁来负责后台、服务器和正式上线?
运维·服务器·人工智能
liyunlong-java1 小时前
敏感词过滤完整指南(DFA 字典树)
java·开发语言·spring
专业程序开发源1 小时前
SSM笔记本在线销售系统32649-计算机课程设计、毕业设计
java·spring boot·后端·python·django·php·课程设计