参考文档: OAuth 2.0 实战

1. OAuth 2.0 是什么

那,OAuth 2.0 到底是什么呢?我们先从字面上来分析下。OAuth 2.0 一词中的 「Auth」 表示 「授权」,字母 「O」 是 Open 的简称,表示 「开放」 ,连在一起就表示 「开放授权」。这也是为什么我们使用 OAuth 的场景,通常发生在开放平台的环境下

用一句话总结来说,OAuth 2.0 就是一种授权协议。那如何理解这里的「授权」呢?

举一个电商的场景,你估计更有感觉。假如你是一个卖家,在京东商城开了一个店铺,日常运营中你要将订单打印出来以便给用户发货。但打印这事儿也挺繁琐的,之前你总是手工操作,后来发现有个叫「小兔」的第三方软件,它可以帮你高效率地处理这事。

但你想想,小兔是怎么访问到这些订单数据的呢?其实是这样,京东商城提供了开放平台,小兔通过京东商家开放平台的 API 就能访问到用户的订单数据

只要你在软件里点击同意,小兔就可以拿到一个访问令牌,通过访问令牌来获取到你的订单数据帮你干活儿了。你看,这里也是有一次授权。你要是不同意,平台肯定不敢把这些数据给到第三方软件。

总结来说,OAuth 2.0 这种授权协议,就是保证第三方(软件)只有在获得授权之后,才可以进一步访问授权者的数据

现在访问授权者的数据主要是通过 Web API,所以凡是要保护这种对外的 API 时,都需要这样授权的方式。而 OAuth 2.0 的这种颁发访问令牌的机制,是再合适不过的方法了。同时,这样的 Web API 还在持续增加,所以 OAuth 2.0 是目前 Web 上重要的安全手段之一了。

2. OAuth 2.0 是怎样运转的?

img

分析下这个流程,我们不难发现小兔软件最终的目的,是要获取一个叫做「访问令牌」的东西。

我们不难发现,OAuth 2.0 授权的核心就是颁发访问令牌、使用访问令牌, 而且不管是哪种类型的授权流程都是这样。

在小兔软件这个例子中呢,我们使用的就是授权码许可(Authorization Code)类型。它是 OAuth 2.0 中最经典、最完备、最安全、应用最广泛的许可类型。除了授权码许可类型外,OAuth 2.0 针对不同的使用场景,还有 3 种基础的许可类型,分别是隐式许可(Implicit)、客户端凭据许可(Client Credentials)、资源拥有者凭据许可(Resource Owner Password Credentials)。相对而言,这 3 种授权许可类型的流程,在流程复杂度和安全性上都有所减弱(在后续与你详细分析)。

3. 为什么需要授权码?

访问令牌是通过授权码换来的,为什么要用授权码来换令牌?为什么不能直接颁发访问令牌呢?

在讲这个问题之前,我先要和你同步下,在 OAuth 2.0 的体系里面有 4 种角色,按照官方的称呼它们分别是:

  • 资源拥有者
  • 客户端
  • 授权服务
  • 受保护资源

在我们的例子中,对应的关系为

  • 资源拥有者 -> 小明
  • 第三方软件 -> 小兔软件
  • 授权服务 -> 京东商家开放平台的授权服务
  • 受保护资源 -> 小明店铺在京东上面的订单

img

从图中看到,在第 4 步授权服务生成了授权码 code,按照一开始我们提出来的问题,如果不要授权码,这一步实际上就可以直接返回访问令牌 access_token 了。

按着这个没有授权码的思路继续想,如果这里直接返回访问令牌,那我们肯定不能使用重定向的方式。因为 这样会把安全保密性要求极高的访问令牌暴露在浏览器上 ,从而将会面临访问令牌失窃的安全风险。显然,这是不能被允许的。

也就是说,如果没有授权码的话,我们就只能把访问令牌发送给第三方软件小兔的后端服务。按照这样的逻辑,上面的流程图就会变成下面这样:

img

到这里,看起来天衣无缝。小明访问小兔软件,小兔软件说要打单你得给我授权,不然京东不干,然后小兔软件就引导小明跳转到了京东的授权服务。到授权服务之后,京东商家开放平台验证了小兔的合法性以及小明的登录状态后,生成了授权页面。紧接着,小明赶紧点击同意授权,这时候,京东商家开放平台知道可以把小明的订单数据给小兔软件。

于是,京东商家开放平台没含糊,赶紧生成访问令牌 access_token,并且通过后端服务的方式返回给了小兔软件。这时候,小兔软件就能正常工作了。

这样,问题就来了,什么问题呢? 当小明被浏览器重定向到授权服务上之后,小明跟小兔软件之间的 「连接」 就断了,相当于此时此刻小明跟授权服务建立了「连接」后,将一直「停留在授权服务的页面上」。你会看到图 2 中问号处的时序上,小明再也没有重新「连接」到小兔软件。

到这里,你就能理解在授权码许可的流程中,为什么需要两次重定向了吧。

为了重新建立起这样的一次连接,我们又不能让访问令牌暴露出去,就有了这样一个 临时的、间接的凭证:授权码。因为小兔软件最终要拿到的是安全保密性要求极高的访问令牌,并不是授权码,而授权码是可以暴露在浏览器上面的。这样有了授权码的参与,访问令牌可以在后端服务之间传输,同时呢还可以重新建立小明与小兔软件之间的「连接」。这样通过一个授权码,既「照顾」到了小明的体验,又「照顾」了通信的安全。

