Oauth2 概念和授权码模式

鸽了很久,其实也因为自己确实比较忙,加之自己在造demo的时候也遇到了很多问题,并且网上这方面的解答非常之少,不过也正是因为少,才更加让我想写这样的知识分享,最终,在一篇博客的解答下解决了问题,本节我们就开始Oauth2的学习。在之前的学习中,我们可以说SpringSecurity本身的基础学习差不多告一段落,大家已经可以通过自己的能力去搭建一个SpringSecurity的安全应用。

那么接下来我们就接着学习OAuth2这样一个庞大的主题,我们在这一章主要介绍一个概要,即OAuth2是什么,然后将会把它应用到一个关注使用单点登录(SSO)进行身份验证的应用程序上。这里之所以选用SSO,是因为其非常简单,也非常有用。

OAuth2框架

在大多数情况下,OAuth2被称为授权框架(或规范框架),其主要目的是允许第三方网站或应用程序访问资源。有时人们也把OAuth2称为一项委托协议。无论如何称呼它,重要的是要记住OAuth2不是一个特定的实现或库。也可以将OAuth2流程定义应用于其他平台、工具或语言。这里将介绍如何实现OAuth2与SpringBoot和Spring Security的集成应用。

理解OAuth2我们可以通过和之前学习过的HTTP Basic身份验证方法进行对比,我们以前使用的是HTTP Basic身份言则会那个,它有以下两个问题:

  • 为每个请求发送凭据
  • 由单独的系统管理用户的凭据

为每个请求发送凭据可能只适用于隔离环境的情况,但这通常是不可取的,因为这意味着:

  • 需要经常在网络上共享凭据
  • 让客户端(就Web应用程序而言就是浏览器)以某种方式存储凭据,以便客户端可以将这些凭据发送到服务器,并请求进行身份验证和授权。

我们希望在应用程序的架构中去掉这两点,因为它们会使凭据变成漏洞,从而削弱安全性。通常,我们会希望有一个单独的系统管理系统用户凭据。假设我们必须为组织中使用的所有应用程序配置和使用单独的凭据。如下图:在组织中,通常会使用多个应用程序。其中大多数需要用户进行身份验证才能使用。用户需要记住多个密码,组织要管理多个凭据集合,这将是一项挑战。

如果将凭据管理的职责隔离在系统的一个组件中会更好。目前,我们将其称为授权服务器。如下图:它拥有更易于维护的架构会将凭据单独保存,并允许所有应用程序为其用户使用同一组凭据

这种方式消除了表示同一个人的重复凭据。通过这种方式,架构将变得简单。

OAuth2身份验证架构的组件

  • 资源服务器托管用户所拥有的资源的应用程序。资源可以是用户的数据或他们被授权的操作
  • 用户(也成为资源拥有者):拥有由资源服务器暴露的资源的个体。用户通常有一个用户名和密码,他们用用户名和密码标识自己。
  • 客户端:以用户名义访问用户所拥有的资源的应用程序客户端使用客户端ID和客户端密钥来标识自己。请注意,这些凭据与用户凭据不同。客户端在发出请求时需要自己的凭据来标识自己
  • 授权服务器授权客户端访问由资源服务器暴露的用户资源的应用程序当授权服务器决定授权客户端已用户名义访问资源时,它会发出一个令牌客户端使用此令牌向资源服务器证明他已被授权服务器授权。如果它有一个有效的令牌,则资源服务器将允许客户端访问它请求的资源

使用OAuth2的实现选项

OAuth2主要是指使用令牌进行授权。令牌就像门禁卡一样。一旦获得令牌,就可以访问特定的资源。但是OAuth2提供了多种可能性以便获取令牌也称为授权。以下是可以选择的最常见的OAuth2授权方式。

  • 授权码
  • 密码
  • 刷新令牌
  • 客户端凭据

实现授权码授权类型

这种授权类型是最常用的OAuth2流程之一。

如上图,授权代码授权类型。客户端会要求用户直接与授权服务器交互,以便为其授予用户请求的权限。在授权之后,授权服务器会发出令牌,这样客户端就可以使用该令牌访问用户的资源。

ps:上图中的箭头不一定表示HTTP请求和响应。这些箭头表示OAuth2的参与者之间交换的消息。例如,当客户端告知用户“告知授权服务器你允许我做这个操作”时,客户端就会将用户重定向到授权服务器登录页面。当授权服务器向客户端提供访问令牌时,授权服务器实际上会根据所谓的重定向URI来调用客户端。当然这些细节后面会讲到,这里只需知道这些序列图不仅仅表示HTTP请求和响应,它们是对OAuth2参与者之间通信的简化描述

以下是授权码授权类型的工作方式。接下来将详细深入讲解每个步骤的细节。

(1)发出身份验证请求

(2)获取访问令牌

(3)调用受保护的资源

步骤1:使用授权码授权类型发出身份验证请求

客户端将用户重定向到需要进行身份验证的授权服务器端点。假设我们正在使用应用程序X,并且需要访问一个受保护的资源。为了访问该资源,应用程序X需要我们进行身份验证。它会打开一个授权服务器上的页面,其中包含登录表单,我们必须用凭据填写该表单

ps:这里真正需要注意的是,用户会直接与授权服务器交互。用户不会向客户端应用程序发送凭据
从技术上讲,这里发生的处理是,当客户端将用户重定向到授权服务器时,客户端会调用授权端点,并在请求中使用以下详细信息。

  • 带有code值的response_type,它会告知授权服务器客户端需要一个授权码。客户端需要该授权码用来获取访问令牌,第(2)步中将会接受。
  • client_id具有客户端ID值,它会标识应用程序本身
  • redirect_uri它会告知授权服务器在成功进行身份验证之后将用户重定向到何处。有时授权服务器已经知道每个客户端的默认重定向URI。由于这个原因,客户端不需要发送重定向URI。
  • scope,它类似于被授予的权限
  • state,他定义了一个跨站请求伪造(CSRF)令牌。用以CSRF防护
    拿一个后面实例中这一步出现的链接说明:

这一步就是客户端将用户重定向到授权服务器的场景,此时客户端向授权服务器(这里是gitee)发送请求,请求链接为:

https://gitee.com/oauth/authorize?response_type=code&client_id=c70egngnghhn464541b580db179d027df1f3017797e053890edd&scope=user_info&state=x5dLt6OkIRluOTVN4UQXG4mds5tqJBokhAd140_nqog%3D&redirect_uri=http://localhost:9090/login/oauth2/code/gitee

大家对比一下就会发现和上面的元素是一样的
身份验证成功后,授权服务器将根据重定向URI回调客户端,并提供授权码和状态值。客户端要检查状态值是否与它在请求中发送的状态值相同,以确认不是其他人试图调用重定向URI。之后客户端会使用授权码获取第(2)步中所示的访问令牌。

步骤2:使用授权码授权类型获取访问令牌

为了允许用户访问资源,第(1)步所产生的授权码就是经过身份验证的用户的客户端证明。没错,这就是它被称为授权码类型的原因。现在客户端将使用该授权码调用授权服务器以获取令牌

PS:在第一步中,交互发生在用户和授权服务器之间。而在这个步骤中,交互是在客户端和授权服务器之间进行的

你可能会好奇,为什么流程需要对授权服务器进行两次调用并且得到两个不同的令牌——授权码和访问令牌。花点时间理解这一点:

  • 授权服务器生成第一个授权码作为用户直接与之交互的证明。客户端接收到此授权码,并且必须再次使用该授权码及其凭据进行身份验证,以获得访问令牌。
  • 客户端使用第二个令牌访问资源服务器上的资源

那么授权服务器为什么不直接返回访问令牌呢?OAuth2定义了一个被称为隐式授权类型的流程,授权服务器会在其中直接返回访问令牌。该隐式授权类型后面不会去进行讲解,因为不建议使用,而且如今大多数授权服务器都不允许使用。授权服务器将使用访问令牌直接调用重定向URI,而不会确保接收该令牌的确实是正确的客户端,这一简单事实会降低流程的安全性通过首先发送授权码,客户端必须再次使用其凭据来证明其身份,以便获得令牌。客户端会进行最后一次调用以获取访问令牌并在其中发送:

  • 授权码,这会证明用户对客户端进行了授权
  • 客户端的凭据,这将证明它们确实是同一个客户端,而不是其他人截获了授权码

从技术上讲,客户端现在会向授权服务器发出请求。该请求包含以下详细信息:

  • code,这是步骤1中接收到的授权码。这将证明用户经过了身份验证。
  • client_id和client_secret,它们是客户端的凭据。
  • redirect_uri,它与步骤1中用于验证的重定向URI相同。
  • 具有authorization_code值得grant_type,它会标识所使用的的流程的类型。服务器可能支持多个流程,因此必须始终指定当前要执行哪个身份验证流程。

作为响应,服务器会返回一个access_token这个令牌是一个客户端可用来调用由资源服务器暴露的资源的值

步骤3:使用授权码授权类型调用受保护资源

在成功地从授权服务器获得访问令牌之后,客户端现在就可以调用受保护的资源了。在调用资源服务器的端点时,客户端要在授权请求头中使用访问令牌。

我将接口所经历的步骤总结成下图:

客户端从进行第三方认证操作的起点,默认格式为{baseUrl}/oauth2/authorization/{clientRegistrationId},其中clientRegistrationId代表着一个第三方标识,可以是微信、支付宝等开放平台,这里为gitee。用户点击了这个请求后就开始了授权之旅。

也就有了客户端向客户重定向的第②步,然后第⑤步就是向授权服务器(gitee)去获取授权码,然后用户通过认证同意授权后,授权服务器将客户重定向到通过配置的发回授权码的redirectUri发回授权码,
用户此时通过这个Url将授权码发给了客户端,客户端通过该授权码通过⑩请求获取accessToken
那么更详细的,也是我参考过觉得不错的文章有下面2篇,大家可以去看一下:

https://blog.csdn.net/longlivechina008/article/details/125007457
https://www.cnblogs.com/felordcn/p/13952072.html

其中上图中Client客户端中的OAuthFilter相当于之前SpringSecurity流程中的AuthenticationManager->AuthenticationProvider->UserDetailsService层层返回UserDetails到Filter,然后Filter将UserDetails打包为Authentication然后放到SecurityContext安全上下文中供后续使用,然后结束filterChain到达。

授权码是使用频率非常高的一种授权类型,大家需要好好吸收,尤其是概念,后面我们还会通过授权码授权类型的一个实例-sso单点登录去让大家印象更加深刻。

授权码授权类型的最大优点是让用户可以允许客户端执行特定的操作,而不需要与客户端共享其凭据,不过,这种授权类型有一个缺点:如果有人截获授权码,会发生什么?当然,如之前所属,客户端需要使用其凭据进行身份验证。但是,如果客户端凭据也以某种方式被盗了呢?即使这种情况非常罕见,但是我们也可以认为它是这种授权类型的漏洞。要避免这个漏洞,就需要借助PKCE授权码授权类型所提供的更复杂的场景,大家有兴趣可以自行了解。

密码模式、客户端模式、刷新令牌

本篇将讨论剩余的授权类型以及使用刷新令牌重新获得令牌等内容,仍然以概念为主。下一节我们将通过一个SSO实例让大家对授权码授权类型更加熟悉。

实现密码授权类型

此授权类型也被称为资源所有者凭据授权类型。使用此流程的应用程序会假定客户端收集用户凭据,并使用这些凭据进行身份验证,然后从授权服务器获得访问令牌。

如上图,密码授权类型会假设用户与客户端共享其凭据。客户端使用这些凭据从授权服务器获取令牌。然后,它会以用户名义从资源服务器访问资源。

不知道你还记不记得之前动手过的实践,只是我们没有实现JWT而已,但是思想架构和这个非常相似。当然在后面的学习中,我们会搭建一个真正的OAuth2的密码授权类型架构。

对于密码授权类型,我们应该期望应用程序向用户提供一个登陆表单,并让客户端负责向服务器发送用户凭据以进行身份验证。用户不需要知道我们如何在应用程序中设计身份验证职责。让我们看看使用密码授权类型时会发生什么。涉及的两项任务如下:
(1)请求一个访问令牌
(2)使用该访问令牌调用资源

步骤1:使用密码授权类型时请求访问令牌

使用密码授权类型,流程会简单很多。客户端会收集用户凭据并调用授权服务器来获取访问令牌。当请求访问令牌时,客户端还会在请求中发送以下详细信息:

  • 具有password值的grant_type。
  • client_id和client_secret,它们是客户端用于对其自身进行身份验证的凭据。
  • scope:可以将其理解为已授权权限。
  • username和password,它们是用户凭据。会以纯文本的形式将其作为请求头的值来发送。

客户端在响应中接收回一个访问令牌。接下来客户端就可以使用该访问令牌调用资源服务器的端点。

步骤2:使用密码授权类型时,需要使用访问令牌调用资源

一旦客户端有了访问令牌,它就可以使用该令牌调用资源服务器上的端点,这与授权码授权类型完全相同。客户端要在授权请求头中将访问令牌添加到请求。

ps密码授权类型比授权码授权类型更不安全,主要是因为其前提是要与客户端应用程序共享用户凭据。虽然它确实比授权码授权类型更简单,但是我们仍需尽量不把它放到实际开发中,而是将它放在理论示例当中去。

实现客户端凭据授权类型

这是OAuth2所描述的最简单的授权类型。可以在用户不参与的情况下使用它:也就是说,在两个应用程序之间实现身份验证时不需要用户参与。可以将客户端凭据授权类型看作密码授权类型和API密钥身份验证流程的组合。假设有一个使用OAuth2实现身份验证的系统。现在需要允许外部服务器进行身份验证并调用服务器暴露的特定资源。

客户端凭据授权类型的步骤与密码授权类型类似。唯一的例外是对访问令牌的请求不需要任何用户凭据。以下是实现这一授权类型的步骤:

步骤1:使用客户端凭据授权类型请求访问令牌

为了获得访问令牌,客户端要向授权服务器发送一个包含以下详细信息的请求。

  • 具有client_credentials值的grant_type;
  • client_id和client_secret,它们代表了客户端凭据;
  • scope,它表示已授权的权限。

作为响应,客户端将接收到一个访问令牌。接下来客户端可以使用该访问令牌调用资源服务器的端点。

步骤2:使用客户端凭据授权类型时,可访问令牌调用资源

一旦客户端有了访问令牌,它就可以使用该令牌调用资源服务器上的端点,这与授权码授权类型和密码授权类型完全相同。客户端要在授权请求头中将访问令牌添加到请求。

使用刷新令牌获得新的访问令牌

本节将讨论刷新令牌。到目前为止,本节已经介绍了OAuth2流程(也称为授权)的结果是一个访问令牌。但其中并没有过多谈论这个令牌本身。归根结底,OAuth2并没有为令牌预设一个特定的实现。接下来将会介绍,无论如何实现,令牌都可鞥过期。这并不是强制的——可以创建具有无限生命周期的令牌——但是,一般来说,都应该使其生命周期尽可能短。而本节将讨论的刷新令牌代表了使用凭据获取新访问令牌的另一种选择。这里将展示在OAuth2中刷新令牌是如何工作的,后面我们再讲具体实现。

假设在应用程序中实现了永不过期的令牌。这意味着客户端可以一次又一次地使用相同的令牌调用资源服务器上的资源。那么如果令牌被偷了,该怎么办?最后,不要忘记令牌作为一个简单的HTTP头信息被附加在每个请求上。如果令牌没有过期,得到令牌的人就可以使用它访问资源。不会过期的令牌太强大了。它变得几乎和用户凭据一样强大应该避免这种情况,并缩短令牌的生命周期。这样,过期的令牌就不能再使用了。客户端必须获得另一个访问令牌

要获得新的访问令牌,客户端可以根据所使用的的授权类型重新运行流程。例如,如果授权类型是身份验证授权码,则客户端会将用户重定向到授权服务器登录端点,用户必须再次填写他们的用户名和密码。这对用户不太友好,是吗?假设这个令牌的生命周期只有20分钟,而用户在这个在线应用程序上工作了两个小时。那么在这段时间里,这个应用程序会将用户重定向回去6次,以便再次登录为了避免重新进行身份验证授权服务器可以发出刷新令牌,它的值和用途与访问令牌不同。应用程序使用刷新令牌获得一个新的访问令牌,而不必重新进行身份验证

如何使用刷新令牌,从哪里获得刷新令牌,当使用授权码或密码授权类型等流程时,授权服务器将返回一个刷新令牌和一个访问令牌。对于客户端凭据授权,则不存在刷新令牌,因为此流程不需要用户凭据。一旦客户端有了一个刷新令牌,那么当访问令牌过期时,客户端应该发出一个包含以下详细信息的请求:

  • 具有refresh_token值得grant_type;
  • 具有刷新令牌值的refresh_token;
  • 具有客户端凭据的client_id和client_secret;
  • scope,它定义了相同或更少的授权权限。如果需要授权更多已授权的权限,则需要进行重新身份验证。

为了响应此请求,授权服务器会发出一个新的访问令牌和一个新的刷新令牌。

五、OAuth2的弱点
OAuth2并不是无懈可击的。它有其弱点,我们必须意识到这些弱点,并且在构建应用程序时必须考虑这些弱点,这里列举一些最常见的:

  • 在客户端上使用跨站请求伪造(CSRF)——用户已登录时,如果应用程序没有应用任何CSRF防护机制,则可能会遇到CSRF
  • 窃取客户端凭据——存储或传输未受保护的凭据会造成损失,使攻击者得以窃取和使用他们。
  • 重放令牌——这个后面会介绍,令牌时OAuth2身份验证和授权架构中用来访问资源的密钥。这些信息是通过网络发送的,但有时候,它们可能会被拦截。如果被截获,它们就被窃取了,并且可能再次使用。想象一下,如果你把你家大门钥匙弄丢了,会发生什么呢?其他人可以用它打开门,想打开多少次都可以(也就是所谓的令牌重放)
  • 令牌劫持——意味着有人入侵身份验证过程并窃取可以用来访问资源的令牌。这也是使用刷新令牌的一个潜在漏洞,因为这些令牌也可以被拦截并用于获取新的访问令牌.这里推荐一片有用的文章

http://blog.intothesymmetry.com/2015/06/on-oauth-token-hijacks-for-fun-and.html

实现一个简单的sso登录

