一家公司里,订单服务背后站着三个内部系统:物流系统查订单安排发货,报表系统拉数据做统计,对账系统定时核对金额。最早的接法是三方约定一个共享密码,每个系统调接口时都带上它。我接手这种系统时最怕的就是换密码:三个系统的配置、两份脚本、一个定时任务挨个改,改漏一个就出一批 401 告警。共享密码的要害在于「一台机器一个身份、一种权限」没有任何地方可以表达,全部调用方共用同一份凭据。OAuth2 的 client_credentials 授权模式解决的正是这件事:每个系统一个 client,一个 client 一组 scope,凭据过期自动换,接口侧只认 JWT 令牌本身。
这篇是我用 Spring Boot 4.1.1 把订单服务改造成 OAuth2 资源服务器的全程记录:RSA 密钥本地生成,JwtDecoder 校验 Bearer 令牌,scope claim 映射成 authority 后交给 @PreAuthorize 收敛,JwtEncoder 自签发 JWT 供演示与测试,oauth2-client 侧用 RestClient 挂 AuthorizedClientManager 走完整的 client_credentials 链路。12 个测试全绿,正文里的数字全部来自同一轮实测,我留了底。
编程模型与自动配置的事实
写代码之前我习惯先把坐标、自动配置与属性翻清楚,以下全部来自 Central 制品解包与 javap 实测,没有一条是我背出来的。
本篇资源服务器侧的 starter 坐标是 spring-boot-starter-security-oauth2-resource-server,组成(POM 实测):
1
2
3
4
5
6
spring-boot-starter-security-oauth2-resource-server
= spring-boot-starter + spring-boot-starter-security
+ spring-boot-security-oauth2-resource-server
spring-boot-security-oauth2-resource-server(自动配置模块,Boot 4 独立拆分)
spring-security-oauth2-jose 7.1.1 = spring-security-core
+ com.nimbusds:nimbus-jose-jwt 10.9.1
客户端侧的 spring-boot-starter-security-oauth2-client 组成多一个 spring-security-oauth2-client,其余相同。JWT 的编解码能力全部经 jose 模块进来,nimbus-jose-jwt 由 BOM 管版本,工程里不用写版本号,升级时能省一半心。
Boot 4 模块化拆分后,资源服务器自动配置在 spring-boot-security-oauth2-resource-server 模块,包名 org.springframework.boot.security.oauth2.server.resource.autoconfigure,AutoConfiguration.imports 共 4 条,Servlet 工程直接相关的是前两条:
1
2
3
4
org.springframework.boot.security.oauth2.server.resource.autoconfigure.OAuth2ResourceServerAutoConfiguration
org.springframework.boot.security.oauth2.server.resource.autoconfigure.web.OAuth2ResourceServerWebSecurityAutoConfiguration
org.springframework.boot.security.oauth2.server.resource.autoconfigure.reactive.ReactiveOAuth2ResourceServerAutoConfiguration
org.springframework.boot.security.oauth2.server.resource.autoconfigure.web.reactive.ReactiveOAuth2ResourceServerWebSecurityAutoConfiguration
JwtDecoder 的装配路线用 javap 解出来有三条,JwtDecoderConfiguration 按属性决定造哪一种:
1
2
3
jwtDecoderByPublicKeyValue() 属性 public-key-location PEM 公钥文件
jwtDecoderByJwkKeySetUri() 属性 jwk-set-uri 远端 JWKS 端点
jwtDecoderByIssuerUri() 属性 issuer-uri 惰性(SupplierJwtDecoder 包装,首次请求才拉配置)
三条路线互斥,配了哪个属性走哪条。配 audiences 后自动挂一个 JwtClaimValidator 做受众校验,令牌的 aud claim 对不上直接 401。spring.security.oauth2.resourceserver.jwt.* 前缀下的属性共 10 条(spring-configuration-metadata 实证):
1
2
3
4
5
6
7
8
9
10
jwk-set-uri jwk-set-uri 路线的 JWKS 地址
issuer-uri issuer-uri 路线的签发方元数据地址
public-key-location PEM 公钥,类型是 org.springframework.core.io.Resource
audiences List,aud 校验的合法值
jws-algorithms 默认 [RS256]
authority-prefix 权限前缀,默认 SCOPE_
authorities-claim-name 权限来源 claim,默认 scope
authorities-claim-delimiter claim 为字符串时的分隔符
authorities-claim-expressions List,SpEL 表达式路线
principal-claim-name 主名来源 claim,默认 sub
JWT 核心类的位置我在 jose jar 里逐个确认过:JwtDecoder、JwtEncoder、JwtEncoderParameters、JwsHeader 都在 org.springframework.security.oauth2.jwt 包。NimbusJwtEncoder.withKeyPair(pub, priv) 返回 RSA 编码器构造器;JwsHeader.with(algorithm) 是 Security 7 自有的头抽象,keyId()、type() 链式设置。scope 到权限的映射走默认 converter:scope claim 的每个值变成一条 SCOPE_ 前缀的 authority,hasAuthority('SCOPE_orders:read') 直接可比。
测试侧两个工具(spring-security-test 7.1.1 unzip 实证):SecurityMockMvcRequestPostProcessors.jwt() 在 org.springframework.security.test.web.servlet.request 包,构造 MockMvc 用的 JwtAuthenticationToken;reactive 的对应物 SecurityMockServerConfigurers.jwt() 在 org.springframework.security.test.web.reactive.server 包。
依赖和配置
工程我摆了三块依赖:starter-webmvc 撑 HTTP 接口面,starter-security-oauth2-resource-server 是校验侧主角,starter-security-oauth2-client 供机器对机器调用演示:
1
2
3
4
5
6
7
8
9
10
11
12
<dependency>
<groupId> org.springframework.boot</groupId>
<artifactId> spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId> org.springframework.boot</groupId>
<artifactId> spring-boot-starter-security-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId> org.springframework.boot</groupId>
<artifactId> spring-boot-starter-security-oauth2-client</artifactId>
</dependency>
测试侧两个 starter:security-test 提供 jwt() 请求后处理器,webmvc-test 提供 @AutoConfigureMockMvc 本体,缺一不可,只引一个就是编译错误:
1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId> org.springframework.boot</groupId>
<artifactId> spring-boot-starter-security-test</artifactId>
<scope> test</scope>
</dependency>
<dependency>
<groupId> org.springframework.boot</groupId>
<artifactId> spring-boot-starter-webmvc-test</artifactId>
<scope> test</scope>
</dependency>
RSA 密钥对我放在本地生成。一个工具类用 nimbus 的 RSAKeyGenerator 生成 2048 位密钥,落两个文件:全量 JWK(含私钥参数)给编码器,公钥 PEM 给资源服务器校验:
1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main ( String [] args ) throws Exception {
Path outDir = Path . of ( args . length > 0 ? args [ 0 ] : "src/main/resources/jwk" );
RSAKey jwk = new RSAKeyGenerator ( 2048 )
. keyUse ( KeyUse . SIGNATURE )
. keyID ( "order-" + Long . toHexString ( System . currentTimeMillis ()))
. algorithm ( JWSAlgorithm . RS256 )
. generate ();
Files . createDirectories ( outDir );
Files . writeString ( outDir . resolve ( "rs256.json" ), jwk . toJSONString ());
Files . writeString ( outDir . resolve ( "public.pem" ), toPem ( jwk . toRSAPublicKey (). getEncoded ()));
System . out . println ( "RSA 密钥对已生成 kid=" + jwk . getKeyID () + " -> " + outDir . toAbsolutePath ());
}
生成一次提交进仓库即可,rs256.json 供运行期加载,public.pem 是 X.509 SubjectPublicKeyInfo 格式的 PEM 文本。要换密钥就重跑一次 main,新令牌带新 kid,旧令牌在校验侧立刻失效,我把它当作轮换密钥的最小做法。
application.yml 是全篇配置密度最高的一段,资源服务器两行,客户端注册一组:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
spring :
security :
oauth2 :
resourceserver :
jwt :
public-key-location : classpath:jwk/public.pem
audiences : order-service
client :
provider :
order-service :
token-uri : http://localhost:${SERVER_PORT:18190}/oauth2/token
registration :
logistics :
provider : order-service
client-id : logistics
client-secret : logistics-secret-2026
authorization-grant-type : client_credentials
scope : orders:read
reports :
provider : order-service
client-id : reports
client-secret : reports-secret-2026
authorization-grant-type : client_credentials
scope : orders:read,orders:write
资源服务器侧我只配了 public-key-location 与 audiences 两项,Boot 的 JwtDecoderConfiguration 据此走 PublicKey 路线装配解码器并挂上受众校验,工程里不需要声明 JwtDecoder bean。客户端侧声明了 provider 与两个 registration:logistics 只有 orders:read,reports 有 read 加 write,正好对应「查单」与「改单」两种机器身份。
核心代码
过滤器链:oauth2ResourceServer 一处声明
一个 SecurityConfig 类装下会话策略、路径授权与 Bearer 认证出口:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain ( HttpSecurity http , BearerProblemWriter problems ) throws Exception {
http
// 纯 API + STATELESS 会话:没有浏览器表单、没有 Cookie 会话,
// CSRF 保护没有作用对象
. csrf ( AbstractHttpConfigurer :: disable )
. sessionManagement ( s -> s . sessionCreationPolicy ( SessionCreationPolicy . STATELESS ))
. authorizeHttpRequests ( auth -> auth
// 演示令牌端点自带 client_id/client_secret 校验,不放进 Bearer 体系
. requestMatchers ( "/oauth2/token" ). permitAll ()
. anyRequest (). authenticated ())
. oauth2ResourceServer ( rs -> rs
. jwt ( Customizer . withDefaults ())
. authenticationEntryPoint ( problems :: unauthorized )
. accessDeniedHandler ( problems :: forbidden ))
. exceptionHandling ( e -> e
. authenticationEntryPoint ( problems :: unauthorized )
. accessDeniedHandler ( problems :: forbidden ));
return http . build ();
}
}
.jwt(Customizer.withDefaults()) 意思是用容器里自动装配的 JwtDecoder 与 converter,什么都不覆盖。令牌解析失败(过期、签名不对、aud 不符)落 401 出口,认证通过但 scope 不够落 403 出口,两个出口都指向 BearerProblemWriter 的 problem+json。401 响应头按 RFC 6750 带 WWW-Authenticate: Bearer,令牌校验失败时追加 error="invalid_token",调用方靠这个头就能区分「没带令牌」与「带了但无效」。
路径级规则我只写了两条:机器调用方没有角色概念,权限全部收敛到方法级注解上做,下一节展开。
编码器:JwtEncoder 从 JWK 文件造出
校验侧用自动装配,签发侧得自己声明。classpath 的 JWK 文件加载成一个 Provider,编码器从密钥对构造:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Component
public class SigningKeyProvider {
private final RSAKey jwk ;
public SigningKeyProvider () {
try {
String json = new ClassPathResource ( "jwk/rs256.json" )
. getContentAsString ( StandardCharsets . UTF_8 );
this . jwk = RSAKey . parse ( json );
} catch ( IOException e ) {
throw new UncheckedIOException ( "读取 jwk/rs256.json 失败,先跑 SigningKeys 的 main 生成密钥" , e );
} catch ( ParseException e ) {
throw new IllegalStateException ( "jwk/rs256.json 不是合法的 RSA JWK" , e );
}
}
public String keyId () { return jwk . getKeyID (); }
public RSAPublicKey publicKey () { ... }
public RSAPrivateKey privateKey () { ... }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class TokenEncoderConfig {
@Bean
JwtEncoder jwtEncoder ( SigningKeyProvider keys ) {
return NimbusJwtEncoder . withKeyPair ( keys . publicKey (), keys . privateKey ())
. algorithm ( SignatureAlgorithm . RS256 )
// withKeyPair 构造的内部 JWK 默认没有 kid,而签出的令牌头里带 kid,
// 编码器按键选择 JWK,两边不一致会报 Failed to select a JWK signing key
. jwkPostProcessor ( jwk -> jwk . keyID ( keys . keyId ()))
. build ();
}
}
jwkPostProcessor 这一行是我实测踩出来的:令牌头里写了 kid,编码器按头里的 kid 去密钥源里选钥匙,withKeyPair 生成的内部 JWK 恰好没有 kid,选不中就抛 Failed to select a JWK signing key。把 kid 补进 JWK,两边对齐,问题当场消失。
自签发:claim 结构与生产一致
TokenService 负责把 subject、scope、audience、有效期组装成令牌,演示端点与测试都从这里拿令牌:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public Jwt issue ( String subject , List < String > scopes , String audience , Duration ttl ) {
Instant now = Instant . now ();
JwtClaimsSet . Builder claims = JwtClaimsSet . builder ()
. issuer ( "http://order-service.internal" )
. subject ( subject )
. issuedAt ( now )
. expiresAt ( now . plus ( ttl ))
. id ( UUID . randomUUID (). toString ())
. claim ( "scope" , scopes );
if ( audience != null && ! audience . isBlank ()) {
claims . audience ( List . of ( audience ));
}
JwsHeader header = JwsHeader . with ( SignatureAlgorithm . RS256 )
. keyId ( keys . keyId ())
. type ( "JWT" )
. build ();
Jwt jwt = encoder . encode ( JwtEncoderParameters . from ( header , claims . build ()));
log . info ( "JWT_EVENT issued subject={} scope={} expiresAt={}" ,
subject , String . join ( "," , scopes ), expiresAt );
return jwt ;
}
scope 我直接用 List,校验侧的默认 converter 对空格分隔字符串与 List 两种形态都认,List 在解码后更直观。audience 允许传 null,签出的令牌不带 aud claim,专门用来验证受众校验。真实系统的签发方是独立授权服务器,本工程签发与校验共用同一对密钥做完整演示,claim 结构与生产一致,换到真实 IdP 时只换签发方,校验侧代码一行不动。
scope 映射与 @PreAuthorize
scope claim 变成 authority 之后,敏感操作就能在服务层方法上收敛了:
1
2
3
4
5
6
7
8
9
10
11
12
@PreAuthorize ( "hasAuthority('SCOPE_orders:read')" )
public List < Order > list () {
return store . all ();
}
@PreAuthorize ( "hasAuthority('SCOPE_orders:write')" )
public Order create ( String orderNo , BigDecimal amount ) {
Order order = store . create ( orderNo , amount , currentUser ());
log . info ( "ORDER_EVENT created orderNo={} by={} count={}" ,
order . orderNo (), order . createdBy (), store . all (). size ());
return order ;
}
SCOPE_ 前缀来自默认 converter,表达式里的前缀与 application.yml 的 authority-prefix(默认值就是 SCOPE_)是同一个口径,改一边必须改另一边。currentUser() 取的是认证主名,来源是 sub claim,所以建单记录里的操作人是 reports 这样的机器身份,没有人工账号混在里面。@EnableMethodSecurity 要贴在 SecurityConfig 上,少了它所有 @PreAuthorize 静默失效,我第一次联调就栽在这。
演示令牌端点:client_credentials 语义
真实部署里 POST /oauth2/token 属于授权服务器。本工程为了自签环回,我把这个端点做进了订单服务,校验 client_id 与 client_secret 后签发 JWT:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@PostMapping ( consumes = MediaType . APPLICATION_FORM_URLENCODED_VALUE )
public ResponseEntity <?> token ( @RequestParam Map < String , String > form ,
HttpServletRequest request ) {
if ( ! "client_credentials" . equals ( form . get ( "grant_type" ))) {
return problem ( HttpStatus . BAD_REQUEST , "unsupported_grant_type" ,
"grant_type 只支持 client_credentials" );
}
String [] basic = basicCredentials ( request );
String clientId = basic != null ? basic [ 0 ] : form . get ( "client_id" );
String clientSecret = basic != null ? basic [ 1 ] : form . get ( "client_secret" );
ClientEntry entry = clientId == null ? null : CLIENTS . get ( clientId );
// MessageDigest.isEqual 做常量时间比较,避免逐字符比较泄露时序信息
if ( entry == null || clientSecret == null
|| ! MessageDigest . isEqual ( entry . secret (). getBytes ( StandardCharsets . UTF_8 ),
clientSecret . getBytes ( StandardCharsets . UTF_8 ))) {
return problem ( HttpStatus . UNAUTHORIZED , "invalid_client" , "client_id 或 client_secret 不正确" );
}
List < String > requested = form . containsKey ( "scope" )
? List . of ( form . get ( "scope" ). trim (). split ( "\\s+" ))
: entry . scopes ();
if ( ! entry . scopes (). containsAll ( requested )) {
return problem ( HttpStatus . BAD_REQUEST , "invalid_scope" ,
"请求的 scope 超出该客户端的注册范围" );
}
Jwt jwt = tokens . issue ( clientId , requested , TokenService . AUDIENCE , Duration . ofSeconds ( ttl ));
return ResponseEntity . ok ( Map . of (
"access_token" , jwt . getTokenValue (),
"token_type" , "Bearer" ,
"expires_in" , ttl ,
"scope" , String . join ( " " , requested )));
}
客户端认证支持 HTTP Basic 与表单字段两种来源,oauth2-client 默认发 Basic,curl 演示也顺手。secret 比较我用 MessageDigest.isEqual,常量时间完成,不给逐位猜测留时序线索。请求的 scope 是注册 scope 的子集才放行,logistics 想要 orders:write 会被 invalid_scope 挡回来。
oauth2-client:RestClient 挂 AuthorizedClientManager
机器调用方这一侧,RestClient 的拦截器每次请求前向 AuthorizedClientManager 要令牌,缓存没有或临近过期时管理器自动走 client_credentials 换新的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Bean
OAuth2AuthorizedClientManager authorizedClientManager ( ClientRegistrationRepository registrations ) {
OAuth2AuthorizedClientProvider provider = OAuth2AuthorizedClientProviderBuilder . builder ()
. clientCredentials ()
. build ();
AuthorizedClientServiceOAuth2AuthorizedClientManager manager =
new AuthorizedClientServiceOAuth2AuthorizedClientManager (
registrations , new InMemoryOAuth2AuthorizedClientService ( registrations ));
manager . setAuthorizedClientProvider ( provider );
return manager ;
}
@Bean
RestClient logisticsRestClient ( OAuth2AuthorizedClientManager manager ,
@Value ( "${app.base-url:http://localhost:18190}" ) String baseUrl ) {
return RestClient . builder ()
. baseUrl ( baseUrl )
. requestInterceptor ( new BearerInterceptor ( manager , "logistics" , "logistics-cron" ))
. build ();
}
BearerInterceptor 的核心逻辑三行:
1
2
3
4
5
6
7
8
OAuth2AuthorizedClient client = manager . authorize ( OAuth2AuthorizeRequest
. withClientRegistrationId ( registrationId )
. principal ( principal )
. build ());
if ( client == null ) {
throw new IllegalStateException ( "未取得 client credentials 令牌: " + registrationId );
}
request . getHeaders (). setBearerAuth ( client . getAccessToken (). getTokenValue ());
registration 的 client-id、client-secret、token-uri 都来自 application.yml,principal 在 client_credentials 场景只是个缓存键。启动时带 m2m-demo profile 跑一个 ApplicationRunner,logisticsRestClient 真打一次受保护接口,日志落 M2M_CALL 行,就是客户端链路通了的对账依据。
完整案例
一轮实测的场景与结果都在这里。先换令牌,reports 客户端用 Basic 认证发 client_credentials 请求:
1
2
POST /oauth2/token reports + 正确密钥 -> 200 签发耗时 12.6ms,令牌长 675 字符
POST /oauth2/token reports + 错误密钥 -> 401 invalid_client
access_token 响应体逐字如下,expires_in 600 秒:
拿这个令牌打订单接口,三种结果各有归属:
1
2
3
4
GET /api/orders reports 令牌(read+write) -> 200 往返 25.4ms
POST /api/orders reports 令牌(read+write) -> 201 createdBy=reports
POST /api/orders logistics 令牌(只有 read) -> 403 scope 不足
GET /api/orders 无令牌 -> 401 WWW-Authenticate: Bearer
201 与 403 的响应体逐字如下,问题类别用 type 区分。401 与 403 的分界在令牌:拿不出有效令牌是 401,令牌有效但 scope 不够才是 403。
过期与篡改两个失败分支我也各实测了一次,这里有个挺有意思的反直觉结果,单独说:ttl_seconds=1 的令牌过期 1.5 秒后请求仍是 200,我等到 62 秒才看到 401,WWW-Authenticate: Bearer realm="order-service", error="invalid_token"。判断也现成:这是校验器给集群时钟误差留的余量,避坑指南坑一细说。签名段首字符替换后请求 401,与过期令牌同一个 problem+json 出口。
令牌的 payload 用 base64 解开,sub、aud、scope、exp 全部实录:
aud 与 application.yml 的 audiences 对得上,scope 两个值对应两条 SCOPE_ authority,exp 减 iat 正好 600 秒,claims 三处交叉验证一致。日志对账我也逐项核过:JWT_EVENT issued 4 行(reports 与 logistics 的换令牌各 1 行、ttl_seconds=1 的过期令牌 1 行、启动环回 1 行),M2M_CALL 1 行 client=logistics status=200 elapsedMs=248,整个运行 ERROR 0 行。
测试怎么写
四个测试类覆盖四种能力,12 个用例全绿:
1
2
3
4
TokenServiceTest 2 编码侧:claim 结构、kid 头、编码器与解码器互解
JwtApiSecurityTest 4 jwt() 后处理器验 scope 映射与 401/403 分工
RealSignedJwtTest 5 真实签名链路:有效、过期、篡改、aud 不符、aud 缺失
ClientCredentialsLoopTest 1 client_credentials 全链路:换令牌、调用、缓存复用
授权规则的测试用 jwt() 请求后处理器,scope claim 直接写进用例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
void read_scope_can_list_but_cannot_create () throws Exception {
var read = jwt (). jwt ( j -> j . subject ( "logistics-cron" )
. claim ( "scope" , List . of ( "orders:read" )));
mockMvc . perform ( get ( "/api/orders" ). with ( read ))
. andExpect ( status (). isOk ());
mockMvc . perform ( post ( "/api/orders" ). with ( read )
. contentType ( "application/json" )
. content ( "{\"orderNo\":\"SK-JWT-1\",\"amount\":99.50}" ))
. andExpect ( status (). isForbidden ())
. andExpect ( jsonPath ( "$.type" ). value ( "urn:problem-type:forbidden" ));
}
jwt() 把 JwtAuthenticationToken 直接放进安全上下文,不经过解码器,默认按 scope claim 生成 SCOPE_ 前缀 authority,与资源服务器的映射行为一致。jwt() 验授权规则,令牌自身的校验链路(过期、签名、受众)必须用签发出的真 JWT 单独测一遍。 所以真实签名链路我单独拆了一类:
1
2
3
4
5
6
7
8
@Test
void wrong_audience_gets_401 () throws Exception {
Jwt other = tokens . issue ( "reports-cron" , List . of ( "orders:read" ),
"other-service" , Duration . ofSeconds ( 600 ));
mockMvc . perform ( get ( "/api/orders" ). header ( "Authorization" , bearer ( other )))
. andExpect ( status (). isUnauthorized ());
}
TokenService 签出的真令牌走完整的 BearerTokenAuthenticationFilter 到 Boot 自动装配的 JwtDecoder,aud 换成 other-service 后被自动挂上的 JwtClaimValidator 拒掉。过期令牌用 TokenService 的另一个重载签发,签发时间与过期时间都放到过去。客户端链路用一个测试类收口:测试里程序化构造 ClientRegistration 指向真实运行的随机端口,AuthorizedClientManager 换真令牌,RestClient 拦截器挂 Bearer 打受保护接口,两次调用断言拿到同一个令牌,缓存复用就得到了验证。
避坑指南
坑一,过期令牌默认还能用 60 秒。 JwtTimestampValidator 的静态初始化字节码是 Duration.of(60, SECONDS),默认时钟偏移容忍。我实测 ttl_seconds=1 的令牌过期 1.5 秒后请求照样 200,越过偏移窗口才 401。这是给集群时钟误差留的余量,生产排障时「过期令牌还能用一分钟」属于预期行为,默认值改不了,Decoder 想收紧要自己换 validator。
坑二,withKeyPair 的编码器报 Failed to select a JWK signing key。 令牌头里写了 kid 时,编码器按键选 JWK,NimbusJwtEncoder.withKeyPair 构造的内部 JWK 默认没有 kid,选不中直接抛这个异常。解法是 jwkPostProcessor(jwk -> jwk.keyID(...)) 把 kid 补进 JWK,或者头里干脆不写 kid。
坑三,自建 token-uri 的 registration 启动即报 Provider ID must be specified。 oauth2-client 的 registration 不指向 GitHub、Google 这类内置供应商时,必须先在 spring.security.oauth2.client.provider.<id> 下声明,再在 registration 里用 provider: <id> 引用。直接在 registration 里写 token-uri 不算声明 provider,启动校验过不去。
坑四,属性名是 public-key-location,不是 public-key。 类型是 org.springframework.core.io.Resource,classpath:jwk/public.pem 直接可用,PEM 文本按 X.509 SubjectPublicKeyInfo 解析。属性名写短了,Boot 当成未知属性静默忽略,第一条受保护请求报 NoSuchBeanDefinitionException,错误信息还不指向配置,我排查时绕了一圈才对上。
坑五,@PreAuthorize 的 SCOPE_ 前缀要与 authority-prefix 对齐。 默认 converter 把 scope claim 的每个值映射成 SCOPE_ 前缀 authority,表达式写 hasAuthority('SCOPE_orders:write')。配置里改了 authority-prefix 而表达式没跟着改,或者反过来,结果都是任何令牌都匹配不上,受保护接口清一色 403。
坑六,编码器签不出 exp 早于 iat 的令牌。 Jwt 构造时断言 expiresAt must be after issuedAt,想测过期令牌,不能只把有效期改成负数,要用支持指定签发时间的重载把 iat 也放到过去。测试里两个时间一起前移,签出的令牌才能走到校验侧的 exp 判断。
坑七,篡改检测要改签名的有效位。 base64url 编码 256 字节签名后,末字符只承载 2 个有效位,低 4 位是填充,替换末字符解出来的字节可能一模一样,校验照样通过。测篡改场景要改签名段首个字符,6 个位全是有效位,必然解码出不同的签名。
坑八,@AutoConfigureMockMvc 在 Boot 4 换了包。 全限定名是 org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc,它随 spring-boot-starter-webmvc-test 进来,security-test starter 不含注解本体。两个测试 starter 都要引,只引一个就是编译错误或注解找不到。
坑九,过滤器层的 401/403 响应体要自己写。 令牌校验发生在 DispatcherServlet 之前,spring.mvc.problemdetails.enabled 与 @RestControllerAdvice 都够不着。自定义 AuthenticationEntryPoint 与 AccessDeniedHandler,401 的响应头带 WWW-Authenticate: Bearer,校验失败追加 error="invalid_token",调用方才拿得到重试需要的信号。
小结
选型建议一句话:调用方全是机器,就用 client_credentials 加资源服务器这套组合,Boot 4.1 里它已经收敛到接近零配置,校验侧两行属性加一个 SecurityConfig,签发侧一个 JwtEncoder 加一个 TokenService。哪天接入独立 IdP,校验侧换成 jwk-set-uri、签发方指过去就行,业务代码与权限注解都不用动。多客户端的权限差异交给 scope 表达,别再让几个系统共用一个密码。
没解决的事也直说:演示令牌端点做在订单服务进程里,授权码、refresh_token 这类交互式授权没有覆盖;令牌吊销同样没做,签出去的令牌在有效期内收不回,目前只能靠密钥轮换兜底。这两块哪天补齐了我再写。
参考链接