Spring Security in Action 第十八章 手把手OAuth2应用

本专栏将从基础开始,循序渐进,以实战为线索,逐步深入SpringSecurity相关知识相关知识,打造完整的SpringSecurity学习步骤,提升工程化编码能力和思维能力,写出高质量代码。希望大家都能够从中有所收获,也请大家多多支持。
专栏地址:SpringSecurity专栏
本文涉及的代码都已放在gitee上:gitee地址
如果文章知识点有错误的地方,请指正!大家一起学习,一起进步。
专栏汇总:专栏汇总


本章包括

  • 配置Keycloak作为OAuth 2的授权服务器
  • 在OAuth 2资源服务器中使用全局方法安全性

在第12章到第15章,我们详细讨论了OAuth 2系统是如何工作的,以及如何用Spring Security实现一个系统。然后我们改变了主题,在第16章和第17章中,你学到了如何使用全局方法安全在应用程序的任何一层应用授权规则。在本章中,我们将结合这两个基本主题,在一个OAuth 2资源服务器中应用全局方法安全。

除了在我们的资源服务器实现的不同层定义授权规则外,你还将学习如何使用一个名为Keycloak的工具作为你系统的授权服务器。这一章我们要研究的例子对我们有帮助,原因如下:

  • 系统经常使用第三方工具,如Keycloak在现实世界中的实施,以定义一个认证的抽象层。
  • 你很可能需要在你的OAuth 2实施中使用Keycloak或类似的第三方工具。你会发现有许多可能替代Keycloak的工具,如Okta、Auth0和LoginRadius。本章重点讨论一个场景,即你需要在你开发的系统中使用这样一个工具。
  • 在现实世界的场景中,我们不仅对api使用授权,也对应用程序的其他层使用授权。而这也发生在OAuth 2系统中。
  • 你将对我们所讨论的技术和方法的全貌有更好的理解。为了做到这一点,我们将再次使用一个例子来加强你在第12章到第17章中所学到的知识。

让我们深入到下一节,了解我们将在这一实训章中实现的应用程序的情景。

18.1 应用场景

假设我们需要为一个健身应用建立一个后端。除了其他伟大的功能外,该应用还存储了用户的锻炼历史。在本章中,我们将重点讨论应用程序中存储锻炼历史的部分。我们假定我们的后端需要实现三个用例。对于用例所定义的每个动作,我们都有特殊的安全限制(图18.1)。

image-20230131174231580

图18.1 无论是锻炼历史还是银行账户,应用程序都需要实施适当的授权规则,以保护用户数据不被盗用或不需要的更改。

这三个用例是这样的:

  • 为一个用户添加一个新的锻炼记录。在一个名为workout的数据库表中,我们添加一条新的记录,存储用户、锻炼的开始和结束时间,以及锻炼的难度,用1到5的整数表示。 这个用例的授权限制断言,经过认证的用户只能为自己添加锻炼记录。客户端调用资源服务器暴露的一个api来添加一个新的锻炼记录。
  • 找到一个用户的所有锻炼。客户端需要显示用户历史中的锻炼列表。客户端调用一个api来检索该列表。这种情况下的授权限制规定,用户只能获得自己的锻炼记录。
  • 删除一个锻炼。任何具有管理员角色的用户都可以删除任何其他用户的锻炼。客户端调用一个api来删除一个锻炼记录。授权限制说,只有管理员可以删除记录。

我们需要实现三个用例,为此我们有两个代理角色。这两个角色是标准用户:fitnessuser,和管理员:fitnessadmin。一个健身者可以为自己添加锻炼,并可以看到自己的锻炼历史。一个健身管理员能删除任何用户的锻炼记录。当然,管理员也可以是一个用户,在这种情况下,他们也可以为自己添加锻炼,或看到自己的锻炼记录。我们用这三个用例实现的后端是一个OAuth 2资源服务器(图18.2)。我们还需要一个授权服务器。

image-20230131174725246

图18.2 系统中的角色是用户、客户端、授权服务器和资源服务器。我们使用Keycloak来配置授权服务器,并使用Spring Security来实现资源服务器。

在这个例子中,我们使用一个名为Keycloak的工具来配置系统的授权服务器。Keycloak提供了所有的可能性来设置我们的用户,无论是在本地还是通过与其他用户管理服务的整合。

我们通过配置一个本地Keycloak实例作为我们的授权服务器来开始实现。然后我们实现资源服务器并使用Spring Security设置授权规则。一旦我们有了一个工作的应用程序,我们就用cURL调用api来测试它。

18.2 配置Keycloak作为授权服务器

在这一部分,我们将Keycloak配置为系统的授权服务器(图18.3)。Keycloak是一个优秀的开源工具,用于身份和访问管理。你可以从keycloak.org下载Keycloak。Keycloak提供了在本地管理简单用户的能力,同时也提供了高级功能,如用户联盟。你可以把它连接到你的LDAP和活动目录服务或不同的身份提供者。例如,你可以把Keycloak作为一个高级的认证层,把它连接到我们在第12章讨论的常见的OAuth 2提供者之一。

image-20230131174913148

图18.3 作为我们在本章实现的实践应用的一部分,我们遵循三个主要步骤。在本节中,作为第一步,我们将Keycloak配置为系统的授权服务器。

