关于 OAuth2.0 安全性你应该要知道的一些事

当前已是 2017 年,似乎现在还来说 OAuth 2.0 的话题有点过时了,不过很多新人在使用 OAuth 2.0 的时候,也就是照着微信、微博的文档按部就班,不求甚解。而很多细节,微信微博之类的文档自然也是不多说。但如果不了解 OAuth 2.0 的美妙之处,该注意的地方不注意,可是会有安全风险的哦。

首先 OAuth 2.0 是有 4 种认证流程的:Authorization Code、Implicit、Resource Owner Password Credentials、Client Credentials。这里我先说最常见的也是最安全的第一种 Authorization Code。

在继续话题之前先提一下 access token。我在之前的文章里面有提过 token 这个概念,可以说 OAuth 的身份验证过程,就是一个获取 access token 的过程。不过有的人可能就要问了,那为什么要用 token 而不是直接用账号密码来登录呢?

如果 token 用多了,你可能也能感觉到使用 token 都是出现在『让第三方网站访问数据提供服务(微信,微博等提供用户数据的网站)』这样的场景里。如果微信是屋子,你是微信的用户,也就是这屋子的主人,密码是你的钥匙,你会在装修的时候,直接把房门钥匙给装修队吗?且不说装修队会不会装修完了还没事儿去你家看看,万一装修队把钥匙复制了给更多的人了呢?

如同现在装修都会有专门的『装修钥匙』一样,token 与 账号+密码 最大的不同,就是 token 必然有相对短暂的生命期,并且可被回收。即使不小心 token 被攻击者获取,他也没有办法永远能访问你的数据。而 OAuth 则提供了让第三方获取 token 这个临时钥匙的通用解决方案。

回调域名

言归正传,先聊一个大家相对比较熟悉的事情:如果一个网站要使用微信登录,必然是要去微信公众号后台申请 appid 的,并且在申请的时候,必然要写一个获取 code(注意不是 token。code 是什么,后面再说)的域名,而微信后台也会给你一个 secret 对吧?appid,secret,code,申请输入的域名,所有这些,又在整个认证流程里起了什么样的一个作用呢?

这里面辨识度最高的应该是域名了。做过微信开发的同学可能都遇到过,获取 code 时,如果给微信服务器传的 redirect_uri 所包含的域名不是申请 appid 时所输入的域名,那么微信那边会立马返回『redirect_uri 参数错误』的提示。不过,为什么 OAuth 2.0 要求申请 appid 的时候必须输入一个域名,并且要求 redirect_uri 必须是此域名下的地址?

举个栗子,假如我在微信申请 appid 输入的域名是 chrisyue.com,申请到的 appid 是 11111,也假设微信正常获取 code 的地址是 https://wx.qq.com/code(此处只是为了示例,并不是真实的例子),那么获取 code 完整的 URL 是 https://wx.qq.com/code?redirect_uri=https://chrisyue.com&appid=11111&response_type=code&scope=userinfo。只要把这个地址给任何一个微信联系人发过去,他们都能通过自己的微信账号登录 chrisyue.com。

