参考:
- 265Stuff:OAuth 2.0 Simple Example
- 周志明:《凤凰架构-架构安全性-授权-OAuth2》
- Google:OpenID Connect
一个例子:StackOverflow 使用的谷歌 OpenID Connect
为了对 OAuth2 有一个直观的认识。我们先从一个例子开始。
谷歌的 OpenId Connect 是 OAuth2 的一个实践,集成了谷歌 OpenId Connect 的网站,可以实现用谷歌账号登录它们的网站,比如说我们熟悉的 StackOverflow 就可以集成了 OpenId Connect (谷歌还有一个叫 Sign in with Google 的客户端 SDK,虽然也是基于 OAuth2,但是经过了封装和标准协议还是有些区别。比如 Reddit 等网站谷歌账号登录就是用的这个 SDK 。为了阐述标准的 OAuth2,今天我们还是用使用 OpenId Connect 的 StackOverflow 来做说明)。
如果我们之前已经用谷歌账号登录过 StackOverflow,可以先将浏览器 Cookie 缓存清除。再打开 StackOverflow 网站。
用谷歌账号登陆前 StackOverflow,打开 F12 Network,并选中 Preserve log,这样在网页重定向之后,之前的请求记录仍然会保留下来。
完成用谷歌账号登录后,我们来一一分析在这期间发生的请求
如果你的客户端应用要像 StackOverflow 那样使用谷歌的 OpenID Connect,要先在这里 credentials list in Google 创建一个 OAuth 2.0 客户端,这样你就获得了一个 clientID 和 clientSecret ,然后需要填写一个重定向 uri,它是你的一个服务端 http 接口,在这个接口里你需要用 clientSecret 和 谷歌授权码 一起去请求谷歌换取 令牌,并保存令牌。
在阅读下面的步骤之前,请保证已经了解了什么是重定向——《重定向与转发》
为了更好地表示重定向请求之间地关系,我在下面地 UML 图中,将 302 Location 的返回与第二次请求用黄色的虚线连接了起来。
我们从浏览器可以看到的相关请求有:
1、用户点击谷歌账号进行登录,returnurl 是用户现在停留的页面,我停留在 StackOverflow 首页。
https://stackoverflow.com/users/login?
ssrc=head
&returnurl=https://stackoverflow.com/
这时请求的 Request Body
{ "fkey": "0e092d8da5b2ef159f31ca6e96b702bed161d848e75e108b5619adeb57f62b49", "ssrc": "head", "email": "", "password": "", "oauth_version": "2.0", "oauth_server": "https://accounts.google.com/o/oauth2/auth" }
StackOverFlow 后端返回是 302 重定向, Location 是谷歌的 OAuth2 接口,自己的 client_id,以及 redirect_uri 是 StackOverflow 的服务端用来谷歌授权的接口
Status Code: 302 Found Location: https://accounts.google.com/o/oauth2/auth? client_id=717762328687-iludtf96g1hinl76e4lc1b9a82g457nn.apps.googleusercontent.com &scope=profile email &redirect_uri=https://stackauth.com/auth/oauth2/google &state=
{"sid":1,"st":"59:3:1b8,16:c4729f2f7e47e8e0,10:1700192426,16:9b919914a5835436,ab53156abc79d1c3efbfbd96539ee08ce612452f12f2862ff35bb6c82ad2f805","cid":"717762328687-iludtf96g1hinl76e4lc1b9a82g457nn.apps.googleusercontent.com","k":"Google","ses":"272ab7f4f4cd4640b68720f3c05770f5"} &response_type=code
OAuth2.0 的核心流程
2、浏览器收到上面的 302 后,就会用 Location 里的 url 再发出一次请求。这样就会带上 client_id 和 redirect_uri 请求到谷歌授权服务器
https://accounts.google.com/o/oauth2/auth ?client_id=717762328687-iludtf96g1hinl76e4lc1b9a82g457nn.apps.googleusercontent.com &scope=profile email &redirect_uri=https://stackauth.com/auth/oauth2/google &state=
{"sid":1,"st":"59:3:1b8,16:c4729f2f7e47e8e0,10:1700192426,16:9b919914a5835436,ab53156abc79d1c3efbfbd96539ee08ce612452f12f2862ff35bb6c82ad2f805","cid":"717762328687-iludtf96g1hinl76e4lc1b9a82g457nn.apps.googleusercontent.com","k":"Google","ses":"272ab7f4f4cd4640b68720f3c05770f5"} &response_type=code
谷歌授权服务器的响应是 302 重定向
-
- 当需要客户同意授权时,返回的重定向地址是谷歌的授权页面。客户点击同意后会在后面会再重定向到 StackOverflow 设置的 redirect_url 后端地址
- 当不需要客户同意授权时,返回的重定向地址直接就是 StackOverFlow 的服务端负责谷歌授权的接口,后面的参数有谷歌给予的授权码,就是下面 Location 中的 code
Status Code: 302 Found Location: https://stackauth.com/auth/oauth2/google
?state=
{"sid":1,"st":"59:3:1b8,16:c4729f2f7e47e8e0,10:1700192426,16:9b919914a5835436,ab53156abc79d1c3efbfbd96539ee08ce612452f12f2862ff35bb6c82ad2f805","cid":"717762328687-iludtf96g1hinl76e4lc1b9a82g457nn.apps.googleusercontent.com","k":"Google","ses":"272ab7f4f4cd4640b68720f3c05770f5"}
&code=4/0AfJohXkprUYlXEgDK2tLi_a0hVMLckl6WnTepFNvwSbs0KT2je0WxaomUWbLPeDvfZIXQQ
&scope=email profile https://www.googleapis.com/auth/userinfo.profile openid https://www.googleapis.com/auth/userinfo.email&authuser=0&prompt=none
3、浏览器收到上面的重定向后,就会用 Location 里的 url 再发出一次请求。这样就会 带上授权码-code 请求到 stackOverFlow 后端,然后 stackOverFlow 服务端 会把授权码 code 和自己持有的 clientsecret 一起发给谷歌换取令牌。此时谷歌就会返回令牌-access_token 给 stackOverFlow 服务端, stackOverFlow 服务端 需要保存这个令牌。
https://stackauth.com/auth/oauth2/google? state=
{"sid":1,"st":"59:3:1b8,16:c4729f2f7e47e8e0,10:1700192426,16:9b919914a5835436,ab53156abc79d1c3efbfbd96539ee08ce612452f12f2862ff35bb6c82ad2f805","cid":"717762328687-iludtf96g1hinl76e4lc1b9a82g457nn.apps.googleusercontent.com","k":"Google","ses":"272ab7f4f4cd4640b68720f3c05770f5"} &code=4/0AfJohXkprUYlXEgDK2tLi_a0hVMLckl6WnTepFNvwSbs0KT2je0WxaomUWbLPeDvfZIXQQ &scope=email profile https://www.googleapis.com/auth/userinfo.profile openid https://www.googleapis.com/auth/userinfo.email&authuser=0 &prompt=none
这个请求到达 StackOverflow 服务端后,StackOverflow 服务端会把参数里的授权码-code 与自己持有的 clientSecret 一起发给谷歌换取令牌。
https://oauth2.googleapis.com/token? code=4/0AfJohXkprUYlXEgDK2tLi_a0hVMLckl6WnTepFNvwSbs0KT2je0WxaomUWbLPeDvfZIXQQ
&client_id=717762328687-iludtf96g1hinl76e4lc1b9a82g457nn.apps.googleusercontent.com
&client_secret=nvDSpUI4R6iga0xfcvO7-V-s
&redirect_uri=https://stackauth.com/auth/oauth2/google
&grant_type=authorization_code
谷歌返回给 StackOverflow 服务端的令牌大概是这样:
{ "access_token": "ya29.QQIBibTwvKkE39hY8mdkT_mXZoRh7Ub9cK9hNsqrxem4QJ6sQa36VHfyuBe", "token_type": "Bearer", "expires_in": 3600, //access_token 的剩余生命周期(以秒为单位) "id_token": "eyJhSUzI1Ni---Y0ZDQEifQ.eyJpc3ov2FjY29---iOjEyODYwMzZ9.C8gS1YGqjJ3K---g6e8bYJrEEDYiceu6KCLJKCHrA" }
这是我们在浏览器 F12 中看不到的,是返回给 StackOverflow 服务端的。
-
- 其中 access_token 就是 OAuth2 协议中的 “令牌”,用于后续用它来去请求谷歌 openAPI 获取相关资源。它是有时限的,且时限较短。
- 按照 OAuth2 协议,返回会有一个可选的刷新令牌 refresh_token,在谷歌 OpenID Connect 中,仅当身份验证请求中的 access_type 参数设置为 offline 时,此字段才会出现。这里应该是没有设置所以没有。刷新令牌用于在访问令牌失效后重新获取,有效期较长。
- id-token 是谷歌的 OpenID Connnect 引入的一个字段,不属于 OAuth2 标准协议。实际上是一个由 3 个 Base64 编码块组成的 JWT 令牌,这里为了展示被截断了。ID 令牌可能包含有关用户的附加信息,例如他们的电子邮件地址、图片、生日等,ID 令牌用于证明用户已通过身份验证。
下面是谷歌 id_token 的示例
{
"iss":"https://accounts.google.com", "at_hash":"eXUxmt_D_iV52SrEg", "aud":"139281538940-arh29cscgqk2vic01ackiphugqe6m2lr.apps.googleusercontent.com", "sub":"113381163725", // 谷歌用户唯一标志符 "email_verified":true, "azp":"139238940-arcscq2ic0ihugem2lr.apps.googleusercontent.com", "email":"some@email.address", "iat":1449282920, "exp":1449286520 }
StackOverFlow 服务端可以通过这个谷歌用户唯一标识符来建立与自己用户资料的关联,并存储这个 access_token,便于后续用它来请求谷歌资源。
最后,stackOverFlow 服务端给浏览器的响应,可以看到也是一个重定向 302。
Status Code: 302 Found Location: https://stackoverflow.com/users/oauth/google?
code=4/0AfJohXkprUYlXEgDK2tLi_a0hVMLckl6WnTepFNvwSbs0KT2je0WxaomUWbLPeDvfZIXQQ
&state=
{"sid":1,"st":"59:3:1b8,16:c4729f2f7e47e8e0,10:1700192426,16:9b919914a5835436,ab53156abc79d1c3efbfbd96539ee08ce612452f12f2862ff35bb6c82ad2f805","cid":"717762328687-iludtf96g1hinl76e4lc1b9a82g457nn.apps.googleusercontent.com","k":"Google","ses":"272ab7f4f4cd4640b68720f3c05770f5"}
&s=272ab7f4f4cd4640b68720f3c05770f5
获取令牌成功后续流程-刷新页面等
4、浏览器收到上面的 302 后,就会用 Location 里的 url 再发出一次请求。这里可能是 stackOverflow 又进行了确认操作等,所以 UML 图中没有画出这一步
https://stackoverflow.com/users/oauth/google?
code=4/0AfJohXkprUYlXEgDK2tLi_a0hVMLckl6WnTepFNvwSbs0KT2je0WxaomUWbLPeDvfZIXQQ
&state=
{"sid":1,"st":"59:3:1b8,16:c4729f2f7e47e8e0,10:1700192426,16:9b919914a5835436,ab53156abc79d1c3efbfbd96539ee08ce612452f12f2862ff35bb6c82ad2f805","cid":"717762328687-iludtf96g1hinl76e4lc1b9a82g457nn.apps.googleusercontent.com","k":"Google","ses":"272ab7f4f4cd4640b68720f3c05770f5"}
&s=272ab7f4f4cd4640b68720f3c05770f5
重定向是 stackOverflow 首页
Status Code: 302 Found Location: https://stackoverflow.com/
请求资源过程
如谷歌用户头像等资源,浏览器请求 StackOverflow 服务端获取资源,StackOverflow 服务端带上 access_token 请求谷歌资源服务器;谷歌资源服务器根据这个 access_token 所允许的权限,向 StackOverflow 提供资源。
授权码模式
OAuth2 是在RFC 6749中定义的国际标准,在 RFC 6749 正文的第一句就阐明了 OAuth2 是面向于解决第三方应用(Third-Party Application)的认证授权协议。例如前面的例子,StackOverflow 相对于谷歌就是一个第三方应用,谷歌利用实现了 OAuth2 的 OpenID Connect 来认证授权像 StackOverflow 这样第三方应用用谷歌账号登陆,获取用户资料的权限。
OAuth2 一共提出了四种不同的授权方式(这也是 OAuth2 复杂烦琐的主要原因),分别为:
- 授权码模式(Authorization Code)
- 隐式授权模式(Implicit)
- 密码模式(Resource Owner Password Credentials)
- 客户端模式(Client Credentials)
上面 StackOverflow 的例子就是授权码模式,通过上这个例子我们已经了解了 OAuth2 授权码模式。那么,为什么要这样设计 OAuth2 呢?
会不会有其他应用冒充第三方应用骗取授权?
ClientID 代表一个第三方应用的“用户名”,这项信息是可以完全公开的。但 ClientSecret 应当只有应用自己才知道,这个代表了第三方应用的“密码”。在第 5 步发放令牌时,调用者必须能够提供 ClientSecret 才能成功完成。只要第三方应用妥善保管好 ClientSecret,就没有人能够冒充它。
【为什么要先发放授权码给客户端,再转到服务端用授权码换令牌?】
这是因为客户端转向(通常就是一次 HTTP 302 重定向)对于用户是可见的,换而言之,【授权码可能会暴露给用户】以及用户机器上的其他程序,比如在 StackOverflow 的例子中我们就通过浏览器看到了谷歌返回的授权码。但由于之后 StackOverflow 服务端用授权码和 ClientSecret 去请求谷歌换取令牌这一步,发生在 StackOverflow 服务端 和 谷歌服务端之间,对用户是不可见的,即【用户并没有 ClientSecret,光有授权码也是无法换取到令牌的】,所以避免了令牌在传输转向过程中被泄漏的风险。
为什么要设计一个时限较长的刷新令牌和时限较短的访问令牌?不能直接把访问令牌的时间调长吗?
这是为了缓解 OAuth2 在实际应用中的一个主要缺陷,通常访问令牌一旦发放,除非超过了令牌中的有效期,否则很难(需要付出较大代价)有其他方式让它失效,所以访问令牌的时效性一般设计的比较短,譬如几个小时,如果还需要继续用,那就定期用刷新令牌去更新,授权服务器就可以在更新过程中决定是否还要继续给予授权。至于为什么说很难让它失效,我们将放到下一节“凭证”中去解释。
OAuth2 授权码模式的缺陷
它对第三方应用提出了一个“貌似不难”的要求:第三方应用必须有应用服务器(后端)
因为要发起服务端转向,而且要求服务端的地址必须与注册时提供的地址在同一个域内。不要觉得要求一个系统要有应用服务器是天经地义理所当然的事情。现在越来越普遍的是移动或桌面端的客户端 Web 应用(Client-Side Web Applications),譬如现在大量的基于 Cordova、Electron、Node-Webkit.js 的PWA 应用,它们都没有应用服务器的支持。由于有这样的实际需求,因此引出了 OAuth2 的第二种授权模式:隐式授权
隐式授权模式
隐式授权省略掉了通过授权码换取令牌的步骤,整个授权过程都不需要服务端支持,一步到位。
代价是
- 在隐式授权中,授权服务器不会再去验证第三方应用的身份,因为已经没有应用服务器了,ClientSecret 没有人保管,就没有存在的意义了。
- 也不能避免令牌暴露给资源所有者,不能避免用户机器上可能意图不轨的其他程序、HTTP 的中间人攻击等风险了。
但其实还是会限制第三方应用的回调 URI 地址必须与注册时提供的域名一致,尽管有可能被 DNS 污染之类的攻击所攻破,但仍算是尽可能努力一下。
在时序图所示的交互过程里,隐式模式与授权码模式的显著区别是授权服务器在得到用户授权后,直接返回了访问令牌,这显著地降低了安全性。
还有一点,在 RFC 6749 对隐式授权的描述中,特别强调了令牌必须是“通过 Fragment 带回”的。部分对超文本协议没有了解的读者,可能还根本不知道Fragment是个什么东西?
Fragment 就是地址中#
号后面的部分,譬如这个地址:
http://bookstore.icyfenix.cn/#/detail/1
后面的 /detail/1
便是 Fragment,这个语法是在RFC 3986中定义的,RFC 3986 中解释了 Fragment 是用于客户端定位的 URI 从属资源,譬如 HTML 中就可以使用 Fragment 来做文档内的跳转而不会发起服务端请求,你现在可以点击一下这篇文章菜单中的几个子标题,看看浏览器地址栏的变化。
此外,RFC 3986 还规定了如果浏览器对一个带有 Fragment 的地址发出 Ajax 请求,那 Fragment 是不会跟随请求被发送到服务端的,只能在客户端通过 Script 脚本来读取。
所以隐式授权巧妙地利用这个特性,尽最大努力地避免了令牌从操作代理(浏览器)到第三方服务之间的链路存在被攻击而泄漏出去的可能性。至于认证服务器到操作代理之间的这一段链路的安全,则只能通过 TLS(即 HTTPS)来保证中间不会受到攻击了,我们可以要求认证服务器必须都是启用 HTTPS 的,但无法要求第三方应用同样都支持 HTTPS。
密码模式
密码模式原本的设计意图是仅限于用户对第三方应用是高度可信任的场景中使用,因为用户需要把密码明文提供给第三方应用,第三方以此向授权服务器获取令牌。这种高度可信的第三方是极为较罕见的,尽管介绍 OAuth2 的材料中,经常举的例子是“操作系统作为第三方应用向授权服务器申请资源”,但真实应用中极少遇到这样的情况,合理性依然十分有限。
笔者(《凤凰架构》作者周志明)认为,如果要采用密码模式,那“第三方”属性就必须弱化,把“第三方”视作是系统中与授权服务器相对独立的子模块,在物理上独立于授权服务器部署,但是在逻辑上与授权服务器仍同属一个系统,这样将认证和授权一并完成的密码模式才会有合理的应用场景。
密码模式下“如何保障安全”的职责无法由 OAuth2 来承担,只能由用户和第三方应用来自行保障,尽管 OAuth2 在规范中强调到“此模式下,第三方应用不得保存用户的密码”,但这并没有任何技术上的约束力。
客户端模式
客户端模式是四种模式中最简单的,它只涉及到两个主体,第三方应用和授权服务器。如果严谨一点,现在称“第三方应用”其实已经不合适了,因为已经没有了“第二方”的存在,资源所有者、操作代理在客户端模式中都是不必出现的。甚至严格来说叫“授权”都已不太恰当,资源所有者都没有了,也就不会有谁授予谁权限的过程。
客户端模式是指第三方应用(行文一致考虑,还是继续沿用这个称呼)以自己的名义,向授权服务器申请资源许可。此模式通常用于管理操作或者自动处理类型的场景中。
经常有顾客下了订单又拖着不付款,导致部分货物处于冻结状态。所以有一个订单清理的定时服务,自动清理超过两分钟还未付款的订单。在这个场景里,订单肯定是属于下单用户自己的资源,如果把订单清理服务看作一个独立的第三方应用的话,它是不可能向下单用户去申请授权来删掉订单的,而应该直接以自己的名义向授权服务器申请一个能清理所有用户订单的授权。
示例:
如第三方想要用 Facebook 的接口
需要先申请一个应用,然后生成一个 access_token。每次点击生成口令会重新生成
请求接口时加 queryParam
?access_token=