跳转至

10.常见的 OAuth 令牌漏洞

本章将涵盖

  • 什么是 Bearer Token,以及如何安全地生成它
  • 管理使用 Bearer Token 带来的风险
  • 如何安全地保护 Bearer Token
  • 什么是授权码,以及如何安全地处理它

在前面的章节中,我们分析了会影响 OAuth 部署中所有参与方的实现漏洞:客户端、受保护资源以及授权服务器。我们看到的大多数攻击只有一个目的:窃取访问令牌(或用于换取访问令牌的授权码)。本章将进一步深入,探讨如何生成高质量的访问令牌和授权码,以及在处理它们的过程中如何将风险降到最低。我们会看看令牌被盗后会发生什么,并理解为什么与密码被劫持相比,这通常只会造成相对有限的损害。归根结底,OAuth 的出发点,是相较于以密码为中心的世界,提供一种更安全、更灵活的模型。

什么是 Bearer Token?

OAuth 工作组在设计 OAuth 2.0 规范时做出的一个选择,是放弃 OAuth 1.0 规范中原有的自定义签名机制,转而依赖参与方之间的安全传输层机制(例如 TLS)。将签名要求从基础协议中移除后,OAuth 2.0 就能容纳不同类型的令牌。OAuth 规范将 Bearer Token 定义为一种安全凭证,其特性是:任何持有该令牌的一方(即“持有人”,bearer)都可以使用它,而不取决于这方是谁。换句话说,Bearer Token 很像公交代币或游乐园项目的门票:它们只负责授予服务访问权限,并不关心使用者身份。只要你手里有公交代币,就能坐公交。

从技术角度看,你可以把 Bearer Token 与浏览器 Cookie 以类似的方式来理解。二者共享一些基本特性:

  • 使用明文字符串。
  • 不涉及任何密钥或签名。
  • 安全模型的基础是 TLS。

但也存在一些差异:

  • 浏览器在处理 Cookie 方面有很长的经验积累,而 OAuth 客户端则没有。
  • 浏览器会强制执行同源策略,也就是说,一个域的 Cookie 不会被传递到另一个域;但 OAuth 客户端并不具备这一点(这也可能成为问题的来源)。

最初的 OAuth 1.0 协议要求令牌同时绑定一个对应的密钥(secret),并用它对请求计算签名。受保护资源在校验令牌值的同时验证该签名,从而证明调用方同时持有令牌及其关联密钥。事实证明,要把这个签名计算得既正确又一致,对客户端和服务端开发者来说都是沉重负担,而且整个过程极易出现各种令人抓狂的错误。签名计算依赖的因素很多,比如字符串值的编码、请求参数的排序以及 URI 的规范化(canonicalization)。再加上密码学对任何微小差错都毫不宽容,签名不匹配导致系统频繁出问题。

例如,服务端的应用框架可能会注入或重排参数;或者反向代理会让处理 OAuth 的应用拿不到原始请求 URI。两位作者之一曾亲眼见过这样一个案例:某开发者的 OAuth 1.0 实现里,客户端使用大写的十六进制编码(如 %3F、%2D、%3A),而服务端使用小写的十六进制编码(如 %3f、%2d、%3a)。这个特定的实现 Bug 发现起来令人极其恼火。尽管人眼很容易看出它们等价,任何解析十六进制值的机器也能轻松转换,但密码学函数要求两端必须精确一致,签名才能被正确验证。

此外,对 TLS 的要求从未消失。如果在“获取令牌”阶段不使用 TLS,访问令牌及其密钥就可能被窃取;如果在“使用令牌”阶段不使用 TLS,已授权调用的返回结果就可能被窃取(有时还会在时间窗口内被重放)。因此,OAuth 1.0 一直被认为是复杂且难以使用的协议。新的 OAuth 2.0 规范以持有者令牌(bearer token)为核心,构建了一套更简化的协议。消息级签名并没有被彻底抛弃,只是被暂时搁置。随着时间推移,一些 OAuth 2.0 的使用者提出希望扩展协议,引入某种形式的签名;我们将在「bearer 令牌以外的选择」看到一些替代持有者令牌的方案。

