Spring Boot 4 迁移实战:Spring Security 7 服务端 DPoP 与 Token Exchange 落地

概述

随着 OAuth 2.1 成为行业推荐标准,Spring Security 在 7.x 正式提供两大核心能力:

  1. DPoP(RFC 9449):把访问令牌绑定到客户端持有的非对称密钥,防止令牌泄露后被滥用。
  2. Token Exchange(RFC 8693):标准化的服务间委托(impersonation / delegation)链路,避免自行拼装凭证。

本文基于 Spring Security 7.1.1(对应 Spring Boot 4.1)展开,涵盖协议细节、Spring 实现细节、以及从 1.5.x 迁移至 7.x 的实战步骤。

适合读者:国内正在升级到 Spring Boot 4 的团队、需要实现零信任 API Gateway、或已经在使用 Spring Authorization Server 的安全架构师。

整合:Spring Authorization Server 并入 Spring Security 7

2025‑09‑11,Spring 宣布自 2020 年独立、2022‑11 GA 的 Spring Authorization Server 并入 Spring Security 7.0;1.5.x 是最后一个在独立项目内继续开发的分支。当前坐标为 org.springframework.security:spring-security-oauth2-authorization-server:7.1.1,groupId / artifactId 均未变化,只有版本号从 1.5.x 走到 7.x。Spring Security 7.1.0 发布于 2026‑06(配 Spring Boot 4.1),7.1.1 发布于 2026‑08。

图 1:Spring Boot 4 整合——Spring Authorization Server 并入 Spring Security 7.x

图 1 展示这次整合:独立的 Spring Authorization Server(1.5.x,最后一代)折进 Spring Security 7.x,同一套 Maven 坐标下同时提供 DPoP 与 Token Exchange。


DPoP 工作原理

什么是 DPoP?

DPoP(Demonstrating Proof of Possession)是 RFC 9449 定义的机制:客户端在每次请求时携带一个短生命期的 proof JWT,该 JWT 使用客户端自己的非对称私钥签名。授权服务器校验 proof 后,将访问令牌与证明中的公钥指纹(cnf.jkt)绑定。资源服务器在验证令牌时,同时检查随令牌附带的 proof 是否与绑定的公钥匹配。

核心价值:即使令牌泄露,攻击者没有对应私钥仍无法使用——实现“发钥即有”的密钥拥有证明。

Proof 结构(RFC 9449 §4.2)

  • typ: dpop+jwt
  • jwk:客户端公钥
  • htm、htu、iat、jti:绑定请求方法、URI、时间戳、唯一 ID
  • 当携带访问令牌时,还携带该令牌的 SHA‑256 哈希(ath)

图 2:服务端 DPoP 闭环(授权服务器绑定密钥,资源服务器校验 proof)

图 2 是服务端 DPoP 闭环:授权服务器用 cnf.jkt 把令牌绑定到客户端公钥,资源服务器在没有匹配 proof 时直接拒绝该令牌。

Spring Security 对 DPoP 的支持

  • 授权服务器:公共客户端的 refresh token 自 1.5.0‑RC1(2025‑04) 起由 Spring 自动与该密钥绑定,并沿用到 7.x,没有开关可关;机密客户端仍依赖原有的 client 绑定。
  • 资源服务器:DPoP 绑定的访问令牌校验在 6.5.0(2025‑05)GA;7.0 在 classpath 出现 DPoP 类时自动生效,7.1 起必须显式调用 .dPoP()(依赖 Spring Boot 默认资源服务器自动配置的应用同样从未调用过该钩子),否则会静默停止校验。

关键实现点:

步骤 Spring 组件 作用
Proof 校验 DPoPProofVerifier + DPoPProofJwtDecoderFactory 验证 JWT 签名、typ、算法、htm/htu 匹配
Replay 防护 DPoPProofReplayValidator(默认 10 万条目 InMemoryCache) 防止 proof 重放
Token 绑定 自动在访问令牌中加入 cnf.jkt 与 proof 公钥指纹对应
资源服务器 .dPoP(Customizer.withDefaults()) 启用 DPoP 验证(7.1+ 必显式调用)

注意:7.0.x / 7.1.0 的 replay‑cache 是 DPoPProofJwtDecoderFactory 内写死的 1 000 条内存缓存,无法替换;自 6.5.12 / 7.1.1 起改为可插拔的 DPoPProofReplayValidator,默认自带 10 万条目(按 key 设上限)的 InMemoryCache,但其 Javadoc 明确写着”仅适用于单节点”,集群部署需要自行提供 Redis / Hazelcast 等分布式缓存。

Spring 7.1.1 仍缺的一环:nonce 挑战

RFC 9449 §8 允许授权服务器在 proof 中要求一个由服务端生成的 nonce,用于阻止客户端预铸 proof;§9 给资源服务器提供了对应的校验机制。但截至 7.1.1,Spring 两端都没有实现:不下发 DPoP-Nonce 响应头、不处理 use_dpop_nonce 错误(授权服务器返回 400、资源服务器返回 401),也不校验 proof 中可选的 nonce claim。RFC 中它是 SHOULD 而非 MUST,这大概也是它一直没被实现的原因——但如果你的威胁模型需要它,就得自己实现整套机制:签发 nonce、返回挑战响应、存储已签发值、校验 claim。