4. 授权码和访问令牌的颁发流程

开始之前,你还是要先回想下小明给小兔软件授权订单数据的整个流程。

我们说小兔软件先要让小明去京东商家开放平台那里给它授权数据,那这里是不是你觉得很奇怪?你总不能说,「嘿,京东,你把数据给小兔用吧」,那京东肯定会回复说,「小明,小兔是谁啊,没在咱家备过案,我不能给他,万一是骗子呢?」

对吧,你想想是不是这个逻辑。所以,授权这个大动作的前提,肯定是小兔要去平台那里「备案」,也就是注册。注册完后,京东商家开放平台就会给小兔软件 app_id 和 app_secret 等信息,以方便后面授权时的各种身份校验

同时,注册的时候,第三方软件也会请求受保护资源的可访问范围。比如,小兔能否获取小明店铺 3 个月以前的订单,能否获取每条订单的所有字段信息等等。这个权限范围,就是 scope。后面呢,我还会详细讲述范围控制。

文字说起来有点抽象,咱们还是直接上代码吧。关于注册后的数据存储,我们使用如下 Java 代码来模拟:

Map<String,String> appMap =  new HashMap<String, String>();//模拟第三方软件注册之后的数据库存储
appMap.put("app_id","APPID_RABBIT");
appMap.put("app_secret","APPSECRET_RABBIT");
appMap.put("redirect_uri","http://localhost:8080/AppServlet-ch03");
appMap.put("scope","nickname address pic");

备完案之后,咱们接着继续前进。小明过来让平台把他的订单数据给小兔,平台咔咔一查,对了下暗号,发现小兔是合法的,于是就要推进下一步了。

在授权码许可类型中,授权服务的工作,可以划分为两大部分:

  • 一个是 颁发授权码 code
  • 一个是 颁发访问令牌 access_token

img

4.1 颁发授权码 code

4.1.1 验证基本信息

验证基本信息,包括对第三方软件小兔合法性和回调地址合法性的校验。

在 Web 浏览器环境下,颁发 code 的整个请求过程,都是浏览器通过前端通信来完成,这就意味着所有信息都有被冒充的风险。因此,授权服务必须对第三方软件的存在性做判断。

同样,回调地址也是可以被伪造的。比如,不法分子将其伪装成钓鱼页面,或者是带有恶意攻击性的软件下载页面。因此从安全上考虑,授权服务需要对回调地址做基本的校验。

if(!appMap.get("redirect_uri").equals(redirectUri)){
    //回调地址不存在
}

在授权服务的程序中,这两步验证通过后,就会生成或者响应一个页面(属于授权服务器上的页面),以提示小明进行授权。

4.1.2 第一次验证权限范围

既然是授权,就会涉及范围。比如,我们使用微信登录第三方软件的时候,会看到微信提示我们,第三方软件可以获得你的昵称、头像、性别、地理位置等。如果你不想让第三方软件获取你的某个信息,那么可以不选择这一项。同样在小兔中也是一样,当小明为小兔进行授权的时候,也可以选择给小兔的权限范围,比如是否授予小兔获取 3 个月以前的订单的访问权限。

这就意味着,我们需要对小兔传过来的 scope 参数,与小兔注册时申请的权限范围做比对。如果请求过来的权限范围大于注册时的范围,就需要作出越权提示。记住,此刻是第一次权限校验。

String scope = request.getParameter("scope");
if(!checkScope(scope)){
    //超出注册的权限范围
}

4.1.3 生成授权请求界面

这个授权请求页面就是授权服务上的页面,如下图所示:

img

页面上显示了小兔注册时申请的 today、history 两种权限,小明可以选择缩小这个权限范围,比如仅授予获取 today 信息的权限。

至此,颁发授权码 code 的准备工作就完成了。你要注意哈,我一直强调说这也是准备工作,因为当用户点击授权按钮「approve」后,才会 生成授权码 code 值和访问令牌 acces_token 值,「一切才真正开始」。

这里需要说明下: **在上面的准备过程中,我们忽略了小明登录的过程,**但只有用户登录了才可以对第三方软件进行授权,授权服务才能够获得用户信息并最终生成 code 和 app_id(第三方软件的应用标识) + user(资源拥有者标识)之间的对应关系。你可以把登录部分的代码,作为附加练习。

小明点击 approve 按钮之后,生成授权码 code 的流程就正式开始了,主要包括验证权限范围(第二次)、处理授权请求生成授权码 code 和重定向至第三方软件这三大步。

4.1.4 第二次验证权限范围

在步骤二中,生成授权页面之前授权服务进行的第一次校验,是对比小兔 请求过来的权限范围 scope 和注册时的权限做的比对。这里的第二次验证权限范围,是用小明进行 授权之后的权限,再次与小兔软件注册的权限做校验

那这里为什么又要校验一次呢?因为这相当于一次用户的输入权限。小明选择了一定的权限范围给到授权服务,对于权限的校验我们要重视对待,凡是输入性数据都会涉及到合法性检查。另外,这也是要求我们养成一种 在服务端对输入数据的请求,都尽可能做一次合法性校验的好习惯

