零散专题29 OAuth 2.0
OAuth是一种授权机制,数据的所有者告诉系统,同意授权第三方应用进入系统,获取这些数据。系统从而产生一个短期的进入令牌(token),用来代替密码,供第三方应用使用。
定义
OAuth是一种授权机制,数据的所有者告诉系统,同意授权第三方应用进入系统,获取这些数据。系统从而产生一个短期的进入令牌(token),用来代替密码,供第三方应用使用。
例子
举个例子,京东App要求访问我的微信头像和昵称,这里面,我是数据的所有者,而微信就是服务提供商,也就是上面的系统,而京东就是第三方的客户端。
当京东要求获取我的微信数据时,我不会将我的微信账号和密码直接告诉京东,然后让京东自己去登陆我的微信账号去获取对应的数据,因为这样做会有以下的缺点:
- 京东也许会保存我的密码,这样很不安全
- 京东通过密码能够获取我在微信的全部数据,我没有办法限制京东能获得哪些数据、不能获得哪些数据
- 我除了修改密码之外,没有能够取消京东获取我的微信数据的能力
- 只要有一个京东这样的、知晓我的密码的第三方APP被破解,我的微信密码就会泄露
所以京东需要通过OAuth这种授权机制来获得我的微信数据。思路是这样的:
- 京东在它的APP里选择以微信账号登陆京东APP;
- 京东客户端会跳转到微信设置的认证服务器,向我询问是否允许获取我的微信相关资料;
- 当我同意之后,微信的认证服务器就会向京东APP颁发登陆令牌(token),这个token与用户密码不同,并且在登陆的时候可以指定token的权限范围有效期;
- 京东APP获得令牌后,就可以向微信的资源服务器来申请获取用户的微信资料,微信的资源服务器根据令牌的权限和有效期向京东APP开放我的微信头像和昵称。
授权模式
OAuth的核心就是向第三方应用颁发令牌,OAuth 2.0定义了四种授权方式来让客户端得到用户的授权:
- 授权码(authorization-code)
- 隐藏式(implicit)
- 密码式(password)
- 客户端凭证(client credentials)
以上这些授权方式,第三方应用在申请令牌之前,都必须先到系统备案,说明自己的身份,然后会拿到两个身份识别码:客户端ID(client ID)和客户端密钥(client secret)。
授权码模式
授权码(authorization-code)模式,指的是第三方应用先申请授权码,然后用该授权码获取令牌。
这种方式是最常用的,也是安全性最高的,适用于后后端的Web应用。授权码通过前端传送,令牌存储在后端,所有与资源服务器的通信都在后端完成。这样的前后端分离可以避免令牌泄露。
仍旧以京东和微信举例
(1)第一步,当用户点击京东APP以微信登陆的链接后,就会跳转到微信的认证服务器,下面就是一个示意的跳转链接:
1 | https://weixin.com/oauth/authorize? |
weixin.com/oauth/authorize
就是微信认证服务器的URL,response_type
参数表示要求返回授权码(code
),client_id
参数用来表明客户端的ID,redirect_uri
是认证服务器接受或拒接请求后的跳转网址,scope
参数表示请求的授权范围
(2)第二步,用户跳转到认证服务器的页面后,会要求用户登录,然后询问是否同意给京东APP授权。用户表示同意后,认证服务器就会调回redirect_uri
参数指定的网址,在跳转时会以URL中的query
参数的形式传回一个授权码(code
)
1 | https://jd.com/callback?code=AUTHORIZATION_CODE |
上面的URL中,code
参数对应的值就是授权码
(3)第三步,京东拿到授权码后,就可以在后端向微信认证服务器请求令牌(token)
1 | https://weixin.com/oauth/token? |
上面URL中,client_id
和client_secert
参数用来让认证服务器确认京东APP的身份(client_secret
是保密的,所以只能在后端发送请求),grant_type
参数的值是AUTHORIZATION_CODE
,表示采用的授权方式是授权码,code
参数是上一步拿到的授权码,redirect_uir
参数是令牌颁发后的回调地址。
(4)第四步,微信认证服务器收到请求后,核对信息后就会办法令牌,具体的做法是向redirect_uri
指定的网址发送一段JSON数据:
1 | HTTP/1.1 200 OK |
上面JSON对象中,access_token
就是令牌,京东在后端服务器拿到了。整个过程如下图所示:
第二种方式:隐藏式
有一些Web应用是纯前端应用,没有后端,这时就不能使用第一种方式了,需要将令牌存储在前端。这种方式成为隐藏式(implicit),允许直接向前端颁发令牌,而没有授权码这个中间步骤。
(1)第一步,A网站提供一个连接,要求用户跳转到B网站,授权用户数据给A网站使用
1 | https://b.com/oauth/authorize? |
上面的URL中,response_type
参数为token
,表示要求直接返回令牌
(2)第二步,用户跳转到上面的URL后,登陆,然后同意向A网站授权。这时B网站就会跳回redirect_uri
参数指定的跳转网址,并且把令牌作为URL的Hash参数,传递给A网站。
1 | https://a.com/callback#token=ACCESS_TOKEN |
上面的token
参数就是令牌,A网站直接在前端拿到令牌。
注意,令牌在URL中是使用了Hash参数,也就是锚点位置储存,而不是使用查询字符串。这是因为当跳转网址时HTTP协议时,存在“中间人攻击”的风险,而浏览器跳转时Hash参数不会发送到服务器,减少了令牌泄露的风险。
下面于OAuth的安全设置的内容笔记,专门讨论这一部分内容。
这个过程如下所示:
这种方式把令牌直接传给前端,是很不安全的。因此只适用于安全性不搞的场景,且令牌的有效期必须非常短,通常是会话期间有效,浏览器关了令牌就失效。
第三种方式:密码式
如果高度信任某个应用,允许用户把用户名和密码直接告诉该应用,该应用使用密码来申请令牌。这种方式称为密码是(password)
(1)第一步,A网站要求用户提供B网站的用户名和密码,拿到以后,A就直接向B请求令牌
1 | https://oauth.b.com/token? |
上面的URL中,grant_type
参数是password
,代表授权方式是密码式,username
和password
就是B的用户名和密码。
(2)第二步,B网站验证身份通过后,直接给出令牌,这里给出令牌的方式不再是通过URL的跳转,而是将令牌放在JSON数据中,作为HTTP响应,返回给A网站。
这种方式需要用户给出用户名和密码,风险很大,因此只适用于其他授权方式都无法采用、并且对应用高度信任的情况下。
第四种方式:凭证式
凭证式(client credentials)适用于没有前端的命令行应用,即在命令行下请求令牌。
(1)第一步,A应用在命令行向B发出请求
1 | https://oauth.b.com/token? |
上面的URL中,grant_type
参数client_credentials
表示采用凭证式的授权方式,client_id
和client_sercret
用来表示A应用的身份。
(2)第二步,B网站验证通过后,直接返回令牌。
这种方式给出的令牌,是针对第三方应用的,而不是针对用户的,有可能多个用户共享一个令牌。
令牌的使用
拿到令牌后,A网站就可以向B网站提供数据资料的API请求数据了。
此时,每个发到API的请求,都必须带有令牌,具体做法是在请求的头信息加上Authoization
字段,令牌就在这个字段里。
1 | curl -H "Authorization: Bearer ACCESS_TOKEN" \ |
上面的命令中,ACCESS_TOKEN
就是拿到的令牌。
curl
命令是一个命令行工具,支持多种协议,可以再命令行发出网络请求,然后得到和提取数据,显示在标准输出(stdout)上面。-H
参数用来自定义头信息发送给服务器。
更新令牌
令牌的有效期到了,如果让用户重新走一遍上面的获取流程,申请新的令牌,体验会很差。OAuth 2.0允许用户自动更新令牌。
具体做法是,B网站颁发令牌的时候,一次性颁发两个令牌,一个用来获取数据,一个用于获取新的令牌。令牌到期前,用户使用refresh token
发送请求,更新令牌
1 | https://b.com/oauth/token? |
上面的URL中,grant_type
参数为refresh_token
表示要求更新令牌,refresh_token
参数与就是用于更新令牌的令牌。
B网站验证通过后,就会颁发新的令牌。
例子
按照阮一峰的例子,做一个Demo,通过OAuth,以Github进行第三方登录,获取API数据。代码在这里。
登陆的过程就是OAuth授权,实际上和前面以微信账号登陆京东APP的例子是一样的。这里面我们的网站是A,允许使用Github账号登陆。
流程如下:
- 用户点击A网站“使用Gihutb登陆”的链接,页面跳转到Github的授权页面;
- Github要求用户登陆,然后循环是否授权使用当前Github账号登陆A网站
- 用户同意登陆,Github会重定向回A网站在最开始的链接中指明的地址,同时发回一个授权码。
- A网站使用授权码,向Github请求令牌。这个过程必须在后端完成。
- Github返回令牌。
- A网站使用令牌,向Github请求用户数据。
具体过程:
(1)应用要求OAuth授权,必须在对方网站登记,换取clientID和clientSecret,证明自己的身份。Github的登记地址是这个网址。
注意,应用的callback URL必须是Homepage下的子域名。
(2)新建了一个index.html
作为项目的首页
1 |
|
在HTML代码中,跳转链接设置为https://github.com/login/oauth/authorize?client_id=f38d5d8c4b1bb8259159&redirect_uri=http://localhost:8080/oauth/redirect
其中https://github.com/login/oauth/authorize
就是Github用来获取授权码的地址,具体参考它的文档。
在index.js
中使用koa-static来托管访问静态资源,监听8080端口:
1 | const Koa = require('koa'); |
(3)当点击链接,页面会跳转到Github授权的页面
(4)点击授权后,页面跳转到之前指定的Callback URI,也就是http://localhost:8080/oauth/redirect
,所以需要在服务端代码对这个路由进行处理:
1 | const KoaRouter = require('koa-router'); |
当访问/oauth/redirect
会执行oauthController
的逻辑:
1 | const oauthController = async ctx => { |
在oauthController
中,首先我们从request
的查询参数中获取授权码,然后使用授权码 + clientID + clientSecret从Github获取 token,获取token之后再使用相应的API获取用户名,这时需要将上面获得的token作为Authoriztion
字段,加到HTTP请求的头信息里面。
拿到用户名,将页面重定向到welcome.html
,在这个页面会将URL中的用户名取出来并显示在页面。注意由于用户名可能是中文,需要在redirect
中进行安全编码,否则会发生问题。
关于OAuth的安全性问题
回调域名
在做微信登陆时,到了获取授权码code这一步,如果给微信服务器传的redirect_uri
不是申请appid时输入的域名,微信会立刻返回redirect_uri错误
的提示。
为什么会这样?因为如果微信不校验redirect_uri
,会导致中间人攻击,攻击者伪造受害者的身份,使用受害者的微信账号,登陆目标网站。
假设在申请appid时的域名是a.com
,申请的appid是123
,假设微信获取code地址是https://wx.om/code
,这样获取code的完整URL是:
1 | https://wx.com/code? |
把这个地址发送给任何一个微信联系人,他们都可以通过自己的微信账号获取code,微信会带着code参数回调到http://a.com?code=455
,然后a.com
再通过code换取access token,用户就可以使用微信账户登录a.com
。
但是如果微信不验证redirect_uri
是否是a.com
,攻击者将redirect_uri
换为他的网站(假设为hack.com
),受害人访问此链接,确认登录,微信生成code之后通过回调的方式将code传给了攻击者的网站http://hack.com?code=151
,拿到code之后,攻击者再将域名切换为http://a.com?code=151
,而这时a.com
是无法分辨这是微信直接回调还是有人从中动了手脚的地址,无差别的获取code对应的access token,攻击者以受害者身份登陆成功。
对于Implicit方式,更需要校验redirect_uri
,因为是一步到位的获取access token。
所以作为OAuth2.0 Server,redirect_uri
的域名限制是一定要做的。而作为调用者,到不必这么担心,目前大部分遵循OAuth2.0的服务都不会犯这个错误。
对于调用者开发过程中,微信后台对于redirect_uri
域名设置的限制,对于本地地址是无法正常登陆了。解决方法就是更改Host文件,将redirect_uri
的域名直接指向内网就行了:
1 | # /etc/hosts |
code与secret
前面提到了,在使用OAuth之前,需要到提供的服务的服务商进行注册,获取appid(clientID)和secert(client Secret),appid的目的是为了告诉身份认证服务器我是a.com
,而secret是为了告诉任务服务器,我真的是a.com
。这个secert相当于客户端与认证服务器之间的信物,这个信物是不能暴露给用户的,所以它的传递只能通过服务器进行传递。能够被暴露的只有appid。
如果没有secert,当出现DNS污染,本该发往a.com
的code被发往了hack.com
,这时候攻击者就会直接使用code换取token了。
OAuth 2.0设计时的一个目标是,让不支持HTTPS的网站也能够安全使用。所以code才是必须的。如果没有code,直接获取access token,流程如下:
- 用户浏览器访问
a.com
,跳转到微信OAuth服务器获取access token - 用户在微信的网页上登陆成功,并确认授权
a.com
使用微信账号登陆,微信服务器跳转到redirect_uri
并且带上access_token参数 - 用户浏览器访问带access token的连接,完成登陆。
如果a.com
不支持HTTPS,那么在最后一步,access token就完全暴露在浏览器和a.com
服务器之间的线路中。
如果a.com
支持HTTPS,那么理论上来说,可以省略获取code这一步的。
但是如果a.com
不支持HTTPS,那么使用了code,code被暴露的后果是好于access token被暴露的。这是因为OAuth 2.0协议对此规定:
- code只能使用一次
- 如果攻击者比正常用户先用了code,当用户第二次使用code时,之前通过此code获取的access token将被撤回。
所以当code被泄露时,攻击最多让正常用户有点困扰,可能登陆意外失败,或者明明看起来登陆成功但还是获取不到用户信息的情况(access token已失效),攻击者拿不到数据。
state参数
在获取code时,一般服务器会返回一个state参数,一般来说没什么用,也可以为空,但是OAuth2.0文档标注的是Recommended,在什么时候使用呢?
它在防御CSRF时是非常有用的。进行CSRF攻击,攻击者:
- 申请一个的专门用于攻击的账号
- 走正常流程,跳转到微信上登陆此账号
- 登陆成功后,微信带着code跳转回
a.com
,这个时候,攻击者拦截自己的请求不再继续进行,而是将code的链接发送给受害者,棋牌受害者点击 - 受害人点击后,继续攻击者登陆流程,不知不觉登陆了攻击者的账号
而state参数如果利用起来,作为CSRF Token,就能避免此时的发生:
- 攻击者依旧获取code并打算骗受害者点击
- 受害者点击链接,但是服务器(
a.com
)分配给受害者的设备的state值和链接里面的state值不一样,服务器(a.com
)直接返回验证state失败
state或者CSRF Token这种与设备绑定的随机字符串,复杂一点,攻击者就无计可施。
设置一个让攻击者猜不到的、跟设备(或者浏览器)绑定的state或者CSRF Token值,就是解决CSRF的关键。
Implicit授权模式
Implicit授权模式一步到位,直接返回了access token,它的最重要也是最巧妙的设计是,登陆成功后身份认证服务器跳转回来带的参数都是放在#
后面的,而不是查询参数。
这是因为,如果在没有使用HTTPS的线路上通信时,access token很容易被偷走,但是如果access token放在#
后面,浏览器发起请求时,#
后面的内容不会碎请求发送到服务器。这一样就可以防止中间人共计而只让设备用用access token。
同样,access token是一定不能存放在cookie这种可能被中间人发现的地方(除非使用HTTPS)。为了做到粳稻的安全性,access token最好连local/session storage都别放。这样理解,Implicit也就只适合SPA了,SPA不刷新页面可以让access token一直在内存里,直到关掉页面。