OAuth2设计上的思考以及CSRF攻击概述

OAuth2授权码模式的基本授权流程

此处以github第三方认证为例, 说明如何通过github的OAuth2服务来以github的身份登陆网站A

请注意, 事先网站A已经在github平台申请了一个oauth2应用了, 填写了回调地址, 且拥有了client_idclient_secret

1.用户浏览器向网站A发起一个http请求(GET方法访问https://www.justfortest.com/login/oauth2/github)

2.网站A接收到请求, 立马重定向(redirect)到github授权页面(此处是https://github.com/login/oauth/authorize?client_id=github上申请的client_id)

标准的授权码模式(不代表github一定支持), 此处可选的query有:

  • response_type: 我们所介绍的授权方式固定为”code”, 代表授权码模式, 其它模式暂不讨论
  • client_id: github上申请的一个id, 对应创建的某个oauth认证应用
  • redirect_uri: 重定向url, 可选, 代表接收code的重定向地址, 但是github给写死了, 这个query对github没用
  • scope: 表示申请的权限范围, 可选项, 此处我们用github不填写这个
  • state: 可以指定任意值, 认证服务器在在回调返回code的时候原样返回, 这里是安全选项, 我们此处不填写这个, 后面再来讨论它有什么作用

3.用户输入密码, 然后点击授权

4.点击授权后, github重定向到网站A的一个回调接口, 并带上一个时效很短的code。比如这个回调可能是https://www.justfortest.com/login/oauth2/github/callback?code=一个code(值得注意的是, 这个code只能用于提取token, 且只能提取一次, 提取后即失效, code失效很短, 可能几分钟就是失效了)

5.然后网站A拿到了这个code, 向github服务器发送请求, 拿到一个token。比如网站A向github发送这个请求https://github.com/login/oauth/access_token?client_id=github配置的应用id&client_secret=github上配置的应用密钥&code=之前拿到的code。然后github返回的结果就是一个token

6.最后网站A以这个token为鉴权密钥, 访问github的api接口(https://api.github.com/user, 注意带上之前提到的Bearer token), 拿取用户信息

CSRF概述

如果用户进入黑客的网站, 它可以用两种方式伪造跨域请求。下面展示一种POST跨域请求, 它利用了在mybankwebsite已经登陆的用户的cookie信息完成了转账工作:

1
2
3
4
5
<form action="https://www.mybankwebsite.com/transfer_money" method="POST">
 <p>ToBankId: <input type="text" name="to_bankId" /></p>
<p>Money: <input type="text" name="money" /></p>
<p><input type="submit" value="Transfer" /></p>
</form>

具体原理就是, 相当于用户可以用post请求访问了https://www.mybankwebsite.com/transfer_moneymybankwebsite检查到了cookie里面携带的登陆信息(比如可以用session维护用户银行账户的登陆状态)。此时用户的钱就被打走了

此外, get请求也是可以的:

1
<img src=http://www.mybank.com/Transfer.php?toBankId=11&money=1000>

同样能通过用户在网站mybankwebsite之前携带的登陆cookie, 悄无生息地用用户的身份转走钱

所以CSRF如何防御?

问题就在, 明明我是在黑客网站访问信息的。然而黑客网站却能对被攻击网站发送网络请求, 并携带用户浏览器在被攻击网站的cookie, 这显然是很abnormal的

下面描述被攻击网站的的一些情况, 这些将是被黑客所利用的信息: 如果用户用post方法访问https://www.mybankwebsite.com/transfer_money?&money=1000000&for=Markity, 那么登陆者将进行一个转账操作

那么要进行防御, 得从服务端入手, 要防范这个攻击, 可以从以下方法入手

1.对关键的url, 限制其访问的来源(别理就完了)

比如需要访问http://bank.example/withdraw?account=bob&amount=1000000&for=Mallory, 用户必须先登陆 bank.example, 然后通过点击页面上的按钮来触发转账事件。这时,该转帐请求的Referer值就会是转账按钮所在的页面的URL。在关键的url中, 检查这个Referer是否来自本网站, 是完全能够防范攻击的

然而缺点是, 这种操作与浏览器有关, 对于某的老浏览器, 比如IE6, 在黑客网站有能力篡改Referer, 那就无大语了

并且即使是最新的浏览器, 也允许用户禁用Referer来保证用户的隐私, 有些代理服务器和防火墙也会将引用地址信息过滤掉, 那么就别用Referer来做csrf了, 别理它就完了

2.对需要表单提交的请求, 检查csrf-token(目前最好的办法)

在转账页面, 构造如下表单:

1
2
3
4
5
6
<form action="https://www.mybankwebsite.com/transfer_money" method="POST">
<input type="hidden" name="csrf-token" value="qweqweniqwnieqweqweqwmoeowq"></input>
 <input type="text" name="for"/>
<input type="text" name="money"/>
<input type="submit" value="Transfer"/>
</form>

对于转账操作, 会将csrf-token传给服务端, 那么服务端可以检测这个token, 来保证表单POST的安全性

下面是参考django的思路:

django会检查每个http请求的headers里面的X-CSRFToken项的值是否和cookie里面保存的值相同,如果不相同或者缺失,就拒绝这个请求,如果相同,说明这次请求是从真实用户发起的

3.cookie的samesite选项(别理就完了)

前面提到, csrf的根本就是在黑客网站发送给银行网站的http请求 居然 携带了用户之前访问银行账户的cookie。这个cookie就携带了用户的登陆状态, 此时黑客网站就能通过发送请求的方式利用用户的登陆状态干坏事。

所以如果能让在银行网站设置的cookie, 在进行跨域请求时不传递, 那就万事大吉了。这是浏览器的一个很新的功能, 可以设置某个cookie的严格程度, 下面是它的三种设置选项:

1
2
3
4
5
6
7
8
9
10
11
12
- Strict
完全禁止跨站传递Cookie,比如A网站通过超链接跳转B网站也不行,必须用户手动输入这个B网站浏览器才允许使用B网站的Cookie。
过于严格,很少使用。
- Lax
相对宽松(reLax)的规则,大部分情况也不允许跨站传递Cookie,但是对于较为安全的场景:超链接跳转,get类型的Form表单,是允许的。
这个模式是大部分浏览器的SameSite的默认取值(当服务端SetCookie没有制定SameSite时,大部分现代浏览器会默认使用Lax)。
使用Lax已经能够杜绝CSRF攻击。
- None
完全没有限制。
老版本浏览器默认仍然会使用None作为SameSite的默认取值。
大部分现代浏览器默认是Lax。
以及None默认过于危险,如果要使用SameSite=None则浏览器会要求网站服务使用https才行。

这也涉及到了浏览器支持的问题, 因此也不是好办法, 别理它就完了

所以CSRF对OAuth2产生了什么影响?

假如现在网站A集成了账户绑定github账户的功能, 访问https://www.a.com/user/github_bind, 用户就跳转到github的第三方登陆界面。登陆后重定向到https://www.a.com/user/github_bind/callback。那么此时, 用户就能将当前session的账号绑定到github账户上了。之后用github第三方登陆, 就能登陆用户账号。也就是说这个网站提供了github绑定和github登陆两个功能

看下面的步骤:

0.用户事先已经登陆到了网站A, 并且在网站A上有cookie保持登陆状态

1.攻击者自己访问被攻击应用网站的github第三方登陆功能, 然后输入自己的github账户密码, 点击登陆

2.通过工具截获github的code, 但是自己不访问对应的接收code的接口, 而是保存这个code的”活性”(code使用一次就无效了, 我们需要保持它处于有效的状态)

3.用户访问黑客网站, 黑客通过重定向的方式将用户导到被攻击网站的github绑定的接口(https://www.a.com/user/github_bind/callback), 并带上之前的code, code是上面截获的有活性的, 没过期的code

4.被攻击网站用这个code, 搭配自己的client_id, client_secret换取token, 拿取用户信息(这是攻击者的用户信息), 并将攻击者的github信息写入数据库, 用户就将攻击者的github和自己账户绑定了!

5.现在攻击者能用自己的github账户, 第三方登陆到用户的账户了, 用户的数据就完了

那么怎么解决?之前提到的state就提供了帮助, 下面是具体的方案:

用户访问https://www.a.com/user/github_bind后, 重定向带有state属性, 需要放加密的签名信息

只需要在https://www.a.com/user/github_bind/callback请求那里, 检验state的合法性就行了, 先进行解密, 然后校验签名是否合法

疑问与讨论

我们尝试列举一些问题, 并思考为何如此, 下面以QA的形式进行

  1. 为什么不能这么设计?即github回调直接返回token, 而不需要code

我来列举两个原因, 其中A原理最为主要, B原因比较牵强:

A. 因为OAuth2不强制要求使用https, 因此很有可能这个token会被监听, 被盗取。 因此, code不能携带任何敏感信息, 只有网站A(也就是github应用密钥的唯一知情人)才能通过code拿到token, 进一步拿到用户信息。如此在http协议下, 也能保证安全

B. 我们不能让用户能够拿着token为所欲为(试想用户如果知道token怎么用, 那么他可以拿着这个token自己玩, 这看上去就很不靠谱), 而是让网站A才有权力做这一切

  1. 为什么不这么设计?即不需要申请token, 用code+密钥配合访问github接口直接返回用户信息?

我来表述一下我的思考, 我认为这么设计是完全合理且安全的。但是OAuth的设计初衷是使用第三方登陆服务做授权, 获取网站A的RESTFUL API的token(现在的API设计多用token做鉴权)。

github可以在第5步, 返回的结果不是token, 而直接就是用户信息, 这样做一样安全, 且简化了步骤(即不用token再启动一个http连接来得到用户信息, 更为快速且简洁)。

但是OAuth就不是拿来做这个的, OAuth的目的只在获得接口token, 至于这个token是用来干嘛的, 此协议管不着。

因此OIDC(OpenID Connect)应运而生, 它就是来解决这个问题的

OIDC是什么?

OIDC没有什么新鲜的设计, 只是在用code换取token的那一步, 可以选择是否需要认证服务器返回一个id token(通过在换取token的那个请求上加一个Query: scope="openid")。id token是一个JWT格式的信息。这个id token包含了一些基本的用户信息, 足以来区分不同的用户。下面是一个典型的JWT信息(只包含了payload部分):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
sub: '5f719946524ee1099229496b', // subject 的缩写, 为用户 ID
birthdate: null,
family_name: null,
gender: 'U',
given_name: null,
locale: null,
middle_name: null,
name: null,
nickname: null,
picture: 'https://files.authing.co/user-contents/photos/9a9dc4d7-e756-45b1-81d8-095a28e476c6.jpg',
preferred_username: 'test1',
profile: null,
updated_at: '2020-09-30T07:12:19.401Z',
website: null,
zoneinfo: null,
email: 'test1@123.com',
email_verified: false,
phone_number: null,
phone_number_verified: false,
nonce: 'E65b1QoUYt',
at_hash: 'B3IgOYDDa0Pz8v1_9qZrAw',
aud: '5f17a529f64fb009b794a2ff',
exp: 1601453558,
iat: 1601449959,
iss: 'https://oidc1.authing.cn/oidc'
}

负责签名的是认证服务器, 但是如果拥有应用密钥, 可以在本地对id token进行验签

OIDC解决了什么?

如果是拿来做认证, 像QQ, github压根没有提供这个OIDC。只是规定了用token访问特定的接口, 然后就能拿到用户信息。所以仅仅对一般网站的第三方认证来说, OIDC没有解决任何事情, 压根没有多少平台实现了这个协议, 也无从用它来做认证一说了。