使用 Bearer Token 的风险与注意事项

Bearer Token 的特性与浏览器中使用的会话 Cookie 很相似。不幸的是,一旦误解了这种相似性,就会引发各种安全问题。攻击者如果能够截获访问令牌,就可以访问该令牌 scope 所覆盖的全部资源。使用 Bearer Token 的客户端无需证明自己持有任何额外的安全要素,例如密码学密钥材料。除令牌劫持(本书多个章节已做过深入讨论)之外,以下与 OAuth Bearer Token 相关的威胁,在许多其他基于令牌的协议中也很常见:

  • 令牌伪造(Token forgery) 。攻击者可能自行生成伪造令牌,或篡改现有的有效令牌,导致资源服务器向客户端授予不恰当的访问权限。例如,攻击者可以构造一个令牌,以获取原本无权查看的信息。或者,攻击者也可能修改令牌,延长令牌本身的有效期。
  • 令牌重放(Token replay) 。攻击者尝试使用一个过去已经用过且按理应当过期的旧令牌。在这种情况下,资源服务器不应返回任何有效数据,而应返回错误。一个具体场景是:攻击者最初合法获取了访问令牌,但会在令牌过期很久之后试图再次复用它。
  • 令牌重定向(Token redirect) 。攻击者将为某个资源服务器签发、原本仅供其使用的令牌,拿去访问另一个资源服务器,而后者误以为该令牌对自己同样有效。在这种情况下,攻击者先合法获取面向特定资源服务器的访问令牌,然后尝试把该令牌提交给另一台资源服务器。
  • 令牌泄露(Token disclosure)。令牌可能包含系统的敏感信息,从而使攻击者获知原本不可能知道的内容。与前面几种相比,信息泄露通常被视为相对次要的问题,但仍然不容忽视。

以上都是适用于令牌的严重威胁。那么,我们该如何在静态存储与传输过程中保护 Bearer Token?把安全当作事后补救从来行不通,实现者必须在项目早期就做出正确的选择。

如何保护不记名令牌

至关重要的一点是:以 Bearer Token 形式发送的访问令牌,绝不能在不安全的通道上明文传输。按照 OAuth 核心规范的要求,访问令牌在传输过程中必须具备端到端的机密性保护,例如使用 SSL/TLS。那么,SSL/TLS 到底是什么?传输层安全(TLS,Transport Layer Security),前身为安全套接层(SSL,Secure Sockets Layer),是一种密码学协议,旨在为计算机网络上的通信提供安全保障。该协议保护的是两端直接连接的两方之间的传输,其加密过程主要体现在以下几个方面:

  • 连接是私密的,因为使用对称密码对传输的数据进行加密。
  • 连接是可靠的,因为每条传输的消息都会包含基于消息认证码(MAC)的消息完整性校验。

这些通常通过结合公钥密码学的证书机制来实现;尤其是在公网上,发起连接请求的应用会验证接收连接请求一方的证书。在某些有限场景下,也会验证发起连接请求一方的证书,但这种 TLS 连接的双向认证相当受限且并不常见。务必牢记:如果连接中没有 TLS 来保护令牌在传输途中的安全,OAuth Bearer Token 就无法被安全地使用。

TLS 都去哪儿了?

你现在可能已经注意到,在我们所有的实验练习中,完全没有使用 TLS。为什么要这样做?部署一套完善且安全的 TLS 基础设施是个非常复杂的话题,远远超出本书的范围;而要理解 OAuth 的核心工作机制,也并不需要先把 TLS 配好。就像资源所有者的身份认证一样——它同样是构建可用且安全的 OAuth 系统所必需的——在我们的练习中也为了简化而省略了这些内容。但在生产系统中,或任何你在意各组件安全性的部署环境里,正确使用 TLS 都是硬性要求。

请记住:安全软件的实现必须次次都做到正确;而黑客只需要成功一次。

在接下来的章节中,我们将看看 OAuth 的各个组件可以采取哪些措施,来应对与 Bearer Token 相关的威胁。

在客户端侧