Keycloak的配置很灵活,尽管它可以变得很复杂,这取决于你想实现什么。在本章中,我们只讨论我们的例子需要做的设置。我们的设置只定义了几个用户和他们的角色。但Keycloak能做的远不止这些。如果你打算在现实世界的场景中使用Keycloak,我建议你首先阅读其官方网站上的详细文档:https:// www.keycloak.org/documentation。在Ken Finnigan(Manning,2018)的《企业Java微服务》第9章中,你也可以找到关于确保微服务安全的很好的讨论,作者使用Keycloak进行用户管理。链接如下:

https://livebook.manning.com/book/enterprise-java-microservices/chapter-9

(如果你喜欢讨论微服务,我建议你阅读Ken Finnigan的整本书。作者对任何用Java实现微服务的人都应该知道的主题提供了卓越的见解)。

要安装Keycloak,你只需要从官方网站https://www.keycloak.org/downloads,下载一个包含最新版本的存档。然后,将存档解压到一个文件夹中,你可以使用独立的可执行文件启动Keycloak,你可以在bin文件夹中找到这个文件。如果你使用的是Linux,你需要运行stand- alone.sh。对于Windows,你运行standalone.bat。

一旦你启动了Keycloak服务器,在浏览器中访问它,http://localhost:8080。在Keycloak的第一个页面,你通过输入用户名和密码来配置一个管理账户(图18.4)。

image-20230131175149351

图18.4 要管理Keycloak,你首先需要设置你的管理员凭证。你可以在第一次启动Keycloak时访问它来完成这项工作。

image-20230131175222061

图18.5 一旦你设置了你的管理账户,你就可以使用你刚刚设置的凭证登录Keycloak的管理控制台。

这就是了。你成功地设置了你的管理凭证。此后,你用你的凭证登录,管理Keycloak,如图18.5所示。

image-20230131175401016

图18.6 你可以通过点击OpenID api配置链接找到与授权服务器相关的api。你需要这些端点来获取访问令牌和配置资源服务器。

在下一个代码片断中,我提取了OAuth 2配置的一部分,你可以通过点击OpenID api配置链接找到。这个配置提供了令牌端点、授权端点和支持的授予类型列表。 这些细节你应该很熟悉,因为我们在第12章到第15章中讨论过它们。

{
    
    
"issuer":
"http://localhost:8080/auth/realms/master",
"authorization_endpoint":
"http://localhost:8080/auth/realms/master/
➥protocol/openid-connect/auth",
"token_endpoint":
"http://localhost:8080/auth/realms/master/
➥protocol/openid-connect/token",
"jwks_uri":
"http://localhost:8080/auth/realms/master/protocol/
➥openid-connect/certs",
"grant_types_supported":[
"authorization_code",
"implicit",
"refresh_token",
"password",
"client_credentials"
],
...
}

如果你配置了长期存在的访问令牌(图18.7),你可能会发现测试应用程序更舒服。然而,在真实世界的场景中,记住不要给你的令牌一个很长的生命期。例如,在生产系统中,一个令牌应该在几分钟内过期。但是为了测试,你可以让它活跃一天。你可以在图18.8所示的 "令牌 "标签中改变令牌的生存时间。

image-20230131175610210

图18.7 为了测试应用程序,我们手动生成访问令牌,用它来调用端点。如果你为令牌定义一个较短的寿命,你需要更频繁地生成它们,而且当令牌在你能使用它之前过期时,你可能会感到很恼火。

image-20230131175648003

图18.8 如果发出的访问令牌不会很快过期,你可能会发现测试更舒服。你可以在 "令牌 "选项卡中改变其寿命。

现在我们已经安装了Keycloak,设置了管理凭证,并做了一些调整,我们可以配置授权服务器了。这里有一个配置步骤的清单。

  1. 为系统注册一个客户端。一个OAuth 2系统至少需要一个被授权服务器认可的客户端。该客户端为用户发出认证请求。在第18.2.1节中,你将学习如何添加一个新的客户端注册。
  2. 定义一个客户范围。客户端范围确定了客户在系统中的目的。我们使用客户范围的定义来定制授权服务器发出的访问令牌。在第18.2.2节中,你将学习如何添加一个客户范围,在第18.2.4节中,我们将配置它来定制访问令牌。
  3. 为我们的应用程序添加用户。为了调用资源服务器上的api,我们需要为我们的应用程序添加用户。你将在第18.2.3节学习如何添加由Keycloak管理的用户。
  4. 定义用户角色和自定义访问令牌。添加完用户后,你可以为他们发放访问令牌。你会注意到,访问令牌并没有我们所需要的所有细节来完成我们的方案。你将学习如何为用户配置角色并定制访问令牌,以呈现我们将在第18.2.4节中使用Spring Security实现的资源服务器所期望的细节。

18.2.1 为我们的系统注册一个客户

在本节中,我们将讨论在使用Keycloak作为授权服务器时注册客户端的问题。像其他OAuth 2系统一样,我们需要在授权服务器层面注册客户端应用程序。为了添加一个新的客户端,我们使用Keycloak管理控制台。如图18.9所示,你可以通过导航到左侧菜单上的客户选项卡找到客户列表。从这里,你也可以添加一个新的客户注册。

image-20230131183128109

图18.9 要添加一个新的客户,你可以使用左侧菜单中的客户标签导航到客户列表。在这里你可以通过点击客户表右上角的创建按钮添加一个新的客户注册。

我添加了一个新的客户端,我命名为 fitnessapp。这个客户端代表允许从我们将在第18.3节实现的资源服务器中调用端点的应用程序。 图18.10显示了添加客户端的表单。