String[] rscope =request.getParameterValues("rscope");
if(!checkScope(rscope)){
    //超出注册的权限范围
}

4.1.5 生成授权码

当小明同意授权之后,授权服务会校验响应类型 response_type 的值。response_type 有 code 和 token 两种类型的值。在这里,我们是用授权码流程来举例的,因此代码要验证 response_type 的值是否为 code。

String responseType = request.getParameter("response_type");
if("code".equals(responseType)){
  
}

在授权服务中,需要将生成的授权码 code 值与 app_iduser 进行关系映射。也就是说,一个授权码 code,表示某一个用户给某一个第三方软件进行授权,比如小明给小兔软件进行的授权。同时,我们需要将 code 值和这种映射关系保存起来,以便在生成访问令牌 access_token 时使用。

String code = generateCode(appId,"USERTEST");//模拟登录用户为USERTEST
private String generateCode(String appId,String user) {
  ...
  String code = strb.toString();
  codeMap.put(code,appId+"|"+user+"|"+System.currentTimeMillis());
  return code;
}

在生成了授权码 code 之后,我们也按照上面所述绑定了响应的映射关系。这时,你还记得我之前讲到的授权码是临时的、一次性凭证吗?因此,我们还需要为 code 设置一个有效期。

OAuth 2.0 规范建议授权码 code 值有效期为 10 分钟,并且 一个授权码 code 只能被使用一次。不过根据经验呢,在生产环境中 code 的有效期一般不会超过 5 分钟。关于授权码 code 相关的安全方面的内容,我还会在后续中详细讲述。

同时,授权服务还需要 将生成的授权码 code 跟已经授权的权限范围 rscope 进行绑定并存储,以便后续颁发访问令牌时,我们能够通过 code 值取出授权范围并与访问令牌绑定。因为第三方软件最终是通过访问令牌来请求受保护资源的。

Map<String,String[]> codeScopeMap =  new HashMap<String, String[]>();
codeScopeMap.put(code,rscope);//授权范围与授权码做绑定

4.1.6 重定向至第三方软件

生成授权码 code 值之后,授权服务需要将该 code 值告知第三方软件小兔。开始时我们提到,颁发授权码 code 是通过前端通信完成的,因此这里采用重定向的方式。这一步的重定向,也是我在上一讲中提到的第二次重定向。

Map<String, String> params = new HashMap<String, String>();
params.put("code",code);
String toAppUrl = URLParamsUtil.appendParams(redirectUri,params);//构造第三方软件的回调地址,并重定向到该地址
response.sendRedirect(toAppUrl);//授权码流程的“第二次”重定向

到此,颁发授权码 code 的流程全部完成。当小兔获取到授权码 code 值以后,就可以开始请求访问令牌 access_token 的值了.

4.2 颁发访问令牌

4.2.1 验证第三方软件是否存在

此时,接收到的 grant_type 的类型为 authorization_code。

String grantType = request.getParameter("grant_type");
if("authorization_code".equals(grantType)){
  
}

由于颁发访问令牌是通过后端通信完成的,所以这里除了要校验 app_id 外,还要校验 app_secret。

if(!appMap.get("app_id").equals(appId)){
    //app_id不存在
}
if(!appMap.get("app_secret").equals(appSecret)){
    //app_secret不合法
}

4.2.2 验证 code 是否合法

授权服务在颁发授权码 code 的阶段已经将 code 值存储了起来,此时对比从 request 中接收到的 code 值和从存储中取出来的 code 值。在我们给出的课程相关代码中,code 值对应的 key 是 app_id 和 user 的组合值。

String code = request.getParameter("code");
if(!isExistCode(code)){//验证code值
  //code不存在
  return;
}
codeMap.remove(code);//授权码一旦被使用,须立即作废

这里我们一定要记住,确认过授权码 code 值有效以后,应该立刻从存储中删除当前的 code 值,以防止第三方软件恶意使用一个失窃的授权码 code 值来请求授权服务。

4.2.3 生成访问令牌

关于按照什么规则来生成访问令牌 access_token 的值,OAuth 2.0 规范中并没有明确规定,但必须符合三个原则:唯一性、不连续性、不可猜性。在我们给出的 Demo 中,我们是使用 UUID 来作为示例的。

和授权码 code 值一样,我们需要将访问令牌 access_token 值存储起来,并将其与第三方软件的应用标识 app_id 和资源拥有者标识 user 进行关系映射。也就是说,一个访问令牌 access_token 表示某一个用户给某一个第三方软件进行授权

同时,授权服务还需要将授权范围跟访问令牌 access_token 做绑定。最后,还需要为该访问令牌设置一个过期时间 expires_in,比如 1 天。

Map<String,String[]> tokenScopeMap =  new HashMap<String, String[]>();
String accessToken = generateAccessToken(appId,"USERTEST");//生成访问令牌access_token的值
tokenScopeMap.put(accessToken,codeScopeMap.get(code));//授权范围与访问令牌绑定
//生成访问令牌的方法
private String generateAccessToken(String appId,String user){
  
  String accessToken = UUID.randomUUID().toString();
  String expires_in = "1";//1天时间过期
  tokenMap.put(accessToken,appId+"|"+user+"|"+System.currentTimeMillis()+"|"+expires_in);
  return accessToken;
}

