DPoP 详解:为何 Bearer Token 不再安全
目录
什么是 DPoP? 问题所在:Bearer 令牌与“谁捡到就是谁的”风险 DPoP 是如何工作的? 在 Keycloak 中配置 DPoP 使用 Quarkus 实现 DPoP
DPoP 是近年来身份与访问管理(IAM)领域最令人兴奋的发展之一。然而,许多后端开发人员要么没听说过它,要么不确定它到底改变了什么。在本文中,我将剖析什么是 DPoP,它解决了什么问题,并演示一个结合 Keycloak 和 Quarkus 的实际实现。
什么是 DPoP?
DPoP(Demonstration of Proof-of-Possession,持有证明)是 RFC 9449 中定义的一种 OAuth 2.0 安全机制。其核心目的很简单:通过加密方式将访问令牌(Access Token)绑定到请求它的客户端。这样,即使令牌被拦截,其他客户端也无法使用它。
在传统的 Bearer 令牌模型中,任何持有令牌的人都被视为已授权。DPoP 改变了这一模型;要使用令牌,客户端还必须证明其拥有对应的私钥。
问题所在:Bearer 令牌与“谁捡到就是谁的”风险
Bearer 令牌是携带在 HTTP Authorization 请求头中的令牌,服务器在接受时不会对提交者进行任何额外验证。RFC 6750 明确指出,拥有令牌是唯一的授权标准。这意味着任何获得令牌的实体都可以冒充合法客户端行事。
这并非理论上的风险。现实世界的安全漏洞一次又一次地表明,被盗的 Bearer 令牌会直接导致未经授权的访问:
- Codecov 供应链攻击(2021 年): 渗透进 Codecov CI/CD 流程的攻击者窃取了存储在客户环境变量中的令牌。这些令牌可能允许访问数百个组织的私有存储库,包括已确认受影响的 HashiCorp。
- GitHub OAuth 令牌泄露(2022 年): 属于 Heroku 和 Travis CI 的 OAuth 令牌被盗,使攻击者能够列出私有存储库并访问数十个 GitHub 组织(包括 npm)的存储库元数据。
- Microsoft SAS 令牌事件(2023 年): 微软 AI 研究团队不慎在一个 GitHub 存储库中共享了一个权限过大的 SAS 令牌。该令牌导致 38 TB 的内部数据可被访问。
这些事件的共同点在于:令牌被获取后,在不同的上下文中被不同的参与者无缝使用。使之成为可能的原因是 Bearer 令牌模型的核心假设:谁出示令牌 = 谁就是被授权的参与者。 该模型检查的是谁持有令牌,而不是令牌属于谁。
DPoP 是如何工作的?
DPoP 要求客户端在每个请求中发送一个 DPoP 证明(DPoP Proof) JWT。该证明由客户端的私钥签名,并包含以下声明(Claims):
- htm 和 htu(HTTP 方法和 URL): 将证明限制在特定端点,防止为一个资源生成的证明被用于另一个资源。
- jti (JWT ID): 每个证明都有一个唯一 ID。服务器会记录已使用的 jti 值,并拒绝任何试图重用该值的证明。
- iat (Issued At): 指示证明生成的时间,允许服务器强制执行有效窗口并拒绝过期的证明。
- ath (Access Token Hash): 指定证明关联的访问令牌。
流程如下:
在这种模型下,仅窃取令牌是不够的。攻击者没有私钥,无法生成有效的证明,从而将潜在的滥用限制在已捕获但尚未使用的证明及其狭窄的有效期内。与 Bearer 模型相比(被盗令牌在过期前拥有不受限制的访问权限),DPoP 虽然不能消除令牌盗窃,但它使得被盗令牌在根本上更难被利用。
在 Keycloak 中配置 DPoP
在本文中,我使用 Keycloak (v26.5.5) 作为身份提供商。它是开源的、被广泛采用的,并提供了内置的 DPoP 支持,配置非常简单。
DPoP 在 Keycloak 23.0.0 中作为预览功能引入,并于 26.4 版本中正式支持,无需任何额外的客户端配置即可开箱即用。如果客户端在令牌请求期间发送了 DPoP 证明,Keycloak 会对其进行验证,并将密钥指纹包含在签发的令牌中。此默认行为无需额外设置。
但是,如果您想为特定客户端强制执行 DPoP(意味着该客户端的资源将不再接受 Bearer 令牌),请按照以下步骤操作:
第一步: 在 Keycloak 管理控制台中,导航到相关领域(Realm),并从 Clients(客户端) 菜单中选择目标客户端。
第二步: 在 Settings(设置) 选项卡中,找到 Capability config(功能配置) 部分。
第三步: 启用 Require DPoP bound tokens(要求 DPoP 绑定令牌) 开关。
[LOADING...]
启用此选项后,客户端必须在每次令牌请求时包含 DPoP 证明。没有有效证明的请求将被拒绝,且 Bearer 令牌将无法用于访问此客户端的资源。## 在 Quarkus 中应用 DPoP
为了展示 DPoP 的实际应用,我构建了一个带有受保护 REST 端点的 Quarkus 应用程序,并使用 k6 脚本进行了测试。完整的源代码可在 GitHub 上获取。
项目设置
该应用程序使用 Quarkus 3.32.2 以及以下关键扩展:OpenId Connect。Quarkus 为 OpenID Connect 和 OAuth 2.0 访问令牌管理提供了扩展,专注于令牌的获取、刷新和传播。
quarkus.oidc.auth-server-url 属性指定了 OpenID Connect (OIDC) 服务器的基准 URL,在本例中指向 Keycloak 实例:
这里的关键行是 quarkus.oidc.token.authorization-scheme=dpop。该属性告知 Quarkus OIDC 扩展采用 Authorization: DPoP 方案,并执行 RFC 9449 中定义的完整 DPoP 证明验证流程。这包括验证证明的签名、htm、htu、ath 以及令牌与证明公钥之间的 cnf 指纹绑定。
受保护的端点
该应用程序在 /api 路径下公开了三个端点,所有端点都需要身份验证。每个端点通过检查 JWT 中是否存在 cnf 声明,返回调用者的名称和令牌类型(Bearer 或 DPoP):
在 /user-info 上同时设置 GET 和 POST,以及一个单独的 /list-users 端点是有意为之的。这些设置使我们能够演示 DPoP 证明声明(htm 和 htu)如何将令牌的使用限制在特定的 HTTP 方法和 URL 上。
使用 jti 过滤器进行重放保护
如上所述,Quarkus OIDC 扩展处理了 DPoP 的核心验证。然而,jti 重放保护并不在该流程中,因为跟踪已使用的值需要服务端状态,这超出了无状态令牌验证层的范畴。
我添加了一个简单的 @ServerRequestFilter,用于记录每个证明的 jti 并拒绝任何重复使用:
在本示例中,为了简化演示,我使用了内存中的 ConcurrentHashMap。在生产环境中,您应该使用 Redis 或 Infinispan 等分布式存储来跨多个应用程序实例跟踪已使用的 jti 值,并应用与证明有效期一致的基于 TTL 的清理策略。
值得注意的是,Keycloak 已经在授权服务器层面执行了 jti 重放保护。其内部的 DPoPReplayCheck 使用了 SingleUseObjectProvider,该提供程序由 Infinispan 的复制缓存支持。当 DPoP 证明到达令牌端点时,Keycloak 会将 jti 与请求 URI 结合并使用 SHA-1 进行哈希处理,并将其与从证明的 iat 声明中导出的 TTL 一起存储。如果同一个证明再次提交,putIfAbsent 调用将失败,请求也会被拒绝。
然而,这种保护仅涵盖发送到 Keycloak 本身的请求。一旦签发了与 DPoP 绑定的令牌,资源服务器就需要负责其自身的 jti 跟踪。被盗的证明可能会针对 Quarkus 应用程序进行重放,而 Keycloak 对此无法感知。这就是为什么我在资源服务器层添加了 jti 过滤器,从而创建了两层防御:Keycloak 守护令牌端点,而过滤器守护应用程序端点。
使用 k6 进行测试
该代码库包含一个 k6 测试脚本 (k6/dpop-test.js),用于执行完整的 DPoP 流程。运行方式如下:
该脚本按顺序执行七次 HTTP 调用。第一个请求从 Keycloak 获取一个与 DPoP 绑定的令牌,接下来的三次是正常路径请求(每个端点一次),最后三次测试失败场景。让我们深入了解 Keycloak 和 Quarkus 层面的幕后工作:
1. 令牌请求 (Keycloak)
在访问任何资源之前,脚本会请求一个与 DPoP 绑定的访问令牌:
- 脚本使用 WebCrypto API 生成一个 EC 密钥对 (P-256)。
- 它创建一个针对 Keycloak 令牌端点(
htm: POST,htu: .../protocol/openid-connect/token)的 DPoP 证明 JWT,并使用私钥签名。公钥嵌入在证明的jwk头中。 - 它携带
DPoP头和用户凭据 (grant_type=password) 向令牌端点发送POST请求。 - Keycloak 验证 DPoP 证明(签名、结构、声明),然后签发一个访问令牌,其中包含一个带有客户端公钥 SHA-256 指纹的
cnf(确认)声明。这会将令牌绑定到该特定的密钥对。请注意签发令牌中的typ: DPoP和cnf.jkt字段:
2. GET /user-info (正常路径)
- 脚本为
GET /api/user-info创建一个全新的 DPoP 证明,包含一个新的jti、当前的iat,以及一个从访问令牌的 SHA-256 哈希计算得出的ath。证明载荷如下所示:
- 它发送带有
Authorization: DPoP <token>和DPoP: <proof>的GET /api/user-info请求。 - Quarkus jti 过滤器根据已使用 jti 的存储检查证明的
jti。这是一个新的jti,因此请求通过。 - Quarkus OIDC 扩展根据 RFC 9449 (第 7.1 节) 的要求验证 DPoP 证明。它验证证明签名,确认
htm与GET匹配,htu与请求 URL 匹配,ath与令牌哈希匹配,并且令牌中的cnf指纹与证明的公钥匹配。所有检查均通过。 - 端点从令牌中读取
cnf声明,识别出这是一个 DPoP 令牌,并响应:
脚本对 POST /user-info 和 POST /list-users 重复相同的流程,每个请求都附带一个匹配目标方法和 URL 的全新证明。两者均返回 200 并得到相同的响应。
3. GET /user-info (重放攻击)
- 脚本发送在正常路径请求中使用的完全相同的证明。
- Quarkus jti 过滤器检查
jti,发现它已存在于已使用 jti 的存储中。请求在到达 OIDC 验证之前即被拒绝:
注意: 上述错误消息包含
jti值以便演示,方便观察过滤器捕获的内容。在生产环境中,应避免在错误响应中暴露内部声明值。一个不包含主体的通用401 Unauthorized或类似"invalid DPoP proof"的最小消息就足够了,这样可以防止信息泄露。
4. POST /user-info (方法不匹配 - htm)
- 脚本创建一个
htm: GET并针对/api/user-info的新证明,但以POST请求发送。 - Quarkus jti 过滤器放行请求(因为是新的
jti)。 - Quarkus OIDC 扩展比较证明的
htm(GET) 与实际请求方法 (POST)。它们不匹配,请求被拒绝。
5. POST /list-users (URL 不匹配 - htu)
- 脚本创建一个针对
POST /api/user-info的新证明。 - 将请求发送到
POST /api/list-users。 - Quarkus jti 过滤器放行请求。
- Quarkus OIDC 扩展比较证明的
htu与实际请求 URL。它们不匹配,请求被拒绝。
相比之下,如果相同的请求作为普通的 Bearer 令牌发送且没有 DPoP 证明,它们都会以 200 成功返回。重放、方法不匹配和 URL 不匹配的场景将无法被检测到,因为没有需要验证的证明。这正是 DPoP 所弥补的差距。
结论
Bearer 令牌遵循一个简单的规则:持有令牌的人即被授权。DPoP 通过将每个令牌绑定到加密密钥对,并要求在每次请求时提供新鲜的、已签名的证明来改变这一点。仅凭被盗的令牌已不再足够。
IAM 生态系统正朝着这个方向发展。像 Keycloak 这样的身份提供商和像 Quarkus 这样的框架已经提供了内置的 DPoP 支持,使得采用该技术变得简单直接。Bearer 令牌不会消失,但对于访问敏感资源,采用 DPoP 正变得越来越不可或缺。