image-20230131183227512

图18.10 当添加一个客户时,你只需要给它分配一个唯一的客户ID(fitnessapp),然后点击保存。

18.2.2 指定客户范围

在这一节中,我们为我们在第18.2.1节中注册的客户端定义一个范围。客户端范围确定了客户端的目的。我们还将在第18.2.4节中使用客户端范围来定制由Keycloak签发的访问令牌。为了给客户端添加一个范围,我们再次使用Keycloak管理控制台。如图18.11所示,当从左侧菜单导航到客户端作用域标签时,你会发现一个客户端作用域的列表。在这里,你也可以向列表中添加一个新的客户范围。

对于我们在这个实战例子中构建的应用程序,我添加了一个新的客户端作用域,名为 fitnessapp。在添加一个新的作用域时,还要确保你设置的客户端作用域的协议是openid-connect(图18.12)。

image-20230131190223882

图18.12 当添加一个新的客户范围时,给它一个唯一的名字,并确保你为所需的协议定义它。在我们的例子中,我们想要的协议是openidconnect。

你可以选择的另一个协议是SAML 2.0。Spring Security曾经为这个协议提供了一个扩展,你仍然可以在https:// projects.spring.io/spring-security-saml/#quick-start上找到。我们不在本书中讨论使用SAML 2.0,因为Spring Security不再积极开发它了。另外,SAML 2.0在应用中比OAuth 2更少遇到。

一旦你创建了新角色,你就把它分配给你的客户,如图18.13所示。你可以通过导航到客户菜单,然后选择客户范围标签来进入这个屏幕。

image-20230131190504424

图 18.13 一旦你有了客户作用域,你就把它分配给客户。在这个图中,我已经把我需要的作用域移到了右边的框中,名为指定的默认客户作用域。这样,你现在就可以把定义好的作用域用在一个特定的客户身上。

18.2.3 添加用户和获得访问令牌

在这一节中,我们为我们的应用程序创建和配置用户。之前,我们在第18.2.1节和第18.2.2节中介绍了客户端和它的范围。但除了客户端应用程序,我们还需要用户来验证和访问我们的资源服务器所提供的服务。我们配置了三个用户,用来测试我们的应用程序(图18.14)。我把这些用户命名为Mary、Bill和Rachel。

image-20230131190722360

图18.14 通过从左边的菜单导航到用户选项卡,你会发现你的应用程序的所有用户的列表。在这里你也可以通过点击用户表右上角的添加用户来添加一个新用户。

在添加用户表格中添加一个新用户时,给它一个独特的用户名,并勾选说明电子邮件已被验证的方框(图18.15)。另外,要确保该用户没有 “需要的用户行动”。当一个用户的 "所需用户行动 "待定时,你不能用它来验证;因此,你不能为该用户获得访问令牌。

image-20230131190817256

图18.15 当添加一个新用户时,给用户一个唯一的用户名,并确保该用户没有必要的用户行为。

一旦你创建了用户,你应该在用户列表中找到所有的用户。图18.16展示了用户列表。

image-20230131190925377

图18.16 新创建的用户现在出现在用户列表中。你可以从这里选择一个用户来编辑或删除。

当然,用户也需要密码来登录。通常,他们会配置自己的密码,而管理员不应该知道他们的凭证。在我们的例子中,我们别无选择,只能自己为三个用户配置密码(图18.17)。 为了使我们的例子简单,我为所有用户配置了密码 “12345”。我还通过取消勾选临时复选框来确保密码不是临时的。如果你让密码是临时的,Keycloak会自动添加一个必要的动作,让用户在第一次登录时更改密码。由于这个必要的动作,我们将无法对用户进行认证。

image-20230131191144694

图18.17 你可以从列表中选择一个用户来改变或配置其凭证。在保存更改之前,记得要确保将临时复选框设置为关闭。如果凭证是临时的,你将无法在前面对用户进行认证。

在配置好用户后,你现在可以从你的用Keycloak实现的授权服务器获得一个访问令牌。下一个代码片断显示了如何使用密码授予类型获得令牌,以保持例子的简单。然而,正如你在第18.2.1节中所观察到的,Keycloak也支持第12章中所讨论的其他授予类型。图18.18是对我们在那里讨论的密码授予类型的复习。

要获得访问令牌,请调用授权服务器的/token端点:

curl -XPOST "http://localhost:8080/auth/realms/master/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=password" \
--data-urlencode "username=rachel" \
--data-urlencode "password=12345" \
--data-urlencode "scope=fitnessapp" \
--data-urlencode "client_id=fitnessapp"

image-20230131192128294

图18.18 当使用密码授予类型时,用户与客户端共享他们的凭证。客户端使用该凭证从授权服务器获得一个访问令牌。有了这个令牌,客户端就可以访问资源服务器所暴露的用户资源。

你在HTTP响应的正文中收到访问令牌。接下来的片段显示了该响应。

{
    
    
"access_token":"eyJhbGciOiJIUzI…",
"expires_in":6000,
"refresh_expires_in":1800,
"refresh_token":"eyJhbGciOiJIUz… ",
"token_type":"bearer",
"not-before-policy":0,
"session_state":"1f4ddae7-7fe0-407e-8314-a8e7fcd34d1b",
"scope":"fitnessapp"
}

下一个代码片断展示了JWT访问令牌的解码JSON体。看一眼这个代码片断,你可以发现这个令牌并不包含我们需要的所有细节,以使我们的应用程序工作。角色和用户名都不见了。在第18.2.4节中,你将学习如何为用户分配角色,并定制JWT以包含资源服务器需要的所有数据。