正因为 OAuth 2.0 规范没有约束访问令牌内容的生成规则,所以我们有更高的自由度。我们既可以像 Demo 中那样生成一个 UUID 形式的数据存储起来,让授权服务和受保护资源共享该数据;也可以将一些必要的信息通过结构化的处理放入令牌本身。我们将包含了一些信息的令牌,称为结构化令牌,简称 JWT

4.3 刷新令牌

刷新令牌也是给第三方软件使用的,同样需要遵循 先颁发再使用 的原则。因此,我们还是从颁发和使用两个环节来学习刷新令牌。不过,这个颁发和使用流程和访问令牌有些是相同的,所以我只会和你重点讲述其中的区别。

4.3.1 颁发刷新令牌

其实,颁发刷新令牌和颁发访问令牌是一起实现的,都是在过程二的步骤三生成访问令牌 access_token 中生成的。也就是说,第三方软件得到一个访问令牌的同时,也会得到一个刷新令牌:

Map<String,String> refreshTokenMap =  new HashMap<String, String>();
String refreshToken = generateRefreshToken(appId,"USERTEST");//生成刷新令牌refresh_token的值
private String generateRefreshToken(String appId,String user){
  String refreshToken = UUID.randomUUID().toString();
  refreshTokenMap.put(refreshToken,appId+"|"+user+"|"+System.currentTimeMillis());
  return refreshToken;
  
}

看到这里你可能要问了,为什么要一起生成访问令牌和刷新令牌呢?

其实,这就回到了刷新令牌的作用上了。刷新令牌存在的初衷是,在访问令牌失效的情况下,为了不让用户频繁手动授权,用来通过系统重新请求 生成一个新的访问令牌。那么,如果访问令牌失效了,而「身边」又没有一个刷新令牌可用,岂不是又要麻烦用户进行手动授权了。所以,它必须得和访问令牌一起生成。

到这里,我们就解决了刷新令牌的颁发问题。

4.3.2 使用刷新令牌

说到刷新令牌的使用,我们需要先明白一点。在 OAuth 2.0 规范中,刷新令牌是一种特殊的授权许可类型,是嵌入在授权码许可类型下的一种特殊许可类型。在授权服务的代码里,当我们接收到这种授权许可请求的时候,会先比较 grant_typerefresh_token 的值,然后做下一步处理。

这其中的流程主要包括如下两大步骤。

第一步,接收刷新令牌请求,验证基本信息。

此时请求中的 grant_type 值为 refresh_token

String grantType = request.getParameter("grant_type");
if("refresh_token".equals(grantType)){
  
}

和颁发访问令牌前的验证流程一样,这里我们也需要验证第三方软件是否存在。需要注意的是,这里需要同时验证刷新令牌是否存在,目的就是要保证传过来的刷新令牌的合法性

String refresh_token = request.getParameter("refresh_token");
if(!refreshTokenMap.containsKey(refresh_token)){
    //该refresh_token值不存在
}

另外,我们还需要验证刷新令牌是否属于该第三方软件。授权服务是将颁发的刷新令牌与第三方软件、当时的授权用户绑定在一起的,因此这里需要判断该刷新令牌的归属合法性。

String appStr = refreshTokenMap.get("refresh_token");
if(!appStr.startsWith(appId+"|"+"USERTEST")){
    //该refresh_token值不是颁发给该第三方软件的
}

需要注意,一个刷新令牌被使用以后,授权服务需要将其废弃,并重新颁发一个刷新令牌。

第二步,重新生成访问令牌。

生成访问令牌的处理流程,与颁发访问令牌环节的生成流程是一致的。授权服务会将新的访问令牌和新的刷新令牌,一起返回给第三方软件。这里就不再赘述了。

4.3.3 拓展阅读

  • refresh_token 存在的意义是什么?access_token 过期了,为什么要用 refresh_token 去获取 access_token,好像重新获取 access_token 也行

    refresh_token 存在于授权码许可和资源拥有者凭据许可下,为了不烦最终用户频繁的点击【授权】按钮动作,才有了这样的机制; 在 隐式许可和客户端凭据许可,这两种许可类型下,不需要 refresh_token,他们可以直接根据 app_id 和secret 来换取访问令牌,因为,

    1. 隐式许可 对任何内容都是「透明的」,也没有必要存在 refresh_token
    2. 客户端凭据许可,既然是叫做「客户端凭据」了,在获取那些没有跟用户强关联的信息的时候,比如 国家省市信息类似的信息,其实没有用户参与的必要性,当然可以随时获取令牌。
  • 后台的 access_token 也会泄漏,什么时候需要刷新 token,刷新后需要重新获取?

    1. 若 access_token 已超时,那么进行 refresh_token 会获取一个新的 access_token,新的超时时间;
    2. 若 access_token 未超时,那么进行 refresh_token 有两种结果方式:
      1. 会改变 access_token,但超时时间会刷新,相当于续期 access_token,有的开放平台是这么做的
      2. 更新 access_token 的值,我们建议【统一更新 access_token 的值】。
    3. refresh_token 拥有较长的有效期,当 refresh_token 失效后,需要用户重新授权。 课程中也有提到,有了 refresh_token 的参与,提升了用户的体验。

