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

应用是如何记住你的

你登录了。你点了个东西。服务器完全不知道你是谁。

HTTP 是无状态的:每个请求都是全新到达的,既不记得上一个请求,也没有内建的方式把两者关联起来。刚刚成功的那次登录,什么痕迹都没留下。

所以,一个需要在多次请求之间认出你的应用,必须把身份信息存放在某处,并在之后的每一次请求里把它取回来。

这一节要回答的其实只有一个问题:身份信息存在哪里?

几乎所有 Web 登录流程都用以下三种方式之一来回答这个问题。

服务器来记住你

有状态身份认证(stateful identity)中,信息由服务器保管。

你登录时,服务器会创建一个会话(session),也就是一小份代表你的数据:用户 id、名字,也许还有角色。这个会话保存在服务器上。浏览器拿到的是一个携带会话 id 的 cookie,仅此而已,之后每次向该域名发起请求时都会自动带上它。服务器拿到这个 id,查出对应的会话,就又知道你是谁了。

这是传统 Web 应用的模式,也是很多框架的默认做法。如果你用过 express-session,它做的就是这件事。

Juno服务器来记住你 值得记住的一点是,浏览器被信任的部分少得惊人。它拿到的只是一个 id,别的什么都没有。

关于你的一切重要信息都留在服务器上,而那个 id 只有能连到那台服务器的人才用得上。

Juno服务器来记住你 每次请求都要去查一次,这一点值得留意,因为它既是这套方案的优点,也是代价所在。这意味着服务器可以随时修改或销毁一个会话,下一个请求会立刻感受到变化。

同时也意味着每个请求都要访问共享存储。在单台服务器上,这就是内存,几乎不花钱。而在多台服务器之间,就得有一个大家都能访问到的存储,这也是这种模式要求你搭建的第一个基础设施。

Juno服务器来记住你 你花这份代价换来的是即时撤销的能力,这比听起来更值钱。开除某人、变更角色、应对被盗的笔记本电脑:删掉会话,下一个请求就变成匿名的了。不用等任何东西过期。

代价是一个随着部署规模增长的协调问题。存在进程内存里的会话会随进程一起消失,隔壁的实例根本不知道它的存在,所以任何水平扩展都需要一个共享存储,而这个存储本身也变成了一个必须保持在线、否则大家都会被登出的关键部件。

粘性会话(sticky sessions)是个很诱人的捷径,但它只是把问题换成了一个更糟的问题:你的负载均衡现在和身份认证绑在了一起,丢失一个实例就会让它服务过的所有人都被登出。

客户端自己带着凭证

无状态身份认证(stateless identity)中,服务器不存储任何关于你的信息。

客户端持有一个令牌(token),并在每次请求中带上它。服务器检查这个令牌就能作出响应,不需要在请求之间去查找你是谁。

这种方式很适合 API、移动应用和分布式系统,因为在这些场景里,服务器间共享内存要么很别扭,要么根本不可行。如果你调用过某个 API 并收到过 401 Unauthorized: missing authorization header,那就是这种模式在告诉你:把凭证带上。

它有两个版本,两者的差异大到值得各自开一章来讲。不透明的持有者令牌(opaque bearer token)是一串没有意义的随机字符串,由服务器去查找对应信息。JSON Web Token 则把身份信息直接签名后带在令牌本身里,完全不需要查找。

Juno客户端自己带着凭证 这个名字一开始有点容易让人误解。服务器是对无状态,不是对所有事情都无状态。

它照样有一个装满用户信息的数据库。它不再保留的,是"你现在处于登录状态"这条记录。

Juno客户端自己带着凭证 "持有者(bearer)"这个词点出了关键:谁拿着它,谁就能用它。令牌不绑定任何设备、浏览器或网络,所以一个被复制走的令牌照样能用。

这就是为什么传输方式和存储位置在这里如此重要。要全程用 HTTPS,也要认真想清楚客户端把令牌存在哪里,因为 JavaScript 能读到的东西,注入进来的恶意 JavaScript 也能读到。

Juno客户端自己带着凭证 这里的取舍和会话方案正好相反。你换来一个不需要共享状态、能横向自由扩展的服务器,但代价是失去了反悔的能力。

一个你签发的令牌,在它过期之前一直有效,不管它在哪里,不管期间发生了什么。没有一份"名单"可以把它从中删除。这就是为什么生产环境通常的做法是:短期有效的访问令牌,配上一个可以被撤销的、有效期更长的刷新令牌——这重新引入了查找操作,但只发生在刷新的时候,而不是每次请求都要查。

这一点值得说明白:完全无状态的身份认证放弃的就是撤销能力,任何号称两者兼得的方案,其实都是在别处悄悄找回了一部分状态。

由别人来为你担保

委托身份认证(delegated identity)中,一个你信任的第三方代替你确认用户的身份。

你的应用去问 Google 是否认识这个人。Google 完成登录流程,然后把一个证明结果的令牌交给你的应用。每一个"使用 Google 登录""使用 GitHub 登录""使用 Apple 登录"的按钮,做的都是这件事。

