Skip to content
This page has been auto-translated and may contain errors.View in English

不透明持有者令牌

一张门禁卡能让你进入办公楼。卡上没有名字,没有照片,里面也没有任何可读的信息。你刷一下卡,读卡器对照名单核实,门就开了,或者不开。

不透明持有者令牌就是这张卡。一串随机、无法猜测的字符串,里面什么信息都没有。

无状态不代表服务器什么都不记得

这个词很容易让人误解,所以有必要先把它说清楚。

无状态意味着服务器不保存会话:没有一个对象在多次请求之间追踪某个用户。它依然有数据库,依然有用户数据,依然存储各种东西。它不再保存的,是"这个人现在处于登录状态"这条记录。

于是每次请求都必须自带服务器判断"这是谁"所需要的全部信息。

服务器不再记住你;客户端每次都要重新证明自己。

这个名字里的两个词各有含义。不透明(Opaque)指的是服务器无法从令牌里读出任何信息,因为里面本来就没有内容——它不是经过编码或有结构的数据,只是一串随机字符串。持有者(Bearer)指的是谁拿着它,谁就能用它。

Juno无状态不代表服务器什么都不记得 "无状态"听起来像是服务器得了健忘症,但其实并非如此。它对你的账户信息一清二楚。

它唯一不再保留的,是一张写着"你现在处于登录状态"的便条。而令牌,正是用来替代这张便条的东西。

Juno无状态不代表服务器什么都不记得 这套模型其实只是部分无状态,值得直说清楚。服务器依然保留着一张"令牌对应用户"的查找表,所以状态并没有消失,只是变小了。

一个会话对象要保存身份、角色以及其他各种累积信息,而查找表只需要一行:这个字符串对应这个用户。这样存储、复制、同步起来都轻松得多。

Juno无状态不代表服务器什么都不记得 这个请求头的名字算是个历史遗留,而且经常让人产生误解。令牌通过 Authorization: Bearer <token> 传递,在这套模型里,它做的是身份验证(authentication):证明你是谁,本身并不授予任何权限。权限依然是在解析出用户之后另行判断的。

在陌生代码库里尤其要记住这一点,因为这个请求头的名字容易让人误以为"持有即拥有权限"。事实从来都不是这样,而第二步的权限检查,恰恰是最容易被人漏写的部分。

整个流程

一共六步,形状你会觉得眼熟:

  1. 用户提交用户名和密码。
  2. 服务器进行校验。
  3. 校验成功后,服务器生成一个随机令牌。
  4. 服务器存储一条简单的"令牌对应用户"映射,然后把令牌发给客户端。
  5. 客户端保存这个令牌,并在之后每次请求的 Authorization 请求头中带上它。
  6. 服务器查找这个令牌,找到对应的用户,请求便通过了身份验证。
http
GET /api/orders
Authorization: Bearer 7f3a9c1e5b28d4a06e91f7c3

会话与 cookie相比,差异主要体现在三个地方:

有状态会话不透明持有者令牌
服务器存储的完整的会话对象一行"令牌对应用户"的记录
客户端存储的一个保存会话 id 的 cookie令牌本身
传递方式由浏览器自动携带由你在代码中显式添加
适用场景传统 Web 应用API 和移动应用

第三行的差异比看起来重要得多。cookie 是不请自来、自动跟随请求的;持有者令牌则是由你自己的客户端代码在每次请求中主动附加的。

Juno整个流程 如果这看起来很像会话与 cookie,这个感觉没错。都是客户端保存点什么,服务器核实点什么,中间靠一次查找把两者连起来。

区别在于服务器保留了多少信息,以及由谁来发送。骨架相同,重量分布不同。

Juno整个流程 显式发送令牌是一个设计上的优点,而不是麻烦事。cookie 会自动附加到发往该域名的每一个请求上,包括那些由别的网站触发的请求——这正是 CSRF 之所以能够发生的根源。

持有者令牌是由你的代码主动附加的,所以你的代码没有发起的请求,根本不会带上它。这也是为什么面向你无法控制的客户端提供的 API,通常都默认采用这套模型。

Juno整个流程 第三步里藏着一个容易被人跳过的要求:令牌必须来自密码学安全的随机源,也就是 crypto.randomBytes,而不是 Math.random,而且长度要足够,让人猜不出来。32 字节是通常的下限。

第四步有一点值得采纳:存储令牌的哈希值,而不是令牌本身。客户端保留原始令牌,服务器只保存摘要,查找时对收到的值进行哈希后再比对。

这样一来,即便有人读取了这张查找表也毫无用处——原理和密码永远不以明文存储是一样的。可现实中不少生产系统还是把持有者令牌以明文形式保存,而这往往是拿到数据库访问权限的攻击者第一时间会搜刮的东西。

五种出问题的方式

这套模型本身几乎没什么风险,风险都出在令牌怎么存、能用多久、以及查找表维护得好不好这些环节。