5. JWT

5.1 JWT 介绍

Spring Boot+Spring Security+JWT实现单点登录

5.2 令牌内检

什么是令牌内检呢?授权服务颁发令牌,受保护资源服务就要验证令牌。同时呢,授权服务和受保护资源服务,它俩是「一伙的」,受保护资源来 调用授权服务提供的检验令牌的服务我们把这种校验令牌的方式称为令牌内检。

有时候授权服务依赖一个数据库,然后受保护资源服务也依赖这个数据库,也就是我们说的「共享数据库」。不过,在如今已经成熟的分布式以及微服务的环境下,不同的系统之间是依靠 服务不是数据库 来通信了,比如授权服务给受保护资源服务提供一个 RPC 服务。如下图所示。

img

那么,在有了 JWT 令牌之后,我们就多了一种选择,因为 JWT 令牌本身就包含了之前所要依赖数据库或者依赖 RPC 服务才能拿到的信息,比如我上面提到的哪个用户为哪个软件进行了授权等信息。

有了 JWT 令牌之后的通信方式,授权服务「扔出」一个令牌,受保护资源服务「接住」这个令牌,然后自己开始解析令牌本身所包含的信息就可以了,而不需要再去查询数据库或者请求 RPC 服务。这样也实现了我们上面说的令牌内检。

实际上,授权服务颁发了 JWT 令牌后给到了小兔软件,小兔软件拿着 JWT 令牌来请求受保护资源服务,也就是小明在京东店铺的订单。很显然,JWT 令牌需要在公网上做传输。所以在传输过程中,JWT 令牌需要进行 Base64 编码以防止乱码,同时还需要进行签名及加密处理来防止数据信息泄露

5.3 JWT 缺点

JWT 格式令牌的最大问题在于 「覆水难收」,也就是说,没办法在使用过程中修改令牌状态。我们还是借助小明使用小兔软件例子,先停下来想一下。

小明在使用小兔软件的时候,是不是有可能因为某种原因修改了在京东的密码,或者是不是有可能突然取消了给小兔的授权?这时候,令牌的状态是不是就要有相应的变更,将原来对应的令牌置为无效。

但,使用 JWT 格式令牌时,每次颁发的令牌都不会在服务端存储,这样我们要改变令牌状态的时候,就无能为力了。因为服务端并没有存储这个 JWT 格式的令牌。这就意味着,JWT 令牌在有效期内,是可以横行无止的。

为了解决这个问题,我们可以把 JWT 令牌存储到远程的分布式内存数据库中吗?显然不能,因为这会违背 JWT 的初衷(将信息通过结构化的方式存入令牌本身)。因此,我们通常会有两种做法:

  • 一是,将每次生成 JWT 令牌时的秘钥粒度缩小到用户级别,也就是一个用户一个秘钥。这样,当用户取消授权或者修改密码后,就可以让这个密钥一起修改。一般情况下,这种方案需要配套一个单独的密钥管理服务。
  • 二是,在不提供用户主动取消授权的环境里面,如果只考虑到修改密码的情况,那么我们就可以把用户密码作为 JWT 的密钥。当然,这也是用户粒度级别的。这样一来,用户修改密码也就相当于修改了密钥。

5.4 拓展阅读

  • 在 jwt.io 网站上验证的时候,如果不输入密钥,返回 invalid Signature, 但是 header 和 payload 信息依然可以正确显示。我的理解是,在生成 header 和 payload 部分的时候,是通过 base64 编码,没有进行加密处理。最后的签名是保证整个 body 在传输的过程中没有被篡改。那么是不是意味着使用 JWT 方式,信息的主体还是依然能被未授信的第三方获取到?

    JWT 肯定要加密传输,这点我们文中强调了,不做加密的结果就是你说的,加密用对称和非对称都可以,看实际需要,追求性能就是对称,可通过管理秘钥来对冲掉对称带来的相比非对称的弱化的那部分安全。

  • 如果需要从服务器端直接暴力将某些用户「踢出下线」,也就是让 jwt 失效,如何做

    还是通过管理密匙的部分,具体做法是:不用验证签名就能获取 jwt 中的 id 信息,然后获取该 id 的加密密匙,进行验证。 那么当需要让 jwt 失效的时候,修改这个密匙信息。

  • 用户密码当做秘钥合适吗?,如果用户修改密码,所有的授权都会失效

    用户修改密码,这个动作本身在安全背后是一件很严密的事情,对授权系统来讲,它接收到的事件,就是密码修改了,它的反应一定要让授权失效,因为授权系统不知道谁修改了密码。

  • jwt 如果每个用户一个密钥,就还需要访问数据库,这种方式和无结构的 token 优化没那么明显,只是省了token 的存储。

    存储节省不明显是在用户量少的情况下。秘钥管理系统是 JWT 和 OAuth 2.0 之外的成本,安全问题的防护是一个成本问题,如果是低等级防护,当然可以直接使用 JWT 的令牌短时过期。

6. 其它授权流程

6.1 资源拥有者凭证许可

