了解 OAuth
感觉 OAuth 太负盛名了,以至于后来在 OIDC 反而难以企及前辈 OAuth。倒是大家谈论比较多的是 JWT(例如https://www.cnblogs.com/lyzg/p/6132801.html),——实际谈 JWT 就是在实现 OIDC,反而 OIDC 大家不怎么爱谈!但我们要知道的是,真正诠释这些的,做点单点登录的,——是 OIDC 规范,JWT 只是 OIDC 规范下的一种 Token 协议,再说句难听的,如果 JWT 不满足或者有问题,换别的 Token 实现规则也行。
这里再一次不厌其烦地强调:
- 认证(Authentication),识别你是谁。即在网站上用来识别某个用户是否是注册过的合法用户。关于 OIDC 说的是这个。
- 授权(Authorization),识别你能做什么。即在网站上用来识别某个用户是否有某方面的权限。本文正是讲这个的。
不过话说回来,OIDC 与 OAuth 看上去大体是相近,只是把应用场景稍作转换,另外就是返回 Token 的不同,OAuth 不限定 Token 具体实现如何,而 OIDC 推荐带用户信息的 JWT。所以,这么说,也不能怪人们总爱谈 JWT 而忽视 OIDC。
授权模式对比
OAuth2 提供了不同的 Grant Type 以适应不同的客户端类型以及应用场景,具 体有如下几种:
- Authorization Code 授权码模式:主要是服务端类型的应用,这应用是比较安全的,是多数场景使用的模式。
- Implicit 简化模式:浏览器应用或者移动 Apps。
- Password 密码模式:通过用户名密码登录,适用于信任应用(自研应用)。
- Client Credentials 客户端模式:只认证应用,无需用户授权服务端应用。
本文只对前后端授权常见的授权码模式和客户端凭证授权模式做详细介绍,其他授权模式可自行了解,这里只做简单介绍。
授权码流程 Authorization Code
OAuth 2.0 提供了四种具体的授权流程: 授权码流程(Authorization Code),隐式许可流程(Implicit),用户密码流程(Resource owner password credentials)和客户机凭据流程(Client Credentials)。其中授权码流程(authorization code)最常见、安全性也最高。授权码通过前端传送,令牌储存在后端,而且所有与资源服务器的通信都在后端完成,可以避免令牌泄漏。
标准授权码流程流程如下:
- 在授权服务器注册客户端,获得 client_id、client_secret
- 客户端访问授权服务器的授权页
- 用户在授权页,可以选择“授权”或者不授权,选择后会重定向到客户端的回调地址
- 客户端在回调地址拿到 code,通过 code 获取 token,通过 token 获取用户信息
授权确认页面
具体用户是如何授权的呢?一般来说,第三方应用向授权服务器发送用户授权请求时,授权服务器会自动检查当前用户有没有登录 (例如通过 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
后面附上两个应答参数:
- code:授权码,代表用户的授权码
- state:与第一步请求授权中的
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
授权码换取 AccessToken
下面客户机就可以凭获得的授权码 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"
}
出参说明:
- access_token:必选,授权服务器签发给客户机应用的短期有效的访问令牌。
- token_type:必选,令牌类型,详见下文access token的具体用法(Bearer token)。
- expires_in:推荐,令牌有效期,单位为秒,3600秒即1小时。
- scope:授权服务器批准的资源权限,如果与客户机应用申请的权限不同(即批准权限小于申请权限),则必须返回此 参数,如果相同,可以不用返回。
- refresh_token:可选,授权服务器签发给客户机应用的较长有效期的更新令牌。由于 access_token有效期很短,到期后客户机应用可以使用refresh_token向授权服务器申请新的 access_token,从而避免重复找用户要授权。
安全考量
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。
更新 Token:通过 RefreshToken
用 RefreshToken 换发新的 AccessToken
客户机应保存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);
实现如下。
相关问题
- 刷新了新的 Token 之后,旧的 Token 还能用吗?
当使用 RefreshToken 获取新的访问令牌(AccessToken)时,通常情况下旧的 AccessToken 将会被作废,不再有效。 - RefreshToken 有超时的概念吗?
RefreshToken 的有效期可以是长期的,通常不会自动过期。根据 OAuth 2.0 规范,RefreshToken 的过期时间是由授权服务器(Authorization Server)决定的。授权服务器可以为 RefreshToken 设置一个固定的过期时间,也可以让 RefreshToken 永不过期。这取决于授权服务器的配置和策略。
客户端凭证 Client Credentials 授权模式
ClientCredentials 是 OAuth 四大授权流程中最简单的一个流程。只需要用 client_id 和 client_secret 即可换取 AccessToken。这个流程只用来访问客户端拥有的资源而非用户拥有的资源,因为这个流程无须用户授权,跟用户无关,只需要客户端的认证凭证。
客户端凭证模式没有 RefreshToken 机制。如果 Token 快过期或者已过期,重新申请即可。 本方案中 Client 认证也可以刷新 Token。
资源服务器 SDK
AccessToken 如何传参
在 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
- 放在 URI 的查询参数中传递:RFC 6750 不建议采用这种方式,但笔者觉得也应该有必要提供支持(在访问静态资源的时候)。
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 这种,也是推荐方式,安全性最高,原因如下:
- 这个 header 通常不会被打印到 log 中
- 这个 header 不会被缓存
- 这个 header 不会被存储到浏览器中
如何校验 AccessToken?
资源服务器对传入的 Access token,不能一概都认为合法的,总得要校验一下。资源服务器本身不知道怎么校验,只能让授权服务器说了算,以授权服务器的校验为准。作为统一的认证中心,授权服务器无疑拥有最根本的用户状态记录,一切皆以授权服务器的为准——原则上是这样设计。实操上资源服务器与授权服务器之间的协调可以按以下方法去做。
- 对于小型 Web 应用:资源服务器通常与授权服务器同为一体,自然能够通过读取数据库来校验。
- 对于大型 Web 应用:授权服务器和资源服务器通常是独立部署的,有三种方式校验:
- 共享数据库,使得资源服务器能够通过读取数据库来校验。但出于安全目的一般不可行。
- 由授权服务器提供一个令牌校验接口,资源服务器请求该接口来校验。OAuth 2.0 在补充规范 RFC 7662 中定义了令牌校验接口(Introspection Endpoint)的相关标准。但每次访问资源的认证工作都要通讯授权服务器,性能成本会不会太高呢?对于授权服务器的性能是严重的考验。
- 对此,我们可以把 Token 放在 Redis 这类缓存中,让后共享 Redis 给资源服务器。Redis 通过长链接连接,总比 HTTP+数据库 消耗来得低。
- 授权服务器不校验,资源服务器本地对 AccessToken 进行解密、验签。资源服务器和授权服务器双方约定好自包含令牌的结构、签名密钥、加密方法,资源服务器按照约定规则自行校验,例如 JWT。但这类方式注销比较麻烦。授权服务器无法影响资源服务器的 AccessToken 校验。
如果 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
当用户在客户机应用:退出登录、修改密码、注销账号,或卸载了客户机应用时,客户机应用除了主动删除存储在本地的 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 的需求,例如客户机应用下架了。
隐式许可流程(Implicit)模式
授权码模式是服务端类型的应用,用户无法看到源代码可以持有 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 授权模式
Password 顾名思义直接使用用户的用户名、密码换取 AccessToken。一般只有用户非常信任的应用才会使用这种流程,比如 API 提供者发布的应用就可以使用这种流程,移动 Apps 开发可以采用这种模式,因为 API 提供者资源服务器 本身就属于移动 Apps。
Password 授权流程:
- 在用户界面,让用户输入自己的用户名和密码
- 用用户凭证换取 Token,输入参数
grant_tye=password
、client_id
/client_secret
/username
/password
- 如果授权服务器认证用户凭证通过便直接返回 AccessToken 信息
如下接口定义:
/**
* 用户密码 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)》