感觉 OAuth 太负盛名了,以至于后来在 OIDC 反而难以企及前辈 OAuth。倒是大家谈论比较多的是 JWT(例如https://www.cnblogs.com/lyzg/p/6132801.html),——实际谈 JWT 就是在实现 OIDC,反而 OIDC 大家不怎么爱谈!但我们要知道的是,真正诠释这些的,做点单点登录的,——是 OIDC 规范,JWT 只是 OIDC 规范下的一种 Token 协议,再说句难听的,如果 JWT 不满足或者有问题,换别的 Token 实现规则也行。
这里再一次不厌其烦地强调:
不过话说回来,OIDC 与 OAuth 看上去大体是相近,只是把应用场景稍作转换,另外就是返回 Token 的不同,OAuth 不限定 Token 具体实现如何,而 OIDC 推荐带用户信息的 JWT。所以,这么说,也不能怪人们总爱谈 JWT 而忽视 OIDC。
OAuth2 提供了不同的 Grant Type 以适应不同的客户端类型以及应用场景,具 体有如下几种:
本文只对前后端授权常见的授权码模式和客户端凭证授权模式做详细介绍,其他授权模式可自行了解,这里只做简单介绍。

OAuth 2.0 提供了四种具体的授权流程: 授权码流程(Authorization Code),隐式许可流程(Implicit),用户密码流程(Resource owner password credentials)和客户机凭据流程(Client Credentials)。其中授权码流程(authorization code)最常见、安全性也最高。授权码通过前端传送,令牌储存在后端,而且所有与资源服务器的通信都在后端完成,可以避免令牌泄漏。
标准授权码流程流程如下:

具体用户是如何授权的呢?一般来说,第三方应用向授权服务器发送用户授权请求时,授权服务器会自动检查当前用户有没有登录 (例如通过 cookie 机制),如果用户已登录则弹出一个授权确认页面,让用户点击按钮企确认是否授权。若授权服务器检测到当前用户没有登录,则先会弹出登录框让用户进行登录,用户输入用户名密码登录之后再让用户确认是否授权。
客户机应用应该先引导用户到授权确认的页面,询问用户选择是否同意对客户机应用授权,并指定允许其获取哪些资源权限。下面给出两个例子。


此阶段要提供下面的参数:
| 参数 | 说明 |
|---|---|
| redirect_uri | 用户登录成功后,授权服务器回传授权码等信息给户机应用的接口,相当于回调地址 |
| response_type | 固定值 code,表示授权码流程 |
| client_id | 客户机应用在授权服务器注册的 client_id |
| state | 随机值,每次请求都要变化。当授权服务器重定向到 redirect_uri 时,会原样返回给客户机应用,用于防止 CSRF、 XSRF。由于授权服务器会原样返回此参数,可将 state 值与用户在客户机应用登录前最后浏览的 URI 绑定,便于登录完成后将用户重定向回最后浏览的页面 |
这些参数会原封不动传到下面生成授权码的接口。发送请求的例子如下:
GET https://oauth_server.com/oauth/authorization
?response_type=code
&redirect_uri=http://client.com/callback
&scope=profile
&state=c7HBU6Sb1nAcWELJx6l2aU
&client_id=9c21477eb0a5e2191342
当上一步没有出现任何问题,然后用户点击了同意授权,授权确认页面会跳转到生成授权码的接口/oauth/authorization,该接口定义如下。
/**
* 获取授权码(Authorization Code)
*
* @param responseType 授权模式,固定为 code
* @param clientId 客户端标识符,表示 OAuth 客户端的唯一标识
* @param redirectUri 重定向 URI,表示授权服务器将授权码发送到此 URI
* @param scope 作用域,表示客户端请求的权限范围
* @param state 用于防止 CSRF 攻击
* @param req 请求对象
* @param resp 响应对象
*/
@GetMapping("/authorization")
void authorization(@RequestParam("response_type") String responseType, @RequestParam("client_id") String clientId, @RequestParam("redirect_uri") String redirectUri, @RequestParam(required = false) String scope, @RequestParam String state, HttpServletRequest req, HttpServletResponse resp);
同样该接口也要求用户是已经登录了的,然后让生成的 code 与用户绑定(在缓存中保存这关系),最后将 code 以参数的形式附在 redirectUrl 地址上重定向到客户机应用。
此接口源码实现如下。

授权服务器会根据redirectUrl将用户重定向回到客户机应用的回调接口,并且还会在redirectURL后面附上两个应答参数:
state值一模一样,原样返回例如:
https://client.com/callback/?code=AB231DEF2134123kj89&state=987d43e51a262f
注意,授权服务器在重定向到redirectUrl时,应该根据 clientId 校验此 url 是否与注册中的 redirectUrl 一致。
即返回响应:
HTTP/1.1 302 Found
Location: http://client.com/callback
?code=SplxlOBeZQQYbYS6WxSbIA
&state=c7HBU6Sb1nAcWELJx6l2
下面客户机就可以凭获得的授权码 code 换取可以访问 API 的 AccessToken,客户机在服务端访问授权中心的这个/oauth/token接口。接口定义如下:
/**
* 获取 Token
*
* @param authorization client 信息
* @param grantType 授权码流程
* @param code 授权码
* @param state 不透明字符串
* @return 令牌 Token
*/
@PostMapping("/token")
AccessToken token(@RequestHeader String authorization, @RequestParam("grant_type") String grantType, @RequestParam String code, @RequestParam String state);
上面已经介绍过了,这一步换取需要传递如下参数给token接口:
grantType:authorization_code。告诉授权服务器使用授权码流程
clientId:客户机应用 id
clientSecre:客户机秘钥,相当于应用的密码
code:上一步获得的用户授权码
state:随机码
发出请求时, 客户机应用需提供其在授权服务器注册的 client_id、client_secret,相当于客户机的用户名、密码,授权服务器根据这两个参数认证客户机的合法性。这两个参数比较敏感,不适宜明文直接传,应该通过 HTTP 的Authorization Header 来传递,即其加密成Base64UrlEncode(clientId:clientSecret)字符串,如下所示:
Authorization: Basic
MDAwMDAwMDA0NzU1REU0MzpVRWhrTDRzTmVOOFlhbG50UHhnUjhaTWtpVU1nWWlJNg==
实际请求如下:
POST https://oauth_server.com/oauth/token
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA
实现源码如下。

当授权服务器认证通过之后 ,/oauth/token接口会返回:
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache
{
"access_token":"2YotnFZFEjr1zCsicMWpAA",
"token_type":"bearer",
"expires_in":3600,
"scope":"profile",
"refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA"
}
出参说明:
Insufficient Redirect URI validation: The risk of allowing to dynamically add arbitrary query parameters and fragments to the redirect_uri。这是一种 OAuth 2.0 和 OpenID Connect 1.0 实现缺陷模式,允许动态添加查询参数和片段到 redirect_uri。如果 redirect_uri 没有得到适当的验证,攻击者可以构造一个包含指向攻击者控制的服务器的 URL 的链接。这可以用来欺骗 AS 将授权代码发送给攻击者。如果用户在用户代理中打开此链接,AS 将重定向用户代理到恶意 URL。攻击者可以捕获伪造 URL 中传递的代码值,然后将其提交给 AS 令牌端点。如果您想测试 AS 是否容易受到不足的重定向 URI 验证,请使用 HTTP 拦截代理(例如 ZAP)捕获流量。启动 OAuth 流并在授权请求处暂停它。更改 redirect_uri 的值并观察响应。调查响应并确定是否接受了任意 redirect_uri 参数。如果 AS 将用户代理重定向到您指定的 redirect_uri,则 AS 未正确验证 redirect_uri。
客户机应保存expires_in值,在调用 api 之前,客户端应该先拿expires_in与当前时间做比较,若当前时间大于过期时间,则说明 AccessToken 已过期,需要重新换取新的 AccessToken 。如果客户端应用发现 AccessToken 到期,或者权限不足,可以使用 RefreshToken 向授权服务器的令牌接口请求新的 AccessToken 。
发出请求时,客户端应用同样需提供其在授权服务器注册的 client_id、client_secret,从而使授权服务器能对客户机应用的身份进行认证。请求例子:
POST https://abc.com/oauth/refresh_token_basic
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&refresh_token=tGzv3JOkF0XG5Qx2TlKWIA
接口定义如下。
/**
* 通过 Refresh Token 刷新 Access Token
* 这是通过头传输 client_id/client_secret
*
* @param grantType 必选,固定为 refresh_token
* @param authorization 包含 client_id/client_secret 的头,用 Base64 编码
* @param refreshToken 必选,Refresh Token
* @return Token
*/
@PostMapping("/refresh_token")
AccessToken refreshToken(@RequestParam("grant_type") String grantType, @RequestHeader("Authorization") String authorization, @RequestParam("refresh_token") String refreshToken);
实现如下。

ClientCredentials 是 OAuth 四大授权流程中最简单的一个流程。只需要用 client_id 和 client_secret 即可换取 AccessToken。这个流程只用来访问客户端拥有的资源而非用户拥有的资源,因为这个流程无须用户授权,跟用户无关,只需要客户端的认证凭证。

客户端凭证模式没有 RefreshToken 机制。如果 Token 快过期或者已过期,重新申请即可。 本方案中 Client 认证也可以刷新 Token。
在 OAuth 中,不管是哪一类的客户端对保护资源的访问方式都是一样的:即每次请求携带一个 AccessToken 即可。那么客户机应用如何把 AccessToken 传递给资源服务器?OAuth 规范中定义了三种传递 AccessToken 的方式。
Authorization字段中使用Bearer这一关键字传递。所谓 Bearer 是 OAuth 补充规范 RFC 6750 中的指定这样子的。Bearer token 不是一种 token 值的格式,而是一种规范的用法,OAuth 2.0 没有规定 token 值的内容、格式。GET https://api.amazon.com/user/profile
Authorization: Bearer 2YotnFZFEjr1zCsicMWpAA
GET https://api.amazon.com/user/profile?access_token=2YotnFZFEjr1zCsicMWpAA
Cache-Control: no-store
POST https://api.amazon.com/user/profile
Content-Type: application/x-www-form-urlencoded
access_token=2YotnFZFEjr1zCsicMWpAA
一般默认 Bearer token 这种,也是推荐方式,安全性最高,原因如下:
资源服务器对传入的 Access token,不能一概都认为合法的,总得要校验一下。资源服务器本身不知道怎么校验,只能让授权服务器说了算,以授权服务器的校验为准。作为统一的认证中心,授权服务器无疑拥有最根本的用户状态记录,一切皆以授权服务器的为准——原则上是这样设计。实操上资源服务器与授权服务器之间的协调可以按以下方法去做。
如果 AccessToken 允许撤销的话,校验服务器就要需要 存储 Token 的状态,而不能采用解密、签名等方式。所以从这个角度来说客户机因为 AccessToken 有效期不会太长(一般3600秒),及时被撤销也不会长久存储它。
本方案采用第三、第四点校验 Token,即 HTTP 方式和 Redis 方式。先说说 HTTP 方式,请见接口/oauth/token/check如下:
/**
* 验证访问令牌
*
* @param token AccessToken
* @return 是否合法的 AccessToken
*/
@PostMapping("/token/check")
Boolean checkToken(@RequestParam String token);
传统认证基于 Cookie+Session 的方式,是有状态的;单机时代问题不大,但到了集群的时候,如何同步 Session 是件麻烦的事情。另外一个方法是绑定 Session 到指定某一台机器,但这样不仅带来复杂性,而且还不能彻底解决问题。因此,渐渐有了以下分野:
我们主张服务端无状态的设计。当服务端组件不保存任何会话状态时,伸缩将比较容易,直接增加/减少物理服务器的台数即可。《Web应用中的状态(会话状态、应用状态、有状态协议、无状态协议、REST无状态约束)》这文章分析得很透彻了。
当用户在客户机应用:退出登录、修改密码、注销账号,或卸载了客户机应用时,客户机应用除了主动删除存储在本地的 AccessToken 及 RefreshToken,还需要通知授权服务器自己不再需要该用户的令牌,授权服务器将清除与该令牌相关的授权信息。这样可以防止被遗弃令牌的滥用,并改善用户体验,失效的授权将不再出现在授权服务器展示给用户的已授权客户机应用列表中。OAuth 在补充规范 RFC 7009 中定义了一个由授权服务器提供的撤销接口(Revocation Endpoint)来供客户机应用申请撤销 AccessToken 及 RefreshToken。
授权服务器也可以提供撤销授权接口,见接口/oauth/token/revoke如下:
/**
* 撤销访问令牌
*
* @param token AccessToken
* @return 是否成功
*/
@PostMapping("/token/revoke")
Boolean revokeToken(@RequestParam String token);
授权服务器在数据库、缓存中直接删除 AccessToken 及 RefreshToken 即可。
另外在客户机应用层面,也有集体批量注销这个客户机所属的 AccessToken 的需求,例如客户机应用下架了。
授权码模式是服务端类型的应用,用户无法看到源代码可以持有 clientSecret 秘钥,而浏览器/JavaScript/Native 应用由于用户可以直接看到源代码,所以授权服务器不能分配这种客户机 clientSecret。

访问获取授权服务地址:
/**
* 隐式许可流程(Implicit)模式用户授权
*
* @param responseType 授权模式,固定为 token
* @param clientId 客户端标识符,表示 OAuth 客户端的唯一标识
* @param redirectUri 重定向 URI,表示授权服务器将授权码发送到此 URI
* @param scope 作用域,表示客户端请求的权限范围
* @param state 用于防止 CSRF 攻击
* @param req 请求对象
* @param resp 响应对象
*/
@GetMapping("/implicit_authorization")
void implicitAuthorization(@RequestParam("response_type") String responseType, @RequestParam("client_id") String clientId, @RequestParam("redirect_uri") String redirectUri, @RequestParam(required = false) String scope, @RequestParam String state, HttpServletRequest req, HttpServletResponse resp);
从上面参数可以看出看,此流程和授权码模式最大的区别是response_type的值是token。
当用户点击确认授权按钮之后,授权服务器会自动重定向当前请求到redirect_uri指定的 url,并附带一个 token,如下面所示:
http://client.com/implicit_callback?access_token=ya29.AHES6ZSzX&token_type=Bearer&expires_in=3600
redirect_uri其实就是客户机应用地址,此时客户机就可以从redirect_uri中截取access_token,有了令牌客户机便可以访问资源服务器 API 获取用户信息了。
Implicit 流程没有 Refresh token,所以一旦 token 请求过期,就需要重新走一遍 implicit 整个流程。在实际操作中,如果access token已经过期,但当前用户还没有退出登录,第三方应用再重新申请 token 时,授权服务器一般都会直接颁发 AccessToken 无须再让用户确认,这样可以提高用户体验。
Password 顾名思义直接使用用户的用户名、密码换取 AccessToken。一般只有用户非常信任的应用才会使用这种流程,比如 API 提供者发布的应用就可以使用这种流程,移动 Apps 开发可以采用这种模式,因为 API 提供者资源服务器 本身就属于移动 Apps。

Password 授权流程:
grant_tye=password、client_id/client_secret/username/password如下接口定义:
/**
* 用户密码 Password 授权模式
*
* @param grantType 必填,且固定是 password
* @param clientId 客户机应用 id
* @param clientSecret 应用客户端密钥
* @param loginId 用户账号
* @param password 密码
*/
@PostMapping("/password_authorization")
void passwordAuthorization(@RequestParam("grant_type") String grantType, @RequestParam("client_id") String clientId, @RequestParam("client_secret") String clientSecret,
@RequestParam String loginId, @RequestParam String password);
感觉就是跳转来跳转去,便走完 OAuth 授权了。
OAuth 相关流程,看着两篇文章就够了:《开放授权协议:Oauth2.0》、《详解 OAuth 2.0授权协议(Bearer token)》