前言
当前已是 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
。只要把这个地址给任何一个微信联系人发过去,他们都能通过自己的微信账号,换回 code(微信带 code 参数回跳https://chrisyue.com/?code=abcd123
),chrisyue.com 再用 code 换回 access token,用户用微信账号登录 chrisyue.com 成功。
但如果微信不验证 redirect_uri 是否是 chrisyue.com,只要有攻击者稍稍修改上面的地址,把 redirect_uri 改成他的网站(假如叫 http://hack.er),受害人访问此链接,确认登录,微信生成 code 之后再通过回调的方式,便把 code 传给了攻击者的网站:http://hack.er/?code=abcdef...
。拿到 code 之后,攻击者再把域名换回 chrisyue.com 得到:https://chrisyue.com/?code=abcdef
,而此时 chrisyue.com 分辨不出这是微信直接回调还是有人动了手脚的地址,无差别获取了 code 对应的 access_token,攻击者以受害者身份登录成功。
这里多说一句,对于 Implicit,因为获取 access_token 被简化成一步到位,那就更需要验证回调地址的域名了。
所以作为 OAuth2.0 Server,redirect_uri 的域名限制是一定要做的。而作为调用者,到不必这么担心,目前大部分遵循 OAuth2.0 的服务都不会犯这个错误。
不过对于调用者来说,此限制会影响到开发过程中的调试工作,因为微信后台对 redirect_uri 域名设置的限制,难道开发调试就只能用外网的机器了吗?其实这事儿也好解决,如果你的开发环境就是本地 127.0.0.1,那么直接将 redirect_uri 的域名通过 hosts 文件直接指向内网就行了
# /etc/hosts
127.0.0.1 chrisyue.com
有的同学可能又会想:咦,好像还是可以让 code 跳转到别的服务器上?其实没关系,除非受害者他把自己的 hosts 文件改了,否则是不可能通过这种方式泄漏 code 的。还有可能是 DNS 污染导致 code 跳到别的服务器,不过也没关系,至于原因,下面就会说。
code 与 secret
OK,聊完了 redirect_uri,再说说 code。为什么有 code 的存在?它到底在流程中起一个什么作用?说道这个问题,又不得不提到 secret。
如果 appid 是为了告诉身份认证服务器,『我是 chrisyue.com!』;那么 secret 是为了告诉身份认证服务器,『我真的是 chrisyue.com!』,为了告诉身份认证服务你是哪家第三方服务,你总是需要暴露一些信息给身份认证服务器的(暴露是指用户能获取到,比如会在地址栏里出现),获取 code 的时候,那能暴露的只能是 appid,但如果数据交互可以不暴露给用户,比如获取 access token 那一步,由第三方服务器内部直接发起,用户并不可见,那会带上 secret。secret 为什么叫 secret,就因为他绝对不能暴露给外网
。
再说回 code。
OAuth 2.0 当初设计的一个目标之一是,让不支持 https 的网站也能安全使用。既然提到了 https,必然跟中间人攻击有关系。再往下说前,我们再确认一下 OAuth 认证的要满足的条件:
要获取 access token,必须先让用户在身份认证服务器上完成账户密码的输入(或已在登录时确认授权),因为不能把账号密码暴露给第三方网站
access token 必然要悄悄给第三方网站,因为不能被攻击者看到。
我们先假设 OAuth 不需要整什么 code,就直接获取 access token,那么流程就是(还是拿 chrisyue.com 和微信举例):
用户浏览器访问 chrisyue.com,chrisyue.com 服务器发现用户处于未登录状态,返回 302,让浏览器跳转到微信 OAuth 服务获取 access token,(假设为 https://wx.qq.com/token?appid=xxx&redirect_uri=http://chrisyue.com&scope=...
)
用户在微信的网页上完成了账号密码的输入并登录成功(或者已经登录授权成功),微信服务器也返回 302,让浏览器跳转到 redirect_uri 指定的地址并且带上 access token 参数。用户浏览器访问带 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 协议其实对此是有处理方式的:
首先,code 只能被使用一次
其次,若是攻击者比正常用户先用了 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 保护,若不知道,可以来这里了解一下。
因评论里有越来越多的人对此有困惑,我打算单独说一下,虽然其实原理跟上面链接所提到的『登录 CSRF 保护』的原因完全一样。我总结困惑集中在两点:
把 state 要防止的『攻击者骗受害者访问攻击者自己的账号』想成了『攻击者登录受害者的账号』,如果你以为在说『中间人攻击』之类的事情,那一定偏题了
可能也不清楚为什么攻击者要骗受害者登录攻击者自己的账号。简单来说,让受害者以为登录了自己的账号,而做一些不想让其他人发现的事情,却因实际登录账号是攻击者的,完全被攻击者尽收眼底
与之类似,如果 state 参数为空,作为攻击者,
- 先申请一个新的,专门用于攻击他人的账号;
- 然后走正常流程,跳到微信上去登录此账号;
- 登录成功之后,微信带着 code 回跳到 chrisyue.com,这个时候,攻击者拦截自己的请求让他不再往下进行,而直接将带 code 的链接发给受害者,并欺骗受害者点击;
- 受害人点击链接之后,继续攻击者账号的登录流程,不知不觉登录了攻击者的账号
- 受害者如果这个时候没察觉此账号不是他本人的,传了一些『果照』啥的,攻击者立马就能通过自己的账号看到。
而 state 参数如果利用起来,当作 CSRF Token,就能避免此事的发生:
攻击者依旧获取 code 并打算骗受害者点击
受害者点击链接,但因服务器(比如 chrisyue.com)分配给受害者的设备的 state 值和链接里面的 state 值不一样,服务器(chrisyue.com)直接返回验证 state 失败
state 或者说 CSRF Token 这种跟设备绑定的随机字符串,只要稍微复杂一点,攻击者根本就不可能猜得出来,而设置一个让攻击者猜不到的,跟设备或者说浏览器绑定的 state 或者说 CSRF token 值,就是解决 CSRF 攻击的关键。
好啦,关于 OAuth2.0 Authorization Code Flow 安全性的一些细节就说到这里了,如果最近有时间,就把 Implicit 的也写一写吧。
Implicit Grant Flow
Implicit Grant Flow 相对比 Code Grant Flow 最明显的不同,就是一步到位。Implicit 是给不使用服务器而直接使用 Javascript 代码登录的项目准备的。既然服务器不参与登录,那么 code 以及 secret (以及 refresh token)就没什么意义了,所以 Implicit Grant Flow 里是绝不会出现这两个参数的。
关于 Implicit 发起登录请求带的参数,意义其实跟 Code Grant Flow 是一样的,就不再多说了。之前提到过 Implicit 不用 code,但是依然可以巧妙做到相对安全。下面重点就来说这一部分。
用过 Implicit 登录的同学可能都会注意到一个奇怪的细节,那就是登录成功之后身份认证服务器跳转回来带的参数,全都是放在 # 后面的,而不是类似 Code Grant Type 那样用 http query 参数。其实之所以我说 Implicit 巧妙,就妙在这个细节上。
OAuth2.0 要实现的目的之一,就是让不支持 HTTPS 的服务器也能相对安全得第三方登录(好像又说了一遍……),还是以 chrisyue.com 作为例子,假设 chrisyue.com 有一个特别牛逼的 SPA(单页面程序) 需要微信登录,如果微信 Implicit 流程登录成功,跳转回来的地址里带的数据是通过 HTTP query 参数传回来的话,这些参数又将被发送到 chrisyue.com 上(http://chrisyue.com/?access_token=xxxx&
…),但由于 HTTP 无法防止中间人攻击,access_token 很容易被偷走。这里说一个相对比较冷的姿势:浏览器发起一个请求时,地址栏里 # 以及后面部分都是不会随请求发送到服务器的,所以 Implicit 返回的数据放在 # 后面,就是为了防止中间人攻击而只让设备拥有 access_token
。
其实从 OAuth2.0 『煞费苦心』的协议里我们也应该意识到,当使用 Implicit 流程的时候,获取到的 access_token 是一定不能存放在 cookie 这种可能被中间人发现的地方(除非用 HTTPS)。为了做到更高的安全性,access_token 甚至最好连 local/session storage 里都别放,目的是为了防止别的应用或者软件『偷取』浏览器里一些存储层的信息。其实按这逻辑,我感觉 Implicit 也就只适合 SPA 了(SPA 不刷新页面的特性可以让 access token 一直保存在内存里,直到关掉页面)。
最后,可能有的同学还会怀疑:虽然 OAuth2.0 的规定无论是 Code 还是 Implicit,都不会让浏览器和网站(比如 chrisyue.com)之间遭受中间人攻击了,但网站和身份认证服务之间(Code Grant Flow),或者浏览器和身份认证服务之间(Implicit Grant Flow)难道就不可能出现中间人攻击吗?你要用多了第三方登录服务其实很容易发现,如果哪家身份认证提供商不是用的 HTTPS,你可以去乌云提漏洞了
。