[appendix] = OAuth2 OAuth2 是一个开放标准,允许用户授权第三方应用程序访问他们存储在另外的服务提供者上的信息,不需要将用户名和密码提供给第三方应用。 OAuth2 协议允许用户将认证与授权交给一个独立的第三方进行担保。 OAuth2可以提供一个统一的认证服务。 == 应用场景 === 快递员问题 我住在一个大型的居民小区, 小区有门禁系统, 进入的时候需要输入密码。 我经常网购和外卖,每天都有快递员来送货, 我必须找到一个办法,让快递员通过门禁系统,进入小区。 如果我把自己的密码,告诉快递员,他就拥有了与我同样的权限,这样好像不太合适。 万一我想取消他进入小区的权力,也很麻烦,我自己的密码也得跟着改了,还得通知其他的快递员。 有没有一种办法,让快递员能够自由进入小区,又不必知道小区居民的密码,而且他的唯一权限就是送货,其他需要密码的场合,他都没有权限? 于是,我设计了一套授权机制: . 第一步,门禁系统的密码输入器下面,增加一个按钮,叫做"获取授权"。快递员需要首先按这个按钮,去申请授权。 . 第二步,他按下按钮以后,屋主(也就是我)的手机就会跳出对话框:有人正在要求授权。系统还会显示该快递员的姓名、工号和所属的快递公司。 . 我确认请求属实,就点击按钮,告诉门禁系统,我同意给予他进入小区的授权。 . 第三步,门禁系统得到我的确认以后,向快递员显示一个进入小区的令牌(access token)。令牌就是类似密码的一串数字,只在短期内(比如七天)有效。 . 第四步,快递员向门禁系统输入令牌,进入小区。 有人可能会问,为什么不是远程为快递员开门,而要为他单独生成一个令牌?这是因为快递员可能每天都会来送货, 第二天他还可以复用这个令牌。另外,有的小区有多重门禁,快递员可以使用同一个令牌通过它们。 我们把上面的例子搬到互联网,就是 OAuth 的设计了。 首先,居民小区就是储存用户数据的网络服务。比如,微信储存了我的好友信息,获取这些信息,就必须经过微信的"门禁系统"。 其次,快递员(或者说快递公司)就是第三方应用,想要穿过门禁系统,进入小区。 最后,我就是用户本人,同意授权第三方应用进入小区,获取我的数据。 简单说,OAuth 就是一种授权机制。数据的所有者告诉系统,同意授权第三方应用进入系统,获取这些数据。 系统从而产生一个短期的进入令牌(token),用来代替密码,供第三方应用使用。 === "云冲印"网站 有一个"云冲印"的网站,可以将用户储存在Google的照片,冲印出来。用户为了使用该服务,必须让"云冲印"读取自己储存在Google上的照片。 问题是只有得到用户的授权,Google才会同意"云冲印"读取这些照片。那么,"云冲印"怎样获得用户的授权呢? 传统方法是,用户将自己的Google用户名和密码,告诉"云冲印",后者就可以读取用户的照片了。这样的做法有以下几个严重的缺点。 . "云冲印"为了后续的服务,会保存用户的密码,这样很不安全。 . Google不得不部署密码登录,而我们知道,单纯的密码登录并不安全。 . "云冲印"拥有了获取用户储存在Google所有资料的权力,用户没法限制"云冲印"获得授权的范围和有效期。 . 用户只有修改密码,才能收回赋予"云冲印"的权力。但是这样做,会使得其他所有获得用户授权的第三方应用程序全部失效。 . 只要有一个第三方应用程序被破解,就会导致用户密码泄漏,以及所有被密码保护的数据泄漏。 OAuth就是为了解决上面这些问题而诞生的。 OAuth在"客户端"与"服务提供商"之间,设置了一个授权层(authorization layer)。"客户端"不能直接登录"服务提供商",只能登录授权层, 以此将用户与客户端区分开来。"客户端"登录授权层所用的令牌(token),与用户的密码不同。用户可以在登录的时候,指定授权层令牌的权限范围和有效期。 "客户端"登录授权层以后,"服务提供商"根据令牌的权限范围和有效期,向"客户端"开放用户储存的资料。 == 模块构成 * Resource owner (资源拥有者): 拥有该资源的服务或用户,如我们自己或者资源网站 * Authorization server (认证服务器) : 即用来认证与颁发令牌(如token)的服务 * Resource server (资源服务器) : 拥有资源的服务,如我们要访问的网站 * Client (客户端/第三方应用程序) : 即访问的客户端,如我们自己用的访问网站 == 运行流程 image::9999-appendix/oauth2/001.png[] * (A) 用户打开客户端以后,客户端要求用户给予授权。 * (B) 用户同意给予客户端授权。 * (C) 客户端使用上一步获得的授权,向认证服务器申请令牌。 * (D) 认证服务器对客户端进行认证以后,确认无误,同意发放令牌。 * (E) 客户端使用令牌,向资源服务器申请获取资源。 * (F) 资源服务器确认令牌无误,同意向客户端开放资源。 不难看出来,上面六个步骤之中,B 是关键,即用户怎样才能给于客户端授权。有了这个授权以后,客户端就可以获取令牌,进而凭令牌获取资源。 == Oauth 2.1 的进化过程 * 2010年 OAuth 授权规范 1.0 (rfc 5849) 版本发布。 * 2012年 更简单易用的 OAuth 2.0 规范发布(rfc 6749)版本发布。 * 到现在, 网络和移动领域发生了巨大的变化, 当时发布的授权协议标准已经远远不能满足现在的场景和需求, 为了应对这种不断变化的局面, OAuth 社区多年来一直在修补和扩展 OAuth 规范, OAuth 的格局也不断扩大, 越来越多的围绕 OAuth 2.0 core 的扩展授权规范出现, 也让 OAuth 2.0 整体看起来就像一个迷宫一样。 image::9999-appendix/oauth2/002.png[,50%] 在 OAuth 2.0 核心规范 (RFC 6749)中, 定义了四种授权类型:授权码、隐式、密码和客户端凭据, 如下: image::9999-appendix/oauth2/003.png[,30%] 在 OAuth 2.0 中,最安全也是使用最普遍的就是授权码模式, 而对于本地应用,移动应用来说, 通常会使用隐式和密码授权, 这两种本身就是不安全的, 因为这些属于公开的客户端, 本身没有能力保护客户端机密, 但是当时并没有其它好的方案。 为了解决 OAuth 2.0 对公开客户端的授权安全问题, PKCE (RFC 6379)协议应运而生, 全称是 Proof Key for Code Exchange,PKCE 的原理是, 对于公共的客户端, 如果不能使用客户端秘钥(client_secret), 那客户端就提供一个自创建的证明 (code_verifier) 给授权服务器,其中使用了加密算法, 授权服务器通过它来验证客户端。 image::9999-appendix/oauth2/004.png[,30%] 后来,"OAuth 2.0 for Native Apps"(RFC 8252)规范发布,推荐原生应用也使用授权码 + PKCE。 image::9999-appendix/oauth2/005.png[,30%] 随着技术不断地发展, 出现了设备授权的场景, 这里设备指智能电视,打印机等, 和传统的PC或者手机不同, 这种设备是缺少浏览器或者键盘的,那 OAuth 2.0 常规的授权模式肯定是不能满足的, 于是就出现了设备授权(Device Grant) 。 image::9999-appendix/oauth2/006.png[,30%] 在 OAuth 2.0 安全最佳实践(Security BCP)中, 弃用了隐式和密码授权,并且推荐所有的客户端都应该使用 Authorization Code + PKCE 的组合。 image::9999-appendix/oauth2/007.png[,30%] 最终, 调整后的 OAuth 授权模式会更加精简, 转换成下面三种, 这也是 OAuth 2.1 的思想, 参考安全最佳实践(BCP),取其精华, 去其糟粕。 image::9999-appendix/oauth2/008.png[,50%] 归根结底, OAuth 2.1 并不是要推翻 OAuth 2.0,而是根据其安全最佳实践(BCP), 移除不安全的授权流程, 并且对扩展协议进行整合, 让原本复杂如迷宫的 OAuth 2.0 规范成为更易用,更安全的授权规范。 == 授权方式 * authorization_code (授权码模式) : 最正规的模式,客户端先将用户导向认证服务器,登录后获取授权码,然后进行授权,最后根据授权码获取访问令牌 * refresh_token (刷新模式) : 用刷新码获取 * client_credentials (客户端模式) : 第三方应用自己本身需要获取资源 == 认证方式 * client_secret_basic : 客户端认证信息会以 Basic Auth 的方式进行传递 * client_secret_post : 客户端认证信息放在请求体中进行传递 * client_secret_jwt : HMAC 算法生成 JWT 来传递客户端秘钥 * private_key_jwt : 客户端使用 RSA 和 EC 算法生成 JWT 传递客户端认证信息,需提供公钥给授权服务器 * none : 会开启 PKCE 功能以确保安全, 适应于开放型客户端 === client_secret_jwt OAuth2 客户端将自己的密钥作为 HMAC SHA256 算法的 key 生成 SecretKey [source,java] ---- byte[] pin =clientSecret.getBytes(StandardCharsets.UTF_8); SecretKeySpec secretKey =new SecretKeySpec(pin,"HmacSHA256"); ---- 然后通过 SecretKey 生成一个携带 OAuth2 客户端信息的 JWT,在授权码请求 Token 环节携带该 JWT 以便授权服务器进行客户端认证,请求的报文为: [source,html] ---- POST /oauth2/token HTTP/1.1 Host: oauth2_client.felord.cn Content-Type: application/x-www-form-urlencoded grant_type=authorization_code& code=n0esc3NRze7LTCu7iYzS6a5acc3f0ogp4& client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer& client_assertion=你的JWT ---- 授权服务器收到请求后通过 OAuth2 客户端的 client_secret 对 JWT 进行解码校验以认证客户端。这种方式能很好的保护 client_secret 在非 HTTPS 环境下的传输。 这里 OAuth2 客户端的密钥(client_secret)比特长度必须大于等于256。 === private_key_jwt private_key_jwt 和 client_secret_jwt 唯一的区别就是生成 JWT 的方式不同。 通过这种方式,OAuth2 客户端已经不需要 client_secret,只需要配置一对 RSA 或者 EC 密钥,通过密钥来生成 JWT, 另外还需要向授权服务器提供公钥,通常是一个 jwkSetUrl。这种方式让客户端的认证信息更加安全的传输。 == 请求点 |=== | endpoint | method | 说明 | /.well-known/oauth-authorization-server | GET | meta data | /.well-known/openid-configuration | GET | meta data | /oauth2/authorize | GET/POST | 获取授权码 | /oauth2/token | POST | 获取访问令牌 | /oauth2/introspect | POST | 内省 | /oauth2/revoke | POST | 收回令牌 | /userinfo | GET/POST | 用户信息 | /oauth2/jwks | GET | JWK+ JWS | /connect/register | POST | oidc 客户端注册 |=== === /oauth2/authorize 实现类: org.springframework.security.oauth2.server.authorization.web.OAuth2AuthorizationEndpointFilter |=== | 参数名 | 参数类型 | 参数值 | 说明 | client_id | 请求 | | | response_type | 请求 | code | 授权码 | redirect_uri | 请求 | | | scope | 请求 | | | state | 请求 | | | code | 响应 | | | scope | 响应 | | | state | 响应 | | | access_token | 响应 | | | token_type | 响应 | | | expires_in | 响应 | | | error | 响应 | | | error_description | 响应 | | | error_uri | 响应 | | |=== === /oauth2/token |=== | 参数名 | 参数类型 | 参数值 | 说明 | client_id | 请求 | | | client_secret | 请求 | | | client_assertion_type | 请求 | | | client_assertion | 请求 | | | assertion | 请求 | | | grant_type | 请求 | | | redirect_uri | 请求 | | | scope | 请求 | | | code | 请求 | | | refresh_token | 请求 | | | username | 请求 | | | password | 请求 | | | scope | 响应 | | | access_token | 响应 | | | token_type | 响应 | | | expires_in | 响应 | | | refresh_token | 响应 | | | error | 响应 | | | error_description | 响应 | | | error_uri | 响应 | | |=== === /oauth2/revoke |=== | 参数名 | 参数类型 | 参数值 | 说明 | token | 请求 | | | token_type_hint | 请求 | | |=== == 示例 === 获取授权码 在浏览器地址栏中输入: http://localhost:8080/oauth2/authorize?client_id=platform-oidc&client_secret=secret&response_type=code&redirect_uri=http://localhost:8080/oauth2/authorized-oidc image::9999-appendix/oauth2/010.png[,100%] 如果还未登录过,系统重定向到登录页面 image::9999-appendix/oauth2/011.png[,80%] 输入登录用户名和密码,成功登录后,返回授权码。 === 通过授权码获取令牌