你还记得 授权码许可流程的特点 么?它通过授权码这种临时的中间值,让小明这样的用户参与进来,从而让小兔软件和京东之间建立联系,进而让小兔代表小明去访问他在京东店铺的订单数据。

现在小兔被「招安」了,是京东自家的了,是被京东充分信任的,没有「第三方软件」的概念了。同时,小明也是京东店铺的商家,也就是说 软件和用户都是京东的资产。这时,显然没有必要再使用授权码许可类型进行授权了。但是呢,小兔依然要通过互联网访问订单数据的 Web API,来提供为小明打单的功能。

于是,为了保护这些场景下的 Web API,又为了让 OAuth 2.0 更好地适应现实世界的更多场景,来解决比如上述小兔软件这样的案例,OAuth 2.0 体系中还提供了 资源拥有者凭据许可类型

从「资源拥有者凭据许可」这个命名上,你可能就已经理解它的含义了。没错,资源拥有者的凭据,就是用户的凭据,就是 用户名和密码。可见,这是最糟糕的一种方式。那为什么 OAuth 2.0 还支持这种许可类型,而且编入了 OAuth 2.0 的规范呢?

我们先来思考一下。正如上面我提到的,小兔此时就是京东官方出品的一款软件,小明也是京东的用户,那么小明其实是可以使用用户名和密码来直接使用小兔这款软件的。原因很简单,那就是 这里不再有「第三方」的概念了

但是呢,如果每次小兔都是拿着小明的用户名和密码来通过调用 Web API 的方式,来访问小明店铺的订单数据,甚至还有商品信息等,在调用这么多 API 的情况下,无疑增加了用户名和密码等敏感信息的攻击面。

如果是使用了 token 来代替这些「满天飞」的敏感信息,不就能很大程度上保护敏感信息数据了吗?这样,小兔软件只需要使用一次用户名和密码数据来换回一个 token,进而通过 token 来访问小明店铺的数据,以后就不会再使用用户名和密码了。

接下来,我们一起看下这种许可类型的流程,如下图所示:

img

  • 步骤 1:当用户访问第三方软件小兔时,会提示输入用户名和密码。索要用户名和密码,就是 资源拥有者凭据许可类型的特点

  • 步骤 2:这里的 grant_type 的值为 password,告诉授权服务使用资源拥有者凭据许可凭据的方式去请求访问。

    Map<String, String> params = new HashMap<String, String>();
    params.put("grant_type","password");
    params.put("app_id","APPIDTEST");
    params.put("app_secret","APPSECRETTEST");
    params.put("name","NAMETEST");
    params.put("password","PASSWORDTEST");
    String accessToken = HttpURLClient.doPost(oauthURl,HttpURLClient.mapToStr(params));
    
  • 步骤 3:授权服务在验证用户名和密码之后,生成 access_token 的值并返回给第三方软件。

    if("password".equals(grantType)){
        String appSecret = request.getParameter("app_secret");
        String username = request.getParameter("username");
        String password = request.getParameter("password");
        if(!"APPSECRETTEST".equals(appSecret)){
            response.getWriter().write("app_secret is not available");
            return;
        }
        if(!"USERNAMETEST".equals(username)){
            response.getWriter().write("username is not available");
            return;
        }
        if(!"PASSWORDTEST".equals(password)){
            response.getWriter().write("password is not available");
            return;
        }
        String accessToken = generateAccessToken(appId,"USERTEST");//生成访问令牌access_token的值
        response.getWriter().write(accessToken);
    }
    

到了这里,你可以掌握到一个信息:如果软件是官方出品的,又要使用 OAuth 2.0 来保护我们的 Web API,那么你就可以使用小兔软件的做法,采用资源拥有者凭据许可类型

无论是我们的架构、系统还是框架,都是致力于解决现实生产中的各种问题的。除了资源拥有者凭据许可类型外,OAuth 2.0 体系针对现实的环境还提供了客户端凭据许可和隐式许可类型。接下来,让我们继续看看这两种授权许可类型吧。

6.2 客户端凭证许可

如果没有明确的资源拥有者,换句话说就是,小兔软件访问了一个不需要用户小明授权的数据,比如获取京东 LOGO 的图片地址,这个 LOGO 信息不属于任何一个第三方用户,再比如其它类型的第三方软件来访问平台提供的省份信息,省份信息也不属于任何一个第三方用户。

此时,在授权流程中,就不再需要资源拥有者这个角色了。当然了,你也可以形象地理解为 「资源拥有者被塞进了第三方软件中」 或者 「第三方软件就是资源拥有者」。这种场景下的授权,便是客户端凭据许可,第三方软件可以直接使用注册时的 app_id 和 app_secret 来换回访问令牌 token 的值。

我们还是以小明使用小兔软件为例,来看下客户端凭据许可的整个授权流程,如下图所示:

img

另外一点呢,因为授权过程没有了资源拥有者小明的参与,小兔软件的后端服务可以随时发起 access_token 的请求,所以 这种授权许可也不需要刷新令牌

