Oauth 协议为用户资源的授权提供了一个安全的、开放而又简易的标准,先前曾经了解过在 spring-security-oauth2Oauth 四种模式的实现,也通过 Shiro 实现了 Oauth 的授权流程。

目前 spring-security-oauth2 已经被逐步废弃,Spring 也提供了新的框架 spring-authorization-server,整个框架基于 Oauth 2.1 开发。目前重新整理项目代码,借此机会详细梳理一遍 Oauth2.0 授权模式的适用场景和授权流程,后续用于和 2.1 对比参照。

一、四种授权模式

1.1 客户端凭证模式

该模式针对客户端而言,对用户是透明的,不需要用户参与,非用户层面授权。客户端向授权服务器发送自己的的 client_idclient_secrect 请求 access_token ,用户中心仅校验客户端应用身份 。客户端通过授权后可以获得授权范围内所有用户的信息,对客户端应用需要极高的信任。

模式特点: 针对客户端层面进行授权,而非对单独用户进行授权的场景,用户中心仅校验客户端应用的身份。

适用场景: 适用于的自家产品、微服务中,需要从接口层面发起请求的场景。

当前模式仅生成 access_token,不生成 refresh_token

1.2 密码模式

用户直接提供用户名与密码给客户端应用,客户端使用用户的账号和密码、自己的 client_idclient_secrect 向授权服务器请求 token 。用户中心校验用户和客户端应用身份,响应 access_tokenrefresh_token

模式特点: 用户的账号和密码直接暴露给客户端,安全性低。

适用场景: 适用于自家的产品、微服务中,需要从用户层面发起请求的场景。

该模式已经被 Oauth2.1 废弃,直接将用户的账号和密码明文交给客户端应用是一个传统的方案,其本身没有校验意义,需要逐步过渡到通过 token 凭证这种授权方式。

换而言之,客户端都有密码了,通过 Oauth2.0 流程要一个临时的 access_token 干嘛?

1.3 授权码模式

客户端应用引导用户携带着 client_idredirect_url 前往认证服务器认证,认证通过后认证服务会附带上 code 参数重定向到 redirect_url 地址(客户端应用提供的接收授权码的地址)。客户端应用的服务端携带自己的 client_idclient_secrectcode 请求认证服务器获取 access_tokenrefresh_token 返回到用户。

模式特点: 四种模式中最安全、最常见的一种模式。

适用场景: 适用于有服务端服务的客户端应用。

网上说,这种方式避免了 access_token 直接在公网传输,黑客截获到 code 也无法获得最终的 access_token,所以这种方案非常安全。

不是非常认同,如果 code 会被截获,那么用户登录时的 token 是否也可以直接被截获到?Oauthtoken 是否在公网在公网上传输已经不重要了,因为用户登录的 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 密码模式

授权流程:

  1. 客户端提供一个登录接口,直接接收用户的登录账号和密码。

  2. 客户端应用的服务端接收到用户的账号和密码,向授权服务器的 POST /oauth/token 接口发起获取 access_tokenrefresh_token 的请求,并传入如下几个参数:

    参数名 必填 说明
    grant_type 密码模式填 password
    client_id 客户端ID
    client_secret 客户端秘钥
    username 用户登录账号
    password 用户登录密码
    scopes 指定授权范围,多个权限用 , 分隔,默认为客户端可申请的所有权限

    授权服务器将 access_tokenrefresh_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 授权码模式

授权流程:

  1. 打开授权页面 GET /oauth/authorize,并传入如下几个参数:

    参数名 必填 说明
    response_type 授权码模式填 code
    client_id 客户端ID
    redirect_uri 授权通过后重定向跳转的URL
    scopes 指定授权范围,多个权限用 , 分隔,默认为客户端可申请的所有权限
    state 请求状态,用于防重放
  2. 用户进行登录 POST /api/public/user/login,认证用户身份。

  3. (用户无须操作,登录即授权)前端接受到登录成功的响应,自动请求授权接口 POST /oauth/authorize,并传入如下几个参数:

    不需要再传 response_typeclient_id 这些信息了,这些信息已经通过 session 关联。

    参数名 必填 说明
    user_oauth_approval 用户是否通过授权填 true
    scope. true 为指定权限通过授权

    请求通过后,将携带生成的 code 和打开授权界面时传送的 state 参数,跳转到 redirect_uri 地址。

  4. 客户端应用的服务端接收到请求,向授权服务器的 POST /oauth/token 接口发起请求获取 access_tokenrefresh_token 的请求,并传入如下几个参数:

    参数名 必填 说明
    grant_type 授权码模式填 authorization_code
    code 授权码
    client_id 客户端ID
    client_secret 客户端秘钥

    最后授权服务器将 access_tokenrefresh_token 响应给客户端应用的服务端。

请求示例:

  1. 打开授权界面

    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
    
  2. 发起获取授权码的请求

    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"
    }
    
  3. 获取 access_tokenrefresh_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 隐式授权(简易模式)