{
    
    
    "exp": 1585392296,
    "iat": 1585386296,
    "jti": "01117f5c-360c-40fa-936b-763d446c7873",
    "iss": "http://localhost:8080/auth/realms/master",
    "sub": "c42b534f-7f08-4505-8958-59ea65fb3b47",
    "typ": "Bearer",
    "azp": "fitnessapp",
    "session_state": "fce70fc0-e93c-42aa-8ebc-1aac9a0dba31",
    "acr": "1",
    "scope": "fitnessapp"
}

18.2.4 定义用户角色

在18.2.3节中,我们设法获得了一个访问令牌。我们还添加了一个客户端注册,并配置了用户来获取令牌。但是,令牌仍然没有提供我们的资源服务器在应用授权规则时需要的所有细节。为了给我们的方案写一个完整的应用程序,我们需要为我们的用户添加角色。

为一个用户添加角色很简单。左侧菜单中的 "角色 "选项卡允许你找到所有角色的列表并添加新的角色,如图18.19所示。我创建了两个新角色:fitnessuser和fitnessadmin。

image-20230131192610081

图18.19 通过访问左侧菜单中的 "角色 "选项卡,你可以找到所有已定义的角色,并可以创建新角色。然后你把它们分配给用户。

我们现在把这些角色分配给我们的用户。我把fitnessadmin这个角色分配给了我们的管理员Mary,而作为普通用户的Bill和Rachel则担任fitnessuser这个角色。 图18.20向你展示了如何把角色附加给用户。

image-20230131193153857

图18.20 在所选用户的角色映射部分,你分配了角色。这些角色映射作为用户的权限出现在访问令牌中,你用这些来实现授权配置。

不幸的是,在默认情况下,这些新的细节不会出现在访问令牌中。我们必须根据应用程序的要求来定制令牌。我们通过配置我们在第18.2.2节创建并分配给令牌的客户范围来定制令牌。我们还需要给我们的令牌添加三个细节

  • Roles—用于根据方案在api层应用部分授权规则
  • Username—当我们应用授权规则时,对数据进行过滤
  • Audience claim (aud)—由资源服务器用来确认请求,你将在第18.3节中学习。

下一个代码片断介绍了我们完成设置后添加到令牌中的字段。然后,我们通过在客户端范围上定义映射器来添加自定义请求,如图18.21所示。

{
    
    
    // ...
    "authorities": [
    "fitnessuser"
    ],
    "aud": "fitnessapp",
    "user_name": "rachel",
    // ...
}

image-20230131193657016

图 18.21 我们为特定的客户范围创建映射器来定制访问令牌。这样,我们提供了资源服务器授权请求所需的所有细节。

图18.22显示了如何创建一个映射器来将角色添加到令牌中。我们在令牌中添加带有密钥的角色,因为这是资源服务器所期望的方式。

image-20230131194022857

图 18.22 为了在访问令牌中添加角色,我们定义一个映射器。当添加一个映射器时,我们需要为它提供一个名称。我们还指定要添加到令牌中的细节,以及识别指定细节的名称。

通过与图18.22类似的方法,我们也可以定义一个映射器,将用户名添加到令牌中。图18.23显示了如何创建用户名的映射器(mapper)。

image-20230131194127832

图18.23 我们创建一个映射器,将用户名添加到访问令牌中。我们在资源服务器端配置相同的值,以便资源服务器接受该令牌。

为我们在第18.1节中提出的方案。下面的代码片断显示了令牌的主体。

image-20230131194716717

18.3 实现资源服务器

在这一节中,我们使用Spring Security来实现我们方案中的资源服务器。在18.2节中,我们将Keycloak配置为系统的授权服务器(图18.25)。

image-20230131224924678

图18.25 现在我们已经设置了Keycloak授权服务器,我们开始实战例子的下一步--实现资源服务器。

类的设计很简单(图18.26),基于三个层次:一个controller、一个service和一个资源库。我们为每一层实现授权规则。

image-20230131225225634

图 18.26 资源服务器的类设计。我们有三个层次:控制器、service和资源库。根据实现的用例,我们为其中一个层配置授权规则。

我们将依赖关系添加到pom.xml文件中。

<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.12.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.laurentiuspilca</groupId>
    <artifactId>ssia-ch18-ex1</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>ssia-ch18-ex1</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>11</java.version>
        <spring-cloud.version>Hoxton.SR3</spring-cloud.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-data</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

因为我们将细节存储在数据库中,所以我们也将schema.sql和data.sql文件添加到项目中。在这些文件中,我们把创建数据基础结构的SQL查询和一些数据放在一起,以便我们以后在测试应用程序时使用。我们只需要一个简单的表,所以我们的schema.sql文件只存储了创建这个表的查询。

CREATE TABLE IF NOT EXISTS `spring`.`workout` (
    `id` INT NOT NULL AUTO_INCREMENT,
    `user` VARCHAR(45) NULL,
    `start` DATETIME NULL,
    `end` DATETIME NULL,
    `difficulty` INT NULL,
PRIMARY KEY (`id`));

我们还需要在workout表中的一些记录来测试应用程序。为了增加这些记录,你在data.sql文件中写一些INSERT查询。