这样一来,客户端凭据许可类型的关键流程,就是以下两大步。

  • 步骤 1:第三方软件小兔通过后端服务向授权服务发送请求,这里 grant_type 的值为 client_credentials,告诉授权服务要使用第三方软件凭据的方式去请求访问。

    Map<String, String> params = new HashMap<String, String>();
    params.put("grant_type","client_credentials");
    params.put("app_id","APPIDTEST");
    params.put("app_secret","APPSECRETTEST");
    String accessToken = HttpURLClient.doPost(oauthURl,HttpURLClient.mapToStr(params));
    
  • 步骤 2:在验证 app_id 和 app_secret 的合法性之后,生成 access_token 的值并返回。

    String grantType = request.getParameter("grant_type");
    String appId = request.getParameter("app_id");
    if(!"APPIDTEST".equals(appId)){
        response.getWriter().write("app_id is not available");
        return;
    }
    if("client_credentials".equals(grantType)){
        String appSecret = request.getParameter("app_secret");
        if(!"APPSECRETTEST".equals(appSecret)){
            response.getWriter().write("app_secret is not available");
            return;
        }
        String accessToken = generateAccessToken(appId,"USERTEST");//生成访问令牌access_token的值
        response.getWriter().write(accessToken);
    }
    
    • 到这里,我们再小结下。在获取一种不属于任何一个第三方用户的数据时,并不需要类似小明这样的用户参与,此时便可以使用客户端凭据许可类型。

      接下来,我们再一起看看今天要讲的最后一种授权许可类型,就是隐式许可类型。

6.3 隐式许可

让我们再想象一下,如果小明使用的小兔打单软件应用 没有后端服务,就是在浏览器里面执行的,比如纯粹的 JavaScript 应用,应该如何使用 OAuth 2.0 呢?

其实,这种情况下的授权流程就可以使用 隐式许可流程可以理解为第三方软件小兔直接嵌入浏览器中了

在这种情况下,小兔软件对于浏览器就没有任何保密的数据可以隐藏了,也不再需要应用密钥 app_secret 的值了,也不用再通过授权码 code 来换取访问令牌 access_token 的值了。因为使用授权码的目的之一,就是把浏览器和第三方软件的信息做一个隔离,确保浏览器看不到第三方软件最重要的访问令牌 access_token 的值。

因此,**隐式许可授权流程的安全性会降低很多 **。在授权流程中,没有服务端的小兔软件相当于是嵌入到了浏览器中,访问浏览器的过程相当于接触了小兔软件的全部,因此我用虚线框来表示小兔软件,整个授权流程如下图所示:

img

接下来,我使用 Servlet 的 Get 请求来模拟这个流程,一起看看相关的示例代码。

  • 步骤 1:用户通过浏览器访问第三方软件小兔。此时,第三方软件小兔实际上是嵌入浏览器中执行的应用程序。

  • 步骤 2:这个流程和授权码流程类似,只是需要特别注意一点,response_type 的值变成了 token,是要告诉授权服务直接返回 access_token 的值。随着我们后续的讲解,你会发现隐式许可流程是唯一在前端通信中要求返回 access_token 的流程。对,就这么 「大胆」,但「不安全」。

    Map<String, String> params = new HashMap<String, String>();
    params.put("response_type","token");//告诉授权服务直接返回access_token
    params.put("redirect_uri","http://localhost:8080/AppServlet-ch02");
    params.put("app_id","APPIDTEST");
    String toOauthUrl = URLParamsUtil.appendParams(oauthUrl,params);//构造请求授权的URl
    response.sendRedirect(toOauthUrl);
    
  • 步骤 3:生成 acccess_token 的值,通过前端通信返回给第三方软件小兔。

    String responseType = request.getParameter("response_type");
    String redirectUri =request.getParameter("redirect_uri");
    String appId = request.getParameter("app_id");
    if(!"APPIDTEST".equals(appId)){
        return;
    }
    if("token".equals(responseType)){
        //隐式许可流程(模拟),DEMO CODE,注意:该流程全部在前端通信中完成
        String accessToken = generateAccessToken(appId,"USERTEST");//生成访问令牌access_token的值
        Map<String, String> params = new HashMap<String, String>();
        params.put("redirect_uri",redirectUri);
        params.put("access_token",accessToken);
        String toAppUrl = URLParamsUtil.appendParams(redirectUri,params);//构造第三方软件的回调地址,并重定向到该地址
        response.sendRedirect(toAppUrl);//使用sendRedirect方式模拟前端通信
    }
    

如果你的软件就是直接嵌入到了浏览器中运行,而且还没有服务端的参与,并且还想使用 OAuth 2.0 流程的话,也就是像上面我说的小兔这个例子,那么便可以直接使用隐式许可类型了。

6.4 PKCE 协议

隐私协议的 appId 和 appSecret 被拦截后,谁都可以去获取 token,不安全,所以有了 PKCE 协议。

请求访问令牌时需要的 app_secret 就只能保存在用户本地设备上,而这并不是我们所建议的

问题的关键在于如何保存 app_secret,app_secret 一旦被破解,就将会造成灾难性的后果。这时,有的同学突发奇想,如果不用 app_secret,也能在授权码流程里换回访问令牌 access_token,不就可以了吗?

确实可以,但新的问题也来了。在授权码许可类型的流程中,如果没有了 app_secret 这一层的保护,那么通过授权码 code 换取访问令牌的时候,就只有授权码 code 在「冲锋陷阵」了。这时,授权码 code 一旦失窃,就会带来严重的安全问题。那么,我既不使用 app_secret,还要防止授权码 code 失窃,有什么好的方法吗?