但如果微信不验证 redirect_uri 是否是 chrisyue.com,只要有攻击者稍稍修改上面的地址,把 redirect_uri 改成他的网站(假如叫 http://hack.er),受害人访问此链接,确认登录,微信生成 code 之后再通过回调的方式,便把 code 传给了攻击者的网站:http://hack.er/?code=abcdef...。拿到 code 之后,攻击者再把地址换回我的地址访问但修改 code 参数为前一步得到的 code: https://chrisyue.com/?code=abcdef,而此时 chrisyue.com 分辨不出这是微信直接传来的还是有人动了手脚的地址,无差别的获取了 access_token,攻击者宣布受害者登录成功。

这里多说一句,对于 Implicit,因为获取 access_token 被简化成一步到位,那就更需要验证回调地址的域名了。

所以作为 OAuth2.0 Server,redirect_uri 的域名限制是一定要做的。而作为调用者,到不必这么担心,目前大部分遵循 OAuth2.0 的服务都不会犯这个错误。

不过对于调用者来说,此限制会影响到开发过程中的调试工作,因为微信后台对 redirect_uri 域名设置的限制,难道开发调试就只能用外网的机器了吗?其实这事儿也好解决,如果你的开发环境就是本地 127.0.0.1,那么直接将 redirect_uri 的域名通过 hosts 文件直接指向内网就行了:

有的同学可能又会想:咦,好像还是可以让 code 跳转到别的服务器上?其实没关系,除非受害者他把自己的 hosts 文件改了,否则是不可能通过这种方式泄漏 code 的。还有可能是 DNS 污染导致 code 跳到别的服务器,不过也没关系,至于原因,下面就会说。

code 与 secret

OK,聊完了 redirect_uri,再说说 code。为什么有 code 的存在?它到底在流程中起一个什么作用?说道这个问题,又不得不提到 secret。

如果 appid 是为了告诉身份认证服务器,『我是 chrisyue.com!』;那么 secret 是为了告诉身份认证服务器,『我真的是 chrisyue.com!』,有些时候你总需要暴露『你是什么第三方服务器』的信息给身份认证服务器,比如获取 code 的时候,那能暴露的只能是 appid,一旦数据交互可以不暴露给用户,比如拿 code 获取 access token,那会带上 secret。secret 为什么叫 secret,就因为他绝对不能暴露给外网。

再说回 code。

OAuth 2.0 当初设计的一个目标之一是,让不支持 https 的网站也能安全使用。既然提到了 https,必然跟中间人攻击有关系。再往下说前,我们再确认一下 OAuth 认证的要满足的条件:

  1. 要获取 access token,必须先让用户在身份认证服务器上完成账户密码的输入(或已在登录时确认授权),因为不能把账号密码暴露给第三方网站
  2. access token 必然要悄悄给第三方网站,因为不能被攻击者看到。

我们先假设 OAuth 不需要整什么 code,就直接获取 access token,那么流程就是(还是拿 chrisyue.com 和微信举例):

  1. 用户浏览器访问 chrisyue.com,chrisyue.com 服务器发现用户处于未登录状态,返回 302,让浏览器跳转到微信 OAuth 服务获取 access token,(假设为 https://wx.qq.com/token?appid=xxx&redirect_uri=http://chrisyue.com&scope=...
  2. 用户在微信的网页上完成了账号密码的输入并登录成功(或者已经登录授权成功),微信服务器也返回 302,让浏览器跳转到 redirect_uri 指定的地址并且带上 access token 参数。
  3. 用户浏览器访问带 access token 的链接,完成整个登录。

此流程没大毛病,就是最后一步,如果 chrisyue.com 是不支持 https 访问的,那么 access token 就等于是暴露在浏览器和 chrisyue.com 服务器之间的线路中。

当然,从另外一方面来说,如果第三方网站强行要求必须支持 https,理论上来说,code 这一步也是可以省的。

说到这,可能有人会问,那 Implicit 就是直接获取的 access token,那又怎么防止中间人攻击?OAuth2.0 对此问题也是处理得非常巧妙,但之后再说。

可能也有人会问,那多一步获取 code 有什么用呢?如果 chrisyue.com 不支持 https,code 不也是会被暴露吗?攻击者拿到 code,类似与之前讨论 redirect_uri 是否可以不检查域名时所做的一样,直接用获取到的 code,访问 chrisyue.com 获取 access token 的地址来登录受害人的账号。

针对这个问题,OAuth2.0 协议其实对此是有处理方式的:

  1. 首先,code 只能被使用一次
  2. 其次,若是攻击者比正常用户先用了 code 也没事,因为如果同一个 code 被用了两次,之前通过此 code 获取的 access token 将被撤回,而因为普通用户本来就是要访问拿 code 换 access token 的地址,code 是一定会被用的

也就是说,攻击者最多让正常用户有点困扰,可能会出现登录意外失败,或者明明看起来登录成功但还是获取不到微信信息的情况(access token 已经失效),但攻击者依旧拿不到数据。

再说回 secret。

咱们还是来假设,如果没有 secret,会有什么事情发生。不知道大家了不了解 DNS 污染。如果 chrisyue.com 的 DNS 被污染,code 被发送到攻击者的服务器上,code 可是不会像上面说的那样会被执行两次,假如获取 access token 不需要 secret,攻击者直接就拿 code 换 token 了。说到此 secret 的意义也不用多说了。之前说过,secret 就是用来告诉 OAuth 服务器,『我真的是 xxx 网站不是假装的』,它其实就是第三方网站与 OAuth 服务网站之间的信物。此信物是一定一定不能被第三者知道的。如果知道了一定要第一时间重新生成。

而反过来,我们也可以利用这一点。假如 secret 你只是部署给了你信任的服务器,那么这几台服务器就都可以用来登录了。这是实现多域名第三方网站登录的关键:假设申请 appid 时填写的域名为 auth.chrisyue.com,而用户实际要登录的网站是 www.chrisyue.com,用户从 www 站跳到微信做登录操作;用户在微信登录成功,把 code 返回给 auth.chrisyue.com;auth 站再把 code 给 www,www 上只要有 secret 就可以直接获取 access token 了。为此我也写过基于 Symfony 框架的 Bundle,大家可以参考参考(此 Bundle 并不是让 www 站直接到微信,而是先跳转到了 auth 站,再跳微信,目的是为了让 auth 站负责更多身份认证的职责)。

被忽视的安全守卫 state

我相信 state 是最容易被忽略的一个参数,因为几乎所有的 OAuth2.0 服务提供商的文档,都没有解释这个参数到底存在的目的是什么,加上本身这个参数又可以为空……而要知道,虽然 state 参数可以为空,OAuth2.0 官方文档里标注的可是 Recommended。那官网到底因为什么要推荐使用此参数呢?

不知道大家是否知道 CSRF,不知道大家是否了解为什么建议登录的时候需要添加 CSRF 保护,如果不知道,可以来这里了解一下。

与之类似,如果 state 参数为空,作为攻击者,

  1. 先申请一个新的,专门用于攻击他人的账号;
  2. 然后走正常流程,调到微信上去登录此账号;
  3. 登录成功之后,微信带着 code 回跳到 chrisyue.com,这个时候,攻击者拦截自己的请求让他不再往下进行,而直接将带 code 的链接发给受害者,并欺骗受害者点击;
  4. 受害人点击链接之后,继续攻击者账号的登录流程,不知不觉登录了攻击者的账号

受害者如果这个时候没察觉此账号不是他本人的,传了一些裸照啥的,攻击者立马就能通过自己的账号看到。

而 state 参数如果利用起来,当作 CSRF Token,就能避免此事的发生:

  1. 攻击者依旧获取 code 并打算骗受害者点击
  2. 受害者点击链接,但因服务器分配给受害者的 state 值和链接里面的 state 值不一样,服务器直接返回登录失败

state 或者说 CSRF Token 这种跟设备绑定的随机字符串,只要稍微复杂一点,攻击者根本就不可能猜得出来,而设置一个让攻击者猜不到的,跟设备或者说浏览器绑定的 state 或者说 CSRF token 值,就是解决 CSRF 攻击的关键。

好啦,关于 OAuth2.0 Authorization Code 安全性的一些细节就说到这里了,如果最近有时间,就把 Implicit 的也写一写吧。

wx pay

CC BY-NC-ND 4.0 关于 OAuth2.0 安全性你应该要知道的一些事 by Chrisyue's Blog is licensed under a Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License.

发表评论

电子邮件地址不会被公开。