INSERT IGNORE INTO `spring`.`workout` (`id`, `user`, `start`, `end`, `difficulty`) VALUES (1, 'bill', '2020-06-10 15:05:05', '2020-06-10 16:10:07', '3');
INSERT IGNORE INTO `spring`.`workout` (`id`, `user`, `start`, `end`, `difficulty`) VALUES (2, 'rachel', '2020-06-10 15:05:10', '2020-06-10 16:10:20', '3');
INSERT IGNORE INTO `spring`.`workout` (`id`, `user`, `start`, `end`, `difficulty`) VALUES (3, 'bill', '2020-06-12 12:00:10', '2020-06-12 13:01:10', '4');
INSERT IGNORE INTO `spring`.`workout` (`id`, `user`, `start`, `end`, `difficulty`) VALUES (4, 'rachel', '2020-06-12 12:00:05', '2020-06-12 12:00:11', '4');

有了这四条INSERT语句,我们现在有几条用户Bill的锻炼记录和另外两条用户Rachel的锻炼记录可以在我们的测试中使用。在开始编写我们的应用逻辑之前,我们需要定义application.properties文件。我们已经有Keycloak授权服务器在8080端口运行,所以把资源服务器的端口改为9090。另外,在application.properties文件中,写入Spring Boot创建数据源所需的属性。接下来的代码片段显示了application.properties文件的内容。

server.port=9090

spring.datasource.url=jdbc:mysql://localhost/spring?useLegacyDatetimeCode=false&serverTimezone=GMT%2B8&useSSL=false
spring.datasource.username=root
spring.datasource.password=
spring.datasource.initialization-mode=always

现在,让我们首先实现JPA实体和Spring Data JPA资源库。下一个列表介绍了名为Workout的JPA实体类。

清单18.1 workout类

package com.laurentiuspilca.ssia.entities;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import java.time.LocalDateTime;

@Entity
public class Workout {
    
    

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;
    private String user;
    private LocalDateTime start;
    private LocalDateTime end;
    private int difficulty;

    public int getId() {
    
    
        return id;
    }

    public void setId(int id) {
    
    
        this.id = id;
    }

    public String getUser() {
    
    
        return user;
    }

    public void setUser(String user) {
    
    
        this.user = user;
    }

    public LocalDateTime getStart() {
    
    
        return start;
    }

    public void setStart(LocalDateTime start) {
    
    
        this.start = start;
    }

    public LocalDateTime getEnd() {
    
    
        return end;
    }

    public void setEnd(LocalDateTime end) {
    
    
        this.end = end;
    }

    public int getDifficulty() {
    
    
        return difficulty;
    }

    public void setDifficulty(int difficulty) {
    
    
        this.difficulty = difficulty;
    }
}

在清单18.2中,你可以找到Workout实体的Spring Data JPA资源库接口。在这里,在资源库层,我们定义了一个方法来从数据库中检索特定用户的所有锻炼记录。正如你在第17章中学到的,我们没有使用@PostFilter,而是选择在查询中直接应用约束。

清单18.2 WorkoutRepository接口

package com.laurentiuspilca.ssia.repositories;

import com.laurentiuspilca.ssia.entities.Workout;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.util.List;

public interface WorkoutRepository extends JpaRepository<Workout, Integer> {
    
    
	//一个SpEL表达式从安全上下文中检索出认证的用户名的值。
    @Query("SELECT w FROM Workout w WHERE w.user = #{authentication.name}")
    default List<Workout> findAllByUser() {
    
    
        return null;
    }
}

因为我们现在有一个资源库,我们可以继续实现名为WorkoutService的服务类。清单18.3展示了WorkoutService类的实现。控制器直接调用这个类的方法。根据我们的方案,我们需要实现三个方法:

  • saveWorkout()—在数据库中添加一个新的锻炼记录
  • findWorkouts()—检索一个用户的锻炼记录
  • deleteWorkout()—删除一个给定ID的锻炼记录

清单18.3 WorkoutService类

package com.laurentiuspilca.ssia.service;

import com.laurentiuspilca.ssia.entities.Workout;
import com.laurentiuspilca.ssia.repositories.WorkoutRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class WorkoutService {
    
    

    @Autowired
    private WorkoutRepository workoutRepository;

   //通过预授权,确保在锻炼记录不属于用户的情况下不调用该方法
    @PreAuthorize("#workout.user == authentication.name")
    public void saveWorkout(Workout workout) {
    
    
        workoutRepository.save(workout);
    }

    //对于这种方法,我们已经在资源库层应用了过滤。
    public List<Workout> findWorkouts() {
    
    
        return workoutRepository.findAllByUser();
    }

    //在控制层对该方法进行授权。
    public void deleteWorkout(Integer id) {
    
    
        workoutRepository.deleteById(id);
    }
}

你可能会想,为什么我选择像你在例子中看到的那样精确地实现授权规则,而不是用不同的方式。对于deleteWorkout()方法,为什么我在端点层面而不是在服务层写授权规则?对于这个用例,我选择这样做是为了涵盖更多配置授权的方式。如果我在服务层设置删除工作的授权规则,那就和之前的例子一样了。而且,在一个更复杂的应用中,比如在一个真实世界的应用中,你可能会有一些限制,迫使你选择一个特定的层。

controller类只定义了api,这些api进一步调用服务方法。下面列出了控制器类的实现。

清单18.4 WorkoutController类

package com.laurentiuspilca.ssia.controller;

import com.laurentiuspilca.ssia.entities.Workout;
import com.laurentiuspilca.ssia.service.WorkoutService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/workout")
public class WorkoutController {
    
    