本章实现第一个使用带有Spring Boot和Spring Security 的OAuth2框架的应用程序。这个示例将展示如何将OAuth2应用到Spring Security中,并阐释你需要了解的一些接口的内容。顾名思义,单点登录(SSO)应用程序是通过授权服务器进行身份验证的应用程序,然后将使用刷新令牌让用户保持登陆状态。在我们的示例中,它只代表来自OAuth2架构的客户端。

在这个应用程序中,我们要使用Gitee作为授权和资源服务器,并重点关注使用授权码授权类型的组件之间的通信.在后面的学习中我们将在OAuth2架构中实现一个授权服务器和一个资源服务器。

项目前准备

大家如果是从之前跟过来的,我这里推荐大家重新构建一个OAuth2项目取名spring_security_oauth2_sso,然后将之前写的短信认证的那个项目,除了security包的内容不copy,其他都copy下来就好,然后对报错的地方做一下相关适配(例如passwordEncoder,这个自己新建一个security包,将以前的security对应内容放进去就好),这里就不重新展现了。

管理授权服务器

本节将配置授权服务器。本章不会实现我们自己的授权服务器,而是使用一个现有的:Gitee。

如何使用Gitee这样的第三方作为授权服务器呢?这意味着,最终,我们的应用程序不会管理它的用户,任何人都可以使用他们的Gitee账户登录到我们的应用程序。与其他授权服务器一样,Gitee需要知道它要向哪个客户端应用程序发出令牌。因此,OAuth应用程序必须向Gitee授权服务器进行注册。为此,需要使用以下链接完成一个简短的表单。

https://gitee.com/oauth/applications/new

上图的“应用回调地址”就是我们注册的应用程序client(客户端)的接收授权码和access-token的地址其中,/login/oauth2/code必须是这样,这是由Client引入的oauth2AuthenticationFilter的内部默认路径。而后面的gitee是registrationid,与我们在自己的Client代码中配置的一致,Client的oauth2AuthenticationFilter要靠这个值去对应配置在程序中的clientid和clientSecret然后发给gitee认证服务器。且将来gitee授权服务器收到授权请求后,会将配置的这个应用回调地址与请求参数中的redirect_uri匹配,正确才回传授权码以及access_token。否则会报无效的回调地址(别问我为什么知道,问就是鸽那么多天都在尝试解决这个)

创建后会给我产生一个clientId和clientSecret,这个就是Gitee为我们提供的客户端ID和客户端密钥信息。当然我这里打上了马赛克,大家一定要自己去生成自己的凭据,另外在使用这样的凭据编写应用程序时要小心,特别是在使用Gitee存储库存储它们的时候

这个配置就是需要为授权服务器做的所有处理。现在我们有了客户端凭据,可以开始处理应用程序了。

开始实现

1准备工作

本节将开始实现一个SSO应用程序。我们首先需要将一下依赖添加到Pom文件中:
以前的项目中已加入spring-boot-starter-security和spring-boot-starter-web依赖,如果已经加了可以不用加后面两个。

1
2
3
4
5
6
7
8
9
10
11
12
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</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-security</artifactId>
</dependency>

首先需要确保某些东西的安全:一个网页。为此,要创建一个控制器类和一个表示应用程序的简单的HTML页面。如下代码展示了HomeController类,它定义了应用程序的单个端点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Controller
@Slf4j
public class HomeController {


@GetMapping("/")
public String home(OAuth2AuthenticationToken token)
{


log.info(String.valueOf(token.getPrincipal()));
return "home";
}
}

这里的OAuth2AuthenticationToken你可以类比为UsernamePasswordAuthenticationToken,同样实现Authentication接口表示身份验证请求事件,并且会保存请求访问应用程序的实体的详细信息。大家在写短信认证登录的时候,应该知道Authentication是分请求前和请求后,不知道大家是否还记得Principal这个对象,在认证前存放的是认证所需的信息,例如手机号,认证后存放的是认证后的用户详细信息,这里也是一样的,如果SSO认证通过后,我们也可以拿到资源服务器(这里仍然是gitee)给我们的用户信息,通过token.getPrincipal()获取。

而home的页面很简单

1
2
3
4
5
6
7
8
9
10
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h1>Welcome</h1>
</body>
</html>

现在才要开始真正的工作!接下来设置安全配置,以允许应用程序使用GItee登录。首先要编写一个配置类,就像我们过往所做的那样,这里扩展了WebSecurityConfigurerAdapter并重写了configure(HttpSecurity http)方法。现在有了一个不同之处:此处调用了另一个名为**oauth2Login()**方法,而不是之前介绍的httpBasic()或formLogin()。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package com.mbw.security.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.DelegatingPasswordEncoder;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder;

import java.util.HashMap;

@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {



@Override
protected void configure(HttpSecurity http) throws Exception {


http.oauth2Login();
http.authorizeRequests()
.anyRequest().authenticated();
}

@Bean
public PasswordEncoder passwordEncoder() {


HashMap<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("bcrypt", new BCryptPasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
return new DelegatingPasswordEncoder("bcrypt", encoders);
}
}

这里出现了一个新方法:oauth2Login(),但经过了之前的学习的你应该需要能反应过来其中进行了什么处理。与httpBasic()和formLogin()一样,oauth2Login()只是将一个新的身份验证过滤器添加到过滤器链中。我们之前说过SpringSecurity有一些过滤器实现,并且还可以向过滤器链添加自定义的过滤器。在本示例中,当调用oauthLogin()方法时,框架添加到过滤器链中的过滤器就是之前提到过的OAuth2LoginAuthenticationFilter.这个过滤器会拦截请求,并应用OAuth2身份验证所需的逻辑。

ps:securityFilterChain中不再有usernamePasswordAuthenticationFilter和basicAuthenticationFitler,因为你配置的本client的http.oauth2Login().那么本client的“认证凭证”就由oauth2AuthenticationFilter来提供oauth2AuthenticationToken了。

2、实现ClientRegistration

本节将讨论如何实现OAuth2客户端和授权服务器之间的连接。如果想让应用程序真正做一些事情,这是至关重要的。如果现在就启动该应用程序,那么将无法访问主页。无法访问该页面的原因是由于指定了对于任何请求,用户都需要进行身份验证,但是这里还没有提供任何身份验证方法。我们需要将gitee确立为授权服务器。为此Spring Security定义了ClientRegistration契约。

ClientRegistration接口表示OAuth2架构中的客户端。对于该客户端,需要定义其所需的所有详情,其中包括:

  • 客户端ID和密钥
  • 用于身份验证的授权类型
  • 重定向URI
  • 作用域
    你可能还记得在之前讲解授权码授权类型时,应用程序需要将所有这些详细信息用于身份验证过程中,Spring Security还提供了一种创建构建器实例的简单方法,类似于一开始构造UserDetails方法是一样的。下面代码展示了如何构建这样一个表示客户端实现的实例:

GiteeClient.java#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package com.mbw.security.client.gitee;

import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.stereotype.Component;

@Component
public class GiteeClient {



public ClientRegistration clientRegistration(){


return ClientRegistration.withRegistrationId("gitee") //起个名字,代表client,如clientId和clientSecret
.clientId("your clientId") //此处要换成你在gitee上创建应用得到的
.clientSecret("your clientSecret") //此处要换成你在gitee上创建应用得到的
.scope(new String[]{

"user_info"}) //读取用户权限,参见你gitee上创建应用时的授权勾选
.authorizationUri("https://gitee.com/oauth/authorize") //这要看gitee的api,是user认证以及client认证获取授权码的地址
.tokenUri("https://gitee.com/oauth/token") //这要看gitee的api,是client得到授权码后去换token的gitee地址
.userInfoUri("https://gitee.com/api/v5/user") //资源服务器api地址-也是client用access-token去获取用户user详情的“用户详情资源服务器地址”-这里也是gitee】】
.userNameAttributeName("id")
.clientName("gitee") //为我们的应用client起了个名字
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) //注是授权码模式
.redirectUriTemplate("{baseUrl}/{action}/oauth2/code/{registrationId}") //本应用配置的gitee发回授权码的地址
.build();
}
}

第一眼看上去要设置的东西有点多,但它只不过是设置客户端ID和密钥而已。此外,还定义了作用域(授予的权限)、客户端名称和所选择的注册ID。除了这些信息,还必须提供授权服务器的URL。

  • 授权URI:客户端将用户重定向到其进行身份验证的URI。
  • 令牌URI客户端为获取访问令牌和刷新令牌而调用的URI。
  • 用户信息URI客户端在获得访问令牌后可以调用的URI,以获得关于用户的更多详细信息

这些URI是从哪里获得的?如果授权服务器不是由我们开发的,则需要从说明文档中获取它们。以Gitee为例,可以在这里找到它们:

https://gitee.com/api/v5/oauth_doc#/

当然如果你的授权服务器提供者不是gitee,而是Github,Google,FaceBook,Okta这四个中的任意一个,那么Spring security给我们提供了CommonOAuth2Provider的类,这个类部分定义了可以用于身份验证的最常见提供程序的ClientRegistration实例,拿Github为例,你可以这样如下配置:

1
2
3
4
5
6
7
8
9
public ClientRegistration githubClient(){


return CommonOAuth2Provider.GITHUB
.getBuilder("github")
.clientId("your clientId")
.clientSecret("your clientSecret")
.build();
}

如上所示,这样更为清晰,并且我们不必手动查找和设置授权服务器的URL。当然,这只适用于公共提供程序。如果授权服务器不在公共提供程序之列,则只能完全定义ClientRegistration。

然后我们之前定义的GiteeClient已经交由Spring容器进行管理,我们可以在配置类中注入它。但是这样身份验证过滤器仍不能直接获取关于授权服务器客户端注册的详细信息,我们需要实现clientRegistrationRepository!

3、实现ClientRegistrationRepository

我们之前讲到配置了ClientRegistration还不够,需要对其进行设置,以便将其用于身份验证。为此,Spirng Security使用了类型为ClientRegistrationRepository的对象

ClientRegistrationRepository会检索ClientRegistration详细信息(客户端ID、客户端密钥、URL、作用域等)。身份验证过滤器需要将这些详细信息用于身份验证流程。

ClientRegistrationRepository接口类似于前面介绍过的UserDetailsService接口。与UserDetailsService对象通过其用户名查找UserDetails相同,ClientRegistrationRepository对象通过其注册ID查找ClientRegistration

可以实现ClientRegistrationRepository接口来告知框架在哪里找到ClientRegistration实例。Spring Security为ClientRegistrationRepository提供了一个实现,该实现会将ClientRegistration的实例存储在内存中,也就是InMemoryClientRegistrationRepository。是不是很熟悉,这与InMemoryUserDetailsManager对UserDetails实例所做的处理类似。

而且可以实现ClientRegistrationRepository也说明我们可以像之前通过mysql管理userDetails一样去管理ClientRegistration,不知道大家还记不记得之前写过的mybatisUserDetailsService.但是这里就暂时不作展示了,大家有兴趣可以自行尝试。

为了完成该应用程序实现,这里使用InMemoryClientRegistrationRepository实现定义了一个ClientRegistrationRepository,并将构建的ClientRegistration实例添加到InMemoryClientRegistrationRepository中,这是通过将其作为InMemoryClientRegistrationRepository构造函数的参数来完成的。然后将构造 ClientRegistrationRepository的方法通过oauth2Login()的customizaer设置,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package com.mbw.security.config;

import com.mbw.security.client.gitee.GiteeClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.DelegatingPasswordEncoder;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;

import java.util.HashMap;