本书在多个部分都提到过,访问令牌可能会从客户端应用中被窃取并暴露给攻击者。需要牢记的是,Bearer 访问令牌对客户端而言是“透明”的,客户端不需要执行任何加密操作。因此,一旦攻击者拿到了某个 Bearer 访问令牌,就能够访问该令牌及其 Scope 所关联的全部资源。

客户端可以采取的一项对策,是将令牌的 Scope 限制在完成任务所需的最小范围内。比如,如果客户端实现其目的只需要资源所有者的个人资料信息,那么只请求 profile 这个 Scope 就足够了(而不需要其他 Scope,例如 photo 或 location)。1 这种“最小权限”的做法,可以在令牌被截获时限制它的可用范围。为了尽量减少对用户体验的影响,客户端可以在授权阶段一次性请求所有合适的 Scope,然后使用刷新令牌获取受限 Scope 的访问令牌,直接调用资源。

如果条件允许,将访问令牌保存在临时内存中也会更有利,这样可以将由代码仓库注入引发的攻击降到最低。这样一来,即使攻击者拿到了客户端的数据库,也无法获取任何与访问令牌相关的信息。对于所有类型的客户端而言,这并不总是可行,但把令牌安全地存放起来,避开其他应用程序乃至终端用户的窥探,是每个 OAuth 客户端应用都应该做到的。

在授权服务器端

如果攻击者能够访问授权服务器数据库,或对其发起 SQL 注入,那么多个资源所有者的安全性都可能受到影响。这是因为授权服务器是协调并发放访问令牌的中心节点:它向多个客户端签发令牌,而这些令牌又可能被多个受保护资源使用。在大多数实现中(包括我们目前为止的实现),授权服务器会把访问令牌存储在数据库中。受保护资源在从客户端收到令牌后对其进行校验。校验方式有多种,但通常会对数据发起查询以寻找匹配的令牌。在「OAuth 令牌」,我们将看到一种基于结构化令牌的无状态替代方案:JSON Web Token(JWT)。

作为一种高效的预防措施,授权服务器可以存储访问令牌的哈希值(例如使用 SHA-256),而不是直接存储令牌明文。这样,即便攻击者窃取了包含所有访问令牌的整库数据,也很难利用泄露的信息。尽管在存储用户密码时通常建议加盐,但这里一般不需要额外加盐,因为访问令牌本身就应包含足够的熵,以提高离线字典攻击的难度。举例来说,若令牌为随机值,令牌长度至少应为 128 位,并使用密码学强随机或伪随机序列生成。

此外,建议将访问令牌的有效期设置得更短,以降低单个访问令牌泄露所带来的风险。这样即使令牌被攻破,其有效期也会限制攻击者可利用的窗口。如果客户端需要更长时间访问资源,授权服务器可以向客户端签发刷新令牌。刷新令牌只在客户端与授权服务器之间传递,绝不会发送给受保护资源,从而显著缩小这种长生命周期令牌的攻击面。所谓“短”的令牌有效期应完全取决于被保护的应用,但一般而言,令牌的存活时间不应明显超过 API 平均使用所需的时间。

归根结底,授权服务器端最有效的措施之一,是全面且安全的审计与日志记录。每当令牌被签发、使用或撤销时,都可以记录其发生时的上下文(客户端、资源所有者、作用域、资源、时间等),用于监测可疑行为。与此相对应的是,所有这些日志都必须避免记录访问令牌的具体值,以防止令牌通过日志泄露。

在受保护资源端

受保护资源在处理访问令牌时,往往与授权服务器的方式类似,因此在安全性上也应给予同等重视。由于网络中的受保护资源通常比授权服务器更多,甚至可能需要投入更直接、更细致的防护。毕竟,如果你使用的是 Bearer Token,就没有任何机制能阻止恶意的受保护资源把访问令牌转发(重放)到其他受保护资源上。还要注意,访问令牌可能会在系统日志中被无意泄露,尤其是那些为了分析而记录全部入站 HTTP 流量的日志。应当在这类日志中对令牌进行脱敏/清洗,避免令牌值在日志中被使用或暴露。

