说起OAuth,大多数人都听说过,有的还在工作中用到过。OAuth可以用来保护资源,尤其是API。不过当深入OAuth并仔细看看的时候,原来OAuth有很多值得讨论以及注意地方。这篇文章是《OAuth 2 In Action》的阅读笔记与总结。
0x00 从一些实际场景开始
生活中会遇到一些场景,在这些场景下我们作为一些服务的用户需要授权别人来代替我们做一些事情。比如:
- 开车到一个地方之后,可能需要别人代替自己把车泊好;
- 使用了一个网络服务比如网络照片打印服务,需要授权这个服务访问自己的云存储照片来打印;
- ……
让别人代替我们做一些事情,这就涉及到了授权(Authorization)。
当需要泊车员泊车时,需要我们给泊车员车的钥匙,不过如果把我们自己的钥匙给他的话可能会有风险(泊车员开车跑路了)。所以我们需要给他一个只能用来泊车的钥匙(就是泊车钥匙),这样会更安全一些。
当我们需要云打印服务打印我们的云存储照片时,如果给他我们云存储的账号和密码可能会有安全风险(云打印服务就相当于我们自己了啥都能干),如果只给云打印服务它需要的权限会更安全。
OAuth就是用来保护我们资源(云存储照片等)的一个授权协议,它不管保护的是啥。
OAuth的目的就是,让客户端为资源拥有者访问受保护资源:
那么,如果没有OAuth,怎么达到授权的目的呢?一些常见的方法如下:
- 客户端复制用户的凭据(比如账号密码,会话cookie等)然后登陆另一个服务;
- 客户端向用户索要账号密码用于登陆;
- 为客户端办法一个超级的开发者秘钥(Developer Key);
- 给用户一个特殊的密码,专门给第三方服务使用。
这些方法都不能很好地保护用户的资源。
0x01 OAuth是什么
前面提到的几种方法都或多或少地有造成用户资源泄露的风险。
1.1 授权访问
为了避免这种情况发生,需要用户对客户端授权,客户端拿到授权之后就可以申请访问权限,然后在一定的访问权限下访问用户的资源。
这就是授权访问。流程如下:
整个流程涉及到三个主要步骤:
- 客户端向用户申请授权。具体的方法可能有多种;
- 客户端拿到授权后就可以向授权服务器申请访问令牌;
- 客户端拿到令牌后就可以通过这个令牌访问用户资源了。
1.2 OAuth中的角色
在上面的授权流程中涉及到了如下的几个角色:
- OAuth客户端(Client):是代表此资源拥有者访问受保护资源的软件(比如云打印服务);
- 受保护资源(Protected Resource):需要OAuth访问令牌才能访问的用户资源(比如云存储服务中的照片);
- 资源拥有者(Resource Owner):就是有权将访问权限授权给客户端的用户了;
- OAuth授权服务器(Authorization Server):是一个HTTP服务器,在OAuth中充当中心角色。
授权服务器对资源拥有者和客户端进行身份认证,让资源拥有者对客户端授权、为客户端颁发令牌。
1.3 OAuth中的组件
除了四种角色外,OAuth还依赖如下几种组件:
- 授权许可(Authorization Grant):授权许可是客户端获取用户授权的方法(grant_type);
- 访问令牌(Access Token):授权服务器颁发给客户端,有了令牌才可以访问受保护的资源;
- 权限范围(Scopes):表示一组受保护资源的权限。可以对令牌限制具体的权限范围;
- 刷新令牌(Refresh Token):令牌可能会过期,这时可以用刷新令牌获取新的令牌。
1.4 一个完整过程
下面通过一个完整的过程(常见的授权码许可类型)来看看整个流程中每个角色以及组件的作用。
- 资源拥有者(用户)登录客户端,希望客户端代替自己做一些事情;
- 客户端发现完成这件事情需要访问自己没有权限的资源,所以将资源拥有者重定向到授权服务器,在请求中附带一个授权请求以及所需要的权限,同时在请求中还会包含自己的身份信息;
- 授权服务器接收到请求后对客户端进行校验,通过后会要求用户进行身份认证。这对确认资源拥有者的身份以及能向客户端授予哪些权限很重要;
- 资源拥有者身份认证直接在用户(浏览器)和授权服务器之间进行,客户端不可见;
- 用户进行授权,同时还可以缩减客户端申请的权限范围。同时授权服务器还可以保存授权决策,以便下一次使用;
- 然后授权服务器将用户重定向到客户端,这里就会有客户端需要的授权码(code);
- 客户端收到授权码之后,就可以直接发送给授权服务器来获取访问令牌了。这个过程不需要资源拥有者的参与;
- 授权服务器收到请求后会进行各种校验,如果有效就会颁发访问令牌(或许还有刷新令牌);
- 客户端拿到访问令牌之后,就可以把这个访问令牌发送给受保护资源进行访问了;
- 最后,受保护资源会对访问令牌进行校验,有效的话就提供资源。
1.5 交互:后端信道、前端信道
在OAuth的交互中,使用的是HTTP协议,不过并不所有的交互都是通过HTTP请求和响应完成的。
1.5.1 前端信道
在整个流程中有一些步骤中的组件不能直接通信,比如客户端通过用户向授权服务器发送授权请求,以及授权服务器返回用户的授权结果的时候,需要重定向。
这个时候需要浏览器作为媒介,隔离了浏览器两端的会话,实现了跨安全域工作。
比如,如果需要用户向其中一个组件进行身份认证,并不需要将用户凭据暴露给另一方。
两个不直接交互的组件进行通信就需要用到重定向了:
通过这两个重定向,就完成了客户端向授权服务器间接发送的授权请求以及授权服务器间接返回给客户端的授权结果。
不过通过前端信道发送的请求都可被浏览器访问,那就有在最终请求发送前被篡改的可能,所以OAuth限制了可以通过前端信道传输的信息类别,并确保只要是通过前端信道传输的信息,就不能在授权任务中单独使用(比如授权码)。
1.5.2 后端信道
除了上面通过前端信道进行的交互外,OAuth使用的都是直接的HTTP请求和响应来完成通信的。
这就是后端信道。比如客户端向授权服务器申请访问令牌以及客户端使用访问令牌访问受保护资源:
0x02 构建OAuth环境
OAuth中有四个角色:客户端、受保护资源、资源拥有者和授权服务器。
其中资源拥有者就是用户,所以构建一个OAuth环境所需要的构建的就是客户端、受保护资源和授权服务器。
2.1 构建简单的OAuth客户端
关注的点有:
- 客户端不需要理解访问令牌,令牌对客户端是透明的;
- 客户端需要提前在授权服务器上进行注册,便于身份校验;
- 客户端在获取用户授权的过程中是通过两个重定向完成的(需要提供一个redirect_uri);
- 拿到授权码之后客户端可以直接通过后端信道获取令牌并访问受保护资源;
- 客户端向受保护资源出示访问令牌的方式有三种:Authorization头部、form表单和URL参数;
- 客户端可以通过刷新令牌获取新的访问令牌而不需要用户重新授权;
- 为了添加跨站保护,可以添加state参数并对返回结果进行校验。
2.2 构建简单的OAuth受保护资源
大多数实现中,将受保护资源和授权服务器放在一起。
关注的点有:
- 建议支持通过三种方式获取访问令牌;
- 得到访问令牌之后对其进行校验;
- 可以根据访问令牌提供内容;
- 令牌尽量不出现在日志中。
2.3 构建简单的OAuth授权服务器
授权服务器关注的点有:
- 管理客户端的注册;
- 一个授权端点,对客户端进行授权;
- 一个令牌端点,向客户端颁发令牌;
- 还可以支持刷新令牌以及令牌的权限范围。
2.4 授权许可类型
客户端为了获得访问令牌需要用户的授权。
除了之前的授权码许可类型,达到这一授权的方式也有多种。
2.4.1 隐式许可类型
对于完全运行于浏览器中的应用,可以使用隐式许可类型:
- 隐式许可类型只通过前端信道和授权服务器通信;
- 默认获取了用户的授权,所以拿到的直接就是访问令牌了;
- response_type=token。
2.4.2 客户端凭据许可类型
如果没有明确的资源拥有者,或对于客户端来说资源拥有者不可区分。
比如后端系统之间的直接通信(不涉及浏览器,无法重定向)。
就可以通过客户端凭据许可类型获取授权了:
- 客户端只通过后端信道与授权服务器通信;
- 客户端直接向授权服务器进行身份认证,授权服务器也直接颁发令牌;
- 客户端可以随时获取令牌,所以无需刷新令牌;
- grant_type=client_credentials。
2.4.3 资源拥有者凭据许可类型
如果资源拥有者在授权服务器上有纯文本的账号密码,那么客户端可以向用户索要凭据,然后换取令牌:
- 客户端索要用户的凭据,和用户交互的是客户端,而不是授权服务器;
- 只是用后端信道,直接获取访问令牌;
- 其实这就是中间人攻击,不建议使用;
- grant_type=password。
2.4.4 断言许可类型
断言许可类型是OAuth的一个扩展许可类型。
客户端会得到一个结构化的且被加密保护的信息,叫做断言。
客户端可以通过向授权服务器出示这个断言获取令牌:
- 断言许可类型使用用后端信道,直接获取令牌;
- 仅用于有限的环境中,通常是企业。
2.4.5 选择合适的许可类型
可以通过下面的流程来选择一个合适的许可类型:
- 客户端是否代表某个资源拥有者?也就是说客户端区分具体用户吗?如果区分,那就从授权码、隐式或资源拥有者凭据中选择;否则在客户端凭据或断言中选择;
- 客户端代表某个用户:客户端可以对用户重定向吗?如果能,就选择隐式许可类型或授权码;如果不能,就只能选择资源拥有者凭据许可类型了;
- 客户端可以对用户重定向:客户端是否完全运行在浏览器中?如果是,选择隐式许可类型;如果不是,选择授权码许可类型;
- 对于授权码许可类型:如果客户端是原生应用的话,还应该在授权码的基础上添加动态注册(DynReg)或代码交换证明秘钥(PKCE);
- 客户端不代表某个用户:客户端是否代表自己?这时客户端不针对单个的用户,选择客户端凭据许可类型;
- 客户端既不代表某个用户也不代表自己,但是在权威第三方的指示下运行,选择断言许可类型。
2.5 客户端的类型
在前面一节中已经对不同类型的客户端有了一个简单的认识了,这里详细看看客户端的类型。
2.5.1 Web应用
就是常见的Web服务器,通常使用cookie与浏览器保持连接。
- 可以充分利用前端信道和后端信道;
- 可以使用授权码、客户端凭据或断言等许可类型;
- 不适合使用隐式许可类型。
2.5.2 浏览器应用
一般使用JavaScript,完全运行在浏览器中。
- 只能使用前端信道;
- 适合隐式许可类型。
2.5.3 原生应用
这种客户端是运行在用户终端上的应用,需要将软件编译然后安装到用户的终端上。
- 容易使用后端信道;
- 可以在原生应用中嵌入浏览器来使用前端信道;
- 对于移动应用来说,重定向的URI可以使用自定义的格式。
2.5.4 处理秘钥
客户端秘钥的作用就是让客户端向授权服务器进行身份认证。
在OAuth 1中,不管什么类型的客户端,都要有自己的秘钥,不过对于原生应用和浏览器应用来说做不到这点。
这是因为,秘密有两种:
- 配置期间秘密(configuration time secret):客户端的每个副本中都一样;
- 运行时秘密(runtime secret):每个客户端的实例都不一会。
对于客户端秘钥来说,它属于配置期间秘密,因为它唯一标识客户端。
而访问令牌、刷新令牌和授权码属于运行时秘密。
OAuth 2.0 不要求所有客户端都有客户端秘钥,而是将客户端分为两种:
- 公开客户端(public clients):不能保持配置期间秘密,因为这种秘密都会以某种方式暴露给最终用户,客户端有多个实例,每个实例都可以获取这个秘密。所以公开客户端没有客户端秘钥,比如绝大多数的浏览器应用和原生应用;
- 保密客户端(confidential clients):能够保持客户端秘钥,每个实例有独立的配置信息,包括client_id和client_secret,最终用户难以获得。比如最常见的Web应用,它运行在Web服务器上。
可以通过动态注册,将公开客户端的配置期间秘密转化成运行时秘密。
0x03 OAuth 2.0 的实现与漏洞
这里展示了部署OAuth系统不当造成的一些危害,以及如何避免。
3.1 保护客户端
这里介绍一下针对客户端的常见攻击,以及如何防御。
3.1.1 针对客户端的CSRF攻击
先看看什么是CSRF攻击:
攻击者通过正常途径获取一个授权码或者伪造一个授权码;
攻击者设法让用户的客户端使用攻击者的授权码。比如:
<img src="https://oauthclient.com/callback?code=ATTACKER_AUTHORIZATION_CODE">
这样,资源拥有者的客户端就和攻击者的授权码产生了联系。
客户端的应对措施是使用一个难以猜测的state(至少32位随机字符串)参数并进行校验。
3.1.2 客户端凭据失窃
不同类型的客户端对客户端凭据:
- Web应用可以将client_secret安全地存储在服务器上;
- 浏览器应用不具备保存client_secret的能力;
- 对于原生应用,应该动态注册生成client_id和client_secret。
客户端需要保护好client_secret不外漏。
3.2 保护受保护资源
- 防止跨站脚本(XSS)攻击:过滤不可信数据+选择合适的Content-Type;
- 支持隐式许可:使用同源策略;
- 对客户端进行认证;
- 尽可能多地对客户端传过来的token进行校验;
- 尽量缩小令牌的权限范围,遵循集合最小化原则。
3.3 保护授权服务器
授权服务器处于系统的中心位置,保障它的安全更加复杂。
3.3.1 会话劫持
记住:授权码只能使用一次,使用之后立即销毁。
同时,赋予授权码更多的信息,比如绑定对应的客户端client_id并进行校验等。
3.3.2 客户端假冒
授权服务器需要对客户端进行严格管理,防止客户端假冒。
应对措施有:
- 严格校验redirect_uri;
- 对授权码和令牌的生成都添加客户端的详细信息;
- 增加额外不可预测参数(比如微信第三方平台授权的ticket推送);
- 限制客户端调用的IP名单。
0x04 更进一步
接下来从令牌格式和用户身份认证两方面深入了解一下OAuth 2.0。
4.1 OAuth令牌
令牌就是一种特殊的秘钥。
在前面的流程中,使用的令牌都是Bearer令牌,也就是一个长的随机字符串。
授权服务器和受保护资源都需要可以访问令牌的详细信息来进行校验,可以通过共享数据库的方式来达到这个目的。
不过有的时候受保护资源和授权服务器不能共享数据库。
这时就需要结构化令牌或令牌内省了。
4.1.1 结构化令牌JWT
结构化令牌将所有必要的信息放在令牌中,这样授权服务器就可以间接和受保护资源沟通了。
JWT(JSON Web Token)的格式如下:
..
比如:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjkwMDEvIiwic3ViIjoiOVhFMy1KSTM0LTAwMTMyQSIsImF1ZCI6Imh0dHA6Ly9sb2NhbGhvc3Q6OTAwMi8iLCJpYXQiOjE0NjcyNTEwNzMsImV4cCI6MTQ2NzI1MTM3MywianRpIjoiaEZLUUpSNmUifQ.WqRsY03pYwuJTx-9pDQXftkcj7YbRn95o-16NHrVugg
通过“.”将JWT分成了三个部分,每一部分都是一个JSON结构信息的Base64编码,里面存储了所有详细的信息。
这些信息也会发给客户端,但是客户端不需要理解。
受保护资源需要理解JWT,并对客户端传来的token进行校验。
4.1.2 令牌内省(Token Introspection)
JWT的问题就是尺寸太大了。
另一种不需要共享数据库的方案就是令牌内省。
在原来Bearer的基础上,授权服务器可以提供一个获取令牌信息的接口,这样受保护资源受到客户端的令牌之后调用授权服务器的接口来查看令牌的详细信息,并进行校验。
缺点是增加了网络流量。不过受保护资源可以缓存一下令牌的详细信息。
4.1.3 内省+JWT
还可以将两个方案组合到一起,在JWT中只存储基本信息。
4.1.4 令牌撤回
之前所有关于令牌的方案中,都有一个有效期,令牌只要颁发并在有效期内都可以使用。
还可以在这个基础上添加令牌撤回的功能。
实现起来也很简单,只需要在授权服务器上添加一个撤回接口即可。
4.1.5 OAuth令牌的生命周期
综合前面的所有内容,下图展示了令牌的整个生命周期:
4.2 使用OAuth 2.0 进行身份认证
OAuth不是身份认证协议,不过可以用来构建一个。
4.2.1 OAuth 2.0 与身份认证
什么是身份认证?身份认证会告诉应用,当前的用户是谁以及是否正在使用此应用。
但是OAuth 2.0 本身不提供任何关于用户的信息,它只是获取token,使用token。
至于是谁授权的以及用户是不是存在,它一无所知。
但是可以基于OAuth 2.0 来构建一个身份认证协议。
4.2.2 OAuth到身份认证协议的映射
在身份认证协议中,需要添加下面的角色:
- 身份提供方(identity provider, IdP):提供身份信息的一方(比如QQ微信);
- 登录依赖方(relying party, RP):依赖用户身份登录的一方(比如通过QQ微信登录的第三方应用)。
为了基于OAuth 2.0 构建一个身份认证协议,需要对角色进行映射。
很自然的想法就是,身份提供方由于提供了用户的身份信息,相当于一个资源服务器。
同时身份提供方需要各种登录校验,因此也是一个授权服务器。
而登录依赖方就相当于客户端:
有了这个映射就可以基于OAuth 2.0 设计一个身份认证协议了,不过还需要在原来访问令牌的基础上添加一个ID令牌,用来携带有关身份认证事件本身的信息:
4.2.3 OpenID Connect
OpenID Connect是一个开放标准,它定义了一种使用OAuth 2.0 执行用户身份认证的互通方式。
ID令牌
OpenID Connect的ID令牌是一个经过签名的JWT,和访问令牌一起提供给客户端。
不过ID令牌是需要RP解析的。
这个ID令牌包含了关于身份认证会话的神明,包括:
- 用户标识符(sub);
- 令牌颁发者URL(iss);
- RP的客户端ID(aud);
- 令牌到期时间戳(exp);
- 颁发令牌时间戳(iat)。
RP客户端根据这些信息足以登录了。
如果想获取更多关于用户的信息(比如名称、头像等),可以通过访问令牌到身份提供方处拉取。