有,OAuth 2.0 里面就有这样的指导方法。这个方法就是我们将要介绍的 PKCE 协议,全称是 Proof Key for Code Exchange by OAuth Public Clients。

在下面的流程图中,为了突出第三方软件使用 PKCE 协议时与授权服务之间的通信过程,我省略了受保护资源服务和资源拥有者的角色:

img

首先,App 自己要生成一个随机的、长度在 43~128 字符之间的、参数为 code_verifier 的字符串验证码;接着,我们再利用这个 code_verifier ,来生成一个被称为 「挑战码」的参数 code_challenge

那怎么生成这个 code_challenge 的值呢?OAuth 2.0 规范里面给出了两种方法,就是看 code_challenge_method 这个参数的值:

  • 一种 code_challenge_method=plain,此时 code_verifier 的值就是 code_challenge 的值;

  • 另外一种 code_challenge_method=S256,就是将 code_verifier 值进行 ASCII 编码之后再进行哈希,然后再将哈希之后的值进行 BASE64-URL 编码,如下代码所示。

    code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))
    

好了,我知道有这样两个值,也知道它们的生成方法了,但这两个值跟我们的授权码流程有什么关系呢,又怎么利用它们呢?不用着急,我们接着讲。

授权码流程简单概括起来不是有两步吗,第一步是获取授权码 code,第二步是用 app_id+app_secret+code 获取访问令牌 access_token。刚才我们的「梦想」不是设想不使用 app_secret,但同时又能保证授权码流程的安全性么?

没错。code_verifier 和 code_challenge 这两个参数,就是来帮我们实现这个梦想的。

第一步获取授权码 code 的时候,我们使用 code_challenge 参数。需要注意的是,我们要同时将 code_challenge_method 参数也传过去,目的是让授权服务知道生成 code_challenge 值的方法是 plain 还是 S256。

https://authorization-server.com/auth?
response_type=code&
app_id=APP_ID&
redirect_uri=REDIRECT_URI&
code_challenge=CODE_CHALLENGE&
code_challenge_method=S256

第二步获取访问令牌的时候,我们使用 code_verifier 参数,授权服务此时会将 code_verifier 的值进行一次运算。那怎么运算呢?就是上面 code_challenge_method=S256 的这种方式。

没错,第一步请求授权码的时候,已经告诉授权服务生成 code_challenge 的方法了。所以,在第二步的过程中,授权服务将运算的值跟第一步接收到的值做比较,如果相同就颁发访问令牌。

POST https://api.authorization-server.com/token?
  grant_type=authorization_code&
  code=AUTH_CODE_HERE&				// 第一步中获取到的授权码
  redirect_uri=REDIRECT_URI&
  app_id=APP_ID& 
  code_verifier=CODE_VERIFIER       // 将自己的随机字符串传递过去

6.5 如何选择

现在,我们已经理解了 OAuth 2.0 的 5 种授权许可类型的原理与流程。那么,我们应该如何选择到底使用哪种授权许可类型呢?

这里,我给你的建议是,在对接 OAuth 2.0 的时候先考虑授权码许可类型,其次再结合现实生产环境来选择:

  • 如果小兔软件是 官方出品,那么可以直接使用 资源拥有者凭据许可
  • 如果小兔软件就是 只嵌入到浏览器端的应用且没有服务端,那就 只能选择隐式许可 或者 PKCE 协议
  • 如果小兔软件获取的信息 不属于任何一个第三方用户,那可以直接使用 客户端凭据许可类型

6.6 总结

好了,我们马上要结束这篇文章了,在这之前呢,我们一直讲的是授权码许可类型,你已经知道了这是一种流程最完备、安全性最高的授权许可流程。不过呢,现实世界总是有各种各样的变化,OAuth 2.0 也要适应这样的变化,所以才有了我们今天讲的另外这三种许可类型。同时,关于如何来选择使用这些许可类型,我前面也给了大家一个建议。

加上前面我们讲的授权码许可类型,我们一共讲了 5 种授权许可类型,它们最显著的区别就是 获取访问令牌 access_token 的方式不同。最后,我通过一张表格来对比下:

授权许可类型 获取访问令牌的方式
授权码许可 通过授权码 code 获取 access_token
客户端凭证许可 通过第三方软件的 app_id 和 app_secret 获取 access_token
隐式许可 通过嵌入浏览器中的第三方软件的 app_id 来获取 access_token
资源拥有者凭证许可 通过资源拥有者的用户名和密码获取 access_token
PKCE 协议 通过 code_challenge 和 code_challenge_method 获取 code,通过 code 和 code_verifier 获取 token

除了上面这张表格所展现的 5 种授权许可类型的区别之外,我希望你还能记住以下两点。

  1. 所有的授权许可类型中,授权码许可类型的安全性是最高的。因此,只要具备使用授权码许可类型的条件,我们一定要首先授权码许可类型。
  2. 所有的授权许可类型都是为了解决现实中的实际问题,因此我们还要结合实际的生产环境,在保障安全性的前提下选择最合适的授权许可类型,比如使用客户端凭据许可类型的小兔软件就是一个案例。