OpenID Connect 和 OAuth 2.0 概览
在早些年代,网站之间互不连接。后来,连接需求越来越大,但是怎么安全地连接就成了一个问题。经过 3 次修改,OAuth 2.0 (先是 1.0,后来是 1.0a)诞生了,其中的 O 就是开放互联的意思。
OAuth 2.0 中有一些术语,其中一部分我列在了下表里。纯解释有些抽象,我们想象一个具体的场景:你现在要登录知乎网站,选择了使用微信登录。
术语 | 解释 | 举例 |
---|---|---|
客户端应用 | 代替你做一些操作的应用 | 知乎 |
访问令牌 | 你授权客户端应用之后,颁发给客户端应用的一个字符串 | 一个 jwt 或者是一个普通的字符串 |
授权服务器 | 验证资源拥有者以及客户端应用是否可以访问受保护资源的服务器 | 微信 |
资源服务器 | 保存着资源以及提供相关服务的服务器 | 在这里,仍然是微信。但可以是不同的服务器。 |
资源拥有者 | 你自己 | |
受保护资源 | 你的微信昵称、头像等 |
以上例子中,通过 OAuth 2.0 就将知乎和微信连接起来了。在通过 OAuth 2.0 开放互联的新世界里,还缺少一样东西,就是身份识别。于是 OIDC (即 OpenID Connect)诞生了。OIDC 在 OAuth 2.0 的基础上增加了很薄的一层,即引进了一种新的令牌:身份令牌, 使用 jwt 格式存储了经过验证的用户身份信息。这将开放互联提升了一个高度,并且为单点登录打开了一扇门(单点登录除了 OIDC 协议,还有 SAML 协议等)。
OAuth 2.0 以及 OIDC 使用了一系列的流程来管理客户端应用、授权服务器和资源服务器之间的交互。比如上面的例子中,就是一个授权码流程,就是从浏览器触发,并且像这样来工作:
- 知乎想要你的微信昵称,并且展示了一个微信图标按钮。
- 你点了这个按钮,就会被重定向到微信的站点并要求扫码登录。
- 一早你扫码,微信(手机上)会展示一个页面说知乎想要获取你的微信名称和头像。
- 一旦你点击允许,微信(浏览器上)会跳转回知乎页面,并且在 URL 上带上临时的授权码。
- (知乎事先在微信的开放平台注册了一个网站应用),知乎用从 URL 上获取到授权码,加上它在微信的开放平台的网站应用里获取到的凭据(appid 和 appsecret),去微信换取访问令牌。
- 微信校验授权码,如果一切校验通过,就为知乎颁发一个受限(仅允许对你的微信名称和头像进行只读访问)的访问令牌。
- 知乎接着使用这个访问令牌去调用微信 API(获取你的用户信息)
- 微信 API 验证访问令牌,如果请求匹配令牌的能力,就返回你的用户信息
注意以上流程图中的第 8 步,知乎不仅要带上授权码,还要带上事先由微信分配的秘密凭据,这是知乎网站的后端服务器才能获取到的:
以上的流程对传统的网站应用来说是完美的,但是对于单页应用来说,没有服务端的支持,就没有安全地存储这种秘密凭据的方式。如果存储在客户端,流程上虽然可以工作,但是任何人都能通过浏览器查看源码从而获取到这种秘密信息。在早些的 OAuth 2.0 时代,由于没有更好的选择,就发明了隐式许可流程来从授权服务器处获取访问令牌。这是一种不安全的方式,在 OAuth 2.1 中被废弃,并提供了 PKCE 这个更好的做法来代理隐式许可。不过,在了解 PKCE 之前,让我们先来审视一下隐式流程,看看为什么它不够安全。
为什么不能再使用隐式流程
OAuth 2.0 规范中一度包含了隐式流程,那时候单页应用在浏览器中受到的限制比较大,比如 JavaScript 不能访问浏览器的浏览历史、也不能访问本地存储。并且,那时候很多服务器也不接受跨站 POST 请求,导致单页应用没法通过 /token 端点获取到令牌,从而整个授权码流程不能正常运行。
我们假想一个单页应用“哈德韦”,使用隐式许可流程接入微信登录,那么流程会是以下这样:
注意当你认证完成,授权服务器(微信)在响应回客户端应用(哈德韦)时,会在 URL 上直接带上令牌。这时候,微信并不能确认是否真的是浏览器(期待的接收者)接收到了令牌,更糟的是,多数浏览器或者插件支持同步浏览历史,这会导致令牌泄露。为了缓解这个问题,一般支持隐式流程的授权服务器,都会通过 #token=xxx 的方式将令牌返回到客户端应用(在授权链接中通过 response_mode=fragment 指定),因为#后面的部分不会被发送服务器,仅存在浏览器端,但是这仍然不够安全。
隐式许可的授权链接结构如下:
plain https://dev-micah.okta.com/oauth2/default/v1/authorize? client_id=0oapu4btsL2xI0y8y356 &redirect_uri=http://localhost:8080/callback &response_type=id_token token &response_mode=fragment &state=SU8nskju26XowSCg3bx2LeZq7MwKcwnQ7h6vQY8twd9QJECHRKs14OwXPdpNBI58 &nonce=Ypo4cVlv0spQN2KTFo3W4cgMIDn6sLcZpInyC40U5ff3iqwUGLpee7D4XcVGCVco &scope=openid profile email
响应(跳转链接)结构如下:
plain http://localhost:8080/callback# id_token=eyJraWQiOiI3bFV0aGJyR2hWVmx... &access_token=eyJraWQiOiI3bFV0aGJyR2... &token_type=Bearer &expires_in=3600 &scope=profile+openid+email &state=SU8nskju26XowSCg3bx2LeZq7MwKcwnQ7h6vQY8twd9QJECHRKs14OwXPdpNBI58
如果不用隐式流程,对于单页应用,那该怎么办呢?其实可以使用 PKCE,它的全称是 Proof Key for Code Exchange,做为授权码流程的扩展,已经被广泛用在了手机原生应用上。
使用 PKCE 来让应用更安全
PKCE 有单独的规格说明,它让使用公开客户端的授权码流程更加安全。在理解这一点前,我们先来看看不用它的话为什么不够安全。这里提到了公开客户端,也就是这种客户端在授权服务器的应用列表里,只有 client id,没有 client secret。在原生应用场景里,当授权服务器将授权码返回给应用时,会调用它的 Scheme URL,来唤起该应用。但这时如果有恶意应用通过向手机注册同样的 Scheme URL 来冒充它,那么授权码就可能被该恶意应用截获,从而抢在真正的应用之前向授权服务器换取令牌(因为没有 client secret,而 client id 又是可以通过网络抓包获取到的,从而只需要通过 client id 加上授权码换取令牌)。
所以 PKCE 流程,会利用动态生成的秘密值在发起整个授权码流程之前做一些准备工作,并且在授权码的流程结束附近处增加验证环节,让接收授权码的应用证明它就是发起整个流程的那个应用。
具体来说,应用在发起流程前,生成一个随机值,称为code verifier。应用将该值 hash 之后,即成为 code challenge。然后,应用像普通的授权码流程一样,发起整个流程,只是在查询字符串里带上了 code challenge,一并发往授权服务器。授权服务器会存储 hash 后的 code challenge,在后面校验时会用到。当用户验证成功后,授权服务器把授权码发回应用。
应用收到授权码,会再次发起请求以换取令牌,但是要带上 code verifier(虽然不需要传递固定的 client secret 了,但是需要传递一个动态的 secret,code verifier 就相当于动态的 secret)。现在授权服务器可以 hash 这个 code verifier,并且和它之前存储起来的 hash 过后的值进行比较,如果一致,验证就通过,正常返回令牌。这是一个非常有效的使用动态密钥代替静态密钥的机制。
这个流程最初只被运用在原生应用场景,因为那时候的浏览器和多数的授权服务器都不支持 PKCE。但是现在这个情况已经改变了,浏览器和授权服务器都可以支持 PKCE。
这样的流程图如下所示:
写个测试来验证下
纸上得来终觉浅,绝知此事要写个测试。和上次《<font style=color:rgb(18, 18, 18);>OIDC (OAuth 2.0)授权码许可流程详解:纸上得来终觉浅,绝知此事得写个测试 - Jeff Tian的文章 - 知乎 》一样,我们还是来写个完整的端到端自动化测试。同样还是选用 Duende IdentityServer 作为授权服务器,完整的代码提交见: https://github.com/Jeff-Tian/IdentityServer/commit/d9c3afa78eb16318b4ff8d6be7d7c1d249b1f1b4。
在写测试时就碰到了一些细节问题,这里重点提一下。
哈希算法是可以选择的
首先,在上面的流程介绍里提到了哈希(随机值)=code challenge,这里的随机值就是 code verifier。但是哈希算法是可以有多种的,它在构造授权链接时,通过 code_challenge_method 指定,常用的是 S256。比如一个典型的 PKCE 授权链接是:
plain https://dev-micah.okta.com/oauth2/default/v1/authorize? client_id=0oapu4btsL2xI0y8y356& redirect_uri=http://localhost:8080/callback& response_type=code& response_mode=fragment& state=MdXrGikS5LACsWs2HZFqS7IC9zMC6F9thOiWDa5gxKRqoMf7bCkTetrrwKw5JIAA& nonce=iAXdcF77sQ2ejthPM5xZtytYUjqZkJTXcHkgdyY2NinFx6y83nKssxEzlBtvnSY2& code_challenge=elU6u5zyqQT2f92GRQUq6PautAeNDf4DQPayyR0ek_c& code_challenge_method=S256& scope=openid profile email
除了 S256,还有 plain,在上面的测试代码中,为了略简单一点,没有使用 S256,而是使用了 plain,即 plain(随机值) = 原来的随机值,即 code verifier 和 code challenge 的值是一样的。但是注意,使用 plain,其实没有安全保证,所以默认禁止的:
尽管不推荐,但在自动化测试场景下,这可以通过设置 AllowPlainTextPkce为会 true,来让测试运行起来。
code challenge 是有长度要求的
本来为了简单省事,将 code challenge 设置成了 123,结果得到了 code_challenge is either too short or too long错误:
这才了解到,其实 code challenge 是有长度要求的,感谢王新栋老师在极客时间的专栏中(http://gk.link/a/1291Y)提醒,code verifier 的长度必须在 43 到 128 个字符之间。
我们现在使用了 plain,所以 code challenge 也应该在这个长度。于是我设置成了 1234567890123456789012345678901234567890123正好长度是 43,通过了测试。
墙裂安利
最后,为王新栋老师在极客时间的《OAuth 2.0 实战课》打个广告,我已经反复学了 3 遍并且还时不时复习,受益匪浅,欢迎大家一起加入学习(http://gk.link/a/1291Y)!