3、令牌绑定技术
令牌绑定,说白了就是给令牌加一把“物理锁”。
我早年做支付系统的时候,遇到过一件挺头疼的事:用户的 access token 被中间人截获了,攻击者拿着这个 token 就能冒充用户做任何操作。那时候我们用的还是纯 Bearer Token,谁拿到谁就能用。后来我才意识到,问题出在令牌和客户端之间没有绑定关系。
令牌绑定的核心思想很简单:令牌只在特定的安全上下文中有效。这个上下文可以是 TLS 连接、Channel ID,或者通过专门的 Token Binding 协议来建立。一旦令牌离开了它绑定的环境,就变成了一张废纸。
3.1 令牌绑定概念
令牌绑定(Token Binding)是一种安全机制,它把令牌和传输层的某些特征绑定在一起。攻击者即使偷走了令牌,也无法在其他设备或连接上使用它。
我习惯把令牌绑定分成三个层次:
- 弱绑定:令牌和客户端 IP 地址绑定。这招在 NAT 环境下基本失效,我不推荐。
- 中绑定:令牌和 TLS 会话或 Channel ID 绑定。这是目前业界的主流做法。
- 强绑定:通过 Token Binding 协议,在应用层建立加密绑定。这是最安全的方案。
核心原则:令牌的签发和验证必须基于同一个绑定密钥。签发时记录绑定信息,验证时检查绑定信息是否匹配。
3.2 Channel ID 绑定
Channel ID 是 Google 在 OAuth 2.0 中提出的一种绑定方案。它利用 TLS 客户端证书来标识一个安全的通道。
我记得第一次实现 Channel ID 时,踩了个坑:客户端证书的生成和管理是个大麻烦。每个客户端都需要生成自己的密钥对,然后把公钥注册到服务器。这听起来简单,但在移动端做起来特别痛苦。
Channel ID 的工作流程大致如下:
- 客户端生成一个 RSA 密钥对(2048 位以上)。
- 客户端把公钥通过 TLS 握手发送给服务器。
- 服务器计算 Channel ID = SHA256(公钥)。
- 服务器在签发令牌时,把 Channel ID 嵌入令牌中。
- 后续请求中,客户端必须使用对应的私钥签名请求。
- 服务器验证签名,并比对令牌中的 Channel ID 是否匹配。
注意:Channel ID 要求客户端必须支持 TLS 扩展。在老旧浏览器或某些嵌入式设备上,这个方案可能行不通。
3.3 TLS 会话绑定
TLS 会话绑定比 Channel ID 更轻量。它不需要客户端证书,而是利用 TLS 会话的某些特征来建立绑定。
我个人比较喜欢用 TLS 会话 ID 或 TLS 会话票证(Session Ticket)来做绑定。为什么呢?因为这些信息在 TLS 握手中就已经存在了,不需要额外的协议开销。
具体做法是这样的:
- 服务器在 TLS 握手完成后,获取会话 ID 或会话票证。
- 把这个信息作为绑定因子,嵌入到令牌中。
- 每次请求时,服务器检查当前 TLS 会话是否和令牌中的绑定信息一致。
这里有个坑:TLS 会话可能会过期或被重用。我曾经遇到过一个问题:负载均衡器把同一个用户的请求分发到了不同的后端服务器,导致 TLS 会话 ID 不一致,令牌验证失败。解决方案是使用会话票证,并在所有服务器之间共享票证密钥。
| 绑定方式 | 绑定因子 | 优点 | 缺点 |
|---|---|---|---|
| Channel ID | 客户端公钥 | 强安全、不依赖 TLS 实现 | 需要客户端证书管理 |
| TLS 会话 ID | 会话 ID | 实现简单、零额外开销 | 负载均衡场景下容易失效 |
| TLS 会话票证 | 票证内容 | 支持跨服务器共享 | 票证密钥管理复杂 |
3.4 Token Binding 协议
Token Binding 协议是 IETF 的标准方案(RFC 8471、RFC 8472)。它解决了前面两种方案的共同痛点:没有统一的标准。
Token Binding 协议的核心是:在 TLS 握手阶段,客户端和服务器协商一个 Token Binding 密钥。这个密钥由 TLS 的导出密钥材料(Exported Keying Material, EKM)生成。
嗯,这里要注意:Token Binding 协议不是取代 TLS,而是在 TLS 之上增加一层绑定关系。
协议流程如下:
- 客户端在 TLS 握手中发送
token_binding扩展。 - 服务器确认支持 Token Binding,并返回自己的参数。
- 双方基于 TLS EKM 计算出 Token Binding 密钥。
- 客户端使用这个密钥对令牌进行签名。
- 服务器验证签名,确保令牌和当前 TLS 连接绑定。
经验之谈:Token Binding 协议在浏览器环境中支持度还不够好。如果你做的是原生应用或服务端之间的通信,可以考虑使用。Web 场景下,我建议先用 TLS 会话绑定过渡。
Token Binding 协议定义了两种绑定模式:
- Provided Token Binding:客户端提供绑定密钥,服务器验证。
- Referred Token Binding:一个服务把令牌传递给另一个服务,后者验证绑定关系。
第二种模式在微服务架构中特别有用。比如,认证服务签发了令牌,业务服务验证令牌时,可以检查 Token Binding 是否一致。
// Token Binding 验证示例(伪代码)
function validateTokenBinding(token, tlsConnection) {
// 从令牌中提取绑定的密钥标识
const bindingKeyId = token.getBindingKeyId();
// 从 TLS 连接中获取当前的 Token Binding 密钥
const currentKey = tlsConnection.getTokenBindingKey();
// 比对密钥是否一致
if (bindingKeyId !== currentKey.id) {
throw new Error("Token Binding 不匹配");
}
// 验证令牌签名
if (!token.verifySignature(currentKey.publicKey)) {
throw new Error("令牌签名验证失败");
}
return true;
}
关键点:Token Binding 协议把绑定密钥的生成和验证都标准化了。你不需要自己设计密钥派生逻辑,协议已经帮你做好了。
下面这张图展示了三种令牌绑定技术的核心差异和适用场景:
三种技术各有千秋。我个人建议:如果你从零开始搭建系统,优先考虑 Token Binding 协议。如果系统已经上线,想快速加固,TLS 会话绑定是最快的选择。Channel ID 嘛,除非你有强制性的客户端证书管理需求,否则不太推荐。
避坑指南:我曾经在一个项目中同时使用了 Channel ID 和 TLS 会话绑定,结果发现两者互相干扰。记住,绑定方式只能选一种,不要混用。混用会导致验证逻辑复杂化,而且容易引入安全漏洞。