SpringSecurity-第四版-全-

SpringSecurity 第四版(全)

原文:zh.annas-archive.org/md5/fb14b8af17aa34cfc7782b72dc3b9c06

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

知道经验丰富的黑客渴望测试你的技能,这使得安全成为创建应用程序中最困难且压力最大的问题之一。当你必须将这一因素与现有代码、新技术和其他框架集成时,正确保护应用程序的复杂性会进一步增加。使用这本书,你可以轻松地使用经过验证和值得信赖的Spring Security框架来保护你的 Java 应用程序,这是一个强大且高度可定制的认证和访问控制框架。

本书首先集成各种认证机制。然后演示如何正确限制对应用程序的访问。它还涵盖了与一些更受欢迎的 Web 框架集成的技巧。还包括 Spring Security 如何防御会话固定攻击的示例,以及如何利用会话管理进行管理功能,以及并发控制。

它以 RESTful Web 服务和微服务的先进安全场景结束,详细说明了围绕无状态认证的问题,并展示了一种简洁的、分步骤的方法来解决这些问题。

本书面向对象

如果你是一名 Java Web 和/或 RESTful Web 服务开发者,并且对创建 Java 17/21、Java Web 和/或 RESTful Web 服务应用程序、XML 以及 Spring Security 框架有基本的了解,那么这本书适合你。

本书涵盖内容

第一章不安全应用程序的解剖结构,涵盖了对我们的日历应用程序的假设性安全审计,说明了通过正确应用 Spring Security 可以解决的常见问题。你将了解一些基本的安全术语,并回顾一些使示例应用程序运行所需的先决条件。

第二章Spring Security 入门,演示了 Spring Security 的"Hello World"安装。之后,本章将引导你了解 Spring Security 的一些最常见定制。

第三章自定义认证,通过自定义认证基础设施的关键部分来逐步解释 Spring Security 的认证架构,以解决现实世界的问题。通过这些定制,你将了解 Spring Security 认证的工作原理以及如何与现有和新认证机制集成。

第四章基于 JDBC 的认证,介绍了使用 Spring Security 内置的Java 数据库连接JDBC)支持对数据库进行认证。然后我们讨论了如何使用 Spring Security 的新加密模块来保护我们的密码。

第五章, 使用 Spring Data 进行认证,探讨了 Spring Data 项目,以及如何利用Jakarta Persistence (JPA)在关系型数据库上执行认证。我们还将探讨如何使用 MongoDB 对文档数据库进行认证。

第六章, LDAP 目录服务,将回顾轻量级目录访问协议 (LDAP),并了解它如何集成到启用 Spring Security 的应用程序中,为感兴趣的参与者提供认证、授权和用户信息服务。

第七章, 记住我服务,展示了 Spring Security 中记住我功能的用法以及如何配置它。我们还探讨了在使用它时需要考虑的额外因素。我们将添加应用记住用户的功能,即使他们的会话已过期且浏览器已关闭。

第八章, 使用 TLS 的客户端证书认证,展示了尽管用户名和密码认证极为常见,正如我们在第一章不安全应用程序的解剖结构,以及第二章Spring Security 入门中讨论的那样,存在其他认证形式,允许用户展示不同类型的凭证。Spring Security 也满足这些需求。在本章中,我们将超越基于表单的认证,探索使用受信任的客户端证书进行认证。

第九章, 开启 OAuth 2 支持,解释了 OAuth 2 是一种非常流行的受信任身份管理形式,允许用户通过单个受信任的提供者管理其身份。这个便捷的功能为用户提供将密码和个人信息存储在受信任的 OAuth 2 提供者的安全性,并在请求时可选地披露个人信息。此外,启用 OAuth-2 的网站可以提供信心,即提供 OAuth 2 凭证的用户就是他们所说的那个人。

第十章, SAML 2 支持,将深入探讨安全断言标记语言 (SAML 2.0)支持的领域,以及它如何无缝集成到 Spring Security 应用程序中。SAML 2.0 是一种基于 XML 的标准,用于在身份提供者 (IdP)和服务提供者 (SP)之间交换认证和授权数据。

第十一章细粒度访问控制,将首先探讨两种实现细粒度授权的方法——这种授权可能会影响应用程序页面的一部分。接下来,我们将探讨 Spring Security 通过方法注解和使用基于接口的代理来确保业务层安全的方法,以实现面向方面编程AOP)。然后,我们将回顾基于注解的安全性的一个有趣功能,该功能允许对数据集合进行基于角色的过滤。最后,我们将探讨基于类的代理与基于接口的代理之间的区别。

第十二章访问控制列表,将讨论复杂的访问控制列表ACLs)主题,它可以为域对象实例级授权提供丰富的模型。Spring Security 附带了一个强大但复杂的 ACL 模块,可以合理地满足从小型到中型实施的需求。

第十三章自定义授权,将包括一些针对 Spring Security 关键授权 API 的自定义实现。完成这些后,我们将利用对自定义实现的理解来了解 Spring Security 授权架构的工作原理。

第十四章会话管理,讨论了 Spring Security 如何管理和保护用户会话。本章首先解释了会话固定攻击以及 Spring Security 如何防御这些攻击。然后讨论了如何管理已登录用户并限制单个用户并发会话的数量。最后,我们描述了 Spring Security 如何将用户与HttpSession关联以及如何自定义此行为。

第十五章额外的 Spring Security 功能,涵盖了其他 Spring Security 功能,包括常见的安全漏洞,如跨站脚本XSS)、跨站请求伪造CSRF)、同步令牌点击劫持,以及如何防范它们。

第十六章迁移到 Spring Security 6,提供了从 Spring Security 5 迁移的路径,包括显著的配置更改、类和包迁移,以及包括 Java 17 支持和新的 OAuth 2.1 认证机制在内的重要新功能。

它还强调了 Spring Security 6.1 中可以找到的新功能,并提供了书中这些功能示例的参考。

第十七章使用 OAuth 2 和 JSON Web 令牌的微服务安全,将探讨基于微服务的架构以及 OAuth 2 与JSON Web 令牌JWT)如何在基于 Spring 的应用程序中确保微服务的安全。它还突出了 Spring Security 6.1 中的新功能,并提供了书中这些功能示例的参考。

第十八章使用中央认证服务进行单点登录,展示了如何通过集成中央认证服务CAS)为您的 Spring-Security 启用应用程序提供单点登录和单点注销支持。

第十九章构建 GraalVM 原生镜像,探讨了 Spring Security 6 对使用 GraalVM 构建原生图像的支持。这可以是一种提高 Spring Security 应用程序性能和安全的绝佳方式。

要充分利用本书

与示例代码集成的首选方法是提供GradleMaven兼容的项目。由于许多集成开发环境IDE)与 Gradle 和 Maven 有丰富的集成,用户应该能够将代码导入支持 Gradle 或 Maven 的任何 IDE。由于许多开发者使用 Gradle 和 Maven,我们认为这是打包示例的最直接方法。无论您熟悉哪种开发环境,希望您能找到一种方法来处理本书中的示例。

许多 IDE 提供了 Gradle 或 Maven 工具,可以自动为您下载 Spring 和Spring Security 6的 Javadoc 和源代码。然而,有时这可能不可行。在这种情况下,您可能需要下载Spring 6和 Spring Security 6 的完整版本。Javadoc 和源代码质量上乘。如果您感到困惑或需要更多信息,示例可以为您提供额外的支持或学习保障。

要运行示例应用程序,您需要一个 IDE,例如IntelliJ IDEAEclipse,并使用GradleMaven构建,这些工具对硬件要求不严格。然而,以下是一些一般性建议,以确保开发体验顺畅:

  1. 系统要求:

    • 一台配备至少 4GB RAM 的现代计算机(建议 8GB 或更多)。

    • 多核处理器以加快构建和开发速度。

  2. 操作系统:

    • Spring 应用程序可以在 Windows、macOS 或 Linux 上开发。选择您最舒适的一个。
  3. 磁盘空间:

    • 您的项目文件、依赖项以及可能使用的任何数据库都需要磁盘空间。至少建议有 10GB 的空闲磁盘空间。
  4. 网络连接:

    • 在项目设置期间下载依赖项、插件和库可能需要稳定的网络连接。
本书涵盖的软件/硬件 操作系统要求
IntelliJ IDEA 和 Eclipse 都是 Spring 开发的流行选择 Windows、macOS 或 Linux
JDK 版本:17 或 21
Spring- Security 6.
Spring- Boot 3.
Thymeleaf 6.

如果您正在使用本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误。

第三章自定义身份验证开始,本书将重点转向深入探讨 Spring Security,尤其是在与 Spring Boot 框架结合使用时。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/Spring-Security-Fourth-Edition/。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他来自我们丰富图书和视频目录的代码包可供选择,可在github.com/PacktPublishing/找到。查看它们吧!

代码实战

本书代码实战视频可在packt.link/Om1ow查看。

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“我们从classpath加载calendar.ldif文件,并使用它来填充 LDAP 服务器。”

代码块设置如下:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Bean public SecurityFilterChain filterChain(HttpSecurity http,        PersistentTokenRepository persistentTokenRepository, RememberMeServices rememberMeServices) throws Exception {    http.authorizeHttpRequests( authz -> authz                 .requestMatchers("/webjars/**").permitAll()
…
    // Remember Me     http.rememberMe(httpSecurityRememberMeConfigurer -> httpSecurityRememberMeConfigurer           .key("jbcpCalendar")          .rememberMeServices(rememberMeServices)          .tokenRepository(persistentTokenRepository));        return http.build();}

第一行//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java指示要修改的文件位置。

任何命令行输入或输出都应如下编写:

X-Content-Security-Policy: default-src 'self' X-WebKit-CSP: default-src 'self'

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“右键单击世界并选择搜索。”

小贴士或重要提示

看起来像这样。

联系我们

我们欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请通过 customercare@packtpub.com 给我们发邮件,并在邮件主题中提及书名。

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,我们将非常感谢。请访问www.packtpub.com/support/errata并填写表格。

盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过版权@packtpub.com 与我们联系,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

分享您的想法

一旦您阅读了《Spring Security》,我们很乐意听听您的想法!请点击此处直接进入本书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

下载本书的免费 PDF 副本

感谢您购买本书!

您喜欢随时随地阅读,但无法携带您的印刷书籍到处走?

您的电子书购买是否与您选择的设备不兼容?

别担心,现在每购买一本 Packt 图书,你都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何地方、任何设备上阅读。从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠远不止于此,您还可以获得独家折扣、时事通讯和每日收件箱中的精彩免费内容。

按照以下简单步骤获取福利:

  1. 扫描二维码或访问以下链接:

二维码

packt.link/free-ebook/9781835460504

  1. 提交您的购买证明

  2. 就这些!我们将直接将您的免费 PDF 和其他福利发送到您的电子邮件。

第一部分:应用安全基础

本部分深入探讨应用安全的基础方面,为理解潜在漏洞奠定基础。我们通过 Spring Security 对应用安全进行全面的探索。本部分向您介绍对假设的日历应用进行安全审计的过程。通过这次审计,我们发现了常见的安全漏洞,并为实施强大的安全措施奠定了基础。

在此基础上,本部分指导您进行 Spring Security 的安装和配置。我们从基本的"Hello World"示例开始,逐步定制 Spring Security 以满足我们应用程序的特定需求。

我们还将更深入地探讨 Spring Security 中的身份验证过程。通过定制身份验证基础设施的关键组件,我们解决现实世界的身份验证挑战,并全面了解 Spring Security 的身份验证机制。通过实际示例和动手练习,我们学习如何无缝地将自定义身份验证解决方案集成到我们的应用程序中。

本部分包含以下章节:

  • 第一章不安全应用程序的解剖结构

  • 第二章Spring Security 入门

  • 第三章自定义身份验证

第一章:不安全应用程序的解剖结构

安全性可以说是 21 世纪任何基于 Web 的应用程序最重要的架构组件之一。在恶意软件、犯罪分子和恶意员工始终存在并积极测试软件以寻找漏洞的时代,智能和全面的安全使用是您将负责的任何项目的关键要素。

这本书的编写是为了遵循一种开发模式,我们认为这种模式为处理复杂主题提供了一个有用的前提——以 Spring 6 为基础的基于 Web 的应用程序,并理解使用 Spring Security 6 对其进行安全保护的核心概念和策略。我们通过为每一章提供完整的 Web 应用程序的示例代码来补充这种方法。

在本章中,我们将深入探讨一个示例场景,以突出几个常见的安全漏洞。我们的旅程将从检查安全编码的基本原则开始。然后,我们将转向下一章,探讨常见的漏洞,如 SQL 注入、跨站脚本攻击XSS)和跨站请求伪造CSRF)。

无论您是否已经在使用 Spring Security,还是希望将您对软件的基本使用提升到更复杂的水平,您都会在这本书中找到一些有用的内容。

在本章的讨论过程中,我们将涵盖以下主题:

  • 探索软件架构风格

  • 理解安全审计

  • 应对安全审计发现

建议在深入研究后续部分之前,您对 Spring 框架有一个基本的了解。

在本章结束时,您将了解应用程序可能受到攻击的方式,并且您将掌握可以用来保护应用程序的核心安全机制。

如果您已经熟悉基本的安全术语,您可以跳转到第二章Spring Security 入门,在那里我们开始使用框架的基本功能。

探索软件架构风格

许多企业正在从在线云服务平台获取计算能力,并主要依赖云来开发大多数应用程序。这种转变促使应用程序设计发生了变化。

图 1.1 – 单体架构与分层架构与 SOA 与微服务架构的比较

图 1.1 – 单体架构与分层架构与 SOA 与微服务架构的比较

选择最合适的应用架构取决于您的具体业务需求。我们将探讨四种旨在促进数字化转型并满足一般业务需求的设计方案。

单体架构

一种传统架构,其中整个应用程序作为一个统一且紧密集成的实体构建。

虽然最初开发和部署很容易,但随着项目的扩展,扩展和维护可能会带来挑战。

N-Tier 架构(分层架构)

N 层架构,也称为具有明显层的层次结构,是指一种软件系统的设计方法,它将应用程序组织成多个层,通常是四层:表示层业务层持久层数据层。这种架构模型在企业应用程序中常用,通过分室化和促进模块化开发来提高可维护性。每一层都有特定的职责。

模型-视图-控制器MVC)软件设计模式将应用程序分为三个相互关联的组件:模型(数据和业务逻辑)、视图(用户界面)和控制器(处理用户输入并相应地更新模型和视图)。

这种分割有助于可扩展性、易于维护和适应不断变化的业务需求。

SOA

服务导向模式,也称为服务导向架构SOA),是一种将软件应用程序构建为一系列松散耦合且可独立部署的服务集合的架构风格。

在 20 世纪 90 年代末服务导向架构(SOA)开始使用之前,将应用程序连接到另一个系统中的服务是一个复杂的过程,涉及点到点的集成。

微服务架构

微服务源于 SOA,但 SOA 与微服务不同。

这种架构涉及将应用程序分解成小型、自主的服务,通过 API 进行通信。它提供了可扩展性、灵活性和简化了维护,但引入了处理分布式系统复杂性的挑战。

重要提示

从历史上看,重点是功能性和有状态性,但现在,大多数面向消费者的应用程序正在转向软件即服务SaaS)和数字平台。目前,在应用程序设计中的重点是提高用户体验、拥抱无状态性和优先考虑敏捷性。

在传统 Web 应用程序和单页应用程序之间进行选择

在当代的景观中,构建 Web 应用程序主要有两种主要方法:传统的 Web 应用程序,其中大多数应用程序逻辑在服务器上执行,以及单页应用程序SPA),它们在 Web 浏览器中处理大多数用户界面逻辑,并通过 Web API 主要与 Web 服务器通信。一种可行的混合方法是,在更大的传统 Web 应用程序中托管一个或多个功能丰富的 SPA-like 子应用程序。

在以下情况下选择传统的 Web 应用程序:

  • 您的应用程序客户端需求简单,甚至仅限于只读功能

  • 您的应用程序需要在缺少 JavaScript 支持的浏览器中运行

  • 您的应用程序是公开可访问的,并从搜索引擎可见性和推荐中受益

在以下情况下选择一个单页应用程序(SPA):

  • 您的应用程序需要一个复杂的用户界面,具有众多功能

  • 您的开发团队精通 JavaScript、Angular、ReactJS、VueJS、TypeScript 或 WebAssembly

  • 您的应用程序已经向其他客户端公开了 API,无论是内部还是公开

由 SPA 方法促进的用户体验改进应仔细考虑这些因素。

在本书的下一部分,我们将使用传统的 Spring MVC 应用程序作为示例,来说明各种安全原则。

重要提示

需要注意的是,这些安全原则适用于本章讨论的所有架构风格。

理解安全审计

您作为JBCPCalendar.com的软件开发人员,早上很早,您正在喝第一杯咖啡,这时您收到了以下来自管理员的电子邮件:

图 1.2 – 管理员的电子邮件

图 1.2 – 管理员的电子邮件

什么?您在设计应用程序时没有考虑到安全性?实际上,到目前为止,您甚至不确定什么是安全审计。听起来您将从安全审计员那里学到很多东西!在本章的后面部分,我们将回顾什么是审计,以及审计的结果。首先,让我们花点时间检查正在审查的应用程序。

探索示例应用程序

虽然我们将在本书的进展过程中处理一个虚构的场景,但应用程序的设计以及我们将对其进行的更改都是基于基于 Spring 的应用程序的实际使用。日历应用程序允许用户创建和查看事件:

图 1.3 – 日历应用程序事件信息

图 1.3 – 日历应用程序事件信息

在输入新事件的详细信息后,您将看到以下截图:

图 1.4 – 日历应用程序摘要

图 1.4 – 日历应用程序摘要

该应用程序设计得简单,以便我们能够专注于安全性的重要方面,而不是陷入对象关系映射ORM)和复杂的 UI 技术的细节。我们希望您参考其他补充材料,例如附录附加参考资料(本书补充材料部分),以涵盖示例代码中提供的部分基本功能。

代码是用 Spring 和 Spring Security 6 编写的,但将许多示例适应到其他版本的 Spring Security 相对容易。有关 Spring Security 4 和 6 之间详细更改的讨论,请参阅第十六章迁移到 Spring Security 6,以帮助将示例转换为 Spring Security 6 语法。

重要提示

请不要将此应用程序作为构建真实在线日历应用程序的基准。它已被有意设计得简单,并专注于我们在本书中阐述的概念和配置。

在下一节中,我们将探讨应用程序架构。

JBCP 日历应用程序架构

Web 应用程序遵循标准的三层架构,包括 Web 层、服务层和数据访问层,如下面的图所示:

图 1.5 – JBCP 日历应用程序架构

图 1.5 – JBCP 日历应用程序架构

你可以在附录的[补充材料]部分找到有关 MVC 架构的额外材料,附加 参考资料

Web 层封装了 MVC 代码和功能。在这个示例应用程序中,我们将使用 Spring MVC 框架,但同样可以轻松地使用Spring Web FlowSWF)、Apache Struts,甚至是一个 Spring 友好的 Web 栈,如Apache Wicket

在典型的使用 Spring Security 的 Web 应用程序中,Web 层是配置和代码增强的主要发生地。例如,EventsController类被用来将 HTTP 请求转换为将事件存储到数据库的过程。如果你在 Web 应用程序和 Spring MVC 方面没有太多经验,那么在继续更复杂主题之前仔细审查基准代码并确保你理解它是明智的。再次强调,我们已尽力使网站尽可能简单,而日历应用程序的构建只是为了给网站提供一个合理的标题和轻量级结构。

重要提示

你可以在附录的[附加] 参考资料中找到设置示例应用程序的详细说明。

服务层封装了应用程序的业务逻辑。在我们的应用程序中,我们使用DefaultCalendarService作为数据访问层的一个非常轻量级的门面,以说明关于保护应用程序服务方法的一些特定点。服务层还用于在单个方法调用中操作 Spring Security API 和我们的日历 API。我们将在第三章中更详细地讨论这一点,自定义身份验证

在典型的 Web 应用程序中,服务层包含业务规则验证、业务对象的组合和分解以及如审计等横切关注点。

数据访问层封装了操纵数据库表内容的代码。在许多 Spring 应用程序中,你会在这一层看到 ORM,例如 Hibernate 或 JPA。数据访问层向服务层公开基于对象的 API。在我们的应用程序中,我们使用基本的 JDBC 功能在内存中的 H2 数据库中实现持久性。例如,JdbcEventDao用于将事件对象保存到数据库中。

在典型的 Web 应用程序中,将利用更全面的数据访问解决方案。由于 ORM 和更广泛的数据访问对一些开发者来说可能很复杂,我们选择尽可能简化这一领域,以便于理解。

在下一节中,我们将审查审计结果。

审查审计结果

让我们回到我们的电子邮件,看看审计进展如何。哎呀…结果看起来不太好:

图 1.6 – 审计结果电子邮件

图 1.6 – 审计结果电子邮件

应用程序审计结果:此应用程序表现出以下不安全行为:

  • 由于缺乏 URL 保护和一般认证,意外提升权限

  • 授权使用不当或不存在

  • 缺少数据库凭证安全

  • 可识别的个人或敏感信息容易被访问或未加密

  • 由于缺乏 SSL 加密,传输层保护不安全

风险等级很高。我们建议在此问题解决之前,应将此应用程序下线。

哎呀!这个结果对我们公司来说看起来很糟糕。我们最好尽快努力解决这些问题。

第三方安全专家通常由公司(或其合作伙伴或客户)雇佣,通过结合白帽黑客、源代码审查以及与应用程序开发人员和架构师进行的正式或非正式对话,来审计其软件安全的有效性。

白帽黑客道德黑客是由受雇于公司(或其合作伙伴或客户)的专业人士进行的,他们的目的是指导公司如何更好地保护自己,而不是出于恶意意图。

通常,安全审计的目标是为管理层或客户提供保证,即已遵循基本的安全开发实践,以确保客户数据和系统功能的完整性和安全性。根据软件针对的行业,审计员还可能使用行业特定标准或合规性指标进行测试。

重要提示

在您的职业生涯中,您可能会遇到两种特定的安全标准:支付卡行业数据安全标准PCI DSS)和健康保险隐私和问责法案HIPAA)的隐私规则。这两个标准都是为了通过流程和软件控制相结合来确保特定敏感信息(如信用卡和医疗信息)的安全。

许多其他行业和国家都有关于敏感或个人身份信息PII)的类似规则。未能遵守这些标准不仅是不良的做法,而且在发生安全漏洞的情况下,还可能使您或您的公司面临重大的法律责任(更不用说不良的舆论了)。

接收安全审计的结果可能是一次令人大开眼界的经历。按照所需的软件改进进行操作可能是自我教育和软件改进的绝佳机会,并允许您实施导致安全软件的实践和政策。

在下一节中,我们将回顾审计员发现的问题,并制定一个详细计划来应对这些问题。

应对安全审计发现

在本节中,我们将仔细检查我们的安全审计结果,揭示我们应用程序安全领域中的漏洞和关注区域。我们将分析审计结果,并开始探索各种有效的策略和模式,以保障和减轻这些已识别的风险。本章为增强我们应用程序的安全性提供了路线图,确保它能够抵御潜在的威胁和漏洞。

认证

认证是您在开发安全应用程序时必须内化的两个关键安全概念之一(另一个是授权)。认证确定谁正在尝试请求资源。您可能对日常在线和离线生活中的认证很熟悉,在不同的环境中,如下所示:

  • 基于凭证的认证:当您登录基于网络的电子邮件账户时,您很可能提供了您的用户名和密码。电子邮件服务提供商将您的用户名与其数据库中的已知用户进行匹配,并验证您的密码是否与记录相符。这些凭证是电子邮件系统用来验证您是该系统有效用户的方法。首先,我们将使用这种类型的认证来保护 JBCP 日历应用中的敏感区域。从技术角度讲,电子邮件系统不仅可以在数据库中检查凭证,还可以在任何地方检查,例如,一个企业目录服务器,如微软活动目录。本书中涵盖了这种类型集成的几个示例。

  • 双因素认证:当您从银行的 ATM 机取款时,您需要在被允许取现金或进行其他交易之前,先刷您的身份证并输入您的个人识别号码。这种认证方式与用户名和密码认证类似,只是用户名被编码在卡的磁条上。物理卡片和用户输入的 PIN 码的组合使得银行能够确保您有权访问该账户。密码和物理设备(您的塑料 ATM 卡)的组合是双因素认证的普遍形式。在专业、注重安全的环镜中,这些类型的设备在访问高度安全系统时经常被使用,尤其是在处理财务信息或 PII 时。例如,RSA SecurID这样的硬件设备将基于时间的硬件设备与基于服务器的认证软件相结合,使得环境极其难以被攻破。

  • 硬件认证:当您早上启动汽车时,您将金属钥匙插入点火开关并转动以启动汽车。尽管这可能与其他两个例子感觉不同,但钥匙上的凸起与点火开关中的转轮的正确匹配作为一种硬件认证方式。

实际上存在数十种认证形式可以应用于软件和硬件安全的问题,每种都有其自身的优缺点。我们将在本书的前半部分回顾一些这些方法,它们如何应用于 Spring Security。我们的应用程序缺乏任何类型的认证,这就是为什么审计包括了意外权限提升的风险。

通常,一个软件系统将被分为两个高级领域,例如非认证(或匿名)和认证,如下面的截图所示:

图 1.7 – 软件系统中的高级领域

图 1.7 – 软件系统中的高级领域

匿名领域中的应用功能是独立于用户身份的功能(例如,在线应用程序的欢迎页面)。

匿名区域不执行以下操作:

  • 要求用户登录系统或以其他方式识别自己才能使用

  • 显示敏感信息,如姓名、地址、信用卡和订单

  • 提供操作系统整体状态或其数据的功能

系统的非认证区域旨在供所有人使用,甚至包括我们尚未明确识别的用户。然而,在这些区域中,可能存在一些额外的功能似乎用于识别用户(例如,无处不在的欢迎 {名字}文本)。通过使用 Spring Security 标签库,对认证用户选择性显示内容得到了完全支持,并在第十一章细粒度访问控制中进行了介绍。

我们将在第二章“使用 Spring Security 入门”中解决这个发现,并利用 Spring Security 的自动配置功能实现基于表单的身份验证。之后,我们将探讨执行身份验证的各种其他方法(这通常围绕与企业或其他外部身份验证存储的系统集成)。

在下一节中,我们将探讨授权

授权

授权是两个核心安全概念中的第二个,在实现和理解应用程序安全中至关重要。授权使用在身份验证过程中验证的信息来确定是否应授予特定资源的访问权限。围绕应用程序的授权模型构建,授权将应用程序的功能和数据分区,以便可以通过将权限、功能和数据组合与用户匹配来控制这些项目的可用性。我们应用程序在审计此点时的失败表明,应用程序的功能没有被用户角色所限制。想象一下,如果你正在运行一个电子商务网站,查看、取消或修改订单和客户信息的能力对任何网站用户都是可用的!

授权通常涉及以下两个独立的方面,它们结合在一起描述了受保护系统的可访问性:

  • 第一个是将经过验证的主实体映射到一个或多个权限(通常称为角色)。例如,你网站的一个普通用户可能被视为拥有访客权限,而网站管理员可能被分配管理权限。

  • 第二个是将权限检查分配给系统中的受保护资源。这通常在系统开发时完成,无论是通过代码中的显式声明还是通过配置参数。例如,允许查看其他用户事件的屏幕应该仅对具有管理权限的用户可用。

重要提示

受保护资源可以是系统中的任何方面,其可用性应根据用户的权限有条件地提供。

基于 Web 的应用程序的受保护资源可以是单个网页、整个网站的部分或单个网页的部分。相反,受保护的业务资源可能是类上的方法调用或单个业务对象。

你可能会想象一个权限检查,它会检查主实体(当前用户或与应用程序交互的系统的身份),查找其用户账户,并确定该主实体是否确实是管理员。如果这个权限检查确定尝试访问受保护区域的主体确实是管理员,那么请求将成功。然而,如果主体没有足够的权限,请求应该被拒绝。

让我们更详细地看看一个特定的受保护资源示例,即所有事件页面。所有事件页面需要管理员访问权限(毕竟,我们不希望普通用户查看其他用户的事件),因此它会在访问该页面的主体中寻找一定级别的权限。

如果我们考虑当网站管理员尝试访问受保护资源时可能做出的决定,我们会想象实际权限与所需权限的对比可以用集合论来简洁地表达。我们可能会选择将这个决定表示为针对管理用户的维恩图

图 1.8 – 管理用户的维恩图

图 1.8 – 管理用户的维恩图

对于该页面,用户权限(用户和管理员)与所需权限(管理员)之间存在交集,因此用户被赋予了访问权限。

与此相对比的是未经授权的用户,如下所示:

图 1.9 – 访问(未经授权)用户的维恩图

图 1.9 – 访问(未经授权)用户的维恩图

我们有一个对称差集。权限集是互斥的,没有共同元素。因此,用户被拒绝访问该页面。这样,我们就展示了资源访问授权的基本原理。

实际上,有真正的代码在做出这个决定,结果是用户被授予或拒绝访问请求的保护资源。我们将在第二章“Spring Security 入门”中解决基本的授权问题,随后在第第十二章“访问控制列表”和第十三章“自定义授权”中探讨更高级的授权。

现在我们已经涵盖了授权的概念,我们将探讨它如何应用于数据库安全。

数据库凭证安全

在 Spring 术语中,数据库凭证通常指的是在 Spring 应用程序和数据库之间建立连接所需的信息。这些凭证包括以下内容:

  • 用户名:与 Spring 应用程序使用的数据库账户关联的用户名或用户 ID。

  • 密码:对应于指定用户名的密码,提供对数据库访问的认证。

  • 数据库 URL:指定数据库位置和详细信息的 URL。它包括诸如主机、端口和数据库名称等信息。

通过检查应用程序源代码和配置文件,审计员注意到用户密码以纯文本形式存储在配置文件中,这使得任何有权访问服务器的恶意用户都能轻易地访问应用程序。

由于应用程序包含个人和财务数据,一个恶意用户能够访问任何数据可能会使公司面临身份盗窃或篡改的风险。保护用于访问应用程序的凭证应该是我们的首要任务,而一个重要的第一步是确保安全中的一个故障点不会破坏整个系统。

我们将在第四章基于 JDBC 的认证”中检查 Spring Security 中数据库访问层的配置,该配置需要JDBC 连接。在同一章节中,我们还将探讨提高数据库中存储的密码安全性的内置技术。

在介绍数据库凭证安全之后,我们将探讨敏感信息审计发现。

敏感信息

可识别的个人或敏感信息容易被访问或未加密。审计员指出,系统中的某些重要且敏感的数据完全未加密或未在任何地方进行屏蔽。幸运的是,有一些简单的设计模式和工具可以帮助我们安全地保护这些信息,Spring Security 提供了基于注解的面向切面编程AOP)支持。

传输层保护

由于缺乏 SSL 加密,存在不安全的传输层保护。

虽然在现实世界中,一个包含私人信息的在线应用程序在没有 SSL 保护的情况下运行是不可想象的,但不幸的是,JBCP 日历就处于这种状况。SSL 保护确保浏览器客户端与 Web 应用程序服务器之间的通信能够抵御多种篡改和窃听。

在“Tomcat 中的 HTTPS 设置”部分,在附录,“附加参考资料”,我们将回顾使用传输层安全作为应用程序安全结构定义的一部分的基本选项。

使用 Spring Security 6 解决安全担忧

Spring Security 6提供了丰富的资源,允许许多常见的安全实践以简单的方式声明或配置。在接下来的章节中,我们将结合源代码和应用配置更改来应对安全审计员提出的所有担忧(以及更多),以增强我们对自己日历应用程序安全的信心。

在 Spring Security 6 中,我们将能够进行以下更改以提升我们应用程序的安全性:

  • 将系统用户划分为用户类别

  • 为用户角色分配授权级别

  • 将用户角色分配给用户类别

  • 在应用程序资源全局应用认证规则

  • 在应用程序架构的所有级别应用授权规则

  • 防止旨在操纵或窃取用户会话的常见攻击类型

Spring Security 的存在是为了填补 Java 第三方库宇宙中的一个空白,就像 Spring 框架最初推出时那样。像Java 身份验证和授权服务JAAS)或Jakarta EE 安全这样的标准确实提供了一些执行相同身份验证和授权功能的方法,但 Spring Security 之所以成为赢家,是因为它包括了实现自上而下的应用程序安全解决方案所需的所有内容,简洁且合理。

此外,Spring Security 因其提供开箱即用的许多常见企业身份验证系统集成而受到许多人的青睐,因此它几乎不需要开发者进行额外努力(除了配置)即可适应大多数情况。它被广泛使用,因为实际上没有其他主流框架与它相当!

技术要求

我们已经努力使应用程序尽可能易于运行,通过关注几乎每个 Spring 开发者都会在其开发机器上拥有的基本工具和技术。尽管如此,我们在附录中的使用 JBCP 日历示例代码入门部分提供了入门部分作为补充信息,附加 参考材料

与示例代码集成的首选方法是提供与GradleMaven兼容的项目。由于许多 IDE 与 Gradle 和 Maven 有丰富的集成,用户应该能够将代码导入支持 Gradle 或 Maven 的任何 IDE。由于许多开发者使用 Gradle 和 Maven,我们认为这是打包示例的最直接方法。无论你熟悉哪种开发环境,希望你能找到一种方法来通过本书中的示例进行工作。

许多 IDE 提供了 Gradle 或 Maven 工具,可以自动为你下载 Spring 和 Spring Security 6 的 Javadoc 和源代码。然而,有时这可能不可行。在这种情况下,你将需要下载 Spring 6 和 Spring Security 6 的完整版本。Javadoc 和源代码都是一流的。如果你感到困惑或需要更多信息,示例可以为你提供额外的支持或保证你的学习。请访问附录中的补充材料部分,附加参考材料,以找到有关 Gradle 和 Maven 的更多信息,包括运行示例以及获取源代码和 Javadoc。

要运行示例应用程序,你需要一个集成开发环境IDEs),例如 IntelliJ IDEA 或 Eclipse,并使用 Gradle 或 Maven 构建,它们对硬件要求并不严格。然而,以下是一些一般性建议,以确保开发体验顺畅:

  • 系统要求:

    • 一台现代计算机,至少有 4GB 的 RAM(建议 8GB 或更多)

    • 多核处理器以实现更快的构建和开发

  • 操作系统:Spring 应用可以在 Windows、macOS 或 Linux 上开发。选择您最舒适的一个。

  • 集成开发环境(IDE):IntelliJ IDEA 和 Eclipse 都是 Spring 开发的流行选择。确保您的 IDE 是最新的。

  • Java 开发工具包JDK):Spring 应用需要至少 Java 17。安装与您的 IDE 兼容的最新 JDK 版本。

    在本书出版时,所有代码都已使用最新的长期支持LTS)版本 Java 21 进行了验证。

  • 磁盘空间:您需要磁盘空间来存储项目文件、依赖项以及您可能使用的任何数据库。至少建议有 10 GB 的空闲磁盘空间。

  • 网络连接:在项目设置期间下载依赖项、插件和库可能需要稳定的互联网连接。

重要提示

您现在可以查看 JBCP 日历应用的示例代码:chapter01.00-calendar

第三章自定义认证开始,我们将重点转向深入探讨spring-security,特别是在与spring-boot框架结合使用时。

如果您选择Maven,请从项目目录中使用 Maven 运行以下命令:

./mvnw package cargo:run

如果您使用Gradle,请从项目目录中运行以下命令:

./gradlew tomcatRun

然后在浏览器中打开http://localhost:8080/

摘要

在本章中,我们回顾了未受保护的网络应用中的常见风险点以及我们示例应用的基本架构。我们首先仔细审查了审计结果,突出了关注区域和潜在漏洞。然后章节转向了关键安全概念,包括认证授权数据库凭证安全。我们还讨论了基于 Spring 框架的应用安全策略。

在下一章中,我们将探讨如何快速设置Spring Security并对其工作有一个基本的了解。

第二章:Spring Security 入门

在本章中,我们将应用最小的 Spring Security 配置来开始解决我们的第一个发现——由于缺乏 URL 保护和一般认证,导致意外权限提升,这是在第一章,“不安全应用程序的解剖”中讨论的安全审计。然后,我们将在此基础上构建基本配置,为我们的用户提供定制体验。本章旨在让您开始使用 Spring Security,并为任何其他您需要执行的安全相关任务提供基础。

在本章的讨论过程中,我们将涵盖以下主题:

  • JBCP 日历应用程序上实现基本的安全级别,使用 Spring Security 中的自动配置选项

  • 学习如何自定义登录和注销体验

  • 配置 Spring Security 以根据 URL 限制不同的访问

  • 利用基于表达式的 Spring Security 访问控制

  • 使用JavaServer PagesJSP)库在 Spring Security 中条件性地显示有关登录用户的基本信息

  • 根据用户的角色确定登录后的默认位置

在本书的每一章中,您都会被指导从一个指定的项目chapterX.00-calendar开始探索,其中X表示章节编号。在本章中,重点是使用 Spring 框架中基于 Spring Security 的应用程序,不包括使用 Spring Boot。

主要目标是帮助读者熟悉 Spring Framework 环境下 Spring Security 的集成。

第三章,“自定义认证”开始,本书将重点深入探讨 Spring Security,特别是与 Spring Boot 框架结合使用。

本章的代码操作链接在此:packt.link/fZ96T

Spring Security 你好

虽然 Spring Security 的配置可能非常困难,但产品的创造者已经深思熟虑,并为我们提供了一个非常简单的机制,通过这个机制,我们可以使用强大的基线启用软件的大部分功能。从这个基线开始,额外的配置将允许对应用程序的安全行为进行精细的控制。

我们将从第一章,“不安全应用程序的解剖”,我们的未加密日历应用程序开始,将其转变为一个使用基本的用户名和密码认证的受保护网站。这种认证仅仅是为了说明启用 Spring Security 为我们网络应用程序所涉及的步骤;你会发现这种方法存在一些明显的缺陷,这将引导我们进行进一步的配置优化。

导入示例应用程序

我们鼓励您将chapter02.00-calendar项目导入到您的集成开发环境IDEs)中,并按照本章节中描述的从本章获取源代码,正如在附录使用 JBCP 日历示例代码入门部分所述,附加参考材料

对于每一章,您将找到多个代码修订版本,这些修订版本代表了书中的检查点。这使得您在前进过程中轻松地将您的工作与正确答案进行比较。在每一章的开始,我们将导入该章节的第一个修订版本作为起点。例如,在本章中,我们从chapter02.00-calendar开始,第一个检查点将是chapter02.01-calendar。在第三章自定义身份验证中,我们将从chapter03.00-calendar开始,第一个检查点将是chapter03.01-calendar。在附录附加参考材料部分的使用 JBCP 日历示例代码入门部分有更多详细信息,请务必查阅。

更新依赖项

第一步是更新项目的依赖项,包括必要的 Spring Security JAR 文件。更新之前导入的示例应用程序中的build.gradle Gradle 文件,以包含我们在接下来的几节中将要使用的 Spring Security JAR 文件。

重要提示

在整本书中,我们将演示如何使用 Gradle 提供所需的依赖项。build.gradle文件位于项目的根目录中,代表构建项目所需的所有内容(包括项目的依赖项)。请记住,Gradle 将为每个列出的依赖项下载传递依赖项。因此,如果您使用其他机制来管理依赖项,请确保您也包括传递依赖项。在手动管理依赖项时,了解 Spring Security 参考中包含其传递依赖项的列表是有用的。Spring Security 参考的链接可以在附录附加参考材料部分的补充材料部分找到。

让我们看一下以下代码片段:

// build.gradle
dependencies {
    implementation 'org.springframework.security:spring-security-config'
    implementation 'org.springframework.security:spring-security-web'
    ...
}

此代码为 Spring Framework 项目中的基本 Spring Security 功能提供了必要的依赖项。然而,根据您的具体需求,您可能需要添加额外的配置,我们将在接下来的章节中探讨。

使用 Spring 6 和 Spring Security 6

Spring 6 被一致使用。我们的示例应用程序提供了一个前一个选项的例子,这意味着您不需要做额外的工作。

在以下代码中,我们展示了添加到 build.gradle Gradle 文件中的示例片段,以利用 Gradle 的依赖项管理功能;这确保了在整个应用程序中使用正确的 Spring 版本。我们将利用 Spring 物料清单BOM)依赖项,这将确保所有由 BOM 导入的依赖项版本都能正确地一起工作:

// build.gradle
dependencyManagement {
    imports {
       mavenBom 'org.springframework:spring-framework-bom:6.1.4'
       mavenBom 'org.springframework.security:spring-security-bom:6.2.2'
    }
}
dependencies {
    ...
}

重要提示

如果你正在使用 IntelliJ IDEA,每次更新 build.gradle 文件时,请确保你右键单击项目,导航到 Gradle | 重新加载 Gradle 项目…,然后选择 确定 以更新所有依赖项。

有关 Gradle 如何处理传递依赖项以及 BOM 的更多信息,请参阅 补充材料 部分的 Gradle 文档,该文档列在 附录附加 参考资料 中。

实现 Spring Security 配置

配置过程的下一步是创建一个 Java 配置文件,代表所有需要覆盖标准 Web 请求的 Spring Security 组件。

src/main/java/com/packtpub/springsecurity/configuration/ 目录下创建一个新的 Java 文件,文件名为 SecurityConfig.java,并包含以下内容。该文件展示了我们应用程序中每个页面的用户登录要求,提供了一个登录页面,验证了用户,并要求登录用户与名为 USER 的角色相关联,以覆盖每个 URL 元素:

//src/main/java/com/packtpub/springsecurity/configuration/ SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public InMemoryUserDetailsManager userDetailsService() {
        UserDetails user = User.withDefaultPasswordEncoder()
                .username("user1@example.com")
                .password("user1")
                .roles("USER")
                .build();
        return new InMemoryUserDetailsManager(user);
    }
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests( authz -> authz
                    .requestMatchers("/**").hasRole("USER")
                    .anyRequest().authenticated()
                )
                .formLogin(withDefaults())
                .csrf(AbstractHttpConfigurer::disable);
        // For H2 Console
        http.headers(headers -> headers.frameOptions(FrameOptionsConfig::disable));
        return http.build();
    }
}

重要提示

如果你正在使用 IntelliJ IDEA,你可以通过按 F3 键轻松地审查 SecurityFilterChain。请记住,下一个检查点(chapter02.01-calendar)有一个工作解决方案,因此文件也可以从那里复制。

这是获取我们的 Web 应用程序以最小标准配置安全所需的唯一 Spring Security 配置。这种使用特定于 Spring-security 的 Java 配置的配置风格被称为 Java 配置

让我们花一分钟时间分解这个配置,以便我们可以获得一个高级的概念了解正在发生的事情。在 filterChain (HttpSecurity) 方法中,HttpSecurity 对象创建了一个 Servlet Filter,这确保了当前登录的用户与适当的角色相关联。在这个例子中,过滤器将确保用户与 ROLE_USER 相关联。重要的是要理解角色的名称是任意的。稍后,我们将创建一个具有 ROLE_ADMIN 的用户,并允许这个用户访问当前用户无法访问的额外 URL。

userDetailsService () 方法中,InMemoryUserDetailsManager 对象是 Spring Security 验证用户的方式。在这个例子中,我们使用内存数据存储来比较用户名和密码。

我们的示例和解释所描述的情况有些牵强。在生产环境中,内存中的身份验证存储不会工作。然而,它使我们能够快速启动和运行。随着我们更新应用程序以使用整个书籍中的生产质量安全,我们将逐步提高我们对 Spring Security 的理解。

重要提示

在 Spring 3.1 中,Spring 框架增加了对 Java 配置 的一般支持。自 Spring Security 3.2 版本发布以来,已经提供了 Spring Security Java 配置支持,使用户能够轻松配置 Spring Security 而不使用任何 XML。如果您熟悉 第六章 LDAP 目录服务 和 Spring Security 文档,那么您应该会发现它与 Security Java 配置 支持之间有许多相似之处。

更新您的 Web 配置

下一步涉及对 WebAppInitializer.java 文件的一系列更新。由于应用程序已经使用 Spring MVC,因此其中一些步骤已经执行。然而,我们将回顾这些要求,以确保更基本的 Spring 要求得到理解,如果您在未启用 Spring 的应用程序中使用 Spring Security。

ContextLoaderListener 类

更新 WebAppInitializer.java 文件的第一个步骤是使用 jakarta.servlet.ServletContainerInitializer,这是 Servlet 4.0+ 初始化的首选方法。Spring MVC 提供了 o.s.w.WebApplicationInitializer 接口,该接口利用了这一机制。在 Spring MVC 中,首选的方法是扩展 o.s.w.servlet.support.AbstractAnnotationConfigDispatcherServletInitializerWebApplicationInitializer 类是多态的 o.s.w.context.AbstractContext LoaderInitializer,它使用抽象的 createRootApplicationContext() 方法创建一个根 ApplicationContext,然后将其委托给注册在 ServletContext 实例中的 ContextLoaderListener,如下面的代码片段所示:

//src/main/java/com/packtpub/springsecurity/configuration/JavaConfig.java
@Configuration
@Import({ SecurityConfig.class, DataSourceConfig.class })
@ComponentScan(basePackages =
        {
                «com.packtpub.springsecurity.configuration»,
                «com.packtpub.springsecurity.dataaccess»,
                «com.packtpub.springsecurity.domain»,
                «com.packtpub.springsecurity.service»
        }
)
public class JavaConfig {}

更新的配置现在将从 WAR 文件的 classpath 中加载 the SecurityConfig.class

ContextLoaderListener 与 DispatcherServlet 的比较

o.s.web.servlet.DispatcherServlet 接口指定了使用 getServletConfigClasses() 方法独立加载的配置类:

//src/main/java/com/packtpub/springsecurity/web/configuration/WebAppInitializer
public class WebAppInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {
    @Override
    protected Class<?>[] getRootConfigClasses() {
        return null;
    }
    @Override
    protected Class<?>[] getServletConfigClasses() {
        return new Class[] { JavaConfig.class, WebMvcConfig.class };
    }
...

DispatcherServlet 类在 Spring 框架内生成一个 o.s.context.ApplicationContext,作为根 ApplicationContext 接口的一个子接口。

通常,特定于 Spring MVC 的组件在 DispatcherServletApplicationContext 接口中初始化。

另一方面,其余组件通过 ContextLoaderListener 加载。

理解这一点至关重要,即子ApplicationContext中的 Bean,如由DispatcherServlet生成的 Bean,可以引用由ContextLoaderListener建立的父ApplicationContext中的 Bean。

然而,父ApplicationContext接口不能引用子ApplicationContext中的 Bean。

这在下图中得到了说明,其中子 Bean可以引用根 Bean,但根 Bean不能引用子 Bean

图 2.1 – ContextLoaderListener 和 DispatcherServlet 的关系

图 2.1 – ContextLoaderListener 和 DispatcherServlet 的关系

与大多数 Spring Security 的使用案例一样,我们不需要 Spring Security 引用任何由 MVC 声明的 Bean。因此,我们决定让ContextLoaderListener初始化所有 Spring Security 的配置。

springSecurityFilterChain 过滤器

下一步是配置springSecurityFilterChain以通过创建AbstractSecurityWebApplicationInitializer的实现来拦截所有请求。确保在调用任何其他逻辑之前请求被安全处理是至关重要的。

下图显示了SecurityFilterChain的作用:

图 2.2 – SecurityFilterChain 的角色

图 2.2 – SecurityFilterChain 的角色

为了确保springSecurityFilterChain首先加载,我们可以使用@Order(1),如下面的配置所示:

//src/main/java/com/packtpub/springsecurity/web/configuration/SecurityWebAppInitializer
@Order(1)
public class SecurityWebAppInitializer
        extends AbstractSecurityWebApplicationInitializer {
    public SecurityWebAppInitializer() {
        super();
    }
}

SecurityWebAppInitializer类将自动为应用程序中的每个 URL 注册springSecurityFilterChain过滤器,并将添加ContextLoaderListener,该监听器加载SecurityConfig

DelegatingFilterProxy 类

o.s.web.filter.DelegatingFilterProxy类是由 Spring Web 提供的Servlet Filter,它将所有工作委托给ApplicationContext根中的一个 Spring Bean,该 Bean 必须实现jakarta.servlet.Filter接口。由于默认情况下,Bean 是通过名称查找的,使用<filter-name>值,我们必须确保我们使用springSecurityFilterChain作为<filter-name>的值。

下面是DelegatingFilterProxy如何适应Filter实例和FilterChain的示意图。

图 2.3 – DelegatingFilterProxy 的角色

图 2.3 – DelegatingFilterProxy 的角色

o.s.web.filter.DelegatingFilterProxy如何为我们的web.xml文件工作,其伪代码可以在以下代码片段中找到:

// DelegatingFilterProxy Pseudo Code
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
    Filter delegate = getFilterBean(someBeanName);
    delegate.doFilter(request, response);
}

这段代码展示了 Spring Security DelegatingFilterProxy在基于Servlet的应用程序中的交互。

FilterChainProxy 类

当与 Spring Security 一起工作时,o.s.web.filter.DelegatingFilterProxy将委托给 Spring Security 的o.s.s.web.FilterChainProxy接口。FilterChainProxy类允许 Spring Security 有条件地将任意数量的Servlet Filters应用到Servlet 请求上。我们将在本书的其余部分学习每个 Spring Security 过滤器及其在确保我们的应用程序得到适当保护中的作用。

下面的图示显示了FilterChainProxy的作用:

图 2.4 – DelegatingFilterProxy 的作用

图 2.4 – DelegatingFilterProxy 的作用

FilterChainProxy的工作伪代码如下:

public class FilterChainProxy implements Filter { void doFilter(request, response, filterChain) {
// lookup all the Filters for this request
    List<Filter> delegates =   lookupDelegates(request,response)
// invoke each filter unless the delegate decided to stop
    for delegate in delegates { if continue processing
        delegate.doFilter(request,response,filterChain)
    }
// if all the filters decide it is ok allow the
// rest of the application to run if continue processing
    filterChain.doFilter(request,response) }
}

重要提示

由于DelegatingFilterProxyFilterChainProxy在 Web 应用程序中使用时都是 Spring Security 的前门,所以在尝试找出发生了什么时,你会添加一个调试点。

运行受保护的应用程序

如果你还没有这样做,请重新启动应用程序并访问http://localhost:8080/。你将看到以下屏幕:

图 2.5 – 登录页面

图 2.5 – 登录页面

干得好!我们已经在应用程序中使用了 Spring Security 实现了基本的安全层。到现在为止,你应该能够使用user1@example.com作为user1密码登录。你会看到日历欢迎页面,它从高层次上描述了从应用程序中可以期待的安全功能。

重要提示

你的代码现在应该看起来像chapter02.01-calendar

常见问题

许多用户在应用程序中 Spring Security 的初始实现上遇到了麻烦。以下是一些常见问题和建议。我们希望确保你可以运行示例应用程序并跟随操作:

  • 在将 Spring Security 应用到应用程序之前,请确保你可以构建和部署应用程序。

  • 如果需要,请查看你的 servlet 容器的入门示例和文档。

  • 通常,使用 IDE,如 IntelliJ IDEA,来运行你的 servlet 容器是最容易的。不仅部署通常是无缝的,而且控制台日志也便于查看错误。你还可以在关键位置设置断点,以便在异常触发时更好地诊断错误。

  • 确保你使用的 Spring 和 Spring Security 版本匹配,并且应用程序中没有遗留任何意外的 Spring JAR 文件。如前所述,当使用 Gradle 时,在依赖管理部分声明 Spring 依赖项是一个好主意。

在本节中,我们建立了一个 Hello Spring Security 应用程序。我们通过导入示例应用程序、更新依赖项、配置 Spring Security、管理 Web 配置以及最终解决可能出现的常见问题来启动这个过程。

在接下来的部分中,我们将深入探讨登录后的用户体验定制。

一点点的润色

在这个点上停下来,思考我们刚刚构建的内容。你可能已经注意到了一些明显的问题,这些问题需要我们在应用程序准备好投入生产之前做一些额外的工作,并了解 Spring Security 产品。尝试列出你认为在安全实现准备在公共网站推出之前所需的更改列表。

应用 Hello World Spring Security 实现非常快,并为我们提供了一个登录页面、基于用户名和密码的认证,以及自动拦截我们的日历应用程序中的 URL。然而,自动配置设置提供的内容和我们的最终目标之间存在差距,如下列所示:

  • 虽然登录页面很有帮助,但它完全是一般性的,看起来不像我们的 JBCP 日历应用程序的其他部分。我们应该添加一个与我们的应用程序外观和感觉集成的登录表单。

  • 用户没有明显的注销方式。我们锁定了应用程序中的所有页面,包括Welcome页面,潜在的浏览者可能希望匿名浏览。我们需要重新定义角色以适应匿名用户、认证用户和管理员用户。

  • 我们没有显示任何上下文信息来告知用户他们已经认证。显示一个类似于“欢迎user1@example.com”的问候语会很好。

  • 我们不得不在SecurityConfig配置文件中硬编码用户的用户名、密码和角色信息。回想一下我们添加的userDetailsService()方法的这一部分:

User.withDefaultPasswordEncoder()
        .username("user1@example.com")
        .password("user1")
        .roles("USER")
        .build();
  • 你可以看到用户名和密码就在文件中。我们不太可能希望为系统中的每个用户都在文件中添加一个新的声明!为了解决这个问题,我们需要使用另一种类型的身份验证来更新配置。

我们将在本书的前半部分探索不同的认证选项。

自定义登录

我们已经看到了 Spring Security 如何使开始变得非常容易。现在,让我们看看我们如何可以自定义登录体验。在下面的代码片段中,我们展示了自定义登录的一些更常见方式的用法,但我们鼓励你参考 Spring Security 的参考文档,其中包括附录附加参考资料,其中包含了所有支持的属性。

让我们看看以下步骤来自定义登录:

  1. 首先,按照以下方式更新你的SecurityConfig.java文件:

    //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
    ...
    http
            .authorizeHttpRequests( authz -> authz
                    .requestMatchers("/**")
                    .hasRole("USER")
            )
            .formLogin(form -> form
                    .loginPage("/login/form")
                    .loginProcessingUrl("/login")
                    .failureUrl("/login/form?error")
                    .usernameParameter("username")
                    .passwordParameter("password")
    ....
    

    让我们看看以下代码片段中描述的以下方法:

    • loginPage()方法指定了当访问受保护的页面且用户未认证时,Spring Security 将重定向浏览器到的位置。如果没有指定登录页面,Spring Security 将重定向用户到/spring_security_login。然后,o.s.s.web.filter.FilterChainProxy将选择o.s.s.web.authentication.ui.DefaultLoginPageGeneratingFilter,作为代表之一渲染默认登录页面,因为DefaultLoginPageGeneratingFilter默认配置为处理/spring_security_login。由于我们选择覆盖默认 URL,当请求/login/form URL 时,我们将负责渲染登录页面。

    • loginProcessingUrl()方法默认为/j_spring_security_check,并指定登录表单(应包括用户名和密码)应通过 HTTP post 提交到的 URL。当 Spring Security 处理此请求时,它将尝试验证用户。

    • failureUrl()方法指定了 Spring Security 在将无效的用户名和密码提交到loginProcessingUrl()时将重定向到的页面。

    • usernameParameter()passwordParameter()方法默认为j_usernamej_password,并指定 Spring Security 在处理loginProcessingUrl()方法时将使用的 HTTP 参数。

重要提示

虽然可能很明显,但如果我们只想添加自定义登录页面,我们只需要指定loginPage()方法。然后,我们将使用剩余属性的默认值创建我们的登录表单。然而,通常一个好的做法是覆盖任何用户可见的值,以防止暴露我们正在使用 Spring Security。透露我们使用的框架是一种信息泄露,这使得攻击者更容易确定我们安全中的潜在漏洞。

  1. 下一步是创建登录页面。我们可以使用任何我们想要的技術来渲染登录页面,只要登录表单在提交时产生我们使用 Spring Security 配置指定的 HTTP 请求。通过确保 HTTP 请求符合我们的配置,Spring Security 可以为我们验证请求。创建login.xhtml文件,如下面的代码片段所示:

    //src/main/webapp/WEB-INF/tempates/login.xhtml
    <div class="alert alert-danger" th:if="${param.error != null}">
        <strong>Failed to login.</strong>
        <span th:if="${session[SPRING_SECURITY_LAST_EXCEPTION] != null}">
            <span th:text="${session[SPRING_SECURITY_LAST_EXCEPTION].message}">Invalid credentials</span>
        </span>
    </div>
    <div class="alert alert-success" th:if="${param.logout != null}">
        You have been logged out.
    </div>
    <fieldset>
        <legend>Login Form</legend>
        <div class="mb-3">
            <label class="form-label" for="username">Username</label>
            <input autofocus="autofocus" class="form-control" id="username"
                   name="username"
                   type="text"/>
        </div>
        <div class="mb-3">
            <label class="form-label" for="password">Password</label>
            <input class="form-control" id="password" name="password"
                   type="password"/>
        </div>
        <div class="mb-3">
            <input class="btn btn-primary" id="submit" name="submit" type="submit"
                   value="Login"/>
        </div>
    

重要提示

记住,如果你在书中输入任何内容时遇到问题,你可以参考下一个检查点(chapter02.02- calendar)中的解决方案。

在前面的login.xhtml文件中,以下数量的事项值得突出:

  • 表单操作应该设置为/login,以匹配我们指定的loginProcessingUrl()方法的值。出于安全考虑,Spring Security 默认只尝试在POST请求时进行身份验证。

  • 我们可以使用param.error来查看登录过程中是否出现了问题,因为我们的failureUrl()方法返回的值/login/form?error包含了HTTP参数错误。

  • 会话属性 SPRING_SECURITY_LAST_EXCEPTION 包含最后一个 o.s.s.core.AuthenticationException 异常,该异常可以用来显示登录失败的原因。可以通过利用 Spring 的国际化支持来自定义错误消息。

  • usernamepassword 输入的输入名称被选择与我们为 usernameParameter()passwordParameter() 方法在 SecurityConfig.java 配置中指定的值相对应。

  1. 最后一步是让 Spring MVC 知道我们新的 URL。这可以通过向 WebMvcConfig 添加以下方法来完成:
//src/main/java/com/packtpub/springsecurity/web/configuration/WebMvcConfig.java
@Configuration
@EnableWebMvc
public class WebMvcConfig implements WebMvcConfigurer {
...
    @Override
    public void addViewControllers(final ViewControllerRegistry registry) {
        registry.addViewController("/login/form")
              .setViewName("login");
    }
...
}

此代码添加了自定义的 login.xhtml 登录页面和相关控制器请求映射 URL:/login/form

配置注销

Spring Security 的 HttpSecurity 配置自动添加了对用户注销的支持。所需做的只是创建一个指向 /j_spring_security_logout 的链接。然而,我们将通过以下步骤演示如何通过以下步骤自定义用于注销用户的 URL:

  1. 按照以下方式更新 Spring Security 配置:

    //src/main/java/com/packtpub/springsecurity/configuration/ SecurityConfig.java
    http
            .authorizeHttpRequests( authz -> authz
                  .requestMatchers("/**")
                  .hasRole("USER")
            )
    ...
            ).logout(form -> form
                  .logoutUrl("/logout")
                  .logoutSuccessUrl("/login?logout")
            )
    ....
    
  2. 您必须提供一个链接,用户点击后可以注销。我们将更新 header.xhtml 文件,以便 注销 链接出现在每个页面上:

    //src/main/webapp/WEB-INF/templates/fragments/header.xhtml
    <div id="navbar" ...>
           ...
        <ul class="nav navbar-nav pull-right">
            <li><a id="navLogoutLink" th:href="@{/logout}"> Logout</a></li>
        </ul>
           ...
    </div>
    
  3. 最后一步是更新 login.xhtml 文件,以便在存在 logout 参数时显示注销成功的消息:

    //src/main/webapp/WEB-INF/templates/login.xhtml
    <div th:if="${param.logout != null}" class="alert alert-success"> You have been logged out.</div>
    <label for="username">Username</label>
           ...
    

重要提示

您的代码现在应该看起来像 chapter02.02-calendar

页面没有正确重定向

如果您还没有这样做,请重新启动应用程序并访问 http://localhost:8080;您将看到一个错误,如下面的截图所示:

图 2.6 – 页面未正确重定向

图 2.6 – 页面未正确重定向

发生了什么问题?问题是,由于 Spring Security 不再渲染登录页面,我们必须允许每个人(而不仅仅是 USER 角色访问 登录 页面)。如果不授予对 登录 页面的访问权限,就会发生以下情况:

  1. 我们在浏览器中请求 欢迎 页面。

  2. Spring Security 检测到 USER 角色并且我们没有认证,因此它将浏览器重定向到 登录 页面。

  3. 浏览器请求 登录 页面。

  4. Spring Security 检测到 USER 角色并且我们尚未认证,因此它再次将浏览器重定向到 登录 页面。

  5. 浏览器再次请求 登录 页面。

  6. Spring Security 检测到 USER 角色如下所示:

图 2.7 – 使用 Spring Security 的登录过程

图 2.7 – 使用 Spring Security 的登录过程

该过程可能会无限期地重复。幸运的是,对于 Firefox 来说,它意识到发生了太多的重定向,停止执行重定向,并显示一个非常有用的错误信息。在下一节中,我们将学习如何通过根据它们所需访问权限的不同来配置 URL 来修复此错误。

基于角色的基本授权

我们可以在 Hello Spring Security 的 Spring Security 配置基础上进行扩展,通过 URL 变更访问控制。在本节中,您将找到一个配置,它允许对资源如何被访问有更细粒度的控制。在配置中,Spring Security 执行以下任务:

完全忽略了以 /resources/ 开头的任何请求。这很有益处,因为我们的图片、CSS 和 JavaScript 不需要使用 Spring Security。

它允许匿名用户访问 欢迎登录注销 页面。它只允许管理员访问 所有事件 页面。

它添加了一个可以访问 所有事件 页面的管理员。

看一下下面的代码片段:

//src/main/java/com/packtpub/springsecurity/configuration/ SecurityConfig.java
...
http
        .authorizeHttpRequests( authz -> authz
              .requestMatchers("/resources/**").permitAll()
              .requestMatchers("/webjars/**").permitAll()
              .requestMatchers("/").hasAnyRole("ANONYMOUS", "USER")
              .requestMatchers("/login/*").hasAnyRole("ANONYMOUS", "USER")
              .requestMatchers("/logout/*").hasAnyRole("ANONYMOUS", "USER")
              .requestMatchers("/admin/*").hasRole("ADMIN")
              .requestMatchers("/events/").hasRole("ADMIN")
              .requestMatchers("/**").hasRole("USER")
...
@Bean
public InMemoryUserDetailsManager userDetailsService() {
    UserDetails user = User.withDefaultPasswordEncoder()
          .username("user")
          .password("user")
          .roles("USER")
          .build();
    UserDetails admin = User.withDefaultPasswordEncoder()
          .username("admin")
          .password("admin")
          .roles("ADMIN")
          .build();
    UserDetails user1 = User.withDefaultPasswordEncoder()
          .username("user1@example.com")
          .password("user1")
          .roles("USER")
          .build();
    UserDetails admin1 = User.withDefaultPasswordEncoder()
          .username("admin1@example.com")
          .password("admin1")
          .roles("USER", "ADMIN")
          .build();
    return new InMemoryUserDetailsManager(user,admin, user1, admin1);
}

重要提示

注意,我们在 Spring Security 配置中不包括应用程序的上下文根,因为 Spring Security 会为我们透明地处理上下文根。这样,如果我们决定将其部署到不同的上下文根,我们不需要更新我们的配置。

在 Spring Security 6 中,您可以使用构建器模式指定多个 requestMatchers 条目,这允许您对如何将安全性应用于应用程序的不同部分有更大的控制权。第一个 RequestMatcher 对象声明 Spring Security 应忽略任何以 /resources/ 开头的 URL,第二个 RequestMatcher 对象声明任何其他请求将由它处理。关于使用多个 requestMatchers 方法,有以下几点需要注意:

  • 如果未指定路径属性,则相当于使用 /** 路径,它匹配所有请求。

  • 每个 requestMatchers() 方法按顺序考虑,并且只应用第一个匹配项。因此,它们在配置文件中出现的顺序很重要。这意味着只有最后一个 requestMatchers() 方法可以使用匹配每个请求的路径。如果不遵循此规则,Spring Security 将产生错误。以下是不合法的,因为第一个匹配器匹配所有请求,永远不会到达第二个映射:

    http.authorizeHttpRequests((authz) -> authz
            .requestMatchers("/**").hasRole("USER")
            .requestMatchers(("/admin/*").hasRole("ADMIN"))
    
  • 默认模式由 o.s.s.web.util.AntPathRequestMatcher 支持,它将比较指定的模式与 HttpServletRequestservletPathpathInfo 方法。

  • RequestMatcher 接口用于确定请求是否匹配给定的规则。我们使用 securityMatchers 来确定是否应该将 给定 HttpSecurity 应用到给定的请求。同样,我们可以使用 requestMatchers 来确定我们应该应用到给定请求的授权规则。

  • requestMatchers()方法的路径属性上进一步细化了请求的过滤,并允许应用访问控制。你可以看到更新后的配置允许根据 URL 模式应用不同类型的访问。ANONYMOUS角色特别引人关注,因为我们没有在SecurityConfig.java中定义它。这是分配给未登录用户的默认权限。以下是从我们的SecurityConfig.java文件更新中允许匿名(未认证)用户和具有USER角色权限的用户访问登录页面的那一行。我们将在本书的第二部分更详细地介绍访问控制选项:

    .requestMatchers("/login/*").hasAnyRole("ANONYMOUS", "USER")
    

    在定义requestMatchers()方法时,有几个需要注意的事项,包括以下内容:

    • 就像每个requestMatchers()方法一样。这意味着首先指定最具体的元素非常重要。以下示例说明了没有首先指定更具体模式的配置,这将导致 Spring Security 在启动时发出警告:

      http.authorizeHttpRequests()
      …
              // matches every request, so it will not continue
              .requestMatchers("/**").hasRole("USER")
              // below will never match
              .requestMatchers("/login/form").hasAnyRole("ANONYMOUS", "USER")
      
    • 需要注意的是,如果http.authorizeHttpRequests()可以使用anyRequest(),则不应定义任何子requestMatchers()方法。这是因为anyRequest()将匹配所有与这个http.authorizeHttpRequests()标签匹配的请求。定义一个带有anyRequest()requestMatchers()子方法与requestMatchers()声明相矛盾。以下是一个示例:

      http.authorizeHttpRequests((authz) -> authz.anyRequest().permitAll())
      // This matcher will never be executed
      // and not produce an error.
             .requestMatchers("/admin/*").hasRole("ADMIN"))
      
    • requestMatchers()元素的路径属性是独立的,并且不知道anyRequest()方法。

如果你还没有这样做,请重新启动应用程序并访问http://localhost:8080。通过以下方式实验应用程序,以查看你已做的所有更新:

  1. 选择一个需要认证的链接,并观察新的登录页面。

  2. 尝试输入无效的用户名/密码并查看错误消息。

  3. 尝试以管理员身份(admin1@example.com/admin1)登录,并查看所有事件。请注意,我们能够查看所有事件。

  4. 尝试注销并查看注销成功消息。

  5. 尝试以普通用户身份(user1@example.com/user1)登录,并查看所有事件。请注意,我们得到一个访问 被拒绝页面。

重要提示

你的代码现在应该看起来像chapter02.03-calendar

基于表达式的授权

你可能已经注意到,授予所有人访问权限并不像我们可能希望的那样简洁。幸运的是,Spring Security 可以利用Spring 表达式语言SpEL)来确定用户是否有权限。在以下代码片段中,你可以看到使用SpEL与 Spring Security 一起使用时的更新。

对于 Java 配置,WebExpressionAuthorizationManager可用于帮助使用传统的 SpEL:

//src/main/java/com/packtpub/springsecurity/configuration/ SecurityConfig.java
http
        .authorizeHttpRequests( authz -> authz
              .requestMatchers("/").access(new WebExpressionAuthorizationManager("hasAnyRole('ANONYMOUS', 'USER')"))
              .requestMatchers("/resources/**").permitAll()
              .requestMatchers("/webjars/**").permitAll()
              .requestMatchers("/login/*").access(new WebExpressionAuthorizationManager("hasAnyRole('ANONYMOUS', 'USER')"))
              .requestMatchers("/logout/*").access(new WebExpressionAuthorizationManager("hasAnyRole('ANONYMOUS', 'USER')"))
              .requestMatchers("/errors/**").permitAll()
              .requestMatchers("/admin/*").access(new WebExpressionAuthorizationManager("hasRole('ADMIN')"))
              .requestMatchers("/events/").access(new WebExpressionAuthorizationManager("hasRole('ADMIN')"))
              .requestMatchers("/**").access(new WebExpressionAuthorizationManager("hasRole('USER')"))
        )

重要提示

您可能会注意到/events/安全约束是脆弱的。例如,/events URL 没有被 Spring Security 保护以限制ADMIN角色。这证明了我们需要确保提供多层安全。我们将在第十一章细粒度访问控制中利用这种类型的弱点。

access属性从hasAnyRole('ANONYMOUS', 'USER')更改为permitAll()可能看起来并不起眼,但这只是 Spring Security 表达式强大功能的一小部分。我们将在本书的后半部分更详细地介绍访问控制和 Spring 表达式。请运行应用程序以验证更新是否生效。

重要提示

建议您使用类型安全的授权管理器而不是 SpEL。您的代码现在应该看起来像chapter02.04-calendar

条件显示认证信息

目前,我们的应用程序没有指示我们是否已登录。看起来我们总是登录,因为注销链接总是显示。在本节中,我们将演示如何使用Thymeleaf的 Spring Security 标签库显示已认证用户的用户名,并条件性地显示页面的一部分。我们通过以下步骤来完成:

  1. 更新您的依赖项以包含thymeleaf-extras-springsecurity6 JAR 文件。由于我们使用 Gradle,我们将在build.gradle文件中添加一个新的依赖项声明,如下所示:

    //build.gradle
    dependencies{
    ...
        implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6:3.1.2.RELEASE'
    }
    
  2. 接下来,我们需要将SpringSecurityDialect添加到 Thymeleaf 引擎中,如下所示:

    //src/com/packtpub/springsecurity/web/configuration/ ThymeleafConfig.java
    @Bean
    public SpringTemplateEngine templateEngine(final ITemplateResolver templateResolver) {
        SpringTemplateEngine engine = new SpringTemplateEngine();
        engine.setTemplateResolver(templateResolver);
        engine.setAdditionalDialects(new HashSet<>() {{
            add(new LayoutDialect());
            add(new SpringSecurityDialect());
        }});
        return engine;
    }
    
  3. sec:authorize属性确定用户是否通过isAuthenticated()值进行认证,如果用户已认证,则显示 HTML 节点,如果用户未认证,则隐藏该节点。access属性应该与requestMatchers().access()元素中的access属性相当熟悉。实际上,这两个组件都利用了相同的 SpEL 支持。

    sec:authentication属性中存在一些属性,它们将查找当前的o.s.s.core.Authentication对象。property属性将找到o.s.s.core.Authentication对象的主属性,在这个例子中是o.s.s.core.userdetails.UserDetails。然后它获取UserDetails的 username 属性并将其渲染到页面上。如果这个细节让你感到困惑,请不要担心。我们将在第三章自定义认证中更详细地讲解这一点。

    更新header.xhtml文件以利用 Spring Security 标签库。您可以通过以下方式找到更新:

    //src/main/webapp/WEB-INF/templates/fragments/header.xhtml
    <html  >
    <body>
    …
                    <div id="navbar" class="collapse navbar-collapse">
    …
                        <ul class="nav navbar-nav pull-right" **sec:authorize="isAuthenticated()"**>
                            <li>
                                <p class="navbar-text">Welcome <div class="navbar-text" th:text="**${#authentication.name}"**>User</div></p>
                            </li>
                            <li>
                                **<a id="navLogoutLink" class="btn btn-default" role="button"  th:href="@{/logout}">Logout</a>**
                            </li>
                            <li>&nbsp;|&nbsp;</li>
                        </ul>
                        <ul class="nav navbar-nav pull-right" **sec:authorize=" ! isAuthenticated()"**>
                            <li><a id="navLoginLink" class="btn btn-default" role="button" th:href="@{/login/form}">Login</a></li>
                            <li>&nbsp;|&nbsp;</li>
                        </ul>
                    </div>
                </div>
            </nav>
       </div>
    ...
    

如果您还没有这样做,请重新启动应用程序以查看我们所做的更新。在此阶段,您可能会意识到我们仍在显示我们没有访问权限的链接。例如,user1@example.com 不应该看到“所有事件”页面的链接。请放心,当我们在第十一章细粒度访问控制中更详细地介绍标签时,我们会解决这个问题

重要提示

您的代码现在应该看起来像这样:chapter02.05-calendar

登录后的行为定制

我们已经讨论了如何在登录期间定制用户体验,但有时有必要在登录后定制行为。在本节中,我们将讨论 Spring Security 在登录后的行为,并提供一个简单的机制来自定义此行为。

在默认配置下,Spring Security 在成功认证后有两个不同的流程。第一种情况是如果用户从未访问过需要认证的资源。在这种情况下,在成功登录尝试后,用户将被发送到与 formLogin() 方法链接的 defaultSuccessUrl() 方法。如果未定义,defaultSuccessUrl() 将是应用程序的上下文根。

如果用户在认证之前请求受保护的页面,Spring Security 将使用 o.s.s.web.savedrequest.RequestCache 记住认证之前访问的最后受保护的页面。在成功认证后,Spring Security 将将用户发送到认证之前访问的最后受保护的页面。例如,如果一个未认证的用户请求“我的事件”页面,他们将被发送到登录页面。

在成功认证后,他们将被发送到之前请求的 我的 事件 页面。

一个常见的需求是根据用户的角色自定义 Spring Security,以便将用户发送到不同的 defaultSuccessUrl() 方法。让我们看看如何通过执行以下步骤来实现这一点:

  1. 第一步是配置在 formLogin() 方法之后链接的 defaultSuccessUrl() 方法。请更新 SecurityConfig.java 文件:

    //src/main/java/com/packtpub/springsecurity/configuration/ SecurityConfig.java
    .formLogin(form -> form
           .loginPage("/login/form")
           .loginProcessingUrl("/login")
           .failureUrl("/login/form?error")
           .usernameParameter("username")
           .passwordParameter("password")
           .defaultSuccessUrl("/default")
           .permitAll()
    
  2. 下一步是创建一个处理 /default 的控制器。在以下代码中,您将找到一个示例 Spring MVC 控制器 DefaultController,它演示了如何将管理员重定向到 所有事件 页面,以及其他用户重定向到 欢迎 页面。在以下位置创建一个新文件:

    //src/main/java/com/packtpub/springsecurity/web/controllers/DefaultController.java
    @Controller
    public class DefaultController {
        @RequestMapping("/default")
        public String defaultAfterLogin(HttpServletRequest request) {
           if (request.isUserInRole("ADMIN")) {
              return "redirect:/events/";
           }
           return "redirect:/";
        }
    }
    

重要提示

在 IntelliJ IDEA 中,您可以按 Alt + Enter 自动添加缺失的导入。

关于 DefaultController 和其工作方式,有一些事情需要指出。首先,Spring Security 使得 HttpServletRequest 参数对当前登录用户知情。在这种情况下,我们可以检查用户属于哪个角色,而无需依赖 Spring Security 的任何 API。

这很好,因为如果 Spring Security 的 API 发生变化,或者我们决定想要切换我们的安全实现,我们需要更新的代码会更少。还应注意的是,虽然我们使用 Spring MVC 控制器来实现这个控制器,但如果我们愿意,我们的defaultSuccessUrl()方法可以被任何控制器实现(例如,Struts、标准 servlet 等)处理。

  1. 如果你希望始终跳转到defaultSuccessUrl()方法,你可以利用defaultSuccessUrl()方法的第二个参数,该参数是一个用于始终使用的Boolean类型。在我们的配置中我们不会这样做,但你可以在以下示例中看到它:

    .defaultSuccessUrl("/default", true)
    
  2. 你现在可以尝试一下。重新启动应用程序,直接转到我的事件页面,然后登录;你会看到你已经在我的****事件页面上了。

  3. 接下来,注销并尝试以user1@example.com登录。

  4. 你应该使用admin1@example.com,你将被发送到所有****事件页面。

重要提示

你的代码现在应该看起来像chapter02.06-calendar

摘要

在本章中,我们应用了一个非常基本的 Spring Security 配置。主要目标是概述在 Spring 6 应用程序中实现 Spring Security 所需的步骤和关键概念。为了实现这一目标,我们首先导入示例应用程序,然后更新依赖项,配置 Spring Security,管理 Web 配置,最后解决常见问题。

此外,我们解释了如何自定义用户的登录和注销体验,并演示了如何根据角色和 SpEL 显示基本信息。我们通过在登录后自定义行为来结束这一章。

在下一章中,我们将讨论 Spring Security 中的认证工作原理以及我们如何根据需要对其进行自定义。**

第三章:自定义认证

第二章 中,Spring Security 入门,我们展示了如何使用内存数据存储来认证用户。在本章中,我们将探讨通过扩展 Spring Security 的认证支持来使用我们现有的 API 集合来解决一些常见、现实世界的问题。通过这次探索,我们将了解 Spring Security 用于认证用户的每个构建块。

在本章中,我们将涵盖以下主题:

  • 利用 Spring Security 的注解和基于 Java 的配置

  • 发现如何获取当前登录用户的详细信息

  • 在创建新账户后添加登录功能

  • 学习向 Spring Security 表明用户已认证的最简单方法

  • 创建自定义的 UserDetailsServiceAuthenticationProvider 实现,以正确地将应用程序的其余部分与 Spring Security 解耦

  • 添加基于域的认证以展示如何使用不仅仅是用户名和密码进行认证

本章的代码示例链接在此:packt.link/5tPFD

Spring Security 的认证架构

应用程序安全领域基本上涉及解决两个在很大程度上独立的问题:认证(识别 你是谁)和授权(确定 你被允许做什么)。

有时,个人可能会将术语 访问控制授权 互换使用,增加了一层潜在的混淆。

然而,将其视为 访问控制 可以提供清晰度,考虑到在其他地方术语 授权 的多方面使用。

Spring Security 采用了一种故意设计的架构,将认证与授权分离,为每个提供不同的策略和扩展点。在本节中,我们将揭示 Spring Security 用于认证的主要架构组件。

SecurityContextHolder

Spring Security 的认证模型的核心是 SecurityContextHolder。它包含 SecurityContext

图 3.1 – Spring Security 的 SecurityContextHolder

图 3.1 – Spring Security 的 SecurityContextHolder

SecurityContext 接口

SecurityContextHolder 是 Spring Security 存储谁已认证的细节的地方。Spring Security 不关心 SecurityContextHolder 如何被填充。如果它包含一个值,它就被用作当前已认证的用户。

Authentication 接口

Spring Security 中的 Authentication 接口具有双重用途:

  • 它作为 AuthenticationManager 的输入,为认证提供用户提供的凭据。在这个上下文中,isAuthenticated() 方法返回 false。

  • 它作为当前已认证用户的表示,可以从 SecurityContext 中检索。

认证接口中的关键组件包括以下内容:

  • UserDetails,尤其是在用户名/密码认证中。

  • 凭证:这通常包括密码。在许多情况下,认证后清除这些信息以防止意外泄露。

  • 表示授予用户高级权限的GrantedAuthority实例。例如包括角色和范围。

AuthenticationManager接口

AuthenticationManager充当 API,指定 Spring Security 的过滤器如何执行认证。随后,由调用控制器(即 Spring Security 的Filters实例)在SecurityContextHolder上建立认证结果。

如果你没有与 Spring Security 的Filters实例集成,你可以直接设置SecurityContextHolder,无需AuthenticationManager

虽然AuthenticationManager的实现可以不同,但常见的选择通常是ProviderManager

ProviderManager

ProviderManagerAuthenticationManager经常使用的实现方式。它将责任委托给一系列AuthenticationProvider实例。每个AuthenticationProvider都有能力表达认证是否成功、失败或委托决策给后续的AuthenticationProvider。如果配置的任何AuthenticationProvider实例都无法进行认证,认证过程将导致ProviderNotFoundException。这种特定的AuthenticationException表示ProviderManager缺乏配置以支持其提供的特定认证类型。

图 3.2 – Spring Security SecurityContextHolder

图 3.2 – Spring Security SecurityContextHolder

在实际应用中,每个AuthenticationProvider都配备有执行特定认证方法的能力。例如,一个AuthenticationProvider可能用于验证用户名/密码,而另一个则能够认证SAML Assertion。这种设置使得每个AuthenticationProvider能够处理特定形式的认证,适应各种认证类型,并只提供一个单一的AuthenticationManager bean。

图 3.3 – Spring Security SecurityContextHolder

图 3.3 – Spring Security SecurityContextHolder

此外,ProviderManager允许配置一个可选的父AuthenticationManager。当没有AuthenticationProvider能够执行认证时,将咨询这个父AuthenticationManager。父AuthenticationManager可以采取任何形式的AuthenticationManager,其中ProviderManager通常是选择类型。

多个 ProviderManager 实例可以共享一个父级 AuthenticationManager。这种情况在多个 SecurityFilterChain 实例共享一个共同的认证过程(由共享的父级 AuthenticationManager 表示)时相当典型。然而,这些实例也可能采用不同的认证机制,每个机制由不同的 ProviderManager 实例管理。

图 3.4 – Spring Security 的

图 3.4 – Spring Security 的 SecurityContextHolder

默认情况下,ProviderManager 会尝试在成功认证请求返回的 Authentication 对象中移除任何敏感凭证信息。这种预防措施确保敏感细节,如密码,不会在 HttpSession 中存储超过必要的时间。

AuthenticationProvider 接口

可以将多个 AuthenticationProviders 实例注入到 ProviderManager 中。每个 AuthenticationProvider 负责一种特定的认证形式。例如,DaoAuthenticationProvider 设计用于基于用户名/密码的认证,而 JwtAuthenticationProvider 专门用于认证 JSON Web Tokens。

探索 JBCP 日历架构

我们将从这个章节开始,分析 JBPC 日历架构中的领域模型。

第一章,“不安全应用程序的解剖”和 第二章,“Spring Security 入门”中,我们使用了 Spring 物料清单BOM)来帮助依赖关系管理,但项目中的其余代码使用了核心 Spring 框架,并需要手动配置。从本章开始,我们将使用 Spring Boot 来简化应用程序配置过程。我们将为 Spring Boot 和非 Boot 应用程序创建相同的 Spring Security 配置。我们将在 附录,“附加参考材料”中详细介绍 Spring IO 和 Spring Boot。

在接下来的章节中,我们将深入研究 JBCP 日历应用程序的领域模型。我们的目标是了解将 Spring Security 与个性化用户配置和 API 集成的过程。

CalendarUser 对象

我们的日历应用程序使用一个名为 CalendarUser 的领域对象,其中包含有关用户的信息,如下所示:

//src/main/java/com/packtpub/springsecurity/domain/CalendarUser.java
public class CalendarUser implements Serializable {
    private Integer id;
    private String firstName;
    private String lastName;
    private String email;
    private String password;
... accessor methods omitted ..
}

Event 对象

我们的应用程序有一个包含每个事件信息的 Event 对象,如下所示:

//src/main/java/com/packtpub/springsecurity/domain/Event.java
public record Event(
       Integer id,
       @NotEmpty(message = "Summary is required") String summary,
       @NotEmpty(message = "Description is required") String description,
       @NotNull(message = "When is required") Calendar dateWhen,
       @NotNull(message = "Owner is required") CalendarUser owner,
       CalendarUser attendee
) {}

CalendarService 接口

我们的应用程序包含一个 CalendarService 接口,可以用来访问和存储我们的领域对象。CalendarService 的代码如下:

//src/main/java/com/packtpub/springsecurity/service/CalendarService.java
public interface CalendarService {
    CalendarUser getUser(int id);
    CalendarUser findUserByEmail(String email);
    List<CalendarUser> findUsersByEmail(String partialEmail);
    int createUser(CalendarUser user);
    Event getEvent(int eventId);
    int createEvent(Event event);
    List<Event> findForUser(int userId);
    List<Event> getEvents();
}

我们不会详细介绍CalendarService中使用的函数,但它们应该是相当直接的。如果您想了解每个函数的作用,请参阅示例代码中的 Javadoc。

UserContext 接口

与大多数应用程序一样,我们的应用程序需要我们与当前登录的用户进行交互。我们创建了一个非常简单的界面,称为UserContext,用于管理当前登录的用户,如下所示:

//src/main/java/com/packtpub/springsecurity/service/UserContext.java
public interface UserContext {
    CalendarUser getCurrentUser();
    void setCurrentUser(CalendarUser user);
}

这意味着我们的应用程序可以调用UserContext.getCurrentUser()来获取当前登录用户的详细信息。它还可以调用UserContext.setCurrentUser(CalendarUser)来指定哪个用户已登录。在本章的后面部分,我们将探讨如何编写一个使用 Spring Security 访问我们的当前用户并使用SecurityContextHolder获取其详细信息的接口实现。

Spring Security 提供了多种不同的方法来验证用户。然而,最终结果是 Spring Security 将o.s.s.core.context.SecurityContext填充为o.s.s.core.AuthenticationAuthentication对象代表我们在认证时收集的所有信息(用户名、密码、角色等)。然后,SecurityContext接口被设置在o.s.s.core.context.SecurityContextHolder接口上。这意味着 Spring Security 和开发人员可以使用SecurityContextHolder来获取当前登录用户的信息。以下是如何获取当前用户名的示例:

String username = SecurityContextHolder.getContext()
       .getAuthentication()
       .getName();

重要提示

应该注意的是,在Authentication对象上始终应该进行null检查,因为如果用户未登录,它可能是null

SpringSecurityUserContext 接口

当前UserContext实现UserContextStub是一个存根,总是返回相同的用户。这意味着无论谁登录,我的事件页面都会显示相同的用户。让我们更新我们的应用程序,以便利用当前的 Spring Security 用户名,以确定在我的****事件页面上显示哪些事件。

重要提示

您应该从chapter03.00- calendar中的示例代码开始。

看一下以下步骤:

  1. 第一步是注释掉UserContextStub上的@Component属性,这样我们的应用程序就不再使用我们的扫描结果。

重要提示

@Component注解与在com/packtpub/springsecurity/web/configuration/WebMvcConfi g.java中找到的@Configuration注解一起使用,用于自动创建 Spring bean,而不是为每个 bean 创建显式的 XML 或 Java 配置。您可以在docs.spring.io/spring-framework/reference/core/beans/classpath-scanning.xhtml了解更多关于 Spring 扫描类路径的信息。

看一下下面的代码片段:

...
@Component
public class UserContextStub implements UserContext {
...
  1. 下一步是利用SecurityContext来获取当前登录的用户。我们已经在本章的代码中包含了SpringSecurityUserContext,它与必要的依赖项连接,但没有任何实际的功能。

  2. 打开SpringSecurityUserContext.java文件,并添加@Component注解。接下来,替换getCurrentUser实现,如下面的代码片段所示:

    //src/main/java/com/packtpub/springsecurity/service/ SpringSecurityUserContext.java
    @Component
    public class SpringSecurityUserContext implements UserContext {
        private final CalendarService calendarService;
        private final UserDetailsService userDetailsService;
        public SpringSecurityUserContext(final CalendarService calendarService,
              final UserDetailsService userDetailsService) {
           if (calendarService == null) {
              throw new IllegalArgumentException("calendarService cannot be null");
           }
           if (userDetailsService == null) {
              throw new IllegalArgumentException("userDetailsService cannot be null");
           }
           this.calendarService = calendarService;
           this.userDetailsService = userDetailsService;
        }
        @Override
        public CalendarUser getCurrentUser() {
           SecurityContext context = SecurityContextHolder.getContext();
           Authentication authentication = context.getAuthentication();
           if (authentication == null) {
              return null;
           }
           User user = (User) authentication.getPrincipal();
           String email = user.getUsername();
           if (email == null) {
              return null;
           }
           CalendarUser result = calendarService.findUserByEmail(email);
           if (result == null) {
              throw new IllegalStateException(
                    "Spring Security is not in synch with CalendarUsers. Could not find user with email " + email);
           }
           return result;
        }
        @Override
        public void setCurrentUser(CalendarUser user) {
           throw new UnsupportedOperationException();
        }
    }
    

    我们的代码从当前的 Spring Security Authentication对象中获取用户名,并利用它通过电子邮件地址查找当前的CalendarUser对象。由于我们的 Spring Security 用户名是电子邮件地址,我们可以使用电子邮件地址将CalendarUser与 Spring Security 用户关联起来。请注意,如果我们要将账户关联起来,我们通常会希望使用我们生成的键来完成,而不是可能发生变化的东西(即电子邮件地址)。我们遵循良好的实践,只将我们的域对象返回给应用程序。这确保了我们的应用程序只知道我们的CalendarUser对象,因此不会与 Spring Security 耦合。

    这段代码看起来可能与我们在第二章“Spring Security 入门”中使用的sec:authorize= "isAuthenticated()" 标签属性时非常相似。实际上,Spring Security 标签库以与我们这里相同的方式使用SecurityContextHolder。我们可以使用我们的UserContext接口将当前用户放置在HttpServletRequest上,从而消除对 Spring Security 标签库的依赖。

  3. 启动应用程序,访问http://localhost:8080/,并使用admin1@example.com作为用户名和admin1作为密码进行登录。

  4. 访问我的事件页面,您将看到只显示当前用户的事件,该用户是事件的所有者或参与者。

  5. 尝试创建一个新事件;您将观察到事件的所有者现在与登录用户相关联。

  6. 从应用程序中注销,并使用user1@example.com作为用户名和user1作为密码重复这些步骤。

重要提示

您的代码现在应该看起来像chapter03.01-calendar

在本节中,我们介绍了 JBCP 日历架构。在下一节中,我们将看到如何使用SecurityContextHolder来管理新用户。

使用 SecurityContextHolder 登录新用户

一个常见的需求是允许用户创建新账户,然后自动将其登录到应用程序。在本节中,我们将描述利用SecurityContextHolder来指示用户已认证的最简单方法。

在 Spring Security 中管理用户

第一章不安全应用程序的解剖 中提供的应用程序提供了一个创建新的 CalendarUser 对象的机制,因此用户注册后创建我们的 CalendarUser 对象应该相当简单。然而,Spring Security 对 CalendarUser 一无所知。这意味着我们还需要在 Spring Security 中添加新用户。不用担心,我们将在本章的后面部分消除对用户的双重维护需求。

Spring Security 提供了一个 o.s.s.provisioning.UserDetailsManager 接口用于管理用户。还记得我们之前的内存中 Spring Security 配置吗?

auth.inMemoryAuthentication(). withUser("user").password("user").roles("USER");

SecurityConfig.userDetailsService() 方法创建了一个内存中的 UserDetailsManager 实现,名为 o.s.s.provisioning.InMemoryUserDetailsManager,它可以用来创建新的 Spring Security 用户。

让我们通过以下步骤来了解如何在 Spring Security 中管理用户:

  1. 要使用基于 Java 的配置来暴露 UserDetailsManager,我们需要创建 InMemoryUserDetailsManager

    //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
    @Bean
    public InMemoryUserDetailsManager userDetailsService() {
           UserDetails user1 = User.withDefaultPasswordEncoder()
           .username("user1@example.com")
           .password("user1")
           .roles("USER")
           .build();
           UserDetails admin1 = User.withDefaultPasswordEncoder()
           .username("admin1@example.com")
           .password("admin1")
           .roles("USER", "ADMIN")
           .build();
           return new InMemoryUserDetailsManager(user1, admin1);
    }
    
  2. 一旦我们在 Spring 配置中暴露了 UserDetailsManager 接口,我们只需要更新现有的 CalendarService 实现,即 DefaultCalendarService,以在 Spring Security 中添加用户。对 DefaultCalendarService.java 文件进行以下更新:

    //src/main/java/com/packtpub/springsecurity/service/ DefaultCalendarService.java
    public int createUser(final CalendarUser user) {
           List<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList("ROLE_USER");
           UserDetails userDetails = new User(user.getEmail(), user.getPassword(), authorities);
           userDetailsManager.createUser(userDetails);
           return userDao.createUser(user);
           }
    
  3. 要利用 UserDetailsManager,我们首先将 CalendarUser 转换为 Spring Security 的 UserDetails 对象。

  4. 之后,我们使用 UserDetailsManager 来保存 UserDetails 对象。这种转换是必要的,因为 Spring Security 不了解如何保存我们的自定义 CalendarUser 对象,因此我们必须将 CalendarUser 映射到 Spring Security 理解的对象。您将注意到 GrantedAuthority 对象对应于我们的 SecurityConfig 文件的 authorities 属性。我们为了简单起见并因为我们的现有系统中没有角色的概念而将其硬编码。

在应用程序中登录新用户

现在我们能够向系统中添加新用户,我们需要表明该用户已认证。更新 SpringSecurityUserContext 以在 Spring Security 的 SecurityContextHolder 对象上设置当前用户,如下所示:

@Override
public void setCurrentUser(CalendarUser user) {
    if (user == null) {
       throw new IllegalArgumentException("user cannot be null");
    }
    UserDetails userDetails = userDetailsService.loadUserByUsername(user.getEmail());
    UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails,
          user.getPassword(), userDetails.getAuthorities());
    SecurityContextHolder.getContext().setAuthentication(authentication);
}

我们执行的第一步是将我们的 CalendarUser 对象转换为 Spring Security 的 UserDetails 对象。这是必要的,因为正如 Spring Security 不知道如何保存我们的自定义 CalendarUser 对象一样,Spring Security 也不理解如何使用我们的自定义 CalendarUser 对象进行安全决策。我们使用 Spring Security 的 o.s.s.core.userdetails.UserDetailsService 接口来获取与 UserDetailsManager 保存的相同的 UserDetails 对象。UserDetailsService 接口提供了 Spring Security 的 UserDetailsManager 对象所提供功能的一个子集,我们之前已经见过。

接下来,我们创建一个UsernamePasswordAuthenticationToken对象,并将UserDetails、密码和GrantedAuthority放入其中。最后,我们在SecurityContextHolder上设置认证。在 Web 应用程序中,Spring Security 会自动将SecurityContextHolder中的SecurityContext对象与我们 HTTP 会话关联起来。

重要提示

重要的是 Spring Security 不能被指示忽略一个 URL(即使用permitAll()方法),如在第2 章“使用 Spring Security 入门”中讨论的那样,其中访问或设置了SecurityContextHolder。这是因为 Spring Security 将忽略请求,因此不会为后续请求持久化SecurityContext。会话管理支持由几个协同工作的组件组成,以提供功能,因此我们使用了securityContext.requireExplicitSave(false)来持久化会话。

这种方法的优点是无需再次击中数据存储。在我们的情况下,数据存储是一个内存数据存储,但它可以由数据库支持,这可能会带来一些安全影响。这种方法的缺点是我们无法大量重用代码。由于此方法调用不频繁,我们选择重用代码。一般来说,最好单独评估每种情况,以确定哪种方法最有意义。

更新SignupController

应用程序有一个SignupController对象,它处理创建新的CalendarUser对象的 HTTP 请求。最后一步是更新SignupController以创建我们的用户并指示他们已登录。对SignupController进行以下更新:

//src/main/java/com/packtpub/springsecurity/web/controllers/SignupController.java
@PostMapping("/signup/new")
public String signup(final @Valid SignupForm signupForm,
final BindingResult result,
       RedirectAttributes redirectAttributes) {
       if (result.hasErrors()) {
       return "signup/form";
       }
       String email = signupForm.getEmail();
       if (calendarService.findUserByEmail(email) != null) {
       result.rejectValue("email", "errors.signup.email", "Email address is already in use. FOO");
       redirectAttributes.addFlashAttribute("error", "Email address is already in use. FOO");
       return "signup/form";
       }
       CalendarUser user = new CalendarUser(null, signupForm.getFirstName(), signupForm.getLastName(), email, signupForm.getPassword());
       int id = calendarService.createUser(user);
       user.setId(id);
       userContext.setCurrentUser(user);
       redirectAttributes.addFlashAttribute("message", "You have successfully signed up and logged in.");
       return "redirect:/";
       }

如果您还没有这样做,请重新启动应用程序,访问http://localhost:8080/,创建一个新用户,您将看到新用户会自动登录。

重要提示

您的代码现在应该看起来像chapter03.02-calendar

在本节中,我们介绍了新用户注册工作流程。在下一节中,我们将创建一个自定义的UserDetailsService对象。

创建自定义UserDetailsService对象

虽然我们可以将我们的领域模型(CalendarUser)与 Spring Security 的领域模型(UserDetails)关联起来,但我们必须维护用户的多个表示形式。为了解决这种双重维护问题,我们可以实现一个自定义的UserDetailsService对象,将我们的现有CalendarUser领域模型转换为 Spring Security 的UserDetails接口的实现。通过将我们的CalendarUser对象转换为UserDetails,Spring Security 可以使用我们的自定义领域模型进行安全决策。这意味着我们不再需要管理用户的两种不同表示形式。

CalendarUserDetailsService

到目前为止,我们需要为用户提供两种不同的表示形式:一个用于 Spring Security 进行安全决策,另一个用于我们的应用程序将域对象与之关联。创建一个名为CalendarUserDetailsService的新类,以便 Spring Security 了解我们的CalendarUser对象。这将确保 Spring Security 可以根据我们的域模型做出决策。创建一个名为CalendarUserDetailsService.java的新文件,如下所示:

//src/main/java/com/packtpub/springsecurity/service/CalendarUserDetailsService.java
@Component
public class CalendarUserDetailsService implements UserDetailsService {
    private static final Logger logger = LoggerFactory
          .getLogger(CalendarUserDetailsService.class);
    private final CalendarUserDao calendarUserDao;
    public CalendarUserDetailsService(final CalendarUserDao calendarUserDao) {
       if (calendarUserDao == null) {
          throw new IllegalArgumentException("calendarUserDao cannot be null");
       }
       this.calendarUserDao = calendarUserDao;
    }
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
       CalendarUser user = calendarUserDao.findUserByEmail(username);
       if (user == null) {
          throw new UsernameNotFoundException("Invalid username/password.");
       }
       Collection<? extends GrantedAuthority> authorities = CalendarUserAuthorityUtils.createAuthorities(user);
       return new User(user.getEmail(), user.getPassword(), authorities);
    }
}

在这里,我们利用CalendarUserDao通过电子邮件地址获取CalendarUser。我们注意不要返回 null 值;相反,应该抛出UsernameNotFoundException异常,因为返回null会破坏UserDetailsService接口。

然后,我们将CalendarUser转换为UserDetails,由用户实现,就像我们在前面的章节中所做的那样。

我们现在利用一个名为CalendarUserAuthorityUtils的实用工具类,我们在示例代码中提供了它。这将根据电子邮件地址创建GrantedAuthority,以便我们可以支持用户和管理员。如果电子邮件以admin开头,则用户被视为ROLE_ADMINROLE_USER。否则,用户被视为ROLE_USER。当然,我们不会在真实的应用程序中这样做,但正是这种简单性使我们能够专注于这个课程。

配置 UserDetailsService

现在我们有一个新的UserDetailsService对象,让我们更新 Spring Security 配置以利用它。由于我们利用了classpath扫描和@Component注解,我们的CalendarUserDetailsService类会自动添加到 Spring 配置中。这意味着我们只需要更新 Spring Security 以引用我们刚刚创建的CalendarUserDetailsService类。userDetailsService()方法,Spring Security 的UserDetailsService内存实现,因为我们现在提供了自己的UserDetailsService实现。

更新SecurityConfig.java文件,如下所示以声明一个具有默认映射的DelegatingPasswordEncoder。可以添加额外的映射,并且编码将更新以符合最佳实践。然而,由于DelegatingPasswordEncoder的性质,更新不应影响用户:

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
...
    }
   @Bean
   public PasswordEncoder encoder() {
       return PasswordEncoderFactories.createDelegatingPasswordEncoder();
   }
}

移除对 UserDetailsManager 的引用

我们需要移除在DefaultCalendarService中添加的代码,该代码使用UserDetailsManager来同步 Spring Security 的o.s.s.core.userdetails.User接口和CalendarUser。首先,由于 Spring Security 现在引用了CalendarUserDetailsService,这段代码不再必要。其次,由于我们移除了inMemoryAuthentication()方法,我们的 Spring 配置中没有定义UserDetailsManager对象。继续移除DefaultCalendarService中找到的所有UserDetailsManager引用。更新将类似于以下示例片段:

启动应用程序并查看 Spring Security 的内存 UserDetailsManager 对象现在不再必要(我们已从 SecurityConfig.java 文件中删除它)。

重要提示

您的代码现在应该看起来像 chapter03.03-calendar

CalendarUserDetails 对象

我们已经成功消除了同时管理 Spring Security 用户和我们的 CalendarUser 对象的需求。然而,对于我们来说,仍然需要不断地在这两个对象之间进行转换仍然很麻烦。因此,我们将创建一个 CalendarUserDetails 对象,它可以被称作 UserDetailsCalendarUser。将 CalendarUserDetailsService 更新为使用 CalendarUserDetails,如下所示:

@Component
public class CalendarUserDetailsService implements UserDetailsService {
    private final CalendarUserDao calendarUserDao;
    public CalendarUserDetailsService(CalendarUserDao calendarUserDao) {
       if (calendarUserDao == null) {
          throw new IllegalArgumentException("calendarUserDao cannot be null");
       }
       this.calendarUserDao = calendarUserDao;
    }
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        ...
       }
       return new CalendarUserDetails(user);
    }
    private final class CalendarUserDetails extends CalendarUser implements UserDetails {
       CalendarUserDetails(CalendarUser user) {
          super(user.getId(), user.getFirstName(), user.getLastName(), user.getEmail(), user.getPassword());
       }
       @Override
       public Collection<? extends GrantedAuthority> getAuthorities() {
          return CalendarUserAuthorityUtils.createAuthorities(this);
       }
       @Override
       public String getUsername() {
          return getEmail();}
       @Override
       public boolean isAccountNonExpired() {
          return true;}
       @Override
       public boolean isAccountNonLocked() {
          return true;}
       @Override
       public boolean isCredentialsNonExpired() {
          return true; }
       @Override
       public boolean isEnabled() {
         return true;   }
    }
}

在下一节中,我们将看到我们的应用程序现在可以引用当前 CalendarUser 对象上的主体认证。然而,Spring Security 可以继续将 CalendarUserDetails 作为 UserDetails 对象处理。

SpringSecurityUserContext 简化

我们已将 CalendarUserDetailsService 更新为返回一个扩展 CalendarUser 并实现 UserDetailsUserDetails 对象。这意味着,我们不再需要在两个对象之间进行转换,我们可以简单地引用 CalendarUser 对象。按照以下方式更新 SpringSecurityUserContext

@Component
public class SpringSecurityUserContext implements UserContext {
    @Override
    public CalendarUser getCurrentUser() {
       SecurityContext context = SecurityContextHolder.getContext();
       Authentication authentication = context.getAuthentication();
       if (authentication == null) {
          return null;
       }
       return (CalendarUser) authentication.getPrincipal();
    }
    @Override
    public void setCurrentUser(CalendarUser user) {
       if (user == null) {
          throw new IllegalArgumentException("user cannot be null");
       }
       Collection<? extends GrantedAuthority> authorities = CalendarUserAuthorityUtils.createAuthorities(user);
       UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(user,
             user.getPassword(), authorities);
       SecurityContextHolder.getContext().setAuthentication(authentication);
    }
}

更新不再需要使用 CalendarUserDao 或 Spring Security 的 UserDetailsService 接口。还记得我们之前章节中的 loadUserByUsername 方法吗?这个方法调用的结果成为认证的主体。由于我们更新的 loadUserByUsername 方法返回一个扩展 CalendarUser 的对象,我们可以安全地将 Authentication 对象的主体强制转换为 CalendarUser。在调用 setCurrentUser 方法时,我们可以将 CalendarUser 对象作为主体传递给 UsernamePasswordAuthenticationToken 构造函数。这允许我们在调用 getCurrentUser 方法时仍然将主体强制转换为 CalendarUser 对象。

显示自定义用户属性

现在,由于 CalendarUser 已填充到 Spring Security 的认证中,我们可以更新我们的 UI 以显示当前用户的姓名而不是电子邮件地址。使用以下代码更新 header.xhtml 文件:

//src/main/resources/templates/fragments/header.xhtml
<li class="nav-item">
    <a class="nav-link" href="#">Welcome <span class="navbar-text"
                                               th:text="${#authentication.getPrincipal().getName()}"> </span></a>
</li>

内部,"${#authentication.getPrincipal().getName()}" 标签属性执行以下代码。注意,高亮显示的值与我们在 header.xhtml 文件中指定的 authentication 标签的 property 属性相关联:

SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
CalendarUser user = (CalendarUser) authentication.getPrincipal();
String firstAndLastName = user.getName();

重新启动应用程序,访问 http://localhost:8080/ 并登录以查看更新。现在您应该看到的是用户的首字母和姓氏,而不是当前用户的电子邮件地址。

重要提示

您的代码现在应该看起来像 chapter03.04-calendar

在本节中配置 CalendarUserDetailsServiceUserDetailsService 并简化 SpringSecurityUserContext 以显示自定义用户属性后,在下一节中,我们将探讨如何创建自定义的 AuthenticationProvider

创建自定义 AuthenticationProvider 对象

Spring Security 将委托给一个AuthenticationProvider对象来确定用户是否已认证。这意味着我们可以编写自定义的AuthenticationProvider实现来告知 Spring Security 如何以不同的方式认证。好消息是,Spring Security 提供了相当多的AuthenticationProvider对象,所以大多数情况下您不需要创建一个。事实上,直到这一点,我们一直在使用 Spring Security 的o.s.s.authentication.dao.DaoAuthenticationProvider对象,该对象比较UserDetailsService返回的用户名和密码。

创建 CalendarUserAuthenticationProvider

在本节的其余部分,我们将创建一个名为CalendarUserAuthenticationProvider的自定义AuthenticationProvider对象,它将替换CalendarUserDetailsService。然后,我们将使用CalendarUserAuthenticationProvider来考虑一个额外的参数,以支持从多个域验证用户。

重要提示

我们必须使用AuthenticationProvider对象而不是UserDetailsService,因为UserDetails接口没有域参数的概念。

创建一个名为CalendarUserAuthenticationProvider的新类,如下所示:

//src/main/java/com/packtpub/springsecurity/authentication/ CalendarUserAuthenticationProvider.java
@Component
public class CalendarUserAuthenticationProvider implements AuthenticationProvider {
    private final CalendarService calendarService;
    public CalendarUserAuthenticationProvider(final CalendarService calendarService) {
       if (calendarService == null) {
          throw new IllegalArgumentException("calendarService cannot be null");
       }
       this.calendarService = calendarService;
    }
    @Override
    public Authentication authenticate(final Authentication authentication) throws AuthenticationException {
       UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;
       String email = token.getName();
       CalendarUser user = email == null ? null : calendarService.findUserByEmail(email);
       if (user == null) {
          throw new UsernameNotFoundException("Invalid username/password");
       }
       String password = user.getPassword();
       if (!password.equals(token.getCredentials())) {
          throw new BadCredentialsException("Invalid username/password");
       }
       Collection<? extends GrantedAuthority> authorities = CalendarUserAuthorityUtils.createAuthorities(user);
       return new UsernamePasswordAuthenticationToken(user, password, authorities);
    }
    @Override
    public boolean supports(final Class<?> authentication) {
       return UsernamePasswordAuthenticationToken.class.equals(authentication);
    }
}

重要提示

请记住,您可以使用您的chapter03.05-calendar

在 Spring Security 可以调用authenticate方法之前,必须确保将要传入的Authentication类的supports方法返回true。在这种情况下,AuthenticationProvider可以验证用户名和密码。我们不接受UsernamePasswordAuthenticationToken的子类,因为可能存在我们不知道如何验证的额外字段。

authenticate方法接受一个表示认证请求的Authentication对象作为参数。在实践中,这是我们需要尝试验证的用户输入。如果认证失败,该方法应抛出o.s.s.core.AuthenticationException异常。如果认证成功,它应返回一个包含用户适当GrantedAuthority对象的Authentication对象。返回的Authentication对象将被设置在SecurityContextHolder中。如果无法确定认证,该方法应返回null

验证请求的第一步是从Authentication对象中提取我们需要用于验证用户的信息。在我们的例子中,我们提取用户名并通过电子邮件地址查找CalendarUser,就像CalendarUserDetailsService所做的那样。如果提供的用户名和密码与CalendarUser匹配,我们将返回一个包含适当GrantedAuthorityUsernamePasswordAuthenticationToken对象。否则,我们将抛出AuthenticationException异常。

记得登录页面是如何利用SPRING_SECURITY_LAST_EXCEPTION来解释登录失败的原因吗?在AuthenticationProvider中抛出的AuthenticationException异常消息是最后一个AuthenticationException异常,并在登录失败的情况下显示在我们的登录页面上。

配置CalendarUserAuthenticationProvider对象

让我们执行以下步骤来配置CalendarUserAuthenticationProvider

  1. 更新SecurityConfig.java文件以引用我们新创建的CalendarUserAuthenticationProvider对象,并移除对CalendarUserDetailsService的引用,如下面的代码片段所示:

    //src/main/java/com/packtpub/springsecurity/configuration/ SecurityConfig.java
    @EnableWebSecurity
    public class SecurityConfig {
        private final CalendarUserAuthenticationProvider cuap;
        public SecurityConfig(CalendarUserAuthenticationProvider cuap) {
           this.cuap = cuap;
        }
        @Bean
        public AuthenticationManager authManager(HttpSecurity http) throws Exception {
           AuthenticationManagerBuilder authenticationManagerBuilder =
                 http.getSharedObject(AuthenticationManagerBuilder.class);
           authenticationManagerBuilder.authenticationProvider(cuap);
           return authenticationManagerBuilder.build();
        }
    ...
    }
    
  2. 更新SecurityConfig.java文件,如下所示,通过移除PasswordEncoderbean:

    @Configuration
    @EnableWebSecurity
    public class SecurityConfig {
        @Bean
        public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    ...
        }
    // We removed the PasswordEncoder
    }
    
  3. 重新启动应用程序并确保一切仍然正常工作。作为一个用户,我们不会注意到任何不同。然而,作为一个开发者,我们知道CalendarUserDetails不再需要;我们仍然能够显示当前用户的姓名和姓氏,Spring Security 仍然能够利用CalendarUser进行认证。

重要提示

您的代码现在应该看起来像chapter03.05-calendar

使用不同参数进行认证

AuthenticationProvider的一个优点是它可以使用您想要的任何参数进行认证。例如,也许您的应用程序使用随机标识符进行认证,或者它可能是一个多租户应用程序,需要用户名、密码和域。在下一节中,我们将更新CalendarUserAuthenticationProvider以支持多个域。

重要提示

域是我们用户范围的一种方式。例如,如果我们只部署一次应用程序,但有多个客户端使用相同的部署,每个客户端可能希望有一个用户名为admin的用户。通过向我们的用户对象添加域,我们可以确保每个用户都是唯一的,并且仍然支持这一要求。

DomainUsernamePasswordAuthenticationToken

当用户进行认证时,Spring Security 会将用户提供的信息提交给AuthenticationProviderAuthentication对象。当前的UsernamePasswordAuthentication对象仅包含用户名和密码字段。创建一个包含domain字段的DomainUsernamePasswordAuthenticationToken对象,如下面的代码片段所示:

//src/main/java/com/packtpub/springsecurity/authentication/ DomainUsernamePasswordAuthenticationToken.java
public final class DomainUsernamePasswordAuthenticationToken extends
       UsernamePasswordAuthenticationToken {
    private final String domain;
    // used for attempting authentication
    public DomainUsernamePasswordAuthenticationToken(String
          principal, String credentials, String domain) {
       super(principal, credentials);
       this.domain = domain;
    }
    // used for returning to Spring Security after being
    //authenticated
    public DomainUsernamePasswordAuthenticationToken(CalendarUser
          principal, String credentials, String domain,
          Collection<? extends GrantedAuthority> authorities) {
       super(principal, credentials, authorities);
       this.domain = domain;
    }
    public String getDomain() {
       return domain;
    }
}

更新CalendarUserAuthenticationProvider

让我们看看更新CalendarUserAuthenticationProvider.java文件的以下步骤:

  1. 现在,我们需要更新CalendarUserAuthenticationProvider以利用域字段,如下所示:

    @Component
    public class CalendarUserAuthenticationProvider implements AuthenticationProvider {
        private static final Logger logger = LoggerFactory
              .getLogger(CalendarUserAuthenticationProvider.class);
        private final CalendarService calendarService;
        @Autowired
        public CalendarUserAuthenticationProvider(CalendarService calendarService) {
           if (calendarService == null) {
              throw new IllegalArgumentException("calendarService cannot be null");
           }
           this.calendarService = calendarService;
        }
        @Override
        public Authentication authenticate(Authentication authentication) throws AuthenticationException {
           DomainUsernamePasswordAuthenticationToken token = (DomainUsernamePasswordAuthenticationToken) authentication;
           String userName = token.getName();
           String domain = token.getDomain();
           String email = userName + "@" + domain;
           CalendarUser user = calendarService.findUserByEmail(email);
           logger.info("calendarUser: {}", user);
           if (user == null) {
              throw new UsernameNotFoundException("Invalid username/password");
           }
           String password = user.getPassword();
           if (!password.equals(token.getCredentials())) {
              throw new BadCredentialsException("Invalid username/password");
           }
           Collection<? extends GrantedAuthority> authorities = CalendarUserAuthorityUtils.createAuthorities(user);
           logger.info("authorities: {}", authorities);
           return new DomainUsernamePasswordAuthenticationToken(user, password, domain, authorities);
        }
        @Override
        public boolean supports(Class<?> authentication) {
           return DomainUsernamePasswordAuthenticationToken.class.equals(authentication);
        }
    }
    
  2. 我们首先更新支持方法,以便 Spring Security 将DomainUsernamePasswordAuthenticationToken传递到我们的authenticate方法中。

  3. 然后,我们使用域信息来创建我们的电子邮件地址并进行认证,就像我们之前做的那样。诚然,这个例子是人为设计的。然而,这个例子可以说明如何使用额外的参数进行认证。

  4. CalendarUserAuthenticationProvider接口现在可以使用新的域名字段。然而,用户无法指定域名。为此,我们必须更新我们的login.xhtml文件。

将域名添加到登录页面

打开login.xhtml文件并添加一个名为domain的新输入,如下所示:

//src/main/resources/templates/login.xhtml
<div class="mb-3">
<label class="form-label" for="username">Username</label>
<input autofocus="autofocus" class="form-control" id="username"
       name="username"
       type="text"/>
</div>
<div class="mb-3">
<label class="form-label" for="password">Password</label>
<input class="form-control" id="password" name="password"
       type="password"/>
</div>
<div class="mb-3">
<label class="form-label" for="domain">Domain</label>
<input class="form-control" id="domain" name="domain" type="text"/>
</div>

现在,当用户尝试登录时,将提交一个域名。然而,Spring Security 不知道如何使用该域名来创建DomainUsernamePasswordAuthenticationToken对象并将其传递给AuthenticationProvider。为了解决这个问题,我们需要创建DomainUsernamePasswordAuthenticationFilter

DomainUsernamePasswordAuthenticationFilter

Spring Security 提供了一些Servlet Filters,这些过滤器充当认证用户的控制器。这些过滤器作为我们在第二章,“Spring Security 入门”中讨论的FilterChainProxy对象的一个代表被调用。之前,formLogin()方法指示 Spring Security 使用o.s.s.web.authentication.UsernamePasswordAuthenticationFilter作为登录控制器。该过滤器的任务是执行以下任务:

  • 从 HTTP 请求中获取用户名和密码。

  • 使用从 HTTP 请求中获得的信息创建一个UsernamePasswordAuthenticationToken对象。

  • 请求 Spring Security 验证UsernamePasswordAuthenticationToken

  • 如果令牌被验证,它将在SecurityContext 持有者上设置返回的认证,就像我们在新用户注册账户时所做的。我们需要扩展UsernamePasswordAuthenticationFilter以利用我们新创建的DoainUsernamePasswordAuthenticationToken对象。

  • 创建一个DomainUsernamePasswordAuthenticationFilter对象,如下所示:

    //src/main/java/com/packtpub/springsecurity/web/authentication/ DomainUsernamePasswordAuthenticationFilter.java
    public final class DomainUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
        public DomainUsernamePasswordAuthenticationFilter(final AuthenticationManager authenticationManager) {
           super.setAuthenticationManager(authenticationManager);
        }
        public Authentication attemptAuthentication
              (HttpServletRequest request, HttpServletResponse response) throws
              AuthenticationException {
           if (!request.getMethod().equals("POST")) {
              throw new AuthenticationServiceException
                    ("Authentication method not supported: "
                          + request.getMethod());
           }
           String username = obtainUsername(request);
           String password = obtainPassword(request);
           String domain = request.getParameter("domain");
           DomainUsernamePasswordAuthenticationToken authRequest
                 = new DomainUsernamePasswordAuthenticationToken(username,
                 password, domain);
           setDetails(request, authRequest);
           return this.getAuthenticationManager()
                 .authenticate(authRequest);
        }
    }
    

新的DomainUsernamePasswordAuthenticationFilter对象将执行以下任务:

  • HttpServletRequest方法中获取用户名、密码和域名。

  • 使用从 HTTP 请求中获得的信息创建我们的DomainUsernamePasswordAuthenticationToken对象。

  • 请求 Spring Security 验证DomainUsernamePasswordAuthenticationToken。这项工作委托给了CalendarUserAuthenticationProvider

  • 如果令牌被验证,其超类将在SecurityContextHolder上设置由CalendarUserAuthenticationProvider返回的认证,就像我们在用户创建新账户后进行认证时所做的。

更新我们的配置

现在我们已经创建了所需的所有代码,我们需要配置 Spring Security 以了解它。以下代码片段包括对SecurityConfig.java文件的必要更新,以支持我们的附加参数:

//src/main/java/com/packtpub/springsecurity/configuration/ SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    private final CalendarUserAuthenticationProvider cuap;
    public SecurityConfig(CalendarUserAuthenticationProvider cuap) {
       this.cuap = cuap;
    }
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http, AuthenticationManager authManager) throws Exception {
       http.authorizeRequests((authz) -> authz
                   .requestMatchers(antMatcher("/webjars/**")).permitAll()
                   .requestMatchers(antMatcher("/css/**")).permitAll()
                   .requestMatchers(antMatcher("/favicon.ico")).permitAll()
                   // H2 console:
                   .requestMatchers(antMatcher("/admin/h2/**")).permitAll()
                   .requestMatchers(antMatcher("/")).permitAll()
                   .requestMatchers(antMatcher("/login/*")).permitAll()
                   .requestMatchers(antMatcher("/logout")).permitAll()
                   .requestMatchers(antMatcher("/signup/*")).permitAll()
                   .requestMatchers(antMatcher("/errors/**")).permitAll()
                   .requestMatchers(antMatcher("/admin/*")).hasRole("ADMIN")
                   .requestMatchers(antMatcher("/events/")).hasRole("ADMIN")
                   .requestMatchers(antMatcher("/**")).hasRole("USER"))
             .exceptionHandling(exceptions -> exceptions
                   .accessDeniedPage("/errors/403")
                   .authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login/form")))
             .logout(form -> form
                   .logoutUrl("/logout")
                   .logoutSuccessUrl("/login/form?logout")
                   .permitAll())
             // CSRF is enabled by default, with Java Config
             .csrf(AbstractHttpConfigurer::disable)
             // Add custom DomainUsernamePasswordAuthenticationFilter
             .addFilterAt(domainUsernamePasswordAuthenticationFilter(authManager), UsernamePasswordAuthenticationFilter.class);
       http.securityContext((securityContext) -> securityContext.requireExplicitSave(false));
       http.headers(headers -> headers.frameOptions(FrameOptionsConfig::disable));
       return http.build();
    }
    @Bean
    public DomainUsernamePasswordAuthenticationFilter domainUsernamePasswordAuthenticationFilter(AuthenticationManager authManager) {
       DomainUsernamePasswordAuthenticationFilter dupaf = new
             DomainUsernamePasswordAuthenticationFilter(authManager);
       dupaf.setFilterProcessesUrl("/login");
       dupaf.setUsernameParameter("username");
       dupaf.setPasswordParameter("password");
       dupaf.setAuthenticationSuccessHandler(new SavedRequestAwareAuthenticationSuccessHandler() {{
          setDefaultTargetUrl("/default");
       }});
       dupaf.setAuthenticationFailureHandler(new SimpleUrlAuthenticationFailureHandler() {{
          setDefaultFailureUrl("/login/form?error");
       }});
       dupaf.afterPropertiesSet();
       return dupaf;
    }
    @Bean
    public AuthenticationManager authManager(HttpSecurity http) throws Exception {
       AuthenticationManagerBuilder authenticationManagerBuilder =
             http.getSharedObject(AuthenticationManagerBuilder.class);
       authenticationManagerBuilder.authenticationProvider(cuap);
       return authenticationManagerBuilder.build();
    }
}

重要提示

chapter03.06-calendar.

以下是配置更新的一些亮点:

  • 我们覆盖了 defaultAuthenticationEntryPoint 并添加了对 o.s.s.web.authentication.LoginUrlAuthenticationEntryPoint 的引用,该引用确定当请求受保护资源且用户未认证时会发生什么。在我们的情况下,我们将被重定向到登录页面。

  • 我们移除了 formLogin() 方法,并使用 .addFilterAt() 方法将我们的自定义过滤器插入到 FilterChainProxy 中。位置指示了 FilterChain 代理者被考虑的顺序,不能与其他过滤器重叠,但可以替换当前位置的过滤器。我们将 UsernamePasswordAuthenticationFilter 替换为我们的自定义过滤器。

    请参考以下图表以供参考:

图 3.5 – 自定义身份验证实现

图 3.5 – 自定义身份验证实现

您现在可以重新启动应用程序并尝试以下步骤,如前图所示,以了解所有部件是如何结合在一起的:

  1. 访问 http://localhost:8080/events

  2. Spring Security 将拦截受保护的 URL 并使用 LoginUrlAuthentication EntryPoint 对象来处理它。

  3. LoginUrlAuthenticationEntryPoint 对象将用户发送到登录页面。将 admin1 作为用户名,example.com 作为域,admin1 作为密码。

  4. DomainUsernamePasswordAuthenticationFilter 对象将拦截登录请求的过程。然后,它将从 HTTP 请求中获取用户名、域和密码,并创建一个 DomainUsernamePasswordAuthenticationToken 对象。

  5. DomainUsernamePasswordAuthenticationFilter 对象将 DomainUsernamePasswordAuthenticationToken 提交到 CalendarUser AuthenticationProvider

  6. CalendarUserAuthenticationProvider 接口验证 DomainUsername PasswordAuthenticationToken,然后返回一个已认证的 DomainUsername PasswordAuthenticationToken 对象(即 isAuthenticated() 返回 true)。

  7. DomainUserPasswordAuthenticationFilter 对象使用 DomainUsernamePasswordAuthenticationToken 更新 SecurityContext 并将其放置在 SecurityContextHolder 中。

重要提示

您的代码现在应该看起来像 chapter03.06-calendar

现在我们已经介绍了用户注册工作流程以及如何创建自定义 UserDetailsServiceAuthenticationProvider 对象,我们将在下一节讨论应该使用哪种身份验证方法。

应该使用哪种身份验证方法?

我们已经介绍了三种主要的身份验证方法,那么哪一种是最好的呢?像所有解决方案一样,每种方法都有其优缺点。您可以通过参考以下列表来找到何时使用特定类型身份验证的总结:

  • SecurityContextHolder: 直接与 SecurityContextHolder 交互无疑是验证用户的最简单方式。当你需要验证新创建的用户或以非传统方式验证时,它工作得很好。通过直接使用 SecurityContextHolder,我们无需与那么多 Spring Security 层进行交互。缺点是,我们无法获得 Spring Security 自动提供的一些更高级功能。例如,如果我们想在登录后发送用户到之前请求的页面,我们必须手动将其集成到我们的控制器中。

  • UserDetailsService: 创建自定义的 UserDetailsService 对象是一种简单的机制,它允许 Spring Security 根据我们的自定义域模型做出安全决策。它还提供了一种机制来挂钩到其他 Spring Security 功能。例如,Spring Security 需要 UserDetailsService 使用在 第七章 中介绍的内置记住我(remember-me)支持,记住我服务。当验证不是基于用户名和密码时,UserDetailsService 对象不起作用。

  • AuthenticationProvider: 这是扩展 Spring Security 的最灵活方法。它允许用户使用他们想要的任何参数进行验证。然而,如果我们想利用像 Spring Security 的记住我(remember-me)这样的功能,我们仍然需要 UserDetailsService

摘要

本章使用了现实世界的问题来介绍 Spring Security 中使用的基本构建块。它还向我们展示了我们如何通过扩展这些基本构建块来使 Spring Security 对我们的自定义域对象进行验证。简而言之,我们了解到 SecurityContextHolder 接口是确定当前用户的中心位置。它不仅可以被开发者用来访问当前用户,还可以用来设置当前登录用户。

我们还探讨了如何创建自定义的 UserDetailsServiceAuthenticationProvider 对象,以及如何使用不仅仅是用户名和密码进行验证。

在下一章中,我们将探索基于 Java 数据库连接JDBC)的内置验证支持。

第二部分:验证技术

在这部分,我们探讨了 Spring Security 提供的各种验证方法和服务。首先,我们深入探讨了使用 Spring Security 的 JDBC 支持对数据库中的用户进行验证。此外,我们还讨论了使用 Spring Security 的加密模块来保护密码以增强安全性。

接下来,我们将探索 Spring Data 与 Spring Security 的集成,利用 JPA 对关系型数据库进行验证,并使用 MongoDB 对文档数据库进行验证。

接下来,我们将介绍轻量级目录访问协议LDAP)及其与 Spring Security 的集成。我们将探讨 LDAP 如何在启用 Spring Security 的应用程序中提供身份验证、授权和用户信息服务。

然后,我们将揭示 Spring Security 中记住我功能的特性和其配置。此外,我们还将讨论实现记住我功能时的注意事项,使应用程序能够在会话过期和浏览器关闭后仍然记住用户。

最后,我们将探讨可用的替代身份验证方法,以适应各种凭证类型。我们将超越传统的基于表单的身份验证,深入到使用受信任的客户端证书进行身份验证的领域。Spring Security 为这些多样化的身份验证需求提供了强大的支持,提供了一个框架来实施和管理使用客户端证书的身份验证,从而增强了应用程序中的安全措施。

本部分包含以下章节:

  • 第四章基于 JDBC 的 身份验证

  • 第五章使用 Spring Data 进行身份验证

  • 第六章LDAP 目录服务

  • 第七章记住我 服务

  • 第八章使用 TLS 的客户端证书身份验证

第四章:基于 JDBC 的认证

在上一章中,我们看到了如何扩展 Spring Security 以利用我们的CalendarDao接口和现有的领域模型来认证用户。在本章中,我们将看到如何使用 Spring Security 的内置 JDBC 支持。为了保持简单,本章的示例代码基于我们从第二章继承的 Spring Security 设置,Spring Security 入门。在本章中,我们将涵盖以下主题:

  • 使用 Spring Security 内置的基于 JDBC 的认证支持

  • 利用 Spring Security 的基于组的授权来简化用户管理

  • 学习如何使用 Spring Security 的 UserDetailsManager 接口

  • 配置 Spring Security 以利用现有的 CalendarUser 模式进行用户认证

  • 学习如何使用 Spring Security 的新加密模块来安全地存储密码

  • 使用 Spring Security 的默认 JDBC 认证

如果您的应用程序尚未实现安全功能,或者您的安全基础设施正在使用数据库,Spring Security 提供开箱即用的支持,可以简化解决您的安全需求。Spring Security 为用户、权限和组提供默认模式。如果这不符合您的需求,它允许自定义查询和管理用户。在下一节中,我们将介绍使用 Spring Security 设置 JDBC 认证的基本步骤。

本章代码的实际链接在此:packt.link/of0XA

安装所需的依赖项

我们的应用程序已经定义了本章所需的所有必要依赖项。然而,如果您正在使用 Spring Security 的 JDBC 支持,您可能希望在build.gradle文件中列出以下依赖项。重要的是要强调,您将使用的 JDBC 驱动程序将取决于您正在使用的数据库。请咨询您的数据库供应商的文档,以获取有关您数据库所需驱动程序的详细信息。

重要注意事项

请记住,所有 Spring 版本都需要匹配,所有 Spring Security 版本也需要匹配(这包括传递依赖项版本)。如果您在自己的应用程序中遇到困难,您可能希望在build.gradle中定义依赖项管理部分以强制执行此操作,如第二章中所示,Spring Security 入门。如前所述,当使用示例代码时,您无需担心这一点,因为我们已经为您设置了必要的依赖项。

以下片段定义了本章所需的依赖项,包括 Spring Security 和 JDBC 依赖项:

//build.gradle
dependencies {
...
    // spring-jdbc
    implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
    // H2 db
    implementation 'com.h2database:h2'
    // spring-security
    implementation 'org.springframework.boot:spring-boot-starter-security'
...
}

build.gradle中的主要更改是添加spring-boot-starter-data-jdbc依赖项,以启用 Spring JDBC 支持。

使用 H2 数据库

本练习的第一部分涉及设置 Java 基础的 H2 实例,这是一个用 Java 编写的开源、内存和嵌入式关系型数据库。它设计得快速、轻量级且易于使用。H2 数据库将填充 Spring Security 默认的模式。我们将使用 Spring 的EmbeddedDatabase配置功能配置 H2 在内存中运行,这是一种比手动设置数据库显著更简单的配置方法。你可以在 H2 网站上找到更多信息:www.h2database.com/

请记住,在我们的示例应用程序中,我们主要使用 H2,因为它易于设置。Spring Security 可以与任何支持 ANSI SQL 的数据库无缝工作。我们鼓励你在跟随示例时调整配置并使用你偏好的数据库。由于我们不想让这本书的这一部分专注于数据库设置的复杂性,我们选择了便利性而不是现实性来设计练习。

在以下子节中,我们将提供我们的 JBCP 日历应用程序的示例 SQL 脚本。这些脚本将使用 H2 嵌入式数据库进行配置。

最后,我们将启用 spring-security 支持,我们需要添加一个自定义的UserDetailsManager实现。

提供的 JDBC 脚本

我们已经将用于创建 H2 数据库中模式和数据的所有 SQL 文件提供在了src/main/resources/database/h2/文件夹中。任何以calendar开头的前缀文件都是 JBCP 日历应用的定制 SQL 文件。希望这会使运行示例变得稍微容易一些。如果你正在跟随自己的数据库实例,你可能需要调整模式定义语法以适应你的特定数据库。额外的数据库模式可以在 Spring Security 参考文档中找到。你可以在书籍的附录中找到 Spring Security 参考的链接,附加 参考资料

配置 H2 嵌入式数据库

要配置 H2 嵌入式数据库,我们需要创建一个DataSource并运行 SQL 来创建 Spring Security 表结构。我们需要更新启动时加载的 SQL,包括 Spring Security 的基本模式定义、Spring Security 用户定义和用户的权限映射。你可以在以下代码片段中找到DataSource定义和相关的更新:

//src/main/java/com/packtpub/springsecurity/configuration/DataSourceConfig. Java
@Bean
public DataSource dataSource() {
    return new EmbeddedDatabaseBuilder()
          .setName("dataSource")
          .setType(EmbeddedDatabaseType.H2)
          .addScript("/database/h2/calendar-schema.sql")
          .addScript("/database/h2/calendar-data.sql")
          .addScript("/database/h2/security-schema.sql")
          .addScript("/database/h2/security-users.sql")
          .addScript("/database/h2/security-user-authorities.sql")
          .build();
}

记住,EmbeddedDatabaseBuilder()方法仅在内存中创建此数据库,因此你不会在磁盘上看到任何东西,并且你无法使用标准工具来查询它。然而,你可以使用应用程序内嵌入的 H2 控制台与数据库交互。查看我们应用程序的欢迎页面上的说明,了解如何使用它。

配置 JDBC UserDetailsManager 实现

我们将修改 SecurityConfig.java 文件,声明我们使用的是 JDBC User DetailsManager 实现,而不是我们在第二章中配置的 Spring Security 内存 User DetailsService 实现,即 Spring Security 入门。这是通过简单更改 UserDetailsManager 声明来实现的,如下所示:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Bean
public UserDetailsManager userDetailsService(DataSource dataSource) {
    return new JdbcUserDetailsManager(dataSource);
}

我们用 userDetailsService() 方法替换了之前的 configure(AuthenticationManagerBuilder) 方法以及所有子元素,如前述代码片段所示。

在本节中,我们已经能够使用自定义 UserDetailsManager 实现配置 H2 数据库,以启用 Spring Security 支持。

Spring Security 的默认用户模式

让我们看一下初始化数据库所使用的每个 SQL 文件。我们添加的第一个脚本包含了默认的 Spring Security 用户模式和权限定义。下面的脚本已经从 Spring Security 的参考中改编,该参考列在附录中,附加参考资料,以具有明确命名的约束,以便更容易进行故障排除:

//src/main/resources/database/h2/security-schema.sql
create table users
(
    username varchar(256) not null primary key,
    password varchar(256) not null,
    enabled  boolean      not null
);
create table authorities
(
    username  varchar(256) not null,
    authority varchar(256) not null,
    constraint fk_authorities_users foreign key (username) references users (username)
);
create unique index ix_auth_username on authorities (username, authority);

定义用户

下一个脚本负责定义我们应用程序中的用户。包含的 SQL 语句创建了我们在整本书中一直使用的相同用户。该文件还添加了一个额外的用户 disabled1@example.com,由于我们指示该用户已被禁用,因此该用户将无法登录:

//src/main/resources/database/h2/security-users.sql
insert into users (username, password, enabled)
values ('user1@example.com', '{noop}user1', 1);
insert into users (username, password, enabled)
values ('admin1@example.com', '{noop}admin1', 1);
insert into users (username, password, enabled)
values ('user2@example.com', '{noop}admin1', 1);
insert into users (username, password, enabled)
values ('disabled1@example.com', '{noop}disabled1', 0);
insert into users (username, password, enabled)
values ('admin', '{noop}admin', 1);

定义用户权限

你可能已经注意到,没有指示说明一个用户是管理员还是普通用户。下一个文件指定了用户到相应权限的直接映射。如果一个用户没有映射到任何权限,Spring Security 将不允许该用户登录:

//src/main/resources/database/h2/security-user-authorities.sql
insert into authorities(username, authority)
values ('user1@example.com', 'ROLE_USER');
insert into authorities(username, authority)
values ('admin1@example.com', 'ROLE_ADMIN');
insert into authorities(username, authority)
values ('admin1@example.com', 'ROLE_USER');
insert into authorities(username, authority)
values ('user2@example.com', 'ROLE_USER');
insert into authorities(username, authority)
values ('disabled1@example.com', 'ROLE_USER');

在将 SQL 添加到嵌入式数据库配置之后,我们应该能够启动应用程序并登录。尝试使用 disabled1@example.com 作为 usernamedisabled1 作为 password 登录新用户。注意,Spring Security 不允许用户登录,并提供了错误信息 原因:用户被禁用

重要提示

你的代码现在应该看起来像这样:calendar04.01-calendar

在本节中,我们使用了默认的 Spring Security 用户模式和权限。在下一节中,我们将探讨如何定义 基于组的访问控制GBAC)。

探索 UserDetailsManager 接口

我们已经在 第三章自定义认证 中利用了 Spring Security 的 InMemoryUserDetailsManager 类,在我们的 SpringSecurityUserContext 实现 UserContext 中查找当前的 CalendarUser 应用程序。这允许我们确定在查找 DefaultCalendarService.java 文件的事件时应该使用哪个 CalendarUser,以确保在创建 CalendarUser 时创建一个新的 Spring Security 用户。本章重复使用完全相同的代码。唯一的不同之处在于,UserDetailsManager 的实现由 Spring Security 的 JdbcUserDetailsManager 类支持,该类使用数据库而不是内存数据存储。

UserDetailsManager 还提供了哪些开箱即用的功能?

虽然这些类型的函数可以通过额外的 JDBC 语句相对容易地编写,但 Spring Security 实际上提供了一些开箱即用的功能来支持在 JDBC 数据库上对用户执行许多常见的 创建、读取、更新和删除CRUD)操作。这对于简单的系统来说可能很方便,并且是构建任何用户可能需要的自定义要求的好基础:

方法 描述
void createUser(UserDetails user) 它使用给定的 UserDetails 信息创建一个新用户,包括任何声明的 GrantedAuthority 权限。
void updateUser(final UserDetails user) 它使用给定的 UserDetails 信息更新一个用户。它更新 GrantedAuthority 并从用户缓存中删除用户。
void deleteUser(String username) 它删除具有给定用户名的用户,并从用户缓存中删除用户。
boolean userExists(String username) 它指示是否有一个具有给定用户名的用户(活动或非活动)存在。
void changePassword(String oldPassword, String newPassword) 它更改当前登录用户的密码。用户必须提供正确的密码才能使操作成功。

表 4.1 – 自定义数据库要求设置

如果 UserDetailsManager 没有提供您应用程序所需的所有方法,您可以扩展该接口以提供这些自定义要求。例如,如果您需要能够在管理视图中列出所有可能的用户,您可以编写自己的接口并实现一个指向您当前使用的 UserDetailsManager 实现相同数据存储的实现。

基于组的访问控制

JdbcUserDetailsManager 类支持通过将 GrantedAuthority 分组到称为组的逻辑集合中,在用户和 GrantedAuthority 声明之间添加一层间接性。

然后,用户被分配一个或多个组,他们的成员资格赋予了一组 GrantedAuthority 声明:

图 4.1 – 基于组的访问控制示例

图 4.1 – 基于组的访问控制示例

正如您在前面的图中可以看到,这种间接性允许通过简单地将任何新用户分配到现有组来将同一组角色分配给多个用户。这与我们迄今为止看到的不同,我们之前直接将 GrantedAuthority 分配给单个用户。

这种将常见权限集捆绑起来的做法在以下场景中可能很有帮助:

  • 您需要将用户隔离到社区中,组之间存在一些重叠的角色。

  • 您想全局更改一类用户的授权。例如,如果您有一个供应商组,您可能希望启用或禁用他们对应用程序特定部分的访问。

  • 您有大量用户,并且不需要用户级权限配置。

除非您的应用程序用户基数非常小,否则您很可能正在使用基于组的访问控制。虽然基于组的访问控制比其他策略稍微复杂一些,但管理用户访问的灵活性和简单性使这种复杂性变得值得。这种通过组聚合用户权限的间接技术通常被称为 GBAC。

GBAC 是市场上几乎每个受保护操作系统或软件包中常见的做法。Microsoft Active DirectoryAD)是大型 GBAC 的最明显实现之一,因为它将 AD 用户放入组中并为这些组分配权限。通过使用 GBAC,大型基于 AD 的组织中的权限管理变得指数级简单。

尝试思考您使用的软件的安全模型——用户、组和权限是如何管理的?安全模型编写的方式有哪些优缺点?

让我们在 JBCP 日历应用程序中添加一层抽象,并将基于组的授权概念应用于网站。

配置基于组的访问控制

我们将向应用程序添加两个组:普通用户,我们将其称为 Users,以及管理员用户,我们将其称为 Administrators。我们的现有账户将通过一个额外的 SQL 脚本与适当的组关联。

配置 JdbcUserDetailsManager 以使用组

默认情况下,Spring Security 不使用 GBAC。因此,我们必须指示 Spring Security 启用组的使用。修改 SecurityConfig.java 文件以使用 GROUP_AUTHORITIES_BY_USERNAME_QUERY,如下所示:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.ja va
private static String CUSTOM_GROUP_AUTHORITIES_BY_USERNAME_QUERY = "select g.id, g.group_name, ga.authority " +
       "from groups g, group_members gm, " +
       "group_authorities ga where gm.username = ? " +
       "and g.id = ga.group_id and g.id = gm.group_id";
@Bean
public UserDetailsManager userDetailsService(DataSource dataSource) {
    JdbcUserDetailsManager jdbcUserDetailsManager = new JdbcUserDetailsManager(dataSource);
    jdbcUserDetailsManager.setEnableGroups(true);
    jdbcUserDetailsManager.setGroupAuthoritiesByUsernameQuery(CUSTOM_GROUP_AUTHORITIES_BY_USERNAME_QUERY);
    return jdbcUserDetailsManager;
}

利用 GBAC JDBC 脚本

接下来,我们需要更新启动时加载的脚本。我们需要删除 security-user-authorities.sql 映射,以便我们的用户不再通过直接映射获得其权限。然后我们需要添加两个额外的 SQL 脚本。更新 DataSource 实例配置以加载 GBAC 所需的 SQL,如下所示:

//src/main/java/com/packtpub/springsecurity/configuration/DataSourceConfig. java
@Bean
public DataSource dataSource() {
    return new EmbeddedDatabaseBuilder()
          .setName("dataSource")
          .setType(EmbeddedDatabaseType.H2)
          .addScript("/database/h2/calendar-schema.sql")
          .addScript("/database/h2/calendar-data.sql")
          .addScript("/database/h2/security-schema.sql")
          .addScript("/database/h2/security-users.sql")
          .addScript("/database/h2/security-groups-schema.sql")
          .addScript("/database/h2/security-groups-mappings.sql")
          .build();
}

基于组的模式

虽然可能很明显,但我们添加的第一个 SQL 文件包含了对模式的支持以支持基于组的授权的更新。您可以在以下代码片段中找到文件的内容:

//src/main/resources/database/h2/security-groups-schema.sql
create table groups
(
    id         bigint generated by default as identity (start with 0) primary key,
    group_name varchar(256) not null
);
create table group_authorities
(
    group_id  bigint      not null,
    authority varchar(50) not null,
    constraint fk_group_authorities_group foreign key (group_id) references groups (id)
);
create table group_members
(
    id       bigint generated by default as identity (start with 0) primary key,
    username varchar(50) not null,
    group_id bigint      not null,
    constraint fk_group_members_group foreign key (group_id) references groups (id)
);

组权限映射

现在我们需要将现有用户映射到组,将组映射到权限。这是在security-groups-mappings.sql文件中完成的。基于组的映射可以很方便,因为组织通常已经根据各种原因有一个逻辑用户组。通过利用现有的用户分组,我们可以极大地简化我们的配置。这就是间接层如何帮助我们。我们在以下组映射中包含了组定义、组到权限映射和一些用户:

//src/main/resources/database/h2/security-groups-mappings.sql
-----
-- Create the Groups
insert into groups(group_name)
values ('Users');
insert into groups(group_name)
values ('Administrators');
-----
-- Map the Groups to Roles
insert into group_authorities(group_id, authority)
select id, 'ROLE_USER'
from groups
where group_name = 'Users';
-- Administrators are both a ROLE_USER and ROLE_ADMIN
insert into group_authorities(group_id, authority)
select id, 'ROLE_USER'
from groups
where group_name = 'Administrators';
insert into group_authorities(group_id, authority)
select id, 'ROLE_ADMIN'
from groups
where group_name = 'Administrators';
-----
-- Map the users to Groups
insert into group_members(group_id, username)
select id, 'user1@example.com'
from groups
where group_name = 'Users';
insert into group_members(group_id, username)
select id, 'admin1@example.com'
from groups
where group_name = 'Administrators';
insert into group_members(group_id, username)
select id, 'user2@example.com'
from groups
where group_name = 'Users';
insert into group_members(group_id, username)
select id, 'disabled1@example.com'
from groups
where group_name = 'Users';
insert into group_members(group_id, username)
select id, 'admin'
from groups
where group_name = 'Administrators';

开始启动应用程序,它将表现得和以前一样;然而,用户和角色之间的额外抽象层简化了管理大量用户。

重要提示

您的代码现在应该看起来像这样:calendar04.02-calendar

在本节中,我们探讨了如何定义 GBAC 之后,将在下一节中定义一个自定义数据库查询来检索用户和权限。

支持自定义模式

对于 Spring Security 的新用户来说,通常是通过将 JDBC 用户、组或角色映射到现有模式来开始他们的体验。即使遗留数据库不符合预期的 Spring Security 模式,我们仍然可以配置JdbcDaoImpl来映射到它。

现在,我们将更新 Spring Security 的 JDBC 支持,以使用我们现有的CalendarUser数据库以及一个新的calendar_authorities表。

我们可以轻松地更改JdbcUserDetailsManager的配置,以利用此模式并覆盖我们用于 JBCP 日历应用程序的 Spring Security 预期的表定义和列。

在以下子节中,我们将更新 SQL 用户和权限脚本以插入自定义角色。最后,我们将配置JdbcUserDetailsManager以使用这些自定义 SQL 查询。

确定正确的 JDBC SQL 查询

JdbcUserDetailsManager类有三个具有明确定义参数和一组返回列的 SQL 查询。我们必须根据预期的功能确定我们将分配给这些查询的 SQL。JdbcUserDetailsManager使用的每个 SQL 查询都以其在登录时提供的用户名作为其唯一参数:

命名空间查询 属性名 描述 预期 SQL 列
users-by-username-query 返回一个或多个匹配用户名的用户;仅使用第一个用户。 Username (字符串) Password (字符串) Enabled (布尔值)
authorities-by-username-query 返回直接提供给用户的授权权限。通常在 GBAC 禁用时使用。 Username (字符串) GrantedAuthority (字符串)
group-authorities-by-username-query 返回通过组成员关系授予用户的 grantedauthorities 和组详情。当启用 GBAC 时使用。 Group 主键``(``任何)``Group 名称 (任何)``GrantedAuthority (字符串)

表 4.2 – Spring-security 中的 JDBC 查询

注意,在某些情况下,默认的 JdbcUserDetailsManager 实现可能没有使用返回的列,但仍然必须返回它们。

更新加载的 SQL 脚本

我们需要使用我们的自定义模式而不是 Spring Security 的默认模式来初始化 DataSource。按照以下方式更新 DataSourceConfig.java 文件:

//src/main/java/com/packtpub/springsecurity/configuration/DataSourceConfig. java
@Bean
public DataSource dataSource() {
    return new EmbeddedDatabaseBuilder()
          .setName("dataSource")
          .setType(EmbeddedDatabaseType.H2)
          .addScript("/database/h2/calendar-schema.sql")
          .addScript("/database/h2/calendar-data.sql")
          .addScript("/database/h2/calendar-authorities.sql")
          .build();
}

注意,我们已经移除了所有以安全开头的脚本,并用 calendar-authorities.sql 替换它们。

日历用户权限 SQL

您可以在以下代码片段中查看 CalendarUser 权限映射:

//src/main/resources/database/h2/calendar-authorities.sql
create table calendar_user_authorities
(
    id IDENTITY NOT NULL PRIMARY KEY,
    calendar_user bigint       not null,
    authority     varchar(256) not null
);
-- user1@example.com
insert into calendar_user_authorities(calendar_user, authority)
select id, 'ROLE_USER'
from calendar_users
where email = 'user1@example.com';
-- admin1@example.com
insert into calendar_user_authorities(calendar_user, authority)
select id, 'ROLE_ADMIN'
from calendar_users
where email = 'admin1@example.com';
insert into calendar_user_authorities(calendar_user, authority)
select id, 'ROLE_USER'
from calendar_users
where email = 'admin1@example.com';
-- user2@example.com
insert into calendar_user_authorities(calendar_user, authority)
select id, 'ROLE_USER'
from calendar_users
where email = 'user2@example.com';

重要提示

注意,我们使用 ID 作为外键,这比使用用户名作为外键(如 Spring Security 所做的那样)更好。通过使用 ID 作为外键,我们可以允许用户轻松更改他们的用户名。

插入自定义权限

当我们添加新的 CalendarUser 类时,我们需要更新 DefaultCalendarService 以使用我们的自定义模式插入用户的权限。这是因为虽然我们重用了用户定义的模式,但我们在现有应用程序中没有定义自定义权限。按照以下方式更新 DefaultCalendarService

//src/main/java/com/packtpub/springsecurity/service/DefaultCalendarService. java
@Repository
public class DefaultCalendarService implements CalendarService {
    private final EventDao eventDao;
    private final CalendarUserDao userDao;
    private final JdbcOperations jdbcOperations;
...
    public int createUser(CalendarUser user) {
        int userId = userDao.createUser(user);
        jdbcOperations.update("insert into calendar_user_authorities(calendar_user,authority) values (?,?)", userId,
              "ROLE_USER");
        return userId;
    }
}

重要提示

您可能已经注意到了用于插入我们的用户的 JdbcOperations 接口。这是 Spring 提供的一个方便的模板,有助于管理样板代码,例如连接和事务处理。有关更多详细信息,请参阅本书的 附录附加参考资料,以找到 Spring 参考。

配置 JdbcUserDetailsManager 使用自定义 SQL 查询

为了使用针对非标准模式的自定义 SQL 查询,我们将简单地更新我们的 userDetailsService() 方法以包含新的查询。这与我们启用 GBAC 支持的方式非常相似,除了我们使用的是修改后的 SQL 而不是默认 SQL。请注意,我们移除了旧的 setGroupAuthoritiesByUsernameQuery() 方法调用,因为我们在这个示例中不会使用它,以保持事情简单:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.ja va
private static String CUSTOM_USERS_BY_USERNAME_QUERY = "select email, password, true " +
       "from calendar_users where email = ?";
private static String CUSTOM_AUTHORITIES_BY_USERNAME_QUERY = "select cua.id, cua.authority " +
       "from calendar_users cu, calendar_user_authorities " +
       "cua where cu.email = ? " +
       "and cu.id = cua.calendar_user";
@Bean
public UserDetailsManager userDetailsService(DataSource dataSource) {
    JdbcUserDetailsManager jdbcUserDetailsManager = new JdbcUserDetailsManager(dataSource);
    jdbcUserDetailsManager.setUsersByUsernameQuery(CUSTOM_USERS_BY_USERNAME_QUERY);
    jdbcUserDetailsManager.setAuthoritiesByUsernameQuery(CUSTOM_AUTHORITIES_BY_USERNAME_QUERY);
    return jdbcUserDetailsManager;
}

这是使用 Spring Security 从现有非默认模式读取设置所需的唯一配置!启动应用程序并确保一切运行正常。

重要提示

您的代码现在应该看起来像这样:calendar04.03-calendar

请记住,使用现有模式通常需要扩展 JdbcUserDetailsManager 以支持更改密码、重命名用户账户和其他用户管理功能。

如果您正在使用JdbcUserDetailsManager执行用户管理任务,那么该类使用了超过 20 个 SQL 查询,这些查询可以通过配置访问。然而,只有三个在命名空间配置中可用。请参阅 Javadoc 或源代码以查看JdbcUserDetailsManager使用的查询的默认值。

配置安全密码

您可能还记得从第一章《不安全应用程序解剖》中的安全审计,密码以明文形式存储的安全性是审计员的首要任务。事实上,在任何安全系统中,密码安全都是认证主体的信任和权威性的关键方面。完全安全系统的设计者必须确保密码以恶意用户难以攻破的方式存储。

应将该以下一般规则应用于数据库中存储的密码:

  • 密码不得以明文(plaintext)形式存储

  • 用户提供的密码必须与数据库中记录的密码进行比较

  • 用户密码不应在用户要求时提供(即使用户忘记了)

对于大多数应用程序而言,满足这些要求的最合适的方法涉及单向编码,即密码的哈希。使用加密哈希提供了诸如安全性和唯一性等属性,这对于正确验证用户非常重要,而且还有一个额外的优点,即一旦哈希,密码就不能从存储的值中提取出来。

在大多数安全的应用程序设计中,在请求时检索用户的实际密码既不是必需的,也不是所希望的,因为在不适当的额外凭证的情况下向用户提供密码可能会带来重大的安全风险。相反,大多数应用程序提供用户重置密码的能力,无论是通过提供额外的凭证(如他们的社会保险号码、出生日期、税务 ID 或其他个人信息),还是通过基于电子邮件的系统。

存储其他类型的敏感信息

列出的许多适用于密码的指南同样适用于其他类型的敏感信息,包括社会保险号码和信用卡信息(尽管,根据应用程序的不同,其中一些可能需要解密的能力)。存储此类信息以以多种方式表示,例如,客户的完整 16 位信用卡号可能会以高度加密的形式存储,但最后四位可能以明文形式存储。为了参考,想想任何显示XXXX XXXX XXXX 1234以帮助您识别存储的信用卡的互联网商业网站。

你可能已经在思考,鉴于我们承认的不切实际的方法,即使用 SQL 填充我们的 H2 数据库中的用户,我们如何对密码进行编码?H2 或其他大多数数据库都没有提供作为内置数据库功能的加密方法。

通常,引导过程(通过 SQL 加载和 Java 代码将初始用户和数据填充到系统中)是通过 SQL 加载和 Java 代码的组合来处理的。根据应用程序的复杂性,这个过程可能会变得非常复杂。

对于 JBCP 日历应用程序,我们将保留 dataSource() 象声明,并在相应的 SQL 代码中将 DataSource 作为名称,然后添加一些 SQL 语句,将密码修改为它们的散列值。

在本节中,我们看到了配置安全密码的最佳实践。

在下一节中,我们将深入探讨使用 PasswordEncoder 接口配置安全密码的不同选项。

探索 PasswordEncoder 接口

Spring Security 中的密码散列封装并由 o.s.s.authentication.encoding.PasswordEncoder 接口的实现定义。通过 PasswordEncoderFactories 元素内的 createDelegatingPasswordEncoder() 方法可以简单地配置密码编码器,如下所示:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Bean
public PasswordEncoder encoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

你会很高兴地了解到,Spring Security 随带了许多 passwordEncoder 的实现,这些实现适用于不同的需求和安全性要求。

下表提供了一组开箱即用的实现类及其优点。

我们可以在 Spring Security 的 Password EncoderFactories 类中找到支持的完整编码器列表。如果其中之一符合我们的要求,我们就不需要重写它。

注意,所有实现类都位于 o.s.s.crypto 包中:

编码器 算法 用途
Pbkdf2PasswordEncoder PBKDF2 提供可配置迭代次数的密钥增强,适用于密码散列。适用于密码存储。
SCryptPasswordEncoder Scrypt 内存硬密钥派生函数,使其对暴力攻击具有抵抗力。适用于密码存储。
StandardPasswordEncoder SHA-256 使用标准的 SHA-256 算法。请注意,由于速度原因,SHA-256 单独不推荐用于密码散列。适用于旧系统,但不推荐用于新应用。
NoOpPasswordEncoder 无操作 不进行散列或编码;密码以纯文本形式存储。不推荐用于生产环境。适用于测试和开发。
LdapShaPasswordEncoder SHA-1 可选盐的 SHA-1 散列。适用于与 LDAP 目录的兼容性。适用于与基于 LDAP 的系统集成。
BCryptPasswordEncoder BCrypt 带有自适应散列的单向散列函数,适用于密码散列。推荐用于密码存储。
MessageDigest PasswordEncoder 可配置(例如,MD5、SHA-256、SHA-512) 使用各种消息摘要算法,但算法的选择对于安全性至关重要。取决于所选算法。由于某些算法的弱点,不推荐用于新应用。

表 4.3 – 主要 PasswordEncoder 实现

与 Spring Security 的许多其他领域一样,也可以通过实现PasswordEncoder来引用 bean 定义,以提供更精确的配置和

允许PasswordEncoder通过依赖注入与其他 bean 连接。对于 JBCP 日历应用,我们需要使用这个 bean 引用方法来对新创建的用户密码进行哈希处理。

DelegatingPasswordEncoder 实现

在 Spring Security 5.0 之前,默认的PasswordEncoderNoOpPasswordEncoder,它需要明文密码。根据密码历史记录部分,你可能会预期默认的PasswordEncoder现在会是类似BCryptPasswordEncoder的东西。然而,这忽略了三个现实世界的问题:

  • 许多应用使用旧的密码编码,难以轻松迁移。

  • 密码存储的最佳实践将再次改变。

  • 作为框架,Spring Security 不能频繁地进行破坏性更改。

相反,Spring Security 引入了DelegatingPasswordEncoder,通过以下方式解决了所有问题:

  • 确保使用当前密码存储建议进行密码编码。

  • 允许验证现代和旧格式中的密码。

  • 允许在未来升级编码。

你可以通过使用PasswordEncoderFactories轻松构建DelegatingPasswordEncoder的实例:

PasswordEncoder passwordEncoder =
PasswordEncoderFactories.createDelegatingPasswordEncoder();

让我们通过配置 JBCP 日历应用的基本密码编码过程来了解这个过程。

配置密码编码

配置基本密码编码涉及两个步骤:在 SQL 脚本执行后对加载到数据库中的密码进行哈希处理,并确保 Spring Security 配置为与PasswordEncoder一起工作。

配置 PasswordEncoder 方法

首先,我们将声明一个PasswordEncoder的实例作为普通的 Spring bean,如下所示:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.ja va
@Bean
public PasswordEncoder passwordEncoder() {
    String idForEncode = "SHA-256";
    Map<String, PasswordEncoder> encoders = new HashMap<>();
    encoders.put("SHA-256", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-256"));
    return new DelegatingPasswordEncoder(idForEncode, encoders);
}

使 Spring Security 了解 PasswordEncoder 方法

我们需要配置 Spring Security 以引用一个类型为PasswordEncoder的 Bean,以便它可以在用户登录期间对提供的密码进行编码和比较。

如果你此时尝试运行应用,你会注意到之前有效的登录凭证现在被拒绝。这是因为数据库中存储的密码(由calendar-users.sql脚本加载)并不是以与密码编码器匹配的哈希形式存储的。我们需要更新存储的密码,使其成为哈希值。

对存储的密码进行哈希处理

如以下图所示,当用户提交密码时,Spring Security 对提交的密码进行哈希处理,然后将其与数据库中的未哈希密码进行比较:

图 4.2 – 存储密码的哈希处理工作流程

图 4.2 – 存储密码的哈希处理工作流程

这意味着用户无法登录我们的应用程序。为了解决这个问题,我们将更新启动时加载的 SQL,将密码更新为哈希值。更新 DataSourceConfig.java 文件,如下所示:

//src/main/java/com/packtpub/springsecurity/configuration/DataSourceConfig. java
@Bean
public DataSource dataSource() {
    return new EmbeddedDatabaseBuilder()
          .setName("dataSource")
          .setType(EmbeddedDatabaseType.H2)
          .addScript("/database/h2/calendar-schema.sql")
          .addScript("/database/h2/calendar-data.sql")
          .addScript("/database/h2/calendar-authorities.sql")
          .addScript("/database/h2/calendar-sha256.sql")
          .build();
}

calendar-sha256.sql 文件只是将现有密码更新为其预期的哈希值,如下所示:

-- original password was: user1
update calendar_users
set password = '{SHA-256}0a041b9462caa4a31bac3567e0b6e6fd9100787db2ab433d96f6d178cabfce90'
where email = 'user1@example.com';

我们是如何知道更新密码应该使用什么值的?我们提供了 o.s.s.authentication.encoding.Sha256PasswordEncoderMain 来演示如何使用配置的 PasswordEncoder 接口来哈希现有密码。相关代码如下:

ShaPasswordEncoder encoder = new ShaPasswordEncoder(256);
String encodedPassword = encoder.encodePassword(password, null);

对新用户密码进行哈希处理

如果我们尝试运行应用程序并创建新用户,我们将无法登录。这是因为新创建的用户密码不会被哈希。我们需要更新 DefaultCalendarService 以哈希密码。进行以下更新以确保新创建的用户密码被哈希:

//src/main/java/com/packtpub/springsecurity/service/DefaultCalendarService. java
public class DefaultCalendarService implements CalendarService {
    private final EventDao eventDao;
    private final CalendarUserDao userDao;
    private final JdbcOperations jdbcOperations;
    private final PasswordEncoder passwordEncoder;
...
    public int createUser(CalendarUser user) {
       String encodedPassword = passwordEncoder.encode(user.getPassword());
       user.setPassword(encodedPassword);
       int userId = userDao.createUser(user);
       jdbcOperations.update("insert into calendar_user_authorities(calendar_user,authority) values (?,?)", userId,
             "ROLE_USER");
       return userId;
    }
}

并不完全安全

现在开始应用程序。尝试使用 user1 作为密码创建一个新用户。从应用程序中注销,然后使用 user1@example.com 的说明。这些值是否相同?我们现在已经发现了另一个用户的密码,这有点令人不安。我们将使用一种称为 盐分 的技术来解决这个问题。

重要提示

您的代码现在应该看起来像这样:calendar04.04-calendar

你想要为这个密码添加一些盐分吗?如果安全审计员检查数据库中的编码密码,他可能会发现一些仍然会让他对网站的安全性感到担忧的事情。让我们检查以下存储的用户名和密码值:

用户名 明文密码 哈希密码
admin1@example.com admin1 {``SHA-256}25f43b1486ad95a1 398e3eeb3d83bc4010015fcc9``bedb35b432e00298d5021f7
user1@example.com user1 {``SHA-256}0a041b9462caa4a3 1bac3567e0b6e6fd9100787db``2ab433d96f6d178cabfce90

表 4.4 – 哈希用户密码

这看起来非常安全——加密的密码显然与原始密码没有任何相似之处。审计员可能会担心什么?如果我们添加一个恰好与我们的 user1@example.com 用户具有相同密码的新用户呢?

用户名 明文密码 哈希密码
hacker@example.com user1 {``SHA-256}0a041b9462caa4 a31bac3567e0b6e6fd91007``87db2ab433d96f6d178cabfce90

表 4.5 – 被破解的哈希用户密码

现在,请注意,hacker@example.com用户的加密密码与真实用户完全相同!因此,如果一个黑客以某种方式获得了读取数据库中加密密码的能力,他们可以将已知密码的加密表示与用户账户的未知加密表示进行比较,并发现它们是相同的!如果黑客能够访问一个自动化的工具来执行这种分析,他们可能在几小时内就能攻破用户的账户。

虽然猜测单个密码很困难,但黑客可以在事先计算出所有 hash 值并存储 hash 到原始密码的映射。然后,找出原始密码只需通过其 hash 值在常数时间内查找密码。这是一种称为彩虹表的攻击技术。

在加密密码中增加另一层安全性的一个常见且有效的方法是加入一个。盐是一个第二个明文组件,在执行哈希之前与明文密码连接,以确保必须使用两个因素来生成(以及比较)哈希密码值。正确选择的盐可以保证没有任何两个密码会有相同的哈希值,从而防止我们审计员所关心的场景,并避免许多常见的暴力破解密码技术。

最佳实践盐通常分为以下三个类别:

  • 它们是从与用户关联的一些数据中算法生成的,例如,用户创建的时间戳

  • 它们是随机生成的并以某种形式存储

  • 它们是纯文本或与用户密码记录双向加密的

记住,因为给定用户的记录中的salt值是为了计算密码的hash值,并在进行身份验证时将其与存储的用户hash值进行比较。

在 Spring Security 中使用盐

spring-security-core模块,并在spring-security-crypto中单独提供。o.s.s.crypto.password.PasswordEncoder接口。实际上,使用此接口是编码密码的首选方法,因为它将使用随机salt对密码进行编码。在撰写本文时,o.s.s.crypto.password.PasswordEncoder有以下三种实现:

描述
o.s.s.crypto.bcrypt.BCryptPasswordEncoder 这个类使用bcrypt哈希函数。它支持盐和随着技术改进而减慢速度的能力。这有助于防止暴力搜索攻击。
o.s.s.crypto.password.NoOpPasswordEncoder 这个类不进行编码(它以明文形式返回密码)。仅用于遗留和测试目的,并且不被认为是安全的。
o.s.s.crypto.password.StandardPasswordEncoder 此类使用 SHA-256 并多次迭代,以及一个随机的 salt 值。仅提供用于遗留和测试目的,并不被认为是安全的。

表 4.6 – 常见的 PasswordEncoder 实现

重要提示

对于熟悉 Spring Security 的人来说,Spring Security Crypto 模块提供了对称加密、密钥生成和密码编码的支持。上述类是核心模块的一部分,并且没有依赖任何其他 Spring Security(或 Spring)代码。

更新 Spring Security 配置

这可以通过更新 Spring Security 配置来完成。移除旧的 ShaPasswordEncoder 编码器,并添加新的 StandardPasswordEncoder 编码器,如下所示:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.ja va
@Bean
public PasswordEncoder passwordEncoder() {
    return new StandardPasswordEncoder();
}

迁移现有密码

让我们看看以下步骤,了解如何迁移现有密码:

  1. 我们需要更新我们的现有密码以使用由新的 PasswordEncoder 类生成的值。如果您想生成自己的密码,可以使用以下代码片段:
StandardPasswordEncoder encoder = new StandardPasswordEncoder();
String encodedPassword = encoder.encode("password");
  1. 删除之前使用的 calendar-sha256.sql 文件,并添加提供的 saltedsha256.sql 文件,如下所示:
//src/main/java/com/packtpub/springsecurity/configuration/ DataSourceConfig.java
@Bean
public DataSource dataSource() {
    return new EmbeddedDatabaseBuilder()
          .setName("dataSource")
          .setType(EmbeddedDatabaseType.H2)
          .addScript("/database/h2/calendar-schema.sql")
          .addScript("/database/h2/calendar-data.sql")
          .addScript("/database/h2/calendar-authorities.sql")
          .addScript("/database/h2/calendar-saltedsha256.sql")
          .build();
}

更新 DefaultCalendarUserService

我们之前定义的 passwordEncoder() 方法足够智能,可以处理新的密码编码器接口。然而,DefaultCalendarUserService 需要更新到新的接口。对 DefaultCalendarUserService 类进行以下更新:

//src/main/java/com/packtpub/springsecurity/service/DefaultCalendarService. java
@Repository
public class DefaultCalendarService implements CalendarService {
    private final EventDao eventDao;
    private final CalendarUserDao userDao;
    private final JdbcOperations jdbcOperations;
    private final PasswordEncoder passwordEncoder;
    public int createUser(CalendarUser user) {
        String encodedPassword = passwordEncoder.encode(user.getPassword());
        user.setPassword(encodedPassword);
        int userId = userDao.createUser(user);
        jdbcOperations.update("insert into calendar_user_authorities(calendar_user,authority) values (?,?)", userId,
              "ROLE_USER");
        return userId;
    }
}

通过前面的代码实现,我们已经能够在 Spring Security 中配置 Salt SHA256。DefaultCalendarService 使用此 Salt PasswordEncoder 插入用户的密码。

在下一节中,我们将探讨使用 SaltBcrypt 算法结合的另一种选项。

尝试使用加盐密码

启动应用程序,并尝试使用密码 user1 创建另一个用户。使用 H2 控制台比较新用户的密码,并观察它们是不同的。

重要提示

您的代码现在应该看起来像这样:calendar04.05-calendar

Spring Security 现在生成一个随机的 salt 并将其与密码结合在一起在哈希我们的密码之前。然后,它将随机的 salt 添加到密码的文本开头,以便可以检查密码。存储的密码可以总结如下:

salt = randomsalt()
hash = hash(salt+originalPassword)
storedPassword = salt + hash

这是创建新密码的伪代码。

为了验证用户,可以从存储的密码中提取 salthash,因为 salthash 都是固定长度的。然后,可以比较提取的 hash 与使用提取的 salt 和输入的密码计算出的新 hash

图 4.3 – 存储密码的加盐工作流程

图 4.3 – 存储密码的加盐工作流程

以下是对加盐密码进行验证的伪代码:

storedPassword = datasource.lookupPassword(username) salt, expectedHash = extractSaltAndHash(storedPassword) actualHash = hash(salt+inputedPassword)
authenticated = (expectedHash == actualHash)Trying out salted passwords with StandardPasswordEncoder

BCryptPasswordEncoder 是另一种使用广泛支持的 bcrypt 算法来散列密码的盐实现。Bcrypt 使用随机的 16 字节盐 值,并且是一个故意设计得较慢的算法,以阻碍密码破解者。您可以通过使用强度参数来调整它所做的工作量,该参数的值从 431。值越高,计算散列所需的努力就越多。默认值是 10。您可以在部署的系统更改此值而不影响现有密码,因为该值也存储在编码的散列中。以下示例使用强度参数值为 4BCryptPasswordEncoder

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder(4);
}

此外,我们不得不添加提供的 calendar-bcrypt.sql 文件,如下所示:

//src/main/java/com/packtpub/springsecurity/configuration/ DataSourceConfig.java
@Bean
public DataSource dataSource() {
    return new EmbeddedDatabaseBuilder()
          .setName("dataSource")
          .setType(EmbeddedDatabaseType.H2)
          .addScript("/database/h2/calendar-schema.sql")
          .addScript("/database/h2/calendar-data.sql")
          .addScript("/database/h2/calendar-authorities.sql")
          .addScript("/database/h2/calendar-bcrypt.sql")
          .build();
}

启动应用程序并尝试使用用户名 user1@example.com 和密码 user1 登录到应用程序。

重要提示

您的代码现在应该看起来像这样:calendar04.06-calendar

摘要

在本章中,我们学习了如何使用 Spring Security 的内置 JDBC 支持。具体来说,我们了解到 Spring Security 为新应用程序提供了一个默认的模式。我们还探讨了如何实现 GBAC 以及它如何简化用户管理。

我们还学习了如何将 Spring Security 的 JDBC 支持与现有数据库集成,以及如何通过散列密码并使用随机生成的 来保护我们的密码。

在下一章中,我们将探讨 Spring Data 项目以及如何配置 Spring Security 以使用 对象关系映射 (ORM) 连接到 RDBMS,以及文档数据库。

第五章:使用 Spring Data 进行身份验证

在上一章中,我们介绍了如何利用 Spring Security 的内置Java 数据库连接JDBC)支持。在本章中,我们将探讨 Spring Data 项目以及如何利用Java 持久性 APIJPA)对关系型数据库进行身份验证。我们还将探讨如何使用MongoDB对文档数据库进行身份验证。本章的示例代码基于第四章的 Spring Security 设置,基于 JDBC 的身份验证([B21757_04.xhtml#_idTextAnchor106]),并且已经更新以重构对 SQL 的需求,并使用 ORM 进行所有数据库交互。

在本章的讨论过程中,我们将涵盖以下主题:

  • 与 Spring Data 项目相关的一些基本概念

  • 利用 Spring Data JPA 对关系型数据库进行身份验证

  • 利用 Spring Data MongoDB 对文档数据库进行身份验证

  • 如何自定义 Spring Security 以在处理 Spring Data 集成时获得更多灵活性

  • 理解 Spring Data 项目

Spring Data 项目的目标是提供一个熟悉且一致的基于 Spring 的数据访问编程模型,同时仍然保留底层数据提供者的特殊特性。

以下是这个 Spring Data 项目的一些强大功能:

  • 强大的存储库和自定义对象映射抽象

  • 从存储库方法名称动态推导查询

  • 实现领域基类,提供基本属性

  • 支持透明审计(创建和最后更改)

  • 能够集成自定义存储库代码

  • 通过基于 Java 的配置和自定义 XML 命名空间轻松实现 Spring 集成

  • 与 Spring MVC 控制器的高级集成

  • 对跨存储持久化的实验性支持

该项目简化了数据访问技术的使用,包括关系型和非关系型数据库、MapReduce框架和基于云的数据服务。这个母项目包含许多特定于给定数据库的子项目。这些项目是由与许多支持这些令人兴奋技术的公司和开发者合作开发的。还有许多由社区维护的模块和其他相关模块,包括JDBC 支持Apache Hadoop

以下表格描述了构成 Spring Data 项目的核心模块:

模块 描述
Spring Data Commons 将核心 Spring 概念应用于所有 Spring Data 项目
Spring Data Gemfire 从 Spring 应用程序中提供简单的配置和访问 Gemfire
Spring Data JPA 使实现基于 JPA 的存储库变得容易
Spring Data Key Value 基于映射的存储库和 SPI,可以轻松构建用于键值存储的 Spring Data 模块
Spring Data LDAP 为 Spring LDAP 提供 Spring Data 存储库支持
Spring Data MongoDB 基于 Spring 的对象-文档支持和 MongoDB 的仓库
Spring Data REST 将 Spring Data 仓库作为超媒体驱动的 RESTful 资源导出
Spring Data Redis 为 Spring 应用程序提供简单的配置和访问 Redis
Spring Data for Apache Cassandra Apache Cassandra 的 Spring Data 模块
Spring Data for Apache Solr Apache Solr 的 Spring Data 模块

表 5.1 – Spring Data 项目的核心模块

在探索 Spring Data 项目的核心模块之后,现在让我们深入了解 Spring Data JPA 的主要功能。

本章的代码示例链接在此:packt.link/omOQK

Spring Data JPA

Spring Data JPA 项目旨在通过减少实际所需的工作量来显著提高数据访问层的 ORM 实现。开发者只需编写仓库接口,包括自定义查找方法,Spring 将自动提供实现。

以下只是 Spring Data JPA 项目的一些特定于项目的强大功能:

  • 基于 Spring 和 JPA 构建仓库的复杂支持

  • 支持QueryDSL谓词,从而实现类型安全的 JPA 查询

  • 领域类的透明审计

  • 分页支持、动态查询执行以及集成自定义数据访问代码的能力

  • 在启动时验证@Query注解的查询

  • 支持基于 XML 的实体映射

  • 通过引入@EnableJpaRepositories实现基于JavaConfig的仓库配置

更新我们的依赖项

我们已经包含了本章所需的全部依赖项,因此您不需要更新您的build.gradle文件。但是,如果您只是将 Spring Data JPA 支持添加到您的应用程序中,您需要在build.gradle文件中添加spring-boot-starter-data-jpa作为依赖项,如下所示:

//build.gradle
dependencies {
    // JPA / ORM / Hibernate:
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
...
}

注意,我们没有移除spring-boot-starter-jdbc依赖项。

spring-boot-starter-data-jpa依赖将包含将我们的领域对象与嵌入式数据库连接所需的全部依赖项。

重新配置数据库配置

首先,我们将转换当前的 JBCP 日历项目。让我们从重新配置数据库开始。

我们可以先删除DataSourceConfig.java文件,因为我们将会利用 Spring Boot 内置对嵌入式 H2 数据库的支持。

初始化数据库

我们现在可以删除src/main/resources/database目录及其中的所有内容。此目录包含多个.sql文件。

现在,我们需要创建一个包含我们的种子数据的data.sql文件,如下所示:

  • 查看以下 SQL 语句,展示了user1的密码:

    //src/main/resources/data.sql
    insert into calendar_users(id,email,password,first_name,last_name) values (0, 'user1@example.com','$2a$04$qr7RWyqOnWWC1nwotUW1nOe1RD5.mKJVHK16WZy6v49pymu1WDHmi','User','1');
    
  • 查看以下 SQL 语句,展示了admin1的密码:

    insert into calendar_users(id,email,password,first_name,last_name) values (1,'admin1@example.com','$2a$04$0CF/Gsquxlel3fWq5Ic/ZOGDCaXbMfXYiXsviTNMQofWRXhvJH3IK','Admin','1');
    
  • 查看以下 SQL 语句,展示了user2的密码:

    insert into calendar_users(id,email,password,first_name,last_name) values (2,'user2@example.com','$2a$04$PiVhNPAxunf0Q4IMbVeNIuH4M4ecySWHihyrclxW..PLArjLbg8CC','User2','2');
    
  • 看一下以下 SQL 语句,描述了用户角色:

    insert into role(id, name) values (0, 'ROLE_USER');
    insert into role(id, name) values (1, 'ROLE_ADMIN');
    
  • 在这里,user1 拥有一个角色:

    insert into user_role(user_id,role_id) values (0, 0);
    
  • 在这里,admin1 拥有两个角色:

    insert into user_role(user_id,role_id) values (1, 0);
    insert into user_role(user_id,role_id) values (1, 1);
    
  • 看一下以下 SQL 语句,描述了事件:

    insert into events (id,date_when,summary,description,owner,attendee) values (100,'2023-07-03 20:30:00','Birthday Party','This is going to be a great birthday',0,1);
    insert into events (id,date_when,summary,description,owner,attendee) values (101,'2023-12-23 13:00:00','Conference Call','Call with the client',2,0);
    insert into events (id,date_when,summary,description,owner,attendee) values (102,'2023-09-14 11:30:00','Vacation','Paragliding in Greece',1,2);
    

现在,我们可以更新应用程序属性,在 src/main/resources/application.yml 文件中定义我们的嵌入式数据库属性,如下所示:

datasource:
  url: jdbc:h2:mem:dataSource;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
  driverClassName: org.h2.Driver
  username: sa
  password:
jpa:
  database-platform: org.hibernate.dialect.H2Dialect
  show-sql: true
  hibernate:
    ddl-auto: create-drop

到目前为止,我们已经移除了旧的数据库配置并添加了新的配置。此时应用程序将无法工作,但仍然可以将其视为转换下一步之前的一个标记点。

重要提示

你的代码现在应该看起来像这样:calendar05.01-calendar

从 SQL 转换到 ORM

从 SQL 转换到 ORM 实现比你想象的要简单。大部分的转换涉及移除以 SQL 形式存在的多余代码。在接下来的这一节中,我们将把我们的 SQL 实现转换为 JPA 实现。

为了让 JPA 将我们的领域对象映射到我们的数据库,我们需要在我们的领域对象上执行一些映射。

使用 JPA 映射领域对象

看一下以下步骤来了解如何映射领域对象:

  1. 让我们先映射我们的 Event.java 文件,以便所有领域对象都将使用 JPA,如下所示:

    //src/main/java/com/packtpub/springsecurity/domain/Event.java
    @Entity
    @Table(name = "events")
    public class Event implements Serializable{
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Integer id;
        @NotEmpty(message = "Summary is required")
        private String summary;
        @NotEmpty(message = "Description is required")
        private String description;
        @NotNull(message = "When is required")
        private Calendar dateWhen;
        @NotNull(message = "Owner is required")
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn(name="owner", referencedColumnName="id")
        private CalendarUser owner;
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn(name="attendee", referencedColumnName="id")
        private CalendarUser attendee;
    ...
    }
    
  2. 我们需要创建一个包含以下内容的 Role.java 文件:

    //src/main/java/com/packtpub/springsecurity/domain/Role.java
    @Entity
    @Table(name = "role")
    public class Role implements Serializable {
        @Id
        @GeneratedValue(strategy = GenerationType.AUTO)
        private Integer id;
        private String name;
        @ManyToMany(fetch = FetchType.EAGER, mappedBy = "roles")
        private Set<CalendarUser> users;
    ...
    }
    
  3. Role 对象将被用来将权限映射到我们的 CalendarUser 表。现在我们已经有了 Role.java 文件,让我们映射我们的 CalendarUser.java 文件:

    //src/main/java/com/packtpub/springsecurity/domain/CalendarUser.java
    @Entity
    @Table(name = "calendar_users")
    public class CalendarUser implements Principal, Serializable {
        private static final long serialVersionUID = 8433999509932007961L;
        @Id
        @SequenceGenerator(name = "user_id_seq", initialValue = 1000)
        @GeneratedValue(generator = "user_id_seq")
        private Integer id;
        private String firstName;
        private String lastName;
        private String email;
        private String password;
        @ManyToMany(fetch = FetchType.EAGER)
        @JoinTable(name = "user_role",
              joinColumns = @JoinColumn(name = "user_id"),
              inverseJoinColumns = @JoinColumn(name = "role_id"))
        private Set<Role> roles;
    ...
    }
    

到目前为止,我们已经使用所需的 JPA 注解映射了我们的领域对象,包括 @Entity@Table 来定义 关系型数据库管理系统RDBMS)的位置,以及结构、引用和关联映射注解。

在这个阶段,你也可以删除以下依赖项:

//build.gradle
dependencies {
...
implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
...
}

此时应用程序将无法工作,但仍然可以将其视为转换下一步之前的一个标记点。

重要提示

你的代码现在应该看起来像这样:calendar05.02-calendar

Spring Data 存储库

我们现在将添加所需的接口,以便 Spring Data 将所需的 创建、读取、更新和删除CRUD)操作映射到我们的嵌入式数据库,通过执行以下步骤:

  1. 我们首先向一个新包中添加一个新的接口,该包将是 com.packtpub.springsecurity.repository。新文件将被命名为 CalendarUserRepository.java,如下所示:

    //com/packtpub/springsecurity/repository/CalendarUserRepository.java
    public interface CalendarUserRepository extends JpaRepository<CalendarUser, Integer> {
        CalendarUser findByEmail(String email);
    }
    
  2. 我们现在可以继续添加一个新的接口到同一个存储库包中,该包将是 com.packtpub.springsecurity.repository,新文件将被命名为 EventRepository.java

    //com/packtpub/springsecurity/repository/EventRepository.java
    public interface EventRepository extends JpaRepository<Event, Integer> {
    }
    

    这将允许对 Event 对象执行标准的 CRUD 操作,如 find()save()delete()

  3. 最后,我们将向同一个仓库包添加一个新的接口,该接口将是com.packtpub.springsecurity.repository,新文件将命名为RoleRepository.java。这个CrudRepository接口将用于管理与给定的CalendarUser关联的安全角色中的Role对象:

//com/packtpub/springsecurity/repository/RoleRepository.java
public interface RoleRepository extends JpaRepository<Role, Integer> {
}

这将允许我们对Role对象执行标准 CRUD 操作,如find()save()delete()

数据访问对象

我们需要将JdbcEventDao.java文件重命名为JpaEventDao.java,这样我们就可以用新的 Spring Data 代码替换 JDBC SQL 代码。让我们看看以下步骤:

  1. 具体来说,我们需要添加新的EventRepository接口,并用新的 ORM 仓库替换 SQL 代码,如下所示:

    //com/packtpub/springsecurity/dataaccess/JpaEventDao.java
    @Repository
    public class JpaEventDao implements EventDao {
        // --- members ---
        private EventRepository repository;
        // --- constructors ---
        public JpaEventDao(EventRepository repository) {
            if (repository == null) {
                throw new IllegalArgumentException("repository cannot be null");
            }
            this.repository = repository;
        }
        // --- EventService ---
        @Override
        @Transactional(readOnly = true)
        public Event getEvent(int eventId) {
            return repository.findById(eventId).orElse(null);
        }
        @Override
        public int createEvent(final Event event) {
            if (event == null) {
                throw new IllegalArgumentException("event cannot be null");
            }
            if (event.getId() != null) {
                throw new IllegalArgumentException("event.getId() must be null when creating a new Message");
            }
            final CalendarUser owner = event.getOwner();
            if (owner == null) {
                throw new IllegalArgumentException("event.getOwner() cannot be null");
            }
            final CalendarUser attendee = event.getAttendee();
            if (attendee == null) {
                throw new IllegalArgumentException("attendee.getOwner() cannot be null");
            }
            final Calendar when = event.getDateWhen();
            if(when == null) {
                throw new IllegalArgumentException("event.getWhen() cannot be null");
            }
            Event newEvent = repository.save(event);
            return newEvent.getId();
        }
        @Override
        @Transactional(readOnly = true)
        public List<Event> findForUser(final int userId) {
            Event example = new Event();
            CalendarUser cu = new CalendarUser();
            cu.setId(userId);
            example.setOwner(cu);
            return repository.findAll(Example.of(example));
        }
        @Override
        @Transactional(readOnly = true)
        public List<Event> getEvents() {
            return repository.findAll();
        }
    }
    
  2. 在这一点上,我们需要重构 DAO 类以支持我们创建的新CrudRepository接口。让我们从重构JdbcCalendarUserDao.java文件开始。首先,我们可以将文件重命名为JpaCalendarUserDao.java,以表明这个文件使用的是 JPA 而不是标准的 JDBC:

    //com/packtpub/springsecurity/dataaccess/JpaCalendarUserDao.java
    @Repository
    public class JpaCalendarUserDao implements CalendarUserDao {
        private static final Logger logger = LoggerFactory
                .getLogger(JpaCalendarUserDao.class);
        // --- members ---
        private CalendarUserRepository userRepository;
        private RoleRepository roleRepository;
        // --- constructors ---
        public JpaCalendarUserDao(final CalendarUserRepository repository,
                                  final RoleRepository roleRepository) {
            if (repository == null) {
                throw new IllegalArgumentException("repository cannot be null");
            }
            if (roleRepository == null) {
                throw new IllegalArgumentException("roleRepository cannot be null");
            }
            this.userRepository = repository;
            this.roleRepository = roleRepository;
        }
        // --- CalendarUserDao methods ---
        @Override
        @Transactional(readOnly = true)
        public CalendarUser getUser(final int id) {
            return userRepository.findById(id).orElse(null);
        }
        @Override
        @Transactional(readOnly = true)
        public CalendarUser findUserByEmail(final String email) {
            if (email == null) {
                throw new IllegalArgumentException("email cannot be null");
            }
            try {
                return userRepository.findByEmail(email);
            } catch (EmptyResultDataAccessException notFound) {
                return null;
            }
        }
        @Override
        @Transactional(readOnly = true)
        public List<CalendarUser> findUsersByEmail(final String email) {
            if (email == null) {
                throw new IllegalArgumentException("email cannot be null");
            }
            if ("".equals(email)) {
                throw new IllegalArgumentException("email cannot be empty string");
            }
            return userRepository.findAll();
        }
        @Override
        public int createUser(final CalendarUser userToAdd) {
            if (userToAdd == null) {
                throw new IllegalArgumentException("userToAdd cannot be null");
            }
            if (userToAdd.getId() != null) {
                throw new IllegalArgumentException("userToAdd.getId() must be null when creating a "+CalendarUser.class.getName());
            }
            Set<Role> roles = new HashSet<>();
            roles.add(roleRepository.findById(0).orElse(null));
            userToAdd.setRoles(roles);
            CalendarUser result = userRepository.save(userToAdd);
            userRepository.flush();
            return result.getId();
        }
    }
    

    在前面的代码中,用于利用 JPA 仓库的更新片段已被加粗,因此现在EventCalendarUser对象被映射到我们的底层 RDBMS。

在这一点上,应用程序可能无法正常工作,但这仍然可以被视为在继续转换的下一步之前的一个标记点。

重要注意事项

在这一点上,您的源代码应该看起来与chapter05.03- calendar相同。

应用程序服务

剩下的唯一事情就是配置 Spring Security 以使用新的工件。

我们需要编辑DefaultCalendarService.java文件,并仅删除用于将USER_ROLE添加到任何新创建的User对象的剩余代码,如下所示:

//com/packtpub/springsecurity/service/DefaultCalendarService.java
@Repository
public class DefaultCalendarService implements CalendarService {
... omitted for brevity ...
  public int createUser(CalendarUser user) {
    String encodedPassword = passwordEncoder.encode(user.getPassword());
    user.setPassword(encodedPassword);
    int userId = userDao.createUser(user);
    return userId;
  }
}

UserDetailsService 对象

让我们看看以下步骤来添加UserDetailsService对象:

  1. 现在,我们需要添加UserDetailsService对象的新实现;我们将使用我们的CalendarUserRepository接口再次对用户进行身份验证和授权,使用相同的底层 RDBMS,但使用我们的新 JPA 实现,如下所示:

    //com/packtpub/springsecurity/service/ CalendarUserDetailsService.java
    @Component
    public class CalendarUserDetailsService implements UserDetailsService {
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        CalendarUser user = calendarUserDao.findUserByEmail(username);
        if (user == null) {
            throw new UsernameNotFoundException("Invalid username/password.");
        }
        return new CalendarUserDetails(user);
     }
    }
    
  2. 现在,我们必须配置 Spring Security 以使用我们的自定义UserDetailsService对象,如下所示:

    //com/packtpub/springsecurity/configuration/SecurityConfig.java
    @Configuration
    @EnableWebSecurity
    public class SecurityConfig {
    ... omitted for brevity ...
        @Bean
        public AuthenticationManager authManager(HttpSecurity http) throws Exception {
            AuthenticationManagerBuilder authenticationManagerBuilder =
                http.getSharedObject(AuthenticationManagerBuilder.class);
            return authenticationManagerBuilder.build();
        }
      }
    ...
    }
    
  3. 启动应用程序并尝试登录应用程序。现在,任何配置的用户都可以登录并创建新事件。您还可以创建一个新用户,并可以立即以这个新用户登录。

重要注意事项

您的代码现在应该看起来像calendar05.04-calendar

从关系型数据库管理系统(RDBMS)重构到文档数据库

幸运的是,随着 Spring Data 项目的出现,一旦我们有了 Spring Data 实现,大部分困难的工作就已经完成了。现在,只需要对几个特定实现进行重构。

使用 MongoDB 的文档数据库实现

现在,我们将着手重构我们的 RDBMS 实现——使用 JPA 作为 ORM 提供者——到文档数据库实现,使用 MongoDB 作为底层数据库提供者。MongoDB 是一个免费的开源跨平台文档导向数据库程序。作为一种 NoSQL 数据库程序,MongoDB 使用具有模式的类似 JSON 的文档。MongoDB 由 MongoDB Inc. 开发,位于 github.com/mongodb/mongo

更新我们的依赖项

我们已经包含了本章所需的全部依赖项,因此你不需要更新你的 build.gradle 文件。然而,如果你只是将 Spring Data JPA 支持添加到自己的应用程序中,你需要在 build.gradle 文件中添加 spring-boot-starter-data-jpa 作为依赖项,如下所示:

//build.gradle
dependencies {
// MondgoDB
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
implementation 'de.flapdoodle.embed:de.flapdoodle.embed.mongo.spring30x:4.9.2'
}

注意,我们已经移除了 spring-boot-starter-jpa 依赖。spring-boot-starter-data-mongodb 依赖将包含将我们的领域对象连接到嵌入式 MongoDB 数据库所需的所有依赖项,这些依赖项结合了 Spring 和 MongoDB 注解。

我们还添加了 Flapdoodle 嵌入式 MongoDB 数据库,但这仅用于测试和演示目的。嵌入式 MongoDB 将提供一种平台无关的方式来在单元测试中运行 MongoDB。这个嵌入式数据库位于 github.com/flapdoodle-oss/de.flapdoodle.embed.mongo

重新配置 MongoDB 数据库配置

首先,我们将开始将当前的 JBCP 日历项目转换为 JPA 实现。让我们首先重新配置数据库以使用 Flapdoodle-嵌入式 MongoDB 数据库。之前,当我们更新这个项目的依赖项时,我们添加了一个 Flapdoodle 依赖项,为项目提供了一个嵌入式 MongoDB 数据库,我们可以自动使用它而不是安装完整的 MongoDB 版本。为了与 JBCP 应用程序保持一致,我们需要更改我们数据库的名称。使用 Spring Data,我们可以通过以下方式使用 YAML 配置更改 MongoDB 配置:

//src/main/resources/application.yml
spring:
  ## Thymeleaf configuration:
  thymeleaf:
    cache: false
    mode: HTML
  # MongoDB
  data:
    mongodb:
      host: localhost
      database: dataSource
de:
  flapdoodle:
    mongodb:
      embedded:
        version: 7.0.0

对于我们当前的需求,最重要的配置是将数据库名称更改为 dataSource,这是我们在这本书中一直使用的名称。

初始化 MongoDB 数据库

使用 JPA 实现,我们使用了 data.sql 文件来初始化数据库中的数据。对于 MongoDB 实现,我们可以移除 data.sql 文件,并用一个 Java 配置文件替换它,我们将称之为 MongoDataInitializer.java

//src/main/java/com/packtpub/springsecurity/configuration/ MongoDataInitializer.java
@Configuration
public class MongoDataInitializer {
    private static final Logger logger = LoggerFactory
            .getLogger(MongoDataInitializer.class);
    private RoleRepository roleRepository;
    private CalendarUserRepository calendarUserRepository;
    private EventRepository eventRepository;
    public MongoDataInitializer(RoleRepository roleRepository, CalendarUserRepository calendarUserRepository, EventRepository eventRepository) {
       this.roleRepository = roleRepository;
       this.calendarUserRepository = calendarUserRepository;
       this.eventRepository = eventRepository;
    }
    @PostConstruct
    public void setUp() {
    }
    CalendarUser user, admin, user2;
    // CalendarUsers
    {
        user = new CalendarUser(0, "user1@example.com","$2a$04$qr7RWyqOnWWC1nwotUW1nOe1RD5.mKJVHK16WZy6v49pymu1WDHmi","User","1");
        admin = new CalendarUser(1,"admin1@example.com","$2a$04$0CF/Gsquxlel3fWq5Ic/ZOGDCaXbMfXYiXsviTNMQofWRXhvJH3IK","Admin","1");
        user2 = new CalendarUser(2,"user2@example.com","$2a$04$PiVhNPAxunf0Q4IMbVeNIuH4M4ecySWHihyrclxW..PLArjLbg8CC","User2","2");
    }
    Role user_role, admin_role;
    private void seedRoles(){
        user_role = new Role(0, "ROLE_USER");
        user_role = roleRepository.save(user_role);
        admin_role = new Role(1, "ROLE_ADMIN");
        admin_role = roleRepository.save(admin_role);
    }
    private void seedEvents(){
        // Event 1
        Event event1 = new Event(
                100,
                "Birthday Party",
                "This is going to be a great birthday",
             LocalDateTime.of(2023, 6,3,6,36,00),
                user,
                admin
                );
        // Event 2
        Event event2 = new Event(
                101,
                "Conference Call",
                "Call with the client",
             LocalDateTime.of(2023, 11,23,13,00,00),
                user2,
                user
                );
        // Event 3
        Event event3 = new Event(
                102,
                "Vacation",
                "Paragliding in Greece",
             LocalDateTime.of(2023, 8,14,11,30,00),
                admin,
                user2
                );
        // save Event
        eventRepository.save(event1);
        eventRepository.save(event2);
        eventRepository.save(event3);
        List<Event> events = eventRepository.findAll();
        logger.info("Events: {}", events);
    }
    private void seedCalendarUsers(){
        // user1
        user.addRole(user_role);
        // admin2
        admin.addRole(user_role);
        admin.addRole(admin_role);
        // user2
        user2.addRole(user_role);
        // CalendarUser
        calendarUserRepository.save(user);
        calendarUserRepository.save(admin);
        calendarUserRepository.save(user2);
        List<CalendarUser> users = calendarUserRepository.findAll();
        logger.info("CalendarUsers: {}", users);
    }
}

这将在加载时执行,并将与我们在 H2 数据库中执行相同的数据种子到我们的 MongoDB 中。

使用 MongoDB 映射领域对象

让我们首先映射我们的 Event.java 文件,以便将每个领域对象作为文档保存在我们的 MongoDB 数据库中。这可以通过以下步骤完成:

  1. 在文档数据库中,领域对象映射略有不同,但相同的 ORM 概念仍然适用。让我们从Event JPA 实现开始,然后我们可以将我们的Entity转换为文档映射:

    //src/main/java/com/packtpub/springsecurity/domain/Event.java
    @Entity
    @Table(name = "events")
    public class Event implements Serializable{
        @Id
        @GeneratedValue(strategy = GenerationType.AUTO)
        private Integer id;
        @NotEmpty(message = "Summary is required")
        private String summary;
        @NotEmpty(message = "Description is required")
        private String description;
        @NotNull(message = "When is required")
        private Calendar dateWhen;
        @NotNull(message = "Owner is required")
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn(name="owner", referencedColumnName="id")
        private CalendarUser owner;
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn(name="attendee", referencedColumnName="id")
        private CalendarUser attendee;
    ...
    }
    
  2. 在基于实体的 JPA 映射中,我们需要使用六个不同的注解来创建所需的映射。现在,在基于文档的 MongoDB 映射中,我们需要更改所有之前的映射注解。以下是我们Event.java文件的完全重构示例:

    //src/main/java/com/packtpub/springsecurity/domain/Event.java
    @Document(collection="events")
    public class Event implements Persistable<Integer>, Serializable{
        @Id
        private Integer id;
        @NotEmpty(message = "Summary is required")
        private String summary;
        @NotEmpty(message = "Description is required")
        private String description;
        @NotNull(message = "When is required")
        private LocalDateTime dateWhen;
        @NotNull(message = "Owner is required")
        @DBRef
        private CalendarUser owner;
        @DBRef
        private CalendarUser attendee;
    ...
    }
    

    在前面的代码中,我们可以看到以下显著的变化。

  3. 首先,我们声明类为@o.s.d.mongodb.core.mapping.Document类型,并为这些文档提供一个集合名称。

  4. 接下来,Event类必须实现o.s.d.domain.Persistable接口,为我们的文档提供主键类型(Integer)。

  5. 现在,我们将我们的领域 ID 的注解更改为@o.s.d.annotation.Id,以定义领域主键。

  6. 之前,我们必须将所有者与会者CalendarUser对象映射到两个不同的映射注解。

    现在,我们只需要定义两种类型为@o.s.d.mongodb.core.mapping.DBRef,并允许 Spring Data 处理底层引用。

  7. 我们必须添加的最后一个注解定义了一个特定的构造函数,用于通过使用@o.s.d.annotation.PersistenceConstructor注解将新文档添加到我们的文档中。

  8. 现在我们已经审查了从 JPA 到 MongoDB 重构所需的更改,让我们重构其他领域对象,从Role.java文件开始,如下所示:

    //src/main/java/com/packtpub/springsecurity/domain/Role.java
    @Document(collection="role")
    public class Role  implements Persistable<Integer>, Serializable {
        @Id
        private Integer id;
        private String name;
    ...
    }
    
  9. 我们需要重构的最后一个领域对象是我们的CalendarUser.java文件。毕竟,这是我们在这个应用程序中最复杂的领域对象:

    //src/main/java/com/packtpub/springsecurity/domain/CalendarUser.java
    @Document(collection="calendar_users")
    public class CalendarUser implements Persistable<Integer>, Serializable {
        @Id
        private Integer id;
        private String firstName;
        private String lastName;
        private String email;
        private String password;
        @DBRef(lazy = false)
        private Set<Role> roles = new HashSet<>(5);
    …
    }
    

如您所见,将我们的领域对象从 JPA 重构到 MongoDB 的努力相当简单,所需的注解配置比 JPA 配置少。

MongoDB 的 Spring Data 仓库

我们现在只需要对从 JPA 实现到 MongoDB 实现的重构进行少量更改。我们将从重构我们的CalendarUserRepository.java文件开始,更改我们的仓库扩展的接口,如下所示:

//com/packtpub/springsecurity/repository/CalendarUserRepository.java
public interface CalendarUserRepository extends MongoRepository<CalendarUser, Integer> {
    CalendarUser findByEmail(String email);
}
...

适当地修改RoleRepository.java文件。

重要提示

如果您需要帮助进行这些更改,请记住chapter05.05的源代码将提供可供参考的完整代码。

MongoDB 中的数据访问对象

在我们的EventDao接口中,我们需要创建一个新的Event对象。在 JPA 中,我们可以自动生成我们的对象 ID。在 MongoDB 中,有几种方法可以分配主键标识符,但为了演示的目的,我们只是使用原子计数器,如下所示:

//src/main/java/com/packtpub/springsecurity/dataaccess/MongoEventDao.java
@Repository
public class MongoEventDao implements EventDao {
private EventRepository repository;
// Simple Primary Key Generator
private AtomicInteger eventPK = new AtomicInteger(102);
  @Override
    public int createEvent(final Event event) {
...
        // Get the next PK instance
        event.setId(eventPK.incrementAndGet());
        Event newEvent = repository.save(event);
        return newEvent.getId();
    }
...
}

在技术上,我们的CalendarUserDao对象没有发生变化,但为了保持本书的一致性,我们将实现文件重命名为表示使用Mongo

@Repository
public class MongoCalendarUserDao implements CalendarUserDao {

对于这个重构示例,没有其他数据访问对象(DAO)更改所需的。

开始应用吧,它将表现得和以前一样。

尝试以user1admin1的身份登录。然后测试应用程序,确保两个用户都可以向系统中添加新事件,确保整个应用程序的映射是正确的。

重要提示

你应该从chapter05.05-calendar的源代码开始。

摘要

我们已经探讨了 Spring Data 项目的强大功能和灵活性,并研究了与应用程序开发相关的几个方面,以及它与 Spring Security 的集成。在本章中,我们介绍了 Spring Data 项目及其一些功能。我们还看到了将遗留的 JDBC 代码使用 SQL 转换为 ORM 使用 JPA 的过程,以及从使用 Spring Data 的 JPA 实现到使用 Spring Data 的 MongoDB 实现的过程。我们还介绍了配置 Spring Security 以利用关系型数据库中的ORM 实体和文档数据库。

在下一章中,我们将探讨 Spring Security 对基于LDAP 认证的内置支持。

第六章:LDAP 目录服务

在本章中,我们将回顾(LDAP)并学习如何将其集成到启用 Spring Security 的应用程序中,以提供身份验证、授权和用户信息。

在本章的讨论过程中,我们将涵盖以下主题:

  • 学习与 LDAP 协议和服务器实现相关的一些基本概念

  • 在 Spring Security 中配置一个自包含的 LDAP 服务器

  • 启用 LDAP 身份验证和授权

  • 理解 LDAP 搜索和用户匹配背后的模型

  • 从标准 LDAP 结构中检索额外的用户详细信息

  • 区分 LDAP 身份验证方法并评估每种类型的优缺点

  • 使用 Spring bean 声明显式配置 Spring Security LDAP

  • 连接到外部 LDAP 目录

  • 探索对 Microsoft AD 的内置支持

  • 我们还将探讨如何定制 Spring Security,以便在处理自定义 AD 部署时具有更大的灵活性

本章代码的实际链接在这里:packt.link/f2tf1

理解 LDAP

LDAP 的根源可以追溯到 30 多年前的逻辑目录模型,概念上类似于组织结构图和地址簿的结合。如今,LDAP 越来越多地被用于集中化企业用户信息,将成千上万的用户划分为逻辑组,并允许在许多不同的系统之间统一共享用户信息。

为了安全起见,LDAP 通常被广泛用于简化集中式的用户名和密码身份验证——用户凭据存储在 LDAP 目录中,并且可以代表用户向目录发起身份验证请求。这简化了管理员的管理工作,因为用户凭据(登录 ID、密码和其他详细信息)存储在 LDAP 目录的单个位置。此外,基于用户在目录中的位置,定义了组织信息,如组或团队分配、地理位置和公司层级成员资格。

LDAP

到目前为止,如果你之前从未使用过 LDAP,你可能想知道它是什么。我们将通过 Apache Directory Server 示例目录的截图来展示一个样本 LDAP 模式:

图 6.1 – LDAP 目录结构示例

图 6.1 – LDAP 目录结构示例

uid=admin1@example.com(在前面的截图中被突出显示)的特定用户条目开始,我们可以通过从树中的这个节点开始向上移动来推断admin1的组织成员资格。我们可以看到用户aeinsteinusers组织单元(ou=users)的成员,而users组织单元本身又是example.com域的一部分(前一个截图中的缩写dc代表域组件)。

在此之前是 LDAP 树本身的组织元素(DITRoot DSE),在 Spring Security 的上下文中我们不关心这些。用户aeinstein在 LDAP 层次结构中的位置在语义上是明确且有意义的——你可以想象一个更复杂的层次结构,它可以很容易地说明一个大组织的组织和部门边界。

通过沿着树向下走到一个单独的叶节点形成的完整从上到下的路径,形成了一个由沿途所有中间节点组成的字符串,就像admin1的节点路径一样,如下所示:

uid=admin1,ou=users,dc=example,dc=com

前一个节点路径是唯一的,被称为节点的唯一名称DN)。DN 类似于数据库的主键,允许在复杂的树结构中唯一地识别和定位节点。我们将看到在 Spring Security LDAP 集成过程中,节点 DN 被广泛用于认证和搜索过程。

注意,在同一组织级别上还列出了几个其他用户,与admin1处于相同的组织位置。所有这些用户都被假定为与admin1处于相同的组织位置。尽管这个示例组织相对简单且扁平,但 LDAP 的结构是任意灵活的,可以有多个嵌套和逻辑组织级别。

Spring Security LDAP 支持由 Spring LDAP 模块(spring.io/projects/spring-ldap)提供协助,该模块是 Spring 框架和 Spring Security 核心项目的独立项目。它被认为是稳定的,并提供了一组围绕标准 Java LDAP 功能的包装器。

常见的 LDAP 属性名称

树中的每个条目都由一个或多个对象类定义。对象类是一个逻辑组织单元,将一组语义相关的属性分组在一起。通过将树中的条目声明为特定对象类的实例,例如人员,LDAP 目录的组织者可以向目录用户提供每个目录元素代表的明确指示。

LDAP 有一套丰富的标准模式,涵盖了可用的 LDAP 对象类及其适用的属性(以及大量其他信息)。如果您计划进行大量的 LDAP 工作,强烈建议您查阅一本好的参考指南,例如书籍《Zytrax OpenLDAP》的附录(www.zytrax.com/books/ldap/ape/)。

在上一节中,我们了解到 LDAP 树中的每个条目都有一个 DN,它在树中是唯一的。DN 由一系列属性组成,其中之一(或多个)用于唯一标识由 DN 表示的条目在树中的路径。由于 DN 描述的路径的每个部分都代表一个 LDAP 属性,因此您可以参考可用的、定义良好的 LDAP 模式和对象类来确定任何给定 DN 中的每个属性的含义。

我们在以下表中包含了某些常见属性及其含义。这些属性通常是组织属性——这意味着它们通常用于定义 LDAP 树的组织结构——并且按照从上到下的顺序排列,这可能是您在典型的 LDAP 安装中看到的结构:

属性名称 描述 示例
dc 域组件:在 LDAP 层次结构中,通常是最高级别的组织。 dc=jbcpcalendar,dc=com
c 国家:一些 LDAP 层次结构在高级别上按国家结构化。 c=US
o 组织名称:这是一个用于分类 LDAP 资源的父业务组织。 o=Oracle Corporation
ou 组织单元:这是一个通常在组织内部的部分业务组织。 ou=Product Development
cn 常见名称:这是对象的常见名称或唯一或可读名称。对于人类来说,这通常是人的全名,而对于 LDAP 中的其他资源(计算机等),通常是主机名。 cn=Super Visor cn=Jim Bob
uid 用户 ID:尽管不是组织性质的,但uid属性通常是 Spring 在用户认证和搜索过程中寻找的内容。 uid=svisor
userPassword 用户密码:此属性存储与该属性关联的人对象的密码。它通常使用 SHA 或类似方法进行单向散列。 userPassword=plaintext userPassword={SHA}cryptval

表 6.1 – LDAP 目录结构示例

前面的表格中的属性实际上是目录树上的组织属性,因此它们可能会形成各种搜索表达式或映射,这些表达式或映射将用于配置 Spring Security 与 LDAP 服务器交互。

重要提示

请记住,有数百个标准 LDAP 属性——这些只是您在集成一个完全填充的 LDAP 服务器时可能看到的一小部分。

更新我们的依赖项

我们已经包含了本章所需的全部依赖项,因此您不需要更新您的build.gradle文件。但是,如果您只是向自己的应用程序添加 LDAP 支持,您需要在build.gradle中添加spring-security-ldap作为依赖项,如下所示:

//build.gradle
dependencies {
...
// LDAP
    implementation 'org.springframework.security:spring-security-ldap'
...
}

重要提示

请记住,有数百个标准 LDAP 属性——这些只是你在与完全填充的 LDAP 服务器集成时可能看到的极小一部分。

如前所述,Spring Security 的 LDAP 支持建立在 Spring LDAP 之上。Gradle 将自动将其作为传递依赖项引入,因此无需显式列出。

配置嵌入式 LDAP 集成

现在我们将启用 JBCP 日历应用程序以支持基于 LDAP 的身份验证。幸运的是,这是一个相对简单的练习,使用嵌入式 LDAP 服务器和示例 UnboundID 服务器。这是通过在 build.gradle 中添加 unboundid-ldapsdk 依赖项来完成的,如下所示:

//build.gradle
dependencies {
...
// LDAP
    implementation 'com.unboundid:unboundid-ldapsdk'
...
}

配置 LDAP 服务器引用

第一步是配置嵌入式 LDAP 服务器。Spring Boot 将自动配置一个嵌入式 LDAP 服务器,但我们需要稍微调整一下配置。请对您的 application.yml 文件进行以下更新:

spring:
   ldap:
     base: dc=jbcpcalendar,dc=com
     embedded:
       ldif: classpath:/ldif/calendar.ldif
       baseDn: ${spring.ldap.base}
       port: 33389

重要提示

你应该从 chapter06.00-calendar 的源代码开始。

我们正在从 classpath 加载 calendar.ldif 文件,并使用它来填充 LDAP 服务器。root 属性使用指定的 DN 声明 LDAP 目录的根。这应该与我们使用的 LDIF 文件中逻辑根 DN 相对应。

小贴士

请注意,对于嵌入式 LDAP 服务器,base-dn 属性是必需的。如果没有指定或指定错误,初始化时可能会收到几个奇怪的错误。此外,请注意,ldif 资源应只加载单个 ldif,否则服务器将无法启动。Spring Security 需要单个资源,因为使用类似 classpath*:calendar.ldif 这样的方式并不能提供所需的确定性排序。

我们稍后会在 Spring Security 配置文件中重用这里定义的 bean ID,当我们声明 LDAP 用户服务和其他配置元素时。在嵌入式 LDAP 模式下,<ldap-server> 声明上的所有其他属性都是可选的。

启用 LDAP AuthenticationManager 接口

接下来,我们需要配置另一个 AuthenticationManager 接口,该接口将用户凭据与 LDAP 提供者进行校验。只需更新 Spring Security 配置以使用 o.s.s.ldap.authentication. AuthenticationManager 引用,如下所示:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Bean
AuthenticationManager authenticationManager(BaseLdapPathContextSource contextSource, LdapAuthoritiesPopulator authorities) {
    LdapBindAuthenticationManagerFactory factory = new LdapBindAuthenticationManagerFactory(contextSource);
    factory.setUserSearchBase("");
    factory.setUserSearchFilter("(uid={0})");
    factory.setLdapAuthoritiesPopulator(authorities);
    return factory.createAuthenticationManager();
}

配置 LdapAuthoritiesPopulator 接口

Spring Security 的 LdapAuthoritiesPopulator 用于确定返回给用户的权限。以下示例展示了如何配置 LdapAuthoritiesPopulator

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Bean
LdapAuthoritiesPopulator authorities(BaseLdapPathContextSource contextSource) {
    String groupSearchBase = "ou=Groups";
    DefaultLdapAuthoritiesPopulator authorities =
          new DefaultLdapAuthoritiesPopulator(contextSource, groupSearchBase);
    authorities.setGroupSearchFilter("(uniqueMember={0})");
    return authorities;
}

此外,我们已删除所有对 PasswordEncoder Bean 和 CalendarUserDetailsService 类的引用。

我们稍后会详细讨论这些属性。现在,请将应用程序恢复到运行状态,并尝试使用 admin1@example.com 作为用户名和 admin1 作为密码进行登录。你应该已经登录成功了!

重要提示

您应该从chapter06.01-calendar的源代码开始。

嵌入式 LDAP 故障排除

很可能您会遇到难以调试的嵌入式 LDAP 问题。如果您在浏览器中尝试访问应用程序时遇到404错误,那么很可能事情没有正确启动。如果您无法运行这个简单的示例,以下是一些需要再次检查的事项:

  • 确保在配置文件中设置了baseDn属性,并确保它与启动时加载的 LDIF 文件中定义的根匹配。如果您收到关于丢失分区的错误,则可能是根属性被遗漏或与您的 LDIF 文件不匹配。

  • 注意到嵌入式 LDAP 启动失败并不一定是致命的失败。为了诊断加载 LDIF 文件时的错误,您需要确保启用了适当的日志设置,包括 LDAP 服务器的日志记录,至少在错误级别。

  • 如果应用程序服务器非正常关闭,您可能需要删除临时目录(Windows 系统上的%TEMP%或基于 Linux 的系统上的/tmp)中的某些文件,以便再次启动服务器。关于此的错误消息(幸运的是)相当清晰。不幸的是,嵌入式 LDAP 不像嵌入式 H2 数据库那样无缝且易于使用,但它仍然比尝试下载和配置许多免费可用的外部 LDAP 服务器要容易得多。

Apache Directory Studio 项目是一个出色的故障排除工具,可用于访问 LDAP 服务器,它提供独立和 Eclipse 插件版本。免费下载可在jxplorer.org/获取。

理解 Spring LDAP 认证的工作原理

我们看到我们可以使用在 LDAP 目录中定义的用户名进行登录。但是,当用户对 LDAP 中的用户发起登录请求时,究竟会发生什么呢?LDAP 认证过程有三个基本步骤:

  1. 将用户提供的凭据与 LDAP 目录进行认证。

  2. 根据用户在 LDAP 中的信息确定用户拥有的GrantedAuthority对象。

  3. 将用户在 LDAP 条目中的信息预先加载到自定义的UserDetails对象中,以便应用程序进一步使用。

认证用户凭据

对于第一步,即对 LDAP 目录进行认证,一个自定义认证提供程序被连接到AuthenticationManagero.s.s.ldap.authentication.LdapAuthenticationProvider接口接受用户提供的凭据,并验证它们与 LDAP 目录,如下面的图所示:

图 6.2 – Spring Security LDAP 认证工作流程

图 6.2 – Spring Security LDAP 认证工作流程

我们可以看到,o.s.s.ldap.authentication.LdapAuthenticator接口定义了一个代理,允许提供者以可定制的方式发出认证请求。我们隐式配置到这一点的实现o.s.s.ldap.authentication.BindAuthenticator尝试使用用户的凭据绑定(登录)到 LDAP 服务器,就像用户自己建立连接一样。对于嵌入式服务器,这对我们的认证需求是足够的;然而,外部 LDAP 服务器可能更严格,在这些服务器中,用户可能不允许绑定到 LDAP 目录。幸运的是,存在一种替代的认证方法,我们将在本章后面探讨。

如前图所示,请注意,搜索是在由DefaultSpringSecurityContextSource引用的baseDn属性指定的凭据创建的 LDAP 上下文中进行的。对于嵌入式服务器,我们不使用此信息,但对于外部服务器引用,除非提供了baseDn,否则将使用匿名绑定。对于需要有效凭据来搜索 LDAP 目录的组织来说,保留对目录中信息公开性的某些控制是非常常见的,因此,在现实场景中,baseDn几乎总是必需的。baseDn属性代表一个具有有效访问权限以绑定目录并执行搜索的用户的完整 DN。

使用 JXplorer 演示认证

我们将通过使用JXplorer连接到我们的嵌入式 LDAP 实例并执行 Spring Security 所执行的相同步骤来演示认证过程的工作原理。在整个模拟过程中,我们将使用user1@example.com。这些步骤将有助于确保对幕后发生的事情有牢固的理解,并有助于你在难以确定正确配置时。

确保日历应用程序已启动并正在运行。接下来,启动Jxplorer

匿名绑定到 LDAP

第一步是匿名绑定到 LDAP。绑定是匿名的,因为我们没有在我们的DefaultSpringSecurityContextSource对象上指定baseDnpassword属性。在Jxplorer中,按照以下步骤创建一个连接:

  1. 点击文件 | 连接

  2. 输入以下信息:

    • 主机名:localhost

    • 端口:33389

  3. 我们没有指定baseDn,因此选择无认证作为认证方法

  4. 点击确定

    你可以安全地忽略指示没有默认模式信息的消息。

图 6.3 – 匿名绑定到 LDAP

图 6.3 – 匿名绑定到 LDAP

你现在应该看到你已经连接到了嵌入式 LDAP 实例。

搜索用户

现在我们已经建立了连接,我们可以通过以下步骤使用它来查找我们希望绑定的用户的 DN:

  1. 右键点击World并选择搜索

  2. 输入搜索基dc=jbcpcalendar,dc=com。这对应于我们指定的spring.ldap.base属性的baseDn属性。

  3. 输入过滤器uid=user1@example.com。这对应于我们为AuthenticationManagerBuilderuserSearchFilter方法指定的值。

  4. 点击搜索

图 6.4 – 搜索用户

图 6.4 – 搜索用户

  1. 点击搜索结果中返回的单个结果的复制 DN。你现在可以看到我们的 LDAP 用户被显示出来。注意,这个 DN 与我们所搜索的值相匹配。记住这个 DN,因为它将在我们的下一步中使用。

图 6.5 – 搜索用户

图 6.5 – 搜索用户

作为用户绑定到 LDAP

现在我们已经找到了我们用户的完整 DN,我们需要尝试以该用户身份绑定到 LDAP 以验证提交的密码。这些步骤与我们已经做的匿名绑定相同,只是我们将指定我们正在认证的用户凭据。

Jxplorer中,按照以下步骤创建连接:

  1. 点击文件 | 连接

  2. 输入以下信息:

    • 主机名:localhost

    • 端口:33389

  3. 安全级别设置为用户 + 密码

  4. 将搜索结果中的 DN 输入为uid=admin1@example.com,ou=Administrators,ou=Users,dc=jbcpcalendar,dc=com

  5. 密码应该是登录时提交的密码。在我们的例子中,我们想使用admin1来成功认证。如果输入了错误的密码,我们将无法连接,Spring Security 将报告错误。

  6. 点击确定

图 6.6 – 作为用户绑定到 LDAP

图 6.6 – 作为用户绑定到 LDAP

当 Spring Security 能够成功绑定提供的用户名和密码时(类似于我们能够创建连接的方式),它将确定该用户的用户名和密码是正确的。然后 Spring Security 将继续确定用户的角色成员资格。

确定用户的角色成员资格

在用户成功通过 LDAP 服务器认证后,接下来必须确定授权信息。授权由主体的角色列表定义,LDAP 认证用户的角色成员资格如以下图所示确定:

图 6.7 – 用户角色成员

图 6.7 – 用户角色成员

我们可以看到,在用户通过 LDAP 进行身份验证后,LdapAuthenticationProvider将委托给LdapAuthoritiesPopulatorDefaultLdapAuthoritiesPopulator接口将尝试在 LDAP 层次结构中的另一个条目或以下的位置定位已验证用户的 DN。搜索用户角色分配的 DN 的位置由groupSearchBase方法定义;在我们的示例中,我们将此设置为groupSearchBase("ou=Groups")。当用户的 DN 位于groupSearchBase的 DN 以下的 LDAP 条目中时,找到其 DN 的条目上的属性将用于授予他们一个角色。

春节安全角色如何与 LDAP 用户关联可能会有些令人困惑,因此让我们看看 JBCP 日历 LDAP 存储库,看看用户与角色的关联是如何工作的。DefaultLdapAuthoritiesPopulator接口使用AuthenticationManagerBuilder声明的几个方法来管理为用户搜索角色的过程。这些属性按以下顺序大约使用:

  1. groupSearchBase:这定义了 LDAP 集成应在其中查找一个或多个与用户 DN 匹配的基 DN。默认值从 LDAP 根进行搜索,这可能很昂贵。

  2. groupSearchFilter:这定义了用于将用户的 DN 与位于groupSearchBase下的条目的属性匹配的 LDAP 搜索过滤器。此搜索过滤器使用两个参数进行参数化——第一个({0})是用户的 DN,第二个({1})是用户的用户名。默认值是uniqueMember={0}

  3. groupRoleAttribute:这定义了匹配条目的属性,该属性将用于组成用户的GrantedAuthority对象。默认值是cn

  4. rolePrefix:这是将添加到groupRoleAttribute中找到的值的默认前缀,以创建一个 Spring Security 的GrantedAuthority对象。默认值是ROLE_

这可能有些抽象,对于新开发者来说可能难以理解,因为它与我们迄今为止使用 JDBC 和 JPA 的UserDetailsService实现所看到的内容非常不同。让我们继续通过我们的user1@example.com用户在 JBCP 日历 LDAP 目录中的登录过程进行操作。

使用 Jxplorer 确定角色

现在,我们将尝试使用Jxplorer确定我们的用户的角色。使用我们之前创建的连接,执行以下步骤:

  1. 右键点击世界并选择搜索

  2. 输入搜索基ou=Groups,dc=jbcpcalendar,dc=com。这对应于我们指定的DefaultSpringSecurityContextSource对象的baseDn属性,加上我们为AuthenticationManagerBuilder对象指定的groupSearchBase属性。

  3. 输入文本过滤器uniqueMember=uid=user1@example.com,ou=Users,dc=jbcpcalendar,dc=com。这对应于默认的groupSearchFilter属性(uniqueMember={0})。请注意,我们已经用我们在之前的练习中找到的用户的全 DN 替换了{}值。

  4. 点击搜索

图 6.8 – 角色搜索

图 6.8 – 角色搜索

  1. 你会注意到Jxplorer。注意该组有一个包含我们用户和其他用户的完整 DN 的uniqueMember属性。

Spring Security 现在通过将找到的组名强制转换为大写并在组名前添加ROLE_来为每个结果创建GrantedAuthority对象。伪代码将类似于以下代码片段:

foreach group in groups:
authority = ("ROLE_"+group).upperCase()
grantedAuthority = new GrantedAuthority(authority)

小贴士

Spring LDAP 与你的灰色物质一样灵活。请记住,尽管这是一种组织 LDAP 目录以与 Spring Security 兼容的方法,但典型的使用场景正好相反——已经存在一个 Spring Security 需要连接的 LDAP 目录。在许多情况下,你将能够重新配置 Spring Security 以处理 LDAP 服务器的层次结构;然而,有效地规划和理解 Spring 在查询 LDAP 时的行为是关键。运用你的大脑,规划用户搜索和组搜索,并想出你能想到的最优计划——尽量使搜索范围最小化和精确。

你能描述一下登录过程的结果将如何与我们的admin1@example.com用户不同吗?如果你现在感到困惑,我们建议你休息一下,并尝试使用Jxplorer来浏览由应用程序运行配置的嵌入式 LDAP 服务器。如果你自己按照之前描述的算法搜索目录,可能会更容易理解 Spring Security 的 LDAP 配置流程。

映射 UserDetails 的额外属性

最后,一旦 LDAP 查找为用户分配了一组GrantedAuthority对象,o.s.s.ldap.userdetails.LdapUserDetailsMapper将咨询o.s.s.ldap.userdetails.UserDetailsContextMapper以检索任何额外的详细信息,以填充用于应用程序使用的UserDetails对象。

使用AuthenticationManagerBuilder,我们到目前为止已经配置了LdapUserDetailsMapper将用于使用从 LDAP 目录中用户条目中获取的信息填充UserDetails对象:

图 6.9 – 映射 UserDetails 的额外属性

图 6.9 – 映射 UserDetails 的额外属性

我们很快就会看到如何配置UserDetailsContextMapper以从标准的 LDAP personinetOrgPerson对象中提取大量信息。使用基线LdapUserDetailsMapper,存储的只有usernamepasswordGrantedAuthority

虽然在 LDAP 用户认证和详细信息检索的背后涉及更多的机制,但你会注意到整个过程似乎与我们在第四章中学习的 JDBC 认证有些相似,即基于 JDBC 的认证(验证用户并填充GrantedAuthority)。与 JDBC 认证一样,可以执行 LDAP 集成的高级配置。让我们深入探讨一下,看看可能有哪些可能性!

高级 LDAP 配置

一旦我们超越了 LDAP 集成的基础知识,Spring Security LDAP 模块中还有许多额外的配置能力,这些能力仍然在安全SecurityFilterChainbean 中。这包括检索用户个人信息,用户认证的附加选项,以及与标准的DaoAuthenticationProvider类结合使用 LDAP 作为UserDetailsService接口。

示例 JBCP LDAP 用户

我们在 JBCP 日历 LDIF 文件中提供了多个不同的用户。以下快速参考图表可能有助于你进行高级配置练习,或者进行自我探索:

用户名/密码 角色(s) 密码编码
admin1@example.com/admin1 ROLE_ADMIN, ROLE_USER Plaintext
user1@example.com/user1 ROLE_USER Plaintext
shauser@example.com/shauser ROLE_USER {``sha}
sshauser@example.com/sshauser ROLE_USER {``ssha}
hasphone@example.com/hasphone ROLE_USER Plaintext (在 telephoneNumber 属性中)`

表 6.2 – LDAP 用户列表

我们将在下一节中解释为什么密码编码很重要。

密码比较与绑定认证

一些 LDAP 服务器将配置为不允许某些个别用户直接绑定到服务器,或者禁用匿名绑定(我们直到目前为止一直在用于用户搜索的)。这种情况通常发生在希望限制一组用户能够从目录中读取信息的大型组织中。

在这些情况下,标准的 Spring Security LDAP 认证策略将不起作用,必须使用替代策略,该策略由o.s.s.ldap.authentication.PasswordComparisonAuthenticatorBindAuthenticator的兄弟类)实现:

图 6.10 – 密码比较与绑定认证

图 6.10 – 密码比较与绑定认证

PasswordComparisonAuthenticator接口绑定到 LDAP 并搜索与用户提供的用户名匹配的 DN。然后,它将用户提供的密码与匹配的 LDAP 条目上存储的userPassword属性进行比较。如果编码的密码匹配,则用户被认证,流程继续,就像BindAuthenticator一样。

配置基本密码比较

配置密码比较认证而不是绑定认证,就像在AuthenticationManager声明中添加一个方法一样简单。按照以下方式更新SecurityConfig.java文件:

@Bean
AuthenticationManager authenticationManager(BaseLdapPathContextSource contextSource, LdapAuthoritiesPopulator authorities) {
    LdapPasswordComparisonAuthenticationManagerFactory factory = new LdapPasswordComparisonAuthenticationManagerFactory(
          contextSource, new LdapShaPasswordEncoder());
    factory.setUserSearchBase("");
    factory.setUserSearchFilter("(uid={0})");
    factory.setLdapAuthoritiesPopulator(authorities);
    factory.setPasswordAttribute("userPassword");
    return factory.createAuthenticationManager();
}

PasswordCompareConfigurer类,通过声明passwordCompare方法使用,使用PlaintextPasswordEncoder进行密码编码。要使用SHA-1密码算法,我们需要设置一个密码编码器,并且我们可以使用o.s.s.a.encoding.LdapShaPasswordEncoder来支持SHA(回忆我们在第四章基于 JDBC 的认证)中广泛讨论的SHA-1密码算法)。

在我们的calendar.ldif文件中,我们将password字段设置为userPasswordPasswordCompareConfigurer类的默认password属性是password。因此,我们还需要使用passwordAttribute方法覆盖password属性。

服务器重启后,您可以使用shauser@example.com作为用户名shauser作为密码尝试登录。

重要提示

您应该从chapter06.02-calendar的源文件开始。

LDAP 密码编码和存储

LDAP 支持多种密码编码算法,从明文到单向哈希算法——类似于我们在上一章中探讨的那些——以及数据库支持的认证。LDAP 密码最常用的存储格式是SHASHA-1单向哈希)和SSHASHA-1单向哈希加上盐值)。许多 LDAP 实现通常支持的其它密码格式在RFC 2307An Approach to Using LDAP as a Network Information Service)中有详细说明(tools.ietf.org/html/rfc2307)。RFC 2307的设计者在密码存储方面做了一件非常巧妙的事情。目录中保留的密码当然是用适当的算法(SHA等)编码的,但随后,它们会在算法前加上前缀。这使得 LDAP 服务器能够非常容易地支持多种密码编码算法。例如,SHA编码的密码在目录中的存储方式如下:

{SHA}5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8

我们可以看到,密码存储算法用{SHA}符号非常清楚地标明,并且与密码一起存储。

SSHA符号是尝试将强大的SHA-1哈希算法与密码盐值结合,以防止字典攻击。与我们在上一章中回顾的密码盐值类似,盐值在计算哈希之前添加到密码中。当哈希密码存储在目录中时,盐值会被附加到哈希密码后面。密码前面加上{SSHA},这样 LDAP 目录就知道用户提供的密码需要以不同的方式比较。大多数现代 LDAP 服务器将SSHA作为它们的默认密码存储算法。

密码比较验证器的缺点

现在你已经了解了一些关于 LDAP 如何使用密码的信息,并且我们已经设置了 PasswordComparisonAuthenticator,你认为如果你使用以 SSHA 格式存储的密码登录我们的 sshauser@example.com 用户会发生什么?

好吧,把书放一边,试试看,然后再回来。你的登录被拒绝了,对吧?然而,你仍然能够以 SHA 编码的密码登录为用户。为什么?当我们使用绑定认证时,密码编码和存储并不重要。你认为那是什么原因呢?

为什么在绑定认证中这并不重要是因为 LDAP 服务器负责处理用户的密码认证和验证。在密码比较认证中,Spring Security LDAP 负责以目录期望的格式对密码进行编码,然后与目录进行匹配以验证认证。

为了安全起见,密码比较认证实际上无法从目录中读取密码(读取目录密码通常会被安全策略拒绝)。相反,PasswordComparisonAuthenticator 在用户的目录条目处执行一个 LDAP 搜索,尝试匹配由 Spring Security 编码的密码所确定的 password 属性和值。

因此,当我们尝试使用 sshauser@example.com 登录时,PasswordComparisonAuthenticator 会使用配置的 SHA 算法对密码进行编码,并尝试进行简单的匹配,但失败了,因为该用户的目录密码是以 SSHA 格式存储的。

我们当前的配置,使用 LdapShaPasswordEncoder,已经支持 SHASSHA,所以目前仍然不起作用。让我们思考一下这可能是为什么。记住,SSHA 使用加盐密码,盐值存储在 LDAP 目录中与密码一起。然而,PasswordComparisonAuthenticator 的编码方式使得它无法从 LDAP 服务器中读取任何内容(这通常违反了不允许绑定的公司的安全策略)。因此,当 PasswordComparisonAuthenticator 计算散列密码时,它无法确定要使用哪个盐值。

总之,PasswordComparisonAuthenticator 在某些有限的情况下很有价值,在这些情况下目录本身的安全性是一个关注点,但它永远不会像直接的绑定认证那样灵活。

配置 UserDetailsContextMapper 对象

如我们之前所述,o.s.s.ldap.userdetails.UserDetailsContextMapper 接口的一个实例用于将用户的条目映射到 LDAP 服务器中的内存中的 UserDetails 对象。默认的 UserDetailsContextMapper 对象的行为类似于 JpaDaoImpl,考虑到返回的 UserDetails 对象上填充的详细程度——也就是说,除了用户名和密码之外,没有返回太多信息。

然而,LDAP 目录可能包含比用户名、密码和角色更多的关于个别用户的详细信息。Spring Security 随带提供两种从两个标准 LDAP 对象模式(personinetOrgPerson)中提取更多用户数据的方法。

UserDetailsContextMapper 的隐式配置

为了配置一个不同于默认的 UserDetailsContextMapper 实现,我们只需声明我们希望 LdapAuthenticationProvider 返回哪个 LdapUserDetails 类。安全命名空间解析器将足够智能,能够根据请求的 LdapUserDetails 接口类型实例化正确的 UserDetailsContextMapper 实现。

让我们重新配置我们的 SecurityConfig.java 文件以使用 inetOrgPerson 版本的映射器。更新 SecurityConfig.java 文件,如下所示:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Bean
LdapAuthoritiesPopulator authorities(BaseLdapPathContextSource contextSource) {
    String groupSearchBase = "ou=Groups";
    DefaultLdapAuthoritiesPopulator authorities =
          new DefaultLdapAuthoritiesPopulator(contextSource, groupSearchBase);
    authorities.setGroupSearchFilter("(uniqueMember={0})");
    return authorities;
}

重要提示

如果我们删除 passwordEncoder 方法,那么使用 SHA 密码的 LDAP 用户将无法通过身份验证。

如果您重新启动应用程序并尝试以 LDAP 用户身份登录,您会发现没有任何变化。实际上,UserDetailsContextMapper 在幕后已经更改,以便在用户目录条目中可用 inetOrgPerson 架构的属性时读取额外的详细信息。

查看更多用户详情

为了帮助您在这个领域,我们将向 JBCP 日历应用程序添加查看当前账户的功能。我们将使用此页面来说明更丰富的个人和 inetOrgPerson LDAP 架构如何为您的启用 LDAP 的应用程序提供额外的(可选)信息。

您可能已经注意到,本章附带了一个名为 AccountController 的额外控制器。您可以看到相关代码,如下所示:

//src/main/java/com/packtpub/springsecurity/web/controllers/AccountControll er.java
@Controller
public class AccountController {
    @RequestMapping("/accounts/my")
    public String view(Model model) {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if(authentication == null) {
            throw new IllegalStateException("authentication cannot be null. Make sure you are logged in.");
        }
        Object principal = authentication.getPrincipal();
        model.addAttribute("user", principal);
        model.addAttribute("isLdapUserDetails", principal instanceof LdapUserDetails);
        model.addAttribute("isLdapPerson", principal instanceof Person);
        model.addAttribute("isLdapInetOrgPerson", principal instanceof InetOrgPerson);
        return "accounts/show";
    }
}

上述代码将检索由 LdapAuthenticationProvider 存储在 Authentication 对象中的 UserDetails 对象(主体),并确定它是哪种类型的 LdapUserDetailsImplinterface。页面代码本身将根据已绑定到用户认证信息的 UserDetails 对象的类型显示各种详细信息,如下面的 JSP 代码所示。我们已包含 JSP:

//src/main/resources/templates/accounts/show.xhtml
<dl>
    <dt>Username</dt>
    <dd id="username" th:text="${user.username}">ChuckNorris</dd>
    <dd>&nbsp;</dd>
    <dt>DN</dt>
    <dd id="dn" th:text="${user.dn}"></dd>
    <dd>&nbsp;</dd>
    <span th:if="${isLdapPerson}">
        <dt>Description</dt>
        <dd id="description" th:text="${user.description}"></dd>
        <dd>&nbsp;</dd>
        <dt>Telephone</dt>
        <dd id="telephoneNumber" th:text="${user.telephoneNumber}"></dd>
        <dd>&nbsp;</dd>
        <dt>Full Name(s)</dt>
        <span th:each="cn : ${user.cn}">
            <dd th:text="${cn}"></dd>
        </span>
        <dd>&nbsp;</dd>
    </span>
    <span th:if="${isLdapInetOrgPerson}">
        <dt>Email</dt>
        <dd id="email" th:text="${user.mail}"></dd>
        <dd>&nbsp;</dd>
        <dt>Street</dt>
        <dd id="street" th:text="${user.street}"></dd>
        <dd>&nbsp;</dd>
    </span>
</dl>

实际上需要完成的工作是在我们的 header.xhtml 文件中添加一个链接,如下面的代码片段所示:

//src/main/resources/templates/fragments/header.xhtml
<li class="nav-item">
    <a class="nav-link" th:href="@{/accounts/my}">Welcome <span class="navbar-text"
                                      th:text="${#authentication.name}"></span></a>
</li>

我们添加了以下两个用户,您可以使用它们来检查可用数据元素之间的差异:

用户名 密码 类型
shainet@example.com shainet inetOrgPerson
shaperson@example.com shaperson person

表 6.3 – 新增 LDAP 用户列表

重要提示

您的代码应类似于 chapter06.03-calendar

重新启动服务器,通过点击右上角的username来检查每种类型用户的Account Details页面。你会注意到,当UserDetails类配置为使用inetOrgPerson时,尽管返回的是o.s.s.ldap.userdetails.InetOrgPerson,但字段是否填充取决于目录条目中可用的属性。

实际上,inetOrgPerson有许多我们在这个简单页面上展示的属性。你可以在RFC 2798inetOrgPerson LDAP 对象类定义中查看完整的列表(tools.ietf.org/html/rfc2798)。

你可能会注意到,没有提供支持在对象条目上指定的额外属性的功能,但这些属性并不符合标准模式。标准的UserDetailsContextMapper接口不支持任意属性列表,但仍然可以通过使用userDetailsContextMapper方法,通过引用你自己的UserDetailsContextMapper接口来自定义它。

使用替代密码属性

在某些情况下,可能需要使用替代 LDAP 属性而不是userPassword来进行认证。这种情况可能发生在公司部署了自定义 LDAP 模式或不需要强密码管理(虽然这绝对不是一个好主意,但在现实世界中确实会发生)的时候。

PasswordComparisonAuthenticator接口还支持验证用户的密码与替代 LDAP 条目属性(而不是标准的userPassword属性)的能力。这非常容易配置,我们可以通过使用明文telephoneNumber属性来演示一个简单的例子。按照以下方式更新SecurityConfig.java

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Bean
AuthenticationManager authenticationManager(BaseLdapPathContextSource contextSource, LdapAuthoritiesPopulator authorities) {
    LdapPasswordComparisonAuthenticationManagerFactory factory = new LdapPasswordComparisonAuthenticationManagerFactory(
          contextSource, new LdapShaPasswordEncoder());
    factory.setUserSearchBase("");
    factory.setUserDetailsContextMapper(new InetOrgPersonContextMapper());
    factory.setUserSearchFilter("(uid={0})");
    factory.setLdapAuthoritiesPopulator(authorities);
    factory.setPasswordAttribute("telephoneNumber");
    return factory.createAuthenticationManager();
}

我们可以重新启动服务器,并尝试使用hasphone@example.com作为username属性和0123456789作为password(电话号码)属性进行登录。

重要提示

你的代码应该看起来像chapter06.04-calendar

当然,这种认证方式具有我们之前讨论过的基于PasswordComparisonAuthenticator认证的所有风险;然而,了解它总归是好的,以防万一它出现在 LDAP 实现中。

使用 LDAP 作为 UserDetailsService

需要注意的一点是,LDAP 也可以用作UserDetailsService。正如我们将在本书后面讨论的,UserDetailsService是启用 Spring Security 基础设施中各种其他功能所必需的,包括记住我功能和 OpenID 认证功能。

我们将修改我们的AccountController对象,使用LdapUserDetailsService接口来获取用户。在这样做之前,请确保删除passwordCompare方法,如下面的代码片段所示:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Bean
AuthenticationManager authenticationManager(BaseLdapPathContextSource contextSource, LdapAuthoritiesPopulator authorities) {
    LdapBindAuthenticationManagerFactory factory = new LdapBindAuthenticationManagerFactory(contextSource);
    factory.setUserSearchBase("");
    factory.setUserSearchFilter("(uid={0})");
    factory.setLdapAuthoritiesPopulator(authorities);
    factory.setUserDetailsContextMapper(new InetOrgPersonContextMapper());
    return factory.createAuthenticationManager();
}

配置 LdapUserDetailsService

将 LDAP 配置为UserDetailsService功能的配置与配置 LDAP AuthenticationProvider非常相似。类似于 JDBC UserDetailsService,LDAP UserDetailsService接口被配置为<http>声明的兄弟元素。请在SecurityConfig.java文件中进行以下更新:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Bean
public UserDetailsService userDetailsService(BaseLdapPathContextSource contextSource, LdapAuthoritiesPopulator authorities) {
    return new LdapUserDetailsService(new FilterBasedLdapUserSearch("", "(uid={0})", contextSource), authorities);
}

在功能上,o.s.s.ldap.userdetails.LdapUserDetailsService的配置几乎与LdapAuthenticationProvider完全相同,唯一的区别是它没有尝试使用主体的用户名来绑定到 LDAP。相反,凭证由DefaultSpringSecurityContextSource引用提供,并用于执行用户查找。

重要提示

如果你打算对 LDAP 本身进行用户认证,不要犯配置AuthenticationManagerBuilder时使用指向LdapUserDetailsServiceUserDetailsService的非常常见的错误!如前所述,由于安全原因,password属性通常无法从 LDAP 中检索,这使得UserDetailsService对认证无济于事。如前所述,LdapUserDetailsService使用DefaultSpringSecurityContextSource声明中提供的baseDn属性来获取其信息——这意味着它不会尝试将用户绑定到 LDAP,因此可能不会按预期工作。

更新 AccountController 以使用 LdapUserDetailsService

我们现在将更新AccountController对象以使用LdapDetailsUserDetailsService接口来查找它显示的用户:

//src/main/java/com/packtpub/springsecurity/web/controllers/AccountControll er.java
@Controller
public class AccountController {
    private final UserDetailsService userDetailsService;
    public AccountController(UserDetailsService userDetailsService) {
       if (userDetailsService == null) {
          throw new IllegalArgumentException("userDetailsService cannot be null");
       }
       this.userDetailsService = userDetailsService;
    }
    @RequestMapping("/accounts/my")
    public String view(Model model) {
       Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
       if(authentication == null) {
          throw new IllegalStateException("authentication cannot be null. Make sure you are logged in.");
       }
       Object principal = userDetailsService.loadUserByUsername(authentication.getName());
       model.addAttribute("user", principal);
       model.addAttribute("isLdapUserDetails", principal instanceof LdapUserDetails);
       model.addAttribute("isLdapPerson", principal instanceof Person);
       model.addAttribute("isLdapInetOrgPerson", principal instanceof InetOrgPerson);
       return "accounts/show";
    }
}

显然,这个例子有点愚蠢,但它演示了LdapUserDetailsService的使用。请继续重启应用程序,并使用usernameadmin1@example.compasswordadmin1进行尝试。你能想出如何修改控制器以显示任意用户的信息吗?

你能想出应该如何修改安全设置以限制管理员访问吗?

重要提示

你的代码应类似于chapter06.05-calendar

将 Spring Security 与外部 LDAP 服务器集成

一旦你测试了与嵌入式 LDAP 服务器的基本集成,你可能会想与外部 LDAP 服务器进行交互。幸运的是,这非常简单,可以使用稍微不同的语法以及相同的

将 Spring Security 配置更新为连接到外部 LDAP 服务器,端口号为33389,如下所示:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Bean
AuthenticationManager authenticationManager(LdapAuthoritiesPopulator authorities) {
    BaseLdapPathContextSource contextSource=  new DefaultSpringSecurityContextSource(
          List.of("ldap://localhost:" + LDAP_PORT + "/"), "dc=jbcpcalendar,dc=com"){{
       setUserDn("uid=admin,ou=system");
       setPassword("secret");
    }};
    LdapPasswordComparisonAuthenticationManagerFactory factory = new LdapPasswordComparisonAuthenticationManagerFactory(
          contextSource, new LdapShaPasswordEncoder());
    factory.setUserSearchBase("");
    factory.setUserDetailsContextMapper(new InetOrgPersonContextMapper());
    factory.setUserSearchFilter("(uid={0})");
    factory.setLdapAuthoritiesPopulator(authorities);
    factory.setPasswordAttribute("userPassword");
    return factory.createAuthenticationManager();
}

这里值得注意的差异(除了 LDAP URL 之外)是提供了账户的 DN 和密码。账户(实际上是可选的)应允许绑定到目录,并在所有相关的 DN 上执行用户和组信息的搜索。将这些凭证应用于 LDAP 服务器 URL 产生的绑定用于 LDAP 安全系统中的剩余 LDAP 操作。

请注意,许多 LDAP 服务器也支持在 LDAP 服务器 URL 开头使用 ldaps://。LDAPS 通常在 TCP 端口 636 上运行。请注意,有许多商业和非商业的 LDAP 实现。

您将使用的确切配置参数将完全取决于供应商和目录的结构,用于连接、用户绑定以及 GrantedAuthoritys 的填充。我们将在下一节中介绍一个非常常见的 LDAP 实现,即 Microsoft AD。

如果您没有现成的 LDAP 服务器,但想尝试一下,请将以下代码添加到您的 SecurityConfig.java 文件中,该代码启动了我们一直在使用的嵌入式 LDAP 服务器:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
private BaseLdapPathContextSource getDefaultSpringSecurityContextSource () {
    DefaultSpringSecurityContextSource defaultSpringSecurityContextSource =     new DefaultSpringSecurityContextSource(
          List.of("ldap://localhost:" + LDAP_PORT), "dc=jbcpcalendar,dc=com");
    defaultSpringSecurityContextSource.setUserDn("uid=admin,ou=system");
    defaultSpringSecurityContextSource.setPassword("secret");
    defaultSpringSecurityContextSource.afterPropertiesSet();
    return defaultSpringSecurityContextSource;
}
@Bean
LdapAuthoritiesPopulator authorities() {
    String groupSearchBase = "ou=Groups";
    DefaultLdapAuthoritiesPopulator authorities =
          new DefaultLdapAuthoritiesPopulator(this.getDefaultSpringSecurityContextSource(), groupSearchBase);
    authorities.setGroupSearchFilter("(uniqueMember={0})");
    return authorities;
}
@Bean
AuthenticationManager authenticationManager(LdapAuthoritiesPopulator authorities) {
    LdapPasswordComparisonAuthenticationManagerFactory factory = new LdapPasswordComparisonAuthenticationManagerFactory(
          this.getDefaultSpringSecurityContextSource(), new LdapShaPasswordEncoder());
    factory.setUserSearchBase("");
    factory.setUserDetailsContextMapper(new InetOrgPersonContextMapper());
    factory.setUserSearchFilter("(uid={0})");
    factory.setLdapAuthoritiesPopulator(authorities);
    factory.setPasswordAttribute("userPassword");
    return factory.createAuthenticationManager();
}
@Bean
public UserDetailsService userDetailsService(LdapAuthoritiesPopulator authorities) {
    return new LdapUserDetailsService(new FilterBasedLdapUserSearch("", "(uid={0})", this.getDefaultSpringSecurityContextSource()), authorities);
}

如果这还不令人信服,请启动您的 LDAP 服务器,将其中的 calendar.ldif 导入。然后您可以连接到外部 LDAP 服务器。请继续重启应用程序,并使用 usernameshauser@example.compasswordshauser 尝试。

重要提示

您的代码应类似于 chapter06.06-calendar

显式 LDAP 实例配置

在本节中,我们将引导您了解所需的一组实例配置,以显式配置连接到外部 LDAP 服务器以及支持对外部服务器进行身份验证所需的 LdapAuthenticationProvider 接口。与其他显式基于实例的配置一样,除非您发现自己处于安全命名空间风格配置的能力无法满足您的业务或技术需求的情况,否则您真的想避免这样做,在这种情况下,请继续阅读!

配置外部 LDAP 服务器引用

为了实现此配置,我们假设我们有一个本地 LDAP 服务器在端口 33389 上运行,与上一节中提供的 DefaultSpringSecurityContextSource 接口示例具有相同的配置。所需的实例定义在 SecurityConfig.java 文件中。实际上,为了使事情简单,我们已经提供了整个 SecurityConfig.java 文件。请审查以下代码片段中的 LDAP 服务器引用:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Bean
public DefaultSpringSecurityContextSource contextSource() {
    DefaultSpringSecurityContextSource defaultSpringSecurityContextSource =     new DefaultSpringSecurityContextSource(
          List.of("ldap://localhost:" + LDAP_PORT), "dc=jbcpcalendar,dc=com");
    defaultSpringSecurityContextSource.setUserDn("uid=admin,ou=system");
    defaultSpringSecurityContextSource.setPassword("secret");
    return defaultSpringSecurityContextSource;
}

接下来,我们将探讨如何执行搜索以在 LDAP 目录中定位用户。

在 LDAP 目录中定位用户执行搜索

如果您已经阅读并理解了本章中关于 Spring Security LDAP 身份验证如何在幕后工作的解释,那么这个实例配置将非常容易理解,具有以下特点:

  • 用户凭证绑定认证(不是密码比较)

  • UserDetailsContextMapper 中使用 InetOrgPerson

请查看以下步骤:

  1. 为我们提供的第一个实例是 BindAuthenticator,以及支持 FilterBased LdapUserSearch 实例的 FilterBased 实例用于在绑定之前在 LDAP 目录中定位用户的 DN,如下所示:

    //src/main/java/com/packtpub/springsecurity/configuration/ SecurityConfig.java
    @Bean
    public BindAuthenticator bindAuthenticator(FilterBasedLdapUserSearch userSearch, BaseLdapPathContextSource contextSource){
        BindAuthenticator bindAuthenticator = new BindAuthenticator(contextSource);
        bindAuthenticator.setUserSearch(userSearch);
        return bindAuthenticator;
    }
    @Bean
    public FilterBasedLdapUserSearch filterBasedLdapUserSearch(BaseLdapPathContextSource contextSource){
        return new FilterBasedLdapUserSearch("", //user-search-base
              "(uid={0})", //user-search-filter
              contextSource); //ldapServer
    }
    
  2. 其次,LdapAuthoritiesPopulatorUserDetailsContextMapper 执行我们在本章前面检查过的角色:

    //src/main/java/com/packtpub/springsecurity/configuration/ SecurityConfig.java
    @Bean
    public LdapAuthoritiesPopulator authoritiesPopulator(BaseLdapPathContextSource contextSource){
        DefaultLdapAuthoritiesPopulator defaultLdapAuthoritiesPopulator = new DefaultLdapAuthoritiesPopulator(contextSource,"ou=Groups");
        defaultLdapAuthoritiesPopulator.setGroupSearchFilter("(uniqueMember={0})");
        return defaultLdapAuthoritiesPopulator;
    }
    @Bean
    public UserDetailsContextMapper userDetailsContextMapper(){
        return new InetOrgPersonContextMapper();
    }
    
  3. 最后,我们必须更新 Spring Security 以利用我们显式配置的 UserDetailsService bean,如下所示:

    //src/main/java/com/packtpub/springsecurity/configuration/ SecurityConfig.java
    @Bean
    public UserDetailsService userDetailsService(FilterBasedLdapUserSearch filterBasedLdapUserSearch,
           LdapAuthoritiesPopulator authoritiesPopulator, UserDetailsContextMapper userDetailsContextMapper) {
        LdapUserDetailsService ldapUserDetailsService = new LdapUserDetailsService(filterBasedLdapUserSearch, authoritiesPopulator);
        ldapUserDetailsService.setUserDetailsMapper(userDetailsContextMapper);
        return ldapUserDetailsService;
    }
    
  4. 到目前为止,我们已经完全配置了使用显式 Spring bean 符号的 LDAP 认证。在 LDAP 集成中使用这种技术在某些情况下很有用,例如当安全命名空间没有公开某些配置属性时,或者需要自定义实现类以提供针对特定业务场景定制的功能。我们将在本章后面探讨这样一个场景,当我们检查如何通过 LDAP 连接到 Microsoft AD 时。

  5. 现在您可以启动应用程序,并尝试使用 usernameshauser@example.compasswordshauser 的配置进行配置。

    假设您有一个运行的外部 LDAP 服务器,或者您保留了配置的内存中 DefaultSpringSecurityContextSource 对象,一切应该仍然正常工作。

重要提示

您的代码应类似于 chapter06.07-calendar

将角色发现委托给 UserDetailsService

一种使用显式 bean 配置填充可用用户角色的技术是实现支持通过用户名查找用户的功能在 UserDetailsService 中,并从该来源获取 GrantedAuthority 对象。

配置很简单,只需将 ldapAuthoritiesPopulator ID bean 替换为更新的 UserDetailsServiceLdapAuthoritiesPopulator 对象,并引用 UserDetailsService。请对 SecurityConfig.java 文件进行以下更新,确保您删除了之前的 ldapAuthoritiesPopulator bean 定义:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Bean
public BindAuthenticator bindAuthenticator(FilterBasedLdapUserSearch userSearch, BaseLdapPathContextSource contextSource){
    BindAuthenticator bindAuthenticator = new BindAuthenticator(contextSource);
    bindAuthenticator.setUserSearch(userSearch);
    return bindAuthenticator;
}
@Bean
public FilterBasedLdapUserSearch filterBasedLdapUserSearch(BaseLdapPathContextSource contextSource){
    return new FilterBasedLdapUserSearch("", //user-search-base
          "(uid={0})", //user-search-filter
          contextSource); //ldapServer
}
@Bean
public LdapAuthoritiesPopulator authoritiesPopulator(UserDetailsService userDetailsService){
    return new UserDetailsServiceLdapAuthoritiesPopulator(userDetailsService);
}

我们还需要确保我们已经定义了 userDetailsService。为了简化问题,添加一个内存中的 UserDetailsService 接口,如下所示:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Bean
public UserDetailsManager userDetailsService() {
    InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
    manager.createUser(User.withUsername("user1@example.com").password("user1").roles("USER").build());
    manager.createUser(User.withUsername("admin1@example.com").password("admin1").roles("USER", "ADMIN").build());
    return manager;
}

最后,我们配置了一个自定义的 LdapAuthenticationProvider 接口,如下所示:

//src/main/java/com/packtpub/springsecurity/configuration/ SecurityConfig.java
@Bean
public LdapAuthenticationProvider authenticationProvider(BindAuthenticator ba,
       LdapAuthoritiesPopulator lap,
       UserDetailsContextMapper cm){
    LdapAuthenticationProvider ldapAuthenticationProvider =  new LdapAuthenticationProvider(ba, lap);
    ldapAuthenticationProvider.setUserDetailsContextMapper(cm);
    return ldapAuthenticationProvider;
}

如果有,您可能希望从 AccountController 中移除对 UserDetailsService 的引用,如下所示:

//src/main/java/com/packtpub/springsecurity/web/controllers/AccountController.java
@Controller
public class AccountController {
    @RequestMapping("/accounts/my")
    public String view(Model model) {
       Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
       if(authentication == null) {
          throw new IllegalStateException("authentication cannot be null. Make sure you are logged in.");
       }
       Object principal = authentication.getPrincipal();
       model.addAttribute("user", principal);
       model.addAttribute("isLdapUserDetails", principal instanceof LdapUserDetails);
       model.addAttribute("isLdapPerson", principal instanceof Person);
       model.addAttribute("isLdapInetOrgPerson", principal instanceof InetOrgPerson);
       return "accounts/show";
    }
}

现在,您应该能够使用 admin1@example.com 作为 usernameadmin1 作为 password 进行认证。当然,我们也可以用这个内存中的 UserDetailsService 接口替换我们在 第四章 中讨论的 JDBC 或 JPA 基于的,以及在 第五章 中讨论的通过 Spring Data 进行认证。

重要提示

您的代码应类似于 chapter06.08-calendar

您可能会注意到与此相关的物流和管理问题,即用户名和角色必须在 LDAP 服务器和 UserDetailsService 所使用的存储库中管理——这可能不是一个适用于大型用户群的可扩展模型。

这种场景的更常见用途是在需要 LDAP 身份验证以确保受保护应用程序的用户是有效企业用户,但应用程序本身想要存储授权信息时。这可以将可能的应用特定数据从 LDAP 目录中分离出来,这可以是一种有益的关注点分离。

通过 LDAP 与 Microsoft Active Directory 集成

Microsoft AD 的一个方便的特性不仅在于它与基于 Microsoft Windows 的网络架构的无缝集成,而且还可以配置为使用 LDAP 协议公开 AD 的内容。如果你在一个大量使用 Microsoft Windows 的公司工作,那么你做的任何 LDAP 集成很可能都是针对你的 AD 实例的。

根据你对 Microsoft AD 的配置(以及目录管理员愿意配置它以支持 Spring Security LDAP 的意愿),你可能会有困难,不是在身份验证和绑定过程中,而是在将 AD 信息映射到 Spring Security 系统中的用户的GrantedAuthority对象上。

在我们的 LDAP 浏览器中,JBCP 日历企业样本 AD LDAP 树看起来如下截图所示:

图 6.11 – Microsoft Active Directory 结构示例

图 6.11 – Microsoft Active Directory 结构示例

这里没有看到的是我们在之前的样本 LDAP 结构中看到的ou=Groups;这是因为 AD 将组成员资格存储在用户自己的 LDAP 条目上的属性中。

我们需要更改我们的配置以支持我们的 AD 结构。假设我们是从上一节中详细说明的 bean 配置开始的,进行以下更新:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Bean
public AuthenticationProvider authenticationProvider(){
    ActiveDirectoryLdapAuthenticationProvider ap = new ActiveDirectoryLdapAuthenticationProvider(
          "corp.jbcpcalendar.com",
          "ldap://corp.jbcpcalendar.com");
    ap.setConvertSubErrorCodesToExceptions(true);
    return ap;
}
@Bean
public DefaultSpringSecurityContextSource contextSource() {
    DefaultSpringSecurityContextSource defaultSpringSecurityContextSource =     new DefaultSpringSecurityContextSource(
          List.of("ldap://corp.jbcpcalendar.com"), "dc=corp,dc=jbcpcalendar,dc=com");
    defaultSpringSecurityContextSource.setUserDn("CN=bnl,CN=Users,DC=corp,DC=jbcpcalendar,DC=com");
    defaultSpringSecurityContextSource.setPassword("admin123!");
    return defaultSpringSecurityContextSource;
}
@Bean
public FilterBasedLdapUserSearch filterBasedLdapUserSearch(BaseLdapPathContextSource contextSource) {
    return new FilterBasedLdapUserSearch("CN=Users", //user-search-base
          "(sAMAccountName={0})", //user-search-filter
          contextSource); //ldapServer
}

如果你已经定义了它,你将想要从SecurityConfig.java文件中移除UserDetailsService声明。最后,你将想要从AccountController中移除对UserDetailsService的引用。

sAMAccountName属性是我们在标准 LDAP 条目中使用的uid属性的 AD 等价物。尽管大多数 AD LDAP 集成可能比这个例子更复杂,但这应该为你提供一个起点,以便跳出来探索你对 Spring Security LDAP 集成内部工作原理的概念理解;支持甚至复杂的集成将会容易得多。

重要注意事项

如果你想运行这个样本,你需要一个与截图显示的架构相匹配的 AD 实例正在运行。另一种选择是调整配置以匹配你的 AD 架构。一个简单的方法是安装Active Directory Lightweight Directory Services,可以在www.microsoft.com/fr-FR/download/details.aspx?id=1451找到。你的代码应该看起来像chapter06.09-calendar

Spring Security 6.1 内置的 AD 支持

Active Directory 支持其自己的非标准认证选项,并且正常的用法模式与标准的LdapAuthenticationProvider不太吻合。通常,认证是通过使用域用户名(形式为user@domain)来执行的,而不是使用 LDAP 区分名称。为了使这更容易,Spring Security 有一个针对典型 Active Directory 设置的定制认证提供者。

配置ActiveDirectoryLdapAuthenticationProvider相当简单。你只需要提供域名和一个提供服务器地址的 LDAP URL,正如我们在前面的章节中所述。以下代码片段展示了配置的样子:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.ja
@Bean
public AuthenticationProvider authenticationProvider(){
    ActiveDirectoryLdapAuthenticationProvider ap = new ActiveDirectoryLdapAuthenticationProvider(
          "corp.jbcpcalendar.com",
          "ldap://corp.jbcpcalendar.com");
    ap.setConvertSubErrorCodesToExceptions(true);
    return ap;
}

关于提供的ActiveDirectory LdapAuthenticationProvider类,有一些需要注意的事项,如下:

  • 需要认证的用户必须能够绑定到 AD(没有管理用户)

  • 填充用户权限的默认方法是搜索用户的memberOf属性

  • 用户必须包含一个名为userPrincipalName的属性,其格式为username@<domain>

由于现实世界中发生的复杂 LDAP 部署,内置支持很可能提供如何与您自定义 LDAP 模式集成的指南。

摘要

我们已经看到,LDAP 服务器可以信赖提供认证和授权信息,以及在请求时提供丰富的用户配置文件信息。在本章中,我们介绍了 LDAP 术语和概念,以及 LDAP 目录可能如何组织以与 Spring Security 一起工作。我们还探索了从 Spring Security 配置文件配置独立(嵌入式)和外部 LDAP 服务器。

我们介绍了针对 LDAP 存储库的用户认证和授权,以及它们随后映射到 Spring Security 角色的过程。我们还看到了认证方案、密码存储和 LDAP 中的安全机制之间的差异,以及它们在 Spring Security 中的处理方式。我们还学习了如何将 LDAP 目录中的用户详细属性映射到UserDetails对象,以便在 LDAP 和 Spring 启用应用程序之间进行丰富的信息交换。我们还解释了 LDAP 的 bean 配置以及这种方法的优缺点。最后,我们还涵盖了与 Microsoft AD 的集成。

在下一章中,我们将讨论 Spring Security 的remember-me功能,该功能允许用户的会话在关闭浏览器后安全地持久化。

第七章:“记住我”服务

在本章中,我们将添加一个功能,使应用程序能够在会话过期和浏览器关闭后仍然记住用户。本章将涵盖以下主题:

  • 讨论什么是“记住我”

  • 学习如何使用基于令牌的“记住我”功能

  • 讨论如何确保“记住我”的安全性,以及各种提高其安全性的方法

  • 启用基于持久的“记住我”功能,以及如何处理使用它时的额外考虑因素

  • 展示“记住我”的整体架构

  • 学习如何创建一个仅限于用户 IP 地址的定制“记住我”实现

本章的代码示例链接在此:packt.link/WEEx2

什么是“记住我”?

提供一个方便的功能,让网站的常客能够选择在浏览器关闭后仍然被记住。在 Spring Security 中,这是通过在用户的浏览器中存储一个“记住我”cookie来实现的。如果 Spring Security 识别出用户正在展示一个“记住我”cookie,那么用户将自动登录到应用程序,并且不需要输入用户名或密码。

什么是 cookie?

Cookie 是一种客户端(即,网页浏览器)持久化状态的方式。有关 Cookie 的更多信息,请参阅其他在线资源,例如维基百科(en.wikipedia.org/wiki/HTTP_cookie)。

Spring Security 提供了以下两种不同的策略,我们将在本章中讨论:

  • 第一种是基于令牌的“记住我”功能,它依赖于加密签名

  • 第二种方法是基于持久的记住我功能,它需要一个数据存储(数据库)

如我们之前提到的,我们将在本章中更详细地讨论这些策略。要启用“记住我”功能,必须显式配置。让我们先尝试基于令牌的“记住我”功能,看看它如何影响登录体验的流程。

依赖项

基于令牌的“记住我”部分不需要除基本设置外任何额外的依赖项,基本设置来自第二章Spring Security 入门。然而,如果您正在利用基于持久的“记住我”功能,您可能需要在您的build.gradle文件中包含以下额外的依赖项。我们已经在章节的示例中包含了这些依赖项,因此无需更新示例应用程序:

//build.gradle
dependencies {
...
    // JPA / ORM / Hibernate:
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    // H2 db
    implementation 'com.h2database:h2'
...
}

基于令牌的“记住我”功能

Spring Security 提供了两种不同的“记住我”功能的实现。我们将首先探讨如何设置基于令牌的“记住我”服务。

配置基于令牌的“记住我”功能

完成这个练习将使我们能够提供一个简单且安全的方法,以延长用户登录时间。首先,执行以下步骤:

  1. 修改SecurityConfig.java配置文件并添加rememberMe方法。

    看一下以下代码片段:

    //src/main/java/com/packtpub/springsecurity/configuration/ SecurityConfig.java
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    ...
        // Remember Me
        http.rememberMe(httpSecurityRememberMeConfigurer ->
            httpSecurityRememberMeConfigurer.key("jbcpCalendar"));
    ...
    }
    

重要提示

您应该从chapter07.00-calendar的源代码开始。

  1. 如果我们现在尝试运行应用程序,我们会看到流程中没有不同之处。这是因为我们还需要在登录表单中添加一个字段,允许用户选择此功能。编辑login.xhtml文件并添加一个复选框,如下面的代码片段所示:

    //src/main/resources/templates/login.xhtml
    <div class="mb-3">
        <label class="form-label" for="password">Password</label>
        <input class="form-control" id="password" name="password"
               type="password"/>
    </div>
    <div class="mb-3">
        <label for="remember-me">Remember Me?</label>
        <input type="checkbox" id="remember-me" name="remember-me" th:checked="true" />
    </div>
    <div class="mb-3">
        <input class="btn btn-primary" id="submit" name="submit" type="submit"
               value="Login"/>
    </div>
    

重要提示

您应该从chapter07.01-calendar的源代码开始。

  1. 当我们下次登录时,如果选中了“记住我”复选框,将在用户的浏览器中设置一个“记住我”cookie。

    Spring Security 通过检查remember-me HTTP 参数来理解它应该通过记住用户。

重要提示

在 Spring Security 4.x 及以后版本中,默认的“记住我”表单字段是remember-me。这可以通过rememberMeParameter方法来覆盖。

  1. 如果用户关闭浏览器并重新打开到 JBCP 日历网站的认证页面,他们不会再次被提示登录页面。现在就试试看——选择“记住我”选项登录,将主页添加到书签,然后重新启动浏览器并访问主页。您会看到您立即成功登录,无需再次提供登录凭证。如果这种情况发生在您身上,这意味着您的浏览器或浏览器插件正在恢复会话。

小贴士

尝试先关闭标签页,然后再关闭浏览器。

另一个有效的解决方案是使用 Chrome 开发者工具来删除JSESSIONIDcookie。这通常可以在您网站上开发验证此类功能时节省时间和烦恼。

图 7.1 – 探索“记住我”cookie

图 7.1 – 探索“记住我”cookie

登录并选择“记住我”后,您应该看到已经设置了两个 cookie,JSESSIONIDremember-me,如截图所示。

基于令牌的“记住我”功能是如何工作的

“记住我”功能在用户的浏览器中设置一个包含以下内容的 Base64 编码字符串的 cookie:

  • 用户名

  • 过期日期/时间

  • expiration日期/时间的 SHA-256 哈希值,usernamepassword,以及rememberMe方法的key属性

这些内容合并为一个单一的 cookie 值,存储在浏览器中以供后续使用。cookie 的组成如下:

base64(username + ":" + expirationTime + ":" + algorithmName + ":"
algorithmHex(username + ":" + expirationTime + ":" password + ":" + key))
username:          As identifiable to the UserDetailsService
password:          That matches the one in the retrieved UserDetails
expirationTime:    The date and time when the remember-me token expires, expressed in milliseconds
key:               A private key to prevent modification of the remember-me token
algorithmName:     The algorithm used to generate and to verify the remember-me token

在接下来的章节中,我们将结合 Spring Security 来探讨 SHA-256 算法。

SHA-256 算法

默认情况下,此实现使用 SHA-256 算法对令牌签名进行编码。为了验证令牌签名,从algorithmName检索到的算法将被解析并使用。如果algorithmName不存在,将使用默认匹配算法,即SHA-256。您可以为签名编码和签名匹配指定不同的算法;这允许用户在仍然能够验证旧版本的情况下安全地升级到不同的编码算法。为此,您可以指定您定制的TokenBasedRememberMeServices作为 bean 并在配置中使用它:

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, RememberMeServices rememberMeServices) throws Exception {
    http
          .authorizeHttpRequests((authorize) -> authorize
                .anyRequest().authenticated()
          )
          .rememberMe((remember) -> remember
                .rememberMeServices(rememberMeServices)
          );
    return http.build();
}
@Bean
RememberMeServices rememberMeServices(UserDetailsService userDetailsService) {
    RememberMeTokenAlgorithm encodingAlgorithm = RememberMeTokenAlgorithm.SHA256;
    TokenBasedRememberMeServices rememberMe = new TokenBasedRememberMeServices(myKey, userDetailsService, encodingAlgorithm);
    rememberMe.setMatchingAlgorithm(RememberMeTokenAlgorithm.MD5);
    return rememberMe;
}

为了回顾,我们已经涵盖了 SHA-256 算法,在下一节中,我们将深入探讨记住我签名。

记住我签名

我们可以看到SHA-256如何确保我们下载了正确的文件,但这是如何应用于 Spring Security 的记住我服务呢?与下载的文件类似,cookie 是不可信的,但如果我们能够验证来自我们应用程序的签名,我们就可以信任它。当一个带有记住我 cookie 的请求到来时,其内容将被提取,预期的签名将与 cookie 中找到的签名进行比较。

计算预期签名的步骤在以下图中展示:

图 7.2 – 基于 SHA-256 散列的令牌方法

图 7.2 – 基于 SHA-256 散列的令牌方法

记住我 cookie 包含用户名过期时间签名。Spring Security 将从 cookie 中提取用户名过期时间。然后,它将使用UserDetailsService通过用户名查找密码密钥已经已知,因为它是在使用rememberMe方法时提供的。现在,所有参数都已知晓,Spring Security 可以使用用户名过期时间密码密钥来计算预期的签名。然后,它将比较预期的签名与 cookie 中的签名

如果两个签名匹配,我们可以相信用户名过期时间是有效的。在没有知道记住我密钥(只有应用程序知道)和用户的密码(只有这个用户知道)的情况下伪造签名几乎是不可能的。这意味着如果签名匹配且令牌未过期,用户可以登录。

重要提示

您已经预见到,如果用户更改他们的用户名或密码,任何设置的记住我令牌将不再有效。如果您允许用户更改他们账户的这些信息,请确保您向用户提供适当的消息。在本章的后面部分,我们将探讨一种仅依赖于用户名而不是密码的替代记住我实现。

注意,仍然可以区分使用记住我 cookie 进行身份验证的用户和提交了用户名和密码(或等效)凭据的用户。我们将在稍后当我们调查记住我功能的安全性时进行实验。

基于 token 的记住我配置指令

通常对以下两个配置更改进行更改,以改变记住我功能的默认行为:

属性 描述
key 这定义了在生成记住我 cookie 签名时使用的唯一密钥。
tokenValiditySeconds 这定义了时间长度(以秒为单位)。记住我 cookie 将被视为有效的身份验证。它也用于设置 cookie 过期时间戳。

表 7.3 – 记住我 cookie 的主要配置

从对 cookie 内容哈希处理的讨论中,你可以推断出key属性对于记住我功能的安全性至关重要。请确保你选择的密钥对你的应用程序来说是唯一的,并且足够长,以至于不容易被猜到。

考虑到本书的目的,我们保持了key值的相对简单,但如果你在应用程序中使用记住我功能,建议你的密钥包含你应用程序的唯一名称,并且至少包含 36 个随机字符。密码生成工具(在谷歌上搜索在线密码生成器)是获取用于组成记住我密钥的伪随机字母数字和特殊字符混合的好方法。对于存在于多个环境(如开发、测试和生产)中的应用程序,记住我 cookie 值应包含这一事实。这将防止在测试期间意外地在错误的环境中使用了记住我 cookie!

在生产应用中,一个示例密钥值可能类似于以下内容:

prodJbcpCalendar-rmkey- YWRtaW4xJTQwZXhhbXBsZS5jb206MTY5ODc2MTM 2ODgwNjpTSEEyNTY6YzE5ZjE2YzliN2U2ZjA xZGMyMjdkMWJmN2JlYWQzNGRhYWJiMGFmNDliMDE0ZGY5MTg4YjIzYzM1YjQzZmMzNw

tokenValiditySeconds方法用于设置记住我 token 在自动登录功能中不被接受的时间(以秒为单位),即使它是一个有效的 token。相同的属性也用于设置用户浏览器上记住我 cookie 的最大生命周期。

记住我会话 cookie 的配置

如果tokenValiditySeconds设置为-1,登录 cookie 将被设置为会话 cookie,用户关闭浏览器后不会持久保存。假设用户没有关闭浏览器,token 将有效(非配置长度为两周)。不要将此与存储用户会话 ID 的 cookie 混淆——它们是两个具有相似名称的不同事物!

你可能已经注意到我们列出的属性非常少。不用担心,我们将在本章中花费时间介绍一些其他配置属性。

记住我是安全的吗?

任何为了用户便利性而添加的安全相关功能都有可能使我们的精心保护网站面临安全风险。记住我功能在其默认形式下,存在用户 cookie 被恶意用户拦截和重用的风险。以下图表说明了这种情况可能发生的方式:

图 7.3 – 记住我会话 cookie 重放攻击

图 7.3 – 记住我会话 cookie 重放攻击

使用安全套接字层SSL)(在附录“附加参考资料”中介绍)和其他网络安全技术可以减轻此类攻击,但请注意,还有其他技术,如跨站脚本XSS),可以窃取或破坏记住的用户会话。虽然这对用户来说很方便,但我们不希望因误用记住的会话而导致财务或其他个人信息意外更改或被盗。

重要提示

尽管我们在这本书中没有详细讨论恶意用户行为,但在实施任何安全系统时,了解可能试图黑客攻击你的客户或员工的用户所采用的技术是很重要的。XSS 就是这样一种技术,但还有许多其他技术。强烈建议您查看OWASP Top Ten文章(owasp.org/www-project-top-ten/)以获取一份良好的列表,并挑选一本关于 Web 应用安全性的参考书籍,其中许多展示的技术可以应用于任何技术。

在保持便利性和安全性平衡的常见方法中,是确定网站上可能存在个人或敏感信息的功能位置。然后,你可以使用fullyAuthenticated表达式来确保这些位置通过授权得到保护,这种授权不仅检查用户的角色,而且还检查他们是否使用完整的用户名和密码进行了认证。我们将在下一节中更详细地探讨这个功能。

记住我授权规则

我们将在第十一章“细粒度访问控制”中全面探讨高级授权技术,然而,重要的是要认识到可以根据是否记住认证会话来区分访问规则。

假设我们想要限制尝试访问 H2 管理员 控制台的用户仅限于使用用户名和密码进行认证的管理员。这与在其他主要面向消费者的商业网站上找到的行为类似,这些网站在输入密码之前会限制对网站高级部分的访问。请记住,每个网站都是不同的,所以不要盲目地将此类规则应用到您的安全网站上。对于我们的示例应用程序,我们将专注于保护 H2 数据库控制台。更新 SecurityConfig.java 文件以使用 fullyAuthenticated 关键字,这确保了尝试访问 H2 数据库的已记住用户将被拒绝访问。这在上面的代码片段中显示:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
...
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, PersistentTokenRepository persistentTokenRepository) throws Exception {
    http.authorizeHttpRequests( authz -> authz
                .requestMatchers("/webjars/**").permitAll()
                .requestMatchers("/css/**").permitAll()
                .requestMatchers("/favicon.ico").permitAll()
                // H2 console:
                .requestMatchers("/admin/h2/**")
                .access(new WebExpressionAuthorizationManager("isFullyAuthenticated() and hasRole('ADMIN')"))
...
    // Remember Me
    http.rememberMe(httpSecurityRememberMeConfigurer -> httpSecurityRememberMeConfigurer
        .key("jbcpCalendar").tokenRepository(persistentTokenRepository));
...
}

现有的规则保持不变。我们添加了一条规则,要求请求账户信息必须具有适当的 GrantedAuthorityROLE_ADMIN,并且用户是完全认证的;也就是说,在此认证会话期间,他们已出示用户名和密码或其他合适的凭据。注意 ANDORNOT 的语法在 SpEL 中用作逻辑运算符。这是 SpEL 设计者深思熟虑的结果,因为 && 运算符在 XML 中表示起来会很尴尬,尽管前面的示例使用了基于 Java 的配置!

重要提示

您应该从 chapter07.02-calendar 的源代码开始。

请使用用户名 admin1@example.com 和密码 admin1 登录,确保您选中了记住我功能。访问 H2 数据库控制台,您会发现访问权限已被授予。现在,删除 JSESSIONID Cookie(或关闭标签页然后关闭所有浏览器实例),并确保对 所有事件 页面的访问权限仍然被授予。

现在,导航到 H2 控制台并观察访问已被拒绝。

此方法结合了记住我功能的可用性增强,并通过要求用户出示完整的凭据来访问敏感信息,增加了一个额外的安全层。在本章的其余部分,我们将探讨其他使记住我功能更加安全的方法。

持久化记住我

Spring Security 提供了通过利用 RememberMeServices 接口的不同实现来更改验证记住我 Cookie 方法的功能。在本节中,我们将讨论如何使用数据库持久化记住我令牌,以及这如何提高我们应用程序的安全性。

使用基于持久化的记住我功能

在此阶段修改我们的记住我配置以持久化到数据库是出奇地简单。Spring Security 配置解析器将识别 rememberMe 方法上的新 tokenRepository 方法,并简单地切换 RememberMeServices 的实现类。现在,让我们回顾一下完成此操作所需的步骤。

添加 SQL 创建记住我架构

我们将包含预期模式的 SQL 文件放置在src/main/resources文件夹中,与我们在第三章“自定义身份验证”中放置的位置相同。您可以在以下代码片段中查看模式定义:

//src/main/resources/schema.sql
create table persistent_logins
(
    username  varchar_ignorecase(50) not null,
    series    varchar(64) primary key,
    token     varchar(64) not null,
    last_used timestamp   not null
);

使用记住我模式初始化数据源

Spring Data 将自动使用schema.sql初始化嵌入式数据库,如前所述。然而,请注意,使用用于初始化数据库的data.sql文件时,我们必须确保数据源初始化延迟如下:

//src/main/resources/application.yml
spring:
  jpa:
    database-platform: org.hibernate.dialect.H2Dialect
    show-sql: false
    hibernate:
      ddl-auto: create-drop
    defer-datasource-initialization: true

在审查了基于持久化的记住我功能,特别是使用数据库之后,下一节将介绍使用 JPA 配置此功能。

配置基于持久化的记住我功能

最后,我们需要对rememberMe声明进行一些简短的配置更改,以便将其指向我们正在使用的数据源,如下面的代码片段所示:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityC onfig.java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, PersistentTokenRepository persistentTokenRepository) throws Exception {
    http.authorizeRequests( authz -> authz
    ...
    // Remember Me
    http.rememberMe(httpSecurityRememberMeConfigurer -> httpSecurityRememberMeConfigurer
          .key("jbcpCalendar").tokenRepository(persistentTokenRepository));
    return http.build();
}
@Bean
public PersistentTokenRepository persistentTokenRepository(DataSource dataSource) {
    JdbcTokenRepositoryImpl db = new JdbcTokenRepositoryImpl();
    db.setDataSource(dataSource);
    return db;
}

这就是我们切换到使用基于持久化的记住我身份验证所需做的所有事情。启动应用程序并尝试一下。从用户的角度来看,我们没有注意到任何差异,但我们知道支持此功能的实现已更改。

重要提示

您应该从chapter07.03-calendar的源开始。

基于持久化的记住我功能是如何工作的?

基于持久化的记住我服务不是验证 cookie 中存在的签名,而是验证令牌是否存在于数据库中。每个基于持久化的记住我 cookie 包含以下内容:

  • 系列标识符:这标识了用户的初始登录,每次用户自动登录到原始会话时都保持一致

  • 令牌值:每次用户使用记住我功能进行身份验证时都会更改的唯一值

看一下以下图表:

图 7.4 – 探索基于持久化的记住我功能

图 7.4 – 探索基于持久化的记住我功能

当提交记住我 cookie 时,Spring Security 将使用o.s.s.web.authentication.rememberme.PersistentTokenRepository实现来查找预期的令牌值和过期时间,使用提交的系列标识符。然后,它将比较 cookie 中的令牌值与预期的令牌值。如果令牌未过期且两个令牌匹配,则认为用户已通过身份验证。将生成一个新的记住我 cookie,具有相同的系列标识符、新的令牌值和更新的过期日期。

如果提交的系列令牌在数据库中找到,但令牌不匹配,则可以假设有人偷走了记住我 cookie。在这种情况下,Spring Security 将终止这一系列的记住我令牌,并警告用户他们的登录已被破坏。

持久化的令牌可以在数据库中找到,并使用以下屏幕截图查看:

图 7.5 – 从数据库获取持久化令牌

图 7.5 – 从数据库获取持久化令牌

在本章中了解了基于持久化的 remember-me 功能的工作原理之后,我们将在下一节深入探讨基于 JPA 的 PersistentTokenRepository

基于 JPA 的 PersistentTokenRepository

正如我们在前面的章节中看到的,使用 Spring Data 项目进行我们的数据库映射可以大大简化我们的工作。因此,为了保持一致性,我们将重构我们的 PersistentTokenRepository 接口,该接口使用 JdbcTokenRepositoryImpl,改为基于 JPA 的。我们将通过以下步骤来完成:

  1. 首先,让我们创建一个域对象来保存持久化登录信息,如下代码片段所示:

    //src/main/java/com/packtpub/springsecurity/domain/ PersistentLogin.java
    @Entity
    @Table(name = "persistent_logins")
    public class PersistentLogin implements Serializable {
        @Id
        private String series;
        private String username;
        private String token;
        private Date lastUsed;
        public PersistentLogin(){}
        public PersistentLogin(PersistentRememberMeToken token){
            this.series = token.getSeries();
            this.username = token.getUsername();
            this.token = token.getTokenValue();
            this.lastUsed = token.getDate();
        }
    // getters/setters omitted for brevity
    }
    
  2. 接下来,我们需要创建一个 o.s.d.jpa.repository.JpaRepository 存储库实例,如下代码片段所示:

    //src/main/java/com/packtpub/springsecurity/repository/ RememberMeTokenRepository.java
    import java.util.Date;
    import java.util.List;
    import com.packtpub.springsecurity.domain.PersistentLogin;
    import org.springframework.data.jpa.repository.JpaRepository;
    public interface RememberMeTokenRepository extends JpaRepository<PersistentLogin, String> {
        PersistentLogin findBySeries(String series);
        List<PersistentLogin> findByUsername(String username);
        Iterable<PersistentLogin> findByLastUsedAfter(Date expiration);
    }
    
  3. 现在,我们需要创建一个自定义的 PersistentTokenRepository 接口来替换 Jdbc 实现。我们必须重写四个方法,但代码看起来相当熟悉,因为我们将对所有操作使用 JPA:

    //src/main/java/com/packtpub/springsecurity/web/authentication/rememberme/JpaPersistentTokenRepository.java:
    public class JpaPersistentTokenRepository implements PersistentTokenRepository {
        private final RememberMeTokenRepository rememberMeTokenRepository;
        public JpaPersistentTokenRepository(RememberMeTokenRepository rememberMeTokenRepository) {
            this.rememberMeTokenRepository = rememberMeTokenRepository;
        }
        @Override
        public void createNewToken(PersistentRememberMeToken token) {
            PersistentLogin newToken = new PersistentLogin(token);
            this.rememberMeTokenRepository.save(newToken);
        }
        @Override
        public void updateToken(String series, String tokenValue, Date lastUsed) {
            PersistentLogin token = this.rememberMeTokenRepository.findBySeries(series);
            if (token != null) {
                token.setToken(tokenValue);
                token.setLastUsed(lastUsed);
                this.rememberMeTokenRepository.save(token);
            }
        }
        @Override
        public PersistentRememberMeToken getTokenForSeries(String seriesId) {
            PersistentLogin token = this.rememberMeTokenRepository.findBySeries(seriesId);
            if(token == null){
                return null;
            } else {
                return new PersistentRememberMeToken(token.getUsername(),
                        token.getSeries(),
                        token.getToken(),
                        token.getLastUsed());
            }
        }
        @Override
        public void removeUserTokens(String username) {
            List<PersistentLogin> tokens = this.rememberMeTokenRepository.findByUsername(username);
            this.rememberMeTokenRepository.deleteAll(tokens);
        }
    }
    
  4. 现在,我们需要在 SecurityConfig.java 文件中做一些修改,以声明新的 PersistentTokenTokenRepository 接口,但上一节中的其余配置没有变化,如下代码片段所示:

    /src/main/java/com/packtpub/springsecurity/configuration/ SecurityConfig.java
    @Bean
    public PersistentTokenRepository persistentTokenRepository(
           RememberMeTokenRepository rmtr) {
        return new JpaPersistentTokenRepository(rmtr);
    }
    
  5. 这就是我们切换 JDBC 到 JPA 持久化 remember-me 认证的所需全部操作。启动应用程序并尝试一下。从用户的角度来看,我们没有注意到任何差异,但我们知道支持此功能的实现已经更改。

重要提示

您应该从 chapter07.04-calendar 的源代码开始。

自定义 RememberMeServices

到目前为止,我们已经使用了一个相当简单的 PersistentTokenRepository 实现。我们使用了基于 JDBC 和 JPA 的实现。这提供了对 cookie 持久性的有限控制;如果我们想要更多的控制,我们将我们的 PersistentTokenRepository 接口包装在 RememberMeServices 中。Spring Security 有一个稍微修改过的版本,如前所述,称为 PersistentTokenBasedRememberMeServices,我们可以将其包装在我们的自定义 PersistentTokenRepository 接口中,并在我们的 remember-me 服务中使用。

在以下部分,我们将使用 PersistentTokenBasedRememberMeServices 包装我们的现有 PersistentTokenRepository 接口,并使用 rememberMeServices 方法将其连接到我们的 remember-me 声明中:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http,
       PersistentTokenRepository persistentTokenRepository, RememberMeServices rememberMeServices) throws Exception {
    http.authorizeHttpRequests( authz -> authz
                .requestMatchers("/webjars/**").permitAll()
…
    // Remember Me
    http.rememberMe(httpSecurityRememberMeConfigurer -> httpSecurityRememberMeConfigurer
          .key("jbcpCalendar")
          .rememberMeServices(rememberMeServices)
          .tokenRepository(persistentTokenRepository));
    return http.build();
}
@Bean
public RememberMeServices rememberMeServices (PersistentTokenRepository ptr, UserDetailsService  userDetailsService){
    PersistentTokenBasedRememberMeServices rememberMeServices = new
          PersistentTokenBasedRememberMeServices("jbcpCalendar",
          userDetailsService, ptr);
    rememberMeServices.setAlwaysRemember(true);
    return rememberMeServices;
}

重要提示

您应该从 chapter07.05-calendar 的源代码开始。

数据库支持的持久化令牌是否更安全?

就像TokenBasedRememberMeServices一样,持久令牌可能会因为 cookie 被盗或中间人技术而被篡改。正如在附录中所述,使用 SSL(安全套接字层)可以绕过中间人技术。如果您使用的是HttpOnly,这有助于减轻应用程序中 XSS 漏洞导致 cookie 被盗的风险。要了解更多关于HttpOnly属性的信息,请参阅本章前面提供的关于 cookie 的外部资源。

使用基于持久化的记住我功能的优点之一是我们能够检测 cookie 是否被篡改。如果提供了正确的系列令牌和错误的令牌,我们知道使用该系列令牌的任何记住我功能都应该被视为被篡改,并且我们应该终止与其相关的任何会话。由于验证是状态性的,我们也可以在不更改用户密码的情况下终止特定的记住我功能。

清理过期的记住我会话

使用基于持久化的记住我功能的缺点是,没有内置的支持来清理过期的会话。为此,我们需要实现一个后台进程来清理过期的会话。我们在本章的示例代码中包含了执行清理的代码。

为了简洁,我们在下面的代码片段中显示了一个不进行验证或错误处理的版本。您可以在本章的示例代码中查看完整版本:

//src/main/java/com/packtpub/springsecurity/web/authentication/rememberme/ JpaTokenRepositoryCleaner.java
public class JpaTokenRepositoryCleaner implements Runnable {
    private Logger logger = LoggerFactory.getLogger(getClass());
    private final RememberMeTokenRepository rememberMeTokenRepository;
    private final long tokenValidityInMs;
    public JpaTokenRepositoryCleaner(RememberMeTokenRepository rememberMeTokenRepository,
                                     long tokenValidityInMs) {
        if (rememberMeTokenRepository == null) {
            throw new IllegalArgumentException("jdbcOperations cannot be null");
        }
        if (tokenValidityInMs < 1) {
            throw new IllegalArgumentException("tokenValidityInMs must be greater than 0\. Got " + tokenValidityInMs);
        }
        this.rememberMeTokenRepository = rememberMeTokenRepository;
        this.tokenValidityInMs = tokenValidityInMs;
    }
    public void run() {
        long expiredInMs = System.currentTimeMillis() - tokenValidityInMs;
        logger.info("Searching for persistent logins older than {}ms", tokenValidityInMs);
        try {
            Iterable<PersistentLogin> expired = rememberMeTokenRepository.findByLastUsedAfter(new Date(expiredInMs));
            for(PersistentLogin pl: expired){
                logger.info("*** Removing persistent login for {} ***", pl.getUsername());
                rememberMeTokenRepository.delete(pl);
            }
        } catch(Throwable t) {
            logger.error("**** Could not clean up expired persistent remember me tokens. ***", t);
        }
    }
}

本章的示例代码还包括一个简单的 Spring 配置,该配置将每十分钟执行一次清理器。如果您不熟悉 Spring 的任务抽象并且想了解更多,那么您可能想阅读更多关于它的内容,请参阅docs.spring.io/spring-framework/reference/integration/scheduling.xhtml。您可以在以下代码片段中找到相关配置。为了清晰起见,我们将此调度器放在JavaConfig.java文件中:

//src/main/java/com/packtpub/springsecurity/configuration/ JavaConfig.java@Configuration
@Configuration
@EnableScheduling
public class JavaConfig {
    private RememberMeTokenRepository rememberMeTokenRepository;
    public JavaConfig(RememberMeTokenRepository rememberMeTokenRepository) {
       this.rememberMeTokenRepository = rememberMeTokenRepository;
    }
    @Scheduled(fixedRate = 600_000)
    public void tokenRepositoryCleaner(){
       Thread trct = new Thread(
             new JpaTokenRepositoryCleaner(
                   rememberMeTokenRepository,
                   100_000L));
       trct.start();
    }
}

重要提示

请记住,此配置不是集群感知的。因此,如果将其部署到集群中,清理器将为应用程序部署到的每个Java 虚拟机(JVM)执行一次。

启动应用程序并尝试更新。提供的配置将确保清理器每十分钟执行一次。您可能希望将清理任务改为更频繁地运行,并通过修改@Scheduled声明来清理最近使用的记住我令牌。然后您可以创建一些记住我令牌,并查看它们在 H2 数据库控制台中查询时被删除。

重要提示

您应该从chapter07.06-calendar的源文件开始。

记住我架构

我们已经讨论了TokenBasedRememberMeServicesPersistentTokenBasedRememberMeServices的基本架构,但我们还没有描述整体架构。让我们看看所有记住我组件是如何组合在一起的。

以下图表说明了验证基于令牌的“记住我”令牌过程中涉及的不同组件:

图 7.6 – 记住我架构

图 7.6 – 记住我架构

与 Spring Security 的任何过滤器一样,RememberMeAuthenticationFilter是在FilterChainProxy内部调用的。RememberMeAuthenticationFilter的工作是检查请求,如果感兴趣,则采取行动。RememberMeAuthenticationFilter接口将使用RememberMeServices实现来确定用户是否已经登录。RememberMeServices接口通过检查 HTTP 请求中的记住我 cookie 来完成此操作,然后使用我们之前讨论的基于令牌的验证或基于持久性的验证来验证该 cookie。如果令牌检查无误,用户将被登录。

记住我和用户生命周期

RememberMeServices的实现会在用户生命周期(已认证用户会话的生命周期)的几个点上被调用。为了帮助您理解记住我功能,了解记住我服务通知生命周期函数的时间点可能很有帮助:

操作 应该发生什么? RememberMeServices 方法调用
登录成功 如果已发送form参数,实现应设置记住我 cookie loginSuccess
登录失败 如果存在,实现应该取消 cookie loginFailed
用户注销 如果存在,实现应该取消 cookie Logout

表 7.8 – 记住我生命周期事件

重要提示

logout方法不在RememberMeServices接口上。相反,每个RememberMeServices实现也实现了LogoutHandler接口,该接口包含logout方法。通过实现LogoutHandler接口,每个RememberMeServices实现可以在用户注销时执行必要的清理工作。

了解RememberMeServices如何与用户生命周期结合将非常重要,当我们开始创建自定义身份验证处理器时,因为我们需要确保任何身份验证处理器都一致地处理RememberMeServices,以保持此功能的有用性和安全性。

将记住我功能限制为 IP 地址

让我们将我们对记住我架构的理解付诸实践。一个常见的要求是任何记住我令牌都应该与创建它的用户的 IP 地址相关联。这为记住我功能增加了额外的安全性。为此,我们只需要实现一个自定义的PersistentTokenRepository接口。我们将做出的配置更改将说明如何配置自定义的RememberMeServices。在本节中,我们将查看包含在本章源代码中的IpAwarePersistentTokenRepositoryIpAwarePersistenTokenRepository接口确保序列标识符在内部与当前用户的 IP 地址结合,而序列标识符在外部只包含标识符。这意味着每当查找或保存令牌时,都会使用当前 IP 地址来查找或持久化令牌。在下面的代码片段中,你可以看到IpAwarePersistentTokenRepository是如何工作的。如果你想要更深入地了解,我们鼓励你查看章节中包含的源代码。

查找 IP 地址的技巧是使用 Spring Security 的RequestContextHolder。相关代码如下:

重要提示

应该注意的是,要使用RequestContextHolder,你需要确保你已经设置了你的web.xml文件以使用RequestContextListener。我们已经为我们的示例代码完成了这个设置。然而,当在外部应用程序中利用示例代码时,这可能很有用。有关如何设置此内容的详细信息,请参阅IpAwarePersistentTokenRepository的 Javadoc。

看一下下面的代码片段:

//src/main/java/com/packtpub/springsecurity/web/authentication/rememberme/ IpAwarePersistentTokenRepository.java
private String ipSeries(String series) {
    ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
    if (attributes == null) {
        throw new IllegalStateException("RequestContextHolder.getRequestAttributes() cannot be null");
    }
    String remoteAddr = attributes.getRequest().getRemoteAddr();
    logger.debug("Remote address is {}", remoteAddr);
    return series + remoteAddr;
}

我们可以在此基础上构建方法,强制保存的令牌包含 IP 地址在序列标识符中,如下所示:

@Override
public void createNewToken(PersistentRememberMeToken token) {
    String ipSeries = ipSeries(token.getSeries());
    PersistentRememberMeToken ipToken = tokenWithSeries(token, ipSeries);
    this.delegateRepository.createNewToken(ipToken);
}

你可以看到我们首先创建了一个新的序列,其中包含了附加的 IP 地址。

tokenWithSeries方法只是一个辅助方法,它创建了一个具有所有相同值的新令牌,除了一个新的序列。然后我们将带有序列标识符的新令牌提交给delegateRepository,这是PersistentTokenRepository的原始实现。

每当查找令牌时,我们要求将当前用户的 IP 地址附加到序列标识符中。这意味着用户无法获取具有不同 IP 地址的用户的令牌:

@Override
public PersistentRememberMeToken getTokenForSeries(String seriesId) {
    String ipSeries = ipSeries(seriesId);
    PersistentRememberMeToken ipToken = delegateRepository.getTokenForSeries(ipSeries);
    return tokenWithSeries(ipToken, seriesId);
}

代码的其余部分相当相似。内部,我们构建序列标识符以附加到 IP 地址,外部,我们只展示原始序列标识符。通过这种方式,我们强制执行约束,只有创建记住我令牌的用户才能使用它。

让我们回顾一下本章示例代码中包含的 Spring 配置,用于IpAwarePersistent 令牌存储库。在以下代码片段中,我们首先创建了一个IpAwarePersistent 令牌存储库声明,该声明包装了一个新的JpaPersistentTokenRepository声明。然后我们通过实例化一个OrderedRequestContextFilter接口来初始化一个RequestContextFilter类:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Bean
public RememberMeServices rememberMeServices(PersistentTokenRepository ptr, UserDetailsService userDetailsService) {
    PersistentTokenBasedRememberMeServices rememberMeServices = new
          PersistentTokenBasedRememberMeServices("jbcpCalendar",
          userDetailsService, ptr);
    rememberMeServices.setAlwaysRemember(true);
    return rememberMeServices;
}
@Bean
public IpAwarePersistentTokenRepository tokenRepository(RememberMeTokenRepository rmtr) {
    return new IpAwarePersistentTokenRepository(new JpaPersistentTokenRepository(rmtr));
}
@Bean
public OrderedRequestContextFilter requestContextFilter() {
    return new OrderedRequestContextFilter();
}

现在,请启动应用程序。您可以使用第二台计算机以及一个插件,如 Firebug,来操纵您的“记住我”cookie。如果您尝试在另一台计算机上使用来自一台计算机的“记住我”cookie,Spring Security 现在将忽略“记住我”请求并删除相关的 cookie。

重要提示

您应该从chapter07.07-calendar的源代码开始。

注意,如果用户位于共享或负载均衡的网络基础设施之后,如多广域网(WAN)企业环境,基于 IP 的“记住我”令牌可能会出现意外的行为。然而,在大多数情况下,向“记住我”功能添加 IP 地址为有用的用户功能提供了额外的、受欢迎的安全层。

自定义 cookie 和 HTTP 参数名称

好奇的用户可能会想知道,是否可以将“记住我”表单字段复选框的预期值或 cookie 名称更改为“记住我”,以隐藏 Spring Security 的使用。这种更改可以在两个位置之一进行。请查看以下步骤:

  1. 我们可以在RememberMeServices bean中简单地定义更多属性来更改复选框和 cookie 的名称,如下所示:

    //src/main/java/com/packtpub/springsecurity/configuration/ SecurityConfig.java
    @Bean
    public RememberMeServices rememberMeServices(PersistentTokenRepository ptr, UserDetailsService userDetailsService) {
        PersistentTokenBasedRememberMeServices rememberMeServices = new
              PersistentTokenBasedRememberMeServices("jbcpCalendar",
              userDetailsService, ptr);
        rememberMeServices.setAlwaysRemember(true);
        rememberMeServices.setParameter("obscure-remember-me");
        rememberMeServices.setCookieName("obscure-remember-me");
        return rememberMeServices;
    }
    
  2. 不要忘记将login.xhtml页面更改为设置checkbox 表单字段的名称,并匹配我们声明的参数值。请继续更新login.xhtml,如下所示:

    //src/main/resources/templates/login.xhtml
    <div class="mb-3">
        <label for="remember-me">Remember Me?</label>
        <input type="checkbox" id="remember-me" name="obscure-remember-me" th:checked="true" />
    </div>
    
  3. 我们鼓励您在这里进行实验,以确保您理解这些设置之间的关系。请继续启动应用程序并尝试一下。

重要提示

您应该从chapter07.08-calendar的源代码开始。

摘要

本章解释并演示了在 Spring Security 中使用“记住我”功能。我们从最基本的设置开始,学习了如何逐步使该功能更安全。具体来说,我们学习了基于令牌的“记住我”服务及其配置方法。我们还探讨了基于持久化的“记住我”服务如何提供额外的安全性,它们的工作原理以及在使用它们时必要的额外考虑。

我们还介绍了创建一个自定义的“记住我”实现,该实现将“记住我”令牌限制在特定的 IP 地址上。我们看到了使“记住我”功能更安全的各种其他方法。

接下来是基于证书的认证,我们将讨论如何使用受信任的客户端证书进行认证。

第八章:使用 TLS 的客户端证书认证

尽管用户名和密码认证非常常见,正如我们在第一章不安全应用程序的解剖第二章Spring Security 入门中讨论的那样,存在其他认证形式,允许用户展示不同类型的凭证。Spring Security 也满足这些需求。在本章中,我们将超越基于表单的认证,探索使用可信客户端证书进行认证。

在本章的讨论过程中,我们将涵盖以下主题:

  • 学习客户端证书认证如何在用户的浏览器和符合规定的服务器之间协商

  • 配置 Spring Security 以使用客户端证书进行用户认证

  • 理解 Spring Security 中客户端证书认证的架构

  • 探索与客户端证书认证相关的高级配置选项

  • 审查处理客户端证书认证时的优缺点和常见故障排除步骤

本章的代码示例链接在此:packt.link/XgAQ7

客户端证书认证是如何工作的?

客户端证书认证需要服务器请求信息以及浏览器响应来协商客户端(即用户的浏览器)与服务器应用之间的可信认证关系。这种可信关系是通过使用交换的可信和可验证的凭证,即所谓的证书来建立的。

与我们迄今为止看到的大部分内容不同,在客户端证书认证中,Servlet 容器或应用程序服务器本身通常负责通过请求证书、评估它并接受其为有效来协商浏览器与服务器之间的信任关系。

客户端证书认证也称为相互认证,是安全套接字层SSL)协议及其继任者传输层安全性TLS)的一部分。由于相互认证是 SSL 和 TLS 协议的一部分,因此使用客户端证书认证需要 HTTPS 连接(由 SSL 或 TLS 加密)来使用。有关 Spring Security 中 SSL/TLS 支持的更多详细信息,请参阅附录中的生成服务器证书部分,附加参考资料。设置 SSL/TLS 是实施客户端证书认证所必需的。

下面的序列图展示了客户端浏览器与 Web 服务器在协商 SSL 连接并验证用于相互认证的客户端证书的可信度时的交互:

图 8.1 – 客户端证书认证

图 8.1 – 客户端证书认证

我们可以看到,交换两个证书,即服务器证书和客户端证书,提供了双方都已知并且可以信任继续安全对话的认证。为了清晰起见,我们省略了一些 SSL 握手的细节,并信任证书本身的检查;然而,我们鼓励您进一步阅读 SSL 和 TLS 协议以及证书的一般知识,因为关于这些主题存在许多优秀的参考指南。RFC 8446传输层安全性(TLS)协议版本 1.3 (datatracker.ietf.org/doc/html/rfc8446)),是开始阅读客户端证书展示的好地方,如果您想深入了解,SL 和 TLS:设计并构建安全系统,埃里克·雷斯科拉,Addison-Wesley (www.amazon.com/SSL-TLS-Designing-Building-Systems/dp/0201615983)对协议及其实现有非常详细的回顾。

基于客户端证书认证的另一种名称是X.509 认证。术语 X.509 来源于 X.509 标准,最初由国际电信联盟电信ITU-T)组织发布,用于基于 X.500 标准(如您可能从第六章LDAP 目录服务)的目录。后来,这个标准被修改用于确保互联网通信的安全。

我们在这里提到这一点,因为 Spring Security 中与这个主题相关的许多类都引用了 X.509。请记住,X.509 本身并不定义相互认证协议,而是定义了证书的格式和结构以及包含的受信任证书颁发机构。

设置客户端证书认证基础设施

对于你作为一个个人开发者来说,能够实验客户端证书认证需要在相对容易的与 Spring Security 集成之前进行一些非平凡的配置和设置。由于这些设置步骤往往会给初学者带来很多问题,我们认为向您介绍这些步骤非常重要。

我们假设你正在使用本地自签名的服务器证书、自签名客户端证书和 Apache Tomcat。这在大多数开发环境中很典型;然而,你可能有权访问有效的服务器证书、证书颁发机构CA)或另一个应用程序服务器。如果是这种情况,你可以将这些设置说明作为指南,并以类似的方式配置你的环境。请参考附录中的 SSL 设置说明,附加参考资料,以获取配置 Tomcat 和 Spring Security 以在独立环境中使用 SSL 的帮助。

理解公钥基础设施的目的

本章主要关注为学习和教育目的设置一个自包含的开发环境。然而,在大多数情况下,当你将 Spring Security 集成到现有的客户端证书安全环境中时,将存在大量的基础设施(通常是硬件和软件的组合),以提供诸如证书授予和管理、用户自助服务以及撤销等功能。这类环境定义了一个公钥基础设施——硬件、软件和安全策略的组合,从而形成一个高度安全的基于认证的网络生态系统。

除了用于 Web 应用程序认证外,这些环境中的证书或硬件设备还可以用于安全的、不可否认的电子邮件(使用S/MIME)、网络认证,甚至物理建筑访问(使用基于PKCS 11的硬件设备)。

尽管这种环境的运维成本可能很高(并且需要 IT 和流程的卓越表现才能有效实施),但可以说,这是技术专业人士可能拥有的最安全的操作环境之一。

创建客户端证书密钥对

自签名客户端证书的创建方式与自签名服务器证书的创建方式相同——通过使用keytool命令生成密钥对。客户端证书密钥对的不同之处在于它需要密钥库对 Web 浏览器可用,并且需要将客户端的公钥加载到服务器的信任库中(我们将在稍后解释这是什么)。

如果你现在不想生成自己的密钥,你可以跳到下一节,并使用示例章节中./src/main/resources/keys文件夹中的示例证书。否则,请按照以下步骤创建客户端密钥对:

keytool -genkeypair -alias jbcpclient -keyalg RSA -validity 365 -keystore jbcp_clientauth.p12 -storetype PKCS12

重要提示

你可以在 Oracle 网站上找到有关keytool的更多信息,以及所有配置选项,请访问以下链接:docs.oracle.com/en/java/javase/17/docs/specs/man/keytool.xhtml

对于此用例,keytool 的大多数参数都是相当任意的。然而,当提示设置姓名和姓氏时(admin1@example.com 是一个合适的值,因为我们已经在 Spring Security 中设置了 admin1@example.com 用户。命令行交互的示例如下:

What is your first and last name? [Unknown]: admin1@example.com
... etc
Is CN=admin1@example.com, OU=JBCP Calendar, O=JBCP, L=Park City, ST=UT, C=US correct?
[no]: yes

当我们配置 Spring Security 从证书认证用户获取信息时,我们将看到这一点的重要性。在我们可以设置 Tomcat 内部的证书认证之前,我们还有最后一个步骤,这将在下一节中解释。

配置 Tomcat 信任存储库

回想一下,密钥对的定义包括私钥和公钥。类似于 SSL 证书验证和保障服务器通信,客户端证书的有效性需要由创建它的认证机构进行验证。

由于我们已经使用 keytool 命令创建了自己的自签名客户端证书,Java 虚拟机(JVM)不会隐式地信任它,因为它是由受信任的证书颁发机构分配的。

让我们看看以下步骤:

  1. 我们需要强制 Tomcat 识别证书为受信任证书。我们通过从密钥对中导出公钥并将其添加到 Tomcat 信任存储库来实现这一点。

    再次提醒,如果您现在不想执行此步骤,您可以使用 .src/main/resources/keys 中的现有信任存储库,并跳转到本节后面配置 server.xml 的部分。

  2. 我们将导出公钥到一个名为 jbcp_clientauth.cer 的标准证书文件,如下所示:

    keytool -exportcert -alias jbcpclient -keystore jbcp_clientauth.p12 -storetype PKCS12 -storepass changeit -file jbcp_clientauth.cer
    
  3. 接下来,我们将证书导入信任存储库(这将创建信任存储库,但在典型的部署场景中,您可能已经在信任存储库中拥有其他证书):

    tomcat.truststore and prompt you for a password (we chose changeit as the password). You’ll also see some information about the certificate and will finally be asked to confirm that you do trust the certificate, as follows:
    
    

    持有人:CN=admin1@example.com, OU=JBCP Calendar, O=JBCP, L=Park City, ST=UT, C=US

    发行者:CN=admin1@example.com, OU=JBCP Calendar, O=JBCP, L=Park City, ST=UT, C=US

    序列号:464fc10c

    有效期从:2017 年 6 月 23 日星期五 11:10:19 MDT 至:2018 年 2 月 12 日星期四 10:10:19

    MST 2043

    //证书指纹:

    MD5: 8D:27:CE:F7:8B:C3:BD:BD:64:D6:F5:24:D8:A1:8B:50

    SHA1: C1:51:4A:47:EC:9D:01:5A:28:BB:59:F5:FC:10:87:EA:68:24:E3:1F

    SHA256: 2C:F6:2F:29:ED:09:48:FD:FE:A5:83:67:E0:A0:B9:DA:C5:3B: FD:CF:4F:95:50:3A:

    2C:B8:2B:BD:81:48:BB:EF

    签名算法名称:SHA256withRSA 版本:3

    //扩展

    1: ObjectId: 2.5.29.14 Criticality=false SubjectKeyIdentifier [

    KeyIdentifier [

    0000: 29 F3 A7 A1 8F D2 87 4B

    EA 74 AC 8A 4B BC 4B 5D

    )

    K.t..K.K]

    0010: 7C 9B 44 4A

    ..DJ

    ]

    ]

    tomcat.truststore 文件,因为我们将在我们的 Tomcat 配置中引用它。

    
    

密钥存储库和信任存储库之间的区别是什么?

连接器的 keystoreFiletruststoreFile 属性)。文件本身的格式可以完全相同。实际上,每个文件可以是任何 JSSE 支持的密钥存储格式,包括 PKCS 12 等。

  1. 如前所述,我们假设您已经按照 附录 中的说明配置了 SSL 连接器,附加参考资料。如果您在 server.xml 中看不到 keystoreFilekeystorePass 属性,这意味着您应该访问 附录 中的 附加参考资料,以获取 SSL 设置。

  2. 最后,我们需要将 Tomcat 指向信任库并启用客户端证书认证。这通过在 Tomcat 的 server.xml 文件中的 SSL 连接器添加三个额外的属性来完成,如下所示:

    //sever.xml
    <Connector port="8443" protocol="HTTP/1.1" SSLEnabled="true" maxThreads="150"
              scheme="https" secure="true" sslProtocol="TLS" keystoreFile="<KEYSTORE_PATH>/tomcat.keystore"
              keystorePass="changeit" truststoreFile="<CERT_PATH>/tomcat.truststore"
              truststorePass="changeit"  clientAuth="true" />
    
  3. 这应该是触发 Tomcat 在建立 SSL 连接时请求客户端证书所需的剩余配置。当然,您需要确保将 <CERT_PATH><KEYSTORE_PATH> 替换为完整路径。例如,在基于 Unix 的 /home/packt/chapter8/keys/tomcat.keystore

  4. 尝试启动 Tomcat,以确保服务器在日志中没有错误地启动。

重要提示

此外,还有一种配置 Tomcat 以可选方式使用客户端证书认证的方法——我们将在本章后面启用它。目前,我们要求使用客户端证书才能首先连接到 Tomcat 服务器。这使得诊断您是否正确设置了此功能变得更加容易!

在 Spring Boot 中配置 Tomcat

我们还可以配置 Spring Boot 内嵌的 Tomcat 实例,这是我们将在本章的其余部分使用 Tomcat 的方式。

将 Spring Boot 配置为使用我们新创建的证书与配置 YAML 条目的属性一样简单,如下面的代码片段所示:

## Chapter 8 TLS over HTTP/1.1:
## https://localhost:8443
server:
  port: 8443
  ssl:
    key-store: classpath:keys/jbcp_clientauth.p12
    key-store-password: changeit
    keyStoreType: PKCS12
    keyAlias: jbcpclient
    protocol: TLS
    client-auth: need
    trust-store: classpath:keys/tomcat.truststore
    trust-store-password: changeit

最后一步是将证书导入客户端浏览器。

将证书密钥对导入浏览器

根据您使用的浏览器,导入证书的过程可能不同。我们将在此处提供 Firefox、Chrome 和 Internet Explorer 的安装说明,但如果您使用其他浏览器,请咨询其帮助部分或您喜欢的搜索引擎以获取帮助。

使用 Mozilla Firefox

执行以下步骤以在 Firefox 中导入包含客户端证书密钥对的密钥库:

  1. 点击 编辑 | 首选项

  2. 点击 高级 按钮。

  3. 点击 加密 选项卡。

  4. 点击 查看证书 按钮。应该打开 证书管理器 窗口。

  5. 点击 您的 证书 选项卡。

  6. 点击 导入... 按钮。

  7. 浏览到您保存 jbcp_clientauth.p12 文件的位置并选择它。您需要输入创建文件时使用的密码(即 changeit)。

客户端证书应该被导入,并且您应该能在列表中看到它。

使用 Google Chrome

执行以下步骤以在 Chrome 中导入包含客户端证书密钥对的密钥库:

  1. 点击浏览器工具栏上的扳手图标。

  2. 选择设置

  3. 点击显示****高级设置...

  4. HTTPS/SSL部分,点击管理****证书...按钮。

  5. 个人选项卡中点击导入...按钮。

  6. 浏览到保存jbcp_clientauth.p12文件的位置并选择它(确保使用证书的.p12 扩展名)。

  7. 你需要输入你创建文件时使用的密码(即changeit)。

  8. 点击确定

使用 Microsoft Edge

让我们看看使用 Windows OS 的 Microsoft Edge 的步骤:

  1. 在 Windows 资源管理器中双击jbcp_clientauth.p12文件。应该会打开证书导入向导窗口(确保使用证书的.p12 扩展名)。

  2. 点击下一步并接受默认值,直到提示输入证书密码。

  3. 输入证书密码(即changeit)并点击下一步

  4. 接受默认的自动选择证书存储选项并点击下一步

  5. 点击完成

为了验证证书是否正确安装,你需要执行另一系列步骤:

  1. 在 Microsoft Edge 中打开设置菜单。

  2. 选择隐私、搜索服务

  3. 滚动到安全

  4. 点击管理证书

  5. 如果个人选项卡尚未选中,请点击个人选项卡。你应该在这里看到证书列表。

测试总结

现在,你应该能够连接到https://localhost:8443/,注意使用HTTPS8443。如果一切设置正确,当你尝试访问网站时,应该会提示输入证书——在 Chrome 中,证书显示如下:

图 8.2 – Chrome 中的客户端证书详情

图 8.2 – Chrome 中的客户端证书详情

然而,你会发现,如果你尝试访问受保护的网站部分,例如我的事件部分,你会被重定向到登录页面。这是因为我们还没有配置 Spring Security 来识别证书中的信息——在这个阶段,客户端和服务器之间的协商已经停止在 Tomcat 服务器本身。

重要提示

你应该从chapter08.00-calendar中的代码开始。

客户端证书认证故障排除

不幸的是,如果我们说第一次正确配置客户端证书认证——没有任何问题发生——很容易,那我们就是在对你撒谎。事实是,尽管这是一个伟大且非常强大的安全装置,但浏览器和 Web 服务器制造商的文档都做得不好,而且当错误信息出现时,它们最好时令人困惑,最坏时具有误导性。

记住,到目前为止,我们完全没有在等式中涉及 Spring Security,所以调试器可能不会帮助你(除非你手头有 Tomcat 源代码)。有一些常见的错误和需要检查的事项。

当您访问网站时不会被提示证书。这可能有多种可能的原因,这可能是最难以解决的问题之一。以下是一些需要检查的事项:

  1. 确保证书已安装到您正在使用的浏览器客户端中。有时,如果您之前尝试访问该网站并被拒绝,您可能需要重新启动整个浏览器(关闭所有窗口)。

  2. 确保您正在访问服务器的 SSL 端口(在开发设置中通常是 8443),并在您的 URL 中选择了 HTTPS 协议。客户端证书不会在不安全的浏览器连接中显示。请确保浏览器也信任服务器的 SSL 证书,即使您必须强制它信任自签名证书。

  3. 确保您已将 clientAuth 指令添加到您的 Tomcat 配置中(或您所使用的任何应用服务器的等效配置)。

  4. 如果所有其他方法都失败了,请使用网络分析器或数据包嗅探器,如 Wireshark (www.wireshark.org/) 或 Fiddler2 (www.fiddler2.com/)),来审查网络上的流量和 SSL 密钥交换(首先与您的 IT 部门确认——许多公司不允许在其网络上使用此类工具)。

  5. 如果您正在使用自签名客户端证书,请确保公钥已导入到服务器的信任存储中。如果您正在使用由 CA 分配的证书,请确保 CA 被信任的 Java 虚拟机JVM)或 CA 证书已导入到服务器的信任存储中。

  6. 尤其是 Internet Explorer,它根本不会报告客户端证书失败的具体细节(它只是报告一个通用的页面无法显示错误)。使用 Firefox 来诊断您遇到的问题是否与客户端证书有关。

  7. 以下 JVM 选项将启用 SSL 握手级别的日志记录:-Djavax.net.debug=ssl:handshake。这个调试标志可能会产生大量输出,但在诊断底层 SSL 连接问题时非常有帮助。

在 Spring Security 中配置客户端证书身份验证

与我们迄今为止所使用的身份验证机制不同,使用客户端证书身份验证会导致用户的请求在服务器端预先进行身份验证。由于服务器(Tomcat)已经确认用户提供了有效且可信的证书,Spring Security 可以简单地信任这个有效性的声明。

安全登录过程中仍缺少一个重要组件,那就是认证用户的授权。这正是我们配置 Spring Security 的地方——我们必须向 Spring Security 添加一个组件,该组件将识别用户 HTTP 会话(由 Tomcat 填充)中的证书认证信息,然后根据 Spring Security 的 UserDetailsService 调用来验证提供的凭据。UserDetailsService 的调用将导致确定证书中声明的用户是否为 Spring Security 所知,然后根据常规登录规则分配 GrantedAuthority

使用安全命名空间配置客户端证书认证

在 LDAP 配置的复杂性中,配置客户端证书认证是一个受欢迎的缓解。如果我们使用安全命名空间风格的配置,添加客户端证书认证只是一个简单的单行配置更改,添加在 HttpSecurity 声明中。请对提供的 SecurityConfig.java 配置进行以下更改:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
// SSL / TLS x509 support
http.x509(httpSecurityX509Configurer -> httpSecurityX509Configurer
              .userDetailsService(userDetailsService));

重要提示

注意到 .x509() 方法引用了我们现有的 userDetailsService() 配置。为了简化,我们使用了在 第五章 中介绍的 UserDetailsServiceImpl 实现,即 使用 Spring Data 进行认证。然而,我们可以轻松地将它替换为任何其他实现(即,在 第四章 中介绍的基于 LDAP 或 JDBC 的实现,即 基于 JDBC 的认证)。

在重新启动应用程序后,您将再次被提示输入客户端证书,但这次,您应该能够访问需要授权的网站区域。您可以从日志中看到(如果您已启用),您已以 admin1@example.com 用户登录。

重要提示

您的代码应类似于 chapter08.01-calendar

Spring Security 如何使用证书信息?

如前所述,Spring Security 在证书交换中的作用是从提供的证书中提取信息,并将用户的凭据映射到用户服务。在使用 .x509() 方法时,我们没有看到使这一切发生的魔法。回想一下,当我们设置客户端证书时,一个类似于 LDAP DN 的 DN 与证书相关联:

Owner: CN=admin@example.com, OU=JBCP Calendar, O=JBCP, L=Park City, ST=UT, C=US

Spring Security 使用此 DN 中的信息来确定主体的实际用户名,它将在 UserDetailsService 中查找此信息。特别是,它允许指定一个正则表达式,该表达式用于匹配与证书建立的 DN 的一部分,并将此 DN 部分用作主体名称。.x509() 方法的隐含默认配置如下:

http.x509(httpSecurityX509Configurer -> httpSecurityX509Configurer
              .subjectPrincipalRegex("CN=(.*?)(?:,|$)")
              .userDetailsService(userDetailsService));

我们可以看到,这个正则表达式将匹配作为主体名称的 admin1@example.com 值。这个正则表达式必须包含一个匹配组,但它可以被配置为支持应用程序的用户名和 DN 发行要求。例如,如果您的组织证书的 DN 包括 emailuserid 字段,则可以将正则表达式修改为使用这些值作为认证主体名称。

Spring Security 证书认证是如何工作的

让我们通过以下图表来回顾参与客户端证书审查和评估以及将其转换为 Spring-Security 认证会话的各种参与者:

图 8.3 – Spring Security 证书认证工作流程

图 8.3 – Spring Security 证书认证工作流程

我们可以看到,o.s.s.web.authentication.preauth.x509.X509AuthenticationFilter 负责检查未认证用户请求展示客户端证书。如果它发现请求包含有效的客户端证书,它将使用 o.s.s.web.authentication.preauth.x509.SubjectDnX509PrincipalExtractor 提取主体,使用与之前描述的证书所有者 DN 匹配的正则表达式。

重要提示

注意,尽管前面的图表表明证书的审查发生在未认证用户的情况下,但当展示的证书标识的用户与之前认证的用户不同时,也可以执行检查。这将导致使用新提供的凭据发起新的认证请求。这样做的原因应该是显而易见的——任何用户展示新的凭证集时,应用程序都必须意识到这一点,并通过确保用户仍然能够访问它来负责任地做出反应。

一旦证书被接受(或拒绝/忽略),就像其他认证机制一样,将构建一个 Authentication 令牌并将其传递给 AuthenticationManager 进行认证。现在我们可以回顾一下 o.s.s.web.authentication.preauth.PreAuthenticatedAuthenticationProvider 处理认证令牌的简要说明:

图 8.4 – Spring Security PreAuthenticatedAuthenticationProvider 工作流程

图 8.4 – Spring Security PreAuthenticatedAuthenticationProvider 工作流程

虽然我们不会详细讨论它们,但 Spring Security 支持许多其他预认证机制。一些例子包括 Java EE 角色映射(J2eePreAuthenticatedProcessingFilter)、WebSphere 集成(WebSpherePreAuthenticatedProcessingFilter)和 SiteMinder 风格的认证(RequestHeaderAuthenticationFilter)。如果你理解了客户端证书认证的过程流程,理解这些其他认证类型会容易得多。

使用 AuthenticationEntryPoint 处理未认证请求

由于 X509AuthenticationFilter 在认证失败时会继续处理请求,我们需要处理用户未能成功认证并请求受保护资源的情况。Spring Security 允许开发者通过插入自定义的 o.s.s.web.AuthenticationEntryPoint 实现来自定义这一点。在默认表单登录场景中,如果用户被拒绝访问受保护资源且未认证,LoginUrlAuthenticationEntryPoint 用于将用户重定向到登录页面。

相比之下,在典型的客户端证书认证环境中,不支持替代认证方法(记住,无论如何,Tomcat 都会在 Spring Security 表单登录之前期望证书)。因此,保留重定向到表单登录页面的默认行为是没有意义的。相反,我们将修改入口点以简单地返回一个 HTTP 403 禁止 消息,使用 o.s.s.web.authentication.Http403ForbiddenEntryPoint。请按照以下方式在您的 SecurityConfig.java 文件中进行以下更新:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http,
        PersistentTokenRepository persistentTokenRepository,
        Http403ForbiddenEntryPoint forbiddenEntryPoint) throws Exception {
    http.authorizeRequests( authz -> authz
                .requestMatchers(antMatcher("/webjars/**")).permitAll()
...
          .exceptionHandling(exceptions -> exceptions
                .authenticationEntryPoint(forbiddenEntryPoint)
                .accessDeniedPage("/errors/403"))
...
    return http.build();
}
@Bean
public Http403ForbiddenEntryPoint forbiddenEntryPoint(){
    return new Http403ForbiddenEntryPoint();
}

现在,如果用户尝试访问受保护资源且无法提供有效的证书,他们将看到以下页面,而不是被重定向到登录页面:

图 8.5 – Spring Security 禁止错误

图 8.5 – Spring Security 禁止错误

重要提示

我们已删除 admin1@example.com 的用户名,以确保没有与证书 CN 匹配的用户。

你的代码应该看起来像 chapter08.02-calendar

其他通常与客户端证书认证一起执行的配置或应用程序流程调整如下:

  • 完全删除基于表单的登录页面

  • 删除注销链接(因为没有理由注销,因为浏览器总是会显示用户的证书)

  • 删除重命名用户账户和更改密码的功能

  • 删除用户注册功能(除非你能够将其与新的证书发放关联)

支持双模式认证

也可能存在某些环境可能同时支持基于证书和基于表单的认证。如果你的环境是这样,使用 Spring Security 支持它也是可能的(并且是微不足道的)。我们可以简单地保留默认的 AuthenticationEntryPoint 接口(重定向到基于表单的登录页面)不变,并允许用户在没有提供客户端证书的情况下使用标准登录表单登录。

如果你选择以这种方式配置你的应用程序,你需要调整 Tomcat SSL 设置(根据你的应用程序服务器适当更改):简单地将 clientAuth 指令更改为 want,而不是 true

<Connector port="8443" protocol="HTTP/1.1" SSLEnabled="true" maxThreads="150" scheme="https" secure="true" sslProtocol="TLS"
keystoreFile="conf/tomcat.keystore" keystorePass="password" truststoreFile="conf/tomcat.truststore" truststorePass="password" clientAuth="want"
/>

我们还需要删除在之前的练习中配置的authenticationEntryPoint()方法,以便如果用户在浏览器首次查询时无法提供有效的证书,则标准的基于表单的认证工作流程将接管。

虽然这很方便,但在双模式(基于表单和基于证书)认证方面,还有一些事情需要记住,如下所示:

  • 大多数浏览器在用户一次证书认证失败后不会再次提示用户输入证书,所以请确保您的用户知道他们可能需要重新进入浏览器以再次展示他们的证书。

  • 请记住,使用证书进行用户认证不需要密码;然而,如果您仍然使用UserDetailsService来支持您的基于表单认证的用户,这可能就是您用来向PreAuthenticatedAuthenticationProvider提供用户信息的同一个UserDetailsService对象。这可能会带来潜在的安全风险,因为您打算仅使用证书登录的用户可能会使用表单登录凭据进行认证。

解决这个问题有几种方法,以下列表中进行了描述:

  • 确保使用证书进行认证的用户在您的用户存储中有一个适当的强密码。

  • 考虑自定义您的用户存储,以便清楚地识别已启用基于表单登录的用户。这可以通过在包含用户账户信息的表中添加一个额外字段,以及通过调整JpaDaoImpl对象使用的 SQL 查询来实现。

  • 完全为以证书认证用户登录的用户配置一个独立的用户详情存储,以将他们完全与允许使用基于表单登录的用户隔离开。

  • 双模式认证可以成为您网站的一个强大补充,并且可以有效地安全部署,前提是您要考虑到用户将获得访问它的条件。

使用 Spring Bean 配置客户端证书认证

在本章早期,我们回顾了参与客户端证书认证的类的流程。因此,我们应该能够直接使用显式 Bean 来配置 JBCP 日历。通过使用显式配置,我们将有更多的配置选项可供使用。让我们看看如何使用显式配置:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Bean
public X509AuthenticationFilter x509Filter(){
    return new X509AuthenticationFilter(){{
        setAuthenticationManager(authenticationManager);
    }};
}
@Bean
public PreAuthenticatedAuthenticationProvider preAuthAuthenticationProvider(final AuthenticationUserDetailsService authenticationUserDetailsService){
    return new PreAuthenticatedAuthenticationProvider(){{
        setPreAuthenticatedUserDetailsService (authenticationUserDetailsService);
    }};
}
@Bean
public UserDetailsByNameServiceWrapper authenticationUserDetailsService(final UserDetailsService userDetailsService){
    return new UserDetailsByNameServiceWrapper(){{
        setUserDetailsService(userDetailsService);
    }};
}

我们还需要删除x509()方法,将x509Filter添加到我们的过滤器链中,并将我们的AuthenticationProvider实现添加到AuthenticationManger中:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Bean
public AuthenticationManager authManager(HttpSecurity http) throws Exception {
    AuthenticationManagerBuilder authenticationManagerBuilder =
          http.getSharedObject(AuthenticationManagerBuilder.class);
    http.authenticationProvider(preAuthAuthenticationProvider);
    return authenticationManagerBuilder.build();
}

现在,尝试一下应用程序。从用户的角度来看,没有太多变化,但作为开发者,我们已经打开了许多额外的配置选项的大门。

重要提示

您的代码应类似于chapter08.03-calendar

基于 Bean 的配置的附加功能

基于 Spring-bean 的配置通过暴露通过安全命名空间样式配置未暴露的 bean 属性,为我们提供了额外的能力。

X509AuthenticationFilter 上可用的其他属性如下:

属性 描述 默认
continueFilterChainOn UnsuccessfulAuthentication 如果为假,失败的认证将抛出异常而不是允许请求继续。这通常会在预期并需要有效证书才能访问受保护站点的情况下设置。如果为真,即使有失败的认证,过滤器链也会继续。 true
checkForPrincipalChanges 如果为真,过滤器将检查当前认证的用户名是否与客户端证书中提供的用户名不同。如果是,将执行对新证书的认证,并将 HTTP 会话作废(可选,见下一个属性)。如果为假,一旦用户认证成功,即使他们提供不同的凭据,他们也将保持认证状态。 false
invalidateSessionOn PrincipalChange 如果为真,并且请求中的主体发生变化,则在重新认证之前将使用户的 HTTP 会话作废。如果为假,会话将保持不变——请注意,这可能会引入安全风险。 true

表 8.1 – X509AuthenticationFilter 上可用的属性

PreAuthenticatedAuthenticationProvider 实现提供了一些有趣的属性,如下表所示:

属性 描述 默认
preAuthenticated UserDetailsService 此属性用于从证书中提取的用户名构建完整的 UserDetails 对象。
throwExceptionWhen TokenRejected 如果为真,当令牌构建不正确(不包含用户名或证书)时,将抛出 BadCredentialsException 异常。通常在仅使用证书的环境中设置为 true

表 8.2 – PreAuthenticatedAuthenticationProvider 上可用的属性

除了这些属性之外,还有许多其他机会来实现接口或扩展参与证书认证的类,以进一步自定义您的实现。

实施客户端证书认证时的注意事项

尽管客户端证书认证非常安全,但它并不适合每个人,也不适合所有情况。

客户端证书认证的优点如下列出:

  • 证书建立了一个相互信任和可验证的框架,确保双方(客户端和服务器)都是他们所声称的身份

  • 如果正确实施,基于证书的认证比其他形式的认证更难伪造或篡改

  • 如果使用并正确配置了支持良好的浏览器,客户端证书认证可以有效地充当单点登录解决方案,使所有证书保护的应用程序登录透明化。

客户端证书认证的缺点如下:

  • 证书的使用通常要求整个用户群体都拥有它们。这可能导致用户培训负担和行政负担。大多数在大规模部署基于证书的认证的组织必须为证书维护、到期跟踪和用户支持提供足够的自助和帮助台支持。

  • 证书的使用通常是一种全有或全无的事情,这意味着由于网络服务器配置的复杂性或应用支持不佳,不支持混合模式认证和为非证书用户提供支持。

  • 证书的使用可能不会得到您用户群体中所有用户的良好支持,包括使用移动设备的用户。

  • 正确配置支持基于证书的认证所需的基础设施可能需要高级 IT 知识。

正如你所见,客户端证书认证既有优点也有缺点。当正确实施时,它可以成为用户非常方便的访问模式,并且具有非常吸引人的安全和不可否认属性。你需要确定你的具体情况,看看这种认证方式是否合适。

摘要

在本章中,我们探讨了基于客户端证书的认证的架构、流程和 Spring Security 支持。我们涵盖了客户端证书(相互)认证的概念和整体流程。我们探讨了配置 Apache Tomcat 以实现自签名 SSL 和客户端证书场景所需的重要步骤。

我们还学习了如何配置 Spring Security 以了解客户端提供的基于证书的凭证。我们涵盖了与证书认证相关的 Spring Security 类架构。我们还知道了如何配置 Spring bean 风格的客户端证书环境。我们还讨论了这种认证方式的优缺点。

对于不熟悉客户端证书的开发者来说,这种环境中的许多复杂性可能会让他们感到困惑。我们希望这一章能让这个复杂主题更容易理解和实施!

在下一章中,我们将讨论开放授权OAuth 2)协议以及如何使用OpenID 连接OIDC)实现单点登录。

第三部分:探索 OAuth 2 和 SAML 2

本部分重点介绍 OAuth 2,这是一种广泛采用的可信身份管理方法,使用户能够通过单个可信提供者集中管理他们的身份。用户可以从使用可信的 OAuth 2 提供者安全存储他们的密码和个人信息中受益,同时保留在需要时披露个人信息的选项。实施 OAuth 2 身份验证的网站可以信任展示 OAuth 2 凭证的用户是经过身份验证的个人。

在探索 SAML 2 支持的过程中,我们深入探讨了将安全断言标记语言SAML 2.0)集成到 Spring Security 应用程序的复杂性。SAML 2.0 是一种基于 XML 的标准,它促进了身份提供者IdP)和服务提供者SP)之间认证和授权数据的交换,在 Spring Security 框架内提供无缝集成。

本部分包含以下章节:

  • 第九章, 开启 OAuth 2

  • 第十章, SAML 2 支持

第九章:开放 OAuth 2

OAuth 2是一种非常流行的受信任身份管理形式,它允许用户通过单个受信任的提供者来管理他们的身份。这个便捷的功能为用户提供将密码和个人信息存储在受信任的 OAuth 2 提供者的安全性,并在请求时可选地披露个人信息。此外,OAuth 2 启用网站提供信心,即提供 OAuth 2 凭证的用户就是他们所说的那个人。

在本章中,我们将涵盖以下主题:

  • 在 5 分钟内学习如何设置自己的 OAuth 2 应用程序

  • 使用非常快速的实施方式配置JBCP 日历应用程序的 OAuth 2

  • 学习 OAuth 2 的概念架构以及它是如何为您的网站提供可信用户访问的

  • 实现基于 OAuth 2 的用户注册

  • 尝试 OAuth 2 属性交换以实现用户配置文件功能

  • Spring Security中配置 OAuth 2 支持

  • 执行 OAuth 2 提供者连接工作流程

  • 将 OpenID Connect 提供者与Spring Security集成

本章代码的实际链接在这里:packt.link/ejucD

OAuth 2 的充满希望的世界

作为应用程序开发者,你可能经常听到 OAuth 2 这个术语。OAuth 2 已被全球的互联网服务和软件公司广泛采用,并且对于这些公司如何互动和共享信息至关重要。但究竟是什么呢?简单来说,OAuth 2 是一种协议,允许不同的实体以安全和可靠的方式共享信息和资源。

那么,OAuth 1.0 呢?

建立在相同动机的基础上,OAuth 1.0 于 2007 年设计和批准。然而,它因过于复杂而受到批评,并且存在不精确的规范问题,这导致了不安全的实现。所有这些问题都导致了 OAuth 1.0 的较差采用,并最终导致了 OAuth 2 的设计和创建。

OAuth 2 是 OAuth 1.0 的后继者。

还需要注意的是,OAuth 2 与 OAuth 1.0 不向后兼容,因此 OAuth 2 应用程序不能与 OAuth 1.0 服务提供商集成。

这种类型的登录——通过受信任的第三方——已经存在很长时间了,以许多不同的形式(例如,Google 身份提供者Microsoft Entra ID曾一度成为网络上较著名的中央登录服务之一)。

这里是 OAuth 2.0 的关键概念和组件:

角色

  • 资源所有者 (RO):可以授予访问受保护资源的实体。通常,这是最终用户。

  • 客户端: 代表资源所有者请求访问受保护资源的应用程序。

  • 授权服务器 (AS):在获得适当的授权后,验证资源所有者并颁发访问令牌的服务器。

  • 资源服务器 (RS):托管正在访问的保护资源的服务器。

授权许可:

  • OAuth 2.0 定义了多种授权许可类型,例如授权代码、隐式、资源所有者密码凭证和客户端凭证。许可类型决定了流程和客户端获取访问令牌的方式。

访问令牌:

  • 访问令牌是代表授予客户端的授权的凭证。它用于代表资源所有者访问受保护的资源。

作用域:

  • 作用域定义了客户端请求的访问范围。它指定了客户端打算在资源服务器上执行的操作。

授权端点和 令牌端点:

  • 授权端点便于与资源所有者沟通以获得授权许可,而令牌端点便于将此许可交换为访问令牌。

重定向 URI:

  • 在资源所有者授予权限后,授权服务器使用重定向 URI 将用户重定向回客户端应用程序。

重要提示

您可以参考 OAuth 2.0 规范tools.ietf.org/html/rfc6749

以下图示说明了在登录过程中集成 OAuth 2 的网站与 Facebook OAuth 2 提供者之间的高级关系,例如:

图 9.1 – 登录过程中 OAuth 2 和 Facebook OAuth 2 提供者

图 9.1 – 登录过程中 OAuth 2 和 Facebook OAuth 2 提供者

我们可以看到,提交表单将启动对 OAuth 提供者的请求,导致提供者显示一个授权对话框,要求用户允许jbcpcalendar从您的 OAuth 提供者账户获取特定信息的权限。此请求包含一个名为codeuri参数。一旦获得授权,用户将被重定向回jbcpcalendar,并且code参数包含在

uri参数。然后,请求再次重定向到 OAuth 提供者,以授权jbcpcalendar。OAuth 提供者随后响应一个access_token,该令牌可用于访问jbcpcalendar被授权访问的用户 OAuth 信息。

不要无保留地信任 OAuth 2!

在这里,您可以看到一个可能会欺骗系统用户的根本假设。我们有可能注册 OAuth 2 提供者账户,这会让我们看起来像是詹姆斯·高斯林,尽管我们显然不是。不要错误地假设,仅仅因为用户有一个听起来令人信服的 OAuth 2(或 OAuth 2 代表提供者),他们就是他们所说的那个人,而不需要额外的身份识别形式。从另一个角度来看,如果有人来到你家门口,只声称他是詹姆斯·高斯林,你会不验证他的身份就让他进去吗?

OAuth 2.0 启用的应用然后将用户重定向到 OAuth 2.0 提供者,用户向提供者出示其凭证,提供者随后负责做出访问决定。一旦提供者做出访问决定,提供者将用户重定向回原始网站,此时原始网站可以确信用户的真实性。一旦你尝试过 OAuth 2.0,它就会变得容易理解。现在让我们将 OAuth 2.0 添加到JBCP 日历登录界面!

我们为什么需要 OpenID Connect?

RFC 6749 (datatracker.ietf.org/doc/html/rfc6749) 和 RFC 6750 (datatracker.ietf.org/doc/html/rfc6750)。其主要目标是简化通过授权服务器执行的认证来验证用户身份的过程,允许以既互操作又符合 REST 原则的方式检索用户配置文件信息。

此协议赋予应用和网站的开发者启动登录过程并接收关于用户的可信断言的权力,确保在各种平台(包括基于 Web 的、移动的和 JavaScript 客户端)之间的一致性。该规范套件是可定制的,支持各种可选功能,如身份数据的加密、OpenID 提供者的发现和会话注销。

对于开发者来说,OpenID Connect 提供了一种安全和可验证的方式来回答关键问题:“目前使用连接的浏览器或移动应用的是哪位个人?”值得注意的是,它通过消除设置、存储和管理密码的需要,减轻了处理密码——通常与数据泄露相关——的负担。

OpenID Connect 是如何工作的

OpenID Connect 通过提供无缝集成、强大的支持、安全性和隐私保护配置,促进了互联网身份生态系统的建立。它强调互操作性,扩展了对广泛客户端和设备的支持,并允许任何实体作为OpenID 提供者OP)运行。

下面是OpenID Connect的关键概念和组件:

  • RP,即信赖方,指的是将用户身份验证功能委托给身份提供者IDP)的应用或网站。

  • OPIDP:OP 是一个实现了 OpenID Connect 和 OAuth 2.0 协议的实体。有时,OPs 会根据其扮演的角色来表示,例如安全令牌服务STS)、IDP授权服务器(AS)。

  • 身份令牌:作为身份验证过程的成果,身份令牌至少包括用户标识符(称为sub或主题声明)以及关于用户何时以及如何进行身份验证的详细信息。还可以包括其他身份数据。

  • 客户端:客户端是请求令牌的软件,无论是用于用户身份验证还是资源访问(RP)。客户端需要在 OP 上注册,并且可以采取各种形式,如 Web 应用程序、原生移动和桌面应用程序等。

  • 用户:用户是利用已注册客户端访问资源的个人。

在强调 OpenID Connect 原则是建立在 OAuth 2 协议之上的协议之后,我们将学习如何使用一些流行的提供商在我们的 JBCP 日历 应用程序中设置 OAuth 2。

注册 OAuth 2 应用程序

为了充分利用本节中的练习(并能够测试登录),您需要创建一个服务提供商的应用程序。目前,Spring Social 支持 Twitter、Facebook、Google、LinkedIn 和 GitHub,并且这个列表还在增长。

为了充分利用本章中的练习(并能够测试登录),我们建议您至少拥有 Google 账户。我们已经为 jbcpcalendar 应用程序设置了账户,我们将在本章的剩余部分使用它。

如果您正在使用 OAuth 2 功能,请在您的 build.gradle 文件中包含以下附加依赖项:

//build.gradle
dependencies {
...
    // OAuth2 Configuration:
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
...
}

重要提示

您应该从 chapter09.00-calendar 中的源代码开始。

除了 Spring Security OAuth 2 依赖项之外,我们现在将探索 JBCP Calendar 应用程序。

使用 Spring Security 启用 OAuth 2.0 登录

在接下来的几章中,我们可以看到外部身份验证提供商中存在的一个共同主题。Spring Security 提供了全面的 OAuth 2 支持。本节讨论了如何将 OAuth 2 集成到您的基于 servlet 的应用程序中。

OAuth 2.0 登录 功能允许应用程序让用户使用他们现有的 OAuth 2.0 提供商(如 GitHub)或 OpenID Connect 1.0 提供商(如 Google)的账户登录到应用程序。

重要提示

OAuth 2.0 登录 通过使用 授权码授权 实现,如 OAuth 2.0 授权框架 中指定,您可以在tools.ietf.org/html/rfc6749#section-4.1找到,以及 OpenID Connect Core 1.0,您可以在openid.net/specs/openid-connect-core-1_0.xhtml#CodeFlowAuth找到。

初始设置

本节展示了如何通过使用 Google 作为 身份验证提供者 来配置 OAuth 2.0 登录 示例,并涵盖了以下主题:

  • 请遵循此处 OpenID Connect 页面上的说明:developers.google.com/identity/openid-connect/openid-connect,从 设置 OAuth 2.0 部分开始。

  • 完成获取 OAuth 2.0 凭据的说明后,您应该有一个新的 OAuth 客户端,其凭据由一个 客户端 ID 和一个 客户端密钥 组成。

此配置对于将我们的应用程序配置为OAuth 2客户端非常重要。

设置重定向 URI

重定向 URI 是在用户通过 Google 进行身份验证并授予对在同意页面上的OAuth Client(在上一步骤中创建)的访问权限后,最终用户的用户代理被重定向回应用程序中的路径。

在本小节中,请确保授权重定向 URI 字段设置为https://localhost:8443/login/oauth2/code/google

重要提示

默认的重定向 URI 模板是{baseUrl}/login/oauth2/code/{registrationId}registrationIdClientRegistration的唯一标识符。

如果OAuth Client运行在代理服务器后面,您应该检查代理服务器配置(请参阅此链接:docs.spring.io/spring-security/reference/features/exploits/http.xhtml#http-proxy-server),以确保应用程序配置正确。同时,请参阅此处支持的 URI 模板变量:docs.spring.io/spring-security/reference/servlet/oauth2/client/authorization-grants.xhtml#oauth2Client-auth-code-redirect-uri关于redirect-uri

一旦建立了重定向 URI,我们将继续设置application.yml配置。

配置 application.yml

现在您已经有一个新的带有 Google 的OAuth Client,您需要配置应用程序以使用OAuth Client进行身份验证流程。为此,请转到application.yml并设置以下配置:

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: google-client-id
            client-secret: google-client-secret

我们在此配置了以下客户端属性:

  • spring.security.oauth2.client.registrationOAuth Client属性的基属性前缀。

  • 在基属性前缀之后是ClientRegistration的 ID,例如 Google。

在配置 OAuth 2 客户端属性后,我们需要注册一个SecurityFilterChain bean。

注册 SecurityFilterChain Bean

以下示例显示了如何使用@EnableWebSecurity注册SecurityFilterChain bean 并通过httpSecurity.oauth2Login()启用OAuth 2.0登录:

//src/main/java/com/packtpub/springsecurity/configuration/ SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http, PersistentTokenRepository persistentTokenRepository) throws Exception {
       http.authorizeRequests( authz -> authz
                    .requestMatchers(antMatcher("/webjars/**")).permitAll()
                    .requestMatchers(antMatcher("/css/**")).permitAll()
                    .requestMatchers(antMatcher("/favicon.ico")).permitAll()
                    // H2 console:
                    .requestMatchers(antMatcher("/admin/h2/**")).access("isFullyAuthenticated()")
                    .requestMatchers(antMatcher("/")).permitAll()
                    .requestMatchers(antMatcher("/login/*")).permitAll()
                    .requestMatchers(antMatcher("/logout")).permitAll()
                    .requestMatchers(antMatcher("/signup/*")).permitAll()
                    .requestMatchers(antMatcher("/errors/**")).permitAll()
                    .requestMatchers(antMatcher("/events/")).hasRole("ADMIN")
                    .requestMatchers(antMatcher("/**")).hasAnyAuthority("OIDC_USER", "OAUTH2_USER", "ROLE_USER"))
              .exceptionHandling(exceptions -> exceptions
                    .accessDeniedPage("/errors/403"))
              .formLogin(form -> form
                    .loginPage("/login/form")
                    .loginProcessingUrl("/login")
                    .failureUrl("/login/form?error")
                    .usernameParameter("username")
                    .passwordParameter("password")
                    .defaultSuccessUrl("/default", true)
                    .permitAll())
              .logout(form -> form
                    .logoutUrl("/logout")
                    .logoutSuccessUrl("/login/form?logout")
                    .permitAll())
              // CSRF is enabled by default, with Java Config
              .csrf(AbstractHttpConfigurer::disable);
        // OAuth2 Config
        http
              .oauth2Login(withDefaults());
        // For H2 Console
        http.headers(headers -> headers.frameOptions(FrameOptionsConfig::disable));
        return http.build();
    }
... Omitted for brevity
}

配置SecurityFilterChain bean 之后的下一步是更新SpringSecurityUserContext类。

更新 SpringSecurityUserContext 类

SpringSecurityUserContext中的getCurrentUser需要引用新的已认证用户,类型为DefaultOidcUser

以下示例显示了如何将当前实现适配以引用DefaultOidcUser用户类型:

//src/main/java/com/packtpub/springsecurity/service/ SpringSecurityUserContext.java
@Component
public class SpringSecurityUserContext implements UserContext {
    private static final Logger logger = LoggerFactory
            .getLogger(SpringSecurityUserContext.class);
    private final CalendarService calendarService;
    public SpringSecurityUserContext(final CalendarService calendarService) {
      this.calendarService = calendarService;
    }
    @Override
    public CalendarUser getCurrentUser() {
       SecurityContext context = SecurityContextHolder.getContext();
       Authentication authentication = context.getAuthentication();
       if (authentication == null) {
          return null;
       }
       String email;
       if(authentication.getPrincipal() instanceof DefaultOidcUser oidcUser ) {
          email = oidcUser.getEmail();
       } else if (authentication.getPrincipal() instanceof DefaultOAuth2User oauth2User) {
          email = oauth2User.getAttribute("email");
       } else {
          User user = (User) authentication.getPrincipal();
          email = user.getUsername();
       }
       if (email == null) {
          return null;
       }
       CalendarUser result = calendarService.findUserByEmail(email);
       if (result == null) {
          throw new IllegalStateException(
                "Spring Security is not in synch with CalendarUsers. Could not find user with email " + email);
       }
       logger.info("CalendarUser: {}", result);
       return result;
    }
}

完成前一步骤后,我们将测试应用程序。

启动应用程序

  1. 启动示例应用程序并访问https://localhost:8443/oauth2/authorization/google。您将被重定向到默认登录页面,该页面显示一个 Google 链接。

  2. 点击 Google 链接,然后您将被重定向到 Google 进行认证。

  3. OAuth ClientUserInfo Endpoint(了解更多信息:https://openid.net/specs/openid-connect-core-1_0.xhtml#UserInfo)检索您的电子邮件地址和基本个人资料信息,并建立认证会话。

  4. 到目前为止,您应该能够使用 Google OAuth 2 提供者完成完整的登录。发生的重定向如下。首先,我们启动 OAuth 2 提供者登录,如下截图所示:

图 9.2 – 使用 Google 的 OAuth 2 社交登录

图 9.2 – 使用 Google 的 OAuth 2 社交登录

  1. 填写登录详细信息后,用户将被重定向到JBCP Calendar应用程序,并使用提供者显示名称自动登录:

图 9.3 – 认证成功后的欢迎页面

图 9.3 – 认证成功后的欢迎页面

到目前为止,用户存在于应用程序中并且已经认证,但在所有网页上尚未授权。创建事件页面只能由认证用户访问。

重要提示

您的代码现在应该看起来像chapter09.01-calendar中的那样。

自定义登录页面

默认情况下,OAuth 2.0 登录页面由DefaultLoginPageGeneratingFilter自动生成。默认登录页面显示每个配置的OAuth Client,其ClientRegistration.clientName作为链接,能够启动授权请求(或 OAuth 2.0 登录)。

重要提示

为了使DefaultLoginPageGeneratingFilter显示配置的OAuth Clients的链接,注册的ClientRegistrationRepository也需要实现Iterable<ClientRegistration>。请参考InMemoryClientRegistrationRepository

每个OAuth Client的链接默认目的地如下:

OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI + "/{registrationId}"

以下代码展示了如何适配login.xhtml表单:

//src/main/resources/templates/login.xhtml
<div class="mb-3">
    <legend>Login With Google</legend>
    <div class="mb-3">
        <a class="btn btn-danger"
           role="button" th:href="@{/oauth2/authorization/google}">Login with Google</a>
    </div>
</div>

现在,您可以使用登录社交按钮使用 Google 作为身份提供者来认证您的用户。

我们还需要确保用户被重定向到jbcpcalendar应用程序,并且自动登录。以下示例展示了如何将SecurityConfig.java适配以在认证成功后进行适当的重定向:

//src/main/java/com/packtpub/springsecurity/configuration/ SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http, GrantedAuthoritiesMapper grantedAuthoritiesMapper) throws Exception {
... omitted for brevity
        // OAuth2 Login
        http
                .oauth2Login(oauth2 -> oauth2
                        .loginPage("/login/form")
                        .defaultSuccessUrl("/default", true));
        return http.build();
    }
}

到目前为止,您应该能够使用 Google 的 OAuth 2 提供者完成完整的登录。发生的重定向如下。首先,我们启动 OAuth 2 提供者登录。

图 9.4 – 认证成功后的登录屏幕

图 9.4 – 认证成功后的登录屏幕

然后,我们被重定向到JBCP Calendar应用程序。

重要提示

您的代码现在应该看起来像chapter09.02-calendar中的那样。

其他 OAuth 2 提供者

我们已经成功集成了一个 OAuth 2 提供者,使用了一个流行的 OAuth 2 提供者。还有其他几个提供者可供选择;我们将添加更多提供者,以便我们的用户有多个选项。Spring Security目前原生支持GoogleGitHubFacebookOkta提供者。包括额外的提供者将需要配置自定义提供者属性。

CommonOAuth2Provider预先定义了一组默认客户端属性,适用于 Spring Security 原生支持的多个知名提供者,如前所述。

例如,对于提供者,authorization-uritoken-uriuser-info-uri通常不会经常改变。因此,提供默认值是有意义的,可以减少所需的配置。

如前所述,当我们配置 Google 客户端时,只需要client-idclient-secret属性。

为了将 GitHub 提供者添加到JBCP日历应用程序中:

  1. 按照以下步骤在 GitHub 上注册您的应用程序:docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app。最后,保存client-idclient-secret

    https://localhost:8443/login/oauth2/code/github

  2. 需要设置额外的应用程序属性,并且每个配置的提供者将自动使用提供者应用程序的client-idclient-secret键进行注册,如下所示:

    //src/main/resources/application.yml
    spring:
      security:
        oauth2:
          client:
            registration:
              google:
                client-id: google-client-id
                client-secret: google-client-secret
              github:
                client-id: github-client-id
                client-secret: github -client-secret
    
  3. 我们现在可以将新的登录选项添加到我们的login.xhtml文件中,包括新的提供者GitHub

    //src/main/resources/templates/login.xhtml
    <div class="mb-3">
        <legend>Login With Google</legend>
        <div class="mb-3">
            <a class="btn btn-danger"
               role="button" th:href="@{/oauth2/authorization/google}">Login with Google</a>
        </div>
        <legend>Login With Github</legend>
        <div class="mb-3">
            <a class="btn btn-dark"
               role="button" th:href="@{/oauth2/authorization/github}">Login with Github</a>
        </div>
    </div>
    
  4. 现在我们有了连接到 JBCP 日历的额外提供者的必要详细信息,我们可以重新启动JBCP 日历应用程序并测试使用其他 OAuth 2 提供者进行登录。

重要提示

您的代码现在应该看起来像chapter09.03-calendar中的那样。

当现在登录时,我们应该看到额外的提供者选项,如下面的截图所示:

图 9.5 – 使用 Google 和 GitHub 的社交登录选项

图 9.5 – 使用 Google 和 GitHub 的社交登录选项

到目前为止,您应该能够使用 Google OAuth 2 提供者完成完整的登录。发生的重定向如下。首先,我们启动 OAuth 2 提供者登录,如下面的截图所示:

图 9.6 – 使用 GitHub 的 OAuth 2 社交登录

图 9.6 – 使用 GitHub 的 OAuth 2 社交登录

  1. 然后,我们将被重定向到提供者授权页面,请求用户授予对jbcpcalendar应用程序的权限,如下面的截图所示:

图 9.7 – OAuth 2 GitHub 同意屏幕

图 9.7 – OAuth 2 GitHub 同意屏幕

在授权jbcpcalendar应用程序后,用户将被重定向到jbcpcalendar应用程序,并使用提供者显示名称自动登录。

配置自定义提供者属性

有些 OAuth 2.0 提供者支持多租户,这导致每个租户(或子域)有不同的协议端点。

例如,在OKTA注册的OAuth 客户端被分配给一个特定的子域,并且有自己的协议端点。

要开始,你需要一个Okta 开发者账户。你可以通过访问developer.okta.com/signup来设置一个账户。

对于这些情况,Spring Boot 2.x提供了以下基础属性来配置自定义提供者属性:spring.security.oauth2.client.provider.[providerId]

下面的代码片段显示了一个示例:

security:
  oauth2:
    client:
      registration:
        okta:
          client-id: okta-client-id
          client-secret: okta-client-secret
          scope: openid,profile,email
      provider:
        okta:
          issuer-uri: https://your-subdomain.okta.com
          authorization-uri: https://your-subdomain.okta.com/oauth2/v1/authorize
          token-uri: https://your-subdomain.okta.com/oauth2/v1/token
          user-info-uri: https://your-subdomain.okta.com/oauth2/v1/userinfo
          user-name-attribute: sub
          jwk-set-uri: https://your-subdomain.okta.com/oauth2/v1/keys

基础属性(spring.security.oauth2.client.provider.okta)允许自定义配置协议端点位置。

我们现在可以将新的登录选项添加到我们的login.xhtml文件中,包括新的OKTA提供者:

//src/main/resources/templates/login.xhtml
... omitted for brevity
<div class="mb-3">
    <legend>Login With Google</legend>
    <div class="mb-3">
        <a class="btn btn-danger"
           role="button" th:href="@{/oauth2/authorization/google}">Login with Google</a>
    </div>
    <legend>Login With Github</legend>
    <div class="mb-3">
        <a class="btn btn-dark"
           role="button" th:href="@{/oauth2/authorization/github}">Login with Github</a>
    </div>
    <legend>Login With OKTA</legend>
    <div class="mb-3">
        <a class="btn btn-success"
           role="button" th:href="@{/oauth2/authorization/okta}">Login with OKTA</a>
    </div>
</div>

现在我们有了连接到 JBCP 日历的附加提供者的必要详细信息,我们可以重新启动JBCP 日历应用程序并测试使用自定义 OAuth 2 OKTA提供者进行登录。

重要提示

你的代码现在应该看起来像chapter09.04-calendar中的那样。

现在登录时,我们应该会看到额外的提供者选项,如下面的截图所示:

图 9.8 – 使用 Google、GitHub 和 OKTA 的社会登录选项

图 9.8 – 使用 Google、GitHub 和 OKTA 的社会登录选项

启用代码交换证明密钥(PKCE)支持

PKCE代表代码交换证明密钥。它是在 OAuth 2.0 授权流程中用于减轻某些类型攻击的安全功能,尤其是针对授权代码流的攻击。

传统的 OAuth 2.0 授权代码流,客户端应用程序将用户重定向到授权服务器,用户进行身份验证并提供同意,然后授权服务器向客户端颁发授权代码。然后客户端用这个代码交换访问令牌。

PKCE 旨在防止授权代码截获攻击。在这些攻击中,恶意行为者截获授权代码,当它被返回给客户端时,然后使用它来获取访问令牌。PKCE 为这个过程增加了额外的安全层。

下面的序列图描述了 PKCE 的工作原理:

图 9.9 – 使用 Google、GitHub 和 OKTA 的社会登录选项

图 9.9 – 使用 Google、GitHub 和 OKTA 的社会登录选项

公共客户端通过利用 PKCE 获得支持。有关 PKCE 的更多信息,请参阅此链接:datatracker.ietf.org/doc/html/rfc7636。当客户端在不受信任的环境中运行(例如,原生或基于 Web 浏览器的应用程序)时,PKCE 会自动使用,在这种情况下,当以下条件成立时,它无法保持其凭证的秘密:

  • client-secret被省略(或为空)

  • client-authentication-method设置为ClientAuthenticationMethod.NONE)

如果 OAuth 2.0 提供者支持为机密客户端使用 PKCE(了解更多关于机密客户端的信息:datatracker.ietf.org/doc/html/rfc6749#section-2.1),你可以(可选地)使用DefaultServerOAuth2AuthorizationRequestResolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce())来配置它。

以下示例展示了如何通过注册自己的OAuth2AuthorizationRequestResolver来适配SecurityConfig.java以使用 PKCE:

//src/main/java/com/packtpub/springsecurity/configuration/ SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http, OAuth2AuthorizationRequestResolver pkceResolver) throws Exception {
... omitted for brevity
        // OAuth2 Login
        http
                .oauth2Login(oauth2 -> oauth2
                        .loginPage("/login/form")
                        .authorizationEndpoint(authorization -> authorization.authorizationRequestResolver(pkceResolver))
                        .defaultSuccessUrl("/default", true)
                .userInfoEndpoint(userInfo -> userInfo
                        .userAuthoritiesMapper(grantedAuthoritiesMapper)));
        return http.build();
    }
    @Bean
    public OAuth2AuthorizationRequestResolver pkceResolver(ClientRegistrationRepository clientRegistrationRepository) {
       DefaultOAuth2AuthorizationRequestResolver resolver = new DefaultOAuth2AuthorizationRequestResolver(clientRegistrationRepository, OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI);
       resolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce());
       return resolver;
    }
}

重要提示

你的代码现在应该看起来像chapter09.05-calendar中的那样。

OpenID Connect 1.0 注销

RP-Initiated Logout,详细说明见openid.net/specs/openid-connect-rpinitiated-1_0.xhtml

OpenID 提供者支持从OpenID 提供者的发现元数据中的end_session_endpoint URL 的情况下。你可以通过配置ClientRegistration使用issuer-uri来实现这一点,如openid.net/specs/openid-connect-session-1_0.xhtml#OPMetadata中概述的那样:

spring:
  security:
    oauth2:
      client:
        registration:
          okta:
            client-id: okta-client-id
            client-secret: okta-client-secret
            ...
        provider:
          okta:
            issuer-uri: https://dev-1234.oktapreview.com

此外,你可以配置OidcClientInitiatedLogoutSuccessHandler,它实现了 RP-Initiated Logout,如下所示:

//src/main/java/com/packtpub/springsecurity/configuration/ SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    private ClientRegistrationRepository clientRegistrationRepository;
    public SecurityConfig(ClientRegistrationRepository clientRegistrationRepository) {
       this.clientRegistrationRepository = clientRegistrationRepository;
    }
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http, OAuth2AuthorizationRequestResolver pkceResolver) throws Exception {
       http.authorizeRequests(authz -> authz
... omitted for brevity
       // OAuth2 Login
       http
             .oauth2Login(oauth2 -> oauth2
                   .loginPage("/login/form")
                   .authorizationEndpoint(authorization -> authorization.authorizationRequestResolver(pkceResolver))
                   .defaultSuccessUrl("/default", true))
             .logout(logout -> logout
                   .logoutSuccessHandler(oidcLogoutSuccessHandler()));
       return http.build();
    }
    private LogoutSuccessHandler oidcLogoutSuccessHandler() {
       OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler =
             new OidcClientInitiatedLogoutSuccessHandler(this.clientRegistrationRepository);
       // Sets the location that the End-User's User Agent will be redirected to
       // after the logout has been performed at the Provider
       oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}");
       return oidcLogoutSuccessHandler;
    }
}

重要提示

你的代码现在应该看起来像chapter09.06-calendar中的那样。

自动用户注册

许多应用程序需要在其本地保存有关其用户的数据,即使身份验证已委派给外部提供者。这可以通过以下两个步骤完成:

  1. 为你的数据库选择一个后端,并为一个适合你需求的自定义User对象设置一些仓库(例如使用 Spring Data),该对象可以从外部身份验证完全或部分填充。对于我们的JBCP 日历应用程序,我们将适配CalendarUser以添加提供者信息,如下所示:

    @Entity
    @Table(name = "calendar_users")
    public class CalendarUser implements Principal, Serializable {
    ... getter / setter omitted for brevity
        @Id
        @SequenceGenerator(name = "EntityTwoSequence", initialValue = 1000)
        @GeneratedValue(generator = "EntityTwoSequence")
        private Integer id;
        private String firstName;
        private String lastName;
        private String email;
        private String provider;
        private String externalId;
        @ManyToMany(fetch = FetchType.EAGER)
        @JoinTable(name = "user_role",
              joinColumns = @JoinColumn(name = "user_id"),
              inverseJoinColumns = @JoinColumn(name = "role_id"))
        private Set<Role> roles;
        /**
    }
    
  2. 实现OAuth2UserService并公开调用CalendarUser对象,实现OAuth2User

    @Component
    public class CalendarOAuth2UserService implements OAuth2UserService {
        private final CalendarService calendarService;
        public CalendarOAuth2UserService(CalendarService calendarService) {
           this.calendarService = calendarService;
        }
        @Override
        public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
           DefaultOAuth2UserService delegate = new DefaultOAuth2UserService();
           OAuth2User user = delegate.loadUser(userRequest);
           String email = user.getAttribute("email");
           CalendarUser calendarUser = calendarService.findUserByEmail(email);
           if (calendarUser ==null) {
              calendarUser = new CalendarUser();
              calendarUser.setEmail(email);
              calendarUser.setProvider(userRequest.getClientRegistration().getRegistrationId());
              if ("github".equals(userRequest.getClientRegistration().getRegistrationId())) {
                 calendarUser.setExternalId(user.getAttribute("id").toString());
                 calendarUser.setFirstName( user.getAttribute("name"));
                 calendarUser.setLastName(user.getAttribute("name"));
              }
           calendarService.createUser(calendarUser);
           }
           return user;
        }
    }
    
  3. 实现OidcUserService以调用授权服务器以及你的数据库。你的实现应该返回一个扩展你的自定义用户对象并实现OidcUser的对象。

    @Component
    public class CalendarOidcUserService extends OidcUserService {
        private final CalendarService calendarService;
        public CalendarOidcUserService(CalendarService calendarService) {
           this.calendarService = calendarService;
        }
        @Override
        public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
           OidcUser user = super.loadUser(userRequest);
           String email = user.getEmail();
           CalendarUser calendarUser = calendarService.findUserByEmail(email);
           if (calendarUser == null) {
              calendarUser = new CalendarUser();
              calendarUser.setEmail(email);
              calendarUser.setProvider(userRequest.getClientRegistration().getRegistrationId());
              calendarUser.setExternalId(user.getAttribute("sub"));
              calendarUser.setFirstName(user.getGivenName());
              calendarUser.setLastName(user.getFamilyName());
           calendarService.createUser(calendarUser);
           }
           return user;
        }
    }
    

提示

在用户对象中包含一个新属性,以与外部提供者的唯一标识符建立连接(与用户名不同,但与外部平台上的账户唯一关联)。

如果支持多个提供者,需要解决的一个问题是返回的各种提供者详情之间的用户名冲突。

如果你使用列出的每个提供者登录到JBCP 日历应用程序——然后查询存储在H2 数据库中的数据——你会发现数据可能是相似的,如果不是完全相同的话,这取决于用户的账户详情。

CALENDAR_USERS表中,我们有两个可能的问题:

  1. 首先,我们使用UserDetails对象的电子邮件属性作为用户 ID来查找JBCP 日历用户。但用户 ID可能对于某些其他提供者来说与电子邮件不同。

  2. 第二,仍然有可能两个不同提供者的用户标识符是相同的。

我们不会深入探讨检测和纠正这一可能问题的各种方法,但值得将来参考。

重要提示

您的代码现在应该看起来像chapter09.07-calendar中的那样。

映射用户权限

GrantedAuthoritiesMapper接收一组已授予的权限,包括具有对应字符串标识符OAUTH2_USER(或具有字符串标识符OIDC_USEROidcUserAuthority)的唯一权限的OAuth2UserAuthority类型。

我们将提供一个自定义的GrantedAuthoritiesMapper实现,并按以下方式配置它:

//src/main/java/com/packtpub/springsecurity/core/authority/ CalendarUserAuthoritiesMapper.java
@Component
public class CalendarUserAuthoritiesMapper implements GrantedAuthoritiesMapper {
    private CalendarUserRepository userRepository;
    public CalendarUserAuthoritiesMapper(CalendarUserRepository userRepository) {
       this.userRepository = userRepository;
    }
    @Override
    public Collection<? extends GrantedAuthority> mapAuthorities(Collection<? extends GrantedAuthority> authorities) {
       Set<GrantedAuthority> mappedAuthorities = new HashSet<>();
       authorities.forEach(authority -> {
          String email = null;
          if (authority instanceof OidcUserAuthority oidcUserAuthority) {
             OidcIdToken idToken = oidcUserAuthority.getIdToken();
             mappedAuthorities.add(oidcUserAuthority);
             email = idToken.getEmail();
          }
          else if (OAuth2UserAuthority.class.isInstance(authority)) {
             OAuth2UserAuthority oauth2UserAuthority = (OAuth2UserAuthority) authority;
             mappedAuthorities.add(oauth2UserAuthority);
             Map<String, Object> userAttributes = oauth2UserAuthority.getAttributes();
             email = (String) userAttributes.get("email");
          }
          if (email != null) {
             CalendarUser calendarUser = userRepository.findByEmail(email);
             List<String> roles = calendarUser.getRoles().stream().map(Role::getName).toList();
             List<GrantedAuthority> grantedAuthorityList = AuthorityUtils.createAuthorityList(roles.toArray(new String[0]));
             mappedAuthorities.addAll(grantedAuthorityList);
          }
       });
       return mappedAuthorities;
    }
}

以下示例展示了如何将SecurityConfig.java适配以使用GrantedAuthoritiesMapper

//src/main/java/com/packtpub/springsecurity/configuration/ SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
public SecurityFilterChain filterChain(HttpSecurity http, PersistentTokenRepository persistentTokenRepository,
       GrantedAuthoritiesMapper grantedAuthoritiesMapper) throws Exception {
... omitted for brevity
      // OAuth2 Login
      http
            .oauth2Login(oauth2 -> oauth2
                  .loginPage("/login/form")
                  .authorizationEndpoint(authorization -> authorization.authorizationRequestResolver(pkceResolver))
                  .defaultSuccessUrl("/default", true)
            .userInfoEndpoint(userInfo -> userInfo
                  .userAuthoritiesMapper(grantedAuthoritiesMapper)))
            .logout(logout -> logout
                  .logoutSuccessHandler(oidcLogoutSuccessHandler()));
      return http.build();
    }
}

在此实现中,您需要确保 OIDC 用户角色已经存在于数据库中。

对于我们的JBCP 日历应用程序,让我们定义一个具有管理员角色的用户。

例如:

//src/main/resources/data.sql
insert into calendar_users(id,email,first_name,last_name) values (1,'calendarjbcp@gmail.com','Admin','1');
insert into user_role(user_id,role_id) values (1, 1);

在此阶段,用户calendarjbcp@gmail.com具有管理员角色,并在成功认证后可以访问所有活动页面。

重要提示

您的代码现在应该看起来像chapter09.08-calendar中的那样。

OAuth 2 是否安全?

由于 OAuth 2 的支持依赖于 OAuth 2 提供者的可信度和提供者响应的可验证性,因此为了使应用程序对基于 OAuth 2 的登录用户有信心,安全性和真实性至关重要。

幸运的是,OAuth 2 规范的设计者非常清楚这一担忧,并实施了一系列验证步骤来防止响应伪造、重放攻击和其他类型的篡改,具体解释如下:

  • 响应伪造是通过结合共享密钥(在初始请求之前由启用 OAuth 2 的站点创建)和响应本身上的单向散列消息签名来防止的。恶意用户在没有访问共享密钥和签名算法的情况下篡改响应字段中的数据,将生成无效的响应。

  • 重放攻击是通过包含一个 nonce(一次性使用、随机密钥)来防止的,该密钥应由启用 OAuth 2 的站点记录,以确保它永远不会被重用。这样,即使尝试重新发布响应 URL 的用户也会被挫败,因为接收站点会确定 nonce 已被先前使用,并将使请求无效。

  • 最可能导致用户交互受损的攻击形式可能是中间人攻击,恶意用户可能会拦截用户在计算机和 OAuth 2 提供者之间的交互。在这种假设的攻击情况下,攻击者可能能够记录用户浏览器和 OAuth 2 提供者之间的对话,并记录在请求发起时使用的密钥。在这种情况下,攻击者需要非常高的复杂性和对 OAuth 2 签名规范的完整实现——简而言之,这种情况不太可能经常发生。

摘要

在本章中,我们回顾了 OAuth 2,这是一种相对较新的用户身份验证和凭证管理技术。OAuth 2 在互联网上具有非常广泛的应用范围,在过去一两年的时间里在可用性和接受度方面取得了巨大进步。现代网络上的大多数面向公众的网站都应该计划提供某种形式的 OAuth 2 支持,JBCP 日历应用程序也不例外!

我们学习了以下主题:OAuth 2 认证机制及其高级架构和关键术语。我们还学习了使用JBCP 日历应用程序进行 OAuth 2 登录和自动用户注册。我们还涵盖了 OAuth 2 的自动登录及其登录响应的安全性。

我们介绍了使用Spring Security实现的最简单的单点登录机制之一。其缺点之一是不支持标准的单点退出机制。在下一章中,我们将探讨 SAML,这是一种另一种支持单点退出的标准单点登录协议。

第十章:SAML 2 支持

SAML 主要用作基于网络的认证机制,依赖于浏览器代理来促进认证过程。从广义上讲,SAML 的认证流程可以概述如下。

Spring Security 提供了全面的 SAML 2 支持。本节讨论了如何将 SAML 2 集成到基于 Servlet 的应用程序中。

从 2009 年开始,作为扩展项目的一部分,已经提供了对依赖方的支持。到 2019 年,开始努力将这项支持集成到 Spring Security 的核心中。这反映了 2017 年启动的将 Spring Security 的 OAuth 2.0 支持纳入其中的类似过程。

本章将探讨以下主题:

  • SAML 协议的基本方面

  • 使用 Spring Security 建立您的 SAML 2 登录

  • 获取 SAML 2 认证主体

  • 解析和生成 SAML 2.0 元数据

  • 使用 Spring Security SAML 定制权限

  • 执行单点登出

本章的代码示例链接在此:packt.link/7qRvM

什么是 SAML?

安全断言标记语言SAML)是一个基于 XML 的广泛采用的开放标准,专门用于在联合组织之间安全交换认证和授权AA)信息。它用于简化基于浏览器的单点登录SSO)功能。

SAML 2.0 于 2005 年作为 OASIS 标准建立,并由结构化信息标准推进组织OASIS)持续维护,它结合了 SAML 1.1、Liberty Alliance Identity Federation FrameworkID-FF)1.2 和 Shibboleth 1.3 的元素。

在 SAML 2.0 规范中,三个关键实体承担不同的角色:主体、服务提供者和身份提供者。

以 Sally 访问 ucanbeamillionaire.com 上的投资账户为例。为了让她登录并访问她的账户,该网站使用 SAML 进行认证。

SAML 2.0 已被广泛采用并在各种场景中使用,例如企业应用、云服务和基于网络的认证系统,以建立一个安全和互操作的框架,用于身份和访问管理。

SAML 2.0 的关键组件和概念包括:

  • 服务提供者SP)是提供服务的实体,通常以应用程序的形式存在。

  • 身份提供者IdP)是提供身份的实体,包括验证用户的能力。通常,IdP 还包含用户资料,其中包含额外的信息,如名字、姓氏、工作代码、电话号码、地址等。SP 对用户数据的需求可能有所不同,从基本资料(用户名、电子邮件)到更全面的资料集(工作代码、部门、地址、位置、经理等),具体取决于应用程序。

  • SAML 请求,也称为身份验证请求,由 SP 启动,以正式请求身份验证。

  • IdP 生成一个SAML 响应,其中包含已认证用户的实际断言。此外,SAML 响应可能包含额外信息,例如用户配置文件细节和组/角色信息,这些信息基于 SP 支持的功能。

  • 由 SP 启动的登录是指由 SP 启动的 SAML 登录流程。这通常发生在最终用户尝试访问资源或直接在 SP 端登录时,例如当浏览器试图访问 SP 平台上的受保护资源时。

  • 由 IdP 启动的登录是指由 IdP 启动的 SAML 登录流程。在这种情况下,SAML 流程不是由 SP 的重定向触发的,而是 IdP 启动一个重定向到 SP 的 SAML 响应,以验证用户的身份。

图 10.1 – 探索 SAML 协议

图 10.1 – 探索 SAML 协议

这里有一些关键点需要考虑:

  • SP 与 IdP 之间的直接交互从未发生。所有交互都通过浏览器进行,浏览器作为所有重定向的中介。

  • 在获取用户信息之前,SP 必须知道要重定向到的 IdP。

  • SP 在收到 IdP 的 SAML 断言之前,不知道用户的身份。

  • 此流程的启动不仅限于 SP;IdP 也可以启动一个身份验证流程。

  • SAML 身份验证流程是异步的。SP 不确定 IdP 是否会完成整个流程。因此,SP 不会保留与身份验证请求相关的任何状态。当 SP 从 IdP 收到响应时,它必须包含所有必要的信息。

在介绍了 SAML 协议之后,我们将深入探讨 Spring Security 上下文中 SAML 2.0 登录的功能。

SAML 2.0 使用 Spring Security 登录

SAML 2.0 登录功能使应用程序能够作为 SAML 2.0 依赖方运行。这使用户能够使用他们现有的 SAML 2.0 断言方(如 ADFS、Okta 和其他 IdP)的账户登录到应用程序。

重要提示

SAML 2.0 登录的实现使用了Web 浏览器单点登录(SSO)配置文件,如 SAML 2 配置文件规范所述:groups.oasis-open.org/higherlogic/ws/public/document?document_id=35389#page=15

要开始探索 Spring Security 上下文中的 SAML 2.0 依赖方身份验证,我们观察到 Spring Security 引导用户到第三方进行身份验证。这是通过一系列重定向来实现的:

图 10.2 – 重定向到断言方身份验证

图 10.2 – 重定向到断言方身份验证

让我们深入了解这个 SAML 重定向序列:

  1. 初始时,用户在没有适当授权的情况下向 /private 资源提交未认证的请求。

  2. Spring Security 的 AuthorizationFilter 通过抛出 AccessDeniedException 来指示未认证请求的拒绝。

  3. 由于缺乏授权,ExceptionTranslationFilter 触发认证的开始。配置的 AuthenticationEntryPointLoginUrlAuthenticationEntryPoint 的一个实例,它将重定向到由 Saml2WebSsoAuthenticationRequestFilter 管理的生成 <saml2:AuthnRequest> 的端点。如果配置了多个断言方,它可能首先重定向到一个选择页面。

  4. 随后,Saml2WebSsoAuthenticationRequestFilter 使用其配置的 Saml2AuthenticationRequestFactory 生成、签名、序列化和编码一个 <saml2:AuthnRequest>

  5. 浏览器随后将 <saml2:AuthnRequest> 提交给断言方,启动用户认证过程。在认证成功后,断言方将 <saml2:Response> 返回给浏览器。

  6. 浏览器继续将 <saml2:Response> 通过 POST 方式发送到断言消费者服务端点。

    以下图表说明了 Spring Security 中 <saml2:Response> 的认证过程:

图 10.3 – 认证 saml2:Response

图 10.3 – 认证 saml2:Response

我们可以将交互总结如下:

  1. 当浏览器向应用程序提交 <saml2:Response> 时,该过程由 Saml2WebSsoAuthenticationFilter 处理。此过滤器使用其配置的 AuthenticationConverter 通过从 HttpServletRequest 中提取响应来生成 Saml2AuthenticationToken。此外,转换器解析 RelyingPartyRegistration 并将其提供给 Saml2AuthenticationToken

  2. 随后,过滤器将令牌传递给其配置的 AuthenticationManager,默认为 OpenSamlAuthenticationProvider

  3. 在认证失败的情况下:

    • SecurityContextHolder 被清除。

    • AuthenticationEntryPoint 被调用以重新启动认证过程。

  4. 如果认证成功:

    • Authentication 被设置在 SecurityContextHolder 中。

    • Saml2WebSsoAuthenticationFilter 调用 FilterChain#doFilter(request, response) 以继续剩余的应用程序逻辑。

在引入 SAML 2.0 与 Spring Security 登录之后,我们将通过 OKTA 探索一个实际的 SAML 示例。

在 OKTA 上添加 SAML 应用程序

要开始,你需要一个 OKTA 开发者账户。

  1. 首先,访问 OKTA 开发者网站:developer.okta.com/signup。你将看到以下选项来创建账户:

图 10.4 – OKTA 开发者门户

图 10.4 – OKTA 开发者门户

  1. 选择 访问 Okta 开发者版服务,然后创建您的开发者账户。

图 10.5 – OKTA 开发者账户创建

图 10.5 – OKTA 开发者账户创建

  1. 第二步是使用您的账户登录,然后转到 应用程序 | 创建 应用集成

  2. 选择 SAML 2.0 并点击 下一步

  3. 给您的应用程序起一个名字,例如 JBCP 日历 SAML 并点击 下一步

  4. 使用以下配置:

    • 单点登录 URL:https://localhost:8443/login/saml2/sso/okta

    • 对于接收器 URL 和目标 URL:(默认)

    • 对于受众 URI:https://localhost:8443/saml2/service-provider-metadata/okta

  5. 之后点击 下一步。选择以下选项:我是一个 Okta 客户,添加内部应用程序这是一个我们 创建的内部应用程序

  6. 选择 完成

  7. OKTA 将创建您的应用程序。

  8. 前往 SAML 签名证书 并选择 SHA-2 | 操作 | 查看 IdP 元数据。您可以 右键单击 并复制此菜单项的链接或打开其 URL。

  9. 将生成的链接复制到您的剪贴板。它看起来可能如下所示:https://dev-xxxxx.okta.com/app/<随机字符>/sso/saml/metadata

  10. 前往您应用程序的 分配 选项卡并将访问权限分配给 所有人 组。

在 OKTA 中创建用户主体

让我们先在 OKTA 中创建一个用户主体。

  1. 登录 OKTA 并进入 OKTA 管理员控制台。

  2. 导航到 用户 页面。

  3. 登录后,导航到 管理员 部分。

  4. 从菜单中选择 目录。然后选择 人员 子菜单。

  5. 点击 添加人员 按钮或类似按钮。

  6. 填写用户详细信息:提供新用户所需的信息,例如姓名、姓氏、电子邮件地址和任何其他必填字段。您还可以设置用户名并分配角色或组给用户。

图 10.6 – 使用 OKTA 添加用户

图 10.6 – 使用 OKTA 添加用户

其他必需的依赖项

如果您正在使用 OAuth 2 功能,我们将在您的 build.gradle 文件中包含以下附加依赖项:

//build.gradle
dependencies {
...
    constraints {
        implementation "org.opensaml:opensaml-core:4.2.0"
        implementation "org.opensaml:opensaml-saml-api:4.2.0"
        implementation "org.opensaml:opensaml-saml-impl:4.2.0"
    }
    implementation 'org.springframework.security:spring-security-saml2-service-provider'
...

指定 IdP 元数据

在 Spring Boot 应用程序中,通过创建以下类似的设置来配置 IdP 的元数据:

spring:
  security:
    saml2:
      relyingparty:
        registration:
          okta:
            assertingparty:
              metadata-uri: https://dev-xxxxx.okta.com/app/ <random-characters>/sso/saml/metadata

重要提示

您的代码现在应该看起来像 chapter10.01-calendar 中的那样。

获取 SAML 2 认证主体

一旦为特定的断言方正确配置了依赖方,它就准备好接收断言。在依赖方验证断言后,结果是一个包含 Saml2AuthenticatedPrincipalSaml2Authentication。因此,您可以通过 SpringSecurityUserContext 访问主体,如下所示:

//src/main/java/com/packtpub/springsecurity/service/ SpringSecurityUserContext.java
@Component
public class SpringSecurityUserContext implements UserContext {
    private static final Logger logger = LoggerFactory
          .getLogger(SpringSecurityUserContext.class);
    private final CalendarService calendarService;
    public SpringSecurityUserContext(final CalendarService calendarService) {
       if (calendarService == null) {
          throw new IllegalArgumentException("calendarService cannot be null");
       }
       this.calendarService = calendarService;
    }
    @Override
    public CalendarUser getCurrentUser() {
       SecurityContext context = SecurityContextHolder.getContext();
       Authentication authentication = context.getAuthentication();
       if (authentication == null) {
          return null;
       }
       if(authentication.getPrincipal() instanceof DefaultSaml2AuthenticatedPrincipal saml2AuthenticatedPrincipal ) {
          String email = saml2AuthenticatedPrincipal.getName();
          CalendarUser result = calendarService.findUserByEmail(email);
          if (result == null) {
             throw new IllegalStateException(
                   "Spring Security is not in synch with CalendarUsers. Could not find user with email " + email);
          }
          logger.info("CalendarUser: {}", result);
          return result;
       }
       return null;
    }
}

重要提示

您的代码现在应该看起来像 chapter10.02-calendar 中的那样。

Spring Security 可以解析断言方元数据以生成 AssertingPartyDetails 实例,并从 RelyingPartyRegistration 实例发布依赖方元数据。

解析 SAML 2 元数据

通过利用 RelyingPartyRegistrations,可以解析断言方的元数据。如果你使用 OpenSAML 供应商支持,结果 AssertingPartyDetails 将以 OpenSamlAssertingPartyDetails 的形式存在。因此,你可以通过以下步骤访问底层的 OpenSAML XMLObject:

OpenSamlAssertingPartyDetails details = (OpenSamlAssertingPartyDetails)
       registration.getAssertingPartyDetails();
EntityDescriptor openSamlEntityDescriptor = details.getEntityDescriptor();

生成 SAML 2 元数据

你可以使用 saml2Metadata DSL 方法公开元数据端点,如下所示:

http
       // ...
       .saml2Login(withDefaults())
       .saml2Metadata(withDefaults());

利用元数据端点将依赖方注册到断言方。这通常涉及识别适当的表单字段以提供元数据端点。

默认元数据端点是 /saml2/metadata。它还响应于 /saml2/metadata/{registrationId}/saml2/service-provider-metadata/{registrationId}

你可以通过在 DSL 中调用 metadataUrl 方法来适配它:

.saml2Metadata((saml2) -> saml2.metadataUrl("/saml/metadata"))

适配 RelyingPartyRegistration 查找

要配置自己的 Saml2MetadataResponseResolver,你应该使用以下描述的 RelyingPartyRegistration

@Bean
Saml2MetadataResponseResolver metadataResponseResolver(RelyingPartyRegistrationRepository registrations) {
    RequestMatcherMetadataResponseResolver metadata = new RequestMatcherMetadataResponseResolver(
          (id) -> registrations.findByRegistrationId("relying-party"), new OpenSamlMetadataResolver());
    metadata.setMetadataFilename("metadata.xml");
    return metadata;
}

现在我们已经探讨了使用 Spring Security 的 SAML 2.0 登录功能,我们将继续使用自定义的 SAML Spring Boot 自动配置。

覆盖 SAML Spring Boot 自动配置

Spring Boot 为依赖方生成两个 @Bean 对象。

第一个是配置应用程序作为依赖方的 SecurityFilterChain。当包含 spring-security-saml2-service-provider 时,SecurityFilterChain 看起来如下:

你会注意到每个经过身份验证的用户默认都有一个 ROLE_USER 角色。

//src/main/java/com/packtpub/springsecurity/service/ SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
       http.authorizeHttpRequests( authz -> authz
                   .requestMatchers("/webjars/**").permitAll()
                   .requestMatchers("/css/**").permitAll()
                   .requestMatchers("/favicon.ico").permitAll()
                   // H2 console:
                   .requestMatchers("/admin/h2/**").fullyAuthenticated()
                   .requestMatchers("/").permitAll()
                   .requestMatchers("/login/*").permitAll()
                   .requestMatchers("/logout").permitAll()
                   .requestMatchers("/signup/*").permitAll()
                   .requestMatchers("/errors/**").permitAll()
                   .requestMatchers("/events/").hasRole("ADMIN")
                   .requestMatchers("/**").hasRole("USER"))
             .exceptionHandling(exceptions -> exceptions
                   .accessDeniedPage("/errors/403"))
             .logout(form -> form
                   .logoutUrl("/logout")
                   .logoutSuccessUrl("/")
                   .permitAll())
             // CSRF is enabled by default, with Java Config
             .csrf(AbstractHttpConfigurer::disable);
       // @formatter:off
       http
             .saml2Login(withDefaults());
       // For H2 Console
       http.headers(headers -> headers.frameOptions(FrameOptionsConfig::disable));
       return http.build();
    }
...ommited for breviy
}

要测试应用程序,请打开网页浏览器并导航到:https://localhost:8443

接下来,转到 /events 页面。你应该会得到一个 访问 被拒绝 错误。

重要提示

你的代码现在应该看起来像 chapter10.03-calendar 中的那样。

创建自定义 RelyingPartyRegistrationRepository

Spring Boot 创建了一个 RelyingPartyRegistrationRepository,它代表断言方和依赖方元数据。这包括诸如依赖方在请求断言方进行身份验证时应使用的 SSO 端点位置等信息。

你可以通过发布自己的 RelyingPartyRegistrationRepository bean 来覆盖默认设置。你也可以通过编程方式删除现有的 spring.security.saml2.relyingparty.registration 配置属性。

例如,你可以通过访问其元数据端点来查找断言方的配置:

//src/main/java/com/packtpub/springsecurity/service/ SecurityConfig.java
@Value("${metadata.location}")
private String assertingPartyMetadataLocation;
@Bean
public RelyingPartyRegistrationRepository relyingPartyRegistrations() {
    RelyingPartyRegistration registration = RelyingPartyRegistrations
          .fromMetadataLocation(assertingPartyMetadataLocation)
          .registrationId("okta")
          .build();
    return new InMemoryRelyingPartyRegistrationRepository(registration);
}

或者,你可以直接通过 DSL 连接仓库,这也会覆盖自动配置的 SecurityFilterChain

//src/main/java/com/packtpub/springsecurity/service/ SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
... omitted for brevity
       http
             .saml2Login(saml2 -> saml2
                   .relyingPartyRegistrationRepository(relyingPartyRegistrations())
             );
       return http.build();
    }
}

重要提示

registrationId 是用户定义的值,用于区分不同的注册。

你的代码现在应该看起来像 chapter10.04-calendar 中的那样。

使用 Spring Security SAML 创建自定义权限

登录后,你可能观察到显示的页面指示一个 ROLE_USER 权限。

尽管最初授予所有用户访问权限,但您可以配置您的 SAML 应用程序以将用户的组作为属性传输。此外,您还可以选择包括其他属性,如姓名和电子邮件。

  1. 首先选择编辑您的 OKTA 应用程序 SAML 设置部分。

  2. 完成组属性声明部分。

    • 名称:groups

    • 过滤器:Matches regex 并使用 .* 作为值

    • 名称格式:Unspecified

  3. 您可以添加其他属性。例如:

图 10.7 – OKTA 中的附加自定义用户属性

图 10.7 – OKTA 中的附加自定义用户属性

  1. 前往 ROLE_ADMIN

图 10.8 – 在 OKTA 中定义自定义组

图 10.8 – 在 OKTA 中定义自定义组

  1. 然后将用户 admin1@example.com 分配到该组。

图 10.9 – 在 OKTA 中分配用户到组

图 10.9 – 在 OKTA 中分配用户到组

  1. 修改 SecurityConfig.java 类以覆盖默认配置。然后,使用转换器将 groups 属性中的值映射到 Spring Security 权限。
//src/main/java/com/packtpub/springsecurity/service/ SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
       http.authorizeRequests( authz -> authz
         ... omitted for brevity
                   .requestMatchers(antMatcher("/errors/**")).permitAll()
                   .requestMatchers(antMatcher("/events/")).hasRole("ADMIN")
                   .requestMatchers(antMatcher("/**")).hasAuthority("Everyone"))
             .exceptionHandling(exceptions -> exceptions
                   .accessDeniedPage("/errors/403"))
             .logout(form -> form
                   .logoutUrl("/logout")
                  .logoutSuccessUrl("/")
                   .permitAll())
             .csrf(AbstractHttpConfigurer::disable);
       OpenSaml4AuthenticationProvider authenticationProvider = new OpenSaml4AuthenticationProvider();
       authenticationProvider.setResponseAuthenticationConverter(groupsConverter());
       // @formatter:off
       http
             .saml2Login(saml2 -> saml2
                   .authenticationManager(new ProviderManager(authenticationProvider)))
             .saml2Logout(withDefaults());
       // For H2 Console
       http.headers(headers -> headers.frameOptions(FrameOptionsConfig::disable));
       return http.build();
    }
    private Converter<OpenSaml4AuthenticationProvider.ResponseToken, Saml2Authentication> groupsConverter() {
       Converter<ResponseToken, Saml2Authentication> delegate =
             OpenSaml4AuthenticationProvider.createDefaultResponseAuthenticationConverter();
       return (responseToken) -> {
          Saml2Authentication authentication = delegate.convert(responseToken);
          Saml2AuthenticatedPrincipal principal = (Saml2AuthenticatedPrincipal) authentication.getPrincipal();
          List<String> groups = principal.getAttribute("groups");
          Set<GrantedAuthority> authorities = new HashSet<>();
          if (groups != null) {
             groups.stream().map(SimpleGrantedAuthority::new).forEach(authorities::add);
          } else {
             authorities.addAll(authentication.getAuthorities());
          }
          return new Saml2Authentication(principal, authentication.getSaml2Response(), authorities);
       };
    }
}
  1. 现在,您应该能看到您的用户组作为权限。这来自与认证用户相关的 OKTA SAML 上下文。

    您会注意到,通过这些更改,您现在可以访问 admin1@example.com

重要提示

您的代码现在应该看起来像 chapter10.05 中的那样。

执行单点登出

Spring Security 的 SAML 支持包括一个需要一些配置的登出功能。

您可以使用 OpenSSL 创建私钥和证书。确保在过程中至少提供一个问题的值,设置应该会成功。

openssl req -newkey rsa:2048 -nodes -keyout rp-private.key -x509 -days 365 -out rp-certificate.crt

将生成的文件复制到您的应用程序的 src/main/resources/credentials 目录。

application.yml 中配置,生成的密钥、证书位置和 IdP 的登出配置类似于以下内容:

spring:
  security:
    saml2:
      relyingparty:
        registration:
          okta:
            signing:
              credentials:
                - private-key-location: classpath:credentials/rp-private.key
                  certificate-location: classpath:credentials/rp-certificate.crt
            assertingparty:
              metadata-uri: https://dev-xxxxx.okta.com/app/ <random-characters>/sso/saml/metadata
            singlelogout:
              binding: POST
              url: "{baseUrl}/logout/saml2/slo"

在 OKTA 配置页面上:

  1. 打开 OKTA 管理控制台。

  2. 选择 应用程序 | 应用程序

  3. 前往 https://localhost:8443/logout/saml2/slo

  4. 设置 https://localhost:8443/saml2/service-provider-metadata/okta

  5. 点击您在之前步骤中创建的 local.crt 文件,然后点击 上传证书

  • 点击 下一步图 10.10 – 与 OKTA 的单点登出配置

图 10.10 – 与 OKTA 的单点登出配置

  1. 点击 完成

  2. 重新启动 Spring Boot 应用程序。您现在也可以从 OKTA 登出。

重要提示

由于 SAML 2.0 规范允许每个属性有多个值,您可以选择使用 getAttribute 来检索属性列表,或者使用 getFirstAttribute 来获取列表中的第一个值。当已知只有一个值时,getFirstAttribute 方法特别有用。

您的代码现在应该看起来像 chapter10.06-calendar 中的那样。

摘要

本章深入探讨了SAML领域,这是现代身份管理中实现 SSO 的强大标准。从介绍 SAML 的基础原则开始,它逐步过渡到实际实施,引导开发者在 Spring Security 框架中无缝集成SAML 2登录。

关键亮点包括在广泛使用的 IdP OKTA 上添加SAML应用程序的实用步骤,以及在 OKTA 中创建用户主体以简化用户管理。概述了成功SAML集成所必需的关键依赖项,强调了构建弹性认证系统所需的关键工具和库。

您已经了解了关键配置步骤的见解,例如指定 IdP 元数据以确保标准化和安全的通信通道。本章探讨了检索SAML 2认证主体和解析SAML 2元数据,以及生成SAML 2元数据,从而全面理解涉及的技术复杂性。

RelyingPartyRegistration中具有灵活性,可以通过覆盖RelyingPartyRegistrationRepository进行高级定制。本章提供了在 Spring Security SAML 中自定义权限的实用指南,以实现用户角色的有效管理。

本章通过解决单点登出的关键方面结束,展示了 SAML 如何支持跨各种服务的标准化用户登出机制。本质上,本章为您提供了实施基于 SAML 的认证所需的知识和实践见解,有助于在您的应用程序中实现安全且无缝的身份管理体验。

在下一章中,我们将学习更多关于 Spring Security 授权的内容。

第四部分:增强授权机制

本部分深入探讨了细粒度访问控制,探讨了实现精确授权的各种方法,这可能影响应用程序页面的特定部分。最初,我们检查了两种实现细粒度授权的方法。随后,我们探讨了 Spring Security 通过方法注解来保护业务层的方法,利用基于接口的代理来实现面向切面编程AOP)。此外,我们还研究了基于注解的安全在数据集合基于角色过滤方面的能力。最后,我们比较了基于类的代理与基于接口的代理。

在本节中,我们深入探讨了复杂的主题——访问控制列表ACLs),提供了它们在域对象实例级授权方面的潜力概述。Spring Security 提供了一个强大但复杂的 ACL 模块,有效地满足了从小型到中型实施的需求。

此外,我们还承担了为 Spring Security 的核心授权 API 定制实现的任务。这种动手实践的方法有助于更深入地理解 Spring Security 的授权架构。

本部分包含以下章节:

  • 第十一章, 细粒度访问控制

  • 第十二章, 访问控制列表

  • 第十三章, 自定义授权

第十一章:精细粒度访问控制

在本章中,我们将首先探讨两种实现细粒度授权的方法——可能影响应用程序页面部分的授权。接下来,我们将探讨 Spring Security 通过方法注解和使用基于接口的代理来实现业务层安全的方法。然后,我们将回顾基于注解的安全性的一个有趣功能,该功能允许对数据集合进行基于角色的过滤。最后,我们将探讨基于类的代理与基于接口的代理之间的区别。

在本章中,我们将涵盖以下主题:

  • 配置和实验不同的方法,在用户请求的安全上下文中对内容执行页面级授权检查

  • 执行配置和代码注解,使调用者预授权成为我们应用程序业务层安全的关键部分

  • 实现方法级安全性的几种替代方法,并审查每种类型的优缺点

  • 使用方法级注解在集合和数组上实现基于数据的过滤器

  • 在我们的 Spring MVC 控制器上实现方法级安全以避免配置 requestMatchers() 方法和 <intercept-url> 元素

本章的代码示例链接在此:packt.link/Mxijd

集成 Spring 表达式语言 (SpEL)

Spring Security 利用 requestMatchers() 方法:

.requestMatchers("/events/").hasRole("ADMIN")

Spring Security 提供了一个 o.s.s.access.expression.SecurityExpressionRoot 对象,该对象提供了可用于访问控制决策的方法和对象。例如,可用的方法之一是 hasRole 方法,它接受一个字符串。这对应于访问属性(在先前的代码片段中)。实际上,还有许多其他表达式可用,如下表所示:

表达式 描述
hasRole(String role)hasAuthority(String role) 如果当前用户具有指定的权限,则返回 true
hasAnyRole(String... role) hasAnyAuthority(String... authority) 返回 true 如果当前用户具有指定的任何权限。
authentication SecurityContextHolder 类的 getContext() 方法返回的 SecurityContext 接口获取当前的 Authentication 对象。
permitAll 此请求不需要授权,并作为公共端点。重要的是要明确,在这种情况下,Authentication 永远不会从会话中检索。
denyAll 在任何情况下都不允许请求;重要的是要强调,在这种情况下,Authentication 永远不会从会话中检索。
isAnonymous() 如果当前主体是匿名(未认证),则返回 true
isRememberMe() 如果当前主体是通过记住我功能进行认证的,则返回true
isAuthenticated() 如果用户不是匿名用户(即,他们已认证),则返回true
isFullyAuthenticated() 如果用户是通过除“记住我”之外的方式认证的,则返回true
hasPermission(Object target, Object permission) 如果用户具有访问指定对象的给定权限,则返回true
hasPermission(String targetId, String targetType, Object permission) 如果用户具有访问给定类型和权限的指定标识符的权限,则返回true

表 11.1 – 授权规则摘要

我们在下面的代码片段中提供了一些使用这些 SpEL 表达式的示例。请注意,我们将在本章和下一章中详细介绍:

// allow users with ROLE_ADMIN hasRole('ADMIN')
// allow users that do not have the ROLE_ADMIN
!hasRole('ADMIN')
// allow users that have ROLE_ADMIN or ROLE_ROOT and
// did not use the remember me feature to login
isFullyAuthenticated() and hasAnyRole('ADMIN','ROOT')
// allow if Authentication.getName() equals admin authentication.name == 'admin'

开始启动 JBCP 日历应用程序。访问https://localhost:8443,使用用户user1@example.com和密码user1进行登录。您将观察到“我的事件”导航菜单项被显示,同样“所有事件”导航菜单项也被显示。

重要提示

您应该从chapter11.00-calendar的代码开始。

WebSecurityExpressionRoot 类

o.s.s.web.access.expression.WebSecurityExpressionRoot类为我们提供了一些额外的属性。这些属性,连同之前提到的标准属性,都可在requestMatchers()方法的访问属性和<sec:authorize>标签的JSP/Thymeleaf 访问属性中使用,正如我们很快将要讨论的:

表达式 描述
request 当前的HttpServletRequest方法。
hasIpAddress(String... ipAddress) 如果当前 IP 地址与ipAddress值匹配,则返回true。这可以是精确的 IP 地址或 IP 地址/网络掩码。

表 11.2 – 使用 WebSecurityExpressionRoot 的 hasIpAddress 用法

MethodSecurityExpressionRoot 类

方法 SpEL 表达式也提供了一些额外的属性,可以通过o.s.s.access.expression.method.MethodSecurityExpressionRoot类使用:

表达式 描述
target 指的是这个或当前被保护的对象。
returnObject 指的是由注解方法返回的对象。
filterObject 可以与@PreFilter@PostFilter一起用于集合或数组,以仅包括匹配表达式的元素。filterObject对象代表集合或数组的循环变量。
#<methodArg> 可以通过在参数名称前加#来引用方法的一个参数。例如,名为id的方法参数可以使用#id来引用。

表 11.3 – MethodSecurityExpressionRoot 属性

如果这些表达式的描述看起来有点简短,请不要担心;我们将在本章后面通过一些示例来详细讲解。

我们希望您已经对 Spring Security 的 SpEL 支持能力有了相当的了解。要了解更多关于 SpEL 的信息,请参阅 Spring 参考文档:docs.spring.io/spring-framework/reference/core/expressions.xhtml

页面级授权

页面级授权指的是根据特定用户请求的上下文来提供应用程序功能。与我们在第二章“Spring Security 入门”中探讨的粗粒度授权不同,细粒度授权通常指的是页面部分的选择性可用性,而不是完全限制对页面的访问。大多数实际应用都会在细粒度授权规划细节上花费相当多的时间。

Spring Security 为我们提供了以下三种选择性显示功能的方法:

  • Spring Security JSP 标签库允许在页面声明本身内放置条件访问声明,使用标准的 JSP 标签库语法。

  • Thymeleaf Spring Security 标签库允许在页面声明本身内放置条件访问声明,使用标准的 Thymeleaf 标签库语法。

  • 在 MVC 应用程序的控制器层检查用户授权允许控制器做出访问决策并将决策结果绑定到提供给视图的模型数据。这种方法依赖于标准的 JSTL 条件页面渲染和数据绑定,比 Spring Security 标签库稍微复杂一些;然而,它更符合标准 Web 应用程序 MVC 逻辑设计。

在为 Web 应用程序开发细粒度授权模型时,这些方法中的任何一种都是完全有效的。让我们通过一个 JBCP 日历用例来探讨每种方法是如何实现的。

使用 Thymeleaf Spring Security 标签库进行条件渲染

在 Thymeleaf Spring Security 标签库中最常用的功能是基于授权规则条件性地渲染页面部分。这是通过使用<sec:authorize*>标签来实现的,该标签的功能类似于核心 JSTL 库中的<if>标签,即标签体将根据标签属性中提供的条件进行渲染。我们已经看到了 Spring Security 标签库如何被用来限制未登录用户的查看内容的简要演示。

基于 URL 访问规则的条件渲染

Spring Security 标签库提供了基于已在安全配置文件中定义的现有 URL 授权规则渲染内容的功能。这是通过使用authorizeHttpRequests()方法来实现的。

如果有多个 HTTP 元素,authorizeHttpRequests()方法将使用当前匹配的 HTTP 元素的规则。

例如,我们可以确保All Events导航菜单项仅在适当的情况下显示,即对于管理员用户——回想一下我们之前定义的访问规则如下:

.requestMatchers("/events/").hasRole("ADMIN")

更新header.xhtml文件以利用此信息并条件渲染到All Events页面链接:

//src/main/resources/templates/fragments/header.xhtml
<!DOCTYPE html>
<html 
      >
...
<li sec:authorize-url="/events/">
    <a id="navEventsLink" th:href="@{/events/}">All Events</a></li>

这将确保除非用户有足够的权限访问指定的 URL,否则标签的内容不会显示。可以通过在 URL 之前包含方法属性来进一步限定授权检查,如下所示:

<li sec:authorize-url="/events/">
    <a id="navEventsLink" th:href="@{/events/}">All Events</a></li>

使用authorize-url属性在代码块上定义授权检查是方便的,因为它将实际授权检查的知识从你的页面中抽象出来,并将其保留在安全配置文件中。

注意,HTTP方法应该与你的安全requestMatchers()方法中指定的案例匹配,否则它们可能不会如你所期望地匹配。此外,请注意,URL 应始终相对于 Web 应用程序上下文根(正如你的 URL 访问规则一样)。

对于许多用途,使用<sec>标签的authorize-url属性足以在用户被允许查看时正确显示与链接或操作相关的内容。记住,标签不仅需要围绕一个链接;如果用户没有提交权限,它甚至可以围绕整个表单。

使用 SpEL 进行条件渲染

当使用<sec>标签与 SpEL 表达式结合时,还有一个更灵活的方法来控制 JSP 内容的显示。让我们回顾一下我们在第二章中学到的内容,“Spring Security 入门”。我们可以通过更改header.xhtml文件来隐藏My Events链接,如下所示:

//src/main/resources/templates/fragments/header.xhtml
<li sec:authorize="isAuthenticated()">
    <a id="navMyEventsLink" th:href="@{/events/my}">My Events</a></li>

SpEL 评估是由与requestMatchers()方法访问声明规则中使用的表达式背后的相同代码执行的(假设表达式已经配置)。因此,从使用<sec>标签构建的表达式中可以访问相同的内置函数和属性。

这两种利用<sec>标签的方法都提供了基于安全授权规则的强大、细粒度控制,以显示页面内容。

继续启动 JBCP 日历应用程序。访问https://localhost:8443并使用用户user1@example.com和密码user1登录。你会观察到admin1@example.com用户名和密码admin1。现在两个链接都是可见的。

重要提示

你应该从chapter11.01-calendar中的代码开始。

使用控制器逻辑进行条件渲染内容

在本节中,我们将展示如何使用基于 Java 的代码来确定是否应该渲染某些内容。我们可以选择只显示user。这将隐藏欢迎页面上未登录为管理员的用户的创建事件链接。

本章示例代码中的欢迎控制器已被更新,以将一个名为showCreateLink的属性填充到模型中,该属性来源于方法名,如下所示:

//src/main/java/com/packtpub/springsecurity/web/controllers/WelcomeControll er.java
@ModelAttribute("showCreateLink")
public boolean showCreateLink(Authentication authentication) {
    // NOTE We could also get the Authentication from SecurityContextHolder.getContext().getAuthentication()
    return authentication != null && authentication.getName().contains("user");
}

你可能会注意到 Spring MVC 可以自动为我们获取Authentication对象。这是因为 Spring Security 将我们的当前Authentication对象映射到HttpServletRequest.getPrincipal()方法。由于 Spring MVC 会自动将任何java.security.Principal类型的对象解析为HttpServletRequest.getPrincipal()的值,因此将Authentication作为我们控制器的参数是一种访问当前Authentication对象的简单方法。我们也可以通过指定Principal类型的参数来解耦代码与 Spring Security。然而,在这个场景中,我们选择了Authentication来帮助展示一切是如何连接起来的。

如果我们在另一个不知道如何做到这一点的框架中工作,我们可以使用SecurityContextHolder类来获取Authentication对象,就像我们在第三章中做的那样,自定义认证。此外,请注意,如果我们不使用 Spring MVC,我们只需直接设置HttpServletRequest属性,而不是在模型中填充它。我们填充在请求上的属性将像使用带有 Spring MVC 的ModelAndView对象时一样,对我们的 JSP 可用。

接下来,我们需要在index.xhtml文件中使用HttpServletRequest属性来确定是否应该显示创建事件链接。按照以下方式更新index.xhtml

//src/main/resources/templates/fragments/header.xhtml
<li th:if="${showCreateLink}" class="nav-item"><a class="nav-link" id="navCreateEventLink"
                        th:href="@{/events/form}">Create Event</a>

现在,启动应用程序,使用admin1@example.com作为用户名和admin1作为密码登录,并访问所有活动页面。你将不再看到创建活动导航菜单项(尽管它仍然会出现在页面上)。

重要提示

你应该从chapter11.02-calendar的代码开始。

WebInvocationPrivilegeEvaluator

有时候,应用程序可能不会使用 JSP 编写,并且需要能够根据 URL 确定访问权限,就像我们使用<... sec:authorize-url="/events/">时做的那样。这可以通过使用o.s.s.web.access.WebInvocationPrivilegeEvaluator接口来完成,这是支持 JSP 标签库的相同接口。

在下面的代码片段中,我们通过在模型中添加一个名为showAdminLink的属性来展示如何使用WebInvocationPrivilegeEvaluator。我们能够通过使用@Autowired注解来获取WebInvocationPrivilegeEvaluator

//src/main/java/com/packtpub/springsecurity/web/controllers/WelcomeControll er.java
<li th:if="${showAdminLink}" class="nav-item"><a class="nav-link" id="navH2Link"
                        target="_blank"
                        th:href="@{/admin/h2}">H2</a></li>

如果你使用的框架不是由 Spring 管理的,@Autowire 将无法为你提供 WebInvocationPrivilegeEvaluator。相反,你可以使用 Spring 的 org.springframework.web.context.WebApplicationContextUtils 接口来获取 WebInvocationPrivilegeEvaluator 的实例,如下所示:

ApplicationContext context = WebApplicationContextUtils
       .getRequiredWebApplicationContext(servletContext);
WebInvocationPrivilegeEvaluator privEvaluator = context.getBean(WebInvocationPrivilegeEvaluator.class);

要尝试一下,请更新 index.xhtml 以使用 showAdminLink 请求属性,如下所示:

//src/main/resources/templates/index.xhtml
<li th:if="${showAdminLink}">
    <a class="link-warning" id="h2Link" target="_blank" th:href="@{admin/h2/}">H2
...
</li>

重新启动应用程序并查看 admin1@example.com/admin1,你应该能看到它。

重要提示

你应该从 chapter11.03-calendar 的代码开始。

最佳的页面内授权配置方式是什么?

在许多情况下,使用标签的 authorize-url 属性可以适当地将代码与授权规则的变化隔离开来。以下是在以下场景下应使用标签的 authorize-url 属性:

  • 该标签阻止了可以通过单个 URL 清晰识别的显示功能。

  • 标签的内容可以明确地隔离到单个 URL。

    不幸的是,在典型的应用程序中,你频繁使用标签的 authorize-url 属性的可能性相对较低。现实是,应用程序通常比这更复杂,在决定渲染页面的一部分时需要更复杂的逻辑。

使用 Thymeleaf Spring Security 标签库根据其他方法中的安全标准声明渲染页面的部分为受限内容,这很有诱惑力。然而,有多个原因(在许多情况下)这并不是一个好主意,如下所述:

  • 标签库不支持超出角色成员资格的复杂条件。例如,如果我们的应用程序在 UserDetails 实现中包含了自定义属性、IP 过滤器、地理位置等,标准 <sec> 标签将不支持这些。

  • 然而,这些可能可以通过自定义标签或使用 SpEL 表达式来支持。即使在这种情况下,页面更有可能直接与业务逻辑相关,而不是通常鼓励的方式。

  • <sec> 标签必须在它被使用的每一页上引用。这可能导致旨在通用的规则集在不同物理页面上出现潜在的不一致性。一个良好的面向对象系统设计会建议条件规则评估只位于一个地方,并且从它们应该应用的地方进行逻辑引用。

  • 有可能(我们使用我们的公共页眉页面来说明这一点)封装和重用页面的一部分以减少此类问题的发生,但在复杂的应用程序中几乎不可能消除。

  • 在编译时验证规则的正确性是没有办法的。虽然编译时常量可以在典型的基于 Java 的面向对象系统中使用,但标签库(在典型使用中)需要硬编码的角色名称,而简单的打字错误可能一段时间内不会被察觉。

  • 公平地说,这样的错误可以通过对运行中的应用程序进行全面的函数测试来轻松捕捉,但使用标准的 Java 组件单元测试技术进行测试要容易得多。

  • 我们可以看到,尽管基于模板的方法进行条件内容渲染很方便,但也有一些显著的缺点。

所有这些问题都可以通过在控制器中使用代码来解决,这些代码可以用来将数据推送到应用程序视图模型。此外,在代码中执行高级授权判断可以带来复用、编译时检查以及模型、视图和控制器适当逻辑分离的好处。

方法级安全

到目前为止,本书的主要重点是保护 JBCP 日历应用程序的 Web 面向部分;然而,在实际规划安全系统时,我们应同样关注保护允许用户访问任何系统最关键部分——其数据的服务方法。

为什么我们要分层进行安全防护?

让我们花一分钟时间看看为什么即使我们已经保护了我们的 URL,保护我们的方法仍然很重要。

  1. 启动 JBCP 日历应用程序。使用 user1@example.com 作为用户名和 user1 作为密码进行登录。

  2. 访问 https://localhost:8443/events/。您将看到自定义的访问被拒绝页面。

  3. 现在,在浏览器中的 URL 末尾添加 backdoor,这样 URL 现在是 https://localhost:8443/events/backdoor。您现在将看到一个与所有活动页面相同数据的响应。这些数据只应该对管理员可见,但我们通过找到一个未正确配置的 URL 而绕过了它。

  4. 我们还可以查看我们未拥有且未被邀请参加的活动详情。将 backdoor 替换为 102,这样 URL 现在是 https://localhost:8443/events/102:您现在将看到一个未列在您的我的活动页面上的休假活动。这不应该对我们可见,因为我们不是管理员,这不是我们的活动。

如您所见,我们的 URL 规则并不足以完全保护我们的应用程序。这些漏洞甚至不需要利用更复杂的问题,例如容器处理 URL 规范化的差异。简而言之,通常有绕过基于 URL 的安全性的方法。让我们看看在业务层添加安全层如何帮助我们解决新的安全漏洞。

保护业务层

Spring Security 有能力为应用程序中任何 Spring 管理的 bean 的调用添加一层授权(或基于授权的数据修剪)。虽然许多开发者关注 Web 层安全,但业务层安全同样重要,因为恶意用户可能能够渗透您的 Web 层安全或通过非 UI 前端(如 Web 服务)访问公开的服务。

让我们检查以下逻辑图,看看为什么我们对应用二级安全层感兴趣:

图 11.1 – 逻辑应用层

图 11.1 – 逻辑应用层

Spring Security 有以下两种主要技术用于保护方法:

  • GrantedAuthority,例如ROLE_ADMIN。未能满足声明的约束意味着方法调用将失败。

  • 后授权:这项技术确保在方法返回后调用者仍然满足声明的约束。这很少使用,但可以为一些复杂且相互关联的业务层方法提供额外的安全层。

预授权和后授权技术为通常在经典面向对象设计中称为预条件和后条件的概念提供了正式支持。预条件和后条件允许开发者通过运行时检查声明,方法执行周围的一定约束必须始终为真。在安全预授权和后授权的情况下,业务层开发者通过将预期的运行时条件作为接口或类 API 声明的一部分来编码,有意识地决定特定方法的网络安全配置。正如你可能想象的那样,这需要大量的前瞻性思考,以避免意外的后果!

添加@PreAuthorize 方法注解

我们的第一项设计决策将是通过确保用户在允许访问getEvents()方法之前必须以ADMIN用户身份登录,来增强业务层的方法安全性。这是通过在服务接口定义中添加到方法的简单注解来实现的,如下所示:

import org.springframework.security.access.prepost.PreAuthorize;
...
public interface CalendarService {
...
    @PreAuthorize("hasRole('ADMIN')")
    List<Event> getEvents();
}

这就足以确保调用我们的getEvents()方法的任何人都必须是管理员。Spring Security 将使用运行时 AOP 切入点来在方法上执行BeforeAdvice,如果安全约束不满足,则抛出o.s.s.access.AccessDeniedException异常。

指示 Spring Security 使用方法注解

我们还需要对SecurityConfig.java进行一次性修改,其中包含我们剩余的 Spring Security 配置。只需将以下注解添加到类声明中:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityC onfig.java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

在下一节中,让我们测试方法安全性验证。

验证方法安全性

你不相信这会那么简单吗?使用用户名user1@example.com和密码user1登录,并尝试访问https://localhost:8443/events/backdoor。你现在应该看到访问被拒绝页面。

重要注意事项

你应该从chapter11.04-calendar的代码开始。

如果你查看Tomcat 控制台,你会看到一个非常长的堆栈跟踪,从以下输出开始:

org.springframework.security.access.AccessDeniedException: Access Denied
 at org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor.attemptAuthorization
 at org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor.invoke
 at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed
 at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed
 at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept
 at com.packtpub.springsecurity.service.DefaultCalendarService$$SpringCGLIB$$0.getEvents
 at com.packtpub.springsecurity.web.controllers.EventsController.events

根据getEvents方法的调用,我们可以看到用户由于缺少GrantedAuthorityROLE_ADMIN权限,被适当地拒绝访问业务方法。如果你使用用户名admin1@example.com和密码admin1运行相同的操作,你会发现访问将被允许。

难道不是很神奇,仅仅在我们的接口中声明一下,我们就能确保相关方法是安全的?但是 AOP 是如何工作的呢?

基于接口的代理

在上一节给出的示例中,Spring Security 使用基于接口的代理来保护我们的getEvents方法。让我们看看简化后的伪代码,以了解它是如何工作的:

DefaultCalendarService originalService = context.getBean (CalendarService.class)
CalendarService secureService = new CalendarService() {
//… other methods just delegate to originalService ...
    public List<Event> getEvents() {
       if(!permitted(originalService.getEvents)) {
          throw AccessDeniedException()
       }
       return originalCalendarService.getEvents()
    }
};

您可以看到,Spring 创建原始的CalendarService就像它通常做的那样。然而,它指示我们的代码使用另一个CalendarService的实现,在返回原始方法的结果之前执行安全检查。由于 Spring 使用 Java 的java.lang.reflect.Proxy API 动态创建接口的新实现,因此无需了解我们的接口即可创建安全实现。

注意,返回的对象不再是DefaultCalendarService的实例,因为它是一个新的CalendarService实现,即它是CalendarService的匿名实现。这意味着我们必须针对接口编程,才能使用安全实现,否则将发生ClassCastException异常。

要了解更多关于 Spring AOP 的信息,请参阅 Spring 参考文档中的docs.spring.io/spring-framework/reference/core/aop/proxying.xhtml#aop-understanding-aop-proxies

除了@PreAuthorize注解之外,还有几种方法可以在方法上声明安全预授权要求。我们可以检查这些不同的方法保护方式,然后评估它们在不同情况下的优缺点。

JSR-250 兼容的标准化规则

JSR-250 通用注解为 Java 平台定义了一系列注解,其中一些与安全相关,旨在在 JSR-250 兼容的运行环境中可移植。Spring 框架在 Spring 2.x 版本中成为 JSR-250 的合规者,包括 Spring Security 框架。

Gradle 依赖项

根据您决定使用的功能,可能需要几个可选依赖项,例如,为了启用对 JSR 250 @RolesAllowed注解的支持。其中许多依赖项已被注释,因为 Spring Boot 已经在启动器父项目中包含了它们。

您会发现我们的build.gradle文件已经包含了上述依赖项(间接包含):

//build.gradle
// Required for JSR-250 based security:
// JSR-250 Annotations
implementation 'jakarta.annotation:jakarta.annotation-api:2.1.1'

虽然 JSR-250 注解不如 Spring 原生注解表达性强,但它们的好处是,它们提供的声明可以在实现 Jakarta EE 应用服务器的环境中兼容。根据您应用程序的需求和可移植性的要求,您可能会决定减少特定性的权衡是值得的。

为了实现我们在第一个示例中指定的规则,我们通过以下步骤进行了一些更改:

  1. 首先,我们需要更新我们的SecurityConfig文件以使用 JSR-250 注解:

    //src/main/java/com/packtpub/springsecurity/configuration/ SecurityConfig.java
    @Configuration
    @EnableWebSecurity
    @EnableMethodSecurity(jsr250Enabled = true)
    public class SecurityConfig {
    
  2. 最后,需要将@PreAuthorize注解更改为@RolesAllowed注解。正如我们可能预料的,@RolesAllowed注解不支持 SpEL 表达式,因此我们需要如下编辑CalendarService

    @RolesAllowed("ADMIN")
    List<Event> getEvents();
    
  3. 重新启动应用程序,以user1@example.com/user1的身份登录,并尝试访问https://localhost:8443/events/backdoor。你应该再次看到访问被拒绝页面。

重要提示

你应该从chapter11.05-calendar的代码开始。

注意,也可以使用标准的 Java 5 String 数组注解语法提供一个允许的GrantedAuthority名称列表:

@RolesAllowed({"ADMIN", "USER"})
List<Event> getEvents();

JSR-250 还指定了两个额外的注解,即@PermitAll@DenyAll,它们的功能正如你所预期的那样,允许和拒绝针对所涉及方法的全部请求。

重要提示

注意,方法级别的安全注解也可以应用于类级别!如果提供了方法级别的注解,它们将始终覆盖类级别指定的注解。如果你的业务需要为整个类指定安全策略的规范,这可能会很有帮助。

仔细使用此功能,并结合良好的注释和编码标准,以确保开发者非常清楚类及其方法的安全特性。

使用 Spring 的@Secured 注解进行方法安全

Spring 本身提供了一个类似于 JSR-250 @RolesAllowed注解的简单注解风格。@Secured注解在功能和语法上与@RolesAllowed相同。唯一的显著区别是它不需要外部依赖,不能被其他框架处理,并且必须通过@EnableMethodSecurity注解上的另一个属性显式启用这些注解的处理:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(jsr250Enabled = true)
public class SecurityConfig {}

由于@Secured注解与 JSR 标准@RolesAllowed注解功能相同,因此在新代码中实际上没有使用它的真正理由,但你可能会在旧的 Spring 代码中遇到它。

结合方法参数的方法安全规则

从逻辑上讲,在约束条件中引用方法参数的规则对于某些类型的操作似乎是合理的。例如,我们可能需要将findForUser(int userId)方法限制为满足以下约束:

  • userId参数必须等于当前用户的 ID

  • 用户必须是管理员(在这种情况下,用户可以查看任何事件)

虽然我们可以很容易地看到如何修改规则以仅允许管理员调用方法,但并不清楚我们如何确定用户是否试图更改自己的密码。

幸运的是,Spring Security 方法注解使用的 SpEL 绑定支持更复杂的表达式,包括包含方法参数的表达式。您还希望确保您已在SecurityConfig文件中启用了前注解和后注解,如下所示:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig { }
//Lastly, we can update our CalendarService interface as follows:
@PreAuthorize("hasRole('ROLE_ADMIN') or principal.id == #userId")
List<Event> findForUser(int userId);

您可以在此处看到,我们已将我们在第一个练习中使用的 SpEL 指令与对主体 ID 的检查以及userId方法参数(方法参数名称(#userId)的检查相结合。这个强大的方法参数绑定功能可用的事实应该激发您的创造力,并允许您使用一组非常精确的逻辑规则来确保方法调用安全。

重要注意事项

由于从第三章 自定义认证中自定义了认证设置,我们的主体目前是CalendarUser的一个实例。这意味着主体具有我们CalendarUser应用程序上的所有属性。如果我们没有进行此自定义,则只有UserDetails对象的属性将可用。

SpEL 变量使用哈希(#)前缀进行引用。一个重要的注意事项是,为了在运行时使方法参数名称可用,必须在编译后保留调试符号表信息。以下列出了一些保留调试符号表信息的方法:

  1. 如果您使用javac编译器,您在构建类时需要包含-g标志。

  2. 当在 Ant 中使用<javac>任务时,添加属性debug="true"

  3. 在 Gradle 中,确保在运行主方法或bootRun任务时添加--debug

  4. 在 Maven 中,确保maven.compiler.debug=true属性(默认为true)。

  5. 请咨询您的编译器、构建工具或 IDE 文档,以获取在您的环境中配置此相同设置的协助。

  6. 启动您的应用程序,并尝试使用user1@example.com作为用户名和user1作为密码进行登录。

  7. admin1@example.com)链接上查看一个访问被拒绝页面。

  8. 再次尝试使用email=user1@example.com)来查看它是否工作。

注意,在admin1@example.com/admin1上显示的用户。由于您登录的用户具有ROLE_ADMIN权限,您将能够看到两个页面。

重要注意事项

您应该从chapter11.06-calendar的代码开始。

结合返回值的方法安全规则

正如我们能够利用方法参数一样,我们也可以利用方法调用的返回值。让我们更新getEvent方法以满足以下返回值约束:

  • 参与者的 ID 必须是当前用户的 ID

  • 拥有者的 ID 必须是当前用户的 ID

  • 用户必须是管理员(在这种情况下,用户查看任何事件都是有效的)

将以下代码添加到CalendarService接口中:

@PostAuthorize("hasRole('ROLE_ADMIN') or " +
       "principal.id == returnObject.owner.id or " +
       "principal.id == returnObject.attendee.id")
Event getEvent(int eventId);

现在,请尝试使用用户名 user1@example.com 和密码 user1 登录。接下来,请使用欢迎页面上的链接访问假期活动。你现在应该看到访问被拒绝页面。

如果你使用用户名 user2@example.com 和密码 user2 登录,user2@example.com假期活动的参会者。

重要提示

你应该从 chapter11.07-calendar 中的代码开始。

使用基于角色的过滤保护方法数据

最后两个依赖于 Spring Security 的注解是 @PreFilter@PostFilter,它们用于将基于安全性的过滤规则应用于集合或数组(仅使用 @PostFilter)。这种功能被称为安全修剪或安全剪枝,它涉及在运行时使用主体的安全凭证来选择性地从一组对象中删除成员。正如你所期望的,这种过滤是在注解声明中使用 SpEL 表达式符号进行的。

我们将使用 JBCP 日历的示例进行操作,因为我们想过滤 getEvents 方法,使其只返回该用户允许看到的活动。为了做到这一点,我们移除任何现有的安全注解,并将 @PostFilter 注解添加到我们的 CalendarService 接口,如下所示:

@PostAuthorize("hasRole('ROLE_ADMIN') or " +
       "principal.id == returnObject.owner.id or " +
       "principal.id == returnObject.attendee.id")
Event getEvent(int eventId);

重要提示

你应该从 chapter11.08-calendar 中的代码开始。

移除 requestMatchers() 方法,限制对 /events/ URL 的访问,以便我们可以测试我们的注解。启动应用程序并查看 user1@example.com 和密码 user1。你会观察到只有与我们的用户关联的活动被显示。

filterObject 作为引用当前事件的循环变量时,Spring Security 将遍历我们服务返回的 List<Event> 并将其修改为仅包含与我们的 SpEL 表达式匹配的 Event 对象。

通常,@PostFilter 方法的行为如下。为了简洁起见,我们将集合称为方法返回值,但请注意,@PostFilter 适用于集合或数组方法返回类型。

filterObject 对象被重新绑定到 SpEL 上下文中的集合的每个元素。这意味着如果你的方法返回一个包含 100 个元素的集合,SpEL 表达式将针对每个元素进行评估。

SpEL 表达式必须返回一个布尔值。如果表达式评估为 true,对象将保留在集合中,如果表达式评估为 false,对象将被移除。

在大多数情况下,你会发现集合后过滤可以让你避免编写样板代码的复杂性,你可能会编写这些代码。务必理解@PostFilter的概念;与@PreAuthorize不同,@PostFilter指定方法行为而不是先决条件。一些面向对象的纯粹主义者可能会认为@PostFilter不适合作为方法注解,这种过滤应该通过方法实现中的代码来处理。

重要提示

注意,从你的方法返回的实际集合将会被修改!在某些情况下,这种行为可能不是期望的,因此你应该确保你的方法返回一个可以被安全修改的集合。这尤其重要,如果返回的集合是 ORM 绑定的,因为后过滤修改可能会意外地持久化到 ORM 数据存储中!

Spring Security 还提供了预处理方法参数集合的功能;现在让我们尝试实现它。

使用@PreFilter 预过滤集合

@PreFilter注解可以应用于方法,根据当前安全上下文过滤传递给方法的方法参数集合。功能上,一旦它获得对集合的引用,这个注解的行为就与@PostFilter注解完全相同,但有以下几个例外:

  • @PreFilter注解只支持集合参数,不支持数组参数。

  • @PreFilter注解接受一个额外的、可选的filterTarget属性,用于特别标识方法参数并在注解的方法有多个参数时对其进行过滤。

  • 就像@PostFilter一样,请注意,传递给方法的原始集合将被永久修改。这可能不是期望的行为,所以确保调用者知道在方法调用后集合的安全性可能会被裁剪!

假设我们有一个接受事件对象集合的save方法,并且我们只想允许保存由当前登录用户拥有的事件。我们可以这样做:

@PreFilter("principal.id == filterObject.owner.id")
void save(Set<Event> events);

就像我们的@PostFilter方法一样,这个注解会导致 Spring Security 遍历每个事件,使用循环变量filterObject。然后它将当前用户的 ID 与事件所有者的 ID 进行比较。如果它们匹配,则保留事件。如果不匹配,则结果被丢弃。

比较方法授权类型

以下快速参考图表可以帮助你选择要使用的方法授权检查类型:

方法 授权类型 指定为 JSR 标准 允许 SpEL 表达式
@PreAuthorize, @PostAuthorize 注解
@RolesAllowed, @PermitAll, @DenyAll 注解
@Secure 注解
protect-pointcut XML

表 11.4 – 方法授权类型

大多数使用 Spring Security 的 Java 消费者可能会选择使用 JSR-250 注解以实现最大兼容性和重用其业务类(以及相关约束)在整个 IT 组织中。在需要的情况下,这些基本声明可以被与 Spring Security 实现本身绑定的注解所替代。

如果你在一个不支持注解的环境中使用 Spring Security,不幸的是,你的选择相当有限,主要是方法安全执行。即使在这种情况之下,使用 AOP 也提供了一个相当丰富的环境,我们可以在这个环境中开发基本的声明性安全声明。

基于注解的安全性的实际考虑

需要考虑的一件事是,当返回一组现实世界的应用程序时,很可能会进行某种分页。这意味着我们的 @PreFilter@PostFilter 注解不能作为选择返回哪些对象的唯一手段。相反,我们需要确保我们的查询只选择用户允许访问的数据。

这意味着安全注解变成了冗余检查。然而,重要的是要记住本章开头我们学到的教训;我们想要保护层,以防万一某一层被绕过。

摘要

在本章中,我们涵盖了标准 Spring Security 实现中处理授权的大部分剩余领域。我们已经学到了足够多的知识,可以彻底检查 JBCP 日历应用程序,并验证应用程序的所有层都设置了适当的授权检查,以确保恶意用户无法操纵或访问他们没有访问权限的数据。

我们开发了两种微授权技术,即使用 Thymeleaf Spring Security 标签库和 Spring MVC 控制器数据绑定,根据授权或其他安全标准过滤页面内容。我们还探索了在应用程序的业务层中保护业务功能和数据以及支持与代码紧密集成的丰富声明性安全模型的方法。我们还学习了如何保护我们的 Spring MVC 控制器以及接口和类代理对象之间的区别。

到目前为止,我们已经完成了对大多数重要 Spring Security 功能的覆盖,这些功能你很可能在大多数标准、安全的 Web 应用程序开发场景中会遇到。

在下一章中,我们将讨论 Spring Security 的 ACL(域对象模型)模块。这将使我们能够明确声明授权,而不是依赖于现有数据。

第十二章:访问控制列表

在本章中,我们将讨论复杂主题 访问控制列表ACLs),它可以提供丰富的域对象实例级授权模型。Spring Security 随带了一个强大但复杂的 ACL 模块,可以很好地满足从小型到中型实施的需求。

在本章中,我们将涵盖以下主题:

  • 理解 ACL 的概念模型

  • 检查 Spring Security ACL 模块中 ACL 概念的术语和应用

  • 构建和审查支持 Spring ACL 所需的数据库模式

  • 配置 Jim Bob CP 日历JBCP)日历以使用通过注解和 Spring 容器配置的 ACL-安全业务方法

  • 执行高级配置,包括自定义 ACL 权限、启用 ACL 的 JavaServer PageJSP)标签检查和方法安全、可变 ACL 和智能缓存

  • 检查 ACL 在 ACL 部署中的架构考虑因素和规划场景

本章代码的实际链接在此:packt.link/hRby2

ACL 的概念模块

非网络层安全难题的最后一部分是在业务对象级别应用的安全,位于或低于业务层。这一级别的安全是通过称为 ACL 的技术实现的,或称为 ACLs。用一句话总结 ACL 的目标,ACL 允许根据组、业务对象和逻辑操作的独特组合来指定一组组权限。

例如,JBCP 日历的 ACL 声明可能声明特定用户必须对其自己的事件有写入访问权限。这可以表示如下:

用户名 对象 权限
josh event_01 读取,写入
角色用户 event_123 读取
匿名用户 任何事件

表 12.1 – 用户 ACL 声明示例

您可以看到这个 ACL 对人类来说非常易于阅读——josh 对他自己的事件(event_01)有readwrite访问权限;其他注册用户可以读取josh的事件,但匿名用户不能。

简而言之,这种规则矩阵就是 ACL 尝试将受保护系统及其业务数据综合成代码、访问检查和元数据的组合。大多数真正的 ACL 启用系统具有极其复杂的 ACL 列表,整个系统可能包含数百万条记录。尽管这听起来令人恐惧地复杂,但通过适当的初步推理和具备能力的安全库的实施,可以使 ACL 管理变得相当可行。

如果你使用基于 Microsoft Windows 或 Unix/Linux 的计算机,你每天都会体验到 ACL 的魔力。大多数现代计算机操作系统OSs)将 ACL 指令作为其文件存储系统的一部分,允许基于用户或组、文件或目录和权限的组合进行权限授予。在 Microsoft Windows 中,你可以通过右键单击文件并检查其安全属性(属性 | 安全)来查看文件的一些 ACL 能力,如下面的截图所示:

图 12.1 – 使用 Microsoft Windows 的 ACL 能力示例

图 12.1 – 使用 Microsoft Windows 的 ACL 能力示例

当你浏览各种组或用户和权限时,你会看到 ACL 的输入组合是可见且直观的。

在本节中,我们探讨了 ACL 的概念模块。在下一节中,我们将继续深入研究 Spring Security 中 ACL 的工作原理。

Spring Security 中的 ACL

Spring Security 支持通过 ACL 驱动的授权检查,以对受保护系统中个别用户的单个域对象访问进行控制。正如在操作系统文件系统示例中,可以使用 Spring Security ACL 组件来构建业务对象、组或主体的逻辑树结构。请求者和请求者之间权限(继承或显式)的交集用于确定允许的访问。

对于接近 Spring Security ACL 能力的用户来说,其复杂性是很常见的,结合相对缺乏的文档和示例。这种情况由于 ACL 基础设施的设置可能相当复杂,存在许多相互依赖性和对基于 bean 的配置机制的依赖而加剧,这与 Spring Security 的大部分其他部分相当不同(正如我们在设置初始配置时将看到的)。

Spring Security ACL 模块被编写为合理的基线,但打算在功能上大量构建的用户可能会遇到一系列令人沮丧的限制和设计选择,这些限制和设计选择在 Spring Security 早期阶段首次引入时(大部分未得到纠正)。不要让这些限制让你气馁!ACL 模块是嵌入丰富访问控制到你的应用程序的强大方式,并进一步审查和确保用户行为和数据的安全。

在我们深入配置 Spring Security ACL 支持之前,我们需要回顾一些关键术语和概念。

Spring ACL 系统中受保护行为身份的主要单元是GrantedAuthority。你构建的 ACL 数据模型中定义的 SID 对象用作确定特定主体允许访问级别的显式和派生访问控制规则的基础。

如果使用 SIDs 在 ACL 系统中定义操作者,那么安全等式的另一半是对受保护对象的定义。单个受保护对象的识别被称为(不出所料)对象标识。默认的 Spring ACL 对象标识实现要求在单个对象实例级别定义 ACL 规则,这意味着,如果需要,系统中的每个对象都可以有一个单独的访问规则。

单个访问规则被称为访问控制条目ACEs)。ACE 是以下因素的组合:

  • 应用该规则的操作者的 SID

  • 应用该规则的对象标识

  • 应应用于给定 SID 和指定对象标识的权限

  • 对于给定的 SID 和对象标识,是否允许或拒绝指定的权限

Spring ACL 系统的目的是评估每个受保护方法的调用,并确定在方法中操作的对象是否应允许根据适用的 ACEs。适用的 ACEs 在运行时根据调用者和参与的对象进行评估。

Spring Security ACL 在其实现上具有灵活性。尽管本章的大部分内容详细介绍了 Spring Security ACL 模块开箱即用的功能,但请记住,其中许多规则表示默认实现,在许多情况下可以根据更复杂的需求进行覆盖。

Spring Security 使用有用的值对象来表示与这些概念实体相关的数据。以下表格列出了这些对象:

ACL 概念对象 Java 对象
SID o.s.s.acls.model.Sid
对象标识 o.s.s.acls.model.ObjectIdentity
ACL o.s.s.acls.model.Acl
ACE o.s.s.acls.model.AccessControlEntry

表 12.2 – Spring Security ACL Java 对象

让我们通过在 JBCP 日历应用程序中启用 Spring Security ACL 组件的过程来进行一个简单的演示。

Spring Security ACL 支持的基本配置

虽然我们之前暗示过,在 Spring Security 中配置 ACL 支持需要基于 bean 的配置(确实如此),但如果你选择,你可以在保留更简单的安全 XML 命名空间配置的同时使用 ACL 支持。在本章的剩余示例中,我们将专注于基于 Java 的配置。

Gradle 依赖项

与大多数章节一样,我们需要添加一些依赖项才能使用本章中的功能。以下是如何检查我们添加的依赖项及其所需情况的列表和注释:

//build.gradle
//Spring ACL
implementation "org.springframework.security:spring-security-acl"

一旦更新了你的项目依赖项,我们就可以研究在 JBCP 日历应用程序中实现细粒度权限访问控制的具体实现。

定义一个简单的目标场景

我们简单的目标场景是授予 user2@example.com 只能访问生日派对活动的读取权限。

所有其他用户将无法访问任何事件。您会注意到这与我们的其他示例不同,因为user2@example.com与生日派对事件没有其他关联。

尽管有几种设置 ACL 检查的方法,但我们的偏好是遵循本章方法级注解中使用的基于注解的方法。这很好地抽象了 ACL 的使用,使其远离实际的接口声明,并允许(如果您想)在以后(如果您选择)用非 ACL 的其他东西替换角色声明。

我们将在CalendarService.getEvents方法上添加一个注解,该注解根据当前用户对事件的权限来过滤每个事件:

//src/main/java/com/packtpub/springsecurity/service/CalendarService.java
@PostFilter("hasPermission(filterObject, 'read')")
List<Event> getEvents();

重要提示

您应该从chapter12.00-calendar的代码开始。

将 ACL 表添加到 H2 数据库

我们首先需要做的是在我们的内存 H2 数据库中添加所需的表和数据,以支持持久的 ACL 条目。为此,我们将添加一个新的 SQL schema.sql。我们将在本章后面详细说明这些文件。

我们已经包含了一个schema.sql文件,该文件与本章源代码一起提供,基于 Spring Security 参考的附录中的附加 参考资料

// src/main/resources/schema.sql
--- ACLs ----------------------------------------
-- ACL Schema --
create table acl_sid (
  id bigint generated by default as identity(start with 23) not null primary key,
  principal boolean not null,
  sid varchar_ignorecase(100) not null,
  constraint uk_acl_sid unique(sid,principal) );
create table acl_class (
  id bigint generated by default as identity(start with 100) not null primary key,
  class varchar_ignorecase(500) not null,
  constraint uk_acl_class unique(class) );
create table acl_object_identity (
  id bigint generated by default as identity(start with 33) not null primary key,
  object_id_class bigint not null,
  object_id_identity bigint not null,
  parent_object bigint,
  owner_sid bigint not null,
  entries_inheriting boolean not null,
  constraint uk_acl_objid unique(object_id_class,object_id_identity),
  constraint fk_acl_obj_parent foreign key(parent_object)references acl_object_identity(id),
  constraint fk_acl_obj_class foreign key(object_id_class)references acl_class(id),
  constraint fk_acl_obj_owner foreign key(owner_sid)references acl_sid(id) );
create table acl_entry (
  id bigint generated by default as identity(start with 100) not null primary key,
  acl_object_identity bigint not null,
  ace_order int not null,
  sid bigint not null,
  mask integer not null,
  granting boolean not null,
  audit_success boolean not null,
  audit_failure boolean not null,
  constraint uk_acl_entry unique(acl_object_identity,ace_order),
  constraint fk_acl_entry_obj_id foreign key(acl_object_identity)
  references acl_object_identity(id),
  constraint fk_acl_entry_sid foreign key(sid) references acl_sid(id) );
-- the end --

上述代码将导致以下数据库模式:

图 12.2 – ACL 数据库模式

图 12.2 – ACL 数据库模式

您可以看到SIDsOBJECT_IDENTITYACEs的概念如何直接映射到数据库模式。从概念上讲,这是方便的,因为我们可以直接将我们对 ACL 系统的心理模型及其执行方式映射到数据库。

如果您已经将此与 Spring Security 文档中提供的 H2 数据库模式进行了交叉引用,您会注意到我们对一些常见问题进行了调整。具体如下:

  • ACL_CLASS.CLASS列的长度从默认值100更改为500字符。一些长而完全限定的类名无法放入100个字符内。

  • 使用有意义的名称命名外键,以便更容易诊断故障。

如果您使用的是其他数据库,例如 Oracle,您必须将 DDL 翻译成您数据库特定的 DDL 和数据类型。

一旦我们配置了 ACL 系统的其余部分,我们将返回数据库以设置一些基本的 ACEs,以证明 ACL 功能在最原始的形式。

配置 SecurityExpressionHandler

我们需要配置@EnableMethodSecurity以启用注解(我们将根据预期的 ACL 权限进行注解)并引用一个自定义访问决策管理器。

我们还需要提供一个o.s.s.access.expression.SecurityExpressionHandler实现,该实现了解如何评估权限。更新您的SecurityConfig.java配置,如下所示:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true)
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests( authz -> authz
// NOTE: "/events/" is now protected by ACL:
//.requestMatchers(antMatcher("/events/")).hasRole("ADMIN")
...
        return http.build();
    }
}

这是一个对我们在 AclConfig.java 文件中定义的 DefaultMethodSecurityExpressionHandler 对象的 bean 引用,如下所示:

//src/main/java/com/packtpub/springsecurity/configuration/AclConfig.java
   @Bean
   public DefaultMethodSecurityExpressionHandler expressionHandler(){
       DefaultMethodSecurityExpressionHandler dmseh = new DefaultMethodSecurityExpressionHandler();
       dmseh.setPermissionEvaluator(permissionEvaluator());
       dmseh.setPermissionCacheOptimizer(permissionCacheOptimizer());
       return dmseh;
   }

即使是相对简单的 ACL 配置,正如我们在我们的场景中所做的那样,也有许多必需的依赖项需要设置。正如我们之前提到的,Spring Security ACL 模块自带了许多组件,您可以将它们组装起来以提供一套合理的 ACL 功能。

AclPermissionCacheOptimizer 对象

DefaultMethodSecurityExpressionHandler 对象有两个依赖项。AclPermissionCacheOptimizer 对象用于使用单个 JDBC 选择语句为对象集合中的所有 ACL 预填充缓存。本章中包含的相对简单的配置可以按以下方式进行检查:

//src/main/java/com/packtpub/springsecurity/configuration/AclConfig.java @Bean
@Bean
public AclPermissionCacheOptimizer permissionCacheOptimizer(MutableAclService aclService){
    return new AclPermissionCacheOptimizer(aclService);
}

优化 AclPermission 缓存

DefaultMethodSecurityExpressionHandler 对象随后委托给一个 PermissionEvalulator 实例。在本章的目的上,我们使用 ACL,因此我们将使用 AclPermissionEvaluator,它将读取我们在数据库中定义的 ACL。您可以查看提供的 permissionEvaluator 配置,如下所示:

//src/main/java/com/packtpub/springsecurity/configuration/AclConfig.j ava
@Bean
public AclPermissionEvaluator permissionEvaluator(MutableAclService aclService){
    return new AclPermissionEvaluator(aclService);
}

JdbcMutableAclService 对象

到目前为止,我们已经两次看到了带有 aclService ID 的 th 的引用。aclService ID 解析为 o.s.s.acls.model.AclService 的一个实现,该实现通过委托负责将关于由 ACL 保护的对象的信息转换为预期的 ACE:

//src/main/java/com/packtpub/springsecurity/configuration/AclConfig.j ava
@Bean
public MutableAclService aclService(LookupStrategy lookupStrategy, SpringCacheBasedAclCache aclCache){
    return new JdbcMutableAclService(dataSource,
                lookupStrategy,
                aclCache);
}

我们将使用 o.s.s.acls.jdbc.JdbcMutableAclService,这是 o.s.s.acls.model.AclService 的默认实现。这个实现自带,并且已经准备好使用我们在本练习的最后一步中定义的模式。JdbcMutableAclService 对象将使用递归 SQL 和后处理来理解对象和 SID 层次结构,并确保这些层次结构的表示被传递回 AclPermissionEvaluator

BasicLookupStrategy

JdbcMutableAclService 类使用与嵌入式数据库声明中定义的相同的 JDBC dataSource 实例,并且它还委托给 o.s.s.acls.jdbc.LookupStrategy 的一个实现,该实现负责实际执行数据库查询并解决对 ACL 的请求。Spring Security 供应的唯一 LookupStrategy 实现是 o.s.s.acls.jdbc.BasicLookupStrategy,其定义如下:

//src/main/java/com/packtpub/springsecurity/configuration/AclConfig.j ava
@Bean
 public LookupStrategy lookupStrategy(AclCache aclCache,
                AclAuthorizationStrategy aclAuthorizationStrategy, ConsoleAuditLogger consoleAuditLogger){
     return new BasicLookupStrategy(
                  dataSource,
                  aclCache,
                  aclAuthorizationStrategy,
                  consoleAuditLogger);
 }

现在,BasicLookupStrategy 是一个相对复杂的实体。记住,它的目的是将需要保护的 ObjectIdentity 声明列表转换为数据库中的实际、适用的 ACE 列表。

由于 ObjectIdentity 声明可能是递归的,这证明是一个相当具有挑战性的问题,一个可能会经历大量使用的系统应该考虑 SQL 的性能影响,这些 SQL 是为了性能而生成的。

使用最低公倍数进行查询

注意,BasicLookupStrategy旨在通过严格遵循left [outer] joins与所有数据库兼容。一些较旧的数据库不支持这种连接语法,因此,请确保验证 SQL 的语法和结构与你所使用的数据库兼容!

当然,还有许多更有效的基于数据库的方法来使用非标准 SQL 执行分层查询,例如 Oracle 的CONNECT BY语句和许多其他数据库(包括 PostgreSQL 和 Microsoft SQL Server)的公共表表达式CTE)功能。

就像你在第四章的示例中学习的那样,基于 JDBC 的认证,使用自定义模式为JdbcDaoImpl实现UserDetailsService属性暴露出来,以便配置BasicLookupStrategy使用的 SQL。查阅 Javadoc 和源代码本身,以了解它们是如何使用的,以便正确应用于你的自定义模式。

我们可以看到LookupStrategy需要一个引用与AclService使用的相同 JDBC dataSource实例。其他三个引用几乎带我们到达依赖链的尽头。

AclCache 接口

o.s.s.acls.model.AclCache接口声明了一个用于缓存ObjectIdentity到 ACL 映射的接口,以防止冗余(且昂贵)的数据库查找。Spring Security 支持JCache (JSR-107)的任何实现。

例如,为了启用对开源、基于内存和磁盘的缓存库Ehcache的支持,该库在许多开源和商业 Java 产品中得到广泛使用,你需要添加以下 Gradle 依赖项:

//build.gradle
//Enabling Ehcache support
implementation "org.ehcache:ehcache"

我们将通过更新AclConfig.java中的配置来在我们的示例中设置ConcurrentMapCache

//src/main/java/com/packtpub/springsecurity/configuration/AclConfig.java
@Bean
public AclCache aclCache( Cache concurrentMapCache,
       PermissionGrantingStrategy permissionGrantingStrategy, AclAuthorizationStrategy aclAuthorizationStrategy){
    return new SpringCacheBasedAclCache(concurrentMapCache,
          permissionGrantingStrategy,
          aclAuthorizationStrategy);
}
@Bean
public PermissionGrantingStrategy permissionGrantingStrategy(){
    return new DefaultPermissionGrantingStrategy(consoleAuditLogger());
}
@Bean
public ConcurrentMapCache concurrentMapCache(){
    return new ConcurrentMapCache("aclCache");
}
@Bean
public CacheManager cacheManager(){
    return new ConcurrentMapCacheManager();
}

ConsoleAuditLogger 类

接下来,与o.s.s.acls.jdbc.BasicLookupStrategy相关的简单依赖项是一个o.s.s.acls.domain.AuditLogger接口的实现,该接口被BasicLookupStrategy类用于审计 ACL 和 ACE 查找。类似于AclCache接口,Spring Security 只提供了一个实现,它只是简单地记录到控制台。我们将通过另一行 bean 声明来配置它:

//src/main/java/com/packtpub/springsecurity/configuration/AclConfig.j ava
@Bean
public ConsoleAuditLogger consoleAuditLogger(){
    return new ConsoleAuditLogger();
}

AclAuthorizationStrategyImpl 接口

需要解决的最后一个依赖项是实现o.s.s.acls.domain.AclAuthorizationStrategy接口,这个接口在从数据库加载 ACL 时实际上没有任何直接责任。相反,该接口的实现负责确定是否允许对 ACL 或 ACE 的运行时更改,这取决于更改的类型。

我们将在稍后当我们介绍可变 ACLs 时进一步解释这一点,因为逻辑流程既有些复杂,又与完成我们的初始配置不相关。最终的配置要求如下:

//src/main/java/com/packtpub/springsecurity/configuration/AclConfig.j ava
@Bean
public AclAuthorizationStrategy aclAuthorizationStrategy() {
    return new AclAuthorizationStrategyImpl(
            new SimpleGrantedAuthority("ROLE_ADMINISTRATOR")
    );
}

你可能会想知道对具有adminAuthority ID 的 bean 的引用是为什么——AclAuthorizationStrategyImpl提供了指定GrantedAuthority的能力,这是在运行时对可变 ACL 执行特定操作所必需的。我们将在本章后面介绍这些内容。

我们终于完成了 Spring Security ACL 实现的初始配置。接下来的最后一步需要我们在 H2 数据库中插入一个简单的 ACL 和 ACE,并对其进行测试!

创建简单的 ACL 条目

回想一下,我们的非常简单的场景是仅允许user2@example.com访问生日派对活动,并确保其他活动不可访问。

你可能需要参考数据库模式图(图 12.2)的几页,以了解我们正在插入哪些数据以及为什么。

我们已经在示例应用程序中包含了一个名为data.sql的文件。

本节中解释的所有 SQL 都将来自文件——你可以自由地实验并基于我们提供的示例 SQL 添加更多测试用例。实际上,我们鼓励你使用示例数据进行实验!

让我们看看创建简单 ACL 条目的以下步骤:

  1. 首先,我们需要用任何或所有具有 ACL 规则的域对象类填充ACL_CLASS表,在我们的例子中,这仅仅是我们的Event类:
//src/main/resources/data.sql
insert into acl_class (id, class) values (10, 'com.packtpub.springsecurity.domain.Event');

我们选择使用ACL_CLASS表的 10 到 19 之间的主键,ACL_SID表的 20 到 29 之间,依此类推。

这将有助于更容易地理解哪些数据与哪些表相关联。请注意,我们的Event表以100为主键。这些便利性是为了示例目的而做的,并不建议用于生产目的。

  1. 接下来,ACL_SID表被注入了将与 ACE 关联的 SIDs。记住,SIDs 可以是角色或用户——我们在这里填充角色和user2@example.com

  2. 虽然角色的 SID 对象很简单,但用户的 SID 对象并不那么明确。在我们的用途中,用户名用于 SID。要了解更多关于如何解析角色和用户的 SIDs 的信息,请参考o.s.s.acls.domain.SidRetrievalStrategyImpl。如果默认值不符合你的需求,可以将自定义的o.s.s.acls.model.SidRetrievalStrategy默认值注入到AclPermissionCacheOptimizerAclPermissionEvaluator中。在我们的例子中,我们不需要这种定制,但了解它在必要时是可用的总是好的:

//src/main/resources/data.sql
-- User specific:
insert into acl_sid (id, principal, sid) values (20, true, 'user2@example.com');
-- Role specific:
insert into acl_sid (id, principal, sid) values (21, false, 'ROLE_USER');
insert into acl_sid (id, principal, sid) values (22, false, 'ROLE_ADMIN');

开始变得复杂的是ACL_OBJECT_IDENTITY表,它用于声明单个域对象实例、其父对象(如果有)以及拥有SID

例如,这个表代表我们正在保护的对象Event。我们将插入具有以下属性的一行:

  • 一个Event类型的域对象,它是通过OBJECT_ID_CLASS列指向我们的ACL_CLASS表的外键,值为10

  • 一个域对象的100OBJECT_ID_IDENTITY列)主键。这是一个外键(尽管不是通过数据库约束强制执行)到我们的Event对象。

  • user2@example.comSID所有者,这是一个外键,通过OWNER_SID列指向ACL_SID,其值为20

表示具有 ID 为100(生日事件)、101102的事件的 SQL 语句如下:

//src/main/resources/data.sql
-- object identity
-- Event entry for user2 SID
insert into acl_object_identity (id,object_id_identity,object_id_class,parent_object,owner_sid,entries_inheriting)
values (30,100, 10, null, 20, false);
-- Event entry for ROLE_USER SID
insert into acl_object_identity (id,object_id_identity,object_id_class,parent_object,owner_sid,entries_inheriting)
values (31,101, 10, null, 21, false);
-- Event entry for ROLE_ADMIN SID
insert into acl_object_identity (id,object_id_identity,object_id_class,parent_object,owner_sid,entries_inheriting)
values (32,102, 10, null, 21, false);

请记住,拥有SID的所有者也可能代表一个角色——这两种类型的规则在 ACL 系统中都同样有效。

最后,我们将添加一个与该对象实例相关的 ACE,声明user2@example.com被允许读取生日事件的访问权限:

//src/main/resources/data.sql
-- ACEntry list ---------------------------------
-- mask == R
-- Entry for Event entry for user2 SID
insert into acl_entry (acl_object_identity, ace_order, sid, mask, granting, audit_success, audit_failure)
values(30, 1, 20, 1, true, true, true);

这里的MASK列代表一个位掩码,用于在相关对象上授予指定SID的权限。我们将在本章后面详细解释这一点——不幸的是,它可能不像听起来那么有用。

现在,我们可以启动应用程序并运行我们的示例场景。尝试使用user2@example.com/user2登录并访问所有事件页面。你会看到只有生日事件被列出。

当以admin1@example.com/admin1登录并查看所有事件页面时,不会显示任何事件。

然而,如果我们直接导航到一个事件,它将不会受到保护。你能根据本章所学的内容想出如何确保直接访问一个事件的方法吗?

如果你还没有想出来,你可以通过以下方式更新CalendarService.java来确保直接访问一个事件:

//src/main/java/com/packtpub/springsecurity/service/CalendarService.java
@PostAuthorize("hasPermission(filterObject, 'read') ")
Event getEvent(int eventId);

现在我们已经有一个基于 ACL 的安全的基本工作设置(尽管,场景非常简单)。

让我们继续解释我们在这次演练中看到的一些概念,然后回顾一下典型的 Spring ACL 实现中的一些考虑事项,在使用它之前你应该考虑这些事项。

重要注意事项

你应该从chapter12.01-calendar的代码开始。

值得注意的是,我们在创建事件时没有创建新的 ACL 条目。因此,在当前状态下,如果你创建一个事件,你将收到一个类似于以下错误的错误:

Exception during execution of Spring Security application! Unable to find ACL information for object identity 'org.springframework.security.acls.domain.ObjectIdentityImpl[Type: com.packtpub.springsecurity.domain.Event; Identifier: 1]'

在检查创建简单 ACL 条目的过程之后,我们现在深入理解权限如何运作,重点关注高级 ACL 配置。

高级 ACL 主题

我们在配置 ACL 环境时略过的一些高级主题与 ACE 权限和GrantedAuthority指示器的使用有关,这些指示器有助于 ACL 环境确定是否允许某些类型的 ACL 运行时更改。现在我们有了工作环境,我们将回顾这些更高级的主题。

权限如何工作

权限不过是整数中由位表示的单个逻辑标识符。ACE 基于位掩码授予SIDs权限,该位掩码包含所有适用于该 ACE 的权限的逻辑或。

默认权限实现 o.s.s.acls.domain.BasePermission 定义了一系列表示常见 ACL 授权动词的整数值。这些整数值对应于整数中设置的单一位,因此 BasePermissionWRITE 值为 1 的位运算值为 212

这些在以下图中进行了说明:

图 12.3 – 默认和自定义权限掩码

图 12.3 – 默认和自定义权限掩码

我们可以看到,由于将 ReadWrite 权限应用于权限值,Sample 权限掩码将具有整数值 3

在前面图中展示的所有标准整数单一权限值都在 BasePermission 对象中定义为静态常量。

包含在 BasePermission 中的逻辑常量只是 ACE 中常用权限的合理基线,在 Spring Security 框架中没有任何语义意义。对于非常复杂的 ACL 实现,发明自己的自定义权限是很常见的,这些权限补充了最佳实践示例,并依赖于领域或业务依赖性。

用户经常混淆的一个问题是,在实际应用中位掩码是如何使用的,因为许多数据库要么不支持位逻辑,要么不支持可扩展的方式。Spring ACL 旨在通过将计算与位掩码相关的适当权限的负载更多地放在应用程序上而不是数据库上来解决这个问题。

审查解析过程是很重要的,我们可以看到 AclPermissionEvaluator 是如何将方法本身上声明的权限(在我们的例子中,使用 @PostFilter 注解)解析为实际的 ACL 权限的。

下图展示了 Spring ACL 对声明权限与请求主体相关的相关 ACE 进行评估的过程:

图 12.4 – Spring ACL 权限评估过程

图 12.4 – Spring ACL 权限评估过程

我们可以看到,AclPermissionEvaluator 依赖于实现两个接口的类,即 o.s.s.acls.model.ObjectIdentityRetrievalStrategyo.s.s.acls.model.SidRetrievalStrategy,以检索适合授权检查的 ObjectIdentity 和 SIDs。关于这些策略需要注意的是,默认实现类实际上是如何根据授权检查的上下文来确定要返回的 ObjectIdentity 和 SIDs 对象的。

ObjectIdentity 对象有两个属性,typeidentifier,它们是从运行时检查的对象派生出来的,并用于声明 ACE 条目。默认的 ObjectIdentityRetrievalStrategy 接口使用完全限定的类名来填充 type 属性。identifier 属性则填充了在实际对象实例上调用具有 Serializable getId() 签名的方 法的结果。

由于你的对象不需要实现接口以与 ACL 检查兼容,因此对于实现具有特定签名的方法的必要性可能会让实现 Spring Security ACL 的开发者感到惊讶。请提前规划并确保你的域对象包含此方法!你也可以实现自己的ObjectIdentityRetrievalStrategy类(或实现现成的子类)来调用你选择的方法。不幸的是,该方法的名字和类型签名是无法配置的。

不幸的是,AclImpl的实际实现直接比较了我们@PostFilter注解中指定的权限和数据库中 ACE 上存储的权限,而没有使用位运算逻辑。在声明具有权限组合的用户时,你需要注意,要么必须配置AclEntryVoter以包含所有权限组合,或者 ACE 需要忽略权限字段旨在存储多个值的事实,并改为每个 ACE 存储单个权限。

如果你想要通过我们的简单场景验证这一点,将授予user2@example.com SID 的READ权限更改为读和写的位掩码组合,这相当于值3。这将更新到data.sql文件中,如下所示:

//src/main/resources/data.sql
-- READ / WRITE Entry for Event entry for user2 SID
insert into acl_entry
(acl_object_identity, ace_order, sid, mask, granting, audit_success, audit_failure)
values(30, 1, 20, 3, true, true, true);

重要提示

你应该从chapter12.02-calendar的代码开始。

自定义 ACL 权限声明

如前所述,在权限声明讨论中,权限不过是整数位值的逻辑名称。因此,可以扩展o.s.s.acls.domain.BasePermission类并声明你自己的权限。在这里,我们将涵盖一个非常直接的场景,其中我们创建一个新的 ACL 权限,称为ADMIN_READ

这是一个仅授予管理用户的权限,并将分配以保护只有管理员才能读取的资源。尽管这是一个针对 JBCP 日历应用的虚构示例,但这种自定义权限的使用在处理个人身份信息PII)的情况中相当常见(例如,社会保险号等——回想一下我们在第一章不安全应用程序的解剖)中讨论了 PII)。

让我们开始进行必要的更改以支持此操作,按照以下步骤操作:

  1. 第一步是使用我们的com.packtpub.springsecurity.acls.domain.CustomPermission类扩展BasePermission类,如下所示:

    package com.packtpub.springsecurity.acls.domain;
    public class CustomPermission extends BasePermission {
        public static final Permission ADMIN_READ = new CustomPermission(1 << 5, 'M'); // 32
        public CustomPermission(int mask, char code) {
            super(mask, code);
        }
    }
    
  2. 接下来,我们需要配置o.s.s.acls.domain.PermissionFactory默认实现,o.s.s.acls.domain.DefaultPermissionFactory,以注册我们的自定义权限逻辑值。PermissionFactory的作用是将权限掩码解析为逻辑权限值(可以在应用程序的其他区域通过常量值或名称,如ADMIN_READ进行引用)。PermissionFactory实例要求任何自定义权限都必须注册到其中以进行适当的查找。我们已包含以下配置,如下注册我们的CustomPermission类:

    //src/main/java/com/packtpub/springsecurity/configuration/AclConfig.java
    @Bean
    public DefaultPermissionFactory permissionFactory(){
        return new DefaultPermissionFactory(CustomPermission.class);
    }
    
  3. 接下来,我们需要为我们的BasicLookupStrategyAclPermissionEvaluator接口覆盖默认的PermissionFactory实例,使用自定义的DefaultPermissionFactory接口:

    //src/main/java/com/packtpub/springsecurity/configuration/AclConf ig.java
    @Bean
    public AclPermissionEvaluator permissionEvaluator(MutableAclService aclService){
            AclPermissionEvaluator pe = new AclPermissionEvaluator(aclService);
            pe.setPermissionFactory(permissionFactory());
            return pe;
    }
    @Bean
    public LookupStrategy lookupStrategy(AclCache aclCache,
           AclAuthorizationStrategy aclAuthorizationStrategy, ConsoleAuditLogger consoleAuditLogger) {
            BasicLookupStrategy lookupStrategy = new BasicLookupStrategy(
                    dataSource,
                    aclCache,
                    aclAuthorizationStrategy,
                    consoleAuditLogger);
            lookupStrategy.setPermissionFactory(permissionFactory());
            return lookupStrategy;
    }
    
  4. 我们还需要添加 SQL 查询以利用新的权限,允许访问会议电话(acl_object_identity ID 为 31)事件给admin1@example.com。请在data.sql中进行以下更新:

    -- custom permission
    insert into acl_sid (id, principal, sid) values (23, true, 'admin1@example.com');
    insert into acl_entry
    (acl_object_identity, ace_order, sid, mask, granting, audit_success, audit_failure)
    values(31, 1, 23, 32, true, true, true);
    

    我们可以看到新的整数掩码值32已在 ACE 数据中引用。这有意对应于我们在 Java 代码中定义的新ADMIN_READ ACL权限。会议电话事件通过其在ACL_OBJECT_IDENTITY表中的主键(存储在object_id_identity列中)值31进行引用。

  5. 最后一步是更新我们的CalendarServicegetEvents()方法,以便利用我们新的权限,如下所示:

@PostFilter("hasPermission(filterObject, 'read') " +
       "or hasPermission(filterObject, 'admin_read')")
List<Event> getEvents()

在所有这些配置到位后,我们可以再次启动网站并测试自定义 ACL 权限。根据我们配置的样本数据,以下是当各种可用用户点击类别时应该发生的情况:

用户名/密码 生日派对活动 会议电话活动 其他活动
user2@example.com/user2 通过READ允许 拒绝 拒绝
admin1@example.com/admin1 拒绝 通过ADMIN_READ允许 拒绝
user1@example.com/user1 拒绝 拒绝 拒绝

表 12.3 – 用户 ACL Java 对象

我们可以看到,即使使用我们简单的情况,我们现在已经能够以非常有限的方式扩展 Spring ACL 功能,以展示这个细粒度访问控制系统的能力。

重要提示

您应该从chapter12.03-calendar的代码开始。

启用 ACL 权限评估

第二章《Spring Security 入门》中,我们了解到 Spring Security JSP 标签库提供了向用户暴露与认证相关的数据以及根据各种规则限制用户可见性的功能。到目前为止,在这本书中,我们使用了建立在 Spring Security 之上的Thymeleaf Security 标签库

同样的标签库也可以直接与一个 ACL 启用的系统交互!从我们的简单实验中,我们已经在主页列表的前两个类别周围配置了一个简单的 ACL 授权场景。让我们看看以下步骤,学习如何在我们的 Thymeleaf 页面中启用 ACL 权限评估:

  1. 首先,我们需要从我们的CalendarService接口中的getEvents()方法移除@PostFilter注解,以便让我们的 JSP 标签库有机会过滤掉不允许显示的事件。现在就移除@PostFilter注解,如下所示:

    // src/main/java/com/packtpub/springsecurity/service/ CalendarService.java
    List<Event> getEvents();
    
  2. 现在我们已经移除了@PostFilter,我们可以利用<sec:authorize-acl>标签来隐藏用户实际上没有访问权限的事件。参考前一个章节中的表格,以刷新我们对已配置的访问规则的了解!

  3. 我们将使用<sec:authorize-acl>标签包装每个事件的显示,声明要检查显示对象上的权限列表:

    //src/main/resources/templates/events/list.xhtml
    <th:block th:each="event : ${events}">
        <tr sec:authorize-acl="${event} :: '1,32'" >
            <td th:text="${#calendars.format(event.dateWhen, 'yyyy-MM-dd HH:mm')}">today</td>
            <td th:text="${event.owner.name}">Chuck Norris</td>
            <td th:text="${event.attendee.name}">Josh Knutson</td>
            <td><a th:href="@{'/events/{id}'(id=${event.id})}" th:text="${event.summary}">-1</a></td>
        </tr>
    </th:block>
    
  4. 仔细思考一下我们在这里想要实现的目标——我们希望用户只能看到他们实际具有READADMIN_READ(我们的自定义权限)访问权限的项目。然而,为了使用标签库,我们需要使用权限掩码,这可以从以下表中引用:

名称 掩码
READ 1
WRITE 2
ADMIN_READ 32

表 12.4 – 权限掩码表

在幕后,标签实现使用了之前在本章中讨论过的相同的SidRetrievalStrategyObjectIdentityRetrievalStrategy接口。因此,访问检查的计算遵循与 ACL 启用的方法安全性投票相同的流程。正如我们一会儿将看到的,标签实现也将使用相同的PermissionEvaluator

我们已经通过带有引用DefaultMethodSecurity ExpressionHandlerexpressionHandler元素的@EnableMethodSecurity注解启用了我们的@EnableMethodSecurityDefaultMethodSecurityExpressionHandler实现了解我们的AclPermissionEvaluator接口,但我们必须也让 Spring Security 的 Web 层了解AclPermissionEvalulator。如果你这么想,这种对称性是有意义的,因为保护方法和 HTTP 请求是在保护两种非常不同的资源。幸运的是,Spring Security 的抽象使得这一点相当简单。

  1. 添加一个引用 ID 为permissionEvaluatorDefaultWebSecurityExpressionHandler处理器,因为我们已经定义了该 bean:
//src/main/java/com/packtpub/springsecurity/configuration/AclConfig.java
@Bean
public DefaultWebSecurityExpressionHandler webExpressionHandler(AclPermissionEvaluator permissionEvaluator){
    DefaultWebSecurityExpressionHandler webExpressionHandler = new DefaultWebSecurityExpressionHandler();
    webExpressionHandler.setPermissionEvaluator(permissionEvaluator);
    return webExpressionHandler;
}

你可以看到这些步骤与我们添加对方法安全性的权限处理支持的方式非常相似。这次,它稍微简单一些,因为我们能够重用具有PermissionEvaluator ID 的同一个 bean,我们之前已经配置了它。

启动我们的应用程序并尝试访问@PostFilter注解。

我们仍然意识到,直接访问事件会允许用户看到它。然而,这可以通过结合你在本章中学到的内容以及关于本章中@PostAuthorize注解的内容轻松实现。

重要提示

你应该从chapter12.04-calendar的代码开始。

可变 ACL 和授权

虽然 JBCP 日历应用没有实现完整的用户管理功能,但你的应用很可能会有一些常见功能,例如新用户注册和行政用户维护。到目前为止,这些功能的缺失——我们通过在应用启动时使用 SQL 插入来解决这个问题——并没有阻止我们展示 Spring Security 和 Spring ACL 的许多功能。

然而,正确处理声明 ACL 的运行时更改,或者系统中用户的添加或删除,对于维护基于 ACL 的授权环境的一致性和安全性至关重要。Spring ACL 通过可变 ACL 的概念(o.s.s.acls.model.MutableAcl)解决了这个问题。

扩展标准 ACL 接口,MutableAcl接口允许在运行时操作 ACL 字段以更改特定 ACL 的内存表示。这项附加功能包括创建、更新或删除 ACE、更改 ACL 所有权以及其他有用功能。

因此,我们可能期望 Spring ACL 模块能够直接提供一种将运行时 ACL 更改持久化到 JDBC 数据存储的方式,确实如此。o.s.s.acls.jdbc.JdbcMutableAclService类可以用来在数据库中创建、更新和删除MutableAcl实例,以及进行 ACL 的其他支持表的常规维护(处理SIDsObjectIdentity和域对象类名)。

回想本章前面的内容,AclAuthorizationStrategyImpl类允许我们指定对可变 ACL 的操作的管理角色。这些作为 bean 配置的一部分提供给构造函数。构造函数参数及其含义如下:

参数编号 功能
1 它表示主体在运行时对 ACL 保护对象拥有所有权的权限
2 它表示主体在运行时更改 ACL 保护对象的审计所需的权限
3 它表示主体在运行时对 ACL 保护对象进行任何其他类型的更改(创建、更新和删除)所需的权限

表 12.5 – AclAuthorizationStrategyImpl 构造函数的参数

可能会让人困惑的是,当我们列出了三个参数时,我们只指定了一个构造函数参数。AclAuthorizationStrategyImpl 类也可以接受一个 GrantedAuthority,然后它将被用于所有三个参数。如果我们想对所有操作使用相同的 GrantedAuthority,这将很方便。

JdbcMutableAclService 接口包含许多用于在运行时操作 ACL 和 ACE 数据的方法。虽然这些方法本身相当容易理解(createAclupdateAcldeleteAcl),但即使是高级 Spring Security 用户,正确配置和使用 JdbcMutableAclService 也往往很困难。

让我们修改 CalendarService 以为新创建的事件创建一个新的 ACL。

目前,如果用户创建了一个新事件,它将不会在 <sec:authorize-acl> 标签中对该用户可见,该标签仅显示用户有权访问的事件对象。让我们更新我们的 DefaultCalendarService 接口,以便当用户创建一个新事件时,他们被授予对该事件的读取访问权限,并且它将在他们的 所有事件 页面上显示。

让我们看看以下步骤,以将 ACL 添加到新创建的事件中:

  1. 第一步是更新我们的构造函数以接受 MutableAclServiceUserContext

    //src/main/java/com/packtpub/springsecurity/service/ DefaultCalendarService.java
    @Repository
    public class DefaultCalendarService implements CalendarService {
        private final EventDao eventDao;
        private final CalendarUserDao userDao;
        private final MutableAclService aclService;
        private final UserContext userContext;
        public DefaultCalendarService(EventDao eventDao, CalendarUserDao userDao, MutableAclService aclService, UserContext userContext) {
           this.eventDao = eventDao;
           this.userDao = userDao;
           this.aclService = aclService;
           this.userContext = userContext;
        }
    
  2. 然后,我们需要更新我们的 createEvent 方法,以便为当前用户创建 ACL。进行以下更改:

    //src/main/java/com/packtpub/springsecurity/service/ DefaultCalendarService.java
    @Transactional
    @Override
    public int createEvent(Event event) {
        int result = eventDao.createEvent(event);
        event.setId(result);
        // Add new ACL Entry:
        MutableAcl acl = aclService.createAcl(new ObjectIdentityImpl(event));
        PrincipalSid sid = new PrincipalSid(userContext.getCurrentUser().getEmail());
        acl.setOwner(sid);
        acl.insertAce(0,  BasePermission.READ, sid, true);
        aclService.updateAcl(acl);
        return result;
    }
    
  3. JdbcMutableAclService 接口使用当前用户作为创建的 MutableAcl 接口的默认所有者。我们选择再次显式设置所有者,以演示如何覆盖此设置。

  4. 然后,我们添加一个新的 ACE 并保存我们的 ACL。就这么简单。

  5. 启动应用程序并使用 user1@example.com/user1 登录。

  6. 访问 所有事件 页面,并查看目前没有列出任何事件。然后创建一个新事件,下次您访问 所有事件 页面时,它将显示出来。如果您以任何其他用户登录,该事件将不会在 所有 事件 页面上可见。

    然而,由于我们没有对其他页面应用安全措施,它可能会对用户可见。我们再次鼓励您尝试自己保护这些页面。

重要提示

您应该从 chapter12.05-calendar 中的代码开始。

在掌握 Spring Security ACL 的工作原理后,接下来的部分将讨论标准 ACL 部署中需要考虑的因素。

典型 ACL 部署的考虑因素

实际上,在真正的业务应用程序中部署 Spring ACL 通常相当复杂。我们通过考虑大多数 Spring ACL 实现场景中出现的考虑因素来结束对 Spring ACL 的介绍。

ACL 可扩展性和性能建模

对于小型和中型应用,ACL 的添加是相当可控的,尽管它增加了数据库存储和运行时性能的负担,但影响不太可能显著。然而,根据 ACL 和 ACE 建模的粒度,中型到大型应用中的数据库行数可能会非常庞大,甚至可能让经验最丰富的数据库管理员感到压力。

假设我们要将 ACL 扩展到覆盖 JBCP 日历应用的扩展版本。假设用户可以管理账户,将图片发布到事件中,并从事件中管理(添加/删除用户)。我们将数据建模如下:

  • 所有用户都有账户。

  • 10%的用户能够管理事件。一个用户可以管理的事件的平均数量将是两个。

  • 事件将按客户进行保护(只读),但同时也需要由管理员可访问(读/写)。

  • 10%的客户将被允许发布图片。每个用户的平均帖子数将是 20。

  • 发布的图片将按用户以及管理员进行保护(读/写)。发布的图片对所有其他用户将是只读的。

根据我们对 ACL 系统的了解,我们知道数据库表具有以下可扩展属性:

与数据一起扩展 可扩展性说明
ACL_CLASS 每个域类需要一个行。
ACL_SID 是(用户) 每个角色(GrantedAuthority)需要一个行。每个用户账户(如果域对象按用户进行保护)需要一个行。
ACL_OBJECT_IDENTITY 是(每个类的域类实例) 每个受保护域对象的实例需要一个行。
ACL_ENTRY 是(域对象实例的 ACE 条目) 每个 ACE 需要一个行;可能需要为单个域对象多个行。

表 12.6 – 数据库表的可扩展属性

我们可以看到,ACL_CLASS实际上没有可扩展性问题(大多数系统将拥有少于 1,000 个域类)。

ACL_SID表将根据系统中的用户数量线性扩展。这可能不是一个问题,因为其他与用户相关的表也将以这种方式扩展(用户账户等)。

我们关注的两个表是ACL_OBJECT_IDENTITYACL_ENTRY。如果我们对为单个客户建模订单所需的估计行数进行建模,我们得到以下估计:

每个事件的 ACL 数据 每个图片帖子的 ACL 数据
ACL_OBJECT_IDENTITY 单个事件需要一个行。 单个帖子需要一个行。
ACL 条目 三行——一行是所有者(用户 SID)读取访问所需的,两行是管理组 SID 所需的(一行用于读取访问,一行用于写入访问)。 四行——一行是用户组 SID 读取访问所需的,一行是所有者写入访问所需的,两行是管理组 SID 所需的(与事件相同)。

表 12.7 – 每个事件或发布的图片的可扩展性估计

然后,我们可以从上一页的使用假设中计算出以下 ACL 可扩展性矩阵,如下所示:

表/对象 缩放因子 低估计 高估计
用户 10,000 1,000,000
事件 # 用户 * 0.1 * 2 2,000 200,000
图片帖子 # 用户 * 0.1 * 20 20,000 2,000,000
ACL_SID # 用户 10,000 1,000,000
ACL_OBJECT_IDENTITY # 事件 + # 图片帖子 220,000 2,200,000
ACL 条目 (# 事件 * 3) + (# 图片帖子 * 4) 86,000 8,600,000

表 12.8 – ACL 可扩展性矩阵

从仅基于典型 ACL 实现可能涉及和受保护的业务对象子集的预测来看,您可以看到,用于存储 ACL 信息的数据库行数可能会与您的实际业务数据成线性(或更快)增长。特别是在大型系统规划中,预测您可能使用的 ACL 数据量非常重要。对于非常复杂的系统来说,有数亿行与 ACL 存储相关的行数并不罕见。

不要低估定制开发成本

利用 Spring ACL 安全环境通常需要比我们到目前为止所描述的配置步骤更多的开发工作。我们的示例配置场景有以下局限性:

  • 没有提供对事件操作修改或权限修改进行响应的设施。

  • 并非所有应用程序都使用权限。例如,我的事件页面和直接导航到事件都不是受保护的。

应用程序没有有效地使用 ACL 层次结构。如果我们要将 ACL 安全推广到整个网站,这些局限性将严重影响功能。这就是为什么在计划在应用程序中推广 Spring ACL 时,您必须仔细审查所有处理域数据的位置,并确保这些位置正确更新 ACL、ACE 规则并使缓存失效。通常,方法和数据的安全发生在服务或业务应用层,而维护 ACL 和 ACE 所需的钩子发生在数据访问层。

图 12.6 – Spring ACL 权限评估过程

图 12.6 – Spring ACL 权限评估过程

如果你正在处理一个相对标准的应用程序架构,具有适当的功能隔离和封装,那么很可能存在一个易于识别的中心位置来处理这些更改。另一方面,如果你正在处理一个已经退化(或者一开始就没有设计得很好)的架构,那么在数据操作代码中添加 ACL 功能和支持钩子可能会非常困难。

如前所述,重要的是要记住,Spring ACL 架构自Acegi 1.x(Spring Security 的父项目)时代以来并没有发生重大变化。

以下是一些与 Spring ACL 架构最常见和最重要的问题:

  • ACL 基础设施需要一个数字主键。对于使用全局唯一标识符GUID)或通用唯一标识符UUID)主键的应用程序(由于现代数据库中更有效的支持,这种情况更为常见),这可能是一个重大的限制。

  • 在配置 Spring ACL 的方法和 Spring Security 的其他部分之间存在几个不一致之处。一般来说,你可能会遇到一些区域,其中类代理或属性没有通过依赖注入DI)公开,这需要一种耗时且昂贵的覆盖和重写策略。

  • 权限掩码被实现为一个整数,因此有 32 个可能的位。将默认位分配扩展以表示单个对象属性的权限(例如,分配一个位来读取员工的社保号码)是相当常见的。复杂的部署可能每个域对象有超过 32 个属性,在这种情况下,唯一的替代方案可能是在这个限制下重新设计你的域对象。

根据你的应用程序需求,你可能会遇到更多的问题,特别是关于在实现某些类型的自定义时需要更改的类数量。

我应该使用 Spring Security ACL 吗?

正如应用 Spring Security 的细节高度依赖于业务一样,Spring ACL 支持的应用也是如此。事实上,由于它与业务方法和域对象的紧密耦合,ACL 支持往往更是如此。我们希望本指南解释了分析 Spring ACL 以用于你的应用程序所需的重要高级和低级配置和概念,并可以帮助你确定和匹配其实际应用的能力。

摘要

在本章中,我们重点介绍了基于 ACL 的安全性和 Spring ACL 模块实现这种类型安全性的具体细节。

我们回顾了 ACLs 的基本概念,以及为什么它们可以成为授权的有效解决方案的许多原因。我们还了解了与 Spring ACL 实现相关的关键概念,包括 ACEs、SIDs 和对象标识。我们检查了支持分层 ACL 系统所需的数据库模式和逻辑设计。我们配置了所有必需的 Spring beans 以启用 Spring ACL 模块,并增强了一个服务接口以使用注解方法授权。

我们随后将数据库中的现有用户和网站本身使用的业务对象与一组示例 ACE 声明和支持数据关联起来。我们回顾了 Spring ACL 权限处理的概念。我们扩展了我们对 Spring Security Thymeleaf 标签库和 SpEL(用于方法安全)的知识,以利用 ACL 检查。

我们讨论了可变 ACL 的概念,并回顾了在可变 ACL 环境中所需的基本配置和自定义编码。我们开发了一个自定义 ACL 权限,并配置了应用程序以展示其有效性。我们配置并分析了使用Spring缓存管理器以减少 Spring ACL 对数据库的影响。我们分析了在复杂业务应用程序中使用 Spring ACL 系统的影响和设计考虑因素。

这就结束了我们对 Spring Security ACLs 的讨论。在下一章中,我们将进一步探讨 Spring Security 的工作原理。

第十三章:自定义授权

在本章中,我们将为 Spring Security 的关键授权 API 编写一些自定义实现。一旦我们完成这项工作,我们将利用我们对自定义实现的理解来了解 Spring Security 的授权架构是如何工作的。

在本章中,我们将涵盖以下主题:

  • 理解授权是如何工作的

  • 编写一个基于数据库而不是 requestMatchers() 方法的自定义 SecurityMetaDataSource

  • 创建自定义的 Spring 表达式语言SpEL)表达式

  • 实现一个自定义的 PermissionEvaluator 对象,以便封装我们的权限

  • 声明一个自定义的 AuthorizationManager

本章代码示例链接在此:packt.link/e630f

授权请求

就像在认证过程中一样,Spring Security 提供了一个 o.s.s.web.access.intercept.FilterSecurityInterceptor Servlet 过滤器,该过滤器负责决定是否接受或拒绝特定的请求。当过滤器被调用时,主体已经经过认证,因此系统知道一个有效的用户已经登录;记住我们在 第三章自定义认证 中实现了 List<GrantedAuthority>getAuthorities() 方法,该方法返回主体的权限列表。一般来说,授权过程将使用此方法(由 Authentication 接口定义)的信息来确定,对于特定的请求,是否允许请求。

此方法作为 AuthorizationManager 实例获取 GrantedAuthority 精确字符串表示的一种方式。通过提供字符串表示,大多数 AuthorizationManager 实现可以轻松地 读取 GrantedAuthority。如果 GrantedAuthority 不能准确地表示为字符串,则被视为 复杂,并且 getAuthority() 方法必须返回 null

一个复杂的 GrantedAuthority 的主要例子是存储与各种客户账户号码相关的操作和权限阈值的实现。试图将这个复杂的 GrantedAuthority 表示为字符串将带来相当大的挑战。因此,getAuthority() 方法应该返回 null。这向任何 AuthorizationManager 发出信号,它需要支持特定的 GrantedAuthority 实现来理解其内容。

Spring Security 具有一个名为 SimpleGrantedAuthority 的具体 GrantedAuthority 实现名。此实现允许将任何用户指定的字符串转换为 GrantedAuthority。所有集成到安全架构中的 AuthenticationProvider 实例都使用 SimpleGrantedAuthority 来填充 Authentication 对象。

默认情况下,基于角色的授权规则涉及前缀 ROLE_。因此,如果授权规则要求安全上下文具有 USER 角色,Spring Security 将自动寻找返回 ROLE_USERGrantedAuthority#getAuthority

记住,授权是一个二元决策——用户要么可以访问受保护资源,要么不能。在授权方面没有歧义。

Spring Security 框架中,智能面向对象设计无处不在,授权决策管理也不例外。

Spring Security 中,o.s.s.access.AccessDecisionManager 接口定义了两个简单且逻辑上合理的函数,这些函数合理地融入了请求处理决策流程中,具体如下:

  • supports:这个逻辑操作实际上包含两个方法,允许 AccessDecisionManager 实现报告它是否支持当前请求。

  • decide:此方法允许 AccessDecisionManager 实现根据请求上下文和安全配置验证是否允许访问并接受请求。实际上,Decide 方法没有返回值,而是通过抛出异常来报告请求被拒绝。

特定的异常类型可以进一步指导应用程序采取哪些行动来解决授权决策。o.s.s.access.AccessDeniedException 接口是在授权领域中最常见的异常,并且值得过滤器链进行特殊处理。

AccessDecisionManager 的实现完全可以通过标准的 Spring Bean 绑定和引用进行配置。默认的 AccessDecisionManager 实现提供了一个基于 AccessDecisionVoter 和投票聚合的访问授权机制。

投票者是在授权序列中的参与者,其任务是评估以下任何一项或所有内容:

  • 受保护资源的请求上下文(例如,请求 IP 地址的 URL)

  • 用户提供的凭证(如果有)

  • 被访问的安全资源

  • 系统的配置参数以及资源本身

在演示了请求授权的过程之后,我们将深入探讨调用管理。

调用处理

Spring Security 提供了拦截器,负责管理对安全对象的访问,无论是方法调用还是网络请求。AuthorizationManager 实例在做出调用前的决策中起着至关重要的作用,决定调用是否被允许继续进行。此外,这些实例还参与调用后的决策,确定是否可以返回特定的值。

AuthorizationManager

AuthorizationManager 优先于 AccessDecisionManagerAccessDecisionVoter。建议自定义 AccessDecisionManagerAccessDecisionVoter 的应用程序使用 AuthorizationManager 进行过渡。

Spring Security 的基于请求、方法和消息的授权组件调用 AuthorizationManager 实例,将做出最终访问控制决策的责任分配给它们。

AuthorizationManagercheck 方法接收做出授权决策所需的所有相关信息。具体来说,传递安全对象允许检查安全对象实际调用中的参数。例如,如果安全对象是一个 MethodInvocation,查询它以获取任何客户端参数变得简单。随后,可以在 AuthorizationManager 中实现安全逻辑,以确保主体有权操作该客户。预期实现将在授予访问权限时返回正面的 AuthorizationDecision,在拒绝访问时返回负面的 AuthorizationDecision,在放弃做出决策时返回 null AuthorizationDecision

verify 函数在达到负面的 AuthorizationDecision 时调用 check 并抛出 AccessDeniedException

基于 Delegating 的 AuthorizationManager 实现

尽管用户有灵活性来实现自己的 AuthorizationManager 以管理授权的所有方面,但 Spring Security 提供了一个委托的 AuthorizationManager,旨在与单个 AuthorizationManager 协同工作。

RequestMatcherDelegatingAuthorizationManager 将请求与最合适的委托 AuthorizationManager 对齐。对于方法安全,可以使用 AuthorizationManagerBeforeMethodInterceptorAuthorizationManagerAfterMethodInterceptor

对于 AuthorizationManager 实现的相关类,在 图 13.1 中进行了概述供参考。1*:

图 13.1 – AuthorizationManager 的实现

图 13.1 – AuthorizationManager 的实现

采用这种方法,可以咨询一组 AuthorizationManager 实现来做出授权决策。

在接下来的小节中,我们将更深入地探讨一些授权管理器。

AuthorityAuthorizationManager

Spring Security 提供的主要 AuthorizationManagerAuthorityAuthorizationManager。它配置了特定的一组权限,用于在当前的 Authentication 中进行检查。如果 Authentication 包含任何配置的权限,它将产生一个正面的 AuthorizationDecision;否则,将导致一个负面的 AuthorizationDecision

AuthenticatedAuthorizationManager

另一个可用的管理器是AuthenticatedAuthorizationManager。它在区分匿名完全认证记住我认证用户方面非常有用。一些网站在记住我认证下提供有限的访问权限,但需要用户通过登录来确认身份以获得完全访问权限。

AuthorizationManagers

AuthorizationManagers还提供了将单个AuthorizationManagers组合成更复杂表达式的有用静态工厂。

自定义AuthorizationManagers

当然,你可以选择实现自定义的AuthorizationManager,以便包含几乎任何访问控制逻辑。它可以针对你的应用程序定制,与业务逻辑相关,或涉及安全管理逻辑。例如,你可以创建一个能够查询 Open Policy Agent 或你自己的授权数据库的实现。

在深入研究调用管理之后,我们将继续探讨AccessDecisionManagerAccessDecisionVoter的定制化。

修改AccessDecisionManagerAccessDecisionVoter

AuthorizationManager引入之前,Spring Security引入了AccessDecisionManagerAccessDecisionVoter

在某些场景下,例如在迁移旧应用程序时,可能更倾向于使用调用AccessDecisionManagerAccessDecisionVoterAuthorizationManager

要调用现有的AccessDecisionManager,你可以使用:

@Component
public class AccessDecisionManagerAuthorizationManagerAdapter implements AuthorizationManager {
    private final AccessDecisionManager accessDecisionManager;
    private final SecurityMetadataSource securityMetadataSource;
    @Override
    public AuthorizationDecision check(Supplier<Authentication> authentication, Object object) {
       try {
          Collection<ConfigAttribute> attributes = this.securityMetadataSource.getAttributes(object);
          this.accessDecisionManager.decide(authentication.get(), object, attributes);
          return new AuthorizationDecision(true);
       } catch (AccessDeniedException ex) {
          return new AuthorizationDecision(false);
       }
    }
    @Override
    public void verify(Supplier<Authentication> authentication, Object object) {
       Collection<ConfigAttribute> attributes = this.securityMetadataSource.getAttributes(object);
       this.accessDecisionManager.decide(authentication.get(), object, attributes);
    }
}

然后,将其集成到你的SecurityFilterChain中。

或者,如果你只想调用AccessDecisionVoter,你可以使用:

@Component
public class AccessDecisionVoterAuthorizationManagerAdapter implements AuthorizationManager {
    private final AccessDecisionVoter accessDecisionVoter;
    private final SecurityMetadataSource securityMetadataSource;
    @Override
    public AuthorizationDecision check(Supplier<Authentication> authentication, Object object) {
       Collection<ConfigAttribute> attributes = this.securityMetadataSource.getAttributes(object);
       int decision = this.accessDecisionVoter.vote(authentication.get(), object, attributes);
       switch (decision) {
          case ACCESS_GRANTED:
             return new AuthorizationDecision(true);
          case ACCESS_DENIED:
             return new AuthorizationDecision(false);
       }
       return null;
    }
}

然后,将其集成到你的SecurityFilterChain中。

旧版授权组件

在本节中,我们将更详细地研究Spring Security中存在但随Spring Security 6引入而弃用的某些授权组件。

AccessDecisionManager

AbstractSecurityInterceptor会调用AccessDecisionManager,该组件负责做出最终的访问控制决策。AccessDecisionManager接口包含三个方法:

void decide(Authentication authentication, Object secureObject,
       Collection<ConfigAttribute> attrs) throws AccessDeniedException;
boolean supports(ConfigAttribute attribute);
boolean supports(Class clazz);

AccessDecisionManagerdecide方法接收做出授权决策所需的所有相关信息。具体来说,传递安全对象允许检查实际调用安全对象内的参数。例如,如果安全对象是MethodInvocation,你可以查询MethodInvocation中的任何Customer参数,然后在AccessDecisionManager中实现安全逻辑以验证主体是否有权对该客户进行操作。如果拒绝访问,实现应抛出AccessDeniedException

supports(ConfigAttribute) 方法在启动时由 AbstractSecurityInterceptor 调用,以确定 AccessDecisionManager 是否可以处理提供的 ConfigAttributesupports(Class clazz) 方法由安全拦截器实现调用,以确保配置的 AccessDecisionManager 支持安全拦截器提供的安全对象的类型。

基于投票的 AccessDecisionManager 实现

尽管用户有灵活性来实现自己的 AccessDecisionManager 以监督授权的所有方面,但 Spring Security 提供了基于投票机制的多种 AccessDecisionManager 实现。相关类在 投票决策管理器 中解释。

下图展示了 AccessDecisionManager 接口:

图 13.2 – 投票决策管理器

图 13.2 – 投票决策管理器

通过这种方法,将咨询一系列 AccessDecisionVoter 实现以进行授权决策。AccessDecisionManager 随后根据其对投票的评估决定是否抛出 AccessDeniedException

Spring Security 提供了三个具体的 AccessDecisionManager 实现来汇总投票。ConsensusBased 实现基于非弃权投票的共识允许或拒绝访问。可配置属性在投票相等或所有投票者弃权的情况下控制行为。AffirmativeBased 实现如果收到一个或多个 ACCESS_GRANTED 投票(只要至少有一个授予投票,就忽略拒绝投票),则授予访问权限。类似于 ConsensusBased,它有一个参数控制所有投票者弃权时的行为。UnanimousBased 实现要求一致同意的 ACCESS_GRANTED 投票才能访问,忽略弃权。它对任何 ACCESS_DENIED 投票拒绝访问。与其他类似,它有一个参数控制所有投票者弃权时的行为。

可以实现自定义的 AccessDecisionManager 实例以自定义投票计数。例如,来自特定 AccessDecisionVoter 的投票可能具有额外的权重,而来自特定投票者的拒绝投票可能具有否决效果。

RoleVoter

RoleVoterSpring Security 提供的最常用的 AccessDecisionVoter,它将配置属性解释为角色名称,并在用户被分配该角色时投票授予访问权限。

如果任何 ConfigAttributeROLE_ 前缀开头,则会投一票。如果存在返回字符串表示形式(通过 getAuthority() 方法)与一个或多个以 ROLE_ 前缀开头的 ConfigAttribute 实例完全匹配的 GrantedAuthority,则授予访问权限。如果没有任何以 ROLE_ 开头的 ConfigAttribute 与之精确匹配,RoleVoter 将投票拒绝访问。如果没有任何 ConfigAttributeROLE_ 开头,投票者将弃权。

AuthenticatedVoter

另一个隐式投票者是 AuthenticatedVoter,用于区分 匿名完全认证记住我 认证用户。许多网站在 记住我 认证下允许有限的访问,但需要用户通过登录来确认身份以获得完全访问。

为授予匿名访问而处理的 IS_AUTHENTICATED_ANONYMOUSLY 属性由 AuthenticatedVoter 处理,如前例所示。

自定义投票者

实现自定义的 AccessDecisionVoter 可以使几乎任何访问控制逻辑得以包含。它可以根据您的应用程序定制,与业务逻辑相关,或涉及安全管理逻辑。例如,Spring 网站上的一篇博客文章概述了使用投票者拒绝为被暂停账户的用户提供实时访问。

基于表达式的请求授权

正如您可能预料的,Voter 实现 o.s.s.web.access.expression.WebExpressionVoter,它理解如何评估 SpEL 表达式。WebExpressionVoter 类依赖于 SecurityExpressionHandler 接口来实现这一目的。SecurityExpressionHandler 接口负责评估表达式,并为表达式中所引用的安全特定方法提供支持。该接口的默认实现公开了在 o.s.s.web.access.expression.WebSecurityExpressionRoot 类中定义的方法。

这些类之间的流程和关系如下所示:

图 13.3 –  和  之间的关系

图 13.3 – WebSecurityExpressionRootAccessDecisionManager 之间的关系

既然我们已经了解了请求授权的工作原理,让我们通过实现一些关键接口的几个自定义实现来巩固我们的理解。

Spring Security 的授权功能通过其如何适应自定义需求来展示其实力。让我们探讨一些场景,这将有助于加强我们对整体架构的理解。

动态定义对 URL 的访问控制

Spring Security 提供了多种方法将 ConfigAttribute 对象映射到资源。例如,requestMatchers() 方法确保开发人员可以轻松地限制其 Web 应用程序中特定 HTTP 请求的访问。在幕后,o.s.s.acess.SecurityMetadataSource 的一个实现被填充了这些映射,并查询以确定要授权进行任何给定 HTTP 请求所需的内容。

虽然 requestMatchers() 方法非常简单,但有时可能希望提供一种自定义机制来确定 URL 映射。一个例子可能是如果应用程序需要能够动态提供访问控制规则。让我们演示一下将我们的 URL 授权配置移动到数据库需要做些什么。

配置 RequestConfigMappingService

第一步是能够从数据库中获取必要的信息。这将替换从我们的安全配置 Bean 中读取 requestMatchers() 方法的逻辑。为了做到这一点,本章的示例代码包含 JpaRequestConfigMappingService,它将从数据库中获取表示为 RequestConfigMappingAnt Pattern 和表达式的映射。相当简单的实现如下:

//src/main/java/com/packtpub/springsecurity/web/access/intercept/JpaRequestConfigMappingService.java
@Repository
public class JpaRequestConfigMappingService implements RequestConfigMappingService {
    private final SecurityFilterMetadataRepository securityFilterMetadataRepository;
    public JpaRequestConfigMappingService(final SecurityFilterMetadataRepository securityFilterMetadataRepository) {
          this.securityFilterMetadataRepository = securityFilterMetadataRepository;
    }
    public List<RequestConfigMapping> getRequestConfigMappings() {
          return securityFilterMetadataRepository
                .findAll()
                .stream()
                .sorted(Comparator.comparingInt(SecurityFilterMetadata::getSortOrder))
                .map(md -> new RequestConfigMapping(
                      new AntPathRequestMatcher(md.getAntPattern()),
                      new SecurityConfig(md.getExpression()))).toList();
    }
}

重要的是要注意,就像 requestMatchers() 方法一样,顺序很重要。因此,我们确保结果按 sort_order 列排序。该服务创建一个 AntRequestMatcher 并将其与 SecurityConfig 关联,SecurityConfigConfigAttribute 的一个实例。这将提供 HTTP 请求到 ConfigAttribute 对象的映射,这些对象可以被 Spring Security 用于保护我们的 URL。

我们需要创建一个用于映射到 Jakarta PersistenceJPA)的域对象,如下所示:

//src/main/java/com/packtpub/springsecurity/domain/SecurityFilterMetadata.java
@Entity
@Table(name = "security_filter_metadata")
public class SecurityFilterMetadata implements Serializable {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Integer id;
    private String antPattern;
    private String expression;
    private Integer sortOrder;
... setters / getters ...
}

最后,我们需要创建一个 Spring Data 存储库对象,如下所示:

//src/main/java/com/packtpub/springsecurity/repository/ SecurityFilterMetadataRepository.java
public interface SecurityFilterMetadataRepository extends JpaRepository<SecurityFilterMetadata, Integer> {}

为了使新的服务能够工作,我们需要像服务实现一样初始化我们的数据库,包括模式和访问控制映射。security_filter_metadata 表模式可以由 spring-data-jpa 自动生成。

然后,我们可以使用来自 SecurityConfig.java 文件的相同 requestMatchers() 映射来生成 data.sql 文件:

//src/main/resources/data.sql
insert into security_filter_metadata(id,ant_pattern,expression,sort_order) values (115, '/','permitAll',15);
insert into security_filter_metadata(id,ant_pattern,expression,sort_order) values (120, '/login/*','permitAll',20);
insert into security_filter_metadata(id,ant_pattern,expression,sort_order) values (130, '/logout','permitAll',30);
insert into security_filter_metadata(id,ant_pattern,expression,sort_order) values (140, '/signup/*','permitAll',40);
insert into security_filter_metadata(id,ant_pattern,expression,sort_order) values (150, '/errors/**','permitAll',50);
insert into security_filter_metadata(id,ant_pattern,expression,sort_order) values (160, '/admin/**','hasRole("ADMIN")',60);
insert into security_filter_metadata(id,ant_pattern,expression,sort_order) values (170, '/events/','hasRole("ADMIN")',70);
insert into security_filter_metadata(id,ant_pattern,expression,sort_order) values (180, '/**','hasRole("USER")',80);

一旦配置了 RequestConfigMappingService,我们将探讨自定义 SecurityMetadataSource 的实现。

自定义 SecurityMetadataSource 实现

为了让 Spring Security 了解我们的 URL 映射,我们需要提供一个自定义的 FilterInvocationSecurityMetadataSource 实现。FilterInvocationSecurityMetadataSource 包扩展了 SecurityMetadataSource 接口,该接口在给定特定的 HTTP 请求时,为 Spring Security 提供了确定是否应授予访问权限所需的信息。让我们看看我们如何利用我们的 RequestConfigMappingService 接口来实现 SecurityMetadataSource 接口:

//src/main/java/com/packtpub/springsecurity/web/access/intercept/FilterInvocationServiceSecurityMetadataSource.java
@Component
public class FilterInvocationServiceSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
    private FilterInvocationSecurityMetadataSource delegate;
    private final RequestConfigMappingService requestConfigMappingService;
    public FilterInvocationServiceSecurityMetadataSource (RequestConfigMappingService filterInvocationService) {
          this.requestConfigMappingService = filterInvocationService;
    }
    public Collection<ConfigAttribute> getAllConfigAttributes() {
          return this.delegate.getAllConfigAttributes();
    }
    public Collection<ConfigAttribute> getAttributes(Object object) {
          if (delegate == null)
             getDelegate();
          return this.delegate.getAttributes(object);
    }
    public boolean supports(Class<?> clazz) {
         return this.delegate.supports(clazz);
    }
    public void getDelegate() {
          List<RequestConfigMapping> requestConfigMappings = requestConfigMappingService.getRequestConfigMappings();
          LinkedHashMap<RequestMatcher, Collection<ConfigAttribute>> requestMap = new LinkedHashMap<RequestMatcher, Collection<ConfigAttribute>>(requestConfigMappings.size());
          for (RequestConfigMapping requestConfigMapping : requestConfigMappings) {
             RequestMatcher matcher = requestConfigMapping.getMatcher();
             requestMap.put(matcher, requestConfigMapping.getAttributes());
          }
          this.delegate = new ExpressionBasedFilterInvocationSecurityMetadataSource(requestMap, new DefaultWebSecurityExpressionHandler());
    }
}

我们可以使用我们的 RequestConfigMappingService 接口来创建一个映射到 ConfigAttribute 对象的 RequestMatcher 对象的映射。然后,我们将工作委托给 ExpressionBasedFilterInvocationSecurityMetadataSource 的一个实例来完成。为了简化,当前的实现将需要重新启动应用程序以获取更改。然而,通过一些小的修改,我们可以避免这种不便。

注册自定义 SecurityMetadataSource

现在,我们剩下的工作就是配置 FilterInvocationServiceSecurityMetadataSource。唯一的问题是 Spring Security 不支持直接配置自定义的 FilterInvocationServiceSecurityMetadataSource 接口。这并不太难,所以我们将在这个 SecurityConfig 文件中注册这个 SecurityMetadataSource 到我们的 FilterSecurityInterceptor

//src/main/java/com/packtpub/springsecurity/configuration/ SecurityConfig.java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http,
       FilterInvocationServiceSecurityMetadataSource metadataSource,
       AccessDecisionManager accessDecisionManager) throws Exception {
    http.authorizeRequests().anyRequest().authenticated();
    http.authorizeRequests().accessDecisionManager(accessDecisionManager);
    http.authorizeRequests()
          .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
             public <O extends FilterSecurityInterceptor> O postProcess(
                   O fsi) {
                fsi.setPublishAuthorizationSuccess(true);
                fsi.setSecurityMetadataSource(metadataSource);
                return fsi;
             }
          });
...omitted for brevity
    return http.build();
}

这设置了我们的自定义 SecurityMetadataSource 接口,使用 FilterSecurityInterceptor 对象作为默认的元数据源。

现在数据库正在用于映射我们的安全配置,我们可以从我们的 SecurityConfig.java 文件中移除 requestMatchers() 方法。

你现在应该能够启动应用程序并测试以确保我们的 URL 是安全的,就像它们应该的那样。我们的用户不会注意到任何区别,但我们知道我们的 URL 映射现在保存在数据库中了。

重要注意事项

你的代码现在应该看起来像这样:calendar13.01-calendar

创建自定义表达式

o.s.s.access.expression.SecurityExpresssionHandler 接口是 Spring Security 抽象创建和初始化 Spring 表达式的方式。就像 SecurityMetadataSource 接口一样,有一个用于创建 Web 请求表达式的实现,以及一个用于创建方法安全表达式的实现。在本节中,我们将探讨如何轻松地添加新的表达式。

配置自定义的 SecurityExpressionRoot

假设我们想要支持一个名为 isLocal 的自定义 Web Expression,当主机是 localhost 时返回 true,否则返回 false。这个新方法可以用来为我们的 SQL 控制台提供额外的安全保护,确保它只从部署 Web 应用程序的同台机器访问。

这是一个没有增加任何安全效益的人工示例,因为主机来自 HTTP 请求的头部。这意味着恶意用户可以注入一个头部,声称主机是 localhost,即使他们请求的是外部域名。

我们所看到的所有表达式都因为 SecurityExpressionHandler 接口通过一个 o.s.s.access.expression.SecurityExpressionRoot 的实例使它们可用。如果你打开这个对象,你会找到我们在 Spring 表达式中使用的那些方法和属性(即 hasRolehasPermission 等),它们在 Web 和方法安全中都很常见。一个子类提供了特定于 Web 和方法表达式的那些方法。例如,o.s.s.web.access.expression.WebSecurityExpressionRoot 为 Web 请求提供了 hasIpAddress 方法。

要创建一个自定义的 Web SecurityExpressionhandler,我们首先需要创建一个 WebSecurityExpressionRoot 的子类,定义我们的 isLocal 方法,如下所示:

//src/main/java/com/packtpub/springsecurity/web/access/expression/ CustomWebSecurityExpressionRoot.java
public class CustomWebSecurityExpressionRoot extends WebSecurityExpressionRoot {
    public CustomWebSecurityExpressionRoot(Authentication a, FilterInvocation fi) {
       super(a, fi);
    }
    public boolean isLocal() {
       return "localhost".equals(request.getServerName());
    }
}

重要注意事项

需要注意的是,getServerName()返回的是Host头中提供的值。这意味着恶意用户可以将不同的值注入到头中,以绕过约束。然而,大多数应用程序服务器和代理可以强制执行Host头的值。在利用这种方法之前,请阅读适当的文档,以确保恶意用户不会注入Host头值以绕过此类约束。

配置和使用 CustomWebSecurityExpressionHandler

为了使我们的新方法可用,我们需要创建一个自定义的SecurityExpressionHandler接口,该接口利用我们的新根对象。这就像扩展WebSecurityExpressionHandler一样简单,如下所示:

//src/main/java/com/packtpub/springsecurity/web/access/expression/ CustomWebSecurityExpressionHandler.java
@Component
public class CustomWebSecurityExpressionHandler extends DefaultWebSecurityExpressionHandler {
    private final AuthenticationTrustResolver trustResolver = new AuthenticationTrustResolverImpl();
    @Override
    protected SecurityExpressionOperations createSecurityExpressionRoot(Authentication authentication, FilterInvocation fi) {
       WebSecurityExpressionRoot root = new CustomWebSecurityExpressionRoot(authentication, fi);
       root.setPermissionEvaluator(getPermissionEvaluator());
       root.setTrustResolver(trustResolver);
       root.setRoleHierarchy(getRoleHierarchy());
       return root;
    }
}

我们执行与超类相同的步骤,只是我们使用包含新方法的CustomWebSecurityExpressionRoot

CustomWebSecurityExpressionRoot成为我们 SpEL 表达式的根。

重要提示

对于更多详细信息,请参阅 Spring 参考文档中的 SpEL 文档:docs.spring.io/spring-framework/reference/core/expressions.xhtml

配置和使用自定义 SecurityExpressionHandler

我们现在需要配置CustomWebSecurityExpressionHandler。幸运的是,这可以通过使用Spring Security命名空间配置支持轻松完成。将以下配置添加到SecurityConfig.java文件中:

// Web Expression Handler:
http.authorizeRequests()
       .expressionHandler(customWebSecurityExpressionHandler);

现在,让我们更新我们的初始化 SQL 查询以使用新的表达式。更新data.sql文件,使其要求用户必须是ROLE_ADMIN,并且请求来自本地机器。您会注意到,我们可以写local而不是isLocal,因为 SpEL 支持 Java Bean 约定:

//src/main/resources/data.sql
insert into security_filter_metadata(id,ant_pattern,expression,sort_order) values (160, '/admin/**','local and hasRole("ADMIN")',60);

重新启动应用程序,并使用localhost:8080/admin/h2admin1@example.com/admin1访问 H2 控制台,以查看管理控制台。

如果使用127.0.0.1:8080/admin/h2admin1@example.com/admin1访问 H2 控制台,将显示访问被拒绝页面。

重要提示

您的代码现在应该看起来像这样:calendar13.02-calendar

CustomWebSecurityExpressionHandler的替代方案

在检查CustomWebSecurityExpressionHandler的使用后,我们将通过使用自定义PermissionEvaluator来增强CalendarService的安全性,来研究替代方法。

方法安全性是如何工作的?

方法安全性的访问决策机制——是否允许给定的请求——在概念上与网络请求访问的访问决策逻辑相同。AccessDecisionManager轮询一组AccessDecisionVoter实例,每个实例都可以提供一个决定授予或拒绝访问或弃权。AccessDecisionManager的具体实现聚合投票者的决策,并得出一个允许方法调用的总体决策。

由于 servlet 过滤器使得可安全请求的拦截(和拒绝)相对简单,因此 Web 请求访问决策较为简单。由于方法调用可能发生在任何地方,包括不是由Spring Security直接配置的代码区域,Spring Security的设计者选择使用 Spring 管理的面向切面编程(Aspect-Oriented ProgrammingAOP)方法来识别、评估和确保方法调用。

下面的高级流程图说明了在方法调用授权决策中涉及的主要参与者:

图 13.4 – 方法调用授权决策中涉及的主要类

图 13.4 – 方法调用授权决策中涉及的主要类

我们可以看到,Spring Securityo.s.s.access.intercept.aopalliance.MethodSecurityInterceptor 是由标准的 Spring AOP 运行时调用来拦截感兴趣的方法调用的。从这里,是否允许方法调用的逻辑相对简单,如前所述的流程图。

在这一点上,我们可能会对方法安全特性的性能产生疑问。显然,MethodSecurityInterceptor 不能对应用中的每个方法调用进行调用——那么方法或类上的注解是如何导致 AOP 拦截的呢?

首先,默认情况下,AOP 代理不会对所有 Spring 管理的 bean 进行调用。相反,如果在Spring Security配置中定义了@EnableMethodSecurity,则会注册一个标准的 Spring AOP o.s.beans.factory.config.BeanPostProcessor,该处理器将检查 AOP 配置以查看是否有任何 AOP 顾问指示需要进行代理(和拦截)。这个工作流程是标准的 Spring AOP 处理(称为Spring Security。所有注册的BeanPostProcessor实例都在 Spring ApplicationContext初始化时运行;毕竟,Spring 的 bean 配置已经发生。

AOP 自动代理功能查询所有注册的PointcutAdvisor实例,以查看是否有 AOP 切入点可以解析应该应用 AOP 建议的方法调用。Spring Security实现了o.s.s.access.intercept.aopalliance.MethodSecurityMetadataSourceAdvisor类,该类检查所有配置的方法安全注解并设置适当的 AOP 拦截。请注意,只有声明了方法安全注解的接口或类才会被代理以进行 AOP!

重要注意事项

请注意,强烈建议在接口上声明 AOP 规则(和其他安全注解),而不是在实现类上。虽然可以使用 Spring 的 CGLIB 代理来使用类,但这可能会意外地改变应用程序的行为,并且通常不如在接口上通过 AOP 进行的安全声明(通过 AOP)语义正确。MethodSecurityMetadataSourceAdvisor将影响方法的 AOP 建议决策委托给一个o.s.s.access.method.MethodSecurityMetadataSource实例。方法安全注解的不同形式各自有自己的MethodSecurityMetadataSource实现,它用于依次检查每个方法、类,并在运行时添加要执行的 AOP 建议。

下面的图示说明了这个过程是如何发生的:

图 13.5 – 方法安全 AOP 拦截器

图 13.5 – 方法安全 AOP 拦截器

根据您应用程序中配置的 Spring bean 数量和受保护方法注解的数量,添加方法安全代理可能会增加初始化ApplicationContext所需的时间。然而,一旦 Spring 上下文初始化完成,对单个代理 bean 的性能影响可以忽略不计。

现在我们已经了解了如何使用 AOP 来应用Spring Security,让我们通过创建一个自定义的PermissionEvaluator来加强我们对Spring Security授权的理解。

创建自定义 PermissionEvaluator

在上一章中,我们展示了我们可以使用Spring Security的内置PermissionEvaluator实现,即AclPermissionEvaluator,来限制对我们的应用程序的访问。虽然功能强大,但这通常比必要的复杂。我们还发现了SpEL如何制定复杂的表达式,这些表达式能够保护我们的应用程序。虽然简单,但使用复杂表达式的缺点之一是逻辑没有集中化。幸运的是,我们可以轻松地创建一个自定义的PermissionEvaluator,它能够集中我们的授权逻辑,同时避免使用 ACLs 的复杂性。

CalendarPermissionEvaluator

如下所示的是我们自定义的PermissionEvaluator的简化版本,它不包含任何验证:

//src/main/java/com/packtpub/springsecurity/access/CalendarPermissionEvalua tor.java
public final class CalendarPermissionEvaluator implements PermissionEvaluator {
    private final EventDao eventDao;
    public CalendarPermissionEvaluator(EventDao eventDao) {
        this.eventDao = eventDao;
    }
    @Override
    public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) {
        if(targetDomainObject instanceof Event) {
            return hasPermission(authentication, (Event) targetDomainObject, permission);
        }
        return targetDomainObject == null;
    }
    @Override
    public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType,
                                 Object permission) {
        if(!Event.class.getName().equals(targetType)) {
            throw new IllegalArgumentException("targetType is not supported. Got "+targetType);
        }
        if(!(targetId instanceof Integer)) {
            throw new IllegalArgumentException("targetId type is not supported. Got "+targetType);
        }
        Event event = eventDao.getEvent((Integer)targetId);
        return hasPermission(authentication, event, permission);
    }
    private boolean hasPermission(Authentication authentication, Event event, Object permission) {
        if(event == null) {
            return true;
        }
        String currentUserEmail = authentication.getName();
        String ownerEmail = extractEmail(event.getOwner());
        if("write".equals(permission)) {
            return currentUserEmail.equals(ownerEmail);
        } else if("read".equals(permission)) {
            String attendeeEmail = extractEmail(event.getAttendee());
            return currentUserEmail.equals(attendeeEmail) || currentUserEmail.equals(ownerEmail);
        }
        throw new IllegalArgumentException("permission "+permission+" is not supported.");
    }
    private String extractEmail(CalendarUser user) {
        if(user == null) {
            return null;
        }
        return user.getEmail();
    }
}

逻辑与我们已经使用的 Spring 表达式相当相似,只是它区分了读和写访问。如果当前用户的用户名与Event对象的拥有者的电子邮件匹配,则授予读和写访问权限。如果当前用户的电子邮件与与会者的电子邮件匹配,则授予读访问权限。否则,拒绝访问。

重要提示

应当注意,每个域对象都使用单个 PermissionEvaluator。因此,在实际情况下,我们首先必须执行 instanceof 检查。例如,如果我们还正在保护我们的 CalendarUser 对象,这些对象可以传递到这个相同的实例中。有关这些细微变化的完整示例,请参阅书中包含的示例代码。

配置 CalendarPermissionEvaluator

然后,我们可以利用本章提供的 CustomAuthorizationConfig.java 配置来提供一个使用我们的 CalendarPermissionEvaluatorExpressionHandler,如下所示:

//src/main/java/com/packtpub/springsecurity/configuration/ CustomAuthorizationConfig.java
@Bean
public DefaultMethodSecurityExpressionHandler defaultExpressionHandler(EventDao eventDao){
    DefaultMethodSecurityExpressionHandler deh = new DefaultMethodSecurityExpressionHandler();
    deh.setPermissionEvaluator(
            new CalendarPermissionEvaluator(eventDao));
    return deh;
}

配置应类似于 第十二章 访问控制列表 中的配置,但我们现在使用我们的 CalendarPermissionEvalulator 类而不是 AclPermissionEvaluator

保护我们的 CalendarService

最后,我们可以使用 @PostAuthorize 注解保护我们的 CalendarService getEvent(int eventId) 方法。您将注意到这一步与我们之前在 第一章 不安全应用程序的解剖结构 中所做的完全相同,我们只是更改了 PermissionEvaluator 的实现:

//src/main/java/com/packtpub/springsecurity/service/CalendarService.java
@PostAuthorize("hasPermission(returnObject,'read')")
Event getEvent(int eventId);

如果您还没有这样做,请重新启动应用程序,使用用户名/密码 admin1@example.com/admin1 登录,并使用 欢迎 页面上的链接访问 events/101。将显示 访问被拒绝 页面。

重要提示

您的代码现在应该看起来像这样:calendar13.03-calendar

然而,我们希望 ROLE_ADMIN 用户能够访问所有事件。

自定义 PermissionEvaluator 的好处

只有一个方法受到保护,更新注解以检查用户是否具有 ROLE_ADMIN 角色或具有权限将非常简单。然而,如果我们已经保护了所有使用事件的业务方法,这会变得相当繁琐。相反,我们只需更新我们的 CalendarPermissionEvaluator。进行以下更改:

  private boolean hasPermission(Authentication authentication, Event event, Object permission) {
      if(event == null) {
          return true;
      }
// Custom Role verification
GrantedAuthority adminRole = new SimpleGrantedAuthority("ROLE_ADMIN");
if(authentication.getAuthorities().contains(adminRole)) {
    return true;
... omitted for brevity
}
  }

现在,重新启动应用程序并重复之前的练习。这次,会议通话事件将成功显示。

您可以看到,封装我们的授权逻辑的能力可以非常有益。然而,有时,扩展表达式本身可能是有用的。

重要提示

您的代码现在应该看起来像这样:calendar13.04-calendar

删除 CustomWebSecurityExpressionHandler 类

定义自定义 Web 表达式 有一种更简单的方法。

在我们之前的示例中,您可以删除以下类:CustomWebSecurity ExpressionHandlerCustomWebSecurityExpressionRoot

声明一个包含自定义 Web 表达式 的 Spring bean:

//src/main/java/com/packtpub/springsecurity/access/expression/ CustomWebExpression.java
@Component
public class CustomWebExpression {
    public boolean isLocalHost(final HttpServletRequest request) {
       return "localhost".equals(request.getServerName());
    }
}

CustomAuthorizationConfig 类中,添加以下 bean:

//src/main/java/com/packtpub/springsecurity/configuration/ CustomAuthorizationConfig.java
@Bean
public DefaultWebSecurityExpressionHandler customWebSecurityExpressionHandler (){
    return new DefaultWebSecurityExpressionHandler();
}

我们可以删除以下声明到 SecurityConfig 类中的 CustomWebSecurityExpressionHandler

//src/main/java/com/packtpub/springsecurity/configuration/ SecurityConfig.java
// Line of Expression Handler needs to be removed
http.authorizeRequests()
.expressionHandler(customWebSecurityExpressionHandler);

现在,让我们更新我们的初始化 SQL 查询以适应新表达式的语法。更新data.sql如下:

//src/main/resources/data.sql
insert into security_filter_metadata(id,ant_pattern,expression,sort_order) values (160, '/admin/**','@customWebExpression.isLocalHost(request) and hasRole("ADMIN")',60);

重新启动应用程序,并使用用户admin1@example.com/admin1测试两个 URL 的应用程序访问:

  • http://127.0.0.1:8080/admin/h2:访问应该被拒绝

  • http://localhost:8080/admin/h2:访问应该被允许

重要提示

您的代码现在应该看起来像这样:calendar13.05-calendar

声明自定义的 AuthorizationManager

Spring Security 6已弃用AccessDecissionManagerAccessDecisionVoter的使用。

建议的方法是实现一个自定义的AuthorizationManager,正如本章引言中所述。为了实现这一目标,您可以遵循以下步骤。

首先,我们将创建一个自定义的AuthorizationManager实现,该实现根据security_filter_metadata表的定义检查允许的权限:

//src/main/java/com/packtpub/springsecurity/access/ CustomAuthorizationManager.java
@Component
public class CustomAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> {
    private final SecurityExpressionHandler<RequestAuthorizationContext> expressionHandler;
    private final RequestConfigMappingService requestConfigMappingService;
    private static final Logger logger = LoggerFactory.getLogger(CustomAuthorizationManager.class);
    public CustomAuthorizationManager(DefaultHttpSecurityExpressionHandler expressionHandler, RequestConfigMappingService requestConfigMappingService) {
       this.expressionHandler = expressionHandler;
       this.requestConfigMappingService = requestConfigMappingService;
    }
    @Override
    public AuthorizationDecision check(Supplier<Authentication> authentication, RequestAuthorizationContext context) {
       List<RequestConfigMapping> requestConfigMappings = requestConfigMappingService.getRequestConfigMappings();
       LinkedHashMap<RequestMatcher, Collection<ConfigAttribute>> requestMap = new LinkedHashMap<>(requestConfigMappings.size());
       for (RequestConfigMapping requestConfigMapping : requestConfigMappings) {
          RequestMatcher matcher = requestConfigMapping.getMatcher();
          if (matcher.matches(context.getRequest())) {
             requestMap.put(matcher, requestConfigMapping.getAttributes());
             String expressionStr = requestConfigMapping.getAttributes().iterator().next().getAttribute();
             Expression expression = this.expressionHandler.getExpressionParser().parseExpression(expressionStr);
             try {
                EvaluationContext evaluationContext = this.expressionHandler.createEvaluationContext(authentication, context);
                boolean granted = ExpressionUtils.evaluateAsBoolean(expression, evaluationContext);
                return new ExpressionAuthorizationDecision(granted, expression);
             } catch (AccessDeniedException ex) {
                logger.error("Access denied exception: {}", ex.getMessage());
                return new AuthorizationDecision(false);
             }
          }
       }
       return new AuthorizationDecision(false);
    }
}

然后,我们将按照以下方式将AuthorizationManager注入到SecurityFilterChainbean 中:

//src/main/java/com/packtpub/springsecurity/configuration/ SecurityConfig.java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, AuthorizationManager<RequestAuthorizationContext> authorizationManager) throws Exception {
    http
          .authorizeHttpRequests(authorize -> authorize
                .anyRequest()
                .access(authorizationManager));
...omitted for brevity
    return http.build();
}

我们将通过用DefaultHttpSecurityExpressionHandler类型的 bean 替换DefaultWebSecurityExpressionHandler类型的 bean 来更新CustomAuthorizationConfig配置,因为我们选择了使用http.authorizeHttpRequest()而不是http.authorizeRequests()

//src/main/java/com/packtpub/springsecurity/configuration/ CustomAuthorizationConfig.java
@Bean
public DefaultHttpSecurityExpressionHandler defaultHttpSecurityExpressionHandler(){
    return new DefaultHttpSecurityExpressionHandler();
}

您可以删除FilterInvocationServiceSecurityMetadataSource并重新启动应用程序。您应该得到与上一个示例相同的结果。

重要提示

您的代码现在应该看起来像这样:calendar13.06-calendar

摘要

在阅读本章后,您应该对Spring Security如何为 HTTP 请求和方法进行授权有一个牢固的理解。有了这些知识和提供的具体示例,您还应该知道如何扩展授权以满足您的需求。具体来说,在本章中,我们涵盖了 HTTP 请求和方法的Spring Security授权架构。我们还演示了如何从数据库配置受保护的 URL。

我们还看到了如何创建自定义的AuthorizationManagerPermissionEvaluator对象和自定义的Spring Security表达式。

在下一章中,我们将探讨Spring Security如何执行会话管理。我们还将了解如何使用它来限制对应用程序的访问。

第五部分:高级安全功能和部署优化

这一部分首先解释了会话固定攻击以及 Spring Security 针对它们的防御机制。然后,它探讨了管理已登录用户和限制每个用户并发会话数量的方法。Spring Security 将用户与HttpSession关联以及自定义此行为的技术也被详细说明。

然后,我们将深入研究常见的网络安全漏洞,如跨站脚本攻击XSS)、跨站请求伪造CSRF)、同步令牌和点击劫持,以及有效缓解这些风险的战略。

在此之后,我们展示了迁移到 Spring Security 6 的路径,突出了显著的配置更改、类和包迁移以及重要的新功能,包括对 Java 17 的支持以及使用 OAuth 2 增强的认证机制。

随后,我们探讨基于微服务的架构,并检查 OAuth 2 与JSON Web 令牌(JWTs)在 Spring 应用程序中保护微服务的作用。此外,我们讨论了使用中央认证服务(CAS)实现单点登录(SSO)的实施。

在本部分的结尾,我们深入探讨了使用 GraalVM 构建本地图像的过程,提供了在 Spring Security 应用程序中提高性能和安全的见解。

本部分包含以下章节:

  • 第十四章, 会话管理

  • 第十五章, 额外的 Spring Security 功能

  • 第十六章, 迁移到 Spring Security 6

  • 第十七章, 使用 OAuth 2 和 JSON Web 令牌的微服务安全

  • 第十八章, 使用中央认证服务进行单点登录

  • 第十九章, 构建 GraalVM 本地图像

第十四章:会话管理

本章讨论了 Spring Security 的会话管理功能。它从一个 Spring Security 如何防御会话固定的例子开始。然后我们将讨论如何利用并发控制来限制按用户许可的软件的访问。我们还将看到如何利用会话管理进行管理功能。最后,我们将探讨HttpSession在 Spring Security 中的使用以及如何管理会话:

以下是在本章中将要涉及的主题列表:

  • 会话管理/会话固定

  • 并发控制

  • 管理已登录用户

  • HttpSession在 Spring Security 中的使用以及如何控制创建

  • 如何使用DebugFilter类来发现HttpSession的创建位置

本章的代码实战链接在此:packt.link/qaJyz.

配置会话固定保护

由于我们使用的是安全命名空间风格的配置,会话固定保护已经为我们配置好了。如果我们想显式配置以匹配默认设置,我们将执行以下操作:

http.sessionManagement(session -> session.sessionFixation().migrateSession());

会话固定保护是框架的一个特性,除非你尝试扮演恶意用户,否则你很可能不会注意到它。我们将向您展示如何模拟会话窃取攻击;在我们这样做之前,了解会话固定做什么以及它阻止的攻击类型非常重要。

理解会话固定攻击

会话固定是一种攻击类型,恶意用户试图窃取系统未认证用户的会话。这可以通过使用各种技术实现,导致攻击者获得用户的唯一会话标识符(例如,JSESSIONID)。如果攻击者创建一个包含用户JSESSIONID标识符的 cookie 或 URL 参数,他们就可以访问用户的会话。

虽然这显然是一个问题,但通常情况下,如果用户未认证,他们没有输入任何敏感信息。如果用户认证后继续使用相同的会话标识符,这将成为一个更严重的问题。如果认证后使用相同的标识符,攻击者现在可能无需知道用户的用户名或密码就能访问认证用户的会话!

重要提示

在这一点上,你可能会嘲笑并认为这在现实世界中极不可能发生。事实上,会话窃取攻击经常发生。我们建议你花些时间阅读由开放网络应用安全项目OWASP)组织发布的关于该主题非常有信息量的文章和案例研究(www.owasp.org/)。特别是,你将想阅读 OWASP 前 10 名列表。攻击者和恶意用户是真实存在的,如果你不了解他们常用的技术,他们可能会对你的用户、你的应用程序或你的公司造成非常真实的损害。

以下图示说明了会话固定攻击是如何工作的:

图 14.1 – 会话固定攻击

图 14.1 – 会话固定攻击

在这个图中,我们可以看到通过将会话标识符固定为已知值,攻击者绕过了正常的认证过程,并获得了对受害者账户或会话的非授权访问。这种攻击强调了正确管理和保护会话标识符以防止会话固定漏洞的重要性。

现在我们已经看到了这种攻击是如何工作的,我们将看看 Spring Security 能做些什么来预防它。

使用 Spring Security 预防会话固定攻击

如果我们能在用户认证之前防止使用相同的会话,那么我们就可以有效地使攻击者对会话 ID 的了解变得无用。Spring Security 会话固定保护通过在用户认证时显式创建一个新的会话并使旧的会话无效来解决这个问题。

让我们看看以下图示:

图 14.2 – 使用 Spring Security 预防会话固定攻击

图 14.2 – 使用 Spring Security 预防会话固定攻击

我们可以看到一个新的过滤器o.s.s.web.session.SessionManagementFilter负责评估特定用户是否是新认证的。如果用户是新认证的,配置的o.s.s.web.authentication.session.SessionAuthenticationStrategy接口将决定要做什么。o.s.s.web.authentication.session.SessionFixation ProtectionStrategy将创建一个新的会话(如果用户已经有了),并将现有会话的内容复制到新会话中。就是这样——看起来很简单。然而,正如我们可以在前面的图中看到的,它有效地防止了恶意用户在未知用户认证后重新使用会话 ID。

模拟会话固定攻击

在这一点上,你可能想看看模拟会话固定攻击涉及的内容:

重要提示

你应该从chapter14.00-calendar中的代码开始。

  1. 你首先需要在SecurityConfig.java文件中禁用会话固定保护,通过将sessionManagement()方法作为HTTP元素的子元素添加。

    让我们看看以下代码片段:

    //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
    http.sessionManagement(session -> session.sessionFixation().none());
    

重要提示

您的代码现在应该看起来像chapter14.01-calendar

  1. 接下来,您需要打开两个浏览器。我们将在谷歌浏览器中启动会话,从那里窃取它,我们的攻击者将使用在 Firefox 中窃取的会话登录。我们将使用Google ChromeFirefox Web Developer插件来查看和操作 cookies。

  2. 在谷歌浏览器中打开 JBCP 日历主页。

  3. 接下来:

    • 打开开发者工具:右键点击网页并选择检查,或者按Ctrl + Shift + I(Windows/Linux)或Cmd + Opt + I(Mac)来打开开发者工具。

    • 导航到应用程序标签:在开发者工具中,您会在顶部看到一个菜单。点击应用程序标签。

    • 在侧边栏中定位 Cookies:在左侧侧边栏中,您应该看到一个Cookies部分。展开它以查看带有其关联 cookie 的域名列表。

    • 选择特定域名:点击与您感兴趣的网站相关的域名。这将显示与该域名关联的 cookie 列表。

    • 查看 Cookie 值:您可以看到每个 cookie 的详细信息,包括其名称、值、域名、路径等。寻找您感兴趣的特定 cookie,您将找到其值。

图 14.3 – Google Chrome 中的 Cookies 浏览器

图 14.3 – Google Chrome 中的 Cookies 浏览器

  1. 选择JSESSIONID cookie,登录后JSESSIONID的值没有改变,这使得您容易受到会话固定攻击的攻击!

  2. 在 Firefox 中打开 JBCP 日历网站。您将被分配一个会话 cookie,您可以通过使用Ctrl + F2打开底部:Cookie控制台来查看它。然后,输入cookie list [enter]以显示当前页面的 cookies。

  3. 为了完成我们的黑客攻击,我们将点击从谷歌浏览器复制到剪贴板的JSESSIONID cookie,如下截图所示:

图 14.4 – Firefox 中的 Cookies 黑客攻击

图 14.4 – Firefox 中的 Cookies 黑客攻击

  1. 请记住,Firefox 的新版本也包括了网页开发者工具。然而,您需要确保您使用的是扩展程序而不是内置的,因为它提供了额外的功能。

    我们的会话固定黑客攻击已经完成!如果您现在在 Firefox 中重新加载页面,您会看到您以使用谷歌浏览器登录的同一用户身份登录,但不知道用户名和密码。您对恶意用户感到害怕了吗?

  2. 现在,重新启用会话固定保护并再次尝试这个练习。您会看到,在这种情况下,JSESSIONID在用户登录后会发生变化。根据我们对会话固定攻击发生方式的了解,这意味着我们已经降低了不知情用户成为这种攻击受害者的可能性。干得好!

谨慎的开发者应注意到有许多窃取会话 cookie 的方法,其中一些——例如XSS——甚至可能使会话固定保护网站变得脆弱。请参考 OWASP 网站以获取防止此类攻击的额外资源。

比较会话固定保护选项

session-fixation-protection属性有三个选项,允许您更改其行为;它们如下所示:

属性值 描述
none() 此选项禁用会话固定保护,并且(除非其他sessionManagement()属性非默认)不会配置SessionManagementFilter
migrateSession() 当用户经过身份验证并分配新会话时,它确保将旧会话的所有属性移动到新会话中。
newSession() 当用户经过身份验证时,将创建新会话,并且不会从旧(未经身份验证)会话迁移任何属性。

表 14.1 – 会话固定保护选项

在大多数情况下,migrateSession() 方法的默认行为将适用于希望保留用户会话重要属性(如点击兴趣和购物车)的网站,在用户经过身份验证后。

限制每个用户的并发会话数量

在软件行业,软件通常按用户销售。这意味着,作为软件开发者,我们有一个确保每个用户只能存在一个会话的兴趣,以对抗账户共享。Spring Security 的并发会话控制确保单个用户不能同时拥有超过固定数量的活动会话(通常是单个)。确保实施此最大限制需要多个组件协同工作,以准确跟踪用户会话活动的变化。

让我们配置这个功能,查看它是如何工作的,然后测试一下!

配置并发会话控制

现在我们已经了解了并发会话控制中涉及的不同组件,设置它应该更有意义。让我们看看以下步骤来配置并发会话控制:

  1. 首先,您需要按照以下方式更新您的SecurityConfig.java文件:

    //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
    http.sessionManagement(session -> session.maximumSessions(1));
    
  2. 接下来,我们需要在SecurityConfig.java部署描述符中启用o.s.s.web.session.HttpSessionEventPublisher,以便 servlet 容器能够通知 Spring Security(通过HttpSessionEventPublisher)会话生命周期事件,如下所示:

    //src/main/java/com/packtpub/springsecurity/configuration/SessionConfig.java
    @Configuration
    public class SessionConfig {
        @Bean
        public HttpSessionEventPublisher httpSessionEventPublisher() {
           return new HttpSessionEventPublisher();
        }
    }
    

在这两个配置位就绪后,并发会话控制现在将被激活。让我们看看它实际上做了什么,然后我们将演示如何对其进行测试。

理解并发会话控制

并发会话控制使用 o.s.s.core.session.SessionRegistry 来维护活动 HTTP 会话及其关联的已认证用户列表。随着会话的创建和过期,注册表会根据 HttpSessionEventPublisher 发布的会话生命周期事件实时更新,以跟踪每个已认证用户的活跃会话数量。

参考以下图表:

图 14.5 – 并发会话控制

图 14.5 – 并发会话控制

SessionAuthenticationStrategy 的扩展 o.s.s.web.authentication.session.ConcurrentSessionControlStrategy 是跟踪新会话以及实际执行并发控制的方法。每次用户访问受保护网站时,SessionManagementFilter 都会用来检查活动会话与 SessionRegistry 中的活动会话列表是否匹配。如果用户的活跃会话不在 SessionRegistry 中跟踪的活动会话列表中,则最近最少使用的会话将被立即过期。

修改后的并发会话控制过滤器链中的次要参与者是 o.s.s.web.session.ConcurrentSessionFilter。这个过滤器将识别已过期的会话(通常是会话由于 servlet 容器或通过 ConcurrentSessionControlStrategy 接口强制过期)并通知用户他们的会话已过期。

现在我们已经了解了并发会话控制的工作原理,我们应该能够轻松地重现一个强制执行该控制器的场景。

重要提示

你的代码现在应该看起来像 chapter14.02-calendar

测试并发会话控制

当我们验证会话固定保护时,我们需要通过执行以下步骤来访问两个网络浏览器:

  1. 在 Google Chrome 中,以 user1@example.com/user1 登录到该网站。

  2. 现在,在 Firefox 中,以相同用户登录到该网站。

  3. 最后,回到 Google Chrome 并采取任何操作。你将看到一个消息指示你的会话已过期,如下面的截图所示:

图 14.6 – 测试并发会话控制

图 14.6 – 测试并发会话控制

如果你使用此应用程序并收到此消息,你可能会感到困惑。这是因为这显然不是一种友好的通知方式,表明一次只能有一个用户可以访问应用程序。然而,它确实说明了会话已被软件强制过期。

重要提示

并发会话控制对于新 Spring Security 用户来说往往是一个很难理解的概念。许多用户试图在不真正理解其工作原理和好处的情况下实现它。如果你正在尝试启用这个强大的功能,但它似乎没有按预期工作,请确保你已经正确配置了一切,然后回顾本节的理论解释——希望它们能帮助你理解可能出了什么问题。

当会话过期事件发生时,我们可能需要将用户重定向到登录页面,并给他们提供一条消息来指示出了什么问题。

配置过期会话重定向

幸运的是,有一种简单的方法可以将用户引导到友好的页面(通常是登录页面),当并发会话控制标记他们时——只需指定 expired-url 属性并将其设置为应用程序中的一个有效页面。按照以下方式更新你的 SecurityConfig.java 文件:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
http.sessionManagement(session -> session.maximumSessions(1)
       .expiredUrl("/login/form?expired"));

在我们的应用程序中,这会将用户重定向到标准登录表单。然后我们将使用查询参数来显示一条友好的消息,指出我们确定他们有多个活动会话,应该重新登录。更新你的 login.xhtml 页面以使用此参数显示我们的消息:

//src/main/resources/templates/login.xhtml
<div th:if="${param.expired != null}" class="alert alert-success">
    <strong>Session Expired</strong>
    <span>You have been forcibly logged out due to multiple
    sessions on the same account (only one active
        session per user is allowed).</span>
</div>

尝试登录为用户 admin1@example.com/admin1,使用 Google Chrome 和 Firefox 进行登录。

重要提示

你的代码现在应该看起来像 chapter14.03-calendar

这次,你应该看到一个带有自定义错误信息的登录页面:

图 14.7 – 一个并发会话登录页面的自定义错误信息

图 14.7 – 一个并发会话登录页面的自定义错误信息

在设置过期会话的重定向之后,我们将深入了解与并发控制相关的典型挑战。

并发控制中的常见问题

有几个常见的原因会导致使用相同用户登录不会触发注销事件。第一个原因是在使用自定义的 UserDetails(正如我们在 第三章自定义身份验证)时,equalshashCode 方法没有正确实现。这是因为默认的 SessionRegistry 实现使用内存映射来存储 UserDetails。为了解决这个问题,你必须确保你已经正确实现了 hashCodeequals 方法。

第二个问题发生在用户会话持久化到磁盘时重启应用程序容器。当容器重新启动后,已经使用有效会话登录的用户将被登录。然而,用于确定用户是否已经登录的 SessionRegistry 的内存映射将是空的。这意味着 Spring Security 将报告用户未登录,尽管用户实际上已经登录。为了解决这个问题,需要自定义 SessionRegistry 并在容器内禁用会话持久化,或者你必须实现一种特定于容器的解决方案,以确保在启动时将持久化的会话填充到内存映射中。

我们将要讨论的最后一个常见原因是,在默认的 SessionRegistry 实现的集群环境中,并发控制可能不起作用。默认实现使用内存映射。这意味着如果 user1 登录到 应用服务器 A,他们登录的事实将与该服务器相关联。因此,如果 user1 然后对 应用服务器 B 进行身份验证,之前关联的身份验证对 应用服务器 B 将是未知的。

阻止身份验证而不是强制注销

Spring Security 还可以防止用户在已有会话的情况下登录到应用程序。这意味着,而不是强制原始用户注销,Spring Security 将阻止第二个用户登录。配置更改如下所示:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
http.sessionManagement(session -> session.maximumSessions(1)
        .expiredUrl("/login/form?expired").maxSessionsPreventsLogin(true));

使用 Google Chrome 更新并登录到日历应用程序。现在,尝试使用相同的用户以 Firefox 登录到日历应用程序。您应该看到我们来自 login.xhtml 文件的自定义错误消息:

图 14.8 – 阻止并发会话身份验证的自定义错误消息

图 14.8 – 阻止并发会话身份验证的自定义错误消息

重要提示

您的代码现在应类似于 chapter14.04-calendar

这种方法有一个缺点,如果不仔细思考可能不会很明显。尝试在不注销的情况下关闭 Google Chrome,然后再次打开它。现在,再次尝试登录到应用程序。您将观察到您无法登录。这是因为当浏览器关闭时,JSESSIONID cookie 被删除。然而,应用程序并不知道这一点,所以用户仍然被认为是经过身份验证的。您可以将这视为一种内存泄漏,因为 HttpSession 仍然存在,但没有指向它的指针(JSESSIONID cookie 已消失)。只有在会话超时后,我们的用户才能再次进行身份验证。幸运的是,一旦会话超时,我们的 SessionEventPublisher 接口将把用户从我们的 SessionRegistry 接口中删除。我们可以从中吸取的教训是,如果用户忘记注销并关闭浏览器,他们将在会话超时之前无法登录到应用程序。

重要提示

正如在第 7 章 中提到的 Remember-me 服务,如果浏览器决定即使在关闭浏览器后也记住一个会话,这个实验可能无法工作。通常,如果插件或浏览器被配置为恢复会话,就会发生这种情况。在这种情况下,您可能需要手动删除 JSESSIONID cookie 来模拟浏览器已关闭。

并发会话控制的其它好处

并发会话控制的另一个好处是存在 SessionRegistry 来跟踪活动(以及可选的已过期)会话。这意味着我们可以通过执行以下步骤来获取关于我们系统中存在哪些用户活动(至少对于认证用户)的运行时信息:

  1. 即使您不想启用并发会话控制,您也可以这样做。只需将 maximumSessions 设置为 -1,会话跟踪将保持启用,尽管不会强制执行最大值。相反,我们将使用本章 SessionConfig.java 文件中提供的显式 bean 配置,如下所示:

    //src/main/java/com/packtpub/springsecurity/configuration/SessionConfig.java
    @Bean
    public SessionRegistry sessionRegistry(){
        return new SessionRegistryImpl();
    }
    
  2. 我们已经将 SessionConfig.java 文件的导入添加到 SecurityConfig.java 文件中。所以,我们只需要在我们的 SecurityConfig.java 文件中引用自定义配置。现在,将当前的 sessionManagementmaximumSessions 配置替换为以下代码片段:

    //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
    http.sessionManagement(session -> session.maximumSessions(-1)
            .sessionRegistry(sessionRegistry)
            .expiredUrl("/login/form?expired")
            .maxSessionsPreventsLogin(true));
    

重要提示

您的代码现在应该看起来像 chapter14.05-calendar

现在,我们的应用程序将允许同一用户进行无限数量的认证。然而,我们可以使用 SessionRegistry 来强制注销用户。让我们看看我们如何使用这些信息来增强我们用户的安全性。

显示用户的活跃会话

您可能已经看到许多网站允许用户查看并强制注销其账户的会话。我们可以轻松地使用这个强制注销功能来做同样的事情。我们已经提供了 UserSessionController,它可以获取当前登录用户的活跃会话。您可以看到以下实现:

//src/main/java/com/packtpub/springsecurity/web/controllers/UserSessionController.java
@Controller
public class UserSessionController {
    private final SessionRegistry sessionRegistry;
    public UserSessionController(SessionRegistry sessionRegistry) {
        this.sessionRegistry = sessionRegistry;
    }
    @GetMapping("/user/sessions/")
    public String sessions(Authentication authentication, ModelMap model) {
        List<SessionInformation> sessions = sessionRegistry.getAllSessions(authentication.getPrincipal(),
                false);
        model.put("sessions", sessions);
        return "user/sessions";
    }
    @PostMapping(value="/user/sessions/{sessionId}")
    public String removeSession(@PathVariable String sessionId, RedirectAttributes redirectAttrs) {
        SessionInformation sessionInformation = sessionRegistry.getSessionInformation(sessionId);
        if(sessionInformation != null) {
            sessionInformation.expireNow();
        }
        redirectAttrs.addFlashAttribute("message", "Session was removed");
        return "redirect:/user/sessions/";
    }
}

我们的会话方法将使用 Spring Authentication。如果我们没有使用 Spring MVC,我们也可以从 SecurityContextHolder 获取当前的 Authentication,如第 3 章 中讨论的,自定义认证。然后使用主体来获取当前用户的全部 SessionInformation 对象。信息可以通过在 sessions.xhtml 文件中迭代 SessionInformation 对象轻松显示,如下所示:

//src/main/resources/templates/user/sessions.xhtml
…
<tr th:each="currentSession : ${sessions}">
    <td th:text="${#calendars.format(currentSession.lastReques, 'yyyy-MM-dd HH:mm')}">lastUsed</td>
    <td th:text="${currentSession.sessionId}"></td>
    <td>
        <form action="#" th:action="@{'/user/sessions/{id}'(id=${currentSession.sessionId)}"
              th:method="post" cssClass="form-horizon"al">
            <input type="sub"it" value="Del"te" class=""tn"/>
        </form>
    </td>
</tr>
...

您现在可以安全地启动 JBCP 日历应用程序,并使用 user1@example.com/user1 在 Google Chrome 中登录。现在,使用 Firefox 登录并点击右上角的 user1@example.com 链接。然后您将在显示上看到两个会话列表,如下面的截图所示:

图 14.9 – 可用会话列表

图 14.9 – 可用会话列表

在 Firefox 中,点击 UserSessionsControllerdeleteSession 方法。这表示应该终止会话。现在,导航到 Google Chrome 中的任何页面。您将看到自定义消息,表示会话已被强制终止。虽然消息可能需要更新,但我们认为这是用户终止其他活动会话的一个很好的功能。

其他可能的用途包括允许管理员列出和管理所有活动会话,显示网站上的活动用户数量,甚至扩展信息以包括诸如 IP 地址或位置信息等内容。

Spring Security 如何使用 HttpSession 方法?

我们已经讨论了 Spring Security 如何使用SecurityContextHolder来确定当前登录的用户。然而,我们还没有解释SecurityContextHolder是如何被 Spring Security 自动填充的。这个秘密在于o.s.s.web.context.SecurityContextPersistenceFilter过滤器以及o.s.s.web.context.SecurityContextRepository接口。让我们看一下以下图表:

图 14.10 – Spring Security 使用 HttpSession

图 14.10 – Spring Security 使用 HttpSession

下面是对前面图表中每个步骤的解释:

  1. 在每个 Web 请求的开始,SecurityContextPersistenceFilter负责使用SecurityContextRepository获取当前的SecurityContext实现。

  2. 紧接着,SecurityContextPersistenceFilterSecurityContext设置在SecurityContextHolder上。

  3. 在剩余的 Web 请求中,SecurityContext可以通过SecurityContextHolder访问。例如,如果 Spring MVC 控制器或CalendarService想要访问SecurityContext,它可以使用SecurityContextHolder来访问它。

  4. 然后,在每个请求结束时,SecurityContextPersistenceFilterSecurityContextHolder获取SecurityContext

  5. 紧接着,SecurityContextPersistenceFilterSecurityContext保存到SecurityContextRepository。这确保了如果在任何时刻(即在用户创建新账户时,如在第第三章自定义身份验证)更新了SecurityContextSecurityContext将被保存。

  6. 最后,SecurityContextPersistenceFilter清除SecurityContextHolder

现在出现的问题:这与HttpSession有什么关系?这一切都由默认的SecurityContextRepository实现联系在一起,该实现使用HttpSession

HttpSessionSecurityContextRepository 接口

SecurityContextRepository的默认实现是o.s.s.web.context.HttpSessionSecurityContextRepository,它使用HttpSession来检索和存储当前的SecurityContext实现。没有提供其他SecurityContextRepository实现。然而,由于HttpSession的使用被SecurityContextRepository接口抽象化,如果我们愿意,可以轻松编写我们自己的实现。

配置 Spring Security 如何使用 HttpSession

Spring Security 具有配置 Spring Security 何时创建会话的能力。这可以通过http元素的create-session属性来完成。以下表格中可以看到选项的摘要:

属性值 描述
ifRequired Spring Security 仅在需要时才会创建会话(默认值)。
always 如果不存在会话,Spring Security 将主动创建会话。
never Spring Security 永远不会创建会话,但如果应用程序创建了它,则会使用它。这意味着如果有HttpSession方法,SecurityContext将被持久化或从中检索。
stateless Spring Security 不会创建会话,并且将忽略会话以获取 Spring Authentication。在这种情况下,使用NullSecurityContextRepository,它将始终声明当前SecurityContextnull

表 14.2 – 会话固定保护选项

在实践中,控制会话创建可能比最初看起来更困难。这是因为属性仅控制 Spring Security 对HttpSession使用的一部分。它不适用于任何其他组件,例如,如果创建了HttpSession方法,我们可以添加 Spring Security 的DebugFilter

使用 Spring Security 的 DebugFilter 进行调试

让我们看看以下步骤,并了解如何使用 Spring Security 的DebugFilter进行调试:

  1. 更新您的SecurityConfig.java文件,使其会话策略为NEVER。同时,在@EnableWebSecurity注解上将debug标志设置为true,以便我们可以跟踪会话何时被创建。更新如下所示:

    //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
    @Configuration
    @EnableWebSecurity(debug = true)
    public class SecurityConfig {
    ...
    http.sessionManagement(session -> session
           .sessionCreationPolicy(SessionCreationPolicy.NEVER)
           .maximumSessions(-1)
           .sessionRegistry(sessionRegistry)
           .expiredUrl("/login/form?expired")
           .maxSessionsPreventsLogin(true));
    
  2. 当您启动应用程序时,您应该看到类似以下代码被写入标准输出。如果您还没有这样做,请确保您已经在 Spring Security 调试器类别中启用了日志记录:

    ******************************************************
    *******   Security debugging is enabled.        ******
    *******  This may include sensitive information.******
    *******  Do not use in a production system!     ******
    
  3. 现在,清除您的 cookies(这可以在 Firefox 中使用Shift + Ctrl + Delete完成),启动应用程序,并直接导航到http://localhost:8080。当我们查看 cookies 时,就像我们在本章前面所做的那样,我们可以看到即使我们声明 Spring Security 永远不会创建HttpSessionJSESSIONID仍然被创建。再次查看日志,您将看到创建HttpSession的代码调用堆栈如下:

    ******************************************************
    2024-02-02T20:17:27.859+01:00  INFO 54253 --- [nio-8080-exec-1] Spring Security Debugger                :
    ******************************************************
    New HTTP session created: 8C85C6E21D976ED6A1EDE2F8877EB227
    

DebugFilter还有其他一些用途,我们鼓励您自己探索,例如确定何时一个请求将匹配特定的 URL,Spring Security 正在调用哪些过滤器,等等。

摘要

在阅读本章后,您应该熟悉 Spring Security 如何管理会话并防止会话固定攻击。我们还知道如何使用 Spring Security 的并发控制来防止同一用户被多次认证。

我们探讨了利用并发控制来允许用户终止与其账户关联的会话的方法。此外,我们还了解了如何配置 Spring Security 创建会话。我们还介绍了如何使用 Spring Security 的DebugFilter过滤器来排查与 Spring 相关的问题。

我们还学习了关于安全性的知识,包括确定何时创建了一个HttpSession方法及其原因。

这就结束了我们对 Spring Security 会话管理的讨论。在下一章中,我们将讨论一些关于将 Spring Security 与其他框架集成的具体细节。

第十五章:额外的 Spring Security 功能

在本章中,我们将探讨一些我们在这本书中尚未涵盖的额外的 Spring Security 功能,包括以下主题:

  • 跨站 脚本XSS

  • 跨站请求 伪造CSRF

  • 同步器令牌模式

  • Clickjacking

  • 测试 Spring Security 应用程序

  • 反应式应用程序支持

我们将了解如何使用以下方法包括各种 HTTP 标头来防止常见的安全漏洞:

  • Cache-Control

  • Content-Type Options

  • HTTP 严格传输安全HSTS

  • X-Frame-Options

  • X-XSS-Protection

在阅读本章之前,你应该已经理解了 Spring Security 的工作原理。这意味着你应该已经能够在一个简单的网络应用程序中设置身份验证和授权。如果你无法做到这一点,你将想要确保在继续本章之前已经阅读到 第三章自定义身份验证

本章的代码示例链接在此:packt.link/aXvKi

安全漏洞

在互联网时代,有无数可能被利用的漏洞。了解更多关于基于 Web 的漏洞的宝贵资源是位于 owasp.org开放 Web 应用程序安全项目OWASP)。

除了是理解各种漏洞的宝贵资源外,OWASP 还根据行业趋势对前 10 大漏洞进行了分类。

跨站脚本

跨站脚本XSS攻击涉及注入到受信任网站中的恶意脚本。

当攻击者利用允许未经过滤的输入发送到网站的特定网络应用程序时,就会发生 XSS 攻击,通常是以基于浏览器的脚本的形式,然后由网站的不同用户执行。

攻击者可以根据提供给网站验证或未编码的信息采取多种形式。

XSS 可以通过以下序列图来描述:

图 15.1 – 跨站脚本(XSS)

图 15.1 – 跨站脚本(XSS)

这个问题的核心是期望用户信任正在发送的网站信息。最终用户的浏览器无法知道脚本不应该被信任,因为他们在浏览的网站上存在隐含的信任。由于最终用户认为脚本来自受信任的来源,恶意脚本可以访问浏览器保留的任何 cookies、会话令牌或其他敏感信息,并用于该网站。

跨站请求伪造

跨站请求伪造CSRF)是一种欺骗受害者提交恶意请求的攻击。这种攻击继承了或劫持了受害者的身份和权限,并执行未经授权的功能,代表受害者获取访问权限。

对于 Web 应用程序,大多数浏览器会自动包含与网站关联的凭据,包括用户会话、cookie、IP 地址、Windows 域凭据等。

因此,如果用户当前在一个网站上认证,该网站将无法区分受害者发送的伪造请求和合法请求。

CSRF 攻击针对的是在服务器上引起状态变化的函数,例如更改受害者的电子邮件地址或密码,或进行金融交易。

这迫使受害者检索对攻击者无益的数据,因为攻击者不会收到响应;受害者会收到。因此,CSRF 攻击针对的是会改变状态的请求。

以下序列图详细说明了 CSRF 攻击是如何发生的:

图 15.2 – CSRF

图 15.2 – CSRF

为了尝试防止 CSRF 攻击,可以采取多种不同的设计措施;然而,诸如秘密 cookie、HTTP POST请求、多步交易、URL 重写和HTTPS等措施并不能阻止此类攻击。

重要提示

OWASP 的前 10 大安全漏洞列表将 CSRF 列为第 8 大常见攻击,详情请见owasp.org/www-community/attacks/csrf

总结来说,CSRF 是一种攻击的通用概念,即用户被诱骗执行非预期行为。在下一节中,我们将探讨同步器令牌模式,这是一种通过使用与每个用户会话关联的唯一令牌来减轻 CSRF 攻击的具体方法。

同步器令牌模式

CSRF 的解决方案是使用同步器令牌模式。此解决方案确保每个请求除了我们的会话 cookie 外,还需要一个随机生成的令牌作为 HTTP 参数。当请求提交时,服务器必须查找参数的预期值,并将其与请求中的实际值进行比较。如果值不匹配,则请求应失败。

重要提示

《跨站请求伪造预防技巧表》建议将同步器令牌模式作为 CSRF 攻击的有效解决方案:cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.xhtml#General_Recommendation:_%20Synchronizer_Token_Pattern

让我们看看我们的示例将如何改变。假设随机生成的令牌存在于名为_csrf的 HTTP 参数中。例如,转账请求看起来如下:

POST /transfer HTTP/1.1 Host: bank.example.com
Cookie: JSESSIONID=randomid; Domain=bank.example.com; Secure; HttpOnly
Content-Type: application/x-www-form-urlencoded amount=100.00&routingNumber=1234&account=9876&_csrf=<secure-random token>

你会注意到我们添加了一个具有随机值的 _csrf 参数。现在,恶意网站将无法猜测 _csrf 参数的正确值(必须在恶意网站上明确提供),当服务器将实际令牌与预期令牌进行比较时,传输将失败。

以下图显示了 synchronizer token 模式的标准用例:

图 15.3 – 同步器令牌模式

图 15.3 – 同步器令牌模式

Spring Security 默认提供了 synchronizer token 支持。你可能已经注意到,在前面的章节中,我们在 SecurityConfig.java 文件中禁用了 CSRF 保护,如下面的代码片段所示:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
...
    http.csrf(AbstractHttpConfigurer::disable);
    return http.build();
}

到本书的这一部分,我们已经禁用了 synchronizer token 保护,以便我们可以专注于其他安全关注点。

如果我们现在启动应用程序,并且没有任何页面添加 synchronizer token 支持。

重要提示

你应该从 chapter15.00-calendar 的代码开始。

在探索了 synchronizer token 模式之后,我们将探讨何时使用 CSRF 保护。

何时使用 CSRF 保护

建议你为任何可能由浏览器或普通用户处理的请求使用 CSRF 保护。如果你只创建了一个仅供非浏览器客户端使用的服务,你很可能希望禁用 CSRF 保护。

CSRF 保护与 JSON

一个常见的问题是,我是否需要保护由 JavaScript 发起的 JSON 请求?简短的回答是,这取决于。然而,你必须非常小心,因为存在可能影响 JSON 请求的 CSRF 漏洞。例如,恶意用户可以使用以下表单创建带有 JSON 的 CSRF:

<form action="https://example.com/secureTransaction" method="post" enctype="text/plain">
    <input name='{"amount":100,"routingNumber":"maliciousRoutingNumber", "account":"evilsAccountNumber", "ignore_me":"' value='test"}' type='hidden'>
    <input type="submit" value="Win Money!"/>
</form>

这将产生以下 JSON 结构:

{
  "amount": 100,
  "routingNumber": "maliciousRoutingNumber",
  "account": "maliciousAccountNumber",
  "ignore_me": "=test"
}

如果一个应用程序没有验证 Content-Type 方法,那么它就会暴露于这种漏洞。根据配置,一个验证 Content-Type 方法的 Spring MVC 应用程序仍然可能通过更新 URL 后缀以 .json 结尾而被利用,如下面的代码所示:

<form action="https://example.com/secureTransaction.json" method="post" enctype="text/plain">
    <input name='{"amount":100,"routingNumber":"maliciousRoutingNumber", "account":"maliciousAccountNumber", "ignore_me":"' value='test"}' type='hidden'>
    <input type="submit" value="Win Money!"/>
</form>

在本节中,我们探讨了 CSRF 保护与 JSON。在下一节中,我们将介绍 CSRF 和无状态浏览器应用程序。

CSRF 和无状态浏览器应用程序

如果你的应用程序是无状态的,这并不一定意味着你是安全的。事实上,如果用户对于某个请求不需要在浏览器中执行任何操作,他们仍然可能容易受到 CSRF 攻击。

例如,考虑一个使用自定义 cookie 来包含所有状态以进行身份验证而不是 JSESSIONID cookie 的应用程序。当 CSRF 攻击发生时,自定义 cookie 将以与我们在上一个示例中发送 JSESSIONID cookie 相同的方式随请求发送。

使用基本身份验证的用户也容易受到 CSRF 攻击,因为浏览器会自动将用户名和密码包含在任何请求中,就像在我们的上一个例子中发送 JSESSIONID cookie 一样。

使用 Spring Security CSRF 保护

那么,使用 Spring Security 保护我们的网站免受 CSRF 攻击所需的步骤是什么?使用 Spring Security 的 CSRF 保护步骤如下:

  1. 使用正确的 HTTP 动词。

  2. 配置 CSRF 保护。

  3. 包含 CSRF 令牌。

让我们更好地理解这些步骤。

使用正确的 HTTP 动词

防止 CSRF 攻击的第一步是确保你的网站使用正确的 HTTP 动词。具体来说,在 Spring Security 的 CSRF 支持能够发挥作用之前,你需要确定你的应用程序对于任何修改状态的操作都使用了 PATCHPOSTPUT 和/或 DELETE

这不是 Spring Security 支持的限制,而是一个正确的 CSRF 防止的一般要求。原因是将私人信息包含在 HTTP GET 方法中可能会导致信息泄露。

请参阅 RFC 2616,第 15.1.3 节在 URI 中编码敏感信息,以获取有关使用 POST 而不是 GET 来处理敏感信息的通用指导 (www.rfc-editor.org/rfc/rfc2616.xhtml#section-15.1.3)。

配置 CSRF 保护

下一步是在你的应用程序中包含 Spring Security 的 CSRF 保护。一些框架通过使无效的 CSRF 令牌失效用户会话来处理无效的 CSRF 令牌,但这会带来它自己的问题。相反,默认情况下,Spring Security 的 CSRF 保护将产生 HTTP 403 访问拒绝。这可以通过配置 AccessDeniedHandler 来处理 InvalidCsrfTokenException 的不同方式来自定义。

由于被动性原因,如果你正在使用 XML 配置,必须通过 <csrf> 元素显式启用 CSRF 保护。有关其他自定义选项,请参阅 <csrf> 元素的文档。

默认 CSRF 支持

使用 Java 配置时,CSRF 保护默认启用。有关 CSRF 保护配置的更多自定义选项,请参阅 csrf() 的 Java 文档。

为了使这个配置更加详细,我们将在 SecurityConfig.java 文件中添加 CSRF 方法,如下所示:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
...
    http.csrf(Customizer.withDefaults());
    return http.build();
}

要访问 H2 控制台,需要禁用 CSRF,如下所示:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
...
    http
       .csrf(csrf -> csrf
             .ignoringRequestMatchers(toH2Console())
             .disable());
    return http.build();
}

在配置 Spring SecuritySecurityFilterChain bean 中的 CSRF 之后,我们将看到如何启用网页表单中的支持。

提交中包含 CSRF 令牌

最后一步是确保你在所有 PATCHPOSTPUTDELETE 方法中包含 CSRF 令牌。一种方法是使用 _csrf 请求属性来获取当前的 CsrfToken 令牌。以下是一个使用 Java Server Page (JSP) 实现此操作的示例:

<c:url var="logoutUrl" value="/logout"/>
<form action="${logoutUrl}"
      method="post">
    <input type="submit"
           value="Log out" />
    <input type="hidden"
           name="${_csrf.parameterName}"
           value="${_csrf.token}"/>
</form>

<form> 中包含 CSRF 令牌后,我们将通过包含基于 Spring Security JSP 标签的 CSRF 令牌来探索另一个选项。

使用 Spring Security JSP 标签库包含 CSRF 令牌

如果 CSRF 保护已启用,Spring Security 标签会插入一个带有正确名称和值的隐藏表单字段用于 CSRF 保护令牌。如果 CSRF 保护未启用,则此标签没有输出。

通常,Spring Security 会自动为任何 <form:form> 标签插入 CSRF 表单字段,但如果由于某些原因您不能使用 <form:form>csrfInput 是一个方便的替代品。

您应该将此标签放置在 HTML <form></form> 块中,您通常会在其中放置其他输入字段。不要将此标签放置在 Spring <form:form></form:form> 块中。Spring Security 会自动处理 Spring 表单,如下所示:

<form method="post" action="/logout">
    <sec:csrfInput />
    ...
</form>

默认 CSRF 令牌支持

如果您正在使用 Spring MVC <form:form> 标签,或 Thymeleaf 2.1+,并且您还使用了 @EnableWebSecurity,则 CSRF 令牌将自动为您包含(使用我们一直在处理的 CsrfRequestDataValue 令牌)。

因此,对于这本书,我们一直在使用 Thymeleaf 为我们所有的网页。如果我们在 Spring Security 中启用 CSRF 支持,Thymeleaf 默认启用 CSRF 支持。

logout 链接在 CSRF 支持启用的情况下将无法工作,需要替换为以下代码:

//src/main/webapp/WEB-INF/templates/fragments/header.xhtml
<form th:action="@{/logout}" method="post">
    <input type="submit" value="Logout" class="btn btn-outline-light" />
</form></li>

重要注意事项

您的代码现在应该看起来像 chapter15.01-calendar

如果我们启动 JBCP 日历应用程序并导航到 http://localhost:8080/login.xhtml 上的登录页面,我们可以查看 login.xhtml 页面的生成源,如下所示:

<form class="form-horizontal" method="POST" action="/login">
    <input type="hidden" name="_csrf" value="eEOF9AiMgfLo353Q19oTxLz5JFiNDUwbVnp-UiIExznGwFV9HiK2xGq_4sLFvfng4fcn9oTNCTrpay82YEhLNBBhpV_x8DFM"/>
...
</form>

Ajax 和 JavaScript 请求

如果您使用 JSON,那么在 HTTP 参数中提交 CSRF 令牌是不可能的。相反,您可以在 HTTP 头部中提交令牌。一个典型的模式是在您的 <meta> HTML 标签中包含 CSRF 令牌。以下是一个使用 JSP 的示例:

<html>
<head>
    <meta name="_csrf" th:content="${_csrf.token}"/>
    <!-- default header name is X-CSRF-TOKEN -->
    <meta name="_csrf_header" th:content="${_csrf.headerName}"/>
    <!-- ... -->
</head>

您可以不手动创建元标签,而是使用 Spring Security JSP 标签库中的更简单的 csrfMetaTags 标签。

csrfMetaTags 标签

如果 CSRF 保护已启用,此标签会插入包含 CSRF 保护令牌表单字段、头部名称和 CSRF 保护令牌值的元标签。这些元标签对于在您的应用程序中实现 CSRF 保护很有用。

您应该将 csrfMetaTags 标签放置在 HTML <head></head> 块中,您通常会在其中放置其他元标签。一旦使用此标签,您就可以使用 JavaScript 容易地访问表单字段名称、头部名称和令牌值,如下所示:

<!DOCTYPE html>
<html>
<head>
    <title>CSRF Protected JavaScript Page</title>
    <meta name="description" content="This is the description for this page"/>
    <sec:csrfMetaTags/>
    <script type="text/javascript">
        var csrfParameter = $("meta[name='_csrf_parameter']").attr("content");
        var csrfHeader = $("meta[name='_csrf_header']").attr("content");
        var csrfToken = $("meta[name='_csrf']").attr("content");
        // using XMLHttpRequest directly to send an x-www-form-urlencoded request
        var ajax = new XMLHttpRequest();
        ajax.open("POST", "https://www.example.org/do/something", true);
        ajax.setRequestHeader("Content-Type", "application/x-www-form-urlencoded data");
        ajax.send(csrfParameter + "=" + csrfToken + "&name=John&...");
        // using XMLHttpRequest directly to send a non-x-www-form-urlencoded request
        var ajax = new XMLHttpRequest();
        ajax.open("POST", "https://www.example.org/do/something", true);
        ajax.setRequestHeader(csrfHeader, csrfToken);
        ajax.send("...");
    </script>
</head>>
<body>
...
</body>
</html>

如果 CSRF 保护未启用,csrfMetaTags 不输出任何内容。

jQuery 使用

然后,您可以将令牌包含在所有的 Ajax 请求中。如果您使用 jQuery,可以使用以下代码片段完成此操作:

// using JQuery to send an x-www-form-urlencoded request
var data = {};
data[csrfParameter] = csrfToken;
data["name"] = "John";
...
$.ajax({
    url: "https://www.example.org/do/something",
    type: "POST",
    data: data,
    ...
});
// using JQuery to send a non-x-www-form-urlencoded request
var headers = {};
headers[csrfHeader] = csrfToken;
$.ajax({
    url: "https://www.example.org/do/something",
    type: "POST",
    headers: headers,
    ...
});

在探索默认 CSRF 配置之后,我们将以一些 CSRF 注意事项结束。

CSRF 注意事项

在实现 Spring Security 中的 CSRF 时,有一些需要注意的注意事项。让我们在接下来的几节中看看这些注意事项。

超时

一个问题是预期的 CSRF 令牌存储在 HttpSession 方法中,因此一旦 HttpSession 方法过期,您的配置的 AccessDeniedHandler 处理器将收到 InvalidCsrfTokenException。如果您使用的是默认的 AccessDeniedHandler 处理器,浏览器将得到 HTTP 403 并显示一个错误信息。

另一个缺点是,通过移除状态(超时),您将失去在令牌被泄露时强制终止令牌的能力。

一种减轻活跃用户遇到超时的简单方法是通过一些 JavaScript 让用户知道他们的会话即将过期。用户可以点击一个按钮继续并刷新会话。

或者,指定一个自定义的 AccessDeniedHandler 处理器允许您以任何您喜欢的方式处理 InvalidCsrfTokenException,正如我们可以在以下代码中看到:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http, CustomAccessDeniedHandler accessDeniedHandler) throws Exception {
       http
             // ...
             .exceptionHandling(exceptionHandlin) -> exceptionHandling
                   .accessDeniedHandler(accessDeniedHandler);
             );
       return http.build();
    }
    @Bean
    public CustomAccessDeniedHandler accessDeniedHandler(){ return new AccessDeniedHandlerImpl();
    }
}

登录

为了防止伪造登录请求,登录表单也应该保护免受 CSRF 攻击。由于 CsrfToken 令牌存储在 HttpSession 中,这意味着一旦访问 CsrfToken 属性,就会创建一个 HttpSession 方法。

虽然在 RESTful/无状态架构中这听起来很糟糕,但现实是状态对于实现实际安全是必要的。如果没有状态,如果令牌被泄露,我们就无能为力。实际上,CSRF 令牌的大小相当小,应该对我们的架构影响微乎其微。

重要提示

攻击者可能伪造一个请求,使用攻击者的凭据将受害者登录到目标网站;这被称为登录 CSRF (en.wikipedia.org/wiki/Cross-site_request_forgery#Forging_login_requests)。

注销

添加 CSRF 将更新 LogoutFilter 过滤器,使其仅使用 HTTP POST。这确保了注销需要 CSRF 令牌,并且恶意用户无法强制注销您的用户。

防止 CSRF 攻击的一种方法是在注销时使用 <form> 标签。如果您想有一个 HTML 链接,可以使用 JavaScript 让链接执行 HTTP POST(这可以是一个隐藏的表单)。对于禁用了 JavaScript 的浏览器,您可以选择让链接将用户带到注销确认页面,该页面将执行 HTTP POST

如果您想在注销时使用 HTTP GET,您也可以这样做,但请记住,这通常不推荐。例如,以下 Java 配置将在请求注销 URL 模式时使用任何 HTTP 方法执行注销:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
       http
             // ...
             .logout(logout -> logout
                   .logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
             );
       return http.build();
    }
}

在本节中,我们已经讨论了 CSRF,现在让我们深入了解保护 HTTP 响应头。

安全 HTTP 响应头

以下几节将讨论 Spring Security 对添加各种安全头到响应的支持。

Spring Security 允许用户轻松注入默认安全头,以帮助保护他们的应用程序。以下是 Spring Security 提供的当前默认安全头的列表:

  • Cache-Control

  • 内容类型选项

  • HTTP 严格传输安全

  • X-Frame-Options

  • X-XSS-Protection

虽然每个这些头都被认为是最佳实践,但应注意的是,并非所有客户端都使用这些头,因此建议进行额外的测试。出于被动性原因,如果你正在使用 Spring SecurityXML 命名空间 支持,你必须明确启用安全头。所有默认头都可以通过使用没有子元素的 <headers> 元素轻松添加。

如果你正在使用 Spring Security 的 Java 配置,所有默认安全头都会默认添加。它们可以通过以下方式使用 Java 配置来禁用:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
       http
             // ...
             .headers(headers -> headers.disable());
       return http.build();
    }
}

默认情况下,Spring Security 指示浏览器通过使用 <headers-xss-protection,X-XSS-Protection header> 来禁用 XSS 审计器。你可以完全禁用 X-XSS-Protection 头:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Bean
SecurityFilterChain springSecurityFilterChain(HttpSecurity http) throws Exception {
    http
          .headers(headers -> headers
                .xssProtection(XXssConfig::disable)
          );
    return http.build();
}

你也可以设置头值:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Bean
SecurityFilterChain springSecurityFilterChain(HttpSecurity http) throws Exception {
    http
          .headers(headers -> headers
                .xssProtection(xssProtection -> xssProtection.headerValue(XXssProtectionHeaderWriter.HeaderValue.ENABLED_MODE_BLOCK))
          );
    return http.build();
}

一旦你指定了应该包含的任何头,那么就只会包含那些头。例如,以下配置将仅包括对 X-Frame-Options 的支持:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Bean
SecurityFilterChain springSecurityFilterChain(HttpSecurity http) throws Exception {
    http
          .headers(headers -> headers
                .frameOptions(FrameOptionsConfig::sameOrigin));
    return http.build();
}

Cache-Control

在过去,Spring Security 要求你为你的 Web 应用程序提供自己的 Cache-Control 方法。在当时这似乎是合理的,但浏览器缓存已经发展到包括安全连接的缓存。这意味着一个用户可能查看了一个认证页面并登出,然后一个恶意用户可以使用浏览器历史记录来查看缓存的页面。

为了帮助缓解这种情况,Spring Security 添加了 Cache-Control 支持,这将把以下头插入到你的响应中:

Cache-Control: no-cache, no-store, max-age=0, must-revalidate Pragma: no-cache
Expires: 0

为了安全起见,Spring Security 默认添加这些头。然而,如果你的应用程序提供了自己的缓存控制头,Spring Security 将仅依赖于你自己的应用程序头。这允许应用程序确保静态资源(如 CSS 和 JavaScript)可以被缓存。

内容类型选项

从历史上看,包括 Internet Explorer 在内的浏览器会尝试通过内容嗅探来猜测请求的内容类型。这允许浏览器通过猜测未指定内容类型的资源的内容类型来改善用户体验。例如,如果一个浏览器遇到了一个没有指定内容类型的 JavaScript 文件,它将能够猜测内容类型然后执行它。

重要提示

在允许内容上传时,还有许多其他应该做的事情,例如只在一个特定的域名中显示文档,确保设置了 Content-Type 头,清理文档等等,但这些措施超出了 Spring Security 提供的范围。还重要的是指出,在禁用内容嗅探时,你必须指定内容类型才能使事情正常工作。

内容嗅探的问题在于,这允许恶意用户使用 多语言文件(一个可以作为多种内容类型有效的文件)来执行 XSS 攻击。例如,一些网站可能允许用户向网站提交一个有效的 PostScript 文档并查看它。恶意用户可能会创建一个既是有效的 PostScript 文件也是有效的 JavaScript 文件的文档,并使用它执行 XSS 攻击(webblaze.cs.berkeley.edu/papers/barth-caballero-song.pdf)。

通过添加以下头到我们的响应中,可以禁用内容嗅探:

X-Content-Type-Options: nosniff

默认情况下,Spring Security 通过向 HTTP 响应中添加此头来禁用内容嗅探。

HTTP 严格传输安全

当你输入你的银行网站时,你是输入 mybank.example.com,还是输入 https://mybank.example.com?如果你省略了 HTTPS 协议,你可能会容易受到 https://mybank.example.com 的攻击,恶意用户可能会拦截初始的 HTTP 请求并操纵响应(重定向到 https://mibank.example.com 并窃取你的凭证)。

许多用户省略了 HTTPS 协议,这就是为什么需要创建 HSTS 的原因。

根据 RFC6797HSTS 头仅注入到 HTTPS 响应中。为了浏览器能够认可该头,浏览器必须首先信任签发用于建立连接的 证书颁发机构CA),而不仅仅是 SSL 证书(datatracker.ietf.org/doc/html/rfc6797)。

一旦 mybank.example.com 被添加为 HSTS 主机,浏览器就可以事先知道对 mybank.example.com 的任何请求都应解释为 https://mybank.example.com。这大大减少了中间人攻击(MitM)发生的可能性。

一个网站被标记为 HSTS 主机的方法之一是将主机预先加载到浏览器中。另一种方法是向响应中添加 Strict-Transport-Security 头。例如,以下指令将指导浏览器将域名视为 HSTS 主机一年(一年大约有 31,536,000 秒):

Strict-Transport-Security: max-age=31536000 ; includeSubDomains ; preload

可选的 includeSubDomains 指令指示 Spring Security 将子域名(例如 secure.mybank.example.com)也视为 HSTS 域。

可选的 preload 指令指示浏览器将域名作为 HSTS 域名预先加载到浏览器中。有关 HSTS 预加载的更多详细信息,请参阅hstspreload.org

您可以明确自定义结果。以下示例明确提供了 HSTS:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
       http
             // ...
             .headers(headers -> headers
                   .httpStrictTransportSecurity(hsts -> hsts
                         .includeSubDomains(true)
                         .preload(true)
                         .maxAgeInSeconds(31536000)
                   )
             );
       return http.build();
    }
}

HTTP 公共密钥固定(HPKP)

Spring Security为 HPKP 提供 servlet 支持(见此处:docs.spring.io/spring-security/reference/features/exploits/headers.xhtml#headers-hpkp),但不再推荐(docs.spring.io/spring-security/reference/features/exploits/headers.xhtml#headers-hpkp-deprecated)。

您可以使用以下配置启用 HPKP 标题:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
       http
             // ...
             .headers(headers -> headers
                   .httpPublicKeyPinning(hpkp -> hpkp
                         .includeSubDomains(true)
                         .reportUri("https://example.net/pkp-report")
                         .addSha256Pins("d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM=", "E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=")
                   )
             );
       return http.build();
    }
}

X-Frame-Options

允许您的网站被添加到框架中可能是一个安全问题。例如,通过使用巧妙的 CSS 样式,用户可能会被欺骗点击他们本不想点击的东西。

例如,一个登录到其银行的用户可能会点击一个按钮,允许其他用户访问。这种攻击被称为点击劫持

有关 Clickjacking 的更多信息,请参阅owasp.org/www-community/attacks/Clickjacking

处理 Clickjacking 的另一种更现代的方法是使用内容安全策略CSP)。

有多种方法可以缓解 Clickjacking 攻击。例如,为了保护旧版浏览器免受 Clickjacking 攻击,您可以使用框架破坏代码。虽然不是完美的,但框架破坏代码是针对旧版浏览器的最佳选择。

解决 Clickjacking 的一个更现代的方法是使用X-Frame-Options标题,如下所示:

X-Frame-Options: DENY

如果您想更改X-Frame-Options标题的值,则可以使用XFrameOptionsHeaderWriter实例。

一些浏览器内置了对过滤反射 XSS 攻击的支持。这绝对不是万无一失的,但它确实有助于 XSS 保护。

过滤通常默认启用,因此添加该标题只是确保它已启用,并指导浏览器在检测到 XSS 攻击时应做什么。例如,过滤器可能会尝试以最不具侵入性的方式更改内容,以便仍然渲染一切。有时,这种替换可能会成为 XSS 漏洞。相反,最好阻止内容,而不是尝试修复它。为此,我们可以添加以下标题:

X-XSS-Protection: 1; mode=block

CSP

Spring Security默认不添加 CSP(https://docs.spring.io/spring-security/reference/features/exploits/headers.xhtml#headers-csp),因为在不了解应用程序上下文的情况下,无法知道合理的默认值。Web 应用程序作者必须声明要强制执行或监控受保护资源的策略(或策略)。

考虑以下安全策略:

Content-Security-Policy: script-src 'self' https://trustedscripts.example.com; object-src https://trustedplugins.example.com; report-uri /csp-report-endpoint/

在前面的安全策略基础上,你可以启用 CSP 头部:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
       http
             // ...
             .headers(headers -> headers
                   .contentSecurityPolicy(csp -> csp
                         .policyDirectives("script-src 'self' https://trustedscripts.example.com; object-src https://trustedplugins.example.com; report-uri /csp-report-endpoint/")
                   )
             );
       return http.build();
    }
}

要启用 CSP 仅报告头部,请提供以下配置:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
       http
             // ...
             .headers(headers -> headers
                   .contentSecurityPolicy(csp -> csp
                         .policyDirectives("script-src 'self' https://trustedscripts.example.com; object-src https://trustedplugins.example.com; report-uri /csp-report-endpoint/")
                         .reportOnly()
                   )
             );
       return http.build();
    }
}

Referrer Policy

Spring Security默认不添加 Referrer Policy (docs.spring.io/spring-security/reference/features/exploits/headers.xhtml#headers-referrer)头部。你可以通过以下配置启用ReferrerPolicy头部:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
       http
             // ...
             .headers(headers -> headers
                   .referrerPolicy(referrer -> referrer
                         .policy(ReferrerPolicy.SAME_ORIGIN)
                   )
             );
       return http.build();
    }
}

功能策略

Spring Security默认不添加 Feature Policy (https://docs.spring.io/spring-security/reference/features/exploits/headers.xhtml#headers-feature)头部。考虑以下Feature-Policy头部:

Feature-Policy: geolocation 'self'

你可以通过以下配置启用前面的功能策略头部:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
       http
             // ...
             .headers(headers -> headers
                   .featurePolicy("geolocation 'self'")
             );
       return http.build();
    }
}

权限策略

Spring Security默认不添加 Permissions Policy (docs.spring.io/spring-security/reference/features/exploits/headers.xhtml#headers-permissions)头部。考虑以下Permissions-Policy头部:

Permissions-Policy: geolocation=(self)

你可以通过以下配置启用前面的权限策略头部:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
       http
             // ...
             .headers(headers -> headers
                   .featurePolicy("geolocation 'self'")
             );
       return http.build();
    }
}

清除站点数据

Spring Security默认不添加Clear-Site-Data (https://docs.spring.io/spring-security/reference/features/exploits/headers.xhtml#headers-clear-site-data)头部。考虑以下Clear-Site-Data头部:

Clear-Site-Data: "cache", "cookies"

你可以通过以下配置将前面的头部发送到注销功能:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
       http
       // ...
       .logout((logout) -> logout
             .addLogoutHandler(new HeaderWriterLogoutHandler(new ClearSiteDataHeaderWriter(CACHE, COOKIES)))
       );
       return http.build();
    }
}

静态头部

有时候你可能希望向你的应用程序注入自定义的安全头部,这些头部不是默认支持的。例如,你可能希望对 CSP 有早期支持,以确保资源只从同源加载。由于 CSP 的支持尚未最终确定,浏览器使用两种常见的扩展头部之一来实现该功能。这意味着我们需要注入策略两次。以下代码片段显示了头部的示例:

X-Content-Security-Policy: default-src 'self' X-WebKit-CSP: default-src 'self'

当使用 Java 配置时,这些头部可以通过header()方法添加到响应中,如下所示:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.headers(headers -> headers
          .addHeaderWriter(
                new StaticHeadersWriter("X-Content-Security-Policy", "default-src 'self'"))
          .addHeaderWriter(
                new StaticHeadersWriter( "X-WebKit-CSP","default-src 'self'");
    return http.build();
  }
}

HeadersWriter 实例

当命名空间或 Java 配置不支持你想要的头部时,你可以创建一个自定义的HeadersWriter实例,甚至提供HeadersWriter的自定义实现。

让我们看看使用自定义XFrameOptionsHeaderWriter实例的例子。也许你想允许同源内容的框架。这可以通过将策略属性设置为SAMEORIGIN轻松实现,但让我们看看使用ref属性的一个更明确的例子,如下面的代码片段所示:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
       http
             // ...
             .headers(headers -> headers
                   .frameOptions(FrameOptionsConfig::sameOrigin
                   )
             );
       return http.build();
    }
}

DelegatingRequestMatcherHeaderWriter 类

有时,您可能只想为某些请求编写标题。例如,也许您只想保护您的登录页面不被框架化。您可以使用 DelegatingRequestMatcherHeaderWriter 类来实现这一点。当使用 Java 配置时,可以通过以下代码完成:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
       RequestMatcher matcher = new AntPathRequestMatcher("/login");
       DelegatingRequestMatcherHeaderWriter headerWriter =
             new DelegatingRequestMatcherHeaderWriter(matcher,new XFrameOptionsHeaderWriter());
       http
             // ...
             .headers(headers -> headers
                   .frameOptions(frameOptions -> frameOptions.disable())
                   .addHeaderWriter(headerWriter)
             );
       return http.build();
    }
}

总结本节,您可以通过 DelegatingRequestMatcherHeaderWriter、显式 Clear-Site-Data、框架选项、静态标题和 DelegatingRequestMatcherHeaderWriter 添加 CSRF 保护。

重要提示

您的代码现在应类似于 chapter15.02-calendar

测试 Spring Security 应用程序

除了 spring-boot-starter-testSpring Security 还提供了一个专门针对测试目的的工件。其主要目的是提供实用工具和类,帮助开发者编写使用 Spring Security 进行身份验证和授权的应用程序的测试。

org.springframework.security:spring-security-test 提供的一些关键功能包括:

  • 模拟身份验证:它允许您在测试期间轻松模拟身份验证和授权上下文,使您能够模拟不同的用户角色和权限。

  • 集成测试:它通过提供设置测试环境中安全配置的实用工具来支持集成测试,确保您的安全配置得到正确应用和测试。

  • Spring Security 通常涉及配置一系列过滤器来处理身份验证和授权任务。此模块提供了测试这些过滤器单独或作为过滤器链一部分的实用工具。

要开始,您需要在您的 Spring Security 项目中包含以下依赖项:

//build.gradle
dependencies {
...
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}!

通过使用 spring-security-test,您可以有效地测试应用程序的安全功能,确保敏感资源得到适当的保护,并且仅对授权用户可访问。

反应式应用程序支持

反应式编程围绕异步和非阻塞交互展开,以回调和声明式方法为特征。它包含一个背压机制来调节生产者的吞吐量,有助于消费者控制。在 Java 中,反应式编程依赖于 StreamsCompletableFuture 和背压控制。存在许多相关的用例,其中反应式编程证明是有益的,包括支持高峰工作量、微服务、避免竞争、物联网和大数据应用程序。

Spring 依赖于项目 reactor(projectreactor.io/),这是 Spring Webflux (docs.spring.io/spring-framework/reference/web/webflux.xhtml) 的基础。

要将 Spring Security 反应式支持添加到您的 Spring Boot 项目中,您需要在项目中包含 spring-boot-starter-securityspring-boot-starter-webflux 依赖项:

//build.gradle
dependencies {
...
    // spring-webflux
    implementation 'org.springframework.boot:spring-boot-starter-webflux'
    // spring-security
    implementation 'org.springframework.boot:spring-boot-starter-security'...
}!

此表概述了关键类,以帮助从 Spring Security Servlet 实现过渡到 Spring Security 反应式实现。

Spring Security****Servlet implementation Spring Security****Reactive implementation
o.s.s.w.SecurityFilterChain o.s.s.w.s.Security WebFilterChain
o.s.s.c.u.UserDetailsService o.s.s.c.u. ReactiveUserDetailsService
o.s.s.c.a.w.b.HttpSecurity o.s.s.c.w.s.ServerHttpSecurity
o.s.s.c.c. SecurityContextHolder o.s.s.c.c. ReactiveSecurityContextHolder
o.s.s.a.AuthenticationManager o.s.s.a.ReactiveAuthentication Manager
o.s.s.c.a.w.c.EnableWebSecurity o.s.s.c.a.m.r. EnableWeb``FluxSecurity
o.s.s.c.a.m.c. EnableMethodSecurity o.s.s.c.a.m.c.EnableReactive MethodSecurity
o.s.s.p.InMemoryUser DetailsManager o.s.s.c.u. MapReactiveUserDetailsService
o.s.s.o.c.OAuth2Authorized ClientManager o.s.s.o.c.ReactiveOAuth2 AuthorizedClientManager
o.s.s.o.c. OAuth2Authorized``ClientProvider o.s.s.o.c.ReactiveOAuth2 AuthorizedClientProvider
o.s.s.o.c.w.DefaultOAuth2Authorized ClientManager o.s.s.o.c.w.DefaultReactiveO Auth2AuthorizedClientManager
o.s.s.o.c.r.ClientRegistration Repository o.s.s.o.c.r.ReactiveClient RegistrationRepository
o.s.s.o.c.e. OAuth2Access``TokenResponseClient o.s.s.o.c.e.ReactiveOAuth2 AccessTokenResponseClient
o.s.s.o.j.JwtDecoder o.s.s.o.j.ReactiveJwtDecoder

表 15.1 – 从 Spring Security Servlet 实现过渡到反应式实现

重要提示

在本节中,我们提供了一个在 chapter15.03-calendar 中可用的完整功能反应式实现。

你将注意到以下主要变化以启用反应式支持:

  1. 按照以下步骤配置 Spring Security 以使用我们的自定义 ReactiveUserDetailsService 对象:

    //com/packtpub/springsecurity/service/UserDetailsServiceImpl.java
    @Service
    public class UserDetailsServiceImpl implements ReactiveUserDetailsService {
        private final CalendarUserRepository userRepository;
        public UserDetailsServiceImpl(CalendarUserRepository userRepository) {
           this.userRepository = userRepository;
        }
        @Override
        public Mono<UserDetails> findByUsername(String username) {
           return userRepository.findByEmail(username)
                 .flatMap(user -> {
                    Set<GrantedAuthority> grantedAuthorities = new HashSet<>();
                    for (Role role : user.getRoles()) {
                       grantedAuthorities.add(new SimpleGrantedAuthority(role.getName()));
                    }
                    return Mono.just(new User(user.getEmail(), user.getPassword(), grantedAuthorities));
                 });
        }
    }
    
  2. 使用 @EnableWebFluxSecurity 注解,以启用 Spring Security 的反应式支持:

    //com/packtpub/springsecurity/configuration/SecurityConfig.java
    @Configuration
    @EnableWebFluxSecurity
    public class SecurityConfig {
    ...
    }
    
  3. 定义一个 ReactiveAuthenticationManager 实例:

    //com/packtpub/springsecurity/configuration/SecurityConfig.java
    @Bean
    public ReactiveAuthenticationManager reactiveAuthenticationManager(final ReactiveUserDetailsService userDetailsService,
           final PasswordEncoder passwordEncoder) {
        UserDetailsRepositoryReactiveAuthenticationManager authenticationManager = new UserDetailsRepositoryReactiveAuthenticationManager(userDetailsService);
        authenticationManager.setPasswordEncoder(passwordEncoder);
        return authenticationManager;
    }
    
  4. 创建 SecurityWebFilterChain 实例:

    //com/packtpub/springsecurity/configuration/SecurityConfig.java
    @Bean
    public SecurityWebFilterChain filterChain(ServerHttpSecurity http)  {
        http.authorizeExchange(exchanges -> exchanges
                    .pathMatchers("/webjars/**").permitAll()
                    .pathMatchers("/css/**").permitAll()
                    .pathMatchers("/favicon.ico").permitAll()
                    // H2 console:
                    .pathMatchers("/admin/h2/**").permitAll()
                    .pathMatchers("/").permitAll()
                    .pathMatchers("/login/*").permitAll()
                    .pathMatchers("/logout").permitAll()
                    .pathMatchers("/signup/*").permitAll()
                    .pathMatchers("/errors/**").permitAll()
                    .pathMatchers("/admin/*").hasRole("ADMIN")
                    .pathMatchers("/events/").hasRole("ADMIN")
                    .pathMatchers("/**").hasRole("USER"));
        http.formLogin(Customizer.withDefaults());
        http.exceptionHandling(exceptions -> exceptions
              .accessDeniedHandler(new HttpStatusServerAccessDeniedHandler(HttpStatus.FORBIDDEN)));
        return http.build();
    }
    

启动应用程序并尝试通过 http://localhost:8080 访问它。

你应该能够使用配置的用户 admin1@example.com/admin1user1@example.com/user1 登录并测试事件创建。

此外,创建新用户可以立即使用新创建的凭据登录。

总结来说,Spring Security 也提供了一个反应式实现。这个实现最适合需要处理大量并发连接、高可扩展性和高效资源利用的应用程序。它在涉及非阻塞 I/O 操作的场景中特别有益,例如用户流量大的 Web 应用程序或实时数据处理。

摘要

在本章中,我们讨论了几个安全漏洞,以及如何使用 Spring Security 来规避这些漏洞。阅读本章后,你应该理解 CSRF 的威胁以及使用 同步器令牌 模式来防止 CSRF 的方法。

你还应该了解如何使用 Cache-ControlContent-Type OptionsHSTSX-Frame-OptionsX-XSS-Protection 方法来包含各种 HTTP 头,以防范常见的安全漏洞。

在下一章中,我们将讨论如何迁移到 Spring Security 6.x

第十六章:迁移到 Spring Security 6

在本章的最后,我们将回顾与从Spring Security 5.x迁移到Spring Security 6.x相关的常见迁移问题,这些问题包含大量的非被动重构。

在本章末尾,我们还将突出显示Spring Security 6.x中可以找到的一些新功能。然而,我们并没有明确涵盖从Spring Security 5.xSpring Security 6.x的变化。这是因为通过解释这两个版本之间的差异,用户应该能够更新到Spring Security 6.x

你可能计划将现有应用程序迁移到Spring Security 6.x,或者你可能正在尝试向Spring Security 5.x应用程序添加功能,并在此书的页面上寻找指导。我们将在此章中尝试解决你的两个问题。

首先,我们将概述Spring Security 5.x6.x之间的重要差异——包括功能和配置。

其次,我们将提供一些关于映射配置或类名更改的指导。这将更好地帮助你将书中的示例从Spring Security 6.x翻译回Spring Security 5.x(如果适用)。

重要提示

Spring Security 6.x要求迁移到Spring Framework 6Java 17或更高版本。

注意,在许多情况下,迁移这些其他组件可能对你的应用程序的影响比升级Spring Security更大!

在本章中,我们将涵盖以下主题:

  • 检查Spring Security 6.x中的重要增强。

  • 理解在现有 Spring 版本中所需的配置更改。

  • 在将Spring Security 5.x应用程序迁移到Spring Security 6.x时,检查Spring Security 5.x应用程序。

  • 展示Spring Security 6.x中重要类和包的整体迁移情况。

  • 突出显示Spring Security 6.x中的一些新功能。完成本章的审查后,你将处于良好的位置,可以将现有应用程序从Spring Security 5.x迁移到Spring Security 6.x

  • Spring Security 5.x迁移。

本章的代码示例链接在此:packt.link/wD0Sk

漏洞利用保护

Spring Security 5.8中,负责向应用程序提供CsrfToken的默认CsrfTokenRequestHandlerCsrfTokenRequestAttributeHandler。字段csrfRequestAttributeName的默认设置是null,导致在每次请求时加载 CSRF 令牌。

应当认为读取会话是不必要的情况的示例包括明确标记为permitAll()的端点,例如静态资产、静态 HTML 页面以及位于同一域名/服务器下的单页应用程序。

Spring Security 6中,csrfRequestAttributeName现在默认为_csrf。如果你只是为了过渡到 6.0 版本而配置了以下内容,你现在可以安全地将其删除:

requestHandler.setCsrfRequestAttributeName("_csrf");

现在我们已经探讨了如何定义 CsrfToken,我们将探讨如何防范 CSRF 攻击。

防范 CSRF 攻击

Spring Security 5.8 中,使 CsrfToken 可用于应用程序的默认 CsrfTokenRequestHandlerCsrfTokenRequestAttributeHandlerXorCsrfTokenRequestAttributeHandler 的引入是为了启用 CSRF 攻击支持。

在 Spring Security 6 中,XorCsrfTokenRequestAttributeHandler 成为提供 CsrfToken 的默认 CsrfTokenRequestHandler。如果您仅为了过渡到版本 6.0 而配置了 XorCsrfTokenRequestAttributeHandler,现在可以安全地将其移除。

重要提示

如果您已将 csrfRequestAttributeName 设置为 null 以排除延迟令牌,或者如果您已为任何特定目的建立了 CsrfTokenRequestHandler,则可以保持当前配置。

支持 WebSocket 的 CSRF 攻击

Spring Security 5.8 中,用于提供 CsrfToken 并具有 WebSocket 安全性的默认 ChannelInterceptorCsrfChannelInterceptorXorCsrfChannelInterceptor 的引入是为了启用 CSRF 攻击支持。

Spring Security 6 中,XorCsrfChannelInterceptor 成为提供 CsrfToken 的默认 ChannelInterceptor。如果您仅为了过渡到版本 6.0 而配置了 XorCsrfChannelInterceptor,现在可以安全地将其移除。

在探讨了如何防范 CSRF 攻击之后,我们将深入探讨配置迁移选项。

配置迁移

后续章节涉及配置 HttpSecurityWebSecurityAuthenticationManager 的变更。

@Configuration 注解添加到 @Enable* 注解中

在 6.0 版本中,注解 @EnableWebSecurity@EnableMethodSecurity@EnableGlobalMethodSecurity@EnableGlobalAuthentication 不再包含 @Configuration

例如,@EnableWebSecurity 将会被修改为:

@EnableWebSecurity
public class SecurityConfig {
    // ...
}

变更为:

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    // ...
}

为了适应这一变化,无论您在哪里使用这些注解,您可能需要添加 @Configuration

使用新的请求匹配器方法

Spring Security 5.8 中,antMatchersmvcMatchersregexMatchers 方法被弃用,以支持新的 requestMatchers 方法。

新的 requestMatchers 方法的引入扩展到了 authorizeHttpRequestsauthorizeRequests、CSRF 配置、WebSecurityCustomizer 以及具有专用 RequestMatcher 方法的其他位置。截至 Spring Security 6,已弃用的方法已被移除。

新的方法通过自动选择最适合您应用程序的 RequestMatcher 实现提供了更安全的默认设置。

为了总结,这些方法:

  • 如果您的应用程序包含 Spring MVC 在类路径中,请选择 MvcRequestMatcher 实现。

  • 如果没有 Spring MVC,则回退到AntPathRequestMatcher实现,使其行为与 Kotlin 等效方法对齐。

以下表格应指导你在迁移过程中的操作:

Spring Security 5 Spring Security 6
antMatchers("/api/admin/**") requestMatchers("/api/admin/**")
mvcMatchers("/admin/**") requestMatchers("/admin/**")
mvcMatchers("/admin").servletPath("/path") requestMatchers(mvcMatcherBuilder.pattern("/admin"))

表 16.1 – 使用新的 requestMatchers 进行迁移

如果你在新的requestMatchers方法上遇到困难,你可以选择回退到你之前使用的RequestMatcher实现。例如,如果你更喜欢继续使用AntPathRequestMatcherRegexRequestMatcher实现,你可以使用接受RequestMatcher实例的requestMatchers方法:

Spring Security 5 Spring Security 6
antMatchers("/api/admin/**") requestMatchers(antMatcher("/user/**"))

表 16.2 – 使用新的 requestMatchers 的替代方案

重要提示

请注意,提供的示例使用了来自AntPathRequestMatcherRegexRequestMatcher的静态工厂方法来提高可读性。

当你使用WebSecurityCustomizer接口时,你可以用相应的requestMatchers替代方法替换已弃用的antMatchers方法:

@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
    return web -> web.ignoring().antMatchers("/ignore1", "/ignore2");
}

使用对应的requestMatchers替代方法:

@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
    return web -> web.ignoring().requestMatchers("/ignore1", "/ignore2");
}

同样,如果你正在自定义CSRF配置以排除特定路径,你可以用requestMatchers的对应方法替换已弃用的方法。

使用新的 securityMatchers 方法

Spring Security 5.8中,HttpSecurity中的antMatchersmvcMatchersrequestMatchers方法被弃用,以支持新的securityMatchers方法。

需要注意的是,这些方法与被弃用的authorizeHttpRequests方法不同,这些方法被requestMatchers方法所取代。然而,securityMatchers方法与requestMatchers方法有相似之处,即它们会自动选择最适合你应用程序的RequestMatcher实现。

为了详细说明,新的方法:

  • 如果你的应用程序包含 Spring MVC 在类路径中,请选择MvcRequestMatcher实现。

  • 如果没有 Spring MVC,则回退到AntPathRequestMatcher实现,使其行为与 Kotlin 等效方法对齐。securityMatchers方法的引入也有助于避免与authorizeHttpRequests中的requestMatchers方法混淆。

以下表格应指导你在迁移过程中的操作,其中httpHttpSecurity类型:

Spring Security 5 Spring Security 6
http.antMatcher("/api/**") http.securityMatcher("/api/**")
http.requestMatcher(new MyCustomRequestMatcher()) http.securityMatcher(new MyCustomRequestMatcher())
http``.requestMatchers((matchers) -> matchers``.``antMatchers("/api/**", "/app/**")``.``mvcMatchers("/admin/**")``.``requestMatchers(new MyCustomRequestMatcher())) http.securityMatchers((matchers) -> matchers.requestMatchers("/api/**", "/``app/**", "/admin/**")``.``requestMatchers(new MyCustomRequestMatcher()))

表 16.3 – 迁移到新的 securityMatchers

如果你在使用 securityMatchers 方法自动选择 RequestMatcher 实现时遇到挑战,你可以选择手动选择 RequestMatcher 实现:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
          .securityMatchers(matchers -> matchers
                .requestMatchers(antMatcher("/api/**"), antMatcher("/app/**"))
          );
    return http.build();
}

在探索了新的 securityMatchers 方法之后,我们现在将探讨在 Spring Security 6.x 中替换 WebSecurityConfigurerAdapter 的过程。

替换 WebSecurityConfigurerAdapter 类

WebSecurityConfigurerAdapter 类在 Spring Security 6.x 中已被弃用并移除。在接下来的子章节中,我们将探讨这一重大变化的影响。

暴露 SecurityFilterChain Bean

Spring Security 5.4 中,引入了一个新功能,允许发布 SecurityFilterChain Bean 而不是扩展 WebSecurityConfigurerAdapter。然而,在 6.0 版本中,WebSecurityConfigurerAdapter 已被移除。为了适应这一变化,你可以替换类似的结构:

@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
       http
             .authorizeHttpRequests(authorize -> authorize
                   .anyRequest().authenticated()
             )
             .httpBasic(withDefaults());
    }
}

使用以下方法:

@Configuration
public class SecurityConfiguration {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
       http
             .authorizeHttpRequests((authorize) -> authorize
                   .anyRequest().authenticated()
             )
             .httpBasic(withDefaults());
       return http.build();
    }
}

暴露 WebSecurityCustomizer Bean

Spring Security 5.4 引入了 WebSecurityCustomizer 作为 WebSecurityConfigurerAdapterconfigure(WebSecurity web) 的替代品。为了准备其移除,你可以更新类似以下代码:

@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Override
    public void configure(WebSecurity web) {
       web.ignoring().antMatchers("/ignore1", "/ignore2");
    }
}

使用以下方法:

@Configuration
public class SecurityConfiguration {
    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
       return (web) -> web.ignoring().antMatchers("/ignore1", "/ignore2");
    }
}

暴露 AuthenticationManager Bean

随着 WebSecurityConfigurerAdapter 的移除,configure(AuthenticationManagerBuilder) 方法也被消除。

LDAP 认证

当使用 auth.ldapAuthentication()轻量级目录访问协议 (LDAP) 认证支持时,你可以替换以下内容:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
       auth
             .ldapAuthentication()
             .userDetailsContextMapper(new PersonContextMapper())
             .userDnPatterns("uid={0},ou=people")
             .contextSource()
             .port(0);
    }
}

使用以下方法:

@Configuration
public class SecurityConfiguration {
    @Bean
    public EmbeddedLdapServerContextSourceFactoryBean contextSourceFactoryBean() {
       EmbeddedLdapServerContextSourceFactoryBean contextSourceFactoryBean =
             EmbeddedLdapServerContextSourceFactoryBean.fromEmbeddedLdapServer();
       contextSourceFactoryBean.setPort(0);
       return contextSourceFactoryBean;
    }
    @Bean
    AuthenticationManager ldapAuthenticationManager(BaseLdapPathContextSource contextSource) {
       LdapBindAuthenticationManagerFactory factory =
             new LdapBindAuthenticationManagerFactory(contextSource);
       factory.setUserDnPatterns("uid={0},ou=people");
       factory.setUserDetailsContextMapper(new PersonContextMapper());
       return factory.createAuthenticationManager();
    }
}

JDBC 认证

如果你目前正使用 auth.jdbcAuthentication()Java 数据库连接 (JDBC) 认证支持提供支持,你可以替换:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final DataSource dataSource;
    public SecurityConfig(DataSource dataSource) {
       this.dataSource = dataSource;
    }
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
       UserDetails user = User.withDefaultPasswordEncoder()
             .username("user")
             .password("password")
             .roles("USER")
             .build();
       auth.jdbcAuthentication()
             .withDefaultSchema()
             .dataSource(this.dataSource)
             .withUser(user);
    }
}

使用以下方法:

@Configuration
public class SecurityConfig {
    private final DataSource dataSource;
    public SecurityConfig(DataSource dataSource) {
       this.dataSource = dataSource;
    }
    @Bean
    public UserDetailsManager users(DataSource dataSource) {
       UserDetails user = User.withDefaultPasswordEncoder()
             .username("user")
             .password("password")
             .roles("USER")
             .build();
       JdbcUserDetailsManager users = new JdbcUserDetailsManager(dataSource);
       users.createUser(user);
       return users;
    }
}

内存认证

如果你目前正使用 auth.inMemoryAuthentication() 为内存 Authentication 支持提供支持,你可以替换:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
       UserDetails user = User.withDefaultPasswordEncoder()
             .username("user")
             .password("password")
             .roles("USER")
             .build();
       auth.inMemoryAuthentication()
             .withUser(user);
    }
}

使用以下方法:

@Configuration
public class SecurityConfig {
    @Bean
    public InMemoryUserDetailsManager userDetailsService() {
       UserDetails user = User.withDefaultPasswordEncoder()
             .username("user")
             .password("password")
             .roles("USER")
             .build();
       return new InMemoryUserDetailsManager(user);
    }
}

在探索 WebSecurityConfigurerAdapter 移除的影响之后,我们将深入了解密码编码的更新。

密码编码更新

Spring Security 6.0 中,对密码编码的最小要求已被修订,以适应 PBKDF2SCryptArgon2

如果你使用默认的密码编码器,则无需遵循任何准备步骤,你可以跳过这一部分。

Pbkdf2PasswordEncoder 更新

如果你正在使用 Pbkdf2PasswordEncoder,构造函数已被替换为与提供的设置相关的 Spring Security 版本相对应的静态工厂:

@Bean
PasswordEncoder passwordEncoder2() {
    return Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_5();
}

如果你有自定义设置,请使用指定所有设置的构造函数:

@Bean
PasswordEncoder passwordEncoder() {
    return new Pbkdf2PasswordEncoder("secret".getBytes(UTF_8), 16, 185000, 256);
}

SCryptPasswordEncoder 更新

如果你正在使用 SCryptPasswordEncoder,构造函数已被替换为与提供的设置关联的 Spring Security 版本的静态工厂。

您的第一步应该是修改已弃用的构造函数:

@Bean
PasswordEncoder passwordEncoder() {
    return SCryptPasswordEncoder.defaultsForSpringSecurity_v4_1();
}

Argon2PasswordEncoder 更新

如果你正在使用 Argon2PasswordEncoder,构造函数已被替换为与提供的设置关联的 Spring Security 版本的静态工厂。例如:

@Bean
PasswordEncoder passwordEncoder() {
    return Argon2PasswordEncoder.defaultsForSpringSecurity_v5_2();
}

委派 PasswordEncoder 使用

如果你没有使用已弃用的构造函数,更新你的代码以符合最新标准是至关重要的。这包括配置 DelegatingPasswordEncoder 以识别符合当前标准的密码并将它们更新到最新版本。以下使用 Pbkdf2PasswordEncoder 的示例也可以应用于 SCryptPasswordEncoderArgon2PasswordEncoder

@Bean
PasswordEncoder passwordEncoder() {
    String prefix = "pbkdf2@5.8";
    PasswordEncoder current = Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_5();
    PasswordEncoder upgraded = Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8();
    DelegatingPasswordEncoder delegating = new DelegatingPasswordEncoder(prefix, Map.of(prefix, upgraded));
    delegating.setDefaultPasswordEncoderForMatches(current);
    return delegating;
}

弃用 Encryptors.queryableText

使用 Encryptors.queryableText(CharSequence, CharSequence) 被认为是不可安全的,因为相同的输入数据将产生相同的输出(CVE-2020-5408 - github.com/advisories/GHSA-2ppp-9496-p23q)。

Spring Security 6.x 不再支持通过此方法进行数据加密。为了方便升级,你必须使用支持的机制重新加密数据或将数据以解密形式存储。

在检查密码编码更新之后,我们将深入了解会话管理更新的细节。

会话管理更新

在接下来的章节中,我们将详细检查会话管理更新,包括主要的弃用和修改。

强制保存 SecurityContextRepository

Spring Security 5 中,默认过程涉及通过 SecurityContextPersistenceFilter 自动将 SecurityContext 保存到 SecurityContextRepository。这种保存发生在 HttpServletResponse 提交之前,就在 SecurityContextPersistenceFilter 之前。然而,这种自动持久化可能会让用户措手不及,尤其是在请求完成之前(即在提交 HttpServletResponse 之前)执行。它还引入了跟踪状态的复杂性,以确定保存的必要性,有时会导致对 SecurityContextRepository(例如,HttpSession)的不必要写入。

随着 Spring Security 6 的推出,默认行为发生了变化。SecurityContextHolderFilter 现在将仅从 SecurityContextRepository 读取 SecurityContext 并将其填充到 SecurityContextHolder 中。如果用户希望 SecurityContext 在请求之间持续存在,他们现在必须显式使用 SecurityContextRepository 保存 SecurityContext。此修改通过仅在必要时强制写入 SecurityContextRepository(例如,HttpSession)来消除歧义并提高性能。

要选择使用新的Spring Security 6默认设置,可以使用以下配置:

public SecurityFilterChain filterChain(HttpSecurity http) {
    http
          .securityContext((securityContext) -> securityContext
                .requireExplicitSave(true)
          );
    return http.build();
}

在使用此配置时,任何负责使用SecurityContext设置SecurityContextHolder的代码都必须确保在请求之间需要持久化时将SecurityContext保存到SecurityContextRepository

例如,以下代码:

SecurityContextHolder.setContext(securityContext);

应该替换为:

SecurityContextHolder.setContext(securityContext);
securityContextRepository.saveContext(securityContext, httpServletRequest, httpServletResponse);

将 HttpSessionSecurityContextRepository 更改为 DelegatingSecurityContextRepository

Spring Security 5中,默认的SecurityContextRepositoryHttpSessionSecurityContextRepository

Spring Security 6中,默认的SecurityContextRepositoryDelegatingSecurityContextRepository。要采用新的Spring Security 6默认设置,可以使用以下配置:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
          // ...
          .securityContext((securityContext) -> securityContext
                .securityContextRepository(new DelegatingSecurityContextRepository(
                      new RequestAttributeSecurityContextRepository(),
                      new HttpSessionSecurityContextRepository()
                ))
          );
    return http.build();
}

解决 SecurityContextRepository 弃用问题

Spring Security 6中,SecurityContextRepository类中的以下方法已被弃用:

Supplier<SecurityContext> loadContext(HttpServletRequest request)

应该替换为以下方法:

DeferredSecurityContext loadDeferredContext(HttpServletRequest request)

改进 RequestCache 的查询

Spring Security 5中,标准程序涉及在每个传入请求中查询保存的请求。在典型配置中,这意味着每个请求都会咨询HttpSession以利用RequestCache

Spring Security 6中,新的默认设置是仅在 HTTP 参数continue明确定义时才会查询缓存的请求。这种方法使Spring Security能够在与RequestCache一起工作时跳过不必要的HttpSession读取。

需要显式调用 SessionAuthenticationStrategy

Spring Security 5中,标准配置依赖于SessionManagementFilter来识别用户是否最近进行了认证以及触发SessionAuthenticationStrategy。然而,在这种设置中,在典型场景下,每个请求都需要读取HttpSession

Spring Security 6中,新的默认设置是认证机制直接调用SessionAuthenticationStrategy。因此,不需要识别何时发生认证,消除了在每个请求中读取HttpSession的需求。

在调查会话管理更新之后,我们将深入探讨认证更新。

认证更新

我们将检查认证的主要更新,包括采用SHA-256用于Remember Me功能以及与AuthenticationServiceExceptions相关的增强。

使用 SHA-256 实现 Remember Me

Spring Security 6TokenBasedRememberMeServices实现现在默认使用SHA-256用于Remember Me令牌,增强了默认的安全立场。这一变化是由于认识到MD5是一个易受碰撞攻击和模差攻击的弱散列算法。

新生成的令牌包括有关用于令牌生成的算法的信息。此信息被用于匹配目的。如果算法名称不存在,则使用 matchingAlgorithm 属性来验证令牌。这种设计允许从 MD5 无缝过渡到 SHA-256

以下代码展示了如何启用 Remember Me 功能,使用默认实现:

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http, RememberMeServices rememberMeServices) throws Exception {
       http
             // ...
             .rememberMe(remember -> remember
                   .rememberMeServices(rememberMeServices)
             );
       return http.build();
    }
    @Bean
    RememberMeServices rememberMeServices(UserDetailsService userDetailsService) {
       return new TokenBasedRememberMeServices(myKey, userDetailsService);
    }
}

为了接受新的 Spring Security 6 默认编码令牌,同时保持与 MD5 编码令牌的兼容性,你可以将 encodingAlgorithm 属性设置为 SHA-256,并将 matchingAlgorithm 属性设置为 MD5

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http, RememberMeServices rememberMeServices) throws Exception {
       http
             .rememberMe(remember -> remember
                   .rememberMeServices(rememberMeServices)
             );
       return http.build();
    }
    @Bean
    RememberMeServices rememberMeServices(UserDetailsService userDetailsService) {
       RememberMeTokenAlgorithm encodingAlgorithm = RememberMeTokenAlgorithm.SHA256;
       TokenBasedRememberMeServices rememberMe = new TokenBasedRememberMeServices(myKey, userDetailsService, encodingAlgorithm);
       rememberMe.setMatchingAlgorithm(RememberMeTokenAlgorithm.MD5);
       return rememberMe;
    }
}

传播 AuthenticationServiceExceptions

AuthenticationFilterAuthenticationServiceException 转发到 AuthenticationEntryPoint。由于 AuthenticationServiceExceptions 表示服务器端错误而不是客户端错误,在 6.0 版本中,此机制被调整为将它们传播到容器中。

因此,如果你之前通过将 rethrowAuthenticationServiceException 设置为 true 来启用了此行为,你现在可以按照以下方式消除它:

AuthenticationFilter authenticationFilter = new AuthenticationFilter(...);
AuthenticationEntryPointFailureHandler handler = new AuthenticationEntryPointFailureHandler(...);
handler.setRethrowAuthenticationServiceException(true);
authenticationFilter.setAuthenticationFailureHandler(handler);

这可以改为:

AuthenticationFilter authenticationFilter = new AuthenticationFilter(...);
AuthenticationEntryPointFailureHandler handler = new AuthenticationEntryPointFailureHandler(...);
authenticationFilter.setAuthenticationFailureHandler(handler);

在审查认证更新之后,我们将深入探讨授权更新。

授权更新

在本节中,我们将探讨 Spring Security 中授权管理的几个关键增强。我们将首先讨论如何利用 AuthorizationManager 进行 Method Security,以实现对方法级访问的细粒度控制。接下来,我们将深入探讨利用 AuthorizationManager 进行消息安全性,以促进通过消息协议的安全通信。此外,我们还将突出一些弃用,如 AbstractSecurityWebSocketMessageBrokerConfigurer

利用 AuthorizationManager 进行方法安全性

AuthorizationManager API 和直接使用 Spring AOP

如果你在实施这些调整时遇到挑战,请注意,尽管 @EnableGlobalMethodSecurity 已被弃用,但在 6.0 版本中尚未移除。这确保了你可以通过继续使用弃用的注解来选择退出。

将全局方法安全性与方法安全性替换

@EnableGlobalMethodSecurity<global-method-security> 现已弃用,分别由 @EnableMethodSecurity<method-security> 取代。更新的注解和 XML 元素自动激活 Spring 的 pre-post 注解,并内部使用 AuthorizationManager

@EnableTransactionManagement 中更改顺序值

@EnableTransactionManagement@EnableGlobalMethodSecurity 都具有相同的顺序值 Integer.MAX_VALUE。因此,它们在 Spring AOP Advisor 链中的相对顺序是未定义的。

虽然这通常是可接受的,因为大多数 Method Security 表达式不依赖于开放事务来正确运行,但历史上有些情况下需要通过设置它们的顺序值来确保特定的顺序。

相反,@EnableMethodSecurity 由于它调度多个拦截器而没有顺序值。与 @EnableTransactionManagement 不同,它无法保持向后兼容,因为它无法将所有拦截器放置在同一个顾问链位置。

相反,@EnableMethodSecurity 拦截器的顺序值基于偏移量 0。例如,@PreFilter 拦截器的顺序为 100@PostAuthorize 的顺序为 200,依此类推。

如果更新后您发现由于缺少开放事务,您的 Method Security 表达式无法正常工作,请修改您的交易注解定义如下:

@EnableTransactionManagement(order = 0)

使用自定义 @Bean 而不是继承 DefaultMethodSecurityExpressionHandler

为了性能优化,MethodSecurityExpressionHandler 已添加了一个新方法,该方法接受一个 Supplier<Authentication> 而不是 Authentication

此增强功能允许 Spring Security 延迟 Authentication 查找,并在使用 @EnableMethodSecurity 而不是 @EnableGlobalMethodSecurity 时自动使用。

例如,假设您希望对 @PostAuthorize("hasAuthority('ADMIN')") 进行自定义评估。在这种情况下,您可以创建一个自定义 @Bean,如下所示:

class MyAuthorizer {
    boolean isAdmin(MethodSecurityExpressionOperations root) {
       boolean decision = root.hasAuthority("ADMIN");
       // custom work ...
       return decision;
    }
}

然后,在注解中引用它,如下所示:

@PreAuthorize("@authz.isAdmin(#root)")

替换权限评估器以公开 MethodSecurityExpressionHandler

@EnableMethodSecurity 不会自动检测 PermissionEvaluator 以保持其 API 简洁。

如果您已将自定义 PermissionEvaluator 声明为 @Bean,请将其更新如下:

@Bean
static PermissionEvaluator permissionEvaluator() {
    // ... your evaluator
}

更改为:

@Bean
static MethodSecurityExpressionHandler expressionHandler() {
    var expressionHandler = new DefaultMethodSecurityExpressionHandler();
    expressionHandler.setPermissionEvaluator(myPermissionEvaluator);
    return expressionHandler;
}

在方法安全中替换任何自定义 AccessDecisionManagers

您的应用程序可能具有自定义的 AccessDecisionManagerAccessDecisionVoter 配置。适应方法将根据每个配置的具体目的而有所不同。继续阅读以确定最适合您场景的调整。

基于共识的使用案例

如果您的应用程序使用默认投票者的 UnanimousBased,您可能不需要进行任何更改,因为基于一致性的默认行为与 @EnableMethodSecurity 相同。

然而,如果您发现默认的授权管理器不适合,您可以使用 AuthorizationManagers.allOf 来构建您的自定义配置。

基于肯定的使用案例

如果您的应用程序依赖于 AffirmativeBased,您可以创建一个等效的 AuthorizationManager,如下所示:

AuthorizationManager<MethodInvocation> authorization = AuthorizationManagers.anyOf(
       // ... your list of authorization managers
)

基于共识的使用案例

对于 ConsensusBased,框架没有提供内置的等效功能。在这种情况下,您应该实现一个组合 AuthorizationManager,该 AuthorizationManager 考虑到委托 AuthorizationManager 实例的集合。

AccessDecisionVoter 用例

你可以修改类以实现AuthorizationManager或创建如下适配器:

public final class PreAuthorizeAuthorizationManagerAdapter implements AuthorizationManager<MethodInvocation> {
    private final SecurityMetadataSource metadata;
    private final AccessDecisionVoter voter;
    public PreAuthorizeAuthorizationManagerAdapter (MethodSecurityExpressionHandler expressionHandler) {
       ExpressionBasedAnnotationAttributeFactory attributeFactory =
             new ExpressionBasedAnnotationAttributeFactory(expressionHandler);
       this.metadata = new PrePostAnnotationSecurityMetadataSource(attributeFactory);
       ExpressionBasedPreInvocationAdvice expressionAdvice = new ExpressionBasedPreInvocationAdvice();
       expressionAdvice.setExpressionHandler(expressionHandler);
       this.voter = new PreInvocationAuthorizationAdviceVoter(expressionAdvice);
    }
    public AuthorizationDecision check(Supplier<Authentication> authentication, MethodInvocation invocation) {
       List<ConfigAttribute> attributes = this.metadata.getAttributes(invocation, AopUtils.getTargetClass(invocation.getThis()));
       int decision = this.voter.vote(authentication.get(), invocation, attributes);
       if (decision == ACCESS_GRANTED) {
          return new AuthorizationDecision(true);
       }
       if (decision == ACCESS_DENIED) {
          return new AuthorizationDecision(false);
       }
       return null; // abstain
    }
}

AfterInvocationManager 或 AfterInvocationProvider 用例

AfterInvocationManagerAfterInvocationProvider负责对调用结果进行授权决策。例如,在方法调用的上下文中,它们确定方法返回值的授权。

Spring Security 3.0中,授权的决策过程通过@PostAuthorize@PostFilter注解进行了标准化。@PostAuthorize用于确定整个返回值是否允许返回。另一方面,@PostFilter用于从返回的集合、数组或流中过滤单个条目。

这两个注解应该能满足大多数需求,并且鼓励过渡到其中一个或两个,因为AfterInvocationProviderAfterInvocationManager现在已被弃用。

RunAsManager 用例

目前还没有RunAsManager的直接替代品,尽管正在考虑引入一个。

然而,如果需要,修改RunAsManager以与AuthorizationManager API 保持一致相对简单。

这里有一些伪代码可以帮助你开始:

public final class RunAsAuthorizationManagerAdapter<T> implements AuthorizationManager<T> {
    private final RunAsManager runAs = new RunAsManagerImpl();
    private final SecurityMetadataSource metadata;
    private final AuthorizationManager<T> authorization;
    // ... constructor
    public AuthorizationDecision check(Supplier<Authentication> authentication, T object) {
       Supplier<Authentication> wrapped = (auth) -> {
          List<ConfigAttribute> attributes = this.metadata.getAttributes(object);
          return this.runAs.buildRunAs(auth, object, attributes);
       };
       return this.authorization.check(wrapped, object);
    }
}

验证 AnnotationConfigurationException

@EnableMethodSecurity<method-security>启用对Spring Security的非重复或不可兼容注解的更严格执行。如果你在过渡到任一配置后在日志中遇到AnnotationConfigurationException,请按照异常消息中提供的说明来纠正应用程序的方法安全注解使用。

利用 AuthorizationManager 进行消息安全

消息安全通过AuthorizationManager API 和直接使用 Spring AOP 得到了增强。

要为消息安全配置AuthorizationManager,你需要遵循以下步骤:

  1. 确保所有消息都定义了授权规则:

    @Override
    protected void configureInbound(MessageSecurityMetadataSourceRegistry messages) {
        messages
              .simpTypeMatchers(CONNECT, DISCONNECT, UNSUBSCRIBE).permitAll()
              .simpDestMatchers("/user/queue/errors").permitAll()
              .simpDestMatchers("/admin/**").hasRole("ADMIN")
              .anyMessage().denyAll();
    }
    
  2. 添加@EnableWebSocketSecurity注解。

  3. 使用AuthorizationManager<Message<?>>的实例:

    @Bean
    AuthorizationManager<Message<?>> messageSecurity(MessageMatcherDelegatingAuthorizationManager.Builder messages) {
        messages
              .simpTypeMatchers(CONNECT, DISCONNECT, UNSUBSCRIBE).permitAll()
              .simpDestMatchers("/user/queue/errors").permitAll()
              .simpDestMatchers("/admin/**").hasRole("ADMIN")
              .anyMessage().denyAll();
        return messages.build();
    }
    

现在我们已经检查了消息安全的AuthorizationManager配置,我们将深入了解与AbstractSecurityWebSocketMessageBrokerConfigurer相关的修改。

弃用 AbstractSecurityWebSocketMessageBrokerConfigurer

如果你正在使用 Java 配置,你现在可以直接扩展WebSocketMessageBrokerConfigurer

例如,如果你的类扩展了AbstractSecurityWebSocketMessageBrokerConfigurer并命名为WebSocketSecurityConfig,那么可以替换为以下内容:

@EnableWebSocketSecurity
@Configuration
public class WebSocketSecurityConfig implements WebSocketMessageBrokerConfigurer {
    // ...
}

在明确了弃用AbstractSecurityWebSocketMessageBrokerConfigurer实现的原因之后,现在让我们深入了解利用AuthorizationManager进行请求安全的使用。

利用 AuthorizationManager 进行请求安全

使用 AuthorizationManager API 简化了 HTTP 请求安全。我们将在 Spring Security 6.x 中解释 AuthorizationManager 对安全请求的更改。

确保所有请求都有明确的授权规则

Spring Security 5.8 及更早版本中,默认允许没有授权规则的请求。然而,为了更健壮的安全态势,Spring Security 6.0 的默认做法是默认拒绝。这意味着任何缺少显式授权规则的请求将默认被拒绝。

如果你已经有一个满足你要求的 anyRequest 规则,你可以跳过此步骤:

http
       .authorizeRequests((authorize) -> authorize
             .filterSecurityInterceptorOncePerRequest(true)
             .mvcMatchers("/app/**").hasRole("APP")
             // ...
             .anyRequest().denyAll()
       )

如果你已经转换到 authorizeHttpRequests,推荐的修改保持不变。

转换到 AuthorizationManager

要采用 AuthorizationManager 的使用,你可以使用 Java 配置中的 authorizeHttpRequests 或使用 XML 配置中的 use-authorization-manager

http
       .authorizeHttpRequests((authorize) -> authorize
             .shouldFilterAllDispatcherTypes(false)
             .mvcMatchers("/app/**").hasRole("APP")
             // ...
             .anyRequest().denyAll()
       )

从 hasIpAddress 迁移到 access(AuthorizationManager)

要从 hasIpAddress 迁移到 access(AuthorizationManager),请使用:

IpAddressMatcher hasIpAddress = new IpAddressMatcher("127.0.0.1");
http
       .authorizeHttpRequests((authorize) -> authorize
                   .requestMatchers("/app/**").access((authentication, context) ->
                         new AuthorizationDecision(hasIpAddress.matches(context.getRequest()))
                               // ...
                               .anyRequest().denyAll()
             ))

重要提示

通过 IP 地址进行安全保护本质上是微妙的。因此,没有意向将此支持转移到 authorizeHttpRequests

将 SpEL 表达式转换为 AuthorizationManager

当涉及到授权规则时,Java 通常比 SpEL 更容易测试和维护。因此,authorizeHttpRequests 不提供声明 String SpEL 的方法。相反,你可以创建自己的 AuthorizationManager 实现或使用 WebExpressionAuthorizationManager

SpEL AuthorizationManager WebExpressionAuthorizationManager
mvcMatchers("/complicated/**").access("hasRole ('ADMIN') || hasAuthority ('SCOPE_read')") mvcMatchers("/complicated/**").access(anyOf(hasRole ("ADMIN"), hasAuthority ("SCOPE_read")) mvcMatchers("/complicated/**").access (new WebExpressionAuthorization Manager("hasRole('ADMIN') || hasAuthority('SCOPE_read')"))

表 16.4 – SpEL 迁移选项

转换为过滤所有调度器类型

Spring Security 5.8 及更早版本中,授权仅在每次请求中执行一次。因此,在 REQUEST 之后运行的调度器类型如 FORWARDINCLUDE 默认不受保护。建议 Spring Security 保护所有调度器类型。因此,在 6.0 版本中,Spring Security 修改了此默认行为。

要做到这一点,你应该更改:

http
       .authorizeHttpRequests((authorize) -> authorize
             .shouldFilterAllDispatcherTypes(false)
             .mvcMatchers("/app/**").hasRole("APP")
             // ...
             .anyRequest().denyAll()
       )

转换为:

http
       .authorizeHttpRequests((authorize) -> authorize
             .shouldFilterAllDispatcherTypes(true)
             .mvcMatchers("/app/**").hasRole("APP")
             // ...
             .anyRequest().denyAll()
       )

然后,设置:

spring.security.filter.dispatcher-types=request,async,error,forward,include

如果你使用的是 AbstractSecurityWebApplicationInitializer,建议重写 getSecurityDispatcherTypes 方法并返回所有调度器类型:

public class SecurityWebApplicationInitializer extends AbstractSecurityWebApplicationInitializer {
    @Override
    protected EnumSet<DispatcherType> getSecurityDispatcherTypes() {
       return EnumSet.of(DispatcherType.REQUEST, DispatcherType.ERROR, DispatcherType.ASYNC,
             DispatcherType.FORWARD, DispatcherType.INCLUDE);
    }
}

在使用 Spring MVC 时允许 FORWARD

当 Spring MVC 识别出视图名称与实际视图之间的映射时,它将启动对视图的转发。如前节所示,Spring Security 6.0 默认将对 FORWARD 请求应用授权。

替换任何自定义 filter-security AccessDecisionManager

在本节中,我们将探讨不同的用例,以根据AccessDecisionManager替换自定义 filter-security。

UnanimousBased 用例

如果你的应用程序依赖于UnanimousBased,首先调整或替换任何AccessDecisionVoter。随后,你可以创建一个AuthorizationManager,如下所示:

@Bean
AuthorizationManager<RequestAuthorizationContext> requestAuthorization() {
    PolicyAuthorizationManager policy = ...;
    LocalAuthorizationManager local = ...;
    return AuthorizationManagers.allOf(policy, local);
}

然后,将其集成到 DSL 中,如下所示:

http
       .authorizeHttpRequests((authorize) -> authorize.anyRequest().access(requestAuthorization))
// ...

AffirmativeBased 用例

如果你的应用程序使用AffirmativeBased,你可以创建一个等效的AuthorizationManager,如下所示:

@Bean
AuthorizationManager<RequestAuthorizationContext> requestAuthorization() {
    PolicyAuthorizationManager policy = ...;
    LocalAuthorizationManager local = ...;
    return AuthorizationManagers.anyOf(policy, local);
}

然后,将其集成到 DSL 中,如下所示:

http
       .authorizeHttpRequests((authorize) -> authorize.anyRequest().access(requestAuthorization))
// ...

ConsensusBased 用例

如果你的应用程序使用ConsensusBased,框架没有提供等效的解决方案。在这种情况下,你应该实现一个复合AuthorizationManager,该AuthorizationManager考虑了委托AuthorizationManagers的集合。

自定义 AccessDecisionVoter 用例

如果你的应用程序正在使用AccessDecisionVoter,你可以修改该类以实现AuthorizationManager或创建一个适配器。由于不了解你自定义投票器的具体功能,提供通用的解决方案具有挑战性。

然而,这里有一个示例,展示了如何适配SecurityMetadataSourceAccessDecisionVoter以用于anyRequest().authenticated()

public final class AnyRequestAuthenticatedAuthorizationManagerAdapter implements AuthorizationManager<RequestAuthorizationContext> {
    private final SecurityMetadataSource metadata;
    private final AccessDecisionVoter voter;
    public PreAuthorizeAuthorizationManagerAdapter(SecurityExpressionHandler expressionHandler) {
       Map<RequestMatcher, List<ConfigAttribute>> requestMap = Collections.singletonMap(
             AnyRequestMatcher.INSTANCE, Collections.singletonList(new SecurityConfig("authenticated")));
       this.metadata = new DefaultFilterInvocationSecurityMetadataSource(requestMap);
       WebExpressionVoter voter = new WebExpressionVoter();
       voter.setExpressionHandler(expressionHandler);
       this.voter = voter;
    }
    public AuthorizationDecision check(Supplier<Authentication> authentication, RequestAuthorizationContext context) {
       List<ConfigAttribute> attributes = this.metadata.getAttributes(context);
       int decision = this.voter.vote(authentication.get(), invocation, attributes);
       if (decision == ACCESS_GRANTED) {
          return new AuthorizationDecision(true);
       }
       if (decision == ACCESS_DENIED) {
          return new AuthorizationDecision(false);
       }
       return null; // abstain
    }
}

在阐明AuthorizationManager在请求安全中的用法后,现在让我们深入了解 OAuth 的更新。

OAuth 更新

在本节中,我们将深入了解 OAuth 更新,特别是关注与在oauth2Login()中更改默认权限以及有关 OAuth2 客户端的弃用。

更改默认的 oauth2Login()权限

Spring Security 5中,当用户使用oauth2Login()进行身份验证时,分配给他们的默认GrantedAuthorityROLE_USER

Spring Security 6中,使用 OAuth2 提供程序进行身份验证的用户被分配默认权限OAUTH2_USER,而使用OpenID Connect 1.0提供程序进行身份验证的用户被分配默认权限OIDC_USER。这些默认权限根据用户是否使用OAuth2OpenID Connect 1.0提供程序进行身份验证,为用户提供更明确的分类。

如果你的应用程序依赖于如hasRole("USER")hasAuthority("ROLE_USER")之类的授权规则或表达式,基于特定的权限授予访问权限,请注意,Spring Security 6中的更新默认设置将影响你的应用程序。

要在Spring Security 6中采用新的默认设置,你可以使用以下配置:

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
          // ...
          .oauth2Login(oauth2Login -> oauth2Login
                .userInfoEndpoint(userInfo -> userInfo
                      .userAuthoritiesMapper(grantedAuthoritiesMapper())
                )
          );
    return http.build();
}
private GrantedAuthoritiesMapper grantedAuthoritiesMapper() {
    return authorities -> {
       Set<GrantedAuthority> mappedAuthorities = new HashSet<>();
       authorities.forEach(authority -> {
          GrantedAuthority mappedAuthority;
          if (authority instanceof OidcUserAuthority) {
             OidcUserAuthority userAuthority = (OidcUserAuthority) authority;
             mappedAuthority = new OidcUserAuthority(
                   "OIDC_USER", userAuthority.getIdToken(), userAuthority.getUserInfo());
          } else if (authority instanceof OAuth2UserAuthority) {
             OAuth2UserAuthority userAuthority = (OAuth2UserAuthority) authority;
             mappedAuthority = new OAuth2UserAuthority(
                   "OAUTH2_USER", userAuthority.getAttributes());
          } else {
             mappedAuthority = authority;
          }
          mappedAuthorities.add(mappedAuthority);
       });
       return mappedAuthorities;
    };
}

处理 OAuth2 客户端的弃用

Spring Security 6中,OAuth2 客户端中已删除过时的类和方法。以下列出了弃用项及其相应的直接替代品:

弃用列表

| ServletOAuth2 AuthorizedClientExchange FilterFunction | 可以用以下之一替换setAccessTokenExpiresSkew(…)方法:

  • ClientCr edentialsOAuth2Authorized ClientProvider#setClockSkew(…)

  • RefreshTokenOAuth2AuthorizedClient Provider#setClockSkew(…)

  • JwtBearerOA uth2Authorized ClientProvider#setClockSkew(…)

方法 setClientCredentials TokenResponseClient(…) 可以用构造函数 ServletOAuth2Authorized ClientExchangeFilterFunction (OAuth2AuthorizedClientManager) 替换 |

OidcUserInfo 方法 phoneNumberVerified(String) 可以用 phoneNumberVerified(Boolean) 替换
OAuth2Authorized ClientArgument Resolver 方法 setClientCredentialsTokenResponseClient(…) 可以用构造函数 OAuth2AuthorizedClient ArgumentResolver (OAuth2AuthorizedClientManager) 替换
ClaimAccessor 方法 containsClaim(…) 可以用 hasClaim(…) 替换
OidcClient InitiatedLogout SuccessHandler 方法 setPostLogoutRedirectUri(URI) 可以用 setPostLogoutRedirectUri(String) 替换
HttpSessionOAuth2 Authorization RequestRepository 方法 setAllowMultipleAuthorizationRequests(…) 没有直接替换项
AuthorizationRequest Repository 方法 removeAuthorizationRequest(HttpServletRequest) 可以用 removeAuthorizationRequest(HttpServletRequest, HttpServletResponse) 替换
ClientRegistration 方法 getRedirectUriTemplate() 可以用 getRedirectUri() 替换
ClientRegistration .Builder 方法 redirectUriTemplate(…) 可以用 redirectUri(…) 替换
AbstractOAuth2 Authorization GrantRequest 构造函数 AbstractOAuth2Authorization GrantRequest(AuthorizationGrantType) 可以用 AbstractOAuth2Authorization GrantRequest(AuthorizationGrantType, ClientRegistration) 替换
ClientAuthentication Method 静态字段 BASIC 可以用 CLIENT_SECRET_BASIC 替换,静态字段 POST 可以用 CLIENT_SECRET_POST 替换
OAuth2Access TokenResponse HttpMessage Converter 字段 tokenResponseConverter 没有直接替换项,方法 setTokenResponseConverter(…) 可以用 setAccessTokenResponseConverter(…) 替换,字段 tokenResponseParametersConverter 没有直接替换项,方法 setTokenResponseParametersConverter(…) 可以用 setAccessTokenResponse ParametersConverter(…) 替换
Nimbus AuthorizationCode TokenResponseClient NimbusAuthorizationCode TokenResponseClient 可以用 DefaultAuthorizationCode TokenResponseClient 替换
NimbusJwt DecoderJwkSupport NimbusJwtDecoderJwkSupport 可以用 NimbusJwtDecoderJwtDecoders 替换
ImplicitGrant Configurer ImplicitGrantConfigurer 没有直接替换项
Authorization GrantType 静态字段 IMPLICIT 没有直接替换项
OAuth2Authorization ResponseType 静态字段 TOKEN 没有直接替换项
OAuth2Authorization Request 静态方法 implicit() 没有直接替换项
JwtAuthentication Converter extractAuthorities 方法将被弃用并删除。建议不要扩展 JwtAuthenticationConverter,而是使用 JwtAuthenticationConverter#setJwtGrantedAuthoritiesConverter 提供自定义授权转换器。

表 16.5 – OAuth2 废弃列表

重要提示

不建议使用隐式授权类型,并且 Spring Security 6 中已移除所有相关支持。

在介绍完 Spring Security 6 OAuth 更新后,现在让我们深入了解 SAML 的更新。

SAML 更新

Spring Security 过滤器链。

Spring Security 的 SAML 2.0 服务提供者支持的情况下,您可以使用 Spring Securitysaml2Loginsaml2Logout DSL 方法来启用它。这些方法会自动选择适当的过滤器,并将它们放置在过滤器链的相关位置。

在以下章节中,我们将探讨主要的 SAML 更新。

转向 OpenSAML 4

Spring Security 6 停止了对 OpenSAML 3 的支持,并将其基线升级到 OpenSAML 4

要升级到 Spring Security 6SAML 支持,您需要使用 4.1.1 或更高版本。

利用 OpenSaml4AuthenticationProvider

为了同时适应 Spring Security 引入的 OpenSamlAuthenticationProviderOpenSaml4AuthenticationProvider。然而,随着 Spring Security 6 的移除,OpenSamlAuthenticationProvider 也已被停止使用。

需要注意的是,并非所有来自 OpenSamlAuthenticationProvider 的方法都直接转移到 OpenSaml4AuthenticationProvider。因此,在实施挑战时,需要进行一些调整以应对这些变化。

避免使用 SAML 2.0 Converter 构造函数

Spring Security SAML 2.0 支持的初始版本中,Saml2MetadataFilterSaml2AuthenticationTokenConverter 最初配备了 Converter 类型的构造函数。这种抽象级别在类的发展中带来了挑战,导致在后续版本中引入了专门的接口 RelyingPartyRegistrationResolver

在 6.0 版本中,Converter 构造函数已被删除。为了适应这一变化,修改实现 Converter<HttpServletRequest, RelyingPartyRegistration> 的类,改为实现 RelyingPartyRegistrationResolver

转向使用 Saml2AuthenticationRequestResolver

Spring Security 6 中,Saml2AuthenticationContextResolverSaml2AuthenticationRequestFactory 以及相关的 Saml2WebSsoAuthenticationRequestFilter 都已被删除。

它们被 Saml2AuthenticationRequestResolverSaml2WebSsoAuthenticationRequestFilter 的新构造函数所取代。修订后的接口消除了这些类之间的不必要传输对象。

尽管大多数应用程序不需要重大更改,但如果您目前使用或配置了 Saml2AuthenticationRequestContextResolverSaml2Authentication RequestFactory,请考虑以下步骤以使用 Saml2Authentication RequestResolver 进行过渡。

替换 setAuthenticationRequestContextConverter

Spring Security 6 中,您应该将 setAuthenticationRequestContextConverter 替换为 setAuthnRequestCustomizer

此外,由于 setAuthnRequestCustomizer 可以直接访问 HttpServletRequest,因此不需要 Saml2AuthenticationRequestContextResolver。只需使用 setAuthnRequestCustomizer 直接从 HttpServletRequest 中检索所需信息。

替换 setProtocolBinding

以下使用 setProtocolBinding 的实现:

@Bean
Saml2AuthenticationRequestFactory authenticationRequestFactory() {
    OpenSaml4AuthenticationRequestFactory factory = new OpenSaml4AuthenticationRequestFactory();
    factory.setProtocolBinding("urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST")
    return factory;
}

可以替换如下:

@Bean
Saml2AuthenticationRequestResolver authenticationRequestResolver() {
    OpenSaml4AuthenticationRequestResolver reaolver = new OpenSaml4AuthenticationRequestResolver(registrations);
    resolver.setAuthnRequestCustomizer((context) -> context.getAuthnRequest()
          .setProtocolBinding("urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"));
    return resolver;
}

重要提示

由于 Spring Security 专门支持用于身份验证的 POST 绑定,因此在此时覆盖协议绑定不会产生显著的价值。

利用最新的 Saml2AuthenticationToken 构造函数

Spring Security 6 之前,Saml2AuthenticationToken 构造函数需要多个单独的设置作为参数,当添加新参数时,这会带来挑战。认识到这些设置中的大多数都是 RelyingPartyRegistration 的固有属性,因此引入了一个更稳定的构造函数。这个新的构造函数允许提供 RelyingPartyRegistration,更接近于 OAuth2LoginAuthenticationToken 的设计。

尽管大多数应用程序通常不会直接实例化此类,因为它通常由 Saml2WebSsoAuthenticationFilter 处理,但如果您的应用程序确实实例化了它,您应该按照以下方式更新构造函数:

new Saml2AuthenticationToken(saml2Response, registration)

利用 RelyingPartyRegistration 中的更新方法

Spring Security 的初始版本中,RelyingPartyRegistration 方法及其功能。为了解决此问题并适应向 RelyingPartyRegistration 引入的额外功能,有必要通过将方法重命名为与规范语言一致来澄清歧义。

在检查从 Spring Security 5.xSpring Security 6.x 的各种配置迁移选项之后,下一节将演示将 JDBC 应用程序从 Spring Security 5.x 迁移到 Spring Security 6.x 的实际示例。

应用从 Spring Security 5.x 到 Spring Security 6.x 的迁移步骤

在本节中,我们将深入探讨将一个示例应用程序从 Spring Security 5.x 迁移到 Spring Security 6.x 的过程。这次迁移旨在确保与较新版本提供的最新功能、改进和安全增强保持兼容性。

重要提示

使用 Spring Security 5.x 编写的应用程序的初始状态可在项目 chapter16.00-calendar 中找到。

检查应用程序依赖项

以下片段定义了 Spring Security 5.x 需要的初始依赖项:

//build.gradle
plugins {
    id 'java'
    id 'org.springframework.boot' version '2.7.18'
    id 'io.spring.dependency-management' version '1.1.4'
}
...
dependencies {
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    // JPA / ORM / Hibernate:
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
    // H2 db
    implementation 'com.h2database:h2'
    // webjars
    implementation 'org.webjars:webjars-locator:0.50'
    implementation 'org.webjars:bootstrap:5.3.2'
    //Tests
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

build.gradle 的迁移版本中,我们将 Spring Security 升级到 6.x 版本:

//build.gradle
plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.1
    id 'io.spring.dependency-management' version '1.1.4'
}
...
dependencies {
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    // JPA / ORM / Hibernate:
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
    // H2 db
    implementation 'com.h2database:h2'
    // webjars
    implementation 'org.webjars:webjars-locator:0.50'
    implementation 'org.webjars:bootstrap:5.3.2'
    //Tests
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

从 javax 迁移到 jakarta 命名空间

Spring Security 6 中从 javax 命名空间迁移到 jakarta 命名空间主要是由于 Java 生态系统中的变化。

这一变化是由于 Java 企业版EE)规范的发展和由社区领导的 Jakarta EE 努力所致。

替换 WebSecurityConfigurerAdapter 并公开 SecurityFilterChain Bean

如前所述,Spring Security 6 引入了增强和改进,以简化安全配置。最近版本中的一个显著演变是,用更灵活的方法公开 SecurityFilterChain 对象来替换传统的 WebSecurityConfigurerAdapter

这种范式转变为开发者提供了对安全配置的更大控制和定制,促进了针对特定应用程序需求定制的更细粒度的安全设置。

迁移之前,SecurityConfig.java 的样子如下:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Description("Configure HTTP Security")
    @Override
    protected void configure(final HttpSecurity http) throws Exception {
       http.authorizeRequests(authorizeRequests -> authorizeRequests
             .antMatchers("/webjars/**").permitAll()
             .antMatchers("/css/**").permitAll()
             .antMatchers("/favicon.ico").permitAll()
             .antMatchers("/actuator/**").permitAll()
             .antMatchers("/signup/*").permitAll()
             .antMatchers("/").permitAll()
             .antMatchers("/login/*").permitAll()
             .antMatchers("/logout/*").permitAll()
             .antMatchers("/admin/h2/**").access("isFullyAuthenticated() and hasRole('ADMIN')")
             .antMatchers("/admin/*").hasRole("ADMIN")
             .antMatchers("/events/").hasRole("ADMIN")
             .antMatchers("/**").hasRole("USER")
       );
       // The default AccessDeniedException
       http.exceptionHandling(handler -> handler
             .accessDeniedPage("/errors/403")
       );
       // Login Configuration
       http.formLogin(form -> form
             .loginPage("/login/form")
             .loginProcessingUrl("/login")
             .failureUrl("/login/form?error")
             .usernameParameter("username") // redundant
             .passwordParameter("password") // redundant
             .defaultSuccessUrl("/default", true)
             .permitAll()
       );
       // Logout Configuration
       http.logout(form -> form
             .logoutUrl("/logout")
             .logoutSuccessUrl("/login/form?logout")
             .permitAll()
       );
       // Allow anonymous users
       http.anonymous();
       // CSRF is enabled by default, with Java Config
       //NOSONAR
       http.csrf().disable();
       // Cross Origin Resource Sharing
       http.cors().disable();
       // HTTP Security Headers
       http.headers().disable();
       // Enable <frameset> in order to use H2 web console
       http.headers().frameOptions().disable();
    }
...
}

迁移后,我们移除了 WebSecurityConfigurerAdapter 并如下公开了一个 SecurityFilterChain 对象:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
       http.authorizeHttpRequests( authz -> authz
                   .requestMatchers("/webjars/**").permitAll()
                   .requestMatchers("/css/**").permitAll()
                   .requestMatchers("/favicon.ico").permitAll()
                   .requestMatchers("/").permitAll()
                   .requestMatchers("/login/*").permitAll()
                   .requestMatchers("/logout").permitAll()
                   .requestMatchers("/signup/*").permitAll()
                   .requestMatchers("/errors/**").permitAll()
                   // H2 console
                   .requestMatchers("/admin/h2/**")
                   .access(new WebExpressionAuthorizationManager("isFullyAuthenticated() and hasRole('ADMIN')"))
                   .requestMatchers("/events/").hasRole("ADMIN")
                   .requestMatchers("/**").hasRole("USER"))
             .exceptionHandling(exceptions -> exceptions
                   .accessDeniedPage("/errors/403"))
             .formLogin(form -> form
                   .loginPage("/login/form")
                   .loginProcessingUrl("/login")
                   .failureUrl("/login/form?error")
                   .usernameParameter("username")
                   .passwordParameter("password")
                   .defaultSuccessUrl("/default", true)
                   .permitAll())
             .logout(form -> form
                   .logoutUrl("/logout")
                   .logoutSuccessUrl("/login/form?logout")
                   .permitAll())
             // CSRF is enabled by default, with Java Config
             .csrf(AbstractHttpConfigurer::disable);
       // For H2 Console
       http.headers(headers -> headers.frameOptions(FrameOptionsConfig::disable));
       return http.build();
    }
...
}

由于在 Spring Security 6.x@EnableWebSecurity 不再包含 @Configuration,我们在 SecurityConfig.java 的迁移版本中声明了这两个注解。

重要提示

您的代码现在应如下所示:chapter16.01-calendar

摘要

本章回顾了在将现有的 Spring Security 5.x 项目升级到 Spring Security 6.x 时将遇到的重大和细微变化。在本章中,我们回顾了可能促使升级的重大框架增强。我们还检查了升级需求、依赖项、常见代码类型和配置更改,这些更改将防止应用程序在升级后工作。我们还概述了 Spring Security 作者在代码库重构过程中所做的整体代码重组更改。

如果这是您第一次阅读本章,我们希望您回到书的其余部分,并使用本章作为指南,以便尽可能顺利地进行 Spring Security 6.x 的升级!

第十七章:微服务安全性使用 OAuth 2 和 JSON Web Tokens

在本章中,我们将探讨基于微服务的架构,并查看如何使用 OAuth 2JSON Web Tokens (JWT) 在基于 Spring 的应用程序中确保微服务的安全性。

以下是在本章中将要涉及的主题列表:

  • 单体应用微服务 之间的基本区别

  • 比较 面向服务的架构 (SOA) 和微服务

  • OAuth 2 的概念架构以及它如何为您的服务提供可信的客户端访问

  • OAuth 2 访问令牌的类型

  • OAuth 2 授权类型的种类

  • 检查 JWT 及其一般结构

  • 实现资源服务器和认证服务器,用于授予客户端访问 OAuth 2 资源的权利

  • 实现一个 RESTful 客户端,通过 OAuth 2 授权流程访问资源

在本章中,我们有相当多的内容要介绍,但在我们深入探讨如何利用 Spring Security 实现 OAuth 2JWT 的细节之前,我们首先想要创建一个没有 Thymeleaf 或任何其他基于浏览器的用户界面的日历应用程序的基础。

在移除所有 Thymeleaf 配置和资源后,各种控制器已转换为 JAX-RS REST 控制器。

本章代码的实际链接在这里:packt.link/zEHBU

重要提示

您应该从 chapter17.00-calendar 中的代码开始。

微服务是什么?

微服务 是一种架构方法,它允许开发物理上分离的模块化应用程序,这些应用程序是自治的,能够实现敏捷性、快速开发、持续部署和扩展。

应用程序作为一个服务集构建,如 JSONXML,这允许聚合语言无关的服务。基本上,一个服务可以用最适合该服务创建任务的编程语言编写。

每个服务都在自己的进程中运行,并且位置无关,因此可以在访问网络的任何地方定位。

在下一节中,我们将探讨单体架构、微服务和面向服务的架构,并区分它们的差异。然后,我们可以深入研究使用 spring-security 的微服务安全性。

单体架构

微服务方法与传统单体软件方法相反,后者由紧密集成的模块组成,不经常发货,并且必须作为一个单一单元进行扩展。本书中的传统 JBCP 日历 应用程序是单体应用的例子。请看以下图示,它描述了单体架构:

图 17.1 – 单体架构

图 17.1 – 单体架构

虽然单体方法适合某些组织和某些应用程序,但对于需要更多灵活性和可扩展性的生态系统的公司来说,微服务正在变得流行。

微服务

微服务架构是一组小型离散服务,其中每个服务实现特定的业务能力。这些服务运行自己的进程,并通过HTTP API进行通信,通常使用RESTful服务方法。这些服务是为了仅服务于一个特定的业务功能而创建的,例如用户管理、管理角色、电子商务购物车、搜索引擎、社交媒体集成等。请看以下图示,它描述了微服务架构:

图 17.2 – 微服务架构

图 17.2 – 微服务架构

每个服务都可以独立于应用程序中的其他服务和企业中的其他系统进行部署、升级、扩展、重启和移除。

由于每个服务都是独立创建的,因此它们可以分别用不同的编程语言编写并使用不同的数据存储。集中的服务管理几乎不存在,这些服务使用轻量级的HTTPREST进行相互通信。

面向服务的架构

你可能会问自己,“这不是和 SOA 一样吗?” 不完全一样,你可以这样说微服务实现了SOA最初承诺的东西。

SOA 是一种软件设计风格,其中服务通过计算机网络上的语言无关的通信协议暴露给其他组件。

SOA 的基本原则是独立于供应商、产品和技术。

服务的定义是一个离散的功能单元,可以远程访问并独立执行和更新,例如在线检索信用卡对账单。

虽然类似,SOA 和微服务仍然是不同类型的架构。

典型的SOA通常是在部署单体中实现的,并且更受平台驱动,而微服务可以独立部署,因此在整个维度上提供了更多的灵活性。

关键区别当然是大小;微服务一词就说明了这一点。微服务通常比常规的SOA服务小得多。正如 Martin Fowler 所说:

我们应该将 SOA 视为微服务的超集。

微服务安全

微服务可以提供很大的灵活性,但也引入了必须解决的问题。

  • 服务通信:单体应用程序使用进程之间的内存通信,而微服务通过网络进行通信。转向网络通信不仅提出了速度问题,还提出了安全问题。

  • 紧密耦合:微服务使用许多数据存储而不是少数几个。这为微服务与紧密耦合的服务之间隐式服务合同创造了机会。

  • 技术复杂性:微服务可以创建额外的复杂性,这可能导致安全漏洞。如果团队没有正确经验,那么管理这些复杂性可能会迅速变得难以控制。

OAuth 2 规范

有时存在一种误解,认为OAuth 2OAuth 1的演变,但实际上它是一种完全不同的方法。OAuth 1规范要求签名,因此您必须使用加密算法来创建、生成和验证这些签名,而这些签名在OAuth 2中不再需要。OAuth 2的加密现在由TLS处理,这是必需的。

重要提示

OAuth 2 RFC-6749OAuth 2.0 授权框架(tools.ietf.org/html/rfc6749):

OAuth 2.0授权框架允许第三方应用程序代表资源所有者通过在资源所有者和 HTTP 服务之间协调批准交互,或者允许第三方应用程序代表自己获取对 HTTP 服务的有限访问。

此规范取代并使RFC 5849中描述的OAuth 1.0协议过时,OAuth 1.0协议(tools.ietf.org/html/rfc5849)。

为了正确理解如何利用OAuth 2,我们需要确定某些角色以及这些角色之间的协作。让我们定义在OAuth 2授权过程中参与的所有角色:

  • 资源所有者:资源所有者是能够授予对位于资源服务器上的受保护资源访问权限的实体。

  • 授权服务器:授权服务器是在成功验证资源所有者并获得授权后向客户端颁发访问令牌的集中式安全网关。

  • 资源服务器:资源服务器是托管受保护资源的服务器,并且能够使用OAuth 2访问令牌分解和响应受保护资源请求。

  • 微服务客户端:客户端是代表资源所有者进行受保护资源请求的应用程序,但具有其授权。

访问令牌

在代码示例中,access_token代表一个凭证,客户端可以使用它来访问API。我们有两种类型的访问令牌:

  • 访问令牌:访问令牌通常具有有限的生命周期,并在将此令牌包含在每个请求的 HTTP 请求头中时,用于使客户端能够访问受保护资源。

  • 刷新令牌:刷新令牌的生存周期更长,用于在访问令牌过期后获取新的访问令牌,但无需再次向服务器发送凭证。

授权类型

授权类型是客户端可以使用的方法来获取代表授予的权限的访问令牌。根据应用程序的需求,有不同的授权类型允许不同类型的访问。每种授权类型都可以支持不同的 OAuth 2 流,而无需担心实现的技術方面。在 OAuth 2 中,我们有四种主要的授权类型:

  • access_token 和可选的 id_token 以及 refresh_token。客户端现在可以使用此 access_token 代表用户调用受保护的资源。

  • 直接使用 access_token,无需 authorization_code。这是因为客户端应用程序,通常是一个在浏览器中运行的 JavaScript 应用程序,其信任度低于在服务器上运行的应用程序,不能信任 client_secret(在授权代码授予类型中需要)。由于信任度有限,隐式授权类型不会向应用程序发送刷新令牌。

  • access_token 和可选的 refresh_token。当用户和客户端之间有高度信任,并且其他授权授予流不可用时,使用此授权类型。此授权类型消除了客户端通过交换长期生存期的 access_tokenrefresh_token 来存储用户凭证的需要。

  • 使用客户端提供的凭证(客户端 ID 和客户端密钥)通过认证来获取 access_token

在介绍了主要的 OAuth 2 访问令牌和授权类型之后,在下一节中,我们将深入探讨 JSON Web Tokens 规范。

JSON Web Tokens

JWT 是一个开放标准,RFC 7519 (tools.ietf.org/html/rfc7519),它定义了一种紧凑且自包含的格式,用于以 JSON 对象的形式在各方之间安全地传输信息。由于信息是数字签名的,因此可以验证和信任。JWT 可以使用秘密(使用基于哈希的消息认证码(HMAC)算法)或使用公钥/私钥对(使用 Rivest–Shamir–AdlemanRSA)加密算法)进行签名。

重要注意事项

JWT RFC- 7519 (tools.ietf.org/html/rfc7519):

JWT 是一种紧凑、URL 安全的表示声明的方法,用于在双方之间传输。JWT 中的声明编码为一个 JSON 对象,用作 JSON Web SignatureJWS)结构的有效负载或 JSON Web EncryptionJWE)结构的明文,使声明可以通过消息认证码(MAC)进行数字签名或完整性保护,以及/或加密。

JWT 用于携带与携带令牌的客户端的身份和特征(声明)相关的信息。JWT 是一个容器,并由服务器签名以防止客户端篡改。此令牌在认证过程中创建,并在任何处理之前由授权服务器验证。它由资源服务器使用,允许客户端向资源服务器展示代表其 身份证 的令牌,并允许资源服务器以无状态、安全的方式验证令牌的有效性和完整性。

令牌结构

JWT 的结构遵循以下三部分结构,包括标题、有效载荷和签名:

[Base64Encoded(HEADER)] . [Base64Encoded (PAYLOAD)] . [encoded(SIGNATURE)]

编码后的 JWT

以下代码片段是基于客户端请求返回的完整编码的 access_token

eyJraWQiOiJlOTllMzEyYS0yMDJmLTRmNDItOWExNi1h ZmE2NDA5Mzg0N2QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJqYmNwLWNhbGVuZGFyIiwiYXVkIjoiamJjcC1jYWxlbmRhciIsIm5iZiI6MT cwODU0ODUzMCwic2NvcGUiOlsiZXZlbnRzLnJlYWQiXSwiaXNzIjoiaHR0cDovL2xvY2Fs aG9zdDo5MDAwIiwiZXhwIjoxNzA4NTQ4ODMwLCJpYXQiOjE3MDg1NDg1MzAsImp0aSI6I jRhMzVjZmNmLTE5YWItNDZjZC05OWI4LWQxNWM5ZmZlNjQ1MiJ9.WNJTwQwHA4TVE1BYuizQUo88Dnf0K2by0awxVo_mSq_8n5KWkQMuKESFQwQHT32VExn7qHW6JoD6sfxrLK5q2o-KKIYDpL1CACtfjK0mUCWjfpLfpeyXg0FpYPw6s4allS3zUfOSrFf53wP8k4XCNaPxU9yVQ8s2TB064Sanl7W0VwSbxoz4B-VgPQwEob1cxhAXrBBy5WmM8rk7WsvPXYvMLdo ISpkP4n66hCzdmmFiBWFhgsfRsOVG8mNmIWgeJVgLXY BiLrbR2FuFK5KxU7Ls7IMZcWiHd95yAgA6TQ46yBiJErclNVr8Xr5M2SnzFR7HWJY 2OHCNJxnjRpbwEQ

标题

我们 access_token JWT 的编码标题是 base64 编码的,如下面的代码所示:

eyJraWQiOiJlOTllMzEyYS0yMDJmLTRmNDItOWExNi1hZmE2NDA5Mzg0N2QiLCJhbGciOi JSUzI1NiJ9

通过解码编码后的标题,我们得到以下有效载荷:

{
  "kid": "e99e312a-202f-4f42-9a16-afa64093847d",
  "alg": "RS256"
}

有效载荷

我们 access_token JWT 的编码有效载荷是 base64 编码的,如下所示:

eyJzdWIiOiJqYmNwLWNhbGVuZGFyIiwiYXVkIjoia mJjcC1jYWxlbmRhciIsIm5iZiI6MTc wODU0ODUzMCwic2NvcGUiOlsiZXZlbnRzL nJlYWQiXSwiaXNzIjoiaHR0cDovL2xvY2FsaG 9zdDo5MDAwIiwiZXhwIjoxNzA4NTQ4ODMwL CJpYXQiOjE3MDg1NDg1MzAsImp0aSI6IjRhM zVjZmNmLTE5YWItNDZjZC05OWI4LWQxNWM5ZmZlNjQ1MiJ9

通过解码编码后的有效载荷,我们得到以下有效载荷声明:

{
  "sub": "jbcp-calendar",
  "aud": "jbcp-calendar",
  "nbf": 1708548530,
  "scope": [
    "events.read"
  ],
  "iss": "http://localhost:9000",
  "exp": 1708548830,
  "iat": 1708548530,
  "jti": "4a35cfcf-19ab-46cd-99b8-d15c9ffe6452"
}

签名

我们 access_token 的编码有效载荷是由授权服务器使用私钥编码的,如下面的代码所示:

WNJTwQwHA4TVE1BYuizQUo88Dnf0K2by0awxVo _mSq_8n5KWkQMuKESFQwQHT32VExn7qHW 6JoD6sfxrLK5q2o-KKIYDpL1CACtfjK0mUCW jfpLfpeyXg0FpYPw6s4allS3zUfOSrFf53 wP8k4XCNaPxU9yVQ8s2TB064Sanl7W0VwSbxoz4B-VgPQwEob1cxhAXrBBy5WmM8rk7Ws vPXYvMLdoISpkP4n66hCzdmmFiBWFhgsfRsOV G8mNmIWgeJVgLXYBiLrbR2FuFK5KxU7Ls 7IMZcWiHd95yAgA6TQ46yBiJErclNVr8Xr5M2SnzFR7HWJY2OHCNJxnjRpbwEQ

以下是为创建 JWT 签名编写的伪代码:

var encodedString = base64UrlEncode(header) + "."; encodedString += base64UrlEncode(payload);
var privateKey = "[-----PRIVATE KEY
]";
var signature = SHA256withRSA(encodedString, privateKey); var JWT = encodedString + "." + base64UrlEncode(signature);

Spring Security 中的 JWT 认证

接下来,让我们检查 Spring Security 在基于 servlet 的应用程序中实现 JWT 认证所使用的架构元素,类似于我们之前讨论的那个。

JwtAuthenticationProvider 作为 AuthenticationProvider 的实现,利用 JwtDecoderJwtAuthenticationConverter 在认证过程中验证 JWT

现在,让我们深入了解 JwtAuthenticationProviderSpring Security 上下文中的工作原理。随附的图解说明了 AuthenticationManager 的复杂性,如图中描绘的读取 Bearer Token 的过程所示。

图 17.3 – Spring Security 中的 JWT 认证

图 17.3 – Spring Security 中的 JWT 认证

Spring Security 中的 JWT 认证包括以下步骤:

  1. 认证过滤器是读取 BearerTokenAuthenticationTokenAuthenticationManager 的过程的一部分,AuthenticationManagerProviderManager 实现。

  2. ProviderManager 已配置为使用 JwtAuthenticationProvider 类型的 AuthenticationProvider

  3. JwtAuthenticationProvider 通过 JwtDecoder 承担解码、验证和验证 Jwt 的任务。

  4. 随后,JwtAuthenticationProvider 使用 JwtAuthentication 转换器JWT 转换为授权权限的集合。

  5. 在认证成功后,返回的 Authentication 以 JwtAuthenticationToken 的形式呈现,其中包含代表 JwtDecoder 的主体。最终,JwtAuthenticationToken 将由 Authentication Filter 放置在 SecurityContextHolder 中。

在覆盖了 OAuth 2JWT 规范之后,我们将更深入地了解它们在 spring-security 中的实现。

Spring Security 中的 OAuth 2 支持

Spring Security 提供了 Spring Framework 编程模型和配置惯例。

在下一节中,我们将确定参与 OAuth 2 流的主要组件。

资源所有者

资源所有者可以是一个或多个来源,在 JBCP 日历 的上下文中,它将具有日历应用程序作为资源所有者。JBCP 日历除了配置资源服务器外,不需要任何特定的配置来表示其所有权。

资源服务器

大多数资源服务器支持都集中在 spring-security-oauth2-resource-server 中。然而,spring-security-oauth2-jose 负责解码和验证。因此,这两个组件对于功能齐全的资源服务器,能够处理 JWT 编码 Bearer Tokens 至关重要。

在 Spring Boot 中,将应用程序设置为资源服务器涉及两个基本步骤:

  1. 首先,包含必要的依赖项

  2. 其次,指定授权服务器位置。

授权服务器

要启用授权服务器功能,我们将使用提供 Authorization Server 产品实现的 Spring Authorization Server

要启动 Spring Authorization Server 的使用,最直接的方法是将 Spring Authorization Server 作为依赖项构建,如下所示。

//build.gradle
dependencies {
...
    // Spring Authorization Server
    implementation org.springframework.boot:spring-boot-starter-oauth2-authorization-server'
...
}

重要提示

您的代码现在应类似于 chapter17.00-authorization-server

OAuth 2 资源最小配置属性

注意,在项目 chapter17.00-calendar 中,我们已添加 spring-boot-starter-oauth2-resource-server 依赖项,除了 spring-security。这对于我们的应用程序作为资源服务器的行为非常重要。

//build.gradle
dependencies {
...
    // Spring Authorization Server
    implementation "org.springframework.boot:spring-boot-starter-oauth2-resource-server"
...
}

在 Spring Boot 应用程序中,您可以通过以下步骤轻松指定要使用的授权服务器:

//src/main/resources/application.yml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:9000

重要提示

要使 issuer-uri 属性生效,以下端点之一必须是授权服务器支持端点:idp.example.com/issuer/.well-known/openid-configurationidp.example.com/.well-known/openid-configuration/issueridp.example.com/.well-known/oauth-authorization-server/issuer。此端点通常被称为提供者配置端点或授权服务器元数据端点。

创业预期

当使用此属性及其相关依赖项时,资源服务器将自动设置其配置以验证 JWT 格式编码的 Bearer Tokens。

这是通过一个可预测的启动序列完成的:

  1. 询问提供者配置或授权服务器元数据端点以获取jwks_url属性。

  2. 检查jwks_url端点以支持算法。

  3. 配置验证策略以查询jwks_url以获取对应于识别算法的有效公钥

  4. 配置验证策略以验证每个idp.example.com"iss"声明。

这个过程的含义是,授权服务器必须处于运行状态并且能够接收请求,以便资源服务器能够成功初始化。

运行时的期望

在应用程序启动后,Resource Server将努力处理任何包含Authorization: Bearer头的请求。

GET / HTTP/1.1
Authorization: Bearer some-token-value # Resource Server will process this

只要指定了这个方案,资源服务器就会努力按照 Bearer Token 规范来处理请求。

对于结构良好的 JWT,资源服务器将:

  1. 使用在启动时从jwks_url端点获得的公钥验证其签名,确保与 JWT 匹配。

  2. 验证 JWT 的expnbf时间戳,以及 JWT 的iss声明。

  3. 将每个范围与一个权限关联,使用前缀SCOPE_

当授权服务器引入新密钥时,Spring Security 将自动更新和轮换用于验证 JWT 的密钥。

重要提示

你的代码现在应该看起来像chapter17.01-calendar

默认情况下,Authentication#getPrincipal返回的是一个Spring Security Jwt对象,如果可用,Authentication#getName对应于 JWT 的 sub 属性。

在定义jwk-set-uri之后。

定义授权服务器的 JWK Set URI

如果jwk-set-uri如下所示:

//src/main/resources/application.yml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:9000
          jwk-set-uri: http://localhost:9000/.well-known/jwks.json

因此,issuer-uri确保资源服务器验证传入 JWT 中的"iss"声明。

提供受众信息

如前所述,issuer-uri属性验证"iss"声明,识别发送 JWT 的实体。

此外,audiences属性用于验证"aud"声明,确定 JWT 的预期接收者。

你可以这样指定资源服务器的受众:

//src/main/resources/application.yml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:9000
          audiences: https://my-org.audience

结果将是,如果"aud"声明的"iss"声明列表中不包含my-resource-server.example.com,则验证将不会成功。

使用 SecurityFilterChain 配置授权

Authorization Server通常包含一个scopescp属性,表示授予的权限或范围。

在这种情况下,资源服务器将努力将这些范围转换为授权权限列表,并为每个范围添加SCOPE_字符串作为前缀。因此,为了使用从 JWT 获得的范围来保护端点或方法,相关的表达式应包含此前缀。例如:

//src/main/java/com/packtpub/springsecurity/configuration/ SecurityConfig.java
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
          .securityMatcher("/events/**")
          .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/events/**").hasAuthority("SCOPE_events.read"))
          .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
    return http.build();
}

重要提示

你的代码现在应该看起来像chapter17.02-calendar

在这一点上,我们可以启动 chapter17.00-authorization-serverchapter17.02-calendar,我们将准备好发送 OAuth 2 请求。

令牌请求

当我们发起初始令牌请求时,我们应该得到一个类似于以下的成功响应:

curl -i -X POST \
  http://localhost:9000/oauth2/token \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'grant_type=client_credentials&client_id=jbcp-calendar&&client_secret=secret&scope=events.read'

以下是示例响应:

HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Wed, 21 Feb 2024 17:51:02 GMT
{
  "access_token": "eyJraWQiOiJjMzJjNmVlNy0yYTM5LTQ0NDY tOWQzZS02NzA2ZWJjMWM5MGUiLCJhbGciOiJSUzI1NiJ9.eyJzdWI iOiJqYmNwLWNhbGVuZGFyIiwiYXVkIjoiamJjcC1j YWxlbmRhciIsIm5iZiI6MTcwODUzNzg2Miwic2NvcGUiOlsiZX ZlbnRzLnJlYWQiXSwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo5MD AwIiwiZXhwIjoxNzA4NTM4MTYyLCJpYXQiOjE3MDg1Mzc4NjIsImp 0aSI6ImVkNjc1YzcwLTg4MGItNDYxYy1hMDk0LTFmMTA1ZTk3OTk0 NCJ9.OWMHZC_cRqUsshwTCIIdo6oGK_KU39hY25U5YhTUU7QTi-Sm F7wy9QdDxJnl9brIXgjq7NpIeC9zZyi l81S4p7HwFP3_3iCN1NQA54vTZ0-UBfT8q6H1aEQzeEdZUDnhoYK2c oOihbYcNH_Dfn13POMcEwBhFwIsul6tJHN_lLVFBA-CTMxSHoBWBDNq NvU-gIdadOxFPDpWV86No8DfYgDGWKLP18k3KggLC37ebMbNkIMgK24gYxM_5f_g2nR_ueiV6ZQO5fyGq960nYWzePoQtdYVcvHwkQk_FG_B75rcSrITuTTgDrcA8FWrZrOoitvEOnglHmieUguoYVG2BA",
  "scope": "events.read",
  "token_type": "Bearer",
  "expires_in": 299
}

具体来说,我们已经获得了一个可以在后续请求中使用的访问令牌。这个 access_token 将作为我们的载体。

端点请求

现在,我们将使用 access_token 并用该令牌以以下格式向服务器发起额外的请求:

curl -k -i http://localhost:8080/events/  \
-H "Authorization: Bearer eyJraWQiOiJjMzJjNmVl Ny0yYTM5LTQ0NDYtOWQzZS02NzA2ZWJjMWM5MGUiLCJhbG ciOiJSUzI1NiJ9.eyJzdWIiOiJqYmNwLWNhbGVuZGFyIiwiYXVkIjoiamJjcC 1jYWxlbmRhciIsIm5iZiI6MTcwODUzNzg2Miwic2NvcGUi OlsiZXZlbnRzLnJlYWQiXSwiaXNzIjoiaHR0cDovL2xvY2 FsaG9zdDo5MDAwIiwiZXhwIjoxNzA4NTM4MTYyLCJpYXQi OjE3MDg1Mzc4NjIsImp0aSI6ImVkNjc1YzcwLTg4MGItNDY xYy1hMDk0LTFmMTA1ZTk3OTk0NCJ9.OWMHZC_cRqUsshwT CIIdo6oGK_KU39hY25U5YhTUU7QTi-SmF7wy9QdDxJnl9br IXgjq7NpIeC9zZyil81S4p7HwFP3_3iCN1NQA54vTZ0-UBf T8q6H1aEQzeEdZUDnhoYK2coOihbYcNH_Dfn13POMcEwBhF wIsul6tJHN_lLVFBA-CTMxSHoBWBDNqNvU-gIdadOxFPDpW V86No8DfYgDGWKLP18k3KggLC37ebMbNkIMgK24gYxM_5f_g2nR_ueiV6ZQO5fyGq960nYWzePoQtdYVcvHwkQk_FG_B75 rcSrITuTTgDrcA8FWrZrOoitvEOnglHmieUguoYVG2BA"

我们应该得到以下响应:

HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
Strict-Transport-Security: max-age=31536000 ; includeSubDomains
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 21 Feb 2024 17:55:32 GMT
[
  {
    "id": 100,
    "summary": "Birthday Party",
    "description": "This is going to be a great birthday",
    "dateWhen": "2023-07-03T18:30:00.000+00:00"
  },
  {
    "id": 101,
    "summary": "Conference Call",
    "description": "Call with the client",
    "dateWhen": "2023-12-23T12:00:00.000+00:00"
  },
  {
    "id": 102,
    "summary": "Vacation",
    "description": "Paragliding in Greece",
    "dateWhen": "2023-09-14T09:30:00.000+00:00"
  }
]

使用 @PreAuthorize 注解配置授权

另一种配置授权的方法是使用 @PreAuthorize 注解。

第一步是在 SecurityConfig.java 中启用方法安全:

//src/main/java/com/packtpub/springsecurity/configuration/ SecurityConfig.java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
       http
             .securityMatcher("/events/**")
             .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
       return http.build();
    }
}

下一步是确保 /events 端点调用的 CalendarService.getEvents() 的安全性。

//src/main/java/com/packtpub/springsecurity/service/ CalendarService.java
public interface CalendarService {
...omitted for brevity
    @PreAuthorize("hasAuthority('SCOPE_events.read')")
    List<Event> getEvents();
...
}

重要提示

您的代码现在应该看起来像 chapter17.03-calendar

在这一点上,我们可以启动 chapter17.00-authorization-serverchapter17.03-calendar,我们将准备好发送 OAuth 2 请求。

您可以尝试再次进行,对于 /token/events 端点请求使用相同的先前步骤。

现在我们已经准备好了 OAuth 2 服务器,可以为客户颁发 access_tokens,我们现在可以创建一个微服务客户端来与我们的系统交互。

配置 OAuth 2 客户端

现在我们已经配置了资源服务器,您可以创建 REST 客户端来消费 OAuth2 受保护的资源。

  1. 您可以使用 https://start.spring.io/ 通过选择以下依赖项来初始化您的项目:
//build.gradle
dependencies {
...
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
...
}
  1. 接下来,您需要按照以下配置将应用程序与客户端配置一起配置:
//src/main/resources/application.yml
jbcp-calendar:
  events:
    api: http://localhost:8080/events/
spring:
## Chapter 17 Authorization Server
  security:
    oauth2:
      client:
        registration:
          calendar-client:
            client-id: jbcp-calendar
            client-secret: secret
            scope: events.read
            authorization-grant-type: client_credentials
            client-name: Calendar Client
        provider:
          calendar-client:
            token-uri: http://localhost:9000/oauth2/token
server:
  port: 8888
  1. 在此示例中,我们将使用 RestTemplateClientHttpRequestInterceptor 来绑定我们的 REST 客户端的 OAuth2AccessToken

    在 Spring Security 中向第三方 OAuth2AuthorizedClient 类发起请求,并通过在出站请求的授权头中插入 Bearer 令牌来获取受保护资源。

    提供的示例设置了应用程序以作为 OAuth2 客户端运行,能够从第三方 API 请求受保护的资源。

//src/main/java/com/packtpub/springsecurity/config/SecurityConfig.java
@Configuration
public class SecurityConfig {
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
       http
             .oauth2Client(withDefaults());
       return http.build();
    }
    @Bean
    public RestTemplate oauth2RestTemplate(OAuth2HttpRequestInterceptor oAuth2HttpRequestInterceptor) {
       RestTemplate restTemplate = new RestTemplate();
       List<ClientHttpRequestInterceptor> interceptors = restTemplate.getInterceptors();
       if (CollectionUtils.isEmpty(interceptors)) {
          interceptors = new ArrayList<>();
       }
       interceptors.add(oAuth2HttpRequestInterceptor);
       restTemplate.setInterceptors(interceptors);
       return restTemplate;
    }
}
  1. 以下 OAuth2HttpRequestInterceptor 可以按照示例代码中的描述进行定义:
//src/main/java/com/packtpub/springsecurity/config/OAuth2HttpRequestInterceptor.java
@Component
public class OAuth2HttpRequestInterceptor implements ClientHttpRequestInterceptor {
    private final OAuth2AuthorizedClientManager authorizedClientManager;
    private final ClientRegistrationRepository clientRegistrationRepository;
    public OAuth2HttpRequestInterceptor(OAuth2AuthorizedClientManager authorizedClientManager, ClientRegistrationRepository clientRegistrationRepository) {
       this.authorizedClientManager = authorizedClientManager;
       this.clientRegistrationRepository = clientRegistrationRepository;
    }
    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
       ClientRegistration clientRegistration = clientRegistrationRepository.findByRegistrationId("calendar-client");
       OAuth2AuthorizeRequest oAuth2AuthorizeRequest = OAuth2AuthorizeRequest
             .withClientRegistrationId(clientRegistration.getRegistrationId())
             .principal(clientRegistration.getClientId())
             .build();
       OAuth2AuthorizedClient client = authorizedClientManager.authorize(oAuth2AuthorizeRequest);
       String accessToken = client.getAccessToken().getTokenValue();
       request.getHeaders().setBearerAuth(accessToken);
       return execution.execute(request, body);
    }
}
  1. 最后一步是创建 RestTemplate Bean:
//src/main/java/com/packtpub/springsecurity/web/controllers/OAuth2RestClient.java
@RestController
public class OAuth2RestClient {
    private final RestTemplate oauth2RestTemplate  ;
    public OAuth2RestClient(RestTemplate oauth2RestTemplate) {
       this.oauth2RestTemplate = oauth2RestTemplate;
    }
    @Value("${jbcp-calendar.events.api}")
    private String eventsApi;
    @GetMapping("/")
    public  String apiCheck() {
       return oauth2RestTemplate.getForObject(eventsApi, String.class);
    }
}

我们现在应该有一个客户端应用程序的相同代码库。

重要提示

您的代码现在应该看起来像 chapter17.03-calendar-client

我们需要确保 chapter17.03-calendarchapter17.00-authorization-server 应用程序正在运行,并准备好接收来自客户端的 OAuth 2 请求。

然后,我们可以启动 chapter17.03-calendar-client 应用程序,该程序将公开一个 RESTful 端点,该端点将调用我们的资源服务器以访问远程资源上的 /events 路径上配置的事件,并通过运行 http://localhost:8888/ 返回以下结果:

[
  {
    "id":100,
    "summary":"Birthday Party",
    "description":"This is going to be a great birthday",
    "dateWhen":"2023-07-03T18:30:00.000+00:00"
  },
  {
    "id":101,
    "summary":"Conference Call",
    "description":"Call with the client",
    "dateWhen":"2023-12-23T12:00:00.000+00:00"
  },
  {
    "id":102,
    "summary":"Vacation",
    "description":"Paragliding in Greece",
    "dateWhen":"2023-09-14T09:30:00.000+00:00"
  }
]

摘要

在本章中,你学习了单体应用和微服务之间的基本区别,并将SOA微服务进行了比较。你还学习了OAuth 2的概念架构以及它是如何为你的服务提供可信赖的客户端访问,以及了解了OAuth 2访问令牌的类型和OAuth 2客户端凭证类型。

我们考察了JWT及其一般结构,实现了一个资源服务器授权服务器,用于授予客户端访问OAuth 2资源的权限,并实现了一个RESTful客户端,通过OAuth 2****授权流程来访问资源。

我们通过演示使用 spring-security 的实用OAuth 2示例实现来结束本章。接下来,下一章将探讨与中央认证服务CAS)的集成,以实现为你的 Spring Security 启用应用提供单点登录SSO)和单点登出SLO)功能。

第十八章:使用中央认证服务实现单点登录

在本章中,我们将探讨将中央认证服务CAS)用作基于 Spring Security 的应用程序的单点登录SSO)门户。

在本章的讨论过程中,我们将涵盖以下主题:

  • 了解CAS、其架构以及它如何为任何规模的系统管理员和组织带来好处

  • 理解如何重新配置 Spring Security 以处理认证请求的拦截并将它们重定向到CAS

  • 配置JBCP 日历应用程序以利用 CAS SSO

  • 理解如何实现单点登出功能,并配置我们的应用程序以支持它

  • 讨论如何使用CAS代理票据认证服务,并配置我们的应用程序以利用代理票据认证

  • 讨论如何使用推荐的 war 覆盖方法自定义开箱即用的JA-SIG CAS服务器

  • CAS服务器与LDAP集成,并通过CAS将数据从LDAP传递到Spring Security

本章代码示例链接在此:packt.link/lFJjp

介绍中央认证服务

CAS是一个开源的 SSO 服务器,为组织内的基于 Web 的资源提供集中的访问控制和认证。CAS对管理员的好处众多,它支持许多应用程序和多样化的用户社区。以下是一些好处:

  • 可以在一个位置配置对资源(应用程序)的个体或组访问

  • 广泛支持各种认证存储(以集中用户管理),在广泛分布的跨机环境中提供单一的认证和控制点

  • 通过CAS客户端库为基于 Web 和非基于 Web 的 Java 应用程序提供广泛的认证支持

  • 提供一个用户凭据的单一点参考(通过CAS),这样CAS客户端应用程序就不需要了解用户的凭据,或者了解如何验证它们

在本章中,我们将不会过多关注CAS的管理,而是关注认证以及CAS如何作为我们网站用户的认证点。尽管CAS通常在企业或教育机构的内网环境中使用,但它也可以在高调的位置,如索尼在线娱乐的公共网站中使用。

高级CAS认证流程

在高层次上,CAS由一个CAS服务器组成,它是确定认证的中心 Web 应用程序,以及一个或多个CAS服务,它们是使用 CAS 服务器进行认证的独立 Web 应用程序。CAS的基本认证流程通过以下操作进行:

  1. 用户尝试访问网站上的受保护资源。

  2. 用户通过浏览器从 CAS 服务重定向到 CAS 服务器以请求登录。

  3. CAS 服务器负责用户认证。如果用户尚未在 CAS 服务器上认证,则后者会请求用户的凭证。如图所示,用户会看到一个登录页面。

  4. 用户提交他们的凭证(即用户名和密码)。

  5. 如果用户的凭证有效,CAS 服务器将通过浏览器重定向响应一个服务票据。服务票据是一个一次性使用的令牌,用于识别用户。

  6. CAS 服务调用 CAS 服务器以验证票据是否有效、是否已过期等。请注意,这一步骤不是通过浏览器进行的。

  7. CAS 服务器响应一个断言,表明信任已经建立。如果票据是可接受的,则信任已经建立,用户可以通过正常的授权检查继续操作。

    这种行为在以下图中进行了视觉展示:

图 18.1 – 高级 CAS 认证流程

图 18.1 – 高级 CAS 认证流程

我们可以看到 CAS 服务器与受保护应用之间有高度的交互,在建立对用户的信任之前需要几个数据交换握手。我们假设其他网络安全预防措施,如使用 安全套接字层SSL)和网络监控,已经到位。

这种复杂性的结果是,一个很难通过常见技术欺骗的 SSO 协议。

现在我们已经了解了 CAS 认证的一般工作原理,让我们看看它如何应用于 Spring Security。

Spring Security 和 CAS

Spring Security 与 CAS 具有强大的集成能力,尽管它不像我们在本书后半部分所探讨的 OAuth2LDAP 集成那样紧密地集成到安全命名空间风格的配置中。相反,大部分配置依赖于通过安全命名空间元素到 Bean 声明的 Bean 连接和引用配置。

在使用 Spring Security 时,CAS 认证的两大基本组成部分包括以下内容:

  • 替换标准的 AuthenticationEntryPoint 实现,该实现通常处理将未认证用户重定向到登录页面的操作,而这里使用的是将用户重定向到 CAS 服务器的实现

  • 当用户从 CAS 服务器重定向回受保护资源时,通过使用自定义的 Servlet 过滤器处理服务票据

关于CAS的一个重要理解是,在典型部署中,CAS旨在替换我们应用程序中所有替代登录机制。因此,一旦我们为 Spring Security 配置了CAS,我们的用户必须仅使用CAS作为我们应用程序的认证机制。在大多数情况下,这不会成为问题;正如我们在上一节中讨论的,CAS被设计为代理认证请求到一个或多个认证存储(正如 Spring Security 在委托到数据库或LDAP进行认证时所做的)。从之前的图(图 18.1)中,我们可以看到我们的应用程序不再检查其自己的认证存储来验证用户。相反,它使用服务票据来认证用户。然而,正如我们将在从 CAS 断言获取 UserDetails 对象部分中讨论的,最初,Spring Security 仍然需要一个数据存储来确定用户的授权。我们将在本章后面讨论如何去除这个限制。

在完成基本的 Spring Security 与CAS集成后,我们可以从主页上删除登录链接,并享受自动重定向到CAS的登录屏幕,在那里我们尝试访问受保护资源。当然,根据应用程序的不同,仍然允许用户明确登录(这样他们可以看到定制的内容等)也是有益的。

必需的依赖项

在我们走得太远之前,我们应该确保我们的依赖项已更新。以下是我们添加的依赖项列表,并附有它们何时需要的注释:

//build.gradle
//Spring CAS Support
implementation " org.springframework.security:spring-security-cas"

安装和配置 CAS

CAS的好处是有一个极其专注的团队支持它,这个团队在开发高质量的软件和如何使用它的准确、简洁的文档方面做得非常出色。如果您选择跟随本章中的示例,我们鼓励您阅读您CAS平台的适当入门手册。您可以在apereo.github.io/cas/找到此手册。

为了使集成尽可能简单,我们为本章包含了一个CAS服务器应用程序,该应用程序可以在 Eclipse 或 IntelliJ 中部署,以及日历应用程序。

对于本章中的示例,我们将假设https://localhost:9443/cas/和日历应用程序部署在https://localhost:8443/。为了正常工作,CAS需要使用 HTTPS。

重要提示

本章中的示例是使用当时可用的最新版本的CAS服务器编写的,即写作时的7.0.1版本,该版本需要 Java 21。因此,如果您使用的是早期版本的服务器,这些说明可能对您的环境略有不同,或者可能显著不同。

让我们继续配置 CAS 认证所需的组件。

重要提示

您应该从chapter18.00-calendarchapter18.00-cas-server的源开始。

要启动 CAS 服务器,请从chapter18.00-cas-server项目运行以下命令:

./gradlew build run

我们使用以下默认 CAS 登录/密码作为此示例:casuser/Mellon

JBCP 日历应用程序中,我们应该能够使用相同的凭据登录。请注意,用户具有管理员权限。

对于接下来的步骤,我们需要执行以下操作:

  • chapter18.00-cas-server/src/main/resources/etc/cas内部导入 CAS SSL 证书:

    $JBCP_JAVA_HOME variable is the JVM used by the JBCP Calendar application.
    
  • 要检查导入是否成功,请运行以下命令。如果您被要求输入 keystore 密码,默认密码是更改它

    keytool -list -keystore $JBCP_JAVA_HOME/lib/security/cacerts -alias cas-server
    

    输出应类似于以下内容:

    cas-server, May 6, 2024, trustedCertEntry,
    chapter18.00-calendar/src/main/resources/keys. If you are asked for the password the keystore password, the default one is *change it*:
    
    

    $CAS_JAVA_HOME变量是 CAS 服务器使用的 JVM。

    
    
  • 要检查导入是否成功,您可以运行以下命令。如果您被要求输入 keystore 密码,默认密码是更改它

    keytool -list -keystore $CAS_JAVA_HOME/lib/security/cacerts -alias jbcpcalendar
    

    输出应类似于以下内容:

    jbcpcalendar, May 6, 2024, PrivateKeyEntry,
    Certificate fingerprint (SHA-256): 79:0D:62:D7:E7:A1:25:1D:A3:C7:93:F6:03:A8:E4:B8:20:BA:FA:2B:03:9F:5C:E3:5D:6C:61:A5:6F:CD:83:57
    

重要提示

$JBCP_JAVA_HOME代表cacerts文件使用的Java路径,默认如下:$$JBCP_JAVA_HOME/lib/security/cacerts

$CAS_JAVA_HOME代表cacerts文件使用的Java路径,默认如下:$CAS_JAVA_HOME/lib/security/cacerts

如果您不依赖于默认的 JDK cacerts文件,则应将此路径调整为您的当前cacerts文件位置。

要将命令适配到 Windows,您需要将$JBCP_JAVA_HOME Unix/Linux 环境变量语法替换为%JBCP_JAVA_HOME% Windows 语法。在此命令中,%JBCP_JAVA_HOME%假定是一个指向 Java 安装目录的 Windows 环境变量。请确保将其替换为您系统中的实际路径。

如果您没有将CAS 服务器 SSL 证书导入到JBCP 日历 JVM,以及JBCP 日历 SSL 证书导入到CAS 服务器 JVM,您将在日志中看到以下错误:

javax.net.ssl.SSLHandshakeException: PKIX 路径构建失败:sun.security.provider.certpath.SunCertPathBuilderException:无法找到到请求目标的有效证书路径

配置基本的 CAS 集成

由于 Spring Security 命名空间不支持 CAS 配置,我们需要实施更多步骤才能获得基本的工作设置。

配置 CAS 属性

Spring Security 设置依赖于一个o.s.s.cas.ServiceProperties豆来存储关于ServiceProperties对象的一般信息,它在协调各种CAS组件之间的数据交换中发挥作用——它用作数据对象来存储由Spring CAS堆栈中不同的参与者共享(并期望匹配)的 CAS 配置设置。您可以在以下代码片段中查看包含的配置:

//src/main/java/com/packtpub/springsecurity/configuration/CasConfig.java
@Configuration
public class CasConfig {
    @Value("${cas.base.url}")
    private String casBaseUrl;
    @Value("${cas.login.url}")
    private String casLoginUrl;
    @Value("${service.base.url}")
    private String serviceBaseUrl;
    @Bean
    public ServiceProperties serviceProperties() {
       ServiceProperties serviceProperties = new ServiceProperties();
       serviceProperties.setService(serviceBaseUrl+ "/login/cas");
       return serviceProperties;
    }
}

您可能已经注意到,我们利用系统属性来使用名为${cas.base.url}${service.base.url}的变量。这两个值都可以包含在您的应用程序中,Spring 会自动将它们替换为PropertySources配置中提供的值。这是一种常见的策略,当默认部署到https://localhost:9443/cas时,对于日历应用程序,它将部署到https://localhost:8443

此配置可以在应用程序投入生产时使用系统参数进行覆盖。或者,配置可以外部化到一个 Java 属性文件中。这两种机制都允许我们正确地外部化我们的配置。

您可以对您的application.yml文件进行以下更新:

cas:
  base:
    url: https://localhost:9443/cas
  login:
    url: ${cas.base.url}/login
service:
  base:
    url: https://localhost:8443

添加 CasAuthenticationEntryPoint 对象

正如我们在Spring Security 和 CAS部分简要提到的,Spring Security使用一个o.s.s.web.AuthenticationEntryPoint接口从用户请求凭证。通常,这涉及到将用户重定向到登录页面。通过service参数指示o.s.s.cas.web.CasAuthenticationEntryPoint对象的位置,该对象专门为此目的而设计。示例应用程序中包含的配置如下:

//src/main/java/com/packtpub/springsecurity/configuration/CasConfig.java
@Bean
public CasAuthenticationEntryPoint casAuthenticationEntryPoint(ServiceProperties serviceProperties) {
    CasAuthenticationEntryPoint casAuthenticationEntryPoint = new CasAuthenticationEntryPoint();
    casAuthenticationEntryPoint.setLoginUrl(this.casLoginUrl);
    casAuthenticationEntryPoint.setServiceProperties(serviceProperties);
    return casAuthenticationEntryPoint;
}

CasAuthenticationEntryPoint对象使用ServiceProperties类指定用户认证后服务票据的发送位置。CAS 允许根据配置对每个用户、每个应用程序进行选择性授权。我们将在配置预期处理该 URL 的 servlet 过滤器时,稍后检查这个 URL 的细节。

接下来,我们需要更新 Spring Security 以利用具有casAuthentication 入口点 ID 的 bean。对我们的SecurityConfig.java文件进行以下更新:

//src/main/java/com/packtpub/springsecurity/configuration/ SecurityConfig.java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http,
       CasAuthenticationEntryPoint casAuthenticationEntryPoint) throws Exception {
...omitted for brevity
          // Exception Handling
          http.exceptionHandling(exceptions -> exceptions
                .authenticationEntryPoint(casAuthenticationEntryPoint)
                .accessDeniedPage("/errors/403"));
...
    return http.build();
}

启用 CAS 票据验证

参考我们之前看到的图表(图 18.1),我们可以看到 Spring Security 负责识别未认证的请求,并通过FilterSecurityInterceptor类将用户重定向到 CAS。添加CasAuthenticationEntryPoint对象已覆盖了标准重定向到登录页面的功能,并提供了从应用程序到 CAS 服务器的预期重定向。现在,我们需要配置一些设置,以便一旦用户在 CAS 上认证成功,用户就能正确地认证到应用程序。

如果您还记得第九章中的内容,即OAuth2 的开放OAuth2使用类似的重定向方法,通过将未经认证的用户重定向到OAuth2提供者进行认证,然后带着可验证的凭证返回到应用程序。与OAuth2不同,CAS协议在用户返回应用程序时,应用程序预期会回调CAS服务器,明确验证提供的凭证是否有效和准确。与使用基于日期的 nonce 和基于密钥的签名来独立验证OAuth2提供者传递的凭证相比,这是一个区别。

CasConfig.java文件的好处如下:

//src/main/java/com/packtpub/springsecurity/configuration/CasConfig.java
@Bean
public CasAuthenticationFilter casAuthenticationFilter(CasAuthenticationProvider casAuthenticationProvider) {
    CasAuthenticationFilter filter = new CasAuthenticationFilter();
    filter.setAuthenticationManager(new ProviderManager(casAuthenticationProvider));
    return filter;
}
@Bean
public CasAuthenticationProvider casAuthenticationProvider(UserDetailsService userDetailsService,
       ServiceProperties serviceProperties, TicketValidator cas30ServiceTicketValidator) {
    CasAuthenticationProvider provider = new CasAuthenticationProvider();
    provider.setAuthenticationUserDetailsService(new UserDetailsByNameServiceWrapper<>(userDetailsService));
    provider.setServiceProperties(serviceProperties);
    provider.setTicketValidator(cas30ServiceTicketValidator);
    provider.setKey("key");
    return provider;
}
@Bean
public TicketValidator cas30ServiceTicketValidator() {
    return new Cas30ServiceTicketValidator(this.casBaseUrl);
}

接下来,我们需要更新 Spring Security 以利用带有CasAuthenticationFilter Bean 的 Bean。请更新我们的SecurityConfig.java文件如下:

//src/main/java/com/packtpub/springsecurity/configuration/ SecurityConfig.java
public SecurityFilterChain filterChain(HttpSecurity http,
       CasAuthenticationEntryPoint casAuthenticationEntryPoint,
       CasAuthenticationFilter casAuthenticationFilter) throws Exception {
...omitted for brevity
     // CAS Filter
    http.addFilterAt(casAuthenticationFilter, CasAuthenticationFilter.class);
     // Exception Handling
    http.exceptionHandling(exceptions -> exceptions
                .authenticationEntryPoint(casAuthenticationEntryPoint)
                .accessDeniedPage("/errors/403"));
...
    return http.build();
}

最后一步是删除SecurityFilterChain Bean 中现有的formLogin Spring Security定义,因为我们将会依赖CAS登录表单进行用户认证。

重要提示

您的代码应该看起来像chapter18.01-calendar中的那样。

到目前为止,我们应该能够启动https://localhost:8443/,并选择casuser用户和密码Mellon。认证成功后,您将被重定向回JBCP 日历应用程序。做得好!

重要提示

如果您遇到问题,最可能的原因是不正确的 SSL 配置。请确保您已将 CAS SSL 证书导入到您的JBCP 日历应用程序的 JRE 密钥库中。

现在我们已经介绍了 CAS 配置的基础知识,我们将进一步深入探讨单点注销

单点注销

您可能会注意到,如果您注销应用程序,您会看到一个注销确认页面。然而,如果您点击一个受保护的页面,例如我的事件页面,您仍然处于认证状态。问题是注销只发生在本地。因此,当您在JBCP 日历应用程序中请求另一个受保护的资源时,会从CAS服务器请求登录。由于用户仍然登录到CAS服务器,它立即返回一个服务票据并将用户重新登录到JBCP 日历应用程序。

这也意味着,如果用户使用CAS服务器登录了其他应用程序,他们仍然会认证到那些应用程序,因为我们的日历应用程序对其他应用程序一无所知。幸运的是,CAS和 Spring Security 提供了解决这个问题的方案。正如我们可以从CAS服务器请求登录一样,我们也可以请求注销。

您可以在chapter18.01-calendar中看到如何注销CAS的高级示意图:

图 18.2 – CAS 单点注销

图 18.2 – CAS 单点注销

以下步骤解释了单点注销功能是如何工作的:

  1. 用户请求从网络应用程序登出。

  2. 网络应用程序随后通过浏览器发送重定向请求到 CAS 服务器,请求从 CAS 登出。

  3. CAS 服务器识别用户,然后向每个已认证的 CAS 服务发送登出请求。请注意,这些登出请求不是通过浏览器发生的。

  4. CAS 服务器通过提供用于登录用户的原始服务票据来指示哪个用户应该登出。然后应用程序负责确保用户已登出。

  5. CAS 服务器向用户显示登出成功页面。

配置单点登出

单点登出的配置相对简单:

  1. 首先,更新你的 application.yml 文件,添加CAS登出 URL:

    cas:
      base:
        url: https://localhost:9443/cas
      login:
        url: ${cas.base.url}/login
      logout:
        url: ${cas.base.url}/logout
    service:
      base:
        url: https://localhost:8443
    
  2. 第一步是指定一个logout-success-url属性,使其成为SecurityConfig.java文件的登出 URL。这意味着在我们本地登出后,我们将自动将用户重定向到CAS服务器的登出页面:

    //src/main/java/com/packtpub/springsecurity/configuration/ SecurityConfig.java
    @Configuration
    @EnableWebSecurity
    public class SecurityConfig {
        @Value("${cas.logout.url}")
        private String casLogoutUrl;
        @Bean
        public SecurityFilterChain filterChain(HttpSecurity http,
              CasAuthenticationEntryPoint casAuthenticationEntryPoint) throws Exception {
    ...omitted for brevity
           // Logout
           http.logout(form -> form
                       .logoutUrl("/logout")
                       .logoutSuccessUrl(casLogoutUrl))
    ...
           return http.build();
        }
    }
    

    由于我们只有一个应用程序,这就足够让它看起来像发生了单点登出。这是因为我们在重定向到CasConfig.javaLogoutFilter之前登出了我们的日历应用程序,如下所示:

    //src/main/java/com/packtpub/springsecurity/configuration/ CasConfig.java
    @Bean
    public LogoutFilter logoutFilter() {
        LogoutFilter logoutFilter = new LogoutFilter(casLogoutUrl, new SecurityContextLogoutHandler());
        logoutFilter.setFilterProcessesUrl("/logout/cas");
        return logoutFilter;
    }
    
  3. 如果有多个应用程序,并且用户从另一个应用程序登出,则需要创建一个SingleSignoutFilter对象。然后我们需要让 Spring Security 意识到我们的SecurityConfig.java中的singleLogoutFilter对象。

    单点登出过滤器放在常规登出之前,以确保它接收登出事件,如下所示:

    //src/main/java/com/packtpub/springsecurity/configuration/ SecurityConfig.java
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http,
           CasAuthenticationEntryPoint casAuthenticationEntryPoint,
           CasAuthenticationFilter casAuthenticationFilter,
           LogoutFilter logoutFilter) throws Exception {
    ...omitted for brevity
          // Logout Filter
          http
             .addFilterBefore(new SingleSignOutFilter(), CasAuthenticationFilter.class)
             .addFilterBefore(logoutFilter, LogoutFilter.class);
        // Logout
        http.logout(form -> form
                    .logoutUrl("/logout")
                    .logoutSuccessUrl(casLogoutUrl));
    ...
        return http.build();
    }
    
  4. 继续启动应用程序并尝试现在登出。你会观察到你已经登出。

  5. 现在,尝试重新登录并访问https://localhost:9443/cas/logout

  6. 现在,尝试访问JBCP 日历应用程序。你会注意到,除非再次进行身份验证,否则你无法访问该应用程序。这表明单点登出功能是可操作的。

重要提示

你的代码应该看起来像chapter18.02-calendar中的那样。

在本节中,我们介绍了使用CAS单点登出实现。接下来,我们将讨论集群环境。

集群环境

在我们最初将 HttpSession 作为内存映射的图示中,我们未能提及的一件事。这意味着在集群环境中单点登出将无法正常工作:

图 18.3 – 集群环境中的 CAS 身份验证

图 18.3 – 集群环境中的 CAS 身份验证

考虑以下情况,在先前的图示背景下:

  1. 用户登录到集群成员 A

  2. 集群成员 A验证服务票据。

  3. 然后,它在内存中存储服务票据到用户会话的映射。

  4. 用户请求从CAS服务器登出。

CAS服务器向CAS服务发送注销请求,但集群成员 B接收到了注销请求。它在内存中查找,但没有找到Service Ticket A的会话,因为它只存在于集群成员 A中。这意味着用户将无法成功注销。

无状态服务的代理票据认证

使用CAS集中我们的认证对于 Web 应用来说似乎工作得相当好,但如果我们想使用CAS调用 Web 服务怎么办?为了支持这一点,CAS 有一个代理票据PT)的概念。以下是如何工作的示意图:

图 18.4 – CAS 代理票据认证

图 18.4 – CAS 代理票据认证

流程与标准CAS认证相同,直到以下事情发生:

  1. 当包含一个名为代理票据回调 URL(PGT URL)的附加参数时,Service Ticket将被验证。

  2. CAS服务器通过HTTPS调用PGT URL以验证PGT URL确实是它所声称的。像 CAS 中的大多数过程一样,这是通过执行适当的 URL 的 SSL 握手来完成的。

  3. CAS服务器通过HTTPSProxy Granting TicketPGT)和Proxy Granting Ticket I Owe YouPGTIOU)提交到PGT URL,以确保票据被提交到它们声称的来源。

  4. PGT URL接收两个票据,并必须存储PGTIOUPGT的关联。

  5. CAS服务器最终返回一个包含用户名和PGTIOU的响应到步骤 1

  6. CAS服务可以使用PGTIOU查找PGT

配置代理票据认证

现在我们已经了解了PT认证的工作原理,我们将通过以下步骤更新我们的当前配置以获取一个PGT

  1. 第一步是添加对ProxyGrantingTicketStorage实现的引用。请将以下代码添加到我们的CasConfig.java文件中:

    //src/main/java/com/packtpub/springsecurity/configuration/ CasConfig.java
    @Bean
    public ProxyGrantingTicketStorage pgtStorage() {
        return new ProxyGrantingTicketStorageImpl();
    }
    @Scheduled(fixedRate = 300_000)
    public void proxyGrantingTicketStorageCleaner(){
        logger.info("Running ProxyGrantingTicketStorage#cleanup() at {}",
              LocalDateTime.now());
        pgtStorage().cleanUp();
    }
    
  2. ProxyGrantingTicketStorageImpl实现是将PGTIOU映射到PGT的内存映射。就像注销一样,这意味着我们会在集群环境中使用此实现时遇到问题。请参考 JA-SIG 文档以确定如何在集群环境中设置它:apereo.github.io/cas/7.0.x/high_availability/High-Availability-Guide.xhtml

  3. 我们还需要定期通过调用其cleanUp()方法来清理ProxyGrantingTicketStorage。如您所见,Spring 的任务抽象使这变得非常简单。您可以考虑调整配置,以便在适合您环境的单独线程池中清除票据。有关更多信息,请参阅 Spring 框架参考文档的任务执行和调度部分,网址为docs.spring.io/spring-framework/reference/integration/scheduling.xhtml

  4. 现在我们需要使用我们刚刚创建的ProxyGrantingTicketStorage。我们只需要更新ticketValidator方法,使其引用我们的存储,并了解CasConfig.java

    //src/main/java/com/packtpub/springsecurity/configuration/ CasConfig.java
    @Value("${service.proxy.callback-url}")
    private String calendarServiceProxyCallbackUrl;
    @Bean
    public TicketValidator cas30ServiceTicketValidator() {
        Cas30ProxyTicketValidator tv = new Cas30ProxyTicketValidator(this.casBaseUrl);
        tv.setProxyCallbackUrl(calendarServiceProxyCallbackUrl);
        tv.setProxyGrantingTicketStorage(pgtStorage());
        return tv;
    }
    
  5. 然后,我们需要通过添加代理回调 URL 来更新application.yml文件:

    service:
      base:
        url: https://localhost:8443
      proxy:
        callback-url: ${service.base.url}/callback
    
  6. 我们需要做的最后一个更新是对我们的CasAuthenticationFilter对象,当proxyReceptorUrl属性与Cas20ProxyTicketValidator对象的proxyCallbackUrl属性匹配时,存储ProxyGrantingTicketStorage实现,以确保CasConfig.java

    //src/main/java/com/packtpub/springsecurity/configuration/ CasConfig.java
    @Bean
    public CasAuthenticationFilter casAuthenticationFilter(CasAuthenticationProvider casAuthenticationProvider,
           ProxyGrantingTicketStorage pgtStorage) {
        CasAuthenticationFilter filter = new CasAuthenticationFilter();
        filter.setAuthenticationManager(new ProviderManager(casAuthenticationProvider));
        filter.setProxyGrantingTicketStorage(pgtStorage);
        filter.setProxyReceptorUrl("/pgtUrl");
        return filter;
    }
    

现在我们有了PGT,我们该如何使用它?服务票据是一个一次性令牌。然而,PGT可以用来生成PT。让我们看看我们如何使用PGT来创建PT

重要提示

application.yml配置文件中,我们可以观察到proxyCallBackUrl属性与我们的上下文相关proxyReceptorUrl属性路径的绝对路径相匹配。由于我们将基础应用程序部署到${service.base.url},我们的proxyReceptor URL 的完整路径将是${service.base.url}/pgtUrl

在对CAS服务器集群环境中的配置进行此检查之后,我们将深入了解 CAS 代理票据的详细使用。

使用代理票据

我们现在可以使用我们包含在本章代码中的EchoController类。您可以在下面的代码片段中看到它的相关部分。有关更多详细信息,请参阅示例源代码:

//src/main/java/com/packtpub/springsecurity/web/controllers/ EchoController.java
@GetMapping("/echo")
   public String echo()  {
       final CasAuthenticationToken token = (CasAuthenticationToken) SecurityContextHolder
               .getContext()
               .getAuthentication();
    // The proxyTicket could be cached in session and reused if we wanted to
       final String proxyTicket = token.getAssertion().getPrincipal().getProxyTicketFor(targetUrl);
       // Make a remote call using the proxy ticket
       return restTemplate.getForObject(targetUrl+"?ticket={pt}", String.class, proxyTicket);
   }

这个控制器是一个虚构的例子,它将获取一个EchoController对象,实际上是在对同一应用程序中的MessagesController对象进行 RESTful 调用。这意味着日历应用程序正在对自己进行 RESTful 调用。

前往https://localhost:8443/echo查看其效果。页面看起来非常像CAS登录页面(除了 CSS)。这是因为控制器试图回显我们的我的事件页面,而我们的应用程序还不知道如何验证PT。这意味着它被重定向到CAS登录页面。让我们看看我们如何验证代理票据。

重要提示

您的代码应该看起来像chapter18.03-calendar中的那样。

验证代理票据

让我们看看以下步骤来了解如何验证代理票据:

  1. 我们首先需要告诉 ServiceProperties 对象,我们想要验证所有票据,而不仅仅是提交给 filterProcessesUrl 属性的票据。请对 CasConfig.java 进行以下更新:

    //src/main/java/com/packtpub/springsecurity/configuration/ CasConfig.java
    @Bean
    public ServiceProperties serviceProperties() {
        ServiceProperties serviceProperties = new ServiceProperties();
        serviceProperties.setService(serviceBaseUrl+ "/login/cas");
        serviceProperties.setAuthenticateAllArtifacts(true);
        return serviceProperties;
    }
    
  2. 然后,我们需要更新我们的 CasAuthenticationFilter 对象,使其知道我们想要验证所有工件(即票据),而不仅仅是监听特定 URL。我们还需要使用一个 AuthenticationDetailsSource 接口,该接口可以在验证任意 URL 上的代理票据时动态提供 CAS 服务 URL。这很重要,因为当 CAS 服务询问票据是否有效时,它还必须提供用于创建票据的 CAS 服务 URL。由于代理票据可能出现在任何 URL 上,我们必须能够动态发现此 URL。这是通过利用 ServiceAuthenticationDetailsSource 对象来完成的,该对象将提供 HTTP 请求中的当前 URL:

    //src/main/java/com/packtpub/springsecurity/configuration/ CasConfig.java
    @Bean
    public CasAuthenticationFilter casAuthenticationFilter(CasAuthenticationProvider casAuthenticationProvider,
           ProxyGrantingTicketStorage pgtStorage, ServiceProperties serviceProperties) {
        CasAuthenticationFilter filter = new CasAuthenticationFilter();
        filter.setAuthenticationManager(new ProviderManager(casAuthenticationProvider));
        filter.setProxyGrantingTicketStorage(pgtStorage);
        filter.setProxyReceptorUrl("/pgtUrl");
        filter.setServiceProperties(serviceProperties);
        filter.setAuthenticationDetailsSource(new ServiceAuthenticationDetailsSource(serviceProperties));
        return filter;
    }
    
  3. 我们还需要确保我们正在使用 Cas30ProxyTicketValidator 对象,而不是 Cas30ServiceTicketValidator 实现,并指出我们将接受哪些代理票据。我们将配置我们的 Cas30ProxyTicketValidator 以接受来自任何 CAS 服务的代理票据。在生产环境中,您可能希望仅限于信任的 CAS 服务:

    //src/main/java/com/packtpub/springsecurity/configuration/ CasConfig.java
    @Bean
    public TicketValidator cas30ServiceTicketValidator() {
        Cas30ProxyTicketValidator tv = new Cas30ProxyTicketValidator(this.casBaseUrl);
        tv.setProxyCallbackUrl(calendarServiceProxyCallbackUrl);
        tv.setProxyGrantingTicketStorage(pgtStorage());
        tv.setAcceptAnyProxy(true);
        return tv;
    }
    
  4. 最后,我们希望为我们的 CasAuthenticationProvider 对象提供一个缓存,这样我们就不需要为每次调用我们的服务而击中 CAS 服务。为此,我们需要配置一个 StatelessTicketCache,如下所示:

    //src/main/java/com/packtpub/springsecurity/configuration/ CasConfig.java
    @Bean
    public CasAuthenticationProvider casAuthenticationProvider(UserDetailsService userDetailsService,
           ServiceProperties serviceProperties, TicketValidator cas30ServiceTicketValidator, SpringCacheBasedTicketCache springCacheBasedTicketCache) {
        CasAuthenticationProvider provider = new CasAuthenticationProvider();
        provider.setAuthenticationUserDetailsService(new UserDetailsByNameServiceWrapper<>(userDetailsService));
        provider.setServiceProperties(serviceProperties);
        provider.setTicketValidator(cas30ServiceTicketValidator);
        provider.setKey("key");
        provider.setStatelessTicketCache(springCacheBasedTicketCache);
        return provider;
    }
    @Bean
    public SpringCacheBasedTicketCache springCacheBasedTicketCache(CacheManager cacheManager) {
        return new SpringCacheBasedTicketCache(cacheManager.getCache("castickets"));
    }
    

重要提示

不要忘记在 CasConfig.java 中添加 @EnableCaching,以便 Spring 可以自动启用缓存。

  1. 如您所怀疑的,Spring 缓存可以依赖于外部实现,包括 EhCache。请继续启动应用程序并再次访问 https://localhost:8443/echo。这次,您应该看到对调用我们的 我的 事件 页面的响应:

图 18.5 – 验证代理票据响应

图 18.5 – 验证代理票据响应

重要提示

您的代码应该看起来像 chapter18.04-calendar 中的那样。

在使用代理票据之后,我们将接下来探讨定制 CAS 服务器的过程。

定制 CAS 服务器

本节的所有更改都将应用于 CAS 服务器,而不是日历应用程序。本节仅旨在介绍配置 CAS 服务器的过程,因为详细的设置显然超出了本书的范围。就像对日历应用程序所做的更改一样,我们鼓励您跟随本章中的更改。有关更多信息,您可以参考 CAS Aperero 文档:https://apereo.github.io/cas。

CAS WAR 透明覆盖

作为依赖项,首选的定制 cas-server-webapp 的方式,然后提供将合并到现有 WAR 透明覆盖 中的额外文件。

CAS 内部认证是如何工作的?

在我们深入到 CAS 配置之前,我们将简要说明 CAS 认证处理的标准行为。以下图表应有助于您跟随允许 CAS 与我们的嵌入式 LDAP 服务器通信所需的配置步骤:

图 18.6 – CAS 内部认证流程

图 18.6 – CAS 内部认证流程

虽然前面的图描述了 CAS 服务器内部的认证流程,但如果你正在实现 Spring Security 和 CAS 之间的集成,你可能需要调整 CAS 服务器的配置。因此,了解 CAS 认证在高级别上是如何工作的是非常重要的。

org.apereo.cas.authentication.AuthenticationManager 接口(不要与同名的 Spring Security 接口混淆)负责根据提供的凭据对用户进行认证。在 Spring Security 中,凭据的实际处理委托给一个(或多个)实现 org.apereo.cas.authentication.AuthenticationHandler 接口的处理类。我们认识到 Spring Security 中的类似接口将是 AuthenticationProvider

虽然这不是对 CAS 服务器背后功能的全面审查,但这应该有助于您理解接下来几个练习中的配置步骤。我们鼓励您阅读 CAS 的源代码并参考基于网络的文档。

配置 CAS 连接到我们的嵌入式 LDAP 服务器

CAS 通过对 Active Directory 或 OpenLDAP 等 LDAP 目录进行认证来验证用户名/密码。存在各种目录架构,我们为四种常见场景提供了配置选项。

重要的是要注意,CAS 会根据指定的设置自动内部生成必要的组件。如果您想对多个 LDAP 服务器进行认证,您可以增加索引并指定下一个 LDAP 服务器设置。

此外,请注意,在 LDAP 认证期间获得的属性,如果适用,将与来自其他属性存储源的属性结合。直接通过 LDAP 认证获得的属性优先于所有其他属性。

您可以在 CAS 文档中找到可用的设置和属性(apereo.github.io/cas/)。

让我们看看以下步骤来配置带有 CAS 的嵌入式 LDAP 服务器:

  1. 首先,我们更新 chapter18.00-cas-server 项目的 build.gradle 依赖项。我们启用 CAS LDAP 支持,并添加嵌入式 LDAP 服务器的 spring-security 内置支持:

    //CAS LDAP Support
    implementation "org.apereo.cas:cas-server-support-ldap"
    // Spring Security LDAP
    implementation("org.springframework.security:spring-security-ldap")
    implementation("org.springframework.security:spring-security-config")
    // Embedded LDAP Server
    implementation("com.unboundid:unboundid-ldapsdk")
    
  2. 然后我们需要在我们的 application.yml 文件中添加以下部分,包含嵌入式 LDAP 服务器的连接参数:

    // Embedded LDAP
    spring:
      ldap:
        embedded:
          ldif: classpath:/ldif/calendar.ldif
          baseDn: dc=jbcpcalendar,dc=com
          port: 33389
          credential:
            username: uid=admin,ou=system
            password: secret
    // CAS configuration for LDAP
    cas:
      authn:
        ldap[0]:
          useStartTls: false
          search-filter: uid={user}
          type: AUTHENTICATED
          ldapUrl: ldap://localhost:${spring.ldap.embedded.port}
          baseDn: ${spring.ldap.embedded.baseDn}
          bindDn: ${spring.ldap.embedded.credential.username}
          bindCredential: ${spring.ldap.embedded.credential.password}
    
  3. 对于这个练习,我们将使用为本书创建的 LDIF 文件,旨在捕获许多与 LDAP 和 Spring Security 的常见配置场景(就像我们在 第六章LDAP 目录服务)中所做的那样)。将提供的 LDIF 文件复制到以下项目位置:src/main/resources/ldif/calendar.ldif

  4. 最后,我们需要按照以下方式配置 CasOverlayOverrideConfiguration.java

    //src/main/java/org/apereo/cas/config/CasOverlayOverrideConfiguration.java
    @Lazy(false)
    @AutoConfiguration
    public class CasOverlayOverrideConfiguration {
        private final BaseLdapPathContextSource contextSource;
        public CasOverlayOverrideConfiguration(BaseLdapPathContextSource contextSource) {
           this.contextSource = contextSource;
        }
    }
    

重要提示

你的代码应该看起来像 chapter18.05-cas-serverchapter18.05-calendar 中的那样。

现在,我们已经配置了基本的 admin1@example.com/adminuser1@example.com/user1。尝试运行它以检查是否正常工作。如果不起作用,请检查日志并比较您的配置与示例配置。

在从 CAS 断言对 UserDetails 对象进行自定义之后。

从 CAS 断言中获取 UserDetails 对象

到目前为止,我们一直使用 InMemoryUserDetailsManager 对象进行身份验证。然而,我们可以像使用 OAuth2 一样从 CAS 断言中创建 UserDetails 对象。第一步是配置 CAS 服务器以返回额外的属性。

在 CAS 响应中返回 LDAP 属性

我们知道 CAS 可以在 CAS 响应中返回用户名,但它也可以在 CAS 响应中返回任意属性。让我们看看如何更新 CAS 服务器以返回额外的属性。同样,本节的所有更改都在 CAS 服务器中,而不是在 日历应用程序中

将 LDAP 属性映射到 CAS 属性

第一步需要我们将 GrantedAuthority 映射。

我们将在 CAS 的 application.yml 文件中添加更多的配置。这个新的配置项是为了指导 Principal 对象到 principalAttributeList,这将最终作为票据验证的一部分进行序列化。该配置应在 chapter18.06-cas-server 项目中如下声明:

//src/main/resources/application.yml
cas:
  service-registry:
    core:
      init-from-json: true
    json:
      location: classpath:/etc/cas/services
  authn:
    ldap[0]:
      principalAttributeList: sn:lastName,cn:fullName,description:role
      useStartTls: false
      search-filter: uid={user}
      type: AUTHENTICATED
      ldapUrl: ldap://localhost:${spring.ldap.embedded.port}
      baseDn: ${spring.ldap.embedded.baseDn}
      bindDn: ${spring.ldap.embedded.credential.username}
      bindCredential: ${spring.ldap.embedded.credential.password}

背后的功能有些令人困惑——本质上,这个类的目的是将 org.apereo.cas.authentication.principal.Principal 映射回 LDAP 目录。使用 LDAP 查询(uid=user1@example.com)搜索提供的 baseDN Java 实体属性,并从匹配条目中读取属性。这些属性使用 principalAttributeList 属性中的键/值对映射回 org.apereo.cas.authentication.principal.Principal。我们认识到 LDAP 的 cnsn 属性被映射到有意义的名称,而 description 属性被映射到用于确定我们用户授权的角色。

最后,我们希望将相同类型的查询设置在Principal上,到一个完整的 LDAP 唯一名称,然后使用groupOfUniqueNames条目的uniqueMember属性。不幸的是,CAS LDAP 代码还没有这种灵活性,这导致我们得出结论,更高级的LDAP映射将需要在CAS的基类中进行扩展。

从 CAS 获取 UserDetails

当我们最初设置UserDetailsByNameServiceWrapper时,它只是将呈现给UserDetails对象的用户名转换为UserDetailsService中引用的对象。现在,正如我们在第六章的末尾讨论的,LDAP 目录服务,一切都会顺利。

注意,在下一节中,我们将切换回修改日历应用程序而不是 CAS 服务器

GrantedAuthorityFromAssertionAttributesUser 对象

现在我们已经修改了AuthenticationUserDetailsService实现中的UserDetails,将其从AuthenticationUserDetailsService实现修改为

o.s.s.cas.userdetails.GrantedAuthorityFromAssertionAttributesUser DetailsService对象,其任务是读取用户的GrantedAuthority对象。假设有一个属性为 role 的属性将随断言返回。我们只需在CaseConfig.java文件中配置一个新的authenticationUserDetailsService bean(确保替换之前定义的authenticationUserDetailsService bean):

//src/main/java/com/packtpub/springsecurity/configuration/CasConfig.java
@Bean
public AuthenticationUserDetailsService userDetailsService() {
    return new GrantedAuthorityFromAssertionAttributesUserDetailsService(new String[] { "role" });
}

重要提示

您的代码应该看起来像chapter18.06-cas-serverchapter18.06-calendar中的那样。

属性检索有什么用?

记住,CAS为我们提供了一个抽象层,消除了我们的应用程序直接访问用户存储库的能力,并强制所有此类访问都通过CAS作为代理进行。

这非常强大!这意味着我们的应用程序不再关心用户存储在哪种类型的存储库中,也不必担心如何访问它们的细节。这证实了使用CAS进行身份验证足以证明用户应该能够访问我们的应用程序。对于系统管理员来说,这意味着如果 LDAP 服务器被重命名、移动或进行其他调整,他们只需要在单个位置重新配置它——CAS。通过CAS集中访问允许组织整体安全架构具有高度的灵活性和适应性。

现在所有通过CAS进行身份验证的应用程序对用户都有相同的视图,并且可以在任何CAS启用环境中一致地显示信息。

请注意,一旦经过身份验证,Spring Security 不需要Authentication对象可能会随着时间的推移而变得过时,并且可能与源CAS服务器不同步。请妥善设置会话超时,以避免此潜在问题!

附加 CAS 功能

CAS提供了超出通过 Spring Security CAS包装器公开的配置功能。以下是一些这些功能:

  • 为在CAS服务器上配置的时间窗口内访问多个CAS受保护应用程序的用户提供透明的 SSO。

  • 应用程序可以强制用户在TicketValidatorrenew属性设置为true进行身份验证;你可能希望在用户尝试访问应用程序的高安全区域时,在某些自定义代码中条件性地设置此属性。

  • 获取服务票据的RESTful API

  • Aperero CAS 服务器也可以充当OAuth2服务器。如果你这么想,这很有道理,因为CASOAuth2非常相似。

  • CAS服务器提供OAuth支持,以便它可以获取访问令牌到代理 OAuth 提供者(即,Google),或者使CAS服务器本身成为 OAuth 服务器。

我们鼓励您探索 CAS 客户端和服务器全部功能,并向CAS Aperero社区论坛中的热心人士提出任何问题!

摘要

在本章中,我们了解了CAS SSO 门户以及它如何与 Spring Security 集成,我们还介绍了CAS架构以及CAS启用环境中参与者之间的通信路径。我们还看到了CAS启用应用程序对应用程序开发人员和系统管理员的益处。我们还学习了如何配置JBCP Calendar应用程序与基本CAS安装交互。我们还介绍了CAS单点****注销支持。

我们还看到了如何实现代理票据身份验证以及如何利用它来验证无状态服务。

我们还介绍了如何将CAS更新为与LDAP交互以及如何与我们的CAS启用应用程序共享LDAP数据。我们甚至学习了如何使用行业标准SAML协议实现属性交换。

我们希望这一章能成为对 SSO 世界有趣介绍的入门。市场上有很多其他 SSO 系统,大多是商业性的,但CAS是开源 SSO 领域的领导者之一,并为任何组织构建SSO能力提供了一个卓越的平台。

在最后一章中,我们将学习更多关于构建 GraalVM 本地图像的内容。

第十九章:构建 GraalVM 原生图像

本章节致力于提升关于原生图像和GraalVM的技能。在本章中,您将获得构建利用原生功能的 Spring Security 应用程序的指导。

Spring Boot 3通过GraalVM引入了原生图像生成支持,Spring Security 与这一功能无缝集成,使其功能与原生图像兼容。这种集成可以是一种提高 Spring Security 应用程序性能和安全的绝佳方式。

我们将深入探讨高效更新构建配置以利用GraalVM工具的必要步骤,从而实现原生能力与您的应用程序的无缝集成。

此外,我们还将探索GraalVM原生图像对 Spring Security 功能的支持,这些功能在我们这本书中尚未涉及,包括以下主题:

  • 介绍GraalVM

  • 使用Buildpacks创建GraalVM镜像

  • 使用原生构建工具构建原生图像

  • GraalVM原生图像中处理方法安全性

以下部分将提供关于特定Spring Security功能的具体指导,这些功能可能需要应用程序提供额外的提示。

本章节的代码示例链接在此:packt.link/fQ5AM

介绍 GraalVM

GraalVM是一个高性能运行时,提供了在应用程序性能和效率方面的重大改进。GraalVM代表了一个高性能运行时的Java 开发****工具包JDK)。

除了支持即时JIT)编译外,GraalVM还使 Java 应用程序的即时编译成为可能。此功能促进了更快的初始化、增强的运行时性能和减少的资源消耗。然而,生成的可执行文件仅限于在编译它的平台上运行。GraalVM通过提供额外的编程语言和执行模式来扩展其功能。第一个生产就绪版本,GraalVM 19.0,于 2019 年 5 月推出。

在下一节中,我们将更深入地探讨原生图像的概念,以获得更好的理解。

什么是原生图像?

GraalVM中的原生图像指的是从 Java 应用程序即时编译(AOT)的可执行文件。与通常编译为字节码并在Java 虚拟机JVM)上运行的传统的 Java 应用程序不同,原生图像直接编译为特定平台的机器代码。

GraalVM 的原生图像利用 GraalVM 编译器在编译期间分析和优化 Java 应用程序的代码,从而生成针对目标环境定制的独立原生可执行文件。这些原生图像可以独立部署,无需安装单独的 JVM,这使得它们在容器化环境或无服务器平台上部署轻量级、快速启动的应用程序特别有用。

GraalVM 的关键特性

GraalVM 通过几个关键特性将自己与基础 JDK 区分开来:

  • Graal 编译器,作为 JIT 编译器。

  • GraalVM Native Image,一种促进 Java 应用程序提前编译的技术。

  • GraalVM SDK,包括一个基于 Java 的框架和一系列针对开发高性能语言运行时的 API。

  • JavaScriptRubyPython 以及其他几种广泛使用的语言。借助 GraalVM 的多语言能力,开发人员可以在单个应用程序中无缝混合多种编程语言,而无需承担额外开销。

  • 符合 ECMAScript 2023 的 JavaScript 运行时,以及 Node.js。

  • LLVM 位码。

GraalVM 的安全优势

在安全性方面,GraalVM 提供了一些显著的优势:

  • 它阻止在程序运行时加载新的、不熟悉的代码。

  • 它只包含应用程序在其镜像内已证明可到达的路径。

  • 反射默认关闭,并需要特定的包含列表来启用。

  • 仅允许对预定义的类列表进行反序列化。

  • 与即时编译器相关的问题,如崩溃、编译错误或通过如 JIT 溅射(安全漏洞)等技术创建机器代码小工具的可能性,都被消除。

在介绍完 GraalVM 的关键特性和安全优势后,我们将继续探讨构建 GraalVM 图像的实际示例。这包括利用 Buildpacks 并将其应用于我们的 JBCP 日历应用程序。

使用 Buildpacks 的 GraalVM 图像

通过参考 Docker 文档中的详细信息(docs.docker.com/get-docker/)确保已安装 Docker。如果您使用 Linux,请配置 Docker 以允许非 root 用户。

重要提示

对于 macOS 用户,建议将分配的 Docker 内存增加到至少 8 GB,并考虑添加更多 CPU。在 Microsoft Windows 上,通过启用 Docker WSL 2 后端(docs.docker.com/desktop/wsl/)来确保最佳性能。

在接下来的章节中,我们将构建 BuildpacksGradle,以及制作 BuildpacksMaven

使用 Buildpacks 和 Gradle 构建 GraalVM 图像

AOT 任务由 plugins 块中的 org.graalvm.buildtools.native 自动配置。

对于我们的示例应用程序,我们需要将插件声明添加到 build.gradle 文件中:

//build.gradle
plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.4'
    id 'io.spring.dependency-management' version '1.1.4'
    id 'org.graalvm.buildtools.native' version '0.10.1'
}

一旦应用了org.graalvm.buildtools.native插件,bootBuildImage任务将生成原生镜像而不是 JVM 镜像。使用以下命令执行任务:

./gradlew bootBuildImage

使用 Buildpacks 和 Maven 构建 GraalVM 镜像

要使用pom.xml文件创建采用spring-boot-starter-parent并包含org.graalvm.buildtools:native-maven-plugin的原生镜像容器,请确保您的pom.xml中的<parent>部分类似于以下内容:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.2.4</version>
</parent>

此外,您应该在pom.xml<plugins>部分包含以下native-maven-plugin

<build>
    <plugins>
       <plugin>
          <groupId>org.graalvm.buildtools</groupId>
          <artifactId>native-maven-plugin</artifactId>
       </plugin>
    </plugins>
</build>

spring-boot-starter-parent在命令行上包含一个-P标志。

mvn -Pnative spring-boot:build-image

重要提示

您的代码现在应该看起来像chapter19.01-calendar中的那样。

从 Buildpacks 运行 GraalVM 镜像

执行MavenGradle的相关构建命令后,应该可以访问 Docker 镜像。通过使用docker run命令启动您的应用程序:

docker run --rm -p 8080:8080 docker.io/library/chapter19.01-calendar:0.0.1-SNAPSHOT

您的应用程序可以通过http://localhost:8080访问。

要优雅地关闭应用程序,请按Ctrl + C

在构建Buildpacks之后,我们将深入探讨使用原生****构建工具构建原生镜像的详细过程。

使用原生构建工具构建原生镜像

如果您希望在不依赖Docker的情况下生成原生可执行文件,GraalVM****原生构建工具将非常有用。这些工具由GraalVM作为插件提供,适用于MavenGradle,提供一系列GraalVM任务,包括生成原生镜像。

在以下章节中,我们将了解使用原生构建工具MavenGradle构建和运行GraalVM镜像的过程。

前提条件

要使用原生构建工具生成原生镜像,请确保您的系统上已安装GraalVM发行版。

对于我们的示例,我们将使用在 Liberica Native Image Kit 下载中心可用的bellsoft-liberica-vm-openjdk17-23.0.3bell-sw.com/pages/downloads/native-image-kit/#nik-23-(jdk-17))。

使用原生构建工具和 Maven 构建 GraalVM 镜像

就像对构建包的支持一样,确保使用spring-boot-starter-parent以继承原生配置文件至关重要。此外,请确保包含org.graalvm.buildtools:native-maven-plugin插件。

一旦启用原生配置文件,您就可以启动native:compile目标以开始原生镜像编译过程。

mvn -Pnative native:compile

您可以在target目录中找到原生镜像的可执行文件。

使用原生构建工具和 Gradle 构建 GraalVM 镜像

对于我们的示例应用程序,我们需要添加原生构建工具Gradle 插件

当您将 Native Build Tools Gradle 插件集成到项目中时,Spring Boot Gradle 插件将立即激活 Spring AOT 引擎。任务依赖关系预先安排,使您可以简单地执行标准的 nativeCompile 任务以生成原生图像。

./gradlew nativeCompile

您可以在名为 build/native/nativeCompile 的目录中找到原生图像的可执行文件。

从原生构建工具运行 GraalVM 图像

在这个阶段,您的应用程序应该可以正常工作。启动时间因不同机器而异,但预计会比在 JVM 上运行的 Spring Boot 应用程序快得多。

您可以直接执行应用程序来运行它:

target/chapter19.01-calendar

通过在您的网页浏览器中导航到 http://localhost:8080,您应该可以访问 JBCP 日历应用程序。

要优雅地关闭应用程序,请按 Ctrl + C

在掌握构建 GraalVM 图像的基本配置后,我们将深入了解与 Spring Security 原生图像相关的特定用例。

GraalVM 原生图像中的方法安全

虽然 GraalVM 原生图像支持 方法安全,但某些用例可能需要应用程序提供额外的提示以实现正确功能。

如果您正在使用 UserDetails 或身份验证类的自定义实现,并使用 @PreAuthorize@PostAuthorize 注解,您可能需要额外的指示。考虑以下场景,您已经为 UserDetailsService 返回的 UserDetails 类创建了一个自定义实现:

  1. 首先,创建一个自定义的 UserDetails 实现如下:
//src/main/java/com/packtpub/springsecurity/service/ CalendarUserDetails.java
public class CalendarUserDetails extends CalendarUser implements UserDetails {
    CalendarUserDetails(CalendarUser user) {
       setId(user.getId());
       setEmail(user.getEmail());
       setFirstName(user.getFirstName());
       setLastName(user.getLastName());
       setPassword(user.getPassword());
    }
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
       return CalendarUserAuthorityUtils.createAuthorities(this);
    }
    public boolean hasAdminRole() {
       return getUsername().startsWith("admin");
    }
    @Override
    public String getUsername() {
       return getEmail();
    }
    @Override
    public boolean isAccountNonExpired() {
       return true;
    }
    @Override
    public boolean isAccountNonLocked() {
       return true;
    }
    @Override
    public boolean isCredentialsNonExpired() {
       return true;
    }
    @Override
    public boolean isEnabled() {
       return true;
    }
    private static final long serialVersionUID = 3384436451564509032L;
}

重要提示

提供的 hasAdminRole() 实现仅作为如何使用 GraalVM 原生图像管理方法安全性的示例。然而,对于生产环境,建议考虑 hasAdminRole() 的更安全实现。

  1. 我们希望在 CalendarService 接口中的 @PreAuthorize 注解中使用 hasAdminRole() 方法:
//src/main/java/com/packtpub/springsecurity/service/ CalendarUserDetails.java
public interface CalendarService{
... omitted for brevity
    @PreAuthorize("principal?.hasAdminRole()")
    List<Event> getEvents();
}
  1. 确保在 SecurityConfig.java 中存在 @EnableMethodSecurity 注解以激活方法安全注解。

重要提示

您的代码现在应该看起来像 chapter19.02-calendar 中的那样。

  1. 使用 admin1@example.com/admin1 登录应用程序并尝试访问 http://localhost:8080/events。您将注意到所有事件都可以显示。

图 19.1 – 所有事件页面

图 19.1 – 所有事件页面

  1. 当您使用 user1@example.com/user1 登录应用程序并尝试访问 http://localhost:8080/events 时,您将得到以下访问拒绝页面:

图 19.2 – 未授权用户的访问拒绝页面

图 19.2 – 未授权用户的访问拒绝页面

  1. 如果您使用提供的配置执行应用程序的原生图像,尝试调用 hasAdminRole() 方法将导致以下类似错误:

    Caused by: org.springframework.expression.spel.SpelEvaluationException: EL1004E: Method call: Method hasAdminRole() cannot be found on type com.packtpub.springsecurity.service.CalendarUserDetails
    at org.springframework.expression.spel.ast.MethodReference.findAccessorForMethod(MethodReference.java:237) ~[na:na]
    at org.springframework.expression.spel.ast.MethodReference.getValueInternal(MethodReference.java:147) ~[na:na]
    at org.springframework.expression.spel.ast.MethodReference$MethodValueRef.getValue(MethodReference.java:400) ~[na:na]
    at org.springframework.expression.spel.ast.CompoundExpression.getValueInternal(CompoundExpression.java:98) ~[na:na]
    at org.springframework.expression.spel.ast.SpelNodeImpl.getTypedValue(SpelNodeImpl.java:119) ~[chapter19.02-calendar:6.1.5]
    at org.springframework.expression.spel.standard.SpelExpression.getValue(SpelExpression.java:309) ~[na:na]
    at org.springframework.security.access.expression.ExpressionUtils.evaluateAsBoolean(ExpressionUtils.java:30) ~[na:na]
    ... 113 common frames omitted
    

    上面的错误表明无法在CalendarUserDetails.class上找到hasAdminRole()方法。

Spring Security 依赖于反射来调用hasAdminRole()方法,而GraalVM原生镜像本身不支持反射。

要解决这个问题,您必须遵循以下步骤:

  1. GraalVM原生镜像提供提示以启用CalendarUserDetails#hasAdminRole()方法上的反射。这可以通过提供自定义提示来实现,如下面的示例所示:
//src/main/java/com/packtpub/springsecurity/configuration/ SpringSecurityHints.java
@Configuration(proxyBeanMethods = false)
@ImportRuntimeHints(SpringRuntimeHints.class)
public class SpringSecurityHints {
    static class SpringRuntimeHints implements RuntimeHintsRegistrar {
       @Override
       public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
          hints.reflection()
                .registerType(CalendarUserDetails.class,
                      MemberCategory.INVOKE_DECLARED_METHODS);
       }
    }
}
  1. 现在,您可以构建您应用程序的原生镜像,并且它应该按预期运行。

重要提示

您的代码现在应该看起来像chapter19.03-calendar中的那样。

摘要

在本章中,我们深入探讨了与 Spring Security 特性结合的GraalVM原生镜像支持,这些特性在此书中之前未曾涉及。我们介绍了原生镜像的概念,包括GraalVM的关键特性和安全优势。

探索的关键主题包括Spring Boot 3,它通过GraalVM引入了原生镜像生成支持,无缝集成 Spring Security 特性,并使它们与原生镜像兼容。我们已经看到,在某些情况下,我们需要提供GraalVM使用的提示。

此外,我们已经成功提高了我们应用程序的性能和安全性,与当代软件开发实践相一致。

在结束本章之前,我想向您表示衷心的祝贺,恭喜您完成了这本书的里程碑式任务。您对学习Spring Security 6特性的投入和承诺值得赞扬。

当您反思所涵盖的概念时,请记住,您每迈出一步,朝着掌握这些技术迈进,都让您更接近成为一名熟练的开发者。自信地迎接挑战,知道每个障碍都是成长的机会。继续探索,继续实验,永远不要低估持续学习的力量。

祝您在软件开发之旅上继续取得成功!

附录 – 补充参考资料

在本附录中,我们将涵盖一些我们认为有帮助(并且大部分未记录)但过于详尽而无法插入章节中的参考资料。

在本节中,我们将涵盖以下主题:

  • 使用构建工具(GradleMaven)和 IDE(IntelliJ IDEAEclipse

  • 开始使用 JBCP 日历示例代码

  • 生成服务器证书

  • 补充资源

本节旨在为您提供指导,以进一步深入和清晰地理解本书中涵盖的代码。

构建工具

本书中的代码可以通过GradleMaven执行。

在随后的章节中,我们将详细说明使用GradleMaven作为构建工具执行项目的情况。本书中的代码可以独立于任何集成开发环境IDE)运行。

然而,我们将提供使用 EclipseIntelliJ IDEA 的实现示例。

在下一节中,我们将演示如何使用 GradleMaven 等构建工具。

Gradle 构建工具

本书中的所有代码都可以使用 Gradle 在本地构建,请访问 gradle.org/install/

由于源代码的根目录已经安装了 Gradle 包装器,因此不需要本地安装 GradleGradle 包装器可以安装在任何子模块中。您可以在 docs.gradle.org/current/userguide/gradle_wrapper.xhtml 找到有关 Gradle 包装器的更多信息。

Maven 构建工具

在接下来的章节中,您将找到丰富的代码示例、实用技巧和针对熟悉 Maven 的读者的动手练习。

Mavenmaven.apache.org/download.cgi

下载示例代码

您可以从 www.packtpub.com 购买的所有 Packt 书籍的账户中下载示例代码文件。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 并注册以将文件直接通过电子邮件发送给您。

在下一节中,我们将深入探讨使用您首选的 IDE,如 IntelliJ IDEAEclipse,启动 JBCP 日历示例代码。

开始使用 JBCP 日历示例代码

正如我们在 第一章 中所描述的,不安全应用程序的解剖结构,我们假设您已安装 Java 17 作为最低要求。您可以从 Oracle 的网站 (www.oracle.com/java/technologies/downloads/) 下载 JDK 或使用任何其他 OpenJDK 17 版本。

在本书出版时,所有代码都已使用最新的 长期支持 (LTS) 版本 Java 21 进行了验证。

下一个部分将讨论示例代码的结构以及它在您首选的 IDE 中的使用。

示例代码结构

示例代码包含多模块 GradleMaven 项目的文件夹。每个文件夹命名为 ChapterNN,其中 NN章节号。每个 ChapterNN 文件夹包含包含每个里程碑项目的额外文件夹,格式为 chapterNN.mm-calendar,其中 NN章节号mm 是该章节内的 里程碑

为了简化,我们建议你将源代码提取到一个不包含任何空格的路径。每个里程碑都是章节中的一个检查点,允许你轻松地将你的代码与书中的代码进行比较。例如,chapter02.03-calendar包含日历应用程序第二章Spring Security 入门中的里程碑编号03。前一个项目位置将是~/ Spring-Security-Fourth-Edition/Chapter02/chapter02.03-calendar

第一章不安全应用程序的解剖结构,和第二章Spring Security 入门,已被创建为Spring项目,而不是使用Spring Boot作为项目基础。

第三章自定义认证,将日历项目转换为Spring Boot代码库。

为了使每个章节尽可能独立,本书中的大多数章节都是基于第九章开放 OAuth2,或第十五章额外的 Spring Security 功能构建的。这意味着,在大多数情况下,你可以阅读第九章开放 OAuth2,然后跳转到书的其他部分。然而,这也意味着,开始每个章节时,重要的是使用章节的里程碑 00源代码,而不是继续使用上一章的代码。这确保了你的代码从与章节相同的地方开始。

虽然你可以不执行任何步骤就完成整本书,但我们建议从每个章节的里程碑 00开始,并按照书中的步骤进行实施。这将确保你从书中获得最大收益。你可以使用里程碑版本来复制大量代码,或者在遇到问题时比较你的代码。

在 IntelliJ IDEA 中使用示例

Tomcat插件配置了GradleMaven以运行嵌入式实例的情况下,有一些事情是运行示例应用程序所必需的,这有助于你更快地开始。

在 IntelliJ IDEA 中导入项目

本书使用的多数图表都是从GradleMaven项目中提取的。

IntelliJ IDEA 将允许你导入现有项目,或者你可以直接从源代码库的根目录打开build.gradlepom.xml,IDEA 将为你创建必要的项目文件。

  1. 一旦你打开 IntelliJ IDEA,你可以使用打开选项打开整个项目,如下面的截图所示:

附录图 1 – JBCP 日历示例导入

附录图 1 – JBCP 日历示例导入

  1. 然后,你将需要选择 IntelliJ IDEA 如何执行此项目,是Gradle还是Maven,如下面的截图所示:

附录图 2 – Gradle 或 Maven 选项

附录图 2 – Gradle 或 Maven 选项

  1. 例如,一旦我们选择 Gradle,您将能够处理任何章节,布局将如图下截图所示:

附录图 3 – IDEA 中的章节展示布局

附录图 3 – IDEA 中的章节展示布局

IntelliJ IDEA 中导入项目后,您可以按照下一节提供的指导运行您的代码。

在 IntelliJ IDEA 中运行示例

通过为每个项目创建 运行/调试配置 条目来运行里程碑项目。

  1. 对于每个 spring-boot 项目(从 第三章**,自定义身份验证)开始),您可以简单地点击工具栏中的绿色播放按钮,或者右键单击主类并选择 运行 CalendarApplication。IntelliJ IDEA 将启动 Spring Boot 应用程序,您将在 运行 工具窗口中看到日志。

附录图 4 – 使用 IntelliJ IDEA 运行 Spring Boot 项目

附录图 4 – 使用 IntelliJ IDEA 运行 Spring Boot 项目

  1. 对于其他项目,您可以使用终端,如果使用 IntelliJ IDEA,请转到 文件 | 运行 并选择 编辑配置...,如图下截图所示:

附录图 5 – 自定义应用程序,使用 IntelliJ IDEA 运行

附录图 5 – 自定义应用程序,使用 IntelliJ IDEA 运行

  1. 您将看到添加新配置的选项。选择左上角的加号 (+) 以选择新的 Gradle 配置,如图下截图所示:

附录图 6 – 自定义应用程序,使用 IntelliJ IDEA 添加新配置

附录图 6 – 自定义应用程序,使用 IntelliJ IDEA 添加新配置

  1. 现在,您可以给它起一个名字,例如 chapter01.00 (bootRun) 并选择此配置的实际里程碑目录。最后,在 运行 选项下输入 tomcatRun 以执行,如图下截图所示:

附录图 7 – 自定义项目,使用 IntelliJ IDEA 运行

附录图 7 – 自定义项目,使用 IntelliJ IDEA 运行

  1. 选择您要执行的配置;点击绿色 运行 按钮(如图 附录**图 7)。

在下一节中,我们将介绍使用 Eclipse 运行示例代码的方法。

使用 Eclipse 中的示例

在本节中,我们将介绍 Eclipse 中示例应用程序的使用。在所有项目中,已使用 GradleMaven 设置了一个 Tomcat 插件,以促进快速启动嵌入式实例,加快您的初始设置过程。

在 Eclipse 中导入项目

在您下载并安装 Eclipse IDE (www.eclipse.org/downloads/) 之后,启动 Eclipse。

  1. 当你第一次打开 Eclipse 时,它将提示你输入工作空间位置。你可能需要转到文件 | 切换工作空间 | 其他来创建一个新的工作空间。我们建议输入不包含任何空格的工作空间位置。例如,看看下面的截图:

附录图 8 – Eclipse 工作空间选择

附录图 8 – Eclipse 工作空间选择

  1. 一旦你创建了一个新的工作空间,选择导入项目

  2. 这次,我们将选择现有 Maven 项目

附录图 9 – 将 Eclipse 项目导入为 Maven 项目

附录图 9 – 将 Eclipse 项目导入为 Maven 项目

  1. 浏览到导出代码的位置,并选择代码的父文件夹。你将看到所有项目列出来。你可以选择你感兴趣的项目,或者保留所有项目被选中。如果你决定导入所有项目,你可以轻松地专注于当前章节,因为命名约定将确保项目按照在书中展示的顺序排序:

附录图 10 – Eclipse Maven 项目导入确认

附录图 10 – Eclipse Maven 项目导入确认

  1. 你将能够处理任何章节,布局将如以下截图所示:

附录图 11 – 使用 Eclipse 的章节展示布局

附录图 11 – 使用 Eclipse 的章节展示布局

Eclipse中导入项目后,你可以按照下一节提供的指导运行你的代码。

在 Eclipse 内运行示例

要执行每个里程碑项目,你可以按照以下步骤进行:

  1. 对于每个 spring-boot 项目(从第三章**,自定义身份验证)开始,你只需在 Eclipse 中点击运行按钮,或者右键单击你的项目并选择运行方式 | Java 应用程序

附录图 12 – 使用 Eclipse 运行 Spring Boot 项目

附录图 12 – 使用 Eclipse 运行 Spring Boot 项目

  1. 对于其他项目,你可以使用终端,使用 Eclipse 中的运行按钮,或者右键单击你的项目并选择运行配置

附录图 13 – 自定义应用程序,使用 Eclipse 运行

附录图 13 – 自定义应用程序,使用 Eclipse 运行

  1. package cargo:run

从命令行启动示例

第一章不安全应用程序的解剖结构,和第二章Spring Security 入门中,你将使用不同的任务来运行项目。

  • 如果你使用 Gradle,运行以下命令来启动应用程序:

    Maven, run the following command to start the application:
    
    

    ./mvnw package cargo:run

    
    

对于本书的其余章节(从 第三章**,自定义身份验证)开始,使用了 Spring Boot:

  • 如果你正在使用 Gradle,请运行以下命令来启动应用程序:

    Maven, run the following command to start the application:
    
    

    ./mvnw spring-boot:run

    
    

通常,对于本书的每个章节部分,每个章节里程碑的根目录中都有一个 README.md 文件。此文件包括启动应用程序所需的必要命令,针对你首选的构建工具进行了定制。

在下一节中,我们将讨论生成服务器证书。

生成服务器证书

一些章节的示例代码(即,第八章**,使用 TLS 的客户端证书身份验证第九章**,开放 OAuth2第十章**,SAML 2 支持,以及 第十八章**,使用中央认证服务进行单点登录)需要使用 HTTPS 才能使示例代码正常工作。

一些项目已经配置为运行 HTTPS;大多数配置都在属性或 YAML 文件中管理。

现在,当你从 MavenGradle 运行嵌入式 Tomcat 服务器上的示例代码时,你可以连接到 http://localhost:8080https://localhost:8443

如果你还没有证书,你必须首先生成一个。

如果你愿意,你可以跳过此步骤并使用包含证书的 tomcat.keystore 文件,该证书位于书中示例源代码的 src/main/resources/keys 目录中。

在命令提示符中输入以下命令行:

keytool -genkey -alias jbcpcalendar -keypass changeit -keyalg RSA \
-keystore tomcat.keystore
Enter keystore password: changeit
Re-enter new password: changeitWhat is your first and last name? [Unknown]: localhost
What is the name of your organizational unit? [Unknown]: JBCP Calendar What is the name of your organization? [Unknown]: JBCP
What is the name of your City or Locality? [Unknown]: Anywhere What is the name of your State or Province? [Unknown]: UT
What is the two-letter country code for this unit? [Unknown]: US
Is CN=localhost, OU=JBCP Calendar, O=JBCP, L=Anywhere, ST=UT, C=US correct? [no]: yes

大多数值都是不言自明的,但你需要确保对 你的姓名是什么? 的回答是你将访问 Web 应用程序的宿主。这是确保 SSL 握手成功所必需的。

你现在应该有一个名为 tomcat.keystore 的文件位于当前目录中。你可以使用以下命令在相同目录下查看其内容:

keytool -list -v -keystore tomcat.keystore Enter keystore password: changeit
Keystore type: JKS Keystore provider: SUN
...
Alias name: jbcpcalendar
...
Owner: CN=localhost, OU=JBCP Calendar, O=JBCP, L=Anywhere, ST=UT, C=US Issuer: CN=localhost, OU=JBCP Calendar, O=JBCP, L=Anywhere, ST=UT, C=US

如你所猜,使用 changeit 作为密码是不安全的,因为这是许多 JDK 实现中使用的默认密码。在生产环境中,你应该使用一个安全的密码,而不是像 changeit 这样简单的密码。

关于 keytool 命令的更多信息,请参阅 Oracle 网站上的文档(docs.oracle.com/en/java/javase/17/docs/specs/man/keytool.xhtml)。

如果你遇到问题,你可能还会发现 CAS SSL 故障排除和参考指南 有所帮助(apereo.github.io/cas/7.0.x/installation/Troubleshooting-Guide.xhtml)。

补充材料

本节包含了一本书中使用的各种技术和概念的资源列表:

以下是一些 UI 技术:

posted @ 2025-09-12 13:55  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报