它的吸引力在于,你不用再自己维护一套密码系统。不用存密码,不用做重置流程,也不用担心泄露你从未持有过的凭证。OAuth 与委托身份认证 会讲清楚这个交接过程到底是怎么运作的。

Juno由别人来为你担保 可以把它想成"有人替你担保"。你不是直接向应用证明自己,而是应用已经信任的某个人替你确认了身份,应用就相信了这个说法。

这就是为什么你会被跳转到 Google 的页面,然后又跳回来。证明这一步是在那边完成的。

Juno由别人来为你担保 它去掉了一类风险,也带来了一个依赖。你的登录现在会随着他们的服务一起挂掉,而在那边没有账号的用户根本没法注册。

这就是为什么大多数面向消费者的应用会把它作为邮箱密码登录之外的一个选项,而不是彻底取代后者,然后还得处理同一个人从两扇门都能走进来的情况。

Juno由别人来为你担保 这里的措辞值得较真,因为大家经常用错。OAuth 授予的是委托授权(authorization):允许你的应用去操作某个资源。OpenID Connect 是建立在它之上的身份层,那些"使用某某登录"的按钮用的是 OIDC。把 OAuth 的访问令牌当作身份证明来用,而不是用 OIDC 的身份令牌,是一个真实存在且很常见的错误。

你连带继承的还有服务提供方的各种决定:他们的会话有效期,他们的账号找回方式,他们对"邮箱地址"这件事的理解。如果他们允许用户凭手机号找回账号,那现在这也成了你的账号找回方式。

账号关联是最容易出问题的地方。用邮箱地址把一次委托登录匹配到已有账号上,是大多数人第一个想到的做法,但这只有在服务提供方验证过邮箱的情况下才安全,而并不是所有提供方都会这么做。

并排比较

有状态无状态委托
身份信息存在哪里服务器上客户端持有的令牌里由服务提供方保管
每次请求的成本一次查找一次签名校验,或一次查找取决于你最终用的是哪种令牌
撤销访问权限即时生效比较困难,通常要等过期部分取决于服务提供方
能否横向扩展需要共享存储可以自由扩展可以自由扩展
主要牺牲了什么服务器间的协调成本反悔的能力控制权和独立性

它们之中没有哪个是"正确答案"。它们各自针对不同的目标做了优化,这也是为什么真实系统往往会同时用上不止一种:用委托登录来确认你是谁,用会话来支撑网站,用令牌来支撑 API。

Juno并排比较 先别急着背这张表。等你见过每一种实际运作起来,它才会真正有意义。

值得带到下一章的一句话是:有状态是为了控制,无状态是为了扩展,委托是把这个难题交给别人去解决。

Juno并排比较 遇到一个陌生的系统时,最快搞懂它的办法就是问一句:身份信息存在哪里?答案会带出其他一切。

如果服务器持有它,就去看会话是怎么过期的、存在哪里。如果客户端持有它,就去看令牌泄露后会发生什么、要怎么撤销它。如果服务提供方持有它,就去看账号是怎么关联的。

Juno并排比较 "撤销"这一行往往决定了大多数真实的争论结果,而且通常发现得比较晚。团队因为扩展性选择了无状态方案,上线运行,然后遇到第一起需要立刻把某个特定的人登出的安全事件。

诚实地说,你选择的其实是"愿意在哪方面表现差"。有状态在扩展性上表现差,但控制力强。无状态正好相反。任何号称两者兼得的方案,其实都是悄悄在某处重新引入了一次查找——这本身是个完全合理的设计,但值得看清它的本质,而不是把它当成白捡的便宜。

动手试试

针对下面每个系统,说出你预期它会用哪种模式,以及决定这个选择的那个关键属性。

  1. 一个内部管理后台,撤销某人的访问权限必须立刻生效。
  2. 一个为拥有几十万用户的移动应用提供服务的公共 API。
  3. 一个照片应用,整个注册流程就是一个"使用 Google 继续"按钮。
  4. 一个微服务架构,其中十几个服务都需要知道调用者是谁。
对照一下你的答案
#模式决定性的属性
1有状态即时撤销。删掉会话,下一个请求就是匿名的。没有其他方案能做到这一点。
2无状态随着流量增长,不需要协调一个共享的会话存储,而且移动客户端持有令牌也很方便。
3委托不用搭建、存储或担心泄露密码系统,注册速度也是最快的。
4无状态,具体来说是 JWT每个服务都能自己验证令牌,不需要在每次调用时都去问一个中心服务器。

第 1 题是最有意思的一个。它是列表里规模最小、扩展压力最小的系统,却用了扩展性最差的那种模式,因为在这里,控制力比增长更重要。

接下来去哪里

三种方式,各自用不同的思路解决同一个问题。

会话与 Cookie 会把第一种方式拆开来细看:服务器存了什么,浏览器带着什么,以及哪些失误会把一个原本扎实的模式变成一个漏洞百出的模式。