    @Autowired
    private WorkoutService workoutService;

    @PostMapping("/")
    public void add(@RequestBody Workout workout) {
    
    
        workoutService.saveWorkout(workout);
    }

    @GetMapping("/")
    public List<Workout> findAll() {
    
    
        return workoutService.findWorkouts();
    }

    @DeleteMapping("/{id}")
    public void delete(@PathVariable Integer id) {
    
    
        workoutService.deleteWorkout(id);
    }
}

为了拥有一个完整的应用程序,我们需要定义的最后一件事是配置类。我们需要选择资源服务器验证授权服务器发出的令牌的方式。我们在第14章和第15章讨论了三种方法:

  • 通过直接调用授权服务器
  • 使用blackboarding的方法
  • 有了加密的签名

因为我们已经知道授权服务器会发布JWT,所以最舒服的选择是依靠令牌的加密签名。正如你在第15章所知道的,我们需要向资源服务器提供验证签名的密钥。幸运的是,Keycloak提供了一个公开密钥的api:

http://localhost:8080/auth/realms/master/protocol/openid-connect/certs

我们把这个URI和我们在application.properties文件中对token设置的aud claim的值一起加入:

server.port=9090

spring.datasource.url=jdbc:mysql://localhost/spring?useLegacyDatetimeCode=false&serverTimezone=GMT%2B8&useSSL=false
spring.datasource.username=root
spring.datasource.password=
spring.datasource.initialization-mode=always

claim.aud=fitnessapp
jwkSetUri=http://localhost:8080/auth/realms/master/protocol/openid-connect/certs

现在我们可以编写配置文件了。为此,下面的列表显示了我们的configuration类。

清单18.5 资源服务器配置类

package com.laurentiuspilca.ssia.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.data.repository.query.SecurityEvaluationContextExtension;
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.jwk.JwkTokenStore;

@Configuration
@EnableResourceServer
//启用全局方法安全预/后注解
@EnableGlobalMethodSecurity(prePostEnabled = true)
//扩展ResourceServerConfigurerAdapter以定制资源服务器配置。
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
    
    

    //从上下文中注入钥匙的URI和审计要求的值
    @Value("${claim.aud}")
    private String claimAud;

    @Value("${jwkSetUri}")
    private String urlJwk;

    //设置令牌存储和审计要求的预期值
    @Override
    public void configure(ResourceServerSecurityConfigurer resources) {
    
    
        resources.tokenStore(tokenStore());
        resources.resourceId(claimAud);
//        resources.expressionHandler(handler());
    }

    //创建TokenStore Bean,根据在提供的URI上找到的密钥来验证令牌。
    @Bean
    public TokenStore tokenStore() {
    
    
        return new JwkTokenStore(urlJwk);
    }


}

为了创建一个TokenStore的实例,我们使用一个叫做JwkTokenStore的实现。这个实现使用了一个api,我们可以在那里公开多个密钥。为了验证一个令牌,JwkTokenStore寻找一个特定的密钥,其ID需要存在于所提供的JWT令牌的头部(图18.27)。

注意 记住,我们在本章开始时从Keycloak的端点采取了/openid-connect/certs的路径,即Keycloak暴露了密钥。你可能会发现其他工具对这个api使用不同的路径。

image-20230131233435502

图18.27 授权服务器使用私钥来签署令牌。当它签署令牌时,授权服务器还在令牌头中添加了一个密钥对的ID。为了验证令牌,资源服务器调用授权服务器的一个api,并获得令牌头中的ID的公钥。资源服务器使用该公钥来验证令牌签名。

如果你调用keys URI,你会看到类似于下一个代码段的东西。在HTTP响应体中,你有多个key。每个key都有多个属性,包括key的唯一ID。属性kid代表JSON响应中的key ID。

image-20230131233653639

JWT需要指定哪个密钥ID用于签署令牌。资源服务器需要在JWT头中找到该密钥ID。如果你像我们在第18.2节所做的那样用我们的资源服务器生成一个令牌,并对令牌的头进行解码,你可以看到令牌包含预期的密钥ID。在下一个代码片断中,你可以看到用我们的Keycloak授权服务器生成的令牌的公钥。

{
    
    
    "alg": "RS256",
    "typ": "JWT",
    "kid": "LHOsOEQJbnNbUn8PmZXA9TUoP56hYOtc3VOk0kUvj5U"
}

为了完成我们的配置类,让我们添加端点层的授权规则和SecurityEvaluationContextExtension。我们的应用程序需要这个扩展来注入我们在存储库层使用的SpEL表达式中的 authentication.name。最终的配置类看起来就像下面列出的那样。

package com.laurentiuspilca.ssia.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.data.repository.query.SecurityEvaluationContextExtension;
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.jwk.JwkTokenStore;

@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
    
    

    @Value("${claim.aud}")
    private String claimAud;

    @Value("${jwkSetUri}")
    private String urlJwk;

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) {
    
    
        resources.tokenStore(tokenStore());
        resources.resourceId(claimAud);
//        resources.expressionHandler(handler());
    }

    @Bean
    public TokenStore tokenStore() {
    
    
        return new JwkTokenStore(urlJwk);
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
    
    
        //在api水平上应用授权规则
        http.authorizeRequests()
                .mvcMatchers(HttpMethod.DELETE, "/**").hasAuthority("fitnessadmin")
                .anyRequest().authenticated();
    }

    //向Spring上下文添加一个SecurityEvaluationContextExtension Bean。
    @Bean
    public SecurityEvaluationContextExtension securityEvaluationContextExtension() {
    
    
        return new SecurityEvaluationContextExtension();
    }


}