@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {


@Autowired
private GiteeClient giteeClient;

@Override
protected void configure(HttpSecurity http) throws Exception {


http.oauth2Login(c->c.clientRegistrationRepository(clientRegistrationRepository()));
http.authorizeRequests()
.anyRequest().authenticated();
}

private ClientRegistrationRepository clientRegistrationRepository(){


return new InMemoryClientRegistrationRepository(giteeClient.clientRegistration());
}

@Bean
public PasswordEncoder passwordEncoder() {


HashMap<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("bcrypt", new BCryptPasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
return new DelegatingPasswordEncoder("bcrypt", encoders);
}
}

4、Spring boot配置的纯粹方式

Springboot旨在使用其纯粹的配置方式直接从属性文件构建ClientRegistration和ClientRegistrationRepository对象。这种方法在Spring Boot项目并不少见。对于其他对象也是如此,例如数据源配置,下面代码展示了如何在yaml文件中为此处的示例设置客户端注册:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
spring:
security:
oauth2:
client:
registration:
gitee:
client-id: your clientId
client-secret: 4your clientSecret
authorization-grant-type: authorization_code
redirect-uri: '{baseUrl}/{action}/oauth2/code/{registrationId}'
client-name: gitee
provider: gitee
scope:
- user_info
provider:
gitee:
authorization-uri: https://gitee.com/oauth/authorize
token-uri: https://gitee.com/oauth/token
user-info-uri: https://gitee.com/api/v5/user
user-name-attribute: id

这样你的配置类就可以不再需要指定ClientRegistration和ClientRegistrationRepository的任何详情,因为它们是由Spring Boot根据属性文件自动创建的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package com.mbw.security.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.DelegatingPasswordEncoder;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder;

import java.util.HashMap;

@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {



@Override
protected void configure(HttpSecurity http) throws Exception {


http.oauth2Login();
http.authorizeRequests()
.anyRequest().authenticated();
}

@Bean
public PasswordEncoder passwordEncoder() {


HashMap<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("bcrypt", new BCryptPasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
return new DelegatingPasswordEncoder("bcrypt", encoders);
}
}

5、测试应用程序

这个图在当时讲解授权码授权类型时给大家展示过,里面就是一个具体的流程,更加具体的步骤讲解可以参考下面的博客,讲的比较到位。

https://blog.csdn.net/longlivechina008/article/details/125007457

我们下面演示一下流程:
启动程序:
输入localhost:9090时,由于所有路径都需要认证,所以被过滤器拦截,然后重定向到gitee登录页面

输入自己的gitee的账号密码后,客户端将我们重定向到gitee的授权页面,这个就是上图中的⑤-⑧步

点击同意授权后,当Gitee认证服务器认证了user,同时也获得了clientid和用户授权的权限后,发回重定向指令到浏览器,带着【授权码】–实际上是 HTTP 302响应

接着浏览器被重定向,向Client发出请求,带着授权码GET请求

http://localhost:9090/login/oauth2/code/gitee?code=3d459a2fgttbb60c168e9df64175ab3739085b28d0a12071efd&state=-AVIglbqTn6a0GjcoQWJQE0efOtbDI1L1fxYxnlMp1k%3D

【千万注意】这个地址http://localhost:9090/login/oauth2/code/gitee
就是我们代码中配置的

1
.redirectUriTemplate("{baseUrl}/{action}/oauth2/code/{registrationId}")

【注意】redirectUriTemplate其中:

1、 baseUrl的值http://localhost:9090/就是我们的应用Client的根路径;
2、 {action}不是我们配置的,而是OauthAuthenticationFilter拦截请求Filter中固定的-login,不同的路径对应不同拦截功能我们没有自己写filter,采用的是默认的我们只要配置了http.oauth2Login();就会有此默认的拦截器OauthAuthenticationFilter,他的逻辑是固定的;
3、 oauth2/code也是OauthAuthenticationFilter固定的因为我们没有自己写filter;
4、 .gitee是我们为Client起的名字,OauthAuthenticationFilter拦截请求后,会根据路径上的这个名字gitee这个id取出clientidclientsecret权限scope等等;

所以,我们在Gitee上配置的客户端clientid和clientsecret一定要和Client传来的一致,且我们在gitee上配置的应用回调Url才是Gitee发出请求的依据。Gitee是根据配置的这个回调Url来进行请求的。所以,这个回调url必须和我们应用client配置一致才对。gitee对用户user认证并得到user对client的scope确认后,才会根据【gitee上的应用回调地址做出请求】。

而我们.redirectUriTemplate(“{baseUrl}/{action}/oauth2/code/{registrationId}”)这里的配置是配置应用client。当收到这样的请求,client会向gitee发出post请求。以便用得到的授权码到gitee上换取access_token,所以gitee上的用户配置的回调地址决定了Gitee向哪个地址发送认证码
而我们代码配置的.redirectUriTemplate(“{baseUrl}/{action}/oauth2/code/{registrationId}”
是这个地址的决定的Oauth2AuthenticationFilter的有效拦截功能启用两个地址必须一致才能收到Gitee来的授权码.

然后有了accessToken后,我们就可以自由的对整个程序的端点进行访问了。

如果想要移除用户已经存在的有效accessToken重新认证,可以到gitee的配置好的第三方应用处选择移除已授权用户的有效Token然后重启程序,等下次访问就有需要重新授权认证了。

实现授权服务器

本节大家如果一直从一开始看过来的话,就会巧妙发现我们将之前的实践代码全部连接起来,本节将会使用到之前的短信/验证码登录相关的逻辑代码,如果大家没有看的感兴趣可以回到14、Spring Security 速成 - 实现过滤器(下)整合短信认证先将这部分逻辑代码看一看,尝试自己完成。然后本节仍然以实践为主,大家要完全吸收前几节关于OAuth2内容再来学习本节会更好点,然后当时再讲解搭建单点应用程序时,我们讲到过ClientRegistrationRepository这个类,讲到可以通过数据库去管理客户端,但是当时我们没有做相关展示,本次将会通过mysql结合mybatis完成对客户端的管理操作。

我们知道,授权服务器是在OAuth2架构中发挥作用的组件之一。授权服务器的职责是对用户进行身份验证,并向客户端提供令牌。客户端可以使用此令牌访问由资源服务器代表用户暴露的资源其中还介绍了,OAuth2框架定义了获取令牌的多个流程,我们称这些流程为授权。可以根据面临的场景选择一种不同的授权。所选择的授权的不同,授权服务器的行为也会有所不同。本章将介绍如何使用Spring Security为最常见的OAuth2授权类型配置授权服务器。

  • 授权码授权类型
  • 密码授权类型
  • 客户端凭据授权类型

并且还会讲解如何配置授权服务器以颁发刷新令牌。客户端要使用刷新令牌获得新的访问令牌。如果访问令牌过期,则客户端必须获得一个新的访问令牌。

编写我们自己的授权服务器实现

没有授权服务器就没有OAuth2流程。如之前所说,OAuth2的主要处理就是获取访问令牌。授权服务器是OAuth2架构的组件,它可以颁发访问令牌。所以我们首先需要知道如何实现它。

我们接着之前的项目基础上进行改造,首先在父项目的pom上加入spring-cloud-dependencies的组件依赖:

1
2
3
4
5
6
7
<dependency> 
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Hoxton.SR1</version>
<type>pom</type>
<scope>import</scope>
</dependency>

当然我们引入cloud组件主要是为了用spring-cloud-starter-oauth2这个依赖,所以我们接下来在子项目的Pom加入该依赖:

1
2
3
4
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>

接下来可以定义一个配置类,这里将其称为AuthServerConfig.除了经典的@Configuration注解外,还需要使用@EnableAuthorizationServer对这个类进行注解。通过这种方式,就可以指示Spring Boot启用特定于OAuth2授权服务器的配置。可以通过配置AuthorizationServerConfigurerAdapter类和重写将在本章中讨论的特定方法来自定义这个配置:

1
2
3
4
5
6
7
8
9
10
11
12
package com.mbw.security.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;

@Configuration
@EnableAuthorizationServer
public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {


}

定义用户管理

本节将讨论用户管理。授权服务器是OAuth2框架中处理用户身份验证的组件。因此,它自然需要管理用户。幸运的是,这里的用户管理实现和之前讲解的UserDetails一家子是相同的,我们将继续使用它们来管理凭据。为了管理密码,我们要继续使用PasswordEncoder。

上图再次描述了Spring Security中执行身份验证过程的主要组件。你应该注意到,与之前描述的身份验证架构不同的是,图中不再有SecurityContext。之所以发生此更改,是因为身份验证的结果没有存储在SecurityContext中。取而代之的是,其中使用了来自TokenStore的令牌来管理身份验证。这个我们后面说资源服务器会细说。

那关于用户管理的配置不需要我多说,大家直接把当时短信认证登录写的用户的配置完全搬过来即可,顺便可以把之前写的短信认证,验证码登录相关的逻辑代码全部照搬过来,将前文写的ProjectConfig进行相关修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
package com.mbw.security.config;

import com.mbw.security.filter.CaptchaCodeFilter;
import com.mbw.security.handler.CommonLoginFailureHandler;
import com.mbw.security.handler.CommonLoginSuccessHandler;
import com.mbw.security.handler.MyLogoutSuccessHandler;
import com.mbw.security.service.UserDetailsServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import javax.annotation.Resource;

@Configuration
@EnableWebSecurity
public class ProjectConfig extends WebSecurityConfigurerAdapter {


@Autowired
private UserDetailsServiceImpl commonUserDetailServiceImpl;
@Autowired
private CommonLoginSuccessHandler successHandler;
@Autowired
private CommonLoginFailureHandler failureHandler;
@Autowired
private MyLogoutSuccessHandler logoutSuccessHandler;
@Autowired
private CaptchaCodeFilter captchaCodeFilter;
@Resource
private SmsCodeSecurityConfig smsCodeSecurityConfig;

@Override
protected void configure(HttpSecurity http) throws Exception {


http.cors().and()
// .addFilterBefore(captchaCodeFilter, UsernamePasswordAuthenticationFilter.class)
.logout()
.logoutSuccessHandler(logoutSuccessHandler)
.and()
.rememberMe()
//默认都为remember-me
.rememberMeParameter("remeber-me")
//cookieName一般设置复杂一些,迷惑别人(不容易看出)
.rememberMeCookieName("remeber-me")
//过期时间
.tokenValiditySeconds(24 * 60 * 60 * 2)
.and()
.csrf().disable()
.formLogin()
.loginPage("/toLogin") //用户没有权限就跳转到这个页面
.loginProcessingUrl("/login")//登录跳转页面,表单中的action
.usernameParameter("uname")
.passwordParameter("upassword")//传递的属性
.successHandler(successHandler)
.failureHandler(failureHandler)
.and()
.apply(smsCodeSecurityConfig)
.and().httpBasic().and()
.authorizeRequests()
.antMatchers("/home","/toLogin","/login", "/user/create", "/kaptcha", "/smsCode", "/smslogin").permitAll()
.mvcMatchers("/test/a/*").permitAll()
.anyRequest().authenticated()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.sessionFixation().migrateSession()
.maximumSessions(1).
maxSessionsPreventsLogin(false)
.expiredSessionStrategy(new CustomExpiredSessionStrategy());
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {


auth.userDetailsService(commonUserDetailServiceImpl)
.passwordEncoder(passwordEncoder());
}

@Override
public void configure(WebSecurity web) {


//将项目中的静态资源路径开放出来
//为什么要抽出来?上面的规则都需要通过过滤器校验,这个不需要
web.ignoring().antMatchers("/css/**", "/fonts/**", "/js/**", "/templates/**", "/static/**");
}

@Bean
public PasswordEncoder passwordEncoder() {


return new BCryptPasswordEncoder();
}

//跨域配置
@Bean
CorsConfigurationSource corsConfigurationSource() {


CorsConfiguration configuration = new CorsConfiguration();
configuration.addAllowedOrigin("*");
configuration.addAllowedMethod("*");
configuration.addAllowedHeader("*");
configuration.applyPermitDefaultValues();
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}

那么现在已经有了用户,接下来只需将用户管理关联到授权服务器配置即可。为此,需要在Spring上下文中将AuthenticationManager暴露一个bean,然后在AuthServerConfig类中使用它。所以我们紧接着上面的ProjectConfig配置我们的AuthenticationManager。

1
2
3
4
5
6
7
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {


return super.authenticationManagerBean();
}

接下来更改AuthServerConfig类,以便向授权服务器去注册AuthenticationManager.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package com.mbw.security.config;

import com.mbw.security.service.UserDetailsServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;

@Configuration
@EnableAuthorizationServer
public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {


@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private UserDetailsServiceImpl userDetailsService;

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {


endpoints.authenticationManager(authenticationManager)
.userDetailsService(userDetailsService);
}
}

有了这些配置,就有了可以在身份验证服务器上进行身份验证的用户。但OAuth2架构意味着用户向客户端授予权利。客户端则以用户名义使用资源。所以接下来讲解如何配置客户端。

向授权服务器注册客户端

本节将介绍如何让授权服务器知晓客户端。要调用授权服务器,在OAuth2架构中充当客户端的应用程序需要自己的凭据授权服务器还要管理这些凭据,并且只允许来自已知客户端的请求

还记得之前开发过的客户端应用程序吗?就那个GiteeClient,其中使用Gitee作为身份验证服务器。Gitee需要知悉该客户端应用程序,所以我们做的第一件事就是在Gitee上注册这个应用程序然后,我们会接收到客户端ID和客户端密钥:客户端凭据。接着我们配置了这些凭据,该客户端应用程序使用它们通过授权服务器(Gitee)进行身份验证。同样的情况也适用于本示例。授权服务器需要知悉其客户端,因为它要接收来自客户端的请求。这里的处理过程你应该很熟悉。为授权服务器定义客户端的契约是ClientDetails.定义对象以便根据其ID检索ClientDetails的契约是ClientDetailsService

这些名称听着熟悉吗?没错,这些接口的工作方式类似于UserDetails和UserDetailsService接口,只不过一个是用户,一个是客户端。你会发现,他俩很多都是类似的。例如InMemoryClientDetailsService是ClientDetailsService接口的实现,该接口管理内存中的ClientDetails.它的工作方式类似于用于UserDetails的InMemoryUserDetailsManager类。同样,JdbcClientDetailsService也类似于JdbcUserDetailsManager。

可以把这些相似之处总结为很容易记住的以下几点:

  • ClientDetails之于客户端就像UserDetails之于用户一样。
  • ClientDetailsService之于客户端就像UserDetailsService之于用户一样。
  • InMemoryClientDetailsService之于客户端就像InMemoryUserDetailsManager之于用户一样
  • JdbcClientDetailsService之于客户端就像JdbcUserDetailsManager之于用户一样

理解这一层关系后,我们就可以使用之前类似于配置UserDetails的方法同样用于ClientDetails上
首先我们需要一张存储客户端信息的表,表的结构和数据如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/*
Navicat Premium Data Transfer

Source Server : local_mysql
Source Server Type : MySQL
Source Server Version : 50727
Source Host : localhost:3306
Source Schema : spring_security

Target Server Type : MySQL
Target Server Version : 50727
File Encoding : 65001

Date: 28/11/2022 12:00:29
*/

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

--

--------
-- Table structure for oauth_client_details
--

--------
DROP TABLE IF EXISTS oauth_client_details;
CREATE TABLE oauth_client_details (
id bigint(20) NOT NULL COMMENT 'id',
clientId varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '客户端ID',
resourceIds varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '资源ID集合,多个资源时用英文逗号分隔',
clientSecret varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '客户端密匙',
scope varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '客户端申请的权限范围',
authorizedGrantTypes varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '客户端支持的grant_type',
webServerRedirectUri varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '重定向URI',
authorities varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '客户端所拥有的SpringSecurity的权限值,多个用英文逗号分隔',
accessTokenValidity int(11) NULL DEFAULT NULL COMMENT '访问令牌有效时间值(单位秒)',
refreshTokenValidity int(11) NULL DEFAULT NULL COMMENT '更新令牌有效时间值(单位秒)',
additionalInformation varchar(500) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '预留字段',
autoApprove tinyint(1) NULL DEFAULT NULL COMMENT '用户是否自动Approval操作',
PRIMARY KEY (id) USING BTREE,
UNIQUE INDEX UNIQUE_CLIENT_ID(clientId) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '客户端信息' ROW_FORMAT = Dynamic;

--

--------
-- Records of oauth_client_details
--

--------
INSERT INTO oauth_client_details VALUES (1596077605292945410, 'f7n6ockwdb9zmayr', NULL, '$2a$10$b87rn4FF4TeR7r6VB45rC.kTv5M36Qs2U62WheA4mnEB5MmA3mVAW', 'user_info', 'password,authorization_code,refresh_token', 'http://localhost:9090/yidou', 'add,delete', 86400, 432000, NULL, 0);

SET FOREIGN_KEY_CHECKS = 1;

然后同样的我们需要建立一个客户端的实体类,同样的,这个实体类和真正用于OAuth的客户端是分离开来的,类似于我们之前的User类和JwtUserDto类的关系
OAuth2Client.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package com.mbw.pojo;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;

@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName("oauth_client_details")
public class OAuth2Client implements Serializable {
@TableId(type = IdType.ASSIGN_ID, value = "id")
private Long id;
@TableField("clientId")
private String clientId;
@TableField(value = "resourceIds")
private String resourceIds;
@TableField(value = "authorities")
private String authorities;
@TableField("clientSecret")
private String clientSecret;
@TableField(value = "scope")
private String scope;
@TableField(value = "authorizedGrantTypes")
private String authorizedGrantTypes;
@TableField("webServerRedirectUri")
private String webServerRedirectUri;
@TableField("accessTokenValidity")
private Integer accessTokenValidity;
@TableField("refreshTokenValidity")
private Integer refreshTokenValidity;
@TableField("additionalInformation")
private String additionalInformation;
@TableField("autoApprove")
private Boolean autoApprove;
}

同样的dao-service-controller我们也需要建立,并且提供2个方法,通过clientId查找client(这个也是ClientDetailsService需要重写的方法的原理),然后还有注册client的方法

OAuth2ClientMapper.java

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.mbw.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mbw.pojo.OAuth2Client;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface OAuth2ClientMapper extends BaseMapper<OAuth2Client> {


OAuth2Client queryByClientId(String clientId);
OAuth2Client queryClientUnique(String clientId);
}

OAuth2ClientMapper.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.mbw.mapper.OAuth2ClientMapper">
<select id="queryByClientId" resultType="com.mbw.pojo.OAuth2Client">
select * from oauth_client_details
where clientId ={clientId}
</select>
<select id="queryClientUnique" resultType="com.mbw.pojo.OAuth2Client">
select ocd.id,ocd.clientId from oauth_client_details ocd
where ocd.clientId ={clientId} limit 1
</select>
</mapper>

OAuth2ClientService.java
这里主要是需要解析一下clientSecret,我在这里当时出现过Bad Crendential相关的异常。

由于OAuth2解析客户端秘钥也会通过我们配置的passwordEncoder去解密,而我们在projectConfig已经对PasswordEncoder配置为BCryptPasswordEncoder,那么如果在注册客户端不通过passwordEncoder对clientSecret进行加密,在解析的时候就会出现解析错误。所以这里我们需要注入passwordEncoder.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
package com.mbw.service;

import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.RandomUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.mbw.mapper.OAuth2ClientMapper;
import com.mbw.pojo.OAuth2Client;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class OAuth2ClientService extends ServiceImpl<OAuth2ClientMapper, OAuth2Client> {


@Autowired
private OAuth2ClientMapper oAuth2ClientMapper;
@Autowired
@Lazy
private PasswordEncoder passwordEncoder;

public OAuth2Client queryClientByClientId(String clientId){


return oAuth2ClientMapper.queryByClientId(clientId);
}

public String getClientIdUnique(OAuth2Client oAuth2Client){


Long id = ObjectUtil.isEmpty(oAuth2Client.getId()) ? -1: oAuth2Client.getId();
String clientId = RandomUtil.randomString(16);
OAuth2Client oAuthClient = oAuth2ClientMapper.queryClientUnique(clientId);
if(ObjectUtil.isNotEmpty(oAuthClient) && !oAuthClient.getId().equals(id)){


//说明有重复,重新生成一次
return getClientIdUnique(oAuth2Client);
}else {


return clientId;
}
}

public OAuth2Client createOAuth2Client(OAuth2Client oAuth2Client){


String clientId = getClientIdUnique(oAuth2Client);
String clientSecret = RandomUtil.randomString(16);
String clientSecretEncoded = passwordEncoder.encode(clientSecret);
oAuth2Client.setClientId(clientId);
//对clientSecret进行加密
oAuth2Client.setClientSecret(clientSecretEncoded);
oAuth2ClientMapper.insert(oAuth2Client);
oAuth2Client.setClientSecret(clientSecret);
return oAuth2Client;
}
}

但是对于加入@Lazy的原因,如果直接注入passwordEncoder,看似没什么问题,但启动后会报循环依赖的问题。原因是我们的ProjectConfig在引用两个类,SmsUserDetailsServiceImpl和UserDetailsServiceImpl分别代表短信认证和用户认证的两个Service,而这两个Service都依赖了UserService,UserService又依赖了PasswordEncoder,具体关系如下图:

但是我这里有一个疑问,那就是为什么我们在没写OAuth2相关业务时,其实也存在这样一个循环依赖关系,为什么在当时不会报错,而加入OAuth2Service相关逻辑,就报了相关的错误呢,如果有知道的小伙伴可以在评论群帮我解决这个疑惑,非常感谢。

接着上面代码,我们接着写OAuth2ClientController类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package com.mbw.controller;

import com.mbw.common.utils.Res;
import com.mbw.pojo.OAuth2Client;
import com.mbw.service.OAuth2ClientService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/oauth2/client")
public class OAuth2ClientController {


@Autowired
private OAuth2ClientService oAuth2ClientService;

@PostMapping("query/clientId")
public Res queryOauthClientByClientId(String clientId){


OAuth2Client oAuth2Client = oAuth2ClientService.queryClientByClientId(clientId);
return Res.success(oAuth2Client);
}

@PostMapping("create")
public Res createOAuthClient(@RequestBody OAuth2Client oAuth2Client){


OAuth2Client oAuth2ClientCreated = oAuth2ClientService.createOAuth2Client(oAuth2Client);
return Res.success(oAuth2ClientCreated);
}
}

然后别忘了在projectConfig中对创建客户端这个接口进行放行:

1
.mvcMatchers("/oauth2/client/create").permitAll()

然后对于client这个单体我们完成了,现在我们要把它和OAuth2相关的逻辑类实现
类似于我们之前实现UserDetails和UserDetailsService一样
首先我们在dto包中加入OAuth2ClientDto并实现ClientDetails
OAuth2ClientDto.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
package com.mbw.security.dto;

import cn.hutool.core.text.CharSequenceUtil;
import com.mbw.pojo.OAuth2Client;
import lombok.Data;
import lombok.ToString;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.provider.ClientDetails;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

@Data
@ToString
public class OAuth2ClientDto implements ClientDetails {



private OAuth2Client oAuth2Client;

@Override
public String getClientId() {


return oAuth2Client.getClientId();
}

@Override
public Set<String> getResourceIds() {


String resourceIds = oAuth2Client.getResourceIds();
if(CharSequenceUtil.isNotBlank(resourceIds)){


String[] resourceCollection = resourceIds.split(",");
return new HashSet<>(Arrays.asList(resourceCollection));
}
return Collections.emptySet();
}

@Override
public boolean isSecretRequired() {


return true;
}

@Override
public String getClientSecret() {


return oAuth2Client.getClientSecret();
}

@Override
public boolean isScoped() {


return true;
}

@Override
public Set<String> getScope() {


String scopes = oAuth2Client.getScope();
if(CharSequenceUtil.isNotBlank(scopes)){


String[] scopeCollection = scopes.split(",");
return new HashSet<>(Arrays.asList(scopeCollection));
}
return Collections.emptySet();
}

@Override
public Set<String> getAuthorizedGrantTypes() {


String authorizedGrantTypes = oAuth2Client.getAuthorizedGrantTypes();
if(CharSequenceUtil.isNotBlank(authorizedGrantTypes)){


String[] grantTypes = authorizedGrantTypes.split(",");
return new HashSet<>(Arrays.asList(grantTypes));
}
return Collections.emptySet();
}

@Override
public Set<String> getRegisteredRedirectUri() {


String webServerRedirectUri = oAuth2Client.getWebServerRedirectUri();
if(CharSequenceUtil.isNotBlank(webServerRedirectUri)){


HashSet<String> redirectUris = new HashSet<>();
redirectUris.add(webServerRedirectUri);
return redirectUris;
}
return Collections.emptySet();
}

@Override
public Collection<GrantedAuthority> getAuthorities() {


String authorities = oAuth2Client.getAuthorities();
List<GrantedAuthority> grantedAuthorityList = new ArrayList<>();
if(CharSequenceUtil.isNotBlank(authorities)){


String[] grantAuthorities = authorities.split(",");
for (String grantAuthority : grantAuthorities) {


SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(grantAuthority);
grantedAuthorityList.add(simpleGrantedAuthority);
}
return grantedAuthorityList;
}
return Collections.emptyList();
}

@Override
public Integer getAccessTokenValiditySeconds() {


return oAuth2Client.getAccessTokenValidity();
}

@Override
public Integer getRefreshTokenValiditySeconds() {


return oAuth2Client.getRefreshTokenValidity();
}

@Override
public boolean isAutoApprove(String s) {


return oAuth2Client.getAutoApprove();
}

@Override
public Map<String, Object> getAdditionalInformation() {


String additionalInformation = oAuth2Client.getAdditionalInformation();
if(CharSequenceUtil.isNotBlank(additionalInformation)){


HashMap<String, Object> information = new HashMap<>();
information.put("额外信息",additionalInformation);
return information;
}
return Collections.emptyMap();
}

public OAuth2ClientDto(OAuth2Client oAuth2Client) {


this.oAuth2Client = oAuth2Client;
}
}

然后实现ClientDetailsServiceImpl,让它实现ClientDetailsService类。重写loadClientByClientId(String clientId)方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package com.mbw.security.service;

import com.mbw.mapper.OAuth2ClientMapper;
import com.mbw.pojo.OAuth2Client;
import com.mbw.security.dto.OAuth2ClientDto;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.oauth2.provider.ClientDetails;
import org.springframework.security.oauth2.provider.ClientDetailsService;
import org.springframework.security.oauth2.provider.ClientRegistrationException;
import org.springframework.stereotype.Service;

@Service
public class ClientDetailsServiceImpl implements ClientDetailsService {



@Autowired
private OAuth2ClientMapper oAuth2ClientMapper;

@Override
public ClientDetails loadClientByClientId(String clientId) throws ClientRegistrationException {


OAuth2Client oAuth2Client = oAuth2ClientMapper.queryByClientId(clientId);
return new OAuth2ClientDto(oAuth2Client);
}
}

对比一下我们实现UserDetails和UserDetailsService,是不是基本一致呢。

写完后,我们就需要在授权服务器配置类当中去配置客户端了,完整配置类代码如下:
AuthServerConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
package com.mbw.security.config;

import com.mbw.security.service.ClientDetailsServiceImpl;
import com.mbw.security.service.UserDetailsServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;

@Configuration
@EnableAuthorizationServer
public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {


@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private ClientDetailsServiceImpl clientDetailsServiceImpl;
@Autowired
private UserDetailsServiceImpl userDetailsServiceImpl;

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {


endpoints.authenticationManager(authenticationManager)
.userDetailsService(userDetailsServiceImpl);
}

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {


clients.withClientDetails(clientDetailsServiceImpl);
}

/**
* 解决访问/oauth/check_token 403的问题
*
* @param security
* @throws Exception
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {


// 允许表单认证
security
.tokenKeyAccess("permitAll()")
.checkTokenAccess("permitAll()")
.allowFormAuthenticationForClients();

}

}

完成后我们可以来到postman
试试创建一个客户端:

发现创建成功,但是大家最好拿笔记本记录下这个未加密的clientSecret,因为db存储的加密后的secret。到时候我们需要该密钥去请求token。

到此,我们就完成了环境和相关骨架的搭建,下一章我们将在代码基础上完成三种授权类型的测试。

大家如果是一路跟过笔者写的文章过来的,可以发现OAuth2并不是独立的,相反,它将我们之前的学习内容通过这一章又紧密连接了起来,所以大家对于之前的内容不能忘记,必须加以巩固。

如果大家需要代码的话,可以在评论区回复我,我看到时候需不需要把代码放gitee上供大家自由克隆下来学习使用。

使用密码授权类型

本节将使用带有OAuth2密码授权的授权服务器,来测试它是否有效。

首先我们在postman通过之前写的创建客户端的接口创建一个客户端,其中authorizedGrantTypes这个参数,我们填上”password,authorization_code,refresh_token”分别代表密码授权模式,授权码授权模式以及支持刷新token。

创建成功后,我们的客户端就可以使用密码授权类型了。

怎么测试密码授权类型呢,我们可以通过请求/oauth/token端点请求令牌,这个接口是Spring Security自动为我们配置的,我们直接请求就可以,使用密码授权类型需要带上以下参数:

  • grant_type,授权类型,这里填写password即可
  • username:用户名
  • password:用户密码
  • scope:它们是所授予的权限
  • clientId:客户端Id
  • clientSecret:客户端密钥,注意这里的客户端密钥是加密前的密钥,不要直接把db存储的搬上来。

密码授权类型流程图如下:总计就是客户端直接使用资源所有者的凭据进行身份验证并获得访问令牌。

大家注意到除了访问令牌,我们还得到了一个刷新令牌,这个就是因为我们在创建客户端那会儿对
authorizedGrantTypes这个参数配置了refresh_token这个模式。
那么仔细观察上述响应中的令牌。使用Spring Security中的默认配置,令牌其实就是一个简单的UUID。接下来客户端可以使用这个令牌调用资源服务器暴露的资源,后面我们将会学习如何搭建资源服务器。

使用授权码授权类型

在之前讲解授权码授权类型的时候,我曾介绍给这是最常用的OAuth2授权类型之一。理解如何配置授权服务器以便使用这种授权类型非常重要,因为我们很可能会在实际的系统中面对这种需求。下图我们可以回顾一下授权码授权类型是如何工作的:

在授权码授权类型中,客户端会将用户重定向到授权服务器进行身份验证。用户直接与授权服务器交互,通过身份验证后,授权服务器会向客户端返回一个重定向URI。在回调客户端时,它还会提供一个授权码。客户端要使用该授权码获取访问令牌。

而我们刚刚注册的客户端的authorizedGrantTypes已经有了authorization_code这个模式,所以可以直接通过该客户端进行测试
启动程序后在浏览器中访问链接,如下面的代码片段所示。

1
http://localhost:9090/oauth/authorize?response_type=code&client_id=f7n6ockwdb9zmayr

然后授权服务器会将用户重定向到登陆页面,具体的登录页面就是我们在ProjectConfig中配置的loginPage

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@Override
protected void configure(HttpSecurity http) throws Exception {


http.cors().and()
// .addFilterBefore(captchaCodeFilter, UsernamePasswordAuthenticationFilter.class)
.logout()
.logoutSuccessHandler(logoutSuccessHandler)
.and()
.rememberMe()
//默认都为remember-me
.rememberMeParameter("remeber-me")
//cookieName一般设置复杂一些,迷惑别人(不容易看出)
.rememberMeCookieName("remeber-me")
//过期时间
.tokenValiditySeconds(24 * 60 * 60 * 2)
.and()
.csrf().disable()
.formLogin()
.loginPage("/toLogin") //用户没有权限就跳转到这个页面
.loginProcessingUrl("/login")//登录跳转页面,表单中的action
.usernameParameter("uname")
.passwordParameter("upassword")//传递的属性
.successHandler(successHandler)
.failureHandler(failureHandler)
.and()
.apply(smsCodeSecurityConfig)
.and().httpBasic().and()
.authorizeRequests()
.antMatchers("/home","/toLogin","/login", "/user/create", "/kaptcha", "/smsCode", "/smslogin").permitAll()
.mvcMatchers("/test/a/*","/oauth2/client/create").permitAll()
.anyRequest().authenticated()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.sessionFixation().migrateSession()
.maximumSessions(1).
maxSessionsPreventsLogin(false)
.expiredSessionStrategy(new CustomExpiredSessionStrategy());
}

登陆后,授权服务器会明确要求用户提供授予或拒绝所请求的作用域。如下图所示

一旦对作用域进行了授权,授权服务器就会将用户重定向到重定向URI并提供一个访问令牌。以下代码片段显示了授权服务器将用户重定向到的URL。通过请求中查询参数观察客户端获得的访问码:

之后我们在postman通过授权码调用/oauth/token就可以获取访问令牌了:

需要注意的是,授权码只能使用一次。如果尝试再次使用相同的授权码调用/.oauth/token端点,则会收到以下代码片段中所显示的类似错误。只能通过请求用户再次登录来获得另一个有效的授权码:

使用客户端凭据授权类型

本节将讨论如何实现客户端凭据授权类型,在保护与特定用户无关且客户端需要访问的端点时,可以使用客户端凭据授权类型。假设打算实现一个返回服务器状态的端点,客户端可以调用此端点检查连接性,并最终向用户展示连接状态或错误信息。由于此端点仅仅表示客户端和资源服务器的交互,并且不涉及任何特定于用户的资源,因此客户端应该能够调用它而不需要用户进行身份验证。对于这样的场景,可以使用客户端凭据授权类型。

那么我们新建一个客户端,并且让它的授权类型给它设置上客户端凭据授权类型:

然后可以直接启动程序,调用/oauth/token端点来获得访问令牌:

注意:要谨慎使用客户端凭据授权类型。这种授权类型只要求客户端用其凭据。应该确保它无法访问与需要用户凭据的流程相同的作用域,例如我将一个客户端同时设置客户端凭据授权类型和密码授权类型,这样就意味着客户端可以通过用户进行身份验证或仅使用自己的凭据来获得相同的令牌,这肯定是不合理的,甚至是一个安全漏洞都不为过,因为这样会让客户端可以访问客户的资源而不需要经过客户验证

使用刷新令牌授权类型

本节将讨论如何通过Spring Security开发的授权服务器来使用刷新令牌。当与其他授权类型一起使用时,刷新令牌提供了几个好处。可以使用带有授权码授权类型和密码授权类型的刷新令牌

如果希望授权服务器支持刷新令牌,则需要将刷新令牌授权添加到客户端的授权列表中。而我们一开始创建的客户端的authorizedGrantTypes已经包含refresh_token,所以我们使用该客户端通过密码授权类型访问/oauth/token接口,可以看到应用程序将刷新令牌添加到响应中。

实现资源服务器

本章将讨论如何使用Spring Security实现一个资源服务器,资源服务器是管理用户资源的组件。另外,学习本章有个前提,需要先把前面搭建授权服务器的相关文章先给阅读,否则可能后面出现的授权服务器相关代码不知道个所以然。就OAuth2而言,它代表了我们要保护的后端(端点),就像前几章保护的其他应用程序是一样的。为了允许客户端访问资源,资源服务器需要一个有效的访问令牌。客户端会从授权服务器获得访问令牌,并通过将该令牌添加到HTTP请求头信息来使用该令牌调用资源服务器上的资源。

还记得前两章讨论客户端和授权服务器的实现时,我们曾经提起过资源服务器更为重要的是选择资源服务器验证令牌的方式。对于在资源服务器级别实现令牌验证,我们主要有三种方式:

  • 远程检查令牌,即通过网络调用授权服务器检查token
  • 黑板模式,我们使用一个公共数据库,这个可以是Mysql也可以是redis,这个我们都会讲。redis我会专门提起一章来说。授权服务器会在其中存储令牌,然后资源服务器可以在其中访问和验证令牌。这种方法也称为黑板模式。
  • 最后第三个选项是使用加密签名授权服务器在颁发令牌时会对其进行签名,资源服务器则要验证签名。这里是我们通常使用的JWT的地方。这个也会专门提一章后面讲。

实现资源服务器

首先要实现我们的第一个资源服务器应用程序,这是OAuth2拼图的最后一块。

使用可以颁发令牌的授权服务器的原因是为了允许客户端访问用户的资源。资源服务器将管理和保护用户的资源。由于这个原因,我们需要知道如何实现资源服务器。

当资源服务器需要验证令牌时,它会直接调用授权服务器。如果授权服务器确认它颁发了该令牌,则资源服务器认为该令牌有效

要实现资源服务器,需要在和之前搭建授权服务器的同一个父项目下创建一个新项目spring_security_resource_server并添加依赖项,完整pom文件如下,关于mysql,redis,fastjson等后面会用到,大家可以提前加上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring_security_oauth2_demo</artifactId>
<groupId>org.example</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>

<artifactId>spring_security_resource_server</artifactId>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.56</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!--redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
</dependencies>
</project>

资源服务器的目的是管理和保护用户的资源。因此为了证明它是如何工作的,这里需要一个我们希望访问的资源。我们写一个控制器类代表我们需要保护的资源:
TestController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package com.mbw.controller;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/test")
public class TestController {



@GetMapping("/xiao")
public String xiao(){

return "纳西妲我抽爆!";}
@GetMapping("/giao")
public String giao(){


Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String name = authentication.getName();
return "giao,"+name;
}
@GetMapping("/a")
public String getEndpointA(){


return "a";
}
@GetMapping("/a/b")
public String getEndpointAB(){


return "ab";
}

@GetMapping("/product/{code}")
public String productCode(@PathVariable String code){


return code;
}
}

这里还需要一个配置类,在这个类中将使用@EnableResourceServer注解来允许Spring Boot为应用程序配置成为资源服务器所需的内容。而你也可以通过扩展ResourceServerConfigurerAdapter去重写资源服务器相关组件和方法,代码如下:
ResourceServerConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package com.mbw.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;

import javax.servlet.http.HttpServletResponse;
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {


@Override
public void configure(HttpSecurity http) throws Exception {


http
.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint((request, response, authException) -> response.sendError(HttpServletResponse.SC_UNAUTHORIZED))
.and()
.authorizeRequests()
.antMatchers("/test/**").authenticated()
.and()
.httpBasic();
}
}

上面的代码如果大家对spring Security熟悉的小伙伴应该很快能反应过来,几乎一模一样,其中/test/**就是我们刚才写的Controller的所有端点。

那么我们现在就有了一个资源服务器。但是,如果不能访问端点,它就没有任何用处,就像目前这个示例一样,因为还没有配置资源服务器检查令牌的任何方式。我们知道,对资源的请求也需要提供有效的访问令牌。但即使它提供了有效的访问令牌,请求仍然不能工作,这里的资源服务器还无法验证这些是否是有效的令牌,也无法验证授权服务器确实颁发了它们。这是因为还没有实现资源服务器验证访问令牌所需的任何选项,接下来我们将讨论这些方式。

远程检查令牌

本节将通过允许资源服务器直接调用授权服务器来实现令牌验证。此方法是使用有效访问令牌启用对资源服务器的访问的最简单实现,如果系统中的令牌是简单形式(例如,在Spring Security的授权服务器的默认实现中使用的简单UUID),则可以选择此方法。这种验证令牌的机制很简单:

1、 授权服务器暴露一个端点对于有效的令牌,它会返回先前向其颁发该令牌的用户所被授予的权限此处把这个端点称为check_token端点;
2、 资源服务器为每个请求调用check_token端点这样,它就会验证从客户端接收的令牌,并获得授予客户端的权限;

这种方法的优点是简单。可以将其应用于任何类型的令牌实现。这种方法的缺点是,对于资源服务器上具有新的未知令牌的每个请求,资源服务器将调用授权服务器来验证该令牌。这些调用会给授权服务器带来不必要的负荷。此外,请记住:网络并不是100%可靠的。每次在架构中设计新的远程调用时,都需要记住这一点。如果由于网络不稳定导致调用失败,则可能还需要应用一些替代解决方案。并且如果重启资源服务器,就算之前产生了令牌并且还没过期,资源服务器也验证不了这个令牌。因为这个令牌已经失效了,所以也就有了后面将令牌持久化的方案。

那么接下来讨论如何实现。此处的预期是:如果/hello端点提供了授权服务器颁发的访问令牌,则允许客户端访问该端点。

默认情况下,授权服务器会实现端点/oauth/check_token,资源服务器可以使用该端点验证令牌。但是,目前授权服务器将隐式拒绝对该端点的所有请求。在使用/oauth/check_token端点之前,需要确保资源服务器可以调用它。

为了允许经过身份验证的请求调用/oauth/check_token端点,需要重写授权服务器的AuthServerConfig类中的configure(AuthorizationServerSecurityConfigurer c)方法重写configure()方法就可以设置允许调用/oauth/check_token端点的条件。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import com.mbw.security.service.ClientDetailsServiceImpl;
import com.mbw.security.service.UserDetailsServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;

@Configuration
@EnableAuthorizationServer
public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {


@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private ClientDetailsServiceImpl clientDetailsServiceImpl;
@Autowired
private UserDetailsServiceImpl userDetailsServiceImpl;

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {


endpoints.authenticationManager(authenticationManager)
.userDetailsService(userDetailsServiceImpl)
.tokenStore(jsonRedisTokenStore);
DefaultTokenServices tokenService = getTokenStore(endpoints);
endpoints.tokenServices(tokenService);
}

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {


clients.withClientDetails(clientDetailsServiceImpl);
}

/**
* 解决访问/oauth/check_token 403的问题
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {


// 允许表单认证
security
.tokenKeyAccess("permitAll()")
.checkTokenAccess("permitAll()") //指定可以调用check_token端点的条件
.allowFormAuthenticationForClients();

}
}

你也可以使用isAuthenticated(),只是我这儿用permitAll在没有身份验证的情况下也可以访问,便于我测试,但是不建议像我这样做让端点不受保护,在真实场景下,最好对这个端点使用身份验证

除了使这个端点可访问之外,如果还决定只允许经过身份验证的访问,就需要为资源服务器本身注册一个客户端。对于授权服务器而言,资源服务器也是客户端,并且也需要它自己的凭据。需要像添加到其他客户端那样添加它,对于资源服务器,则不需要任何授权类型或作用域,只需要资源服务器用于调用check_token端点的一组凭据即可

所以我修改了下上次注册客户端的接口,让他们也可以自行输入clientId和clientSecret

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public OAuth2Client createOAuth2Client(OAuth2Client oAuth2Client){


String clientId = oAuth2Client.getClientId();
if(CharSequenceUtil.isBlank(clientId)){


clientId = getClientIdUnique(oAuth2Client);
}
String clientSecret = oAuth2Client.getClientSecret();
if(CharSequenceUtil.isBlank(clientSecret)) {


clientSecret = RandomUtil.randomString(16);
}
String clientSecretEncoded = passwordEncoder.encode(clientSecret);
oAuth2Client.setClientId(clientId);
oAuth2Client.setClientSecret(clientSecretEncoded);
oAuth2ClientMapper.insert(oAuth2Client);
oAuth2Client.setClientSecret(clientSecret);
return oAuth2Client;
}

然后到postman调用注册客户端的接口,cleintId就取名为resourceServer,clientSecret就为resourceServerSecret

并且入库,且对clientecret加密。

现在启动授权服务器并获得一个令牌,就想之前授权服务器那样,直接通过postman调用,随便你使用某一种方式都行,这里我为了方便就选择密码授权模式

接下来要调用check_token端点查找前面的代码片段中所获得的访问令牌的详细信息。这一调用是这样的:

观察从check_token端点返回的响应。其中包含关于访问令牌所需的所有详细信息:

  • 该令牌是否仍然有效并且何时过期
  • 该令牌是为哪个用户颁发的
  • 表示权利的权限
  • 该令牌是为哪个客户颁发的

现在,如果使用postman调用端点,则资源服务器应该能够用它验证令牌了。还需要配置授权服务器的端点和资源服务器用于访问端点的凭据。可以在application.yaml文件中完成所有这些配置。以下就是配置文件的代码:

1
2
3
4
5
6
7
8
9
server:
port: 9091
security:
oauth2:
resource:
token-info-uri: http://localhost:9090/oauth/check_token
client:
client-id: resourceServer
client-secret: resourceServerSecret

ps:在对/oauth/check_token(令牌自省)端点使用身份验证时,资源服务器将充当授权服务器的客户端。由于这个原因,它就需要注册一些凭据,在调用自省端点时,它将是=使用这些凭据利用HTTP Basic身份验证进行身份验证

现在可以通过调用/hello端点运行该应用程序和测试整个设置。需要在请求的Authorization头信息中设置访问令牌,并且需要在其值前面加上带有单次bearer的前缀。对于这个单词来说,其大小写是不区分的。这就意味着也可以写作“bearer”或者“BEARER”

如果在没有令牌或使用错误令牌的情况下调用了端点,那么其结果将是HTTP响应上出现401 Unauthorized状态。例如我现在不传Authorization这个请求头,下面的代码片段给出了该响应:

又或者我传了一个错误的令牌:

实现黑板模式

我们将继续之前的项目基础上进行修改。首先解释一下黑板模式的架构:当授权服务器颁发令牌时,它也会将令牌存储在与资源服务器共享的数据库中

它还意味着资源服务器在需要验证令牌时将访问该数据库.

如上图,资源服务器在共享数据库中搜索令牌。如果令牌存在,则资源服务器将在数据库中找到与它相关的详细信息,包括用户名及其权限。有了这些详细信息,资源服务器就可以对请求进行授权。

在授权服务器和资源服务器上,代表在Spring Security中管理令牌的对象的契约是TokenStore。对于授权服务器,可以把它想象成在我们以前使用SecurityContext的身份验证架构中的位置,身份验证完成后,授权服务器会使用TokenStore生成一个令牌

对于资源服务器。身份验证过滤器要使用TokenStore验证令牌并查找稍后用于授权的用户详细信息。然后资源服务器会将用户的详细信息存储在安全上下文中

ps:授权服务器和资源服务器实现了两种不同的职责,但这些职责不一定必须由两个独立的应用程序执行。不过在大多数真实的实现中,我们都会在不同的应用程序中开发它们,但是,也可以选择在同一个应用程序实现这两者。在这种情况下,就不需要建立任何调用或使用一个共享数据库。但是,如果在同一个应用程序中实现这两种职责,授权服务器和资源服务器就都可以访问相同的bean.因此,它们可以使用相同的令牌存储,而不需要进行网络调用或访问数据库。请大家记住这句结论,这个我们后面搭建redisTokenStore会用到这个结论。

SpringSecurity为TokenStore接口提供了各种实现,在大多数情况下,我们都不需要编写自己的实现。例如,对于前面的所有授权服务器实现,我们都没有指定TokenStore实现。SpringSecurity提供了一个InMemoryTokenStore类型的默认令牌存储。可以想见,在所有这些情况下,令牌都会存储在应用程序的内存中。它们没有被持久化!如果重启授权服务器,那么启动前颁发的令牌将不再生效

为了使用黑板模式实现令牌管理,Spring Security提供了JdbcTokenStore实现。顾名思义,这个令牌存储直接通过JDBC与数据库一起工作。它的工作原理类似于之前讨论的JdbcUserDetailsManager,但与用户管理不同的是,JdbcTokenStore管理的是令牌。

ps:我们只是在这一章中选择了JdbcTokenStore实现黑板模式,但是也可以选择使用TokenStore持久化令牌,甚至你可以自己重写你自己的tokenStore以达到你的理想的持久化的方式,例如下一章会说到的重写一个TokenStore以达到redis存储令牌,并且将相关数据放进本地缓存caffeine.那么本章我们还是以JdbcTokenStore为例子进行讲解。

JdbcTokenStore期望数据库中有两个表。它会使用一个表存储访问令牌(该表的名称默认规定为oauth_access_token)和一个表存储刷新令牌(该表的名称默认规定为oauth_refresh_token)。用于存储令牌的表将同时持久化刷新令牌

ps:注意如果我们不对JdbcTokenStore进行重写的话,表名和表的结构都是默认规定好的,不去重写是不能任意去更改表名表的结构等。

如下图,JdbcTokenStore默认已经封装了很多的SQL语句,这也证明你不能在不重写JdbcTokenStore的情况下去更改表的结构。

但是,如果你想使用其他表或者其他列甚至是属性结构,SpringSecurity也允许你去自定义JdbcTokenStore,当然你必须重写它用来检索或存储令牌的所有相关SQL。那这里我们就不对JdbcTokenStore进行重写,采取默认结构去进行讲解:
同样的,既然和sql挂钩,我们就得从表结构开始说起,以下两张表结构已经固定好了。大家直接copy就行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
DROP TABLE IF EXISTS oauth_access_token;
CREATE TABLE oauth_access_token (
token_id varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT 'MD5加密的access_token的值',
token blob NULL COMMENT 'OAuth2AccessToken.java对象序列化后的二进制数据',
authentication_id varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT 'MD5加密过的username,client_id,scope',
user_name varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '登录的用户名',
client_id varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '客户端ID',
authentication blob NULL COMMENT 'OAuth2Authentication.java对象序列化后的二进制数据',
refresh_token varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT 'MD5加密后的refresh_token的值',
PRIMARY KEY (authentication_id) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '访问令牌' ROW_FORMAT = Dynamic;
DROP TABLE IF EXISTS oauth_refresh_token;
CREATE TABLE oauth_refresh_token (
token_id varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT 'MD5加密过的refresh_token的值',
token blob NULL COMMENT 'OAuth2RefreshToken.java对象序列化后的二进制数据',
authentication blob NULL COMMENT 'OAuth2Authentication.java对象序列化后的二进制数据'
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '更新令牌' ROW_FORMAT = Dynamic;

然后授权服务器和资源服务器均加入Mybatis和mysq-javal相关依赖:

1
2
3
4
5
6
7
8
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>

在application.yaml文件中,需要添加数据源的定义。以下代码片段提供了该定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
spring:
datasource:
driver-class-name: com.mysql.jdbc.Driver
username: root
password: 123456
url: jdbc:mysql://127.0.0.1:3306/spring_security?useUnicode=true&characterEncoding=UTF-8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&nullCatalogMeansCurrent=true
initialization-mode: always
mybatis-plus:
mapper-locations: classpath:/mapper/**/*.xml,classpath:/META-INF/modeler-mybatis-mappings/*.xml
typeAliasesPackage: com.mbw.pojo
global-config:
banner: false
configuration:
map-underscore-to-camel-case: false
cache-enabled: false
call-setters-on-nulls: true
jdbc-type-for-null: 'null'
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

接下来我们需要在之前配置的授权服务器配置类AuthServerConfig中注入数据源,然后定义和配置令牌存储。下面代码显示了这一更改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
import com.mbw.security.service.ClientDetailsServiceImpl;
import com.mbw.security.service.UserDetailsServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JdbcTokenStore;

import javax.sql.DataSource;

@Configuration
@EnableAuthorizationServer
public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {


@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private ClientDetailsServiceImpl clientDetailsServiceImpl;
@Autowired
private UserDetailsServiceImpl userDetailsServiceImpl;
//注入application.yaml文件中的数据源
@Autowired
private DataSource dataSource;

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {


endpoints.authenticationManager(authenticationManager)
.userDetailsService(userDetailsServiceImpl)
.tokenStore(tokenStore());
}

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {


clients.withClientDetails(clientDetailsServiceImpl);
}

/**
* 解决访问/oauth/check_token 403的问题
*
* @param security
* @throws Exception
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {


// 允许表单认证
security
.tokenKeyAccess("permitAll()")
.checkTokenAccess("permitAll()")
.allowFormAuthenticationForClients();

}

@Bean
public TokenStore tokenStore(){


return new JdbcTokenStore(dataSource);
}
}

并且我们可以对token本身做相关的设置,例如设置accessToken和refreshToken的有效时间,这里SpringSecurity给我们提供了TokenService类,我们可以通过配置它来对token的一些属性作设置,这里我们也在授权服务器配置类配置即可,完整的AuthServerConfig类如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
import com.mbw.security.service.ClientDetailsServiceImpl;
import com.mbw.security.service.UserDetailsServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.token.DefaultTokenServices;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JdbcTokenStore;

import javax.sql.DataSource;

@Configuration
@EnableAuthorizationServer
public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {


@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private ClientDetailsServiceImpl clientDetailsServiceImpl;
@Autowired
private UserDetailsServiceImpl userDetailsServiceImpl;
@Autowired
private JsonRedisTokenStore jsonRedisTokenStore;
//注入application.yaml文件中的数据源
@Autowired
private DataSource dataSource;

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {


endpoints.authenticationManager(authenticationManager)
.userDetailsService(userDetailsServiceImpl)
.tokenStore(tokenStore());
DefaultTokenServices tokenService = getTokenStore(endpoints);
endpoints.tokenServices(tokenService);
}

//配置TokenService参数
private DefaultTokenServices getTokenStore(AuthorizationServerEndpointsConfigurer endpoints) {


DefaultTokenServices tokenService = new DefaultTokenServices();
tokenService.setTokenStore(endpoints.getTokenStore());
tokenService.setSupportRefreshToken(true);
tokenService.setClientDetailsService(endpoints.getClientDetailsService());
tokenService.setTokenEnhancer(endpoints.getTokenEnhancer());
//token有效期 1小时
tokenService.setAccessTokenValiditySeconds(3600);
//token刷新有效期 15天
tokenService.setRefreshTokenValiditySeconds(3600 * 12 * 15);
tokenService.setReuseRefreshToken(false);
return tokenService;
}

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {


clients.withClientDetails(clientDetailsServiceImpl);
}

/**
* 解决访问/oauth/check_token 403的问题
*
* @param security
* @throws Exception
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {


// 允许表单认证
security
.tokenKeyAccess("permitAll()")
.checkTokenAccess("permitAll()")
.allowFormAuthenticationForClients();

}

@Bean
public TokenStore tokenStore(){


return new JdbcTokenStore(dataSource);
}
}

现在可以启动授权服务器并颁发令牌,这个和前面章节获取令牌是一样的,我们仍然可以采取password授权类型颁发令牌:

响应中返回的访问令牌也可以在oauth_access_token表中作为其记录而找到。由于数据库会持久化令牌,因此及时授权服务器关闭或者重新启动后,资源服务器也可以验证已颁发且未过期的令牌。

因为配置了刷新令牌授权类型,所以会接收到一个刷新令牌。出于这个原因,还可以在oauth_refresh_token表中找到刷新令牌的记录。

且我们还可以通过该refreshToken重新获取一个新的token和refreshToken:

现在是配置资源服务器的时候,以便它也可以使用相同的数据库。所以我们也需要在之前的资源服务器上加入和授权服务器同样的数据源相关依赖和配置,这里我就不作代码演示了,直接copy授权服务器直接写的配置和依赖即可。

然后来到资源服务器的配置类中,需要注入数据源并配置JdbcTokenStore:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {



@Autowired
private DataSource dataSource;

@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {


resources.tokenStore(tokenStore());
}

@Bean
public TokenStore tokenStore(){


return new JdbcTokenStore(dataSource);
}

}

现在可以启动资源服务器,并使用之前颁发的访问令牌访问/test/yidou测试端点。

这样,我们就实现了黑板模式,用于资源服务器和授权服务器之间的通信。其中使用了一个名为JdbcTokenStore的TokenStore实现。现在可以将令牌持久化到数据库中了,并且可以避免在资源服务器和授权服务器之间使用直接调用来验证令牌。但是让授权服务器和资源服务器都依赖于同一个数据库是一个缺点。在大量请求的情况下,共享数据库可能成为一个瓶颈,并影响系统性能。那么就有了可以让redis存储令牌让响应速度快一点,但是这种方案治标不治本,那么有其他实现选项吗?答案是肯定的:在JWT中使用签名令牌,这个我们后面会讲到。

最后的最后,让大家思考一点,在对资源服务器去作相关配置的时候,我说了为了让资源服务器和授权服务器使用相同的tokenStore(共享数据库),所以需要在资源服务器配置和授权服务器同样的tokenStore,这样的做法真的好吗,真的合适吗?说的绝一点,有时候我们需要对TokenStore重写,其中关于用户详细信息的内容涉及到UserDetails,那在授权服务器这个项目重写配置后,你总不能把同样的UserDetails类又放到资源服务器项目上去,然后再重写一个相同的TokenStore,代码太耦合了,这样的做法肯定是不合适的。

再结合一下如果授权服务器和资源服务器如果在同一个项目可以访问相同的bean,而不需要在资源服务器额外配置tokenStore,只需要在授权服务器配置这个点,大家可以去思考下有没有更好地解决方式呢?这个我将在下一章通过redis重写TokenStore中提一提我的解决方案也是我在看了一些oauth项目解决方案初步总结出的。

自定义的tokenStore

本章将在前面几章基础上进行讲解,所以大家最好尽量先去看一下前几章的内容再来跟进会好很多。那么本章我们将通过redis和本地缓存Caffeine对JdbcTokenStore进行重写,并且讲解资源服务器配置的新方案,使得我们可以不用在资源服务器又配置一个和授权服务器一样的tokenStore.
我们上一章实现了JdbcTokenStore,那么大家都知道,redis的速度是肯定的比普通的数据库是要快的,且JdbcTokenStore实在是有点难拓展,尤其涉及到表结构的更改,所以选择使用Redis对TokenStore进行重写。但是大量的请求,且token基本都是长信息,肯定也是会对Redis造成不小的压力,所以这里使用了本地缓存Caffeine。那么这里的写法我是照着下面这篇博客写的,我看了很多重写的方法,基本只有这篇我仿写后能够正常运行。再次也对这篇博客作者表示感谢,真的强!

https://www.cnblogs.com/chongsha/p/14558011.html

后面有时间和能力后我会阅读OAuth2源码结合之前写的代码做几期分享

实现JsonRedisTokenStore

在说实现之前,先来说一下为什么要重写这个类,其实懂一点的开发同仁会说,欸,OAuth2不是提供了RedisTokenStore类吗,为啥还要重写?
确实,OAuth2给我们提供了RedisTokenStore类,并且使用上比JdbcTokenStore还简单,我们只需要配置好redis以及在授权服务器类注入RedisConnectFactory就可以直接配置RedisTokenStore。

但是你或许也看到了,默认的RedisTokenStore采用的默认序列化方式是JDK序列化。

学过Redis的应该了解了,这会引起存储的对象产生乱码问题,所以我们需要使用json对Authentication和token进行序列化,这就是我们重写的原因,且我们可以在其基础上加入本地缓存减小Redis的压力。

首先在授权服务器项目上我们需要redis和Caffeine,fastJson的依赖,大家如果有自己想用的本地缓存也可以直接换,例如仍然使用Redis实现本地缓存也是Ok的:

1
2
3
4
5
6
7
8
9
<!--redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>

然后在yaml文件需要对redis做相关配置:

1
2
3
4
5
6
spring:
redis配置
redis:
host: 127.0.0.1
password: 123456
port: 6379

然后就可以重写了,关于方法的大致解释我已经做了相关注释,大家直接看着一块儿块儿自己写就好,我们这儿
JsonRedisTokenStore.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
package com.mbw.security.token;

import cn.hutool.core.date.DateUnit;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSONObject;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.mbw.pojo.Role;
import com.mbw.pojo.User;
import com.mbw.security.dto.JwtUserDto;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken;
import org.springframework.security.oauth2.common.ExpiringOAuth2RefreshToken;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.common.OAuth2RefreshToken;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.OAuth2Request;
import org.springframework.security.oauth2.provider.token.AuthenticationKeyGenerator;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;

@Component
public class JsonRedisTokenStore implements TokenStore {


private static final String ACCESS = "access:";
private static final String AUTH_TO_ACCESS = "auth_to_access:";
private static final String AUTH = "auth:";
private static final String REFRESH_AUTH = "refresh_auth:";
private static final String ACCESS_TO_REFRESH = "access_to_refresh:";
private static final String REFRESH = "refresh:";
private static final String REFRESH_TO_ACCESS = "refresh_to_access:";
private static final String CLIENT_ID_TO_ACCESS = "client_id_to_access:";
private static final String UNAME_TO_ACCESS = "uname_to_access:";

private final static Cache<Object, Object> CACHE;

static {


CACHE = Caffeine.newBuilder()
.expireAfterWrite(60, TimeUnit.SECONDS)
.maximumSize(1000).build();
}
private final StringRedisTemplate stringRedisTemplate;
private final AuthenticationKeyGenerator authenticationKeyGenerator = new CustomAuthenticationKeyGenerator();
public JsonRedisTokenStore(StringRedisTemplate stringRedisTemplate) {


this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public OAuth2Authentication readAuthentication(OAuth2AccessToken token) {


return this.readAuthentication(token.getValue());
}

//根据AccessToken对象查询对应的OAuth2Authentication(认证的用户信息)
@Override
public OAuth2Authentication readAuthentication(String token) {


String key = AUTH + token;

return (OAuth2Authentication) loadCache(key, (k) -> {


String json = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isBlank(json)) {


return null;
}
return fullParseJSON(json);
});
}

/**
* 完整的OAuth2Authentication 对象转换
*
* @param json 完整OAuth2Authentication json字符串
* @return OAuth2Authentication对象
*/
private OAuth2Authentication fullParseJSON(String json) {


JSONObject jsonObject = JSONObject.parseObject(json);

JSONObject userAuthenticationObject = jsonObject.getJSONObject("userAuthentication");
/**
* 这里通过之前写的UserDetails的构造方法,我的UserDetails由三部分构造:User类,Set<Role>,List<String> authorityName
* 所以这里你可以直接按照你自己的userDetails来进行json解析
* 而UserDetails在这里就是Principal部分,所以json得先解析principal才能得到我需要的UserDetails组件
*/

User userInfo = userAuthenticationObject.getJSONObject("principal").getObject("user",User.class);
Set<Role> roleInfo = new HashSet<>(userAuthenticationObject.getJSONObject("principal").getJSONArray("roleInfo").toJavaList(Role.class));
List<String> authorityNames = userAuthenticationObject.getJSONObject("principal").getJSONArray("authorityNames").toJavaList(String.class);
JwtUserDto jwtUserDto = new JwtUserDto(userInfo, roleInfo, authorityNames);
String credentials = userAuthenticationObject.getString("credentials");
JSONObject detailsJSONObject = userAuthenticationObject.getJSONObject("details");
LinkedHashMap<String, Object> details = new LinkedHashMap<>();
for (String key : detailsJSONObject.keySet()) {


details.put(key, detailsJSONObject.get(key));
}

UsernamePasswordAuthenticationToken userAuthentication = new UsernamePasswordAuthenticationToken(jwtUserDto
, credentials, new ArrayList<>(0));
userAuthentication.setDetails(details);

JSONObject storedRequest = jsonObject.getJSONObject("oAuth2Request");
String clientId = storedRequest.getString("clientId");

JSONObject requestParametersJSON = storedRequest.getJSONObject("requestParameters");
Map<String, String> requestParameters = new HashMap<>();
for (String key : requestParametersJSON.keySet()) {


requestParameters.put(key, requestParametersJSON.getString(key));
}

Set<String> scope = convertSetString(storedRequest, "scope");
Set<String> resourceIds = convertSetString(storedRequest, "resourceIds");
Set<String> responseTypes = convertSetString(storedRequest, "responseTypes");

OAuth2Request oAuth2Request = new OAuth2Request(requestParameters
, clientId
//由于这个项目不需要处理权限角色,所以就没有对权限角色集合做处理
, new ArrayList<>(0)
, storedRequest.getBoolean("approved")
, scope
, resourceIds
, storedRequest.getString("redirectUri")
, responseTypes
, null //extensionProperties
);

return new OAuth2Authentication(oAuth2Request, userAuthentication);
}

private static Set<String> convertSetString(JSONObject data, String key) {


List<String> list = data.getJSONArray(key).toJavaList(String.class);

return new HashSet<>(list);
}

//存储accessToken并且存储用户认证信息(Principal)
@Override
public void storeAccessToken(OAuth2AccessToken token, OAuth2Authentication authentication) {


String serializedAccessToken = JSONObject.toJSONString(token);
String serializedAuth = JSONObject.toJSONString(authentication);
String accessKey = ACCESS + token.getValue();
String authKey = AUTH + token.getValue();
String authToAccessKey = AUTH_TO_ACCESS + authenticationKeyGenerator.extractKey(authentication);
String approvalKey = UNAME_TO_ACCESS + getApprovalKey(authentication);
String clientId = CLIENT_ID_TO_ACCESS + authentication.getOAuth2Request().getClientId();

int seconds = 30 * 24 * 60 * 60;
if (token.getExpiration() != null) {


seconds = (int) DateUtil.between(new Date(),token.getExpiration(), DateUnit.SECOND);
}

try {


stringRedisTemplate.opsForValue().set(accessKey, serializedAccessToken);
stringRedisTemplate.opsForValue().set(authKey, serializedAuth);
stringRedisTemplate.opsForValue().set(authToAccessKey, serializedAccessToken);

if (!authentication.isClientOnly()) {


stringRedisTemplate.opsForHash().putIfAbsent(approvalKey, token.getValue(), serializedAccessToken);
}
} finally {


//如果中途失败,则还可以补偿过期时间
stringRedisTemplate.expire(accessKey, seconds, TimeUnit.SECONDS);
stringRedisTemplate.expire(authKey, seconds, TimeUnit.SECONDS);
stringRedisTemplate.expire(authToAccessKey, seconds, TimeUnit.SECONDS);
stringRedisTemplate.expire(clientId, seconds, TimeUnit.SECONDS);
stringRedisTemplate.expire(approvalKey, seconds, TimeUnit.SECONDS);
}

OAuth2RefreshToken refreshToken = token.getRefreshToken();
if (refreshToken != null && refreshToken.getValue() != null) {


String refreshValue = token.getRefreshToken().getValue();
String refreshToAccessKey = REFRESH_TO_ACCESS + refreshValue;
String accessToRefreshKey = ACCESS_TO_REFRESH + token.getValue();

try {


stringRedisTemplate.opsForValue().set(refreshToAccessKey, token.getValue());
stringRedisTemplate.opsForValue().set(accessToRefreshKey, refreshValue);
} finally {


//如果中途失败,则还可以补偿过期时间
refreshTokenProcess(refreshToken, refreshToAccessKey, accessToRefreshKey);
}

CACHE.put(refreshToAccessKey, token.getValue());
CACHE.put(accessToRefreshKey, refreshValue);
}

CACHE.put(accessKey, token);
CACHE.put(authKey, authentication);
CACHE.put(authToAccessKey, token);
}

private void refreshTokenProcess(OAuth2RefreshToken refreshToken, String refreshKey, String refreshAuthKey) {


int seconds = 30 * 24 * 60 * 60;
if (refreshToken instanceof ExpiringOAuth2RefreshToken) {


ExpiringOAuth2RefreshToken expiringRefreshToken = (ExpiringOAuth2RefreshToken) refreshToken;
Date expiration = expiringRefreshToken.getExpiration();

int temp;
if (expiration != null) {


temp = Long.valueOf((expiration.getTime() - System.currentTimeMillis()) / 1000L)
.intValue();

} else {


temp = seconds;
}
stringRedisTemplate.expire(refreshKey, temp, TimeUnit.SECONDS);
stringRedisTemplate.expire(refreshAuthKey, temp, TimeUnit.SECONDS);
}
}
private String getApprovalKey(OAuth2Authentication authentication) {


String userName = "";
if (authentication.getUserAuthentication() != null) {


JwtUserDto userInfoDetails = (JwtUserDto) authentication.getUserAuthentication().getPrincipal();
userName = userInfoDetails.getUser().getMobile() + "_" + userInfoDetails.getUsername();
}

return getApprovalKey(authentication.getOAuth2Request().getClientId(), userName);
}

private String getApprovalKey(String clientId, String userName) {


return clientId + (userName == null ? "" : ":" + userName);
}
//根据AccessToken的value值查询对应的token对象
@Override
public OAuth2AccessToken readAccessToken(String tokenValue) {


String key = ACCESS + tokenValue;
//先从本地缓存取,没有再从redis中取,都没有返回Null
return (OAuth2AccessToken) loadCache(key, (k) -> {


String json = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(json)) {


return JSONObject.parseObject(json, DefaultOAuth2AccessTokenEx.class);
}
return null;
});
}

@Override
public void removeAccessToken(OAuth2AccessToken accessToken) {


removeAccessToken(accessToken.getValue());
}

public void removeAccessToken(String tokenValue) {


String accessKey = ACCESS + tokenValue;
String authKey = AUTH + tokenValue;
String accessToRefreshKey = ACCESS_TO_REFRESH + tokenValue;

OAuth2Authentication authentication = readAuthentication(tokenValue);
String access = stringRedisTemplate.opsForValue().get(accessKey);

List<String> keys = new ArrayList<>(6);
keys.add(accessKey);
keys.add(authKey);
keys.add(accessToRefreshKey);

stringRedisTemplate.delete(keys);

if (authentication != null) {


String key = authenticationKeyGenerator.extractKey(authentication);
String authToAccessKey = AUTH_TO_ACCESS + key;
String unameKey = UNAME_TO_ACCESS + getApprovalKey(authentication);
String clientId = CLIENT_ID_TO_ACCESS + authentication.getOAuth2Request().getClientId();

stringRedisTemplate.delete(authToAccessKey);
stringRedisTemplate.opsForHash().delete(unameKey, tokenValue);
stringRedisTemplate.opsForList().remove(clientId, 1, access);
stringRedisTemplate.delete(ACCESS + key);

CACHE.invalidate(authToAccessKey);
CACHE.invalidate(ACCESS + key);
}

CACHE.invalidateAll(keys);
}

@Override
public void storeRefreshToken(OAuth2RefreshToken refreshToken, OAuth2Authentication authentication) {


String refreshKey = REFRESH + refreshToken.getValue();
String refreshAuthKey = REFRESH_AUTH + refreshToken.getValue();
String serializedRefreshToken = JSONObject.toJSONString(refreshToken);

stringRedisTemplate.opsForValue().set(refreshKey, serializedRefreshToken);
stringRedisTemplate.opsForValue().set(refreshAuthKey, JSONObject.toJSONString(authentication));

refreshTokenProcess(refreshToken, refreshKey, refreshAuthKey);

CACHE.put(refreshKey, refreshToken);
CACHE.put(refreshAuthKey, authentication);
}
//和readAccessToken的原理一致
@Override
public OAuth2RefreshToken readRefreshToken(String tokenValue) {


String key = REFRESH + tokenValue;
return (OAuth2RefreshToken) loadCache(key, (k) -> {


String json = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(json)) {


return JSONObject.parseObject(json, DefaultOAuth2RefreshTokenEx.class);
}

return null;
});
}

@Override
public OAuth2Authentication readAuthenticationForRefreshToken(OAuth2RefreshToken token) {


return this.readAuthenticationForRefreshToken(token.getValue());
}

public OAuth2Authentication readAuthenticationForRefreshToken(String token) {


String key = REFRESH_AUTH + token;

return (OAuth2Authentication) loadCache(key, (k) -> {


String json = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isBlank(json)) {


return null;
}

return fullParseJSON(json);
});
}

@Override
public void removeRefreshToken(OAuth2RefreshToken refreshToken) {


this.removeRefreshToken(refreshToken.getValue());
}

public void removeRefreshToken(String refreshToken) {


String refreshKey = REFRESH + refreshToken;
String refreshAuthKey = REFRESH_AUTH + refreshToken;
String refresh2AccessKey = REFRESH_TO_ACCESS + refreshToken;
String access2RefreshKey = ACCESS_TO_REFRESH + refreshToken;

List<String> keys = new ArrayList<>(7);
keys.add(refreshKey);
keys.add(refreshAuthKey);
keys.add(refresh2AccessKey);
keys.add(access2RefreshKey);

stringRedisTemplate.delete(keys);

CACHE.invalidateAll(keys);
}

@Override
public void removeAccessTokenUsingRefreshToken(OAuth2RefreshToken refreshToken) {


this.removeAccessTokenUsingRefreshToken(refreshToken.getValue());
}

private void removeAccessTokenUsingRefreshToken(String refreshToken) {


String key = REFRESH_TO_ACCESS + refreshToken;

String accessToken = stringRedisTemplate.opsForValue().get(key);
stringRedisTemplate.delete(key);

if (accessToken != null) {


removeAccessToken(accessToken);
}

CACHE.invalidate(key);
}

private <T> Object loadCache(String key, Function<Object, ? extends T> loadData) {


try {


Object value = CACHE.getIfPresent(key);
if (value == null) {


value = loadData.apply(key);
//如果redis中有则将redis中的token放入本地缓存中
if (value != null) {


CACHE.put(key, value);
}
}

return value;
} catch (Exception e) {


throw new RuntimeException("JsonRedisTokenStore.loadCache从缓存中加载数据发生错误", e);
}
}

@Override
public OAuth2AccessToken getAccessToken(OAuth2Authentication authentication) {


String key = AUTH_TO_ACCESS + authenticationKeyGenerator.extractKey(authentication);

return (OAuth2AccessToken) loadCache(key, (k) -> {


String json = stringRedisTemplate.opsForValue().get(key);

if (StrUtil.isNotBlank(json)) {


DefaultOAuth2AccessToken accessToken = JSONObject.parseObject(json, DefaultOAuth2AccessTokenEx.class);
OAuth2Authentication storedAuthentication = readAuthentication(accessToken.getValue());

if (storedAuthentication == null
|| !key.equals(authenticationKeyGenerator.extractKey(storedAuthentication))) {


this.storeAccessToken(accessToken, authentication);
}

return accessToken;
}

return null;
});
}

@Override
public Collection<OAuth2AccessToken> findTokensByClientIdAndUserName(String clientId, String userName) {


String approvalKey = UNAME_TO_ACCESS + getApprovalKey(clientId, userName);

return getOAuth2AccessTokens(approvalKey);
}

private Collection<OAuth2AccessToken> getOAuth2AccessTokens(String approvalKey) {


return (Collection<OAuth2AccessToken>) loadCache(approvalKey, (k) -> {


Map<Object, Object> accessTokens = stringRedisTemplate.opsForHash().entries(approvalKey);

if (accessTokens.size() == 0) {


return Collections.emptySet();
}

List<OAuth2AccessToken> result = new ArrayList<>();

for (Object json : accessTokens.values()) {


String strJSON = json.toString();
OAuth2AccessToken accessToken = JSONObject.parseObject(strJSON, DefaultOAuth2AccessTokenEx.class);

result.add(accessToken);
}
return Collections.unmodifiableCollection(result);
});
}

@Override
public Collection<OAuth2AccessToken> findTokensByClientId(String clientId) {


String key = CLIENT_ID_TO_ACCESS + clientId;

return getOAuth2AccessTokens(key);
}
}

DefaultOAuth2RefreshTokenEx.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package com.mbw.security.token;

import lombok.Data;
import org.springframework.security.oauth2.common.DefaultOAuth2RefreshToken;
import org.springframework.security.oauth2.common.ExpiringOAuth2RefreshToken;

import java.util.Date;

@Data
public class DefaultOAuth2RefreshTokenEx extends DefaultOAuth2RefreshToken implements ExpiringOAuth2RefreshToken {


private Date expiration;
private String value;

public DefaultOAuth2RefreshTokenEx() {


super(null);
}
}

DefaultOAuth2AccessTokenEx.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package com.mbw.security.token;

import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken;

public class DefaultOAuth2AccessTokenEx extends DefaultOAuth2AccessToken {


private DefaultOAuth2RefreshTokenEx refreshToken;

public DefaultOAuth2AccessTokenEx() {


super((String) null);
}

@Override
public void setValue(String value) {


super.setValue(value);
}

@Override
public DefaultOAuth2RefreshTokenEx getRefreshToken() {


return refreshToken;
}

public void setRefreshToken(DefaultOAuth2RefreshTokenEx refreshToken) {


this.refreshToken = refreshToken;
}
}

在项目中使用spring security oauth2做了统一登录授权,在实际开发过程中,发现不同终端同一账号登录,返回的token是一样的。我们使用的是redis存储token,于是查了资料,发现是因为生成token key的算法的原因,导致了多端登录返回一个token的问题,原因如图:

生成key使用的是DefaultAuthenticationKeyGenerator,代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class DefaultAuthenticationKeyGenerator implements AuthenticationKeyGenerator {



private static final String CLIENT_ID = "client_id";

private static final String SCOPE = "scope";

private static final String USERNAME = "username";

public String extractKey(OAuth2Authentication authentication) {


Map<String, String> values = new LinkedHashMap<String, String>();
OAuth2Request authorizationRequest = authentication.getOAuth2Request();
if (!authentication.isClientOnly()) {


values.put(USERNAME, authentication.getName());
}
values.put(CLIENT_ID, authorizationRequest.getClientId());
if (authorizationRequest.getScope() != null) {


values.put(SCOPE, OAuth2Utils.formatParameterList(new TreeSet<String>(authorizationRequest.getScope())));
}
return generateKey(values);
}
}

从代码里面看,生成key使用的是 client_id、scope、username三个字段,由于这三个字段同一用户在同一子系统中是不变的,所以导致多端登录时,生成的token key是一样的,就会造成返回的token一样,这样的后果就是,其中一个终端退出登录,所有已登录设备就失效了,于是就重写这extractKey方法,继承这个类,增加了一个device_id字段,从而解决多端登录需要互不干扰的需求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package com.mbw.security.token;

import org.springframework.security.oauth2.common.util.OAuth2Utils;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.OAuth2Request;
import org.springframework.security.oauth2.provider.token.DefaultAuthenticationKeyGenerator;

import java.util.LinkedHashMap;
import java.util.Map;
import java.util.TreeSet;

public class CustomAuthenticationKeyGenerator extends DefaultAuthenticationKeyGenerator {


private static final String CLIENT_ID = "client_id";

private static final String SCOPE = "scope";

private static final String USERNAME = "username";

private static final String DEVICE_ID = "device_id";

@Override
public String extractKey(OAuth2Authentication authentication) {


Map<String, String> values = new LinkedHashMap<String, String>();
OAuth2Request authorizationRequest = authentication.getOAuth2Request();
if (!authentication.isClientOnly()) {


values.put(USERNAME, authentication.getName());
}
values.put(CLIENT_ID, authorizationRequest.getClientId());
if (authorizationRequest.getScope() != null) {


values.put(SCOPE, OAuth2Utils.formatParameterList(new TreeSet<String>(authorizationRequest.getScope())));
}

String deviceId = authorizationRequest.getRequestParameters().get(DEVICE_ID);
values.put(DEVICE_ID, deviceId);

return generateKey(values);
}
}

而我们生成Token的接口的参数就要再加一个device_id,而这个deviceId可以通过存进用户表让前端取到。

然后就可以在AuthServerConfig配置该类了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
package com.mbw.security.config;

import com.mbw.security.service.ClientDetailsServiceImpl;
import com.mbw.security.service.UserDetailsServiceImpl;
import com.mbw.security.token.JsonRedisTokenStore;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.token.DefaultTokenServices;

@Configuration
@EnableAuthorizationServer
public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {


@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private ClientDetailsServiceImpl clientDetailsServiceImpl;
@Autowired
private UserDetailsServiceImpl userDetailsServiceImpl;
@Autowired
private JsonRedisTokenStore jsonRedisTokenStore;

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {


endpoints.authenticationManager(authenticationManager)
.userDetailsService(userDetailsServiceImpl)
.tokenStore(jsonRedisTokenStore);
DefaultTokenServices tokenService = getTokenStore(endpoints);
endpoints.tokenServices(tokenService);
}

//配置TokenService参数
private DefaultTokenServices getTokenStore(AuthorizationServerEndpointsConfigurer endpoints) {


DefaultTokenServices tokenService = new DefaultTokenServices();
tokenService.setTokenStore(endpoints.getTokenStore());
tokenService.setSupportRefreshToken(true);
tokenService.setClientDetailsService(endpoints.getClientDetailsService());
tokenService.setTokenEnhancer(endpoints.getTokenEnhancer());
//token有效期 1小时
tokenService.setAccessTokenValiditySeconds(3600);
//token刷新有效期 15天
tokenService.setRefreshTokenValiditySeconds(3600 * 12 * 15);
tokenService.setReuseRefreshToken(false);
return tokenService;
}

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {


clients.withClientDetails(clientDetailsServiceImpl);
}

/**
* 解决访问/oauth/check_token 403的问题
*
* @param security
* @throws Exception
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {


// 允许表单认证
security
.tokenKeyAccess("permitAll()")
.checkTokenAccess("permitAll()")
.allowFormAuthenticationForClients();

}
}

然后现在启动授权服务器调用获取token

然后回到redis看看成果:

发现token已经存进redis,并且序列化。

资源服务器配置

如果按照上一章的说法,我们接下来要在资源服务器也要配置同样的tokenStore,但是这也是我上一章结尾提到的代码耦合问题,明显不应该这样做。那么怎么解决呢?首先大家需要了解一个额外的配置:user-info-uri。user-info-uri原理是在授权服务器认证后将认证信息Principal通过形参绑定的方法通过URL的方式获取用户信息。所以这个大家可以想象成和token-info-uri类似的作用,只是我们不再直接调用授权服务器而已,而是一种类似职责分离,授权认证交给授权服务器,认证后的用户信息给到资源服务器,资源服务器再提供资源。

那么我们只需要在application.yaml修改如下配置即可:

1
2
3
4
5
6
security:
oauth2:
resource:
id: resource_server
user-info-uri: http://localhost:9090/api/member
prefer-token-info: false

与此同时,资源服务器的配置类只需要专注于我们需要保护的资源即可:
ResourceServerConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package com.mbw.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;

import javax.servlet.http.HttpServletResponse;
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {


@Override
public void configure(HttpSecurity http) throws Exception {


http
.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint((request, response, authException) -> response.sendError(HttpServletResponse.SC_UNAUTHORIZED))
.and()
.authorizeRequests()
.antMatchers("/test/**").authenticated()
.and()
.httpBasic();
}

}

那资源服务器怎么知道我要访问哪个tokenStore呢?
还记得如果资源服务器和授权服务器在同一个项目时访问的是同一个bean这个结论吗,那么我们只需要在授权服务器项目上再配置一个资源服务器去保护user-info-uri这个资源即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package com.mbw.security.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;

import javax.servlet.http.HttpServletResponse;
@Configuration
@EnableResourceServer
@Order(3)
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {


@Override
public void configure(HttpSecurity http) throws Exception {


http
.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint((request, response, authException) -> response.sendError(HttpServletResponse.SC_UNAUTHORIZED))
.and()
.requestMatchers().antMatchers("/api/**")
.and()
.authorizeRequests()
.antMatchers("/api/**").authenticated()
.and()
.httpBasic();
}
}

而在授权服务器配置的资源服务器是知道用哪个tokenStore的,那么一个调用链就出现了:
①用户带着token访问资源服务器保护的资源,而资源服务器为了获取用户信息,就会去调用user-info-uri,而这个路径被我们在授权服务器配置的资源服务器拦截,因为需要经过身份认证,第一站经过OAuth2AuthenticationProcessingFilter认证处理过滤器:

此时Authentication中的Principal装着就是token,就类似于短信认证,认证前我们自己封装的Authentication对象Principal认证前放的是短信一个道理

②然后由AuthenticationManager通过token进行身份验证,熟悉吗,是不是和之前学习的原生Spring Security的身份验证链很像,然后通过tokenService的loadAuthentication进行认证获取认证对象,这里的tokenService大家想象成UserDetailsService就好了。

③tokenService获取Authentication对象来自DefaultTokenServices类,就是我们授权服务器配置的类:

那这里的tokenStore就是授权服务器配置的,也就是我们重写的jsonRedisTokenStore:

最后,来到这个配置的user-info-uri,读取用户认证信息返回给我们真正的授权服务器。

然后有了用户的认证信息,资源服务器把资源返回给客户端:

当然这只是我的个人理解,我自己debug了很多遍,由于找不到类似的资料,书上也没有讲解,我只能这样理解。如果大家有更好的想法,可以在评论区分享。

将JSON Web Token(JWT)用于令牌实现。

使用加密签名验证令牌的优点是允许资源服务器验证令牌而不需要直接调用授权服务器,也不需要共享数据库。这种实现令牌验证的方法通常用于使用OAuth2实现身份验证和授权的系统。出于这个原因,我们需要了解这一实现令牌验证的方式。

使用JWT以及对称秘钥签名的令牌

用于令牌签名的最简单的方法是使用对称秘钥。在这种方法中,使用相同的密钥,既可以签署一个令牌,又可以验证它的签名。使用对称秘钥对令牌进行签名的优点是,它比将在本章后面内容讨论的其他方法更简单,而且速度更快。然后,正如将介绍的,它也有缺点,不能总是与身份验证过程中涉及的所有应用程序共享用于签名令牌的密钥

使用JWT

JWT是一个令牌实现。令牌由三部分组成:头信息、主体和签名头信息和主体中的详情用JSON表示,并且它们是Base64编码的,第三部分是签名,这是使用一种加密算法生成的,该算法使用头信息和主体作为其输入密码算法还意味着需要密钥。密钥就像一个密码,拥有正确密钥的所有者可以签署令牌或验证签名的真实性。如果令牌上的签名是真实的,就可以确保在签名之后没有人修改令牌

JWT被签名时,我们也称它为JWS(JSON Web Token Signed) 。通常,应用加密算法对令牌进行签名就足够了,但有时可以选择对令牌进行加密,如果对令牌进行了签名,就可以在没有任何密钥或密码的情况下查看其内容。但是,即使黑客看到了令牌的内容,他们也不能更改令牌的内容,因为如果他们这么做了,签名就会无效。要让签名有效,签名必须

  • 是使用正确密钥生成的
  • 匹配签名过的内容

如果令牌被加密了,则还会将其称为JWE(JSON Web Token Encrypted) 。没有有效密钥,则无法看到已加密令牌的内容。

使用授权服务器以颁发JWT

本家将实现一个授权服务器,该服务器会向客户端颁发JWT以进行授权。我们之前说过管理令牌的组件是TokenStore。本节要做的是使用Spring Security提供的TokenStore的另一种实现。这里要使用的实现的名称是JwtTokenStore,它会管理JWT。本节还将测试授权服务器。而关于资源服务器,我们暂时不会进行特定实现,之后会解析原因。

  • 如果使用相同的密钥对令牌进行签名和验证签名,就可以说该密钥是对称的
  • 如果使用一个密钥签名令牌,但使用另一个密钥验证签名,则可以说使用的是一个非对称密钥对

这个示例将使用对称密钥实现签名。这种方法意味着授权服务器和资源服务器都知道并使用相同的密钥。授权服务器使用密钥对令牌进行签名,资源服务器使用相同的密钥验证签名

那么依赖仍然使用我们之前学习搭建项目使用的依赖即可。

这里为JdbcTokenStore配置JwtTokenStore的方式与 第14章相同。此外,还需要定义一个JwtAccessTokenConverter类型的对象。使用JwtAccessTokenConverter,就可以配置授权服务器验证令牌的方式;在这个示例中,将使用对称密钥。下面代码展示了如何在授权服务器配置类中配置JwtTokenStore:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
import com.mbw.security.service.ClientDetailsServiceImpl;
import com.mbw.security.service.UserDetailsServiceImpl;
import com.mbw.security.token.JsonRedisTokenStore;
import com.mbw.security.token.enhancer.CustomTokenEnhancer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.token.DefaultTokenServices;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
import org.springframework.security.oauth2.provider.token.TokenEnhancerChain;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;

import java.util.ArrayList;
import java.util.List;
@Configuration
@EnableAuthorizationServer
public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {


@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private ClientDetailsServiceImpl clientDetailsServiceImpl;
@Autowired
private UserDetailsServiceImpl userDetailsServiceImpl;
@Autowired
private CustomTokenEnhancer customTokenEnhancer;
@Autowired
private JsonRedisTokenStore jsonRedisTokenStore;

@Value("${jwt.key}")
private String jwtKey; //从application.yaml文件中获取对称密钥的值

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {


endpoints.authenticationManager(authenticationManager)
.userDetailsService(userDetailsServiceImpl)
.accessTokenConverter(jwtAccessTokenConverter()) //配置访问令牌转换器对象
.tokenStore(tokenStore()); //配置令牌存储对象
DefaultTokenServices tokenService = getTokenStore(endpoints);
endpoints.tokenServices(tokenService);
}

//配置TokenService参数
private DefaultTokenServices getTokenStore(AuthorizationServerEndpointsConfigurer endpoints) {


DefaultTokenServices tokenService = new DefaultTokenServices();
tokenService.setTokenStore(endpoints.getTokenStore());
tokenService.setSupportRefreshToken(true);
tokenService.setClientDetailsService(endpoints.getClientDetailsService());
tokenService.setTokenEnhancer(endpoints.getTokenEnhancer());
//token有效期 1小时
tokenService.setAccessTokenValiditySeconds(3600);
//token刷新有效期 15天
tokenService.setRefreshTokenValiditySeconds(3600 * 12 * 15);
tokenService.setReuseRefreshToken(false);
return tokenService;
}

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {


clients.withClientDetails(clientDetailsServiceImpl);
}

/**
* 解决访问/oauth/check_token 403的问题
*
* @param security
* @throws Exception
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {


// 允许表单认证
security
.tokenKeyAccess("permitAll()")
.checkTokenAccess("permitAll()")
.allowFormAuthenticationForClients();

}

@Bean
public TokenStore tokenStore(){


return new JwtTokenStore(jwtAccessTokenConverter()); //创建带有与之关联的访问令牌转换器的令牌存储
}

@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter(){


JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
jwtAccessTokenConverter.setSigningKey(jwtKey); //设置访问令牌转换器对象的对称密钥的值
return jwtAccessTokenConverter;
}
}

这里在application.yaml文件中存储了这个示例的对称密钥的值,如下面的代码片段所示。但是,不要忘记签名密钥是敏感数据,在现实场景中将其存储在密钥库中

1
2
jwt:
key: MjWP5L7CiD

接下来可以启动授权服务器并调用/oauth/token端点来获取访问令牌。下面的代码片段展示了用于调用/oauth/token端点的postman调用展示:
调用后,首先在DefaultTokenService建立原始的refreshToken和accessToken,此时它们还不是JWT

然后放行,你会发现程序直接结束了,出来的token不是jwt,这是为什么呢?

经过我初步查资料,首先我们回归到创建token的代码—-DefaultTokenServices

我们点进createAccessToken的方法,看到最后一行代码,如果accessTokenEnhancer存在,则做token增强,如果不存在,则返回普通token。回到问题之初,正是返回了普通token,所以,最大的可能便是此处的accessTokenEnhancer为空

那我们debug看一下原因是否是这个:

果然是空,所以我们的配置类根本没有设置这个accessTokenEnhancer
我们来到配置DefaultTokenServices的地方–AuthorizationServerEndpointsConfigurer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private DefaultTokenServices createDefaultTokenServices() {


DefaultTokenServices tokenServices = new DefaultTokenServices();
tokenServices.setTokenStore(tokenStore());
tokenServices.setSupportRefreshToken(true);
tokenServices.setReuseRefreshToken(reuseRefreshToken);
tokenServices.setClientDetailsService(clientDetailsService());
tokenServices.setTokenEnhancer(tokenEnhancer());
addUserDetailsService(tokenServices, this.userDetailsService);
return tokenServices;
}

private TokenEnhancer tokenEnhancer() {


if (this.tokenEnhancer == null && accessTokenConverter() instanceof JwtAccessTokenConverter) {


tokenEnhancer = (TokenEnhancer) accessTokenConverter;
}
return this.tokenEnhancer;
}

逻辑比较明显,如果tokenEnhancer为空,同时,此时的accessTokenConverter为JwtAccessTokenConverter时,tokenEnhancer便赋值为accessTokenConverter,即JwtAccessTokenConverter。但是此时,我们并没有配置accessTokenConverter。所以tokenEnhancer便为空。从而造成DefaultTokenServices中的token返回便会直接普通token

那你可能会有疑问,刚刚那段代码我们不是配置了accessTokenConverter吗,为什么没生效呢,原因就在于我们自己配置的DefaultTokenServices修改了配置类获取tokenEnhancer的逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//配置TokenService参数
private DefaultTokenServices getTokenStore(AuthorizationServerEndpointsConfigurer endpoints) {


DefaultTokenServices tokenService = new DefaultTokenServices();
tokenService.setTokenStore(endpoints.getTokenStore());
tokenService.setSupportRefreshToken(true);
tokenService.setClientDetailsService(endpoints.getClientDetailsService());
tokenService.setTokenEnhancer(endpoints.getTokenEnhancer());
//token有效期 1小时
tokenService.setAccessTokenValiditySeconds(3600);
//token刷新有效期 15天
tokenService.setRefreshTokenValiditySeconds(3600 * 12 * 15);
tokenService.setReuseRefreshToken(false);
return tokenService;
}

看到我们这边调用的tokenEnhancer使用的是endpoints.getTokenEnhancer,点进这个方法可以看到它使用的是源码中获取tokenEnhancer的代码,也就是我们如果没有做额外配置,它直接取的是你一开始的属性,也就是空,也就是没有走我们刚刚看到的处理enhancer的逻辑,那么就算我们设置了tokenConverter也是无济于事的

那么我们唯一想到的办法就是给endPoints(AuthorizationServerEndpointsConfigurer)配置上我们的tokenEnhancer,你可能会想,我不额外配置DefaultTokenServices就好了,让它使用默认的,但现在假设我们需要额外设置token的有效时间这些属性,那我们不得不重写tokenServices这个类,那么方法就只有对endPoints中的tokenEnhancer下手,这里呢我们可以直接写一个方法配置tokenEnhancer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
import com.mbw.security.service.ClientDetailsServiceImpl;
import com.mbw.security.service.UserDetailsServiceImpl;
import com.mbw.security.token.JsonRedisTokenStore;
import com.mbw.security.token.enhancer.CustomTokenEnhancer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.token.DefaultTokenServices;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
import org.springframework.security.oauth2.provider.token.TokenEnhancerChain;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;

import java.util.ArrayList;
import java.util.List;
@Configuration
@EnableAuthorizationServer
public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {


@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private ClientDetailsServiceImpl clientDetailsServiceImpl;
@Autowired
private UserDetailsServiceImpl userDetailsServiceImpl;
@Autowired
private CustomTokenEnhancer customTokenEnhancer;
@Autowired
private JsonRedisTokenStore jsonRedisTokenStore;

@Value("${jwt.key}")
private String jwtKey;

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {


endpoints.authenticationManager(authenticationManager)
.userDetailsService(userDetailsServiceImpl)
.tokenEnhancer(tokenEnhancerChain())
.tokenStore(tokenStore());
DefaultTokenServices tokenService = getTokenStore(endpoints);
endpoints.tokenServices(tokenService);
}

@Bean
public TokenEnhancerChain tokenEnhancerChain(){


TokenEnhancerChain enhancerChain = new TokenEnhancerChain();
List<TokenEnhancer> enhancers = new ArrayList<>();
enhancers.add(jwtAccessTokenConverter());
enhancers.add(customTokenEnhancer);
enhancerChain.setTokenEnhancers(enhancers);//将自定义Enhancer加入EnhancerChain的delegates数组中
return enhancerChain;
}

//配置TokenService参数
private DefaultTokenServices getTokenStore(AuthorizationServerEndpointsConfigurer endpoints) {


DefaultTokenServices tokenService = new DefaultTokenServices();
tokenService.setTokenStore(endpoints.getTokenStore());
tokenService.setSupportRefreshToken(true);
tokenService.setClientDetailsService(endpoints.getClientDetailsService());
tokenService.setTokenEnhancer(endpoints.getTokenEnhancer());
//token有效期 1小时
tokenService.setAccessTokenValiditySeconds(3600);
//token刷新有效期 15天
tokenService.setRefreshTokenValiditySeconds(3600 * 12 * 15);
tokenService.setReuseRefreshToken(false);
return tokenService;
}

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {


clients.withClientDetails(clientDetailsServiceImpl);
}

/**
* 解决访问/oauth/check_token 403的问题
*
* @param security
* @throws Exception
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {


// 允许表单认证
security
.tokenKeyAccess("permitAll()")
.checkTokenAccess("permitAll()")
.allowFormAuthenticationForClients();

}

@Bean
public TokenStore tokenStore(){


return new JwtTokenStore(jwtAccessTokenConverter());
}

@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter(){


JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
jwtAccessTokenConverter.setSigningKey(jwtKey);
return jwtAccessTokenConverter;
}
}

如上所示,配置自定义令牌增强器有点复杂,假设我现在需要额外配置一个自定义的令牌增强对象customTokenEnhancer,对我的令牌做一个增强,又想让其包含jwtTokenConverter的功能怎么办呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package com.mbw.security.token.enhancer;

import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
import org.springframework.stereotype.Component;

import java.util.Date;
import java.util.HashMap;

@Component
public class CustomTokenEnhancer implements TokenEnhancer {


@Override
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {


DefaultOAuth2AccessToken token = new DefaultOAuth2AccessToken(accessToken);
HashMap<String, Object> stringObjectHashMap = new HashMap<>();
stringObjectHashMap.put("loginTime",new Date());
token.setAdditionalInformation(stringObjectHashMap);
return token;
}
}

如上代码,既然提到了令牌增强,我就多提一嘴,有时候我们需要向令牌添加自定义的信息,比如上面代码中的loginTime,那么我们就需要创建一个TokenEnhancer类型的对象去实现TokenEnhancer接口,实现它的enhance()方法,enhance()方法会接收要增强的令牌作为参数,并返回该增强后的令牌,其中包含额外的详细信息

那么我们必须创建一个令牌增强器链TokenEnhancerChain,并设置整个链,而不是只设置一个对象,因为访问令牌转换器对象也是一个令牌增强器。如果只配置自定义令牌增强器,则要重写访问令牌转换器的行为。我们转而要将两者都添加到职责链中,并配置包含这两个对象的链

现在我们可以启动我们的授权服务器,并且调用/oauth/token接口了,大家也可以看到接口返回的信息多出了我们刚刚自定义的loginTIme

可以在响应中观察到,访问和刷新令牌现在都是JWT。在下面代码片段中,可以找到令牌主体的解码(JSON)形式,那么这个呢是通过一个在线解析jwt获取的,点击这个链接即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{


"exp": 1672371264,
"user_name": "张飞",
"authorities": [
"read",
"ROLE_USER",
"ROLE_管理员",
"update",
"ROLE_ADMIN",
"delete",
"write"
],
"jti": "dbd597f0-46ed-4a21-83c5-c5a66fb90273",
"client_id": "f7n6ockwdb9zmayr",
"scope": [
"user_info"
]
}

那么下一步我们实现资源服务器,我们来到我们之前搭建的另一个服务–spring_security_resource_server,首先我们将我们配置类之前在yaml配置的用来同步授权服务器那个服务中的资源服务器这部分配置代码先注释,这个是为了能更好的带大家体验jwtTokenStore

然后在yaml配置和授权服务器相同的对称密钥:

1
2
jwt:
key: MjWP5L7CiD

然后在资源服务器中配置tokenStore,这个你会发现要比redisTokenStore好配置很多,之前我们配置redisTokenStore的做法是在授权服务器中暴露一个获取角色资源的端点,然后用授权服务器的同一个服务中的资源服务器去保护这个端点,这样就避免了在其他资源服务器服务中引入过多依赖,尤其是对userDetails还做了定制的服务

但是jwtTokenStore不同,这个不需要我们引入过多依赖,所以我们可以试着通过在资源服务器中配置我们的jwtTokenStore。那么当时在授权服务器配置的资源服务器就没什么用了。那么回到正题,使用对称加密最重要的方式是确保密钥使用相同的值。资源服务器需要该密钥用来验证令牌的签名:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;

import javax.servlet.http.HttpServletResponse;
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {



@Value("${jwt.key}")
private String jwtKey;

@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {


resources.tokenStore(tokenStore());
}

@Override
public void configure(HttpSecurity http) throws Exception {


http
.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint((request, response, authException) -> response.sendError(HttpServletResponse.SC_UNAUTHORIZED))
.and()
.authorizeRequests()
.antMatchers("/test/**").authenticated()
.and()
.httpBasic();
}
@Bean
public TokenStore tokenStore(){


return new JwtTokenStore(jwtAccessTokenConverter());
}

@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter(){


JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
jwtAccessTokenConverter.setSigningKey(jwtKey);
return jwtAccessTokenConverter;
}
}

现在可以启动资源服务器,并使用前面从授权服务器获得的有效JWT调用我们保护的资源。在这个示例中,必须将令牌添加到以”Bearer”为前缀的请求的Authorization HTTP头信息中:

那么已上就是对称密钥对令牌进行签名和验证。

使用通过JWT和非对称密钥签名的令牌

本节将实现OAuth2身份验证的一个示例,其中授权服务器和资源服务器会使用一个非对称密钥对来对令牌签名和验证令牌。有时只让授权服务器和资源服务器共享一个密钥的做法是不可行的。通常,如果授权服务器和资源服务器不是由同一组织开发的,就会发生这种情况。在这种情况下,就可以认为授权服务器不“信任:资源服务器,因此我们不希望授权服务器与资源服务器共享密钥。而且,使用对称密钥,资源服务器就拥有了过多的功能:不仅可以验证令牌,还可以对它们签名(这种情况示例见下图):

ps:对称密钥是私钥。有该密钥的人可以用它进入系统,所以请永远不要在不安全通道交换对称密钥。如果需要在系统之外共享密钥,它就不应该是对称的
当我们不能在授权服务器和资源服务器之间认定一种可信的关系时,就要使用非对称密钥对。由于这个原因,我们就需要知道如何实现这样的系统。

什么是非对称密钥对?它是如何工作的?这个概念很简单。非对称密钥对有两个密钥:一个称为私钥,另一个称为公钥。授权服务器将使用私钥对令牌进行签名,而其他人也只能使用私钥对令牌进行签名。

公钥与私钥是结合在一起的,这就是我们将其称为一对的原因。但是公钥只能用于验证签名。没有人可以使用公钥对令牌进行签名(见下图)

1、生成密钥对

本节将讲解如何生成一个非对称密钥对。这里需要一个密钥对用来配置后面实现的授权服务器和资源服务器。这是一个非对称密钥对(这意味着授权服务器使用它的私钥签署令牌,而资源服务器则使用公钥验证签名)。为了生成该密钥对,这里使用了keytool和OpenSSL,它们是两个简单易用的命令行工具。Java的JDK会安装keytool,所以我们的计算机一般都安装了它。而对于OpenSSL.则需要从官网处下载它。如果使用OpenSSL自带的Git Bash,则不需要单独安装它。有了工具之后,需要运行两个命令:

  • 生成一个私钥
  • 获取之前所生成私钥的公钥

1.1、生成一个私钥

要生成私钥,可以运行下面代码片段中的keytool命令。它将在名为mbw.jks的文件中生成一个私钥。这里还是用了密码”mbw123”保护私钥,并且使用别名”mbw”为密钥指定一个名称。在下面的命令中,可以看到用来生成密钥的算法,即RSA。

1
keytool -genkeypair -alias mbw -keyalg RSA -keypass mbw123 -keystore mbw.jks -storepass mbw123

运行后,回答相关问题生成你的dn后输入y,在keytool.exe所在文件夹下就生成了mbw.jks文件:

1.2、获取公钥

要获取先前所生成私钥的公钥,可以运行这个keytool命令:

1
keytool -list -rfc --keystore mbw.jks | openssl x509 -inform pem -pubkey

在生成公钥时,系统会提示我们输入密码;这里使用的是mbw123.然后就可以在输出中找到公钥和证书。(对于本示例而言,只有密钥的值是必要的。)这个密钥应该类似于下面的代码片段:

1
2
3
4
5
6
7
8
9
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAo7HHwNVxcW6iYwyzqbMt
awSkqiARgh6pz5eArBa1KzG3dCH8pupONozjD0G+PCAEu/hCwxEYglLHVLfcuQil
8b8rWuGXVZgA+VHohEEO5KHibKqazGpJGSDQkJG6VNnfuMasZ7DUeQpyyUI6RRkz
CSU7NuQAHHX5J9QEtrEAodv4Mpla1sKTraxMxjb3+BUtTFvW4iSr9fNJWyUsoxDs
6mOhRYEOUUiBl2P8USm7HK7M7rWy90BRG7hOFvKKjXiyM+d2uRN6ASOWVQ168ZcD
0iOdUeBF4sNowZWqWoY4DOiLp0bFRQIDkKuwxGvwrBRNA/K2J+HY0Jz2ULXhdGlU
iQIDAQAB
-----END PUBLIC KEY-----

就是这样!现在我们有了一个用于JWT签名的私钥和一个用于验证签名的公钥。接下来只需要在授权和资源服务器中配置它们即可。

1.3、实现使用私钥的授权服务器

本节要将授权服务器配置为使用私钥签名JWT。这里首先我们将之前的mbw.jks复制到应用程序的resources文件夹中。需要将密钥添加到resources文件夹中,因为直接从类路径读取它会更容易。但是,将其放入类路径中的做法并不是强制的。在application.yaml文件中,存储了文件名、密钥的别名,以及用于保护私钥而生成的密码。我们需要这些详细信息用来配置JwtTokenStore.下面代码片段展示了yaml文件中的内容:

1
2
3
password: mbw123
privateKey: mbw.jks
alias: mbw

与之前使用对称密钥相比,唯一更改的是JwtAccessTokenConverter对象的定义。这里仍然使用JwtTokenStore,我们仍将使用JwtAccessTokenConverter对象设置私钥。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
import com.mbw.security.service.ClientDetailsServiceImpl;
import com.mbw.security.service.UserDetailsServiceImpl;
import com.mbw.security.token.JsonRedisTokenStore;
import com.mbw.security.token.enhancer.CustomTokenEnhancer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.token.DefaultTokenServices;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
import org.springframework.security.oauth2.provider.token.TokenEnhancerChain;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
import org.springframework.security.oauth2.provider.token.store.KeyStoreKeyFactory;

import java.util.ArrayList;
import java.util.List;
@Configuration
@EnableAuthorizationServer
public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {


@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private ClientDetailsServiceImpl clientDetailsServiceImpl;
@Autowired
private UserDetailsServiceImpl userDetailsServiceImpl;
@Autowired
private CustomTokenEnhancer customTokenEnhancer;
@Autowired
private JsonRedisTokenStore jsonRedisTokenStore;

@Value("${jwt.key}")
private String jwtKey;

@Value("${privateKey}")
private String privateKey;

@Value("${password}")
private String password;

@Value("${alias}")
private String alias;

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {


endpoints.authenticationManager(authenticationManager)
.userDetailsService(userDetailsServiceImpl)
.tokenEnhancer(tokenEnhancerChain())
.tokenStore(tokenStore());
DefaultTokenServices tokenService = getTokenStore(endpoints);
endpoints.tokenServices(tokenService);
}

@Bean
public TokenEnhancerChain tokenEnhancerChain() {


TokenEnhancerChain enhancerChain = new TokenEnhancerChain();
List<TokenEnhancer> enhancers = new ArrayList<>();
enhancers.add(jwtAccessTokenConverter());
enhancers.add(customTokenEnhancer);
enhancerChain.setTokenEnhancers(enhancers);//将自定义Enhancer加入EnhancerChain的delegates数组中
return enhancerChain;
}

//配置TokenService参数
private DefaultTokenServices getTokenStore(AuthorizationServerEndpointsConfigurer endpoints) {


DefaultTokenServices tokenService = new DefaultTokenServices();
tokenService.setTokenStore(endpoints.getTokenStore());
tokenService.setSupportRefreshToken(true);
tokenService.setClientDetailsService(endpoints.getClientDetailsService());
tokenService.setTokenEnhancer(endpoints.getTokenEnhancer());
//token有效期 1小时
tokenService.setAccessTokenValiditySeconds(3600);
//token刷新有效期 15天
tokenService.setRefreshTokenValiditySeconds(3600 * 12 * 15);
tokenService.setReuseRefreshToken(false);
return tokenService;
}

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {


clients.withClientDetails(clientDetailsServiceImpl);
}
/**
* 解决访问/oauth/check_token 403的问题
*
* @param security
* @throws Exception
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {


// 允许表单认证
security
.tokenKeyAccess("permitAll()")
.checkTokenAccess("permitAll()")
.allowFormAuthenticationForClients();

}

@Bean
public TokenStore tokenStore() {


return new JwtTokenStore(jwtAccessTokenConverter());
}

@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {


JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource(privateKey), password.toCharArray());
jwtAccessTokenConverter.setKeyPair(keyStoreKeyFactory.getKeyPair(alias));
return jwtAccessTokenConverter;
}
}

现在可以启动该授权服务器并调用/oauth/token端点来生成一个新的访问令牌。当然,这里只是创建了一个普通的JWT,但是现在的区别是,要验证它的签名,需要使用密钥对中的公钥。

如果遇到启动报错说找不到jks文件的,把idea关了重启就好了,我idea2019.3是这样处理成功的,下面是postman的运行截图:

然后若现在我们使用这个token去请求资源服务器,肯定会失败的:

所以我们现在要去资源服务器配置公钥,在配置公钥之前,我们先把授权服务器的这行代码删除

原因之后会讲到。

首先在yaml文件配置公钥:

1
2
3
jwt:
key: MjWP5L7CiD
publicKey: -----BEGIN PUBLIC KEY-----MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAo7HHwNVxcW6iYwyzqbMtawSkqiARgh6pz5eArBa1KzG3dCH8pupONozjD0G+PCAEu/hCwxEYglLHVLfcuQil8b8rWuGXVZgA+VHohEEO5KHibKqazGpJGSDQkJG6VNnfuMasZ7DUeQpyyUI6RRkzCSU7NuQAHHX5J9QEtrEAodv4Mpla1sKTraxMxjb3+BUtTFvW4iSr9fNJWyUsoxDs6mOhRYEOUUiBl2P8USm7HK7M7rWy90BRG7hOFvKKjXiyM+d2uRN6ASOWVQ168ZcD0iOdUeBF4sNowZWqWoY4DOiLp0bFRQIDkKuwxGvwrBRNA/K2J+HY0Jz2ULXhdGlUiQIDAQAB-----END PUBLIC KEY-----

然后在资源服务器这块儿对公钥进行配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;

import javax.servlet.http.HttpServletResponse;
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {



@Value("${jwt.publicKey}")
private String publicKey;

@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {


resources.tokenStore(tokenStore());
}

@Override
public void configure(HttpSecurity http) throws Exception {


http
.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint((request, response, authException) -> response.sendError(HttpServletResponse.SC_UNAUTHORIZED))
.and()
.authorizeRequests()
.antMatchers("/test/**").authenticated()
.and()
.httpBasic();
}
@Bean
public TokenStore tokenStore(){


return new JwtTokenStore(jwtAccessTokenConverter());
}

@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter(){


JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
jwtAccessTokenConverter.setVerifierKey(publicKey);
return jwtAccessTokenConverter;
}
}

然后启动资源服务器测试被保护的资源:

2.1、使用一个暴露公钥的端点

本节将讨论一种让资源服务器获知公钥的方法–用授权服务器暴露公钥。还记得我们上一节将授权服务器删掉的那一行代码吗,这不就来了?在上一节中,我们使用了公私密钥对来对令牌进行签名和验证。其中在资源服务器配置了公钥。资源服务器使用公钥验证JWT。但是,如果想更改密钥时,会发生什么情况呢》最好不要永远保持同一份密钥对,这就是本节要实现的内容。随着时间的推移,应该定期旋转密钥!这将使得系统不容易受到密钥失窃的影响。

到目前为止,我们已经在授权服务器端配置了私钥,在资源服务器配置了公钥。而将密钥设置在两个地方使得密钥更难管理。不过如果只在一端配置它们,则可以更容易地管理键。解决方案是将整个密钥对迁至授权服务器端,并允许授权服务器使用端点暴露公钥。

那么下面就进入开发
对于授权服务器,我们保持和之前一样的配置即可,只需要能确保能够访问端点即可,也就是暴露公钥。的确,Spring Boot已经配置了这样的端点,但也只是这样而已。默认情况下,对该端点的所有请求都会被拒绝。我们需要重写该端点的配置,并允许任何具有客户端凭据的人访问它。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
import com.mbw.security.service.ClientDetailsServiceImpl;
import com.mbw.security.service.UserDetailsServiceImpl;
import com.mbw.security.token.JsonRedisTokenStore;
import com.mbw.security.token.enhancer.CustomTokenEnhancer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.token.DefaultTokenServices;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
import org.springframework.security.oauth2.provider.token.TokenEnhancerChain;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
import org.springframework.security.oauth2.provider.token.store.KeyStoreKeyFactory;

import java.util.ArrayList;
import java.util.List;
@Configuration
@EnableAuthorizationServer
public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {


@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private ClientDetailsServiceImpl clientDetailsServiceImpl;
@Autowired
private UserDetailsServiceImpl userDetailsServiceImpl;
@Autowired
private CustomTokenEnhancer customTokenEnhancer;
@Autowired
private JsonRedisTokenStore jsonRedisTokenStore;

@Value("${jwt.privateKey}")
private String privateKey;

@Value("${jwt.password}")
private String password;

@Value("${jwt.alias}")
private String alias;

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {


endpoints.authenticationManager(authenticationManager)
.userDetailsService(userDetailsServiceImpl)
.tokenEnhancer(tokenEnhancerChain())
.tokenStore(tokenStore());
DefaultTokenServices tokenService = getTokenStore(endpoints);
endpoints.tokenServices(tokenService);
}

@Bean
public TokenEnhancerChain tokenEnhancerChain() {


TokenEnhancerChain enhancerChain = new TokenEnhancerChain();
List<TokenEnhancer> enhancers = new ArrayList<>();
enhancers.add(jwtAccessTokenConverter());
enhancers.add(customTokenEnhancer);
enhancerChain.setTokenEnhancers(enhancers);//将自定义Enhancer加入EnhancerChain的delegates数组中
return enhancerChain;
}

//配置TokenService参数
private DefaultTokenServices getTokenStore(AuthorizationServerEndpointsConfigurer endpoints) {


DefaultTokenServices tokenService = new DefaultTokenServices();
tokenService.setTokenStore(endpoints.getTokenStore());
tokenService.setSupportRefreshToken(true);
tokenService.setClientDetailsService(endpoints.getClientDetailsService());
tokenService.setTokenEnhancer(endpoints.getTokenEnhancer());
//token有效期 1小时
tokenService.setAccessTokenValiditySeconds(3600);
//token刷新有效期 15天
tokenService.setRefreshTokenValiditySeconds(3600 * 12 * 15);
tokenService.setReuseRefreshToken(false);
return tokenService;
}

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {


clients.withClientDetails(clientDetailsServiceImpl);
}
/**
* 解决访问/oauth/check_token 403的问题
*
* @param security
* @throws Exception
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {


// 允许表单认证
security
.tokenKeyAccess("isAuthenticated()")
.checkTokenAccess("permitAll()")
.allowFormAuthenticationForClients();

}

@Bean
public TokenStore tokenStore() {


return new JwtTokenStore(jwtAccessTokenConverter());
}

@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {


JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource(privateKey), password.toCharArray());
jwtAccessTokenConverter.setKeyPair(keyStoreKeyFactory.getKeyPair(alias));
return jwtAccessTokenConverter;
}
}

现在可以启动该授权服务器并调用/oauth/token_key端点来确保正确实现了配置。下面postman展示了其调用,我们需要使用Basic Auth输入资源服务器曾经注册的账号进行认证:

为了让资源服务器可以使用此端点并获得公钥,只需要在其属性文件中配置该端点和凭据即可。下面的配置代码展示了资源服务器的yaml新增配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
server:
port: 9091
spring:
datasource:
driver-class-name: com.mysql.jdbc.Driver
username: root
password: 123456
url: jdbc:mysql://127.0.0.1:3306/spring_security?useUnicode=true&characterEncoding=UTF-8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&nullCatalogMeansCurrent=true
initialization-mode: always
redis配置
redis:
host: 127.0.0.1
password: 123456
port: 6379
jwt:
key: MjWP5L7CiD
security:
oauth2:
resource:
jwt:
key-uri: http://localhost:9090/oauth/token_key
client:
client-id: resourceServer
client-secret: resourceServerSecret

因为资源服务器现在从授权服务器的/oauth/token_key端点获取公钥,所以不需要在资源服务器配置类中配置它。资源服务器只需要配置保护的资源即可,如下面代码片段所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;

import javax.servlet.http.HttpServletResponse;
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {



@Override
public void configure(HttpSecurity http) throws Exception {


http
.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint((request, response, authException) -> response.sendError(HttpServletResponse.SC_UNAUTHORIZED))
.and()
.authorizeRequests()
.antMatchers("/test/**").authenticated()
.and()
.httpBasic();
}
}

现在启动资源服务器,带着令牌访问被保护资源: