Oauth
协议为用户资源的授权提供了一个安全的、开放而又简易的标准,先前曾经了解过在 spring-security-oauth2
中 Oauth
四种模式的实现,也通过 Shiro
实现了 Oauth
的授权流程。
目前 spring-security-oauth2
已经被逐步废弃,Spring
也提供了新的框架 spring-authorization-server
,整个框架基于 Oauth 2.1
开发。目前重新整理项目代码,借此机会详细梳理一遍 Oauth2.0
授权模式的适用场景和授权流程,后续用于和 2.1
对比参照。
一、四种授权模式
1.1 客户端凭证模式
该模式针对客户端而言,对用户是透明的,不需要用户参与,非用户层面授权。客户端向授权服务器发送自己的的 client_id
和 client_secrect
请求 access_token
,用户中心仅校验客户端应用身份 。客户端通过授权后可以获得授权范围内所有用户的信息,对客户端应用需要极高的信任。
模式特点: 针对客户端层面进行授权,而非对单独用户进行授权的场景,用户中心仅校验客户端应用的身份。
适用场景: 适用于的自家产品、微服务中,需要从接口层面发起请求的场景。
当前模式仅生成 access_token
,不生成 refresh_token
。
1.2 密码模式
用户直接提供用户名与密码给客户端应用,客户端使用用户的账号和密码、自己的 client_id
和 client_secrect
向授权服务器请求 token
。用户中心校验用户和客户端应用身份,响应 access_token
和 refresh_token
。
模式特点: 用户的账号和密码直接暴露给客户端,安全性低。
适用场景: 适用于自家的产品、微服务中,需要从用户层面发起请求的场景。
该模式已经被 Oauth2.1
废弃,直接将用户的账号和密码明文交给客户端应用是一个传统的方案,其本身没有校验意义,需要逐步过渡到通过 token
凭证这种授权方式。
换而言之,客户端都有密码了,通过
Oauth2.0
流程要一个临时的access_token
干嘛?
1.3 授权码模式
客户端应用引导用户携带着 client_id
和 redirect_url
前往认证服务器认证,认证通过后认证服务会附带上 code
参数重定向到 redirect_url
地址(客户端应用提供的接收授权码的地址)。客户端应用的服务端携带自己的 client_id
、 client_secrect
和 code
请求认证服务器获取 access_token
和 refresh_token
返回到用户。
模式特点: 四种模式中最安全、最常见的一种模式。
适用场景: 适用于有服务端服务的客户端应用。
网上说,这种方式避免了 access_token
直接在公网传输,黑客截获到 code
也无法获得最终的 access_token
,所以这种方案非常安全。
不是非常认同,如果
code
会被截获,那么用户登录时的token
是否也可以直接被截获到?Oauth
的token
是否在公网在公网上传输已经不重要了,因为用户登录的token
已经在公网上传输了。
个人观点认为,安全主要体现在这种模式同时校验了用户和客户端应用的 client_secrect
,与密码模式不同,当前模式用户和客户端应用双方都不知道对方的密码明文。
1.4 隐式授权(简易模式)
客户端应用引导用户携带 client_id
前往授权服务器认证,认证通过后认证服务器直接返回 access_token
。
模式特点: 不需要与客户端应用的服务端进行交互,没有校验 client_secrect
。
适用场景: 适用于仅有前端页面,没有后端服务的客户端应用。
通过 #
锚点链接的方式返回 access_token
,避免 token
被携带传输到 web
前端文件托管的服务器上。
该模式已经被 Oauth2.1
废弃,这种方式容易泄露 token
,不安全。
二、授权流程
2.1 客户端凭证模式
授权流程:
客户端启动时向授权服务器的 POST /oauth/token
接口发起获取 access_token
的请求,在 access_token
即将过期时再次请求重新获取 access_token
。
参数名 | 必填 | 说明 |
---|---|---|
grant_type | 是 | 客户端凭证模式填 client_credentials |
client_id | 是 | 客户端ID |
client_secret | 是 | 客户端秘钥 |
scopes | 是 | 指定授权范围,多个权限用 , 分隔,默认为客户端可申请的所有权限 |
请求示例:
通过客户端应用的 ID 和 Secret 获取 access_token
,该 access_token
具有对所有用户的权限:
curl --location --request POST 'http://127.0.0.1:4540/oauth/token?grant_type=client_credentials&client_id=gongyi&client_secret=32c21505c0d0422c99fd158d0eaa5880&scopes=USER_INFO,GET_SECURITY'
>>> 获取token
{
"error": false,
"code": 200,
"data": {
"scopes": [
"USER_INFO",
"GET_SECURITY"
],
"access_token": "59f117422bf945d5a021e4911312a602",
"expires_in": 43200
}
}
2.2 密码模式
授权流程:
-
客户端提供一个登录接口,直接接收用户的登录账号和密码。
-
客户端应用的服务端接收到用户的账号和密码,向授权服务器的
POST /oauth/token
接口发起获取access_token
和refresh_token
的请求,并传入如下几个参数:参数名 必填 说明 grant_type 是 密码模式填 password
client_id 是 客户端ID client_secret 是 客户端秘钥 username 是 用户登录账号 password 是 用户登录密码 scopes 是 指定授权范围,多个权限用 ,
分隔,默认为客户端可申请的所有权限授权服务器将
access_token
和refresh_token
响应给客户端应用的服务端。
请求示例:
通过用户的账号密码和客户端应用的 ID 和 Secret 获取 token
:
curl --location --request POST 'http://127.0.0.1:4540/oauth/token?grant_type=password&username=12312312312&password=123123&client_id=gongyi&client_secret=32c21505c0d0422c99fd158d0eaa5880&scopes=USER_INFO,GET_SECURITY'
>>> 获取token
{
"error": false,
"code": 200,
"data": {
"scopes": [
"USER_INFO",
"GET_SECURITY"
],
"access_token": "4df53c0d1a4446e28fe55e281b5b6693",
"expires_in": 43200,
"refresh_token": "3f447d939fcc41db907551052a96950f"
}
}
2.3 授权码模式
授权流程:
-
打开授权页面
GET /oauth/authorize
,并传入如下几个参数:参数名 必填 说明 response_type 是 授权码模式填 code
client_id 是 客户端ID redirect_uri 是 授权通过后重定向跳转的URL scopes 否 指定授权范围,多个权限用 ,
分隔,默认为客户端可申请的所有权限state 否 请求状态,用于防重放 -
用户进行登录
POST /api/public/user/login
,认证用户身份。 -
(用户无须操作,登录即授权)前端接受到登录成功的响应,自动请求授权接口
POST /oauth/authorize
,并传入如下几个参数:不需要再传
response_type
和client_id
这些信息了,这些信息已经通过session
关联。参数名 必填 说明 user_oauth_approval 是 用户是否通过授权填 true
scope. 是 填 true
为指定权限通过授权请求通过后,将携带生成的
code
和打开授权界面时传送的state
参数,跳转到redirect_uri
地址。 -
客户端应用的服务端接收到请求,向授权服务器的
POST /oauth/token
接口发起请求获取access_token
和refresh_token
的请求,并传入如下几个参数:参数名 必填 说明 grant_type 是 授权码模式填 authorization_code
code 是 授权码 client_id 是 客户端ID client_secret 是 客户端秘钥 最后授权服务器将
access_token
和refresh_token
响应给客户端应用的服务端。
请求示例:
-
打开授权界面
curl --location --request GET 'http://127.0.0.1:4540/oauth/authorize?response_type=code&client_id=gongyi&redirect_uri=https://blog.nineya.com/&scopes=USER_INFO,GET_SECURITY&state=123' >>> 授权界面html
-
发起获取授权码的请求
curl --location --request POST 'http://127.0.0.1:4540/oauth/authorize?user_oauth_approval=true&scope.USER_INFO=true' \ --header 'User-Authorization: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1aWQiOjE2NzE0NTcxNjg4OTU3MzMsImlzcyI6IlVTRVIiLCJpYXQiOjE2NzE1NDMwMzd9.b4q7qn0t_LOX84P5Huf9sV1XFNLyIp972hdL7ng4m4o' >>> 得到的授权码为x24lgN { "error": false, "code": 200, "data": "https://blog.nineya.com/?code=x24lgN&state=123" }
-
获取
access_token
和refresh_token
curl --location --request POST 'http://127.0.0.1:4540/oauth/token?grant_type=authorization_code&code=x24lgN&client_id=gongyi&client_secret=32c21505c0d0422c99fd158d0eaa5880' >>> 获取token { "error": false, "code": 200, "data": { "scopes": [ "USER_INFO" ], "access_token": "b8ee3da722794fb0b1c9f236de7f33a9", "expires_in": 43200, "refresh_token": "f676fdd4bbe34e2d8ac195a00b8f3fa9" } }
2.4 隐式授权(简易模式)
授权流程:
-
打开授权页面
GET /oauth/authorize
,并传入如下几个参数:参数名 必填 说明 response_type 是 token
模式填token
client_id 是 客户端ID redirect_uri 是 授权通过后重定向跳转的URL scopes 否 指定授权范围,多个权限用 ,
分隔,默认为客户端可申请的所有权限state 否 请求状态,用于防重放 -
用户进行登录
POST /api/public/user/login
,认证用户身份。 -
(用户无须操作,登录即授权)前端接受到登录成功的响应,自动请求授权接口
POST /oauth/authorize
,并传入如下几个参数:不需要再传
response_type
和client_id
这些信息了,这些信息已经通过session
关联。参数名 必填 说明 state 否 请求状态,用于防重放 access_token 是 获取的 token
expires_in 是 token
失效时间scopes 是 获取的权限列表 请求通过后,将携带生成的
token
相关信息作为锚点参数(不生成refresh_token
),将打开授权界面时传送的state
作为参数,跳转到redirect_uri
地址。
请求示例:
-
打开授权界面
curl --location --request GET 'http://127.0.0.1:4540/oauth/authorize?response_type=code&client_id=gongyi&redirect_uri=https://blog.nineya.com/&scopes=USER_INFO,GET_SECURITY&state=123' >>> 授权界面html
-
获取
access_token
curl --location --request POST 'http://127.0.0.1:4540/oauth/authorize?user_oauth_approval=true&scope.USER_INFO=true' \ --header 'User-Authorization: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1aWQiOjE2NzE0NTcxNjg4OTU3MzMsImlzcyI6IlVTRVIiLCJpYXQiOjE2NzE1NDMwMzd9.b4q7qn0t_LOX84P5Huf9sV1XFNLyIp972hdL7ng4m4o' >>> 通过锚点参数响应access_token { "error": false, "code": 200, "data": "https://blog.nineya.com/?state=123#access_token=6fea088dfbb54c1da370ebe6b8e6ccf7&expires_in=43200&scopes=USER_INFO" }
2.5 刷新 token
在 access_token
即将过期时,调用 POST /oauth/token
,并传入如下几个参数进行 access_token
刷新。
参数名 | 必填 | 说明 |
---|---|---|
grant_type | 是 | 刷新 token 填 refresh_token |
client_id | 是 | 客户端ID |
client_secret | 是 | 客户端秘钥 |
refresh_token | 否 | refresh_token |
如果刷新成功,则响应新的 access_token
和 refresh_token
,原 access_token
和 refresh_token
失效。
2.6 校验 token
调用 GET/oauth/check_token
,并通过 token
参数传入 access_token
进行校验,校验通过返回用户id、客户端 ID 和授权范围信息。
curl --location --request GET 'http://127.0.0.1:4540/oauth/check_token?token=70b5e4c4084843f2babe1c3146b1f088'
>>>
{
"error": false,
"code": 200,
"data": {
"uid": -1,
"clientId": "gongyi",
"scopes": [
"USER_INFO",
"GET_SECURITY"
]
}
}
三、Oauth授权的思考
本文的四种授权模式的实现基本上参考于 spring-cloud-starter-oauth2:2.2.4
,但是一些授权细节上有调整,具体如下:
- 允许用户未登录就打开授权界面,在授权界面中可一键登录并授权(参考的 QQ 的授权界面);
- 允许设置必选权限、可选权限(也是参考的QQ的授权界面);
- 将获取授权码和
token
时的跳转改为响应跳转url
,在JavaScript
中进行跳转; - 也许还有其他一些细微的调整?忘了。
3.1 凭证模式与密码模式的意义
目前,在使用中还未实际用到客户端凭证模式和密码模式,仅仅是一个思考。
对于内部产品:
现在流行的是微服务架构,我们将产品都集成在了同一个微服务集群中,这样做方便管理,也方便通过 feign
进行服务间互访。
微服务间调用单独的开放一套接口,这些接口对公网的访问屏蔽,对微服务的调用请求没有进行任何权限校验。因为已经集成在同一个微服务集群上的应用之间本身就具有极高的信任程度,一般还是在同一个局域网之中,所以通过客户端凭证的方式进行校验失去了意义。
对于不可信的产品:
外部的可行度较低的服务通过凭证模式接入是一个比较个人推崇的方案,客户端凭证再辅加网络白名单策略或代理策略个人认为完全能保证通信的安全。但是这有一个前提,就是该应用允许具有对该接口完全的访问权限,否则就还需要在接口中额外的开发对客户端应用的身份和访问权限进行校验的逻辑。而密码模式,让客户端应用直接存储用户的账户和密码明文,这种方式是不建议的。
在授权框架层面上,是否能有一种只开放指定数据访问权限,且不需要用户显式操作登录的方案?
3.2 用户授权的方式
除了客户端凭证授权方式,另外三种授权方式用户都参与到了授权流程中。
从 spring-security-oauth2
的实现上看,其中授权码模式和隐式授权模式都和应用前端和 session
具有强关联关系,主要体现在:
- 通过
session
判断当前用户是否打开过授权界面,如果未打开过则不允许访问授权接口; - 获取授权码或
token
后使用了链接跳转和重定向功能。
这样会存在一些问题,session
的方案不适合微服务的架构,除非只部署一个 Oauth
认证服务器,授权流程依赖于那个前端,对于 App
、小程序或者 Vue
单页面应用这类场景,需要再进行适配。
3.3 关于 JWT
在 Oauth
中,token
是由授权服务器颁发,访问资源服务器的数据的模式,资源服务器需要前往授权服务器校验 token
的有效性,也就是需要调用 GET/oauth/check_token
接口。
JWT
是一种将用户可公开的部分数据存放在 token
中的方案,对用户数据进行签名,将签名算法、用户数据和签名后的密文共同组成 token
,token
中的用户数据明文可读。
如果采用 JWT
来做 token
,那么资源服务器无须前往授权服务器校验也可知道 token
的有效性,这将提高资源访问的效率。
但这要建立在一个前提上,颁发出去的 token
不能提前失效,因为资源服务器仅靠 token
校验有效性。如果用户的 token
泄露了就无法阻止非法用户访问数据,所以 token
有效期需要尽量短。在授权服务器应可以禁用某个用户或者 token
避免非法用户使用 refresh_token
刷新更换新的 token
。
可以和客户端协定一个
Jwt
签名公私钥,如果有大量用户的token
泄露则更换签名公私钥。
本文采用 UUID
作为 token
,状态数据存储在 redis
上,如果需要 token
提前过期,删除 redis
中对应的缓存即可。