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

OAuth 与委托身份认证

每一个"使用 Google 登录"按钮,本质上都是一个不打算自己做密码系统的决定。

你的应用不检查密码,不验证这个人是谁,也从不存储任何凭证。它只是去问一个自己已经信任的服务提供方,然后接受对方给出的答案。

这背后的协议是 OAuth,而这个流程涉及的环节,比一个按钮所暗示的要多得多。

两种不同的授权

几乎所有人都会在这里犯迷糊,值得先讲清楚,后面的流程才能说得通。

你一定见过这样的同意页面:一个应用请求获取你的姓名、头像和邮箱地址。有时候还不止这些,联系人、日历,甚至收件箱里的每一封邮件都可能被要求授权。

那个页面进行的是授权,和你应用里的角色体系没有任何关系。它决定的是你的应用可以访问用户 Google 账户里的哪些内容,这些权限被称为scopes(授权范围)

你自己应用里的管理员和普通用户角色,仍然是在登录完成之后由你的后端来决定的,和前面几章讲的完全一样。

OAuth 负责授权,OIDC 负责身份认证

严格来说,OAuth 授予的是委托授权(authorization):允许你的应用访问某项资源的权限。构建在它之上的身份层叫做 OpenID Connect,也就是 OIDC,那些"使用……登录"按钮走的其实是 OIDC。

这个区分在代码层面很重要。OAuth 的访问令牌(access token)说的是你的应用可以访问什么;OIDC 的ID 令牌(ID token)说的是这个用户是谁。把前者当作身份证明来用,是一个常见但真实存在的错误。

Juno两种不同的授权 这里其实同时决定了两件事,它们很容易被混在一起。

Google 决定应用能看到你 Google 账户里的哪些信息;你的应用决定这个人登录进来之后能做什么。问题不同,决定的地方不同,答案也不同。

Juno两种不同的授权 Scopes 是在配置服务提供方的时候设置的,不是针对每个用户单独设置的,所以往往是一次性决定,之后很少再回头检查。这也是为什么很多应用最后申请的权限远超实际用到的。

值得列进检查清单:在上线一个 OAuth 集成之前,把你请求的每一个 scope 都列出来,并且写清楚是哪个功能需要它。没有对应功能的 scope,就删掉。

Juno两种不同的授权 OIDC 这个区分在实际操作中,落到了你读的是哪个令牌上。ID 令牌是一个 JWT,里面装的是关于用户的声明(claims),是给你的应用看的,也是你应该验证并使用的那个。

访问令牌是给服务提供方的 API 用的,你的应用应该把它当作不透明的东西,不去解读它的内容。

从访问令牌里读身份信息,在服务提供方改变令牌格式之前都能凑合用,但对方从来没承诺过这个格式不变。

这样做还会引来"困惑代理人"问题:一个签发给别的应用的访问令牌,被提交到你这里也会被接受,因为令牌本身根本没说清楚它是签发给谁的。

而 ID 令牌上的 aud 声明,存在的意义正是为了防止这种情况,检查它不是可选项。

往返流程

一共八个步骤,用户会在中途离开你的应用:

  1. 用户在你的应用里点击按钮。
  2. 你的应用把用户重定向到 Google。
  3. 用户直接在 Google 上登录,你的应用完全看不到这个过程。
  4. Google 展示同意页面,列出你请求的授权范围。
  5. 如果用户同意,Google 会把一个一次性的批准码发给你的服务器。这个批准码不能让任何人登录,它只代表用户同意了授权,仅此而已。
  6. 你的服务器把这个批准码连同你应用自己的密钥凭证一起发回给 Google。
  7. 这些凭证向 Google 证明了这个请求确实来自你的后端。
  8. Google 回复一个 ID 令牌,说明这个用户是谁,你的应用据此让用户登录。

第五步到第八步这两次交接,是最值得搞懂的部分。批准码经过用户的浏览器传递,单独拿到手毫无用处;真正关键的令牌是在服务器和服务器之间交换的,浏览器从头到尾都看不到它。

Juno往返流程 把用户送出去再接回来,这整套操作的关键就在这里。你的应用故意在用户输入密码的时候不在场,这样也就没有什么东西会被它不小心留下。

这就是它的吸引力所在:你根本没收到过的凭证,是不可能泄露出去的。

Juno往返流程 第五步和第六步回答了一个大家很早就会问的问题:为什么要先发一个批准码,而不是 Google 直接把令牌发过来?

因为批准码要经过浏览器,而浏览器里的东西会被记录、被共享。它生命周期很短,单独拿到也没什么价值。

第六步的交换是在服务器和服务器之间进行的,还带着你的密钥,所以真正有价值的令牌从来不会碰到浏览器。

Juno往返流程 课程里没提到的两个补充,如今都已经是标准做法。PKCE(Proof Key for Code Exchange)让客户端在第二步之前生成一个密钥,并在交换的时候一并提交,这样即便批准码被人偷走,对方也无法用它兑换到令牌。

它最初是为了解决移动端的问题而提出的,现在的最佳实践建议在所有场景下都使用它。