使用OAuth 2网络安全表达式

在大多数情况下,使用普通的表达式来定义授权规则就足够了。 Spring Security允许我们轻松地引用authority、角色和用户名。但对于OAuth 2资源服务器,我们有时需要引用该协议特有的其他值,如客户端角色或范围。虽然JWT令牌包含这些细节,但我们不能用SpEL表达式直接访问它们,也不能在我们定义的授权规则中快速使用它们。

幸运的是,Spring Security为我们提供了通过添加与OAuth 2直接相关的条件来增强SpEL表达式的可能性。为了使用这样的SpEL表达式,我们需要配置一个SecurityExpressionHandler。允许我们用OAuth 2的特定元素来增强我们的授权表达式的SecurityExpressionHandler实现是OAuth2WebSecurityExpressionHandler。 为了配置它,我们改变配置类,如下面的代码片断中所示。

@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ResourceServerConfig
        extends ResourceServerConfigurerAdapter {
     
     
    // Omitted code
    public void configure(ResourceServerSecurityConfigurer resources) {
     
     
        resources.tokenStore(tokenStore());
        resources.resourceId(claimAud);
        resources.expressionHandler(handler());
    }

    @Bean
    public SecurityExpressionHandler<FilterInvocation> handler() {
     
     
        return new OAuth2WebSecurityExpressionHandler();
    }
}

有了这样一个表达式处理程序,你可以写出这样的表达式。