出了什么问题为什么要紧怎么解决
1存储不安全存在 localStorage、普通变量或未加保护的 cookie 里的令牌,页面上任何脚本都能读到。只要出现一个 XSS 漏洞,攻击者就和真实用户没有区别了存到更难被访问到的地方,并减少它被暴露的频率
2令牌有效期太长一个能用好几周的令牌,意味着一旦泄露,攻击者也能用好几周设置较短的过期时间
3没有吊销机制令牌不会自己过期,登出后也照样能用。没人去检查的过期时间只是摆设每次请求都检查是否过期,登出时删除令牌
4查找表膨胀每次登录都会写入一行,记录不断堆积,查找越来越慢。攻击者只要不断攻击登录接口就能人为制造这种情况,这属于拒绝服务攻击用定时任务清理过期令牌
5查找表被篡改这张映射表就是唯一的真相来源。通过 SQL 注入或泄露的凭据获得写入权限,就能把一个令牌重新指向另一个用户像保护应用本身一样,严格保护数据库和管理工具

第五种问题特别值得多想一想。令牌本身没有任何变化,变的是它代表的含义。这是一种完全不需要碰凭据本身,就能实现的权限提升。

Juno五种出问题的方式 留意一下,五个问题里有四个其实和令牌本身没什么关系。问题出在它存在哪里、能用多久、有没有人清理,以及谁能修改那张列表。

令牌没毛病,问题都出在它周围的这些环节上。

Juno五种出问题的方式 第一个问题没有干净利落的答案,了解一下原因会有帮助,因为网上很多建议假装它有。

localStorage 里的内容,页面上任何脚本都能读到。带 HttpOnly 的 cookie 则读不到,但 cookie 又会把 CSRF 的问题带回来,所以你还需要 SameSite,甚至可能需要额外的令牌校验。

这两种做法都是真实存在的方案,也都有真实存在的弱点。问题在于你更愿意防御哪一种攻击,而诚实的答案通常是:先别让 XSS 漏洞出现。

Juno五种出问题的方式 生产环境里最终常见的做法是:一个短生命周期的访问令牌(有效期几分钟),再加上一个存在更难访问位置的长生命周期刷新令牌。访问令牌被频繁检查,很快过期;刷新令牌很少被使用,但可以被吊销。

这样一来,查找操作又回来了,只不过从每次请求都要查,变成了只在刷新时才查。这是一个值得理解的权衡:你用极小的代价换回了吊销能力。

刷新令牌轮换(rotation)是让"被盗"变得可以被发现的关键一环。每次使用都发放一个新的刷新令牌,并让旧的失效,这样一旦被盗的令牌被重放使用,就会表现为一个已经用过的令牌被再次使用,从而在那一刻就能把整批相关令牌一并吊销。

动手试试

一个 API 是这样签发令牌的:

js
const token = Math.random().toString(36).slice(2)
tokens[token] = { userId: 4821 }
res.json({ token })

又是这样校验令牌的:

js
const token = req.headers.authorization?.replace('Bearer ', '')
const entry = tokens[token]
if (!entry) return res.status(401).json({ error: 'Unauthorized' })
req.userId = entry.userId

找出其中四个问题。

对照一下你的答案

1. Math.random 不是密码学安全的随机数生成方式。 它的输出可以根据之前的值预测出来,所以攻击者只要收集到几个令牌,就能推算出其他令牌。修复方法是用 crypto.randomBytes(32).toString('hex'),单单这一处问题就足以让整套方案被人伪造。

2. 完全没有过期机制。 没有存储令牌的签发时间,进来的请求也不会检查这一点,所以每个签发出去的令牌都永久有效。应该存储过期时间,并在每次请求时检查。

3. 令牌以明文形式存储。 tokens[token] 保存的是原始字符串,所以任何能读到这张表的人,都能使用表里的每一个令牌。应该存储哈希值,查找时对收到的值哈希后再比对。

4. tokens 是一个普通对象,所以查找存在漏洞。 tokens['toString'] 会返回 Object.prototype.toString,这是一个真值(truthy),所以只要请求携带字面量令牌 toString,就能顺利绕过 if (!entry) 这个检查。

这样一来 entry.userId 就会是 undefined。这最终会导致程序崩溃,还是会让请求以用户 undefined 的身份通过验证,取决于后面的代码怎么写。应该使用 MapObject.create(null)Object.hasOwn()

第四个问题是那种表面看起来没什么毛病的坑。前三个是需要记住的注意事项,而第四个是 JavaScript 对象本身的特性,已经在真实系统里造成过问题。

接下来会走向哪里

这张查找表让这套模型得以运转,也正是它的局限所在。它给了你吊销令牌的能力,但也意味着每次请求都要访问共享存储——而这恰恰是无状态身份认证本来想要避免的事情。

JSON Web Token 把这个思路推到了极致:彻底去掉查找表,把身份信息直接放进令牌本身。