资源端点的设计应当限制令牌的权限范围,遵循“最小化收集”原则,只请求完成特定任务所需的最小 scope 集合。尽管与令牌关联的 scope 是由客户端发起请求的,但受保护资源的设计者可以通过要求令牌仅包含实现功能所需的最具体 scope 集合,来保护整个生态系统。设计过程中的这一环节,会以逻辑方式对应用资源进行划分,使客户端为了完成工作不必申请超出必要范围的权限。

资源服务器还应正确验证令牌,并避免使用带有某种“超能力”的特制访问令牌。2 虽然受保护资源缓存令牌的当前状态很常见,尤其是在使用「OAuth 令牌」讨论的令牌自省(token introspection)等协议时,但受保护资源必须始终权衡这种缓存带来的收益与风险。此外,采用限流(rate limiting)等技术来保护 API 也是个好主意,这有助于防止攻击者在受保护资源侧“钓取”有效令牌。

将访问令牌保存在临时内存中,有助于在资源服务器的数据存储遭受攻击时降低风险。这会让攻击者更难通过攻击后端系统来发现有效的访问令牌。当然,在这种情况下,攻击者很可能已经能够访问资源所保护的数据,因此一如既往,需要在成本与收益之间做好权衡。

授权码

我们在「OAuth 之舞」已经接触过授权码,并且看到这种授权类型最大的好处在于:访问令牌会直接传递给客户端,而不会经过资源所有者的用户代理,从而避免令牌可能暴露给包括资源所有者在内的其他人。不过,我们也在「常见客户端漏洞」看到,一些复杂的攻击可能导致授权码被劫持。授权码本身并没有太大价值,尤其是在客户端拥有可用于自我认证的客户端密钥时更是如此。然而,正如我们在「真实世界中的 OAuth 2.0」所看到的,原生应用在客户端密钥方面存在一些特有的问题。「动态客户端注册」讨论的动态注册是解决该问题的一种思路,但并非对所有客户端应用都可用或适合。为了缓解针对公共客户端的此类攻击,OAuth 工作组发布了一项额外规范,用于抑制这些攻击路径:代码交换证明密钥(Proof Key for Code Exchange,PKCE,读作“pixie”)。

代码交换证明密钥(PKCE)

使用授权码授权的 OAuth 2.0 公共客户端容易受到授权码拦截攻击。PKCE 规范3 的引入,旨在通过在授权请求与后续令牌请求之间建立安全绑定来防御此类攻击。PKCE 的工作原理很简单:

  • 客户端会创建并保存一个名为 code_verifier 的密钥,如图 10.1 所示,用一面带魔法棒图案的旗帜表示。

图 10.1:PKCE code_challenge
  • 随后,客户端根据 code_verifier 计算 code_challenge,如图 10.1 所示,可理解为在秘密之上叠加了同一面带有复杂图案的旗帜。code_challenge 可以直接使用原始的 code_verifier,也可以使用 code_verifier 的 SHA-256 哈希值;不过强烈建议采用加密哈希,因为它可以防止 verifier 本身在传输过程中被截获。
  • 客户端将 code_challenge 以及可选的 code_challenge_method(用于指明是明文还是 SHA-256 哈希的关键字)连同常规的授权请求参数一起发送给授权服务器(见图 10.1)。
  • 授权服务器按常规方式响应,但会记录 code_challenge 和 code_challenge_method(如果提供)。这些信息会与授权服务器签发的授权码关联保存。
  • 当客户端收到授权码后,会像往常一样发起令牌请求,并附上此前生成的 code_verifier 密钥(见图 10.2)。

图 10.2:PKCE 的 code_verifier
  • 服务器会重新计算 code_challenge,并检查是否与最初的值一致(见图 10.3)。如果两者不相等,将返回错误响应;如果相等,交易则按正常流程继续进行。

图 10.3:对比 code_verifier 与 code_challenge