@PreAuthorize(
        "#workout.user == authentication.name and
        #oauth2.hasScope('fitnessapp')")
        public void saveWorkout(Workout workout) {
     
     
        workoutRepository.save(workout);
        }

观察我在@PreAuthorize注解中添加的条件,它检查客户端范围#oauth2.hasScope(‘fitnessapp’)。现在你可以添加这样的表达式,由我们添加到配置中的OAuth2WebSecurityExpressionHandler进行评估。你也可以在表达式中使用clientHasRole()方法,而不是hasScope()来测试客户端是否有一个特定的角色。注意,你可以使用客户端角色与客户端凭证授予类型。

18.4 测试应用程序

现在我们有了一个完整的系统,我们可以运行一些测试来证明它能如愿工作(图18.28)。在这一节中,我们同时运行我们的授权和资源服务器,并使用cURL来测试实现的行为。

image-20230201094557394

图18.28 你到了顶层!这是实现本章实践应用的最后一步。现在我们可以对系统进行测试,证明我们所配置和实现的东西是按预期工作的。

我们需要测试的情景如下:

  • 客户端只能为已认证的用户添加锻炼记录
  • 客户只能检索自己的锻炼记录
  • 只有管理员用户可以删除一个锻炼记录

在我的例子中,Keycloak授权服务器运行在8080端口,而我在application.properties文件中配置的资源服务器运行在9090端口。你需要确保你通过使用你配置的端口来调用正确的组件。 让我们把三个测试场景中的每一个都拿出来,证明系统是正确安全的。

18.4.1 证明已认证的用户只能为自己增加一条记录

根据该方案,用户只能为自己添加记录。换句话说,如果我以Bill的身份认证,我应该不能为Rachel添加锻炼记录。为了证明这是应用程序的行为,我们调用授权服务器,并为其中一个用户,例如Bill,发出一个令牌。然后我们尝试同时为Bill和Rachel添加锻炼记录。我们证明,Bill可以为自己添加记录,但应用程序不允许他为Rachel添加记录。为了发出一个令牌,我们调用授权服务器,正如下一段代码中所介绍的。

curl -XPOST 'http://localhost:8080/auth/realms/master/protocol/openid-connect/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=password' \
--data-urlencode 'username=bill' \
--data-urlencode 'password=12345' \
--data-urlencode 'scope=fitnessapp' \
--data-urlencode 'client_id=fitnessapp'

在其他细节中,你还会得到一个Bill的访问令牌。我在下面的代码片断中截断了令牌的值,使其更短。访问令牌包含授权所需的所有细节,比如用户名和我们之前在第18.1节中通过配置Keycloak添加的权限。

{
    
    
    "access_token": "eyJhbGciOiJSUzI1NiIsInR…",
    "expires_in": 6000,
    "refresh_expires_in": 1800,
    "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI…",
    "token_type": "bearer",
    "not-before-policy": 0,
    "session_state": "0630a3e4-c4fb-499c-946b-294176de57c5",
    "scope": "fitnessapp"
}

有了访问令牌,我们就可以调用api来添加一个新的锻炼记录。我们首先尝试为Bill添加一条锻炼记录。我们预计为Bill添加锻炼记录是有效的,因为我们拥有的访问令牌是为比尔生成的。

下一个代码片断介绍了你为Bill添加新锻炼而运行的cURL命令。运行这个命令,你会得到一个200 OK的HTTP响应状态,并且一个新的锻炼记录被添加到数据库中。当然,作为授权头的值,你应该加入你先前生成的访问令牌。我在下一个代码段中截断了我的令牌的值,使命令更短,更容易阅读。

curl -v -XPOST 'localhost:9090/workout/' / -H 'Authorization:Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOi...'\ ` -H 'Content-Type: application/json' ` --data-raw '{ "user" : "bill", "start" : "2020-06-10T15:05:05", "end" : "2020-06-10T16:05:05", "difficult" : 2 }'

如果你调用api并试图为Rachel添加一条记录,你得到的HTTP响应状态是403 Forbidden。

curl -v -XPOST 'localhost:9090/workout/' \
-H 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOi...' \
-H 'Content-Type: application/json' \
--data-raw '{
"user" : "rachel",
"start" : "2020-06-10T15:05:05",
"end" : "2020-06-10T16:05:05",
"difficulty" : 2
}'

响应主体是

{
    
    
    "error": "access_denied",
    "error_description": "Access is denied"
}

18.4.2 证明用户只能检索自己的记录

在本节中,我们将证明第二个测试场景:我们的资源服务器只返回已认证用户的锻炼记录。为了证明这一行为,我们为Bill和Rachel生成访问令牌,并调用api来检索他们的锻炼历史。他们中的任何一个人都不应该看到另一个人的记录。要为Bill生成一个访问令牌,请使用这个curl命令。

curl -XPOST 'http://localhost:8080/auth/realms/master/protocol/openid-connect/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=password' \
--data-urlencode 'username=bill' \
--data-urlencode 'password=12345' \
--data-urlencode 'scope=fitnessapp' \
--data-urlencode 'client_id=fitnessapp'

用为Bill生成的访问令牌调用api来检索锻炼历史,应用程序只返回Bill的记录。

curl 'localhost:9090/workout/' -H 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSl...'

响应主体是

[
    {
    
    
    "id": 1,
    "user": "bill",
    "start": "2020-06-10T15:05:05",
    "end": "2020-06-10T16:10:07",
    "difficulty": 3
    },
    . . .
]

接下来,为Rachel生成一个令牌并调用相同的api。为了给Rachel生成一个访问令牌,运行这个curl命令。

curl -XPOST 'http://localhost:8080/auth/realms/master/protocol/openid-connect/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=password' \
--data-urlencode 'username=rachel' \
--data-urlencode 'password=12345' \
--data-urlencode 'scope=fitnessapp' \
--data-urlencode 'client_id=fitnessapp'

使用Rachel的访问令牌来获取锻炼历史,该应用程序只返回Rachel拥有的记录。

curl 'localhost:9090/workout/' \
-H 'Authorization: Bearer eyJhaXciOiJSUzI1NiIsInR5cCIgOiAiSl...'

响应主体是

[
    {
    
    
    "id": 2,
    "user": "rachel",
    "start": "2020-06-10T15:05:10",
    "end": "2020-06-10T16:10:20",
    "difficulty": 3
    },
    ...
]

18.4.3 证明只有管理员可以删除记录

第三个也是最后一个测试场景,我们想证明应用程序的行为是理想的,即只有管理员用户可以删除锻炼记录。为了证明这一行为,我们为我们的管理员用户Mary生成一个访问令牌,并为其他非管理员的用户之一生成一个访问令牌,比方说,Rachel。使用为玛丽生成的访问令牌,我们可以删除一项锻炼。但是应用程序禁止我们使用为Rachel生成的访问令牌调用端点来删除锻炼记录。要为Rachel生成一个令牌,请使用这个curl命令。

curl -XPOST 'http://localhost:8080/auth/realms/master/protocol/openid-connect/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=password' \
--data-urlencode 'username=rachel' \
--data-urlencode 'password=12345' \
--data-urlencode 'scope=fitnessapp' \
--data-urlencode 'client_id=fitnessapp'

如果你使用Rachel的令牌来删除一个现有的锻炼,你会得到一个403 Forbidden HTTP响应状态。当然,该记录并没有从数据库中删除。下面是调用的情况。

curl -XDELETE 'localhost:9090/workout/2' --header 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsIn...'

为Mary生成一个令牌,用新的访问令牌重新运行对端点的相同调用。要为Mary生成一个令牌,使用这个curl命令。

curl -XPOST 'http://localhost:8080/auth/realms/master/protocol/openid-
connect/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=password' \
--data-urlencode 'username=mary' \
--data-urlencode 'password=12345' \
--data-urlencode 'scope=fitnessapp' \
--data-urlencode 'client_id=fitnessapp'

用Mary的访问令牌调用端点删除一条锻炼记录,会返回HTTP状态200 OK。该锻炼记录被从数据库中删除。 以下是调用过程。

curl -XDELETE 'localhost:9090/workout/2' \
--header 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsIn...'

总结

  • 你不一定需要实现你的自定义授权服务器。 通常,在现实世界的场景中,我们使用Keycloak等工具来实现授权服务器。
  • Keycloak是一个开源的身份和访问管理解决方案,在处理用户管理和授权方面提供了很大的灵活性。通常,你可能更喜欢使用这样的工具,而不是实施一个定制的解决方案。
  • 拥有像Keycloak这样的解决方案并不意味着你永远不会实施客户的授权解决方案。在现实世界的场景中,你会发现你需要建立的应用程序的利益相关者不认为第三方的实现是可信的。你需要准备好处理你可能遇到的所有情况。
  • 你可以在一个通过OAuth 2框架实现的系统中使用全局方法安全。在这样的系统中,你在资源服务器层面实现全局方法安全限制,从而保护用户资源。
  • 你可以在你的SpEL表达式中使用特定的OAuth 2元素进行授权。要编写这样的SpEL表达式,你需要配置一个OAuth2Web- SecurityExpressionHandler来解释这些表达式。

猜你喜欢

转载自blog.csdn.net/Learning_xzj/article/details/128998864