落地提示:原文明确指出,截至撰文时 Spring Security 的 issue tracker 中没有相关的开放特性请求,需要 nonce 的身份提供方应当按”自建”来规划,而不是等下一个版本。


Token Exchange(RFC 8693)

为什么需要 Token Exchange?

在微服务或 API‑Gateway 场景下,服务 A 需要以 用户 身份调用 服务 B,但又不想直接把用户的令牌转发(防止令牌泄露给下游)。传统做法是自行拼接新凭证或使用自定义协议,缺少统一标准。

RFC 8693 引入两种语义:

语义 说明 实现方式
Impersonation 只保留 sub(被代理者) 只提交 subject_token
Delegation 同时保留 sub + act(委托者) 同时提交 subject_token 与 actor_token

Spring Security 通过 OAuth2TokenExchangeAuthenticationProvider 处理,生成的令牌自动携带 act( delegation)或仅保留 sub(impersonation)。

示例:Edge‑Service 委托调用 Orders 服务

POST /oauth2/token
Content-Type: application/x-www-form-urlencoded
DPoP: eyJ...

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=eyJ...   # 用户的访问令牌
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&actor_token=eyJ...     # Edge‑Service 的令牌
&actor_token_type=urn:ietf:params:oauth:token-type:access_token
&audience=https://orders.internal
&scope=orders:read

返回的令牌示例:

{
  "sub": "user-123",
  "aud": "https://orders.internal",
  "scope": "orders:read",
  "act": {
    "sub": "edge-service",
    "act": { "sub": "gateway-service" }   # 多跳委托链
  }
}

图 3:DPoP-bound token 被交换为收窄并重新绑定的下游令牌

图 3 展示组合模式:客户端先持有 DPoP‑bound token,Edge‑Service 再对该 token 做 exchange,授权服务器签发收窄后的新令牌(scope 收缩、audience 切换、actor 记入 act claim),并通过 cnf.jkt 把新令牌重新绑定到 Edge‑Service 的密钥上——每一跳都是 sender‑constrained。

Spring 对 Token Exchange 的默认行为

项目 默认值 可自定义
授权服务器 默认关闭(需在 RegisteredClient 中开启 TOKEN_EXCHANGE) 通过 authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE)
作用域收窄 保留原令牌的全部 scope 通过 OAuth2TokenCustomizer<JwtEncodingContext> 裁剪
作用域收窄 默认不收窄(框架不会自动裁剪 scope 或切换 audience) 通过 OAuth2TokenCustomizer<JwtEncodingContext> 自行裁剪
审计(act) 自动生成 由 actor_token 派生,不建议手工拼装
> 安全提醒:Token Exchange 必须 收窄 而非放宽范围,否则会产生特权提升漏洞。原文强调:Spring 只提供 grant 与 customizer 钩子,不会阻止你写出放大 scope 的实现——OAuth2TokenCustomizer 应当作为安全关键代码补上单测。

迁移实战:从 1.5.x 到 7.x

步骤 操作 关键点
1. 坐标升级 spring-security-oauth2-authorization-server 从 1.5.x → 7.1.x 保持 groupId、artifactId 不变
2. DSL 迁移 and() 风格 → lambda DSL 兼容旧链路的 HttpSecurity 需要改写
1b. Starter 更名 spring-boot-starter-oauth2-authorization-server → spring-boot-starter-security-oauth2-authorization-server 旧 starter 标记废弃,两个坐标在 4.1.1 前仍同时发布
3. JWT Decoder 核对 validateTypes(false) 与 JwtTypeValidator 默认值 7.0 起默认开启类型校验;at+jwt(RFC 9068)等非 JWT 类型的令牌需自行注册校验器
4. Jackson 模块 SecurityJackson2Modules → SecurityJacksonModules 持久化授权状态需升级
5. 开启 DPoP 在资源服务器显式调用 .dPoP()(7.1+) 否则 7.1 默认不校验
6. Token Exchange 在 RegisteredClient 中加入 TOKEN_EXCHANGE 按业务需求开启
7. 认证方案 DPoP 绑定的令牌走 DPoP 认证方案,不能按 Bearer 发送 这是常见且错误的捷径,会被资源服务器直接拒绝

迁移成本:原文口径是基线迁移约 1 个 Sprint(且是在启用任何新 OAuth 2.1 特性之前);版本落后较多的团队应把”基线迁移”和”特性接入”拆成两件事来做。


关键数据回顾

  • replay‑cache:自 6.5.12 / 7.1.1 起为可插拔的 DPoPProofReplayValidator,默认 InMemoryCache 10 万条目(按 key 设上限,仅单节点有效);此前 7.0.x / 7.1.0 为 1 000 条硬编码;
  • 迁移耗时:基线迁移约 1 个 Sprint(原文本就是”约一个 sprint”,且不含新特性启用);
  • .dPoP():在 Spring Security 7.1 必须显式调用,否则 DPoP 验证静默失效;
  • nonce 缺口:RFC 9449 §8 的 nonce 挑战在 7.1.1 中完全未实现(不下发 DPoP-Nonce 头、不处理 use_dpop_nonce、不校验 nonce claim),需要时只能自建;
  • Token Exchange:默认 impersonation, delegation 需提供 actor_token。

参考链接

国内落地提示:在迁移至 Spring Boot 4 与 Spring Security 7 时,建议先在 UAT 环境开启 双跑(同时支持 Bearer 与 DPoP),待全部客户端迁移后再下线旧模式。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注