为客户端和授权服务器添加 PKCE 支持非常简单。PKCE 的一大优势(除了毋庸置疑的安全收益之外)在于:它可以作为第二阶段增强直接加上去,不会对服务造成任何中断——即使客户端或授权服务器已经在生产环境中运行也一样。我们将通过为现有的客户端和授权服务器加入 PKCE 支持来验证这一点,并实现 S256(使用 SHA256)的 code_challenge_method。S256 方法是服务器端必须实现的;而客户端只有在因技术原因无法支持 S256 时,才允许使用 plain。

打开 ch-10-ex-1 文件夹并编辑 client.js 文件。找到授权请求(authorization request)相关的代码段。在这里,我们需要生成 code_verifier,计算 code_challenge,并将该 challenge 发送给授权服务器。PKCE 规范建议 code_verifier 的最小长度为 43 个字符、最大长度为 128 个字符。我们选择生成一个相对保守、长度为 80 的字符串。我们使用 S256 方法对 code_verifier 进行哈希处理。

code_verifier = randomstring.generate(80);
var code_challenge = base64url.fromBase64(crypto.createHash('sha256').update(code_verifier).digest('base64'));

var authorizeUrl = buildUrl(authServer.authorizationEndpoint, {
  response_type: 'code',
  scope: client.scope,
  client_id: client.client_id,
  redirect_uri: client.redirect_uris[0],
  state: state,
  code_challenge: code_challenge,
  code_challenge_method: 'S256'
});
res.redirect(authorizeUrl);

现在我们还需要修改 /callback 端点,以便在调用 Token 端点时,将 code_verifier 与授权码一起传过去。

1
2
3
4
5
6
var form_data = qs.stringify({
  grant_type: 'authorization_code',
  code: code,
  redirect_uri: client.redirect_uri,
  code_verifier: code_verifier
});

完成客户端部分后,我们就可以着手处理服务端。由于我们的授权服务器会把发往授权端点的原始请求与授权码一起保存,因此无需额外操作来保存 code challenge 以供后续使用;需要时,我们可以直接从 code.request 对象中取出。不过,我们确实需要对请求进行校验。在 /token 端点中,根据提交上来的 code_verifier,以及原始请求中携带的 code_challenge_method,重新计算一个新的 code_challenge 。我们的服务器将同时支持 plainS256 两种方式。注意,S256 使用的转换规则与客户端最初生成 code_challenge 时完全一致。随后,我们只需确认重新计算得到的 code_challenge 与原始值一致;如果不一致,就返回错误。

if (code.request.client_id == clientId) {
  if (code.request.code_challenge) {

      if (code.request.code_challenge_method == 'plain') {
            var code_challenge = req.body.code_verifier;
      } else if (code.request.code_challenge_method == 'S256') {
            var code_challenge = base64url.fromBase64(crypto.createHash('sha256').update(req.body.code_verifier).digest('base64'));
      } else {
            res.status(400).json({error: 'invalid_request'});
            return;
      }

      if (code.request.code_challenge != code_challenge) {
            res.status(400).json({error: 'invalid_request'});
            return;
      }
  }

如果一切都能对应得上,我们就像平常一样返回一个令牌。需要注意的是,尽管 PKCE 原本是为公共客户端设计的,机密客户端同样也可以使用这种方式。图 10.4 展示了 PKCE 流程的完整细节视图。


图 10.4:PKCE 详细视图

总结

Bearer Token 为 OAuth 流程带来了强有力的简化,让开发者能够更轻松、更准确地实现该协议。但这种简化也意味着:必须在整个系统中对 Token 提供相应的保护。

  • 访问令牌(access token)的传输必须通过安全的传输层机制加以保护,例如 TLS。
  • 客户端应只请求所需的最少信息(对 scope 集合要保守)。
  • 授权服务器应存储访问令牌的哈希值,而不是明文。
  • 授权服务器应将访问令牌的有效期设得更短,以尽量降低单个访问令牌泄露所带来的风险。
  • 资源服务器应仅在临时内存中保存访问令牌。
  • 可以使用 PKCE 来提升授权码(authorization code)的安全性。

到这里,我们已经从零搭建了一个完整的 OAuth 生态,并深入分析了由于错误的实现与部署可能带来的漏洞。接下来,我们将稍微跳出 OAuth 本身,转而看看更广泛的能力生态。