授权流程:

  1. 打开授权页面 GET /oauth/authorize,并传入如下几个参数:

    参数名 必填 说明
    response_type token 模式填 token
    client_id 客户端ID
    redirect_uri 授权通过后重定向跳转的URL
    scopes 指定授权范围,多个权限用 , 分隔,默认为客户端可申请的所有权限
    state 请求状态,用于防重放
  2. 用户进行登录 POST /api/public/user/login,认证用户身份。

  3. (用户无须操作,登录即授权)前端接受到登录成功的响应,自动请求授权接口 POST /oauth/authorize,并传入如下几个参数:

    不需要再传 response_typeclient_id 这些信息了,这些信息已经通过 session 关联。

    参数名 必填 说明
    state 请求状态,用于防重放
    access_token 获取的 token
    expires_in token 失效时间
    scopes 获取的权限列表

    请求通过后,将携带生成的 token 相关信息作为锚点参数(不生成 refresh_token),将打开授权界面时传送的 state 作为参数,跳转到 redirect_uri 地址。

请求示例:

  1. 打开授权界面

    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
    
  2. 获取 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 刷新 tokenrefresh_token
client_id 客户端ID
client_secret 客户端秘钥
refresh_token refresh_token

如果刷新成功,则响应新的 access_tokenrefresh_token,原 access_tokenrefresh_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,但是一些授权细节上有调整,具体如下:

  1. 允许用户未登录就打开授权界面,在授权界面中可一键登录并授权(参考的 QQ 的授权界面);
  2. 允许设置必选权限、可选权限(也是参考的QQ的授权界面);
  3. 将获取授权码和 token 时的跳转改为响应跳转 url,在 JavaScript 中进行跳转;
  4. 也许还有其他一些细微的调整?忘了。

3.1 凭证模式与密码模式的意义

目前,在使用中还未实际用到客户端凭证模式和密码模式,仅仅是一个思考。

对于内部产品:

现在流行的是微服务架构,我们将产品都集成在了同一个微服务集群中,这样做方便管理,也方便通过 feign 进行服务间互访。

微服务间调用单独的开放一套接口,这些接口对公网的访问屏蔽,对微服务的调用请求没有进行任何权限校验。因为已经集成在同一个微服务集群上的应用之间本身就具有极高的信任程度,一般还是在同一个局域网之中,所以通过客户端凭证的方式进行校验失去了意义。

对于不可信的产品:

外部的可行度较低的服务通过凭证模式接入是一个比较个人推崇的方案,客户端凭证再辅加网络白名单策略或代理策略个人认为完全能保证通信的安全。但是这有一个前提,就是该应用允许具有对该接口完全的访问权限,否则就还需要在接口中额外的开发对客户端应用的身份和访问权限进行校验的逻辑。而密码模式,让客户端应用直接存储用户的账户和密码明文,这种方式是不建议的。

在授权框架层面上,是否能有一种只开放指定数据访问权限,且不需要用户显式操作登录的方案?

3.2 用户授权的方式

除了客户端凭证授权方式,另外三种授权方式用户都参与到了授权流程中。

spring-security-oauth2 的实现上看,其中授权码模式和隐式授权模式都和应用前端和 session 具有强关联关系,主要体现在:

  1. 通过 session 判断当前用户是否打开过授权界面,如果未打开过则不允许访问授权接口;
  2. 获取授权码或 token 后使用了链接跳转和重定向功能。

这样会存在一些问题,session 的方案不适合微服务的架构,除非只部署一个 Oauth 认证服务器,授权流程依赖于那个前端,对于 App 、小程序或者 Vue 单页面应用这类场景,需要再进行适配。

3.3 关于 JWT

Oauth 中,token 是由授权服务器颁发,访问资源服务器的数据的模式,资源服务器需要前往授权服务器校验 token 的有效性,也就是需要调用 GET/oauth/check_token 接口。

JWT 是一种将用户可公开的部分数据存放在 token 中的方案,对用户数据进行签名,将签名算法、用户数据和签名后的密文共同组成 tokentoken 中的用户数据明文可读。

如果采用 JWT 来做 token,那么资源服务器无须前往授权服务器校验也可知道 token 的有效性,这将提高资源访问的效率。

但这要建立在一个前提上,颁发出去的 token 不能提前失效,因为资源服务器仅靠 token 校验有效性。如果用户的 token 泄露了就无法阻止非法用户访问数据,所以 token 有效期需要尽量短。在授权服务器应可以禁用某个用户或者 token 避免非法用户使用 refresh_token 刷新更换新的 token

可以和客户端协定一个 Jwt 签名公私钥,如果有大量用户的 token 泄露则更换签名公私钥。

本文采用 UUID 作为 token,状态数据存储在 redis 上,如果需要 token 提前过期,删除 redis 中对应的缓存即可。