还有 state 参数:你在第二步发送一个随机值,等用户返回时再检查一遍,这样就能把返回的响应和你发起的请求对上号。没有它,攻击者可以在受害者的浏览器里完成一个他们自己发起的流程,然后把自己的账户和受害者的会话绑定在一起。

这两个都是成本很低的做法。但在手写的集成代码里却经常被漏掉,这也是为什么用一个维护良好的库,比自己从头写整个流程要靠谱得多的最有力的理由。

三种常见的出错方式

把用户送走,再指望服务提供方把他们送回来,这个过程悄悄留下了三个漏洞。

出问题的地方为什么重要解决办法
1请求的权限太多超出应用实际使用范围的 scope,意味着一旦泄露,暴露的信息会远超应有的程度,而且用户也会因为应用显得"多管闲事"而失去信任。信息泄露只请求你的功能真正需要用到的最小权限集合
2批准码泄露批准码可能出现在地址栏、浏览器历史记录、分析工具或服务器日志里。任何人只要在它过期之前拿到它,就能以该用户的身份完成登录。身份伪装不要让它出现在任何会被记录的地方,并且立刻拿去交换令牌
3不加检查地信任 ID 令牌任何人都能伪造一个看起来正确的令牌,但只有 Google 能给令牌签名。不加验证就接受,等于给了攻击者冒充任何人的机会。身份伪装在信任令牌里的任何内容之前,先验证签名、受众(audience)和过期时间

第三个陷阱最致命,它会让整个模型失效。委托身份认证的意义,就在于由一个可信的第三方为用户背书;跳过验证这一步,等于变成了信任发送请求的任何人,而不是那个可信第三方。

Juno三种常见的出错方式 用便条来打比方正合适。Google 交给你的应用一张签了名的便条,上面写着这个用户是谁。

任何人都能写一张便条。检查签名才是你确认这张便条确实来自 Google 的办法,跳过这一步,就等于相信一个你从没见过的笔迹。

Juno三种常见的出错方式 验证其实是三项检查,不是一项,而第二项恰恰是大家最容易漏掉的。它是不是服务提供方签发的?它是不是签发给你的应用的?它过期了吗?

中间那项检查就是受众声明(audience claim)。没有它,一个签发给别的应用的令牌,虽然签名来自 Google 是真的,却完全说明不了它是不是签给你的。

Juno三种常见的出错方式 账户关联是陷阱清单里没提到的一个棘手问题。用邮箱地址把一次委托登录匹配到一个已有账户,是大家最常用的做法,但只有当服务提供方验证过这个邮箱地址时,这么做才是安全的。检查 email_verified 声明,如果没有这个声明,就当它没验证过。

一旦做错了,就可能出现这种情况:有人在某个服务提供方那里用你用户的邮箱地址注册了一个账户,通过它登录,结果直接进入了那个已经存在的账户。

上线前还需要考虑的另一件事,是当服务提供方宕机、或者用户失去访问它的能力时会发生什么。只支持委托登录,意味着你的可用性完全被对方绑架了,账户找回也变成了一场关于别人家账户找回策略的讨论。

练习一下

来自一次真实 OAuth 集成的五个场景。说出对应的陷阱、风险和解决办法。

  1. 你的应用只需要一个邮箱地址就能让用户登录,但 OAuth 请求里同时要求了联系人、日历和 Drive 文件的权限。
  2. 同意页面警告说,你的应用想要获得删除日历事件的权限。而你的应用从来没用过日历功能。
  3. 登录之后,页面短暂地在地址栏里显示了那个生命周期很短的批准码。
  4. 你的后端收到 ID 令牌后,没检查签名,也没检查签发对象是谁,就直接接受了。
  5. 你的后端接受了一个一小时前就已经过期的 ID 令牌。
对照答案
#陷阱风险解决办法
1请求的权限太多一旦令牌泄露,暴露的用户账户信息会远超应用实际所需只请求某个功能真正用到的 scope
2请求的权限太多同样的信息暴露问题,还额外让同意页面在用户使用应用之前就显得不值得信任去掉没有用到的 scope
3批准码泄露URL 会被记录、保存和共享。任何人只要在批准码过期之前拿到它,就能完成登录不要让批准码出现在 URL 里,拿到手立刻去交换
4信任了未经验证的 ID 令牌任何人都能伪造一个"看起来像令牌"的东西。不做签名和受众检查,攻击者就能冒充任何用户在信任令牌之前,先验证签名和受众
5信任了未经验证的 ID 令牌一个旧令牌可以被反复重放,用来一次次冒充该用户登录检查过期时间,拒绝任何已过期的令牌

第 2 项和第 1 项其实是同一个陷阱,只是后果不同,这也是为什么这份清单更适合当作三个问题、而不是五个问题来读。第 4 项和第 5 项也是同一个陷阱:验证本来就是一组检查,漏掉其中任何一项,都等于没有做验证。

接下来往哪走

三种模型,从不同的方向解决同一个问题:服务器自己记住状态、客户端随身携带证明,或者由一个服务提供方来背书。

如何选择身份认证模型把这三种模型放在一起对比,讲清楚每一种分别适合在什么情况下使用。