OAuth 2.0 协议是一种三方授权协议,目前大部分的第三方登录与授权都是基于该协议的标准或改进实现。OAuth 1.0 的标准在 2007 年发布,2.0 的标准则在 2011 年发布,其中 2.0 的标准取消所有 Token 的加密过程,并简化了授权流程,但因强制使用 HTTPS 协议,被认为安全性高于 1.0 的标准。
对于 OAuth2.0 协议(以下简称 OAuth 协议)的第一次接触,我相信大部分开发者都是通过对接第三方登录才开始知道和了解该协议。的确,OAuth 协议被广泛应用于第三方授权登录中,借助第三方登录可以让用户免于再次注册之苦,支持第三方登录也对这些网站、APP起到了积极的作用,免去了复杂的注册过程,用户体验更佳,更愿意去登录。这样在提高留存率的同时,也更加易于收集用户的一些非敏感信息等,另外还可以借助一些社交类的第三方账号进行站点推广等。
帐号服务对于公司来说是一个基础类服务,既简单也复杂。说它简单,是因为帐号的主要业务就是注册和登录,相信很多人在初次接触 WEB 开发的时候,第一个作业就是实现一个用户注册和登录的流程;说它复杂,是因为帐号服务往往是一个公司开展其它业务的基础,必须是公司业务中 QPS 最高的业务之一,需具备高可用、低延迟等特点,因为涉及到用户的敏感信息,还需要在安全方面下足功夫,近几年听到的盗号、拖库事件越来越没有新鲜感了。所以对于一个规模不大的公司来说,将主要人力投入在建立自己的帐号业务上是一件性价比很低的事情,这个时候接入大公司的第三方帐号登录,应该是更加可取的一种选择。
作为第三方登录服务提供方,我们的核心矛盾点就是既要让用户在对接我们服务的APP上登录,同时还不能让该APP拿到用户的登录凭证。解决这一矛盾的利器就是 token(中文译为令牌),而 OAuth 协议的最终目的就是给第三方应用下发 token,它记录了用户的登录或授权状态,通过将 token 传递给第三方应用,既能让第三方应用登录并拿到用户许可数据,也可以将用户的凭证牢牢拽在自己的手里(token是加密存储的,所以不担心因token下发而泄露用户凭证数据)。
说到用户登录状态的记录,我们可能最先想到的是 session 机制,想想你在做的第一个用户登录应用的时候,是不是拿服务器的 session 去记录用户是否登录。这一做法简单,但是也存在问题,session 说到底也还是缓存,当用户量较大的时候,需要相当大容量的缓存才能够容纳所有用户的登录状态,并且我们的 WEB 服务器往往有多台,通过负载均衡机制来提升服务的可用性,这样的场景下,我们不能简单的通过本地 session 来记录用户的登录状态,必须有专门的 session 服务器,或者其它的一些 session 复制措施,还需要考虑宕机造成的 session 丢失等问题,总之用户量大了,许多最初不是问题的问题逐渐暴露出来,有的甚至可能是极其棘手的。实际上对于用户登录状态的保存,我们可以走 token 机制,让客户端自己去保存用户的登录状态,将服务器从繁重的压力中解脱出来,利用 SSO(单点登录:Single Sign On)来实现公司内各业务之间“一次登录,到处可用”。
回到 OAuth 协议,上面的论述可能侧重了第三方登录,实际上登录只是一个授权的过程,对于一个应用,其最终目的还是希望能够拿到用户存储在资源服务器上的用户数据,所以登录授权还只是第一步,后续 APP 还需要携带 token 去资源服务器请求用户数据,这个时候是一个鉴权的过程,OAuth 协议的主要目的在于授权,至于鉴权,实现上主要是还是对 APP 传递过来的 token 进行解析和验证,这一块相对要简单一些,所以下面主要讲解 OAuth 授权的流程。
客户端是 OAuth 服务的接入方,其目的是请求用户存储在资源服务器上的受保护资源,客户端可以移动应用、网页应用,以及电视应用等等。
用户代理是用户参与互联网的工具,一般可以理解为浏览器。
受保护资源所属的实体,比如资源的持有人等,下文的用户即资源所有者。
授权服务器的主要职责是验证资源所有者的身份,并依据资源所有者的许可对第三方应用下发令牌。
托管资源的服务器,能够接收和响应持有令牌的资源访问请求,可以与授权服务器是同一台服务器,也可以分开。
2.2.1 访问令牌(access token)
访问令牌是在用户授权许可下,授权服务器下发给客户端的一个授权凭证,该令牌所要表达的意思是“用户授予该APP在多少时间范围内允许访问哪些与自己相关的服务”,所以访问令牌主要在 时间范围 和 权限范围 两个维度进行控制,此外访问令牌对于客户端来说是非透明的,外在表现就是一个字符串,客户端无法知晓字符串背后所隐藏的用户信息,因此不用担心用户的登录凭证会因此而泄露。
2.2.2 刷新令牌(refresh token)
刷新令牌的作用在于更新访问令牌,访问令牌的有效期一般较短,这样可以保证在发生访问令牌泄露时,不至于造成太坏的影响,但是访问令牌有效期设置太短存在的副作用就是用户需要频繁授权,虽然可以通过一定的机制进行静默授权,但是频繁的调用授权接口,之于授权服务器也是一种压力,这种情况下就可以在下发访问令牌的同时下发一个刷新令牌,刷新令牌的有效期明显长于访问令牌,这样在访问令牌失效时,可以利用刷新令牌去授权服务器换取新的访问令牌,不过协议对于刷新令牌没有强制规定,是否需要该令牌是客户端可以自行选择。
2.2.3 回调地址(redirect uri)
OAuth2.0 是一类基于回调的授权协议,在授权码模式中,整个授权需要分为两步进行,第一步下发授权码,第二步根据第一步拿到的授权码请求授权服务器下发访问令牌。OAuth 在第一步下发授权码时,是将授权码以参数的形式添加到回调地址后面,并以 302 跳转的形式进行下发,这样简化了客户端的操作,不需要再主动去触发一次请求,即可进入下一步流程。
回调请求的设计却存在一个很大的安全隐患,坏人如果在客户端请求过程中修改了对应的回调地址,并指向自己的服务器,那么坏人可以利用这种机制去拿到客户端的授权码,继而走后面的流程,最终拿到访问令牌,另外坏人可以利用该机制引导用户到一个恶意站点,继而对用户发起攻击。以上两点都是该机制对于用户所造成的安全威胁,对于授权服务器而言,也存在一定的危害,坏人可以利用该机制让授权服务器变成“请求发送器”,以授权服务器为代理请求目标地址,这样在消耗授权服务器性能的同时,也对目标地址服务器产生 DDOS 攻击。
为了避免上述安全隐患,OAuth 协议强制要求客户端在注册时填写自己的回调地址,这个回调地址的目的是为了让回调请求能够到达客户端自己的服务器,从而可以走获取访问令牌的流程。客户端可以同时配置多个回调地址,并在请求授权时携带一个地址,服务器会验证客户端传递上来的回调地址是否与之前注册的回调地址相同,或者前者是后者集合的一个元素,只有在满足这一条件下才允许下发授权码,同时协议还要求两步请求客户端携带的回调地址必须一致,通过这些措施来保证回调过程能够正常达到客户端自己的服务器,并继续后面拿授权码换取访问令牌的流程。
2.2.4 权限范围(scope)
访问令牌自带过期时间,可以在时间维度上对授权进行控制,而在范围维度上,OAuth 引入了一个 scope 的概念。scope 可以看做是一个对象,包含一个权限的 ID,名称,以及描述信息等,比如“获取您的基本资料(头像、昵称)”。应该在接入账号服务时必须向第三方登录服务提供方申请响应的 scope,并在请求授权时指明该参数(否则表明获取该应用所允许的所有权限),这些权限在用户确认授权时,必须毫无保留的展示给用户,以让用户知道该APP需要获取用户的哪些数据或服务。
OAuth协议已定义了 4 种授权模式,其中最具代表性的就是授权码模式,这个在 3.1 小节中详细介绍,这里先以该模式来简单感受一下 OAuth2.0 的授权流程,授权流程图如下:
假设整个流程开始之前,用户已经登录,那么整个授权流程如下:
整个流程中,客户端都无法接触到用户的登录凭证信息,客户端通过访问令牌请求受保护资源,用户可以通过对授权操作的控制来间接控制客户端对于受保护资源的访问权限范围和时效。
OAuth2.0 相对于 1.0 版本在授权模式上做了更多的细化,已定义的授权模式分为四种:1)授权码模式(Authorization Code Grant);2)隐式授权模式(Implicit Grant);3)资源所有者密码凭证模式(Resource Owner Password Credentials Grant);4)以及客户端凭证模式(Client Credentials Grant)。
授权码模式在整个授权流程上与 1.0 版本最贴近,但是整个流程还是要简化了许多,也是 OAuth2.0 中最标准,应用最广泛的授权模式。这类授权模式非常适合于具备服务端的应用,当然现在大多数 APP 都有自己的服务端,所以大部分 APP 的 OAuth 授权都可以采取授权码模式,下图为授权码各个角色之间的交互时序(这里让用户直接参与其中,省略了用户代理):
整个授权流程说明如下(具体参数释义见下文):
3.1.1 获取授权码
授权码是授权流程的一个中间临时凭证,是对用户确认授权这一操作的一个暂时性的证书,其生命周期一般较短,协议建议最大不要超过10分钟,在这一有效时间周期内,客户端可以凭借该暂时性证书去授权服务器换取访问令牌。
请求参数说明:
名称 | 是否必须 | 描述信息 |
---|---|---|
response_type | 必须 | 对于授权码模式 response_type=code |
client_id | 必须 | 客户端ID,用于标识一个客户端,等同于appId,在注册应用时生成 |
redirect_uri | 可选 | 授权回调地址,具体参见 2.2.3 小节 |
scope | 可选 | 权限范围,用于对客户端的权限进行控制,如果客户端没有传递该参数,那么服务器则以该应用的所有权限代替 |
state | 推荐 | 用于维持请求和回调过程中的状态,防止CSRF攻击,服务器不对该参数做任何处理,如果客户端携带了该参数,则服务器在响应时原封不动的返回 |
请求参数示例:
GET /authorize?response_type=code&client_id=s6BhdRkqt3&state=xyz&redirect_uri=https://client.example.com/cb HTTP/1.1
Host: server.example.com
客户端携带上述参数请求授权服务器的令牌端点,授权服务器会验证客户端的身份以及相关参数,并在确认用户登录的前提下弹出确认授权页询问用户是否授权,如果用户同意授权,则会将授权码(code)和state信息(如果客户端传递了该参数)添加到回调地址后面,以 302 的形式下发。
成功响应参数说明:
名称 | 是否必须 | 描述信息 |
---|---|---|
code | 必须 | 授权码,授权码代表用户确认授权的暂时性凭证,只能使用一次,推荐最大生命周期不超过10分钟 |
state | 可选 | 如果客户端传递了该参数,则必须原封不动返回 |
成功响应示例:
HTTP/1.1 302 Found
Location: https://client.example.com/cb?code=SplxlOBeZQQYbYS6WxSbIA&state=xyz
如果请求参数错误,或者服务器端响应错误,那么需要将错误信息添加在回调地址后面,以 302 形式下发(回调地址错误,或客户端标识无效除外)。
错误响应参数说明:
名称 | 是否必须 | 描述信息 |
---|---|---|
error | 必须 | 错误代码 |
error_description | 可选 | 具备可读性的错误描述信息 |
error_uri | 可选 | 错误描述信息页面地址 |
state | 可选 | 如果客户端传递了该参数,则必须原封不动返回 |
错误响应示例:
HTTP/1.1 302 Found
Location: https://client.example.com/cb?error=access_denied&state=xyz
3.1.2 下发访问令牌
授权服务器的授权端点在以 302 形式下发 code 之后,用户 User-Agent,比如浏览器,将携带对应的 code 回调请求用户指定的 redirect_url,这个地址应该能够保证请求打到应用服务器的对应接口,该接口可以由此拿到 code,并附加相应参数请求授权服务器的令牌端点,授权端点验证 code 和相关参数,验证通过则下发 access_token。
请求参数说明:
名称 | 是否必须 | 描述信息 |
---|---|---|
grant_type | 必须 | 对于授权码模式 grant_type=authorization_code |
code | 必须 | 上一步骤获取的授权码 |
redirect_uri | 必须 | 授权回调地址,具体参见 2.2.3 小节,如果上一步有设置,则必须相同 |
client_id | 必须 | 客户端ID,用于标识一个客户端,等同于appId,在注册应用时生成 |
client_secret
),那么客户端必须携带该参数以让授权服务器验证客户端的有效性。请求参数示例:
POST /token HTTP/1.1
Host: server.example.com
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA&redirect_uri=https://client.example.com/cb
授权服务器需要验证客户端的有效性,以及是否与之前请求授权码的客户端是同一个(请求授权时的信息可以记录在 code,或以 code 为 key 建立缓存),授权服务器还要保证code 处于生命周期内(推荐10分钟内有效),且只能被使用一次。授权服务器验证通过之后,生成 access_token,并选择性下发 refresh_token,OAuth2.0 协议明确了 token 的下发策略,对于生成策略没有做太多说明。
成功响应参数说明:
名称 | 是否必须 | 描述信息 |
---|---|---|
access_token | 必须 | 访问令牌 |
token_type | 必须 | 访问令牌类型,比如 bearer,mac 等等 |
expires_in | 推荐 | 访问令牌的生命周期,以秒为单位,表示令牌下发后多久时间过期,如果没有指定该项,则使用默认值 |
refresh_token | 可选 | 刷新令牌,选择性下发,参见 2.2.2 |
scope | 可选 | 权限范围,如果最终下发的访问令牌对应的权限范围与实际应用指定的不一致,则必须在下发访问令牌时用该参数指定说明 |
最后访问令牌以 JSON 格式响应,并要求指定响应首部 Cache-Control: no-store 和 Pragma: no-cache。
成功响应示例:
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache
{
"access_token":"2YotnFZFEjr1zCsicMWpAA",
"token_type":"example",
"expires_in":3600,
"refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA",
"example_parameter":"example_value"
}
错误响应参数说明:
名称 | 是否必须 | 描述信息 |
---|---|---|
error | 必须 | 错误代码 |
error_description | 可选 | 具备可读性的错误描述信息 |
error_uri | 可选 | 错误描述信息页面地址 |
错误响应示例:
HTTP/1.1 400 Bad Request
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache
{
"error":"invalid_request"
}
3.1.3 对于授权码模式的一点感悟
授权码授权模式是 OAuth2.0 协议已定义 4 种模式中最严谨的模式,剩余 3 中模式都是建立在一些特殊场景下,并对这些场景做了一些妥协和优化。授权码授权流程分为两步走,将用户授权与下发 token 分开,这给授权带来了更多的灵活性,正常授权过程中必须经过用户登录这一步骤,在用户已登录的前提下,可以直接询问用户是否同意授权,但是在一些场景下,比如内部走 SSO 登录的应用集成了基于 OAuth 登录的第三方应用,这个时候在 OAuth 授权登录第三方应用时用户体验较好的流程是不需要用户再一次输入用户名和密码登录的,这就需要将外围 APP 的登录态传递给该应用,但是这样是存在安全问题的,用户的登录态必须把握在走 SSO 登录流程的应用中,这样的场景下授权码授权模式的两步走流程就可以满足在不交出用户登录态的情况下,无需再次登录即可授权。
内部应用可以拿着第三方应用的 client_id 等信息代替第三方应用去请求获取 code,因为自己持有用户的登录态,所以过程中无需用户再次输入用户名和密码,拿到 code 之后将其交给第三方应用,第三方应用利用 code 和自己的 client_secret 信息去请求授权服务器下发 token,整个流程内部应用不需要交出自己持有的用户登录态,第三方应用也无需交出自己的 client_secret 信息,最终却能够实现在保护用户登录凭证的前提下无需再次登录即可完成整个授权流程。
略。(三方云接入不支持此类授权模式)
本篇介绍了 OAuth2.0 授权协议的理论知识,OAuth2.0 被广泛应用于第三方授权登录,很多其它的协议都是可以基于该协议进行改造的,比如前面多次提到的 SSO,作为开发人员,还是建议对该协议或多或少有些了解。