fundoit头像
关注

资源服务器如何对 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(推荐,自动发现)

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(直接指定)

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

🔄 完整校验时序

Auth Server JWKSJWK 缓存NimbusJwtDecoderJwtAuthenticationProviderBearerTokenAuthenticationFilter用户请求Auth Server JWKSJWK 缓存NimbusJwtDecoderJwtAuthenticationProviderBearerTokenAuthenticationFilter用户请求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:

@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:定期刷新(最简单)

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

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

// 通过 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 PayloadJwtValidationException → 401否
nbf 生效时间JWT Payload同上 → 401否
iss 签发者JWT Payload vs 配置校验失败 → 401否
aud 受众JWT Payload vs 配置校验失败 → 401否
scope/authoritiesJWT Payload → GrantedAuthority403(认证通过但无权限)否
Token 吊销(可选)Auth /oauth2/introspect401✅ 每次请求(代价大)
注意最后一行:JWT 默认不检查吊销状态,签名有效期内即使 Auth Server 已"拉黑"该 Token,资源服务器也会放行。如果业务要求实时吊销,需要改用不透明令牌(Opaque Token)+ introspection 端点模式——那种模式下资源服务器每次请求都要调 Auth 接口,这是与 JWT 的根本取舍。

💡 回到本项目

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

# 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 接口只是首次发现端点和拉取公钥时的初始化动作,绝非常规路径。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/RadyGo/article/details/167283955

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--