Spring-Security-第三版-全-

Spring Security 第三版(全)

原文:zh.annas-archive.org/md5/3E3DF87F330D174DBAF9E13DAE6DC0C5

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

欢迎来到 Spring Security 4.2 的世界!我们非常高兴您拥有了这本唯一专门针对 Spring Security 4.2 出版的书籍。在您开始阅读本书之前,我们想向您概述一下本书的组织结构以及如何充分利用它。

阅读完这本书后,您应该对关键的安全概念有所了解,并能够解决大多数需要使用 Spring Security 解决的实际问题。在这个过程中,您将深入了解 Spring Security 的架构,这使您能够处理书中未涵盖的任何意外用例。

本书分为以下四个主要部分:

  • 第一部分(第一章,不安全应用程序的剖析和第二章,Spring Security 入门)提供了 Spring Security 的简介,并让您能够快速开始使用 Spring Security。

  • 第二部分(第三章,自定义认证,第四章,基于 JDBC 的认证,第五章,使用 Spring Data 的认证,第六章,LDAP 目录服务,第七章,记住我服务,第八章,使用 TLS 的客户端证书认证,和第九章,开放给 OAuth 2)提供了与多种不同认证技术集成的高级指导。

  • 第三部分(第十章,使用中央认证服务的单点登录,第十一章,细粒度访问控制,和第十二章,访问控制列表)解释了 Spring Security 的授权支持是如何工作的。

  • 最后,最后一部分(第十三章,自定义授权,第十四章,会话管理,第十五章,Spring Security 的其他功能,以及第十六章,迁移到 Spring Security 4.2,第十七章,使用 OAuth 2 和 JSON Web Tokens 的微服务安全)提供了专门主题的信息和指导,帮助您执行特定任务。

安全是一个非常交织的概念,书中也有很多这样的主题。然而,一旦您阅读了前三章,其他章节相对独立。这意味着您可以轻松地跳过章节,但仍能理解正在发生的事情。我们的目标是提供一个食谱式的指南,即使您通读全书,也能帮助您清楚地理解 Spring Security。

本书通过一个简单的基于 Spring Web MVC 的应用程序来阐述如何解决现实世界的问题。这个应用程序被设计得非常简单直接,并且故意包含非常少的功能——这个应用程序的目标是鼓励你专注于 Spring Security 概念,而不是陷入应用程序开发的复杂性中。如果你花时间回顾示例应用程序的源代码并尝试跟随练习,你将更容易地跟随这本书。在附录的开始使用 JBCP 日历示例代码部分,有一些关于入门的技巧。

本书涵盖内容

第一章,《不安全应用程序的剖析》,涵盖了我们的日历应用程序的一个假设性安全审计,说明了可以通过适当应用 Spring Security 解决的一些常见问题。你将学习一些基本的安全术语,并回顾一些将示例应用程序启动并运行的先决条件。

第二章,《Spring Security 入门》,展示了 Spring Security 的“Hello World”安装。在本章中,读者将了解一些 Spring Security 最常见的自定义操作。

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

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

第五章,《使用 Spring Data 的认证》,介绍了使用 Spring Security 与 Spring Data JPA 和 Spring Data MongoDB 集成的数据库认证。

第六章,《LDAP 目录服务》,提供了一个关于应用程序与 LDAP 目录服务器集成的指南。

第七章,《记住我服务》,展示了 Spring Security 中记住我功能的用法和如何配置它。我们还探讨了使用它时需要考虑的其他一些额外因素。

第八章,《使用 TLS 的客户端证书认证》,将基于 X.509 证书的认证作为一个清晰的替代方案,适用于某些商业场景,其中管理的证书可以为我们的应用程序增加额外的安全层。

第九章,《开放给 OAuth 2.0》,介绍了 OAuth 2.0 启用的登录和用户属性交换,以及 OAuth 2.0 协议的逻辑流程的高级概述,包括 Spring OAuth 2.0 和 Spring 社交集成。

第十章 10.html,与中央认证服务集成实现单点登录,介绍了与中央认证服务(CAS)集成如何为您的 Spring Security 启用应用程序提供单点登录和单点登出支持。它还演示了如何使用无状态服务的 CAS 代理票证支持。

第十一章 11.html,细粒度访问控制,涵盖了页面内授权检查(部分页面渲染)和利用 Spring Security 的方法安全功能实现业务层安全。

第十二章 12.html,访问控制列表,介绍了使用 Spring Security ACL 模块实现业务对象级安全的基本概念和基本实现-一个具有非常灵活适用性的强大模块,适用于挑战性的业务安全问题。

第十三章 13.html,自定义授权,解释了 Spring Security 的授权工作原理,通过编写 Spring Security 授权基础设施的关键部分的自定义实现。

第十四章 14.html,会话管理,讨论了 Spring Security 如何管理和保护用户会话。这一章首先解释了会话固定攻击以及 Spring Security 如何防御它们。然后讨论了您可以如何管理已登录的用户以及单个用户可以有多少个并发会话。最后,我们描述了 Spring Security 如何将用户与 HttpSession 相关联以及如何自定义这种行为。

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

第十六章 16.html,迁移到 Spring Security 4.2,提供从 Spring Security 3 迁移的路径,包括显著的配置更改、类和包迁移以及重要的新功能。它还突出了在 Spring Security 4.2 中可以找到的新功能,并提供参考书中的功能示例。

第十七章 17.html,使用 OAuth 2 和 JSON Web Tokens 的微服务安全,探讨了微服务架构以及 OAuth 2 和 JWT 在 Spring 基础应用程序中保护微服务的作用。

附录,附加参考资料,包含一些与 Spring Security 直接相关性不大的参考资料,但与本书涵盖的主题仍然相关。最重要的是,它包含一个协助运行随书提供的示例代码的章节。

您需要什么(本书)

以下列表包含运行随书提供的示例应用程序所需的软件。一些章节有如下附加要求,这些要求在相应的章节中概述:

本书适合谁

如果您是 Java Web 和/或 RESTful Web 服务开发者,并且具有创建 Java 8、Java Web 和/或 RESTful Web 服务应用程序、XML 和 Spring Framework 的基本理解,这本书适合您。您不需要具备任何之前的 Spring Security 经验。

约定

在本书中,您会找到多种文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义。文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、假 URL、用户输入和 Twitter 处理方式如下所示:"下一步涉及对 web.xml 文件进行一系列更新"。代码块如下所示:

 //build.gradle:
    dependencies {
        compile "org.springframework.security:spring-security-  
        config:${springSecurityVersion}"
        compile "org.springframework.security:spring-security- 
        core:${springSecurityVersion}"
        compile "org.springframework.security:spring-security- 
        web:${springSecurityVersion}"
        ...
    }

当我们需要引起您对代码块中的特定部分注意时,相关的行或项目将被加粗:

 [default]
 exten => s,1,Dial(Zap/1|30)
 exten => s,2,Voicemail(u100)
 exten => s,102,Voicemail(b100)
 exten => i,1,Voicemail(s0)

任何命令行输入或输出如下所示:

$ ./gradlew idea

新术语重要词汇以粗体显示。

您在屏幕上看到的单词,例如在菜单或对话框中,会在文本中以这种方式出现:"在 Microsoft Windows 中,您可以通过右键单击文件并查看其安全属性(属性 | 安全)来查看文件的一些 ACL 功能,如下面的屏幕截图所示"。

警告或重要说明以这种方式出现。

技巧和窍门以这种方式出现。

读者反馈

我们的读者提供的反馈总是受欢迎的。告诉我们您对这本书的看法——您喜欢或不喜欢的地方。读者反馈对我们很重要,因为它有助于我们开发出您能真正从中受益的标题。

要向我们提供一般性反馈,只需给feedback@packtpub.com发封电子邮件,并在邮件主题中提到书籍的标题。

如果您在某个主题上有专业知识,并且有兴趣撰写或为书籍做出贡献,请查看我们的作者指南 www.packtpub.com/authors

客户支持

现在您已经成为 Packt 书籍的自豪拥有者,我们有很多事情可以帮助您充分利用您的购买。

下载示例代码

您可以从您在 www.packtpub.com 的账户上下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给您。您可以通过以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的 SUPPORT 标签上。

  3. 点击“代码下载与勘误”。

  4. 在搜索框中输入书籍的名称。

  5. 选择您要下载代码文件的书籍。

  6. 从您购买本书的下拉菜单中选择。

  7. 点击“代码下载”。

文件下载完成后,请确保使用最新版本解压或提取文件夹:

  • 适用于 Windows 的 WinRAR / 7-Zip

  • 适用于 Mac 的 Zipeg / iZip / UnRarX

  • 适用于 Linux 的 7-Zip / PeaZip

本书的代码包也托管在 GitHub 上,地址为github.com/PacktPublishing/Spring-Security-Third-Edition。我们还有其他来自我们丰富书籍和视频目录的代码包,您可以在github.com/PacktPublishing/找到。去看看吧!

勘误表

虽然我们已经尽一切努力确保内容的准确性,但错误仍然会发生。如果您在我们的书中发现错误 - 可能是文本或代码中的错误 - 我们非常感谢您能向我们报告。这样做可以节省其他读者的挫折感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata报告,选择您的书籍,点击勘误提交表单链接,并输入勘误的详细信息。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站,或添加到该标题的勘误部分现有的勘误列表中。要查看之前提交的勘误,请前往www.packtpub.com/books/content/support,在搜索字段中输入书籍的名称。所需信息将在勘误部分出现。

盗版

互联网上的版权材料盗版是一个持续存在的问题,所有媒体都受到影响。 Packt 出版社非常重视我们版权和许可的保护。如果您在互联网上以任何形式发现我们作品的非法副本,请立即提供给我们地址或网站名称,以便我们采取补救措施。请通过copyright@packtpub.com联系我们,附上疑似盗版材料的链接。您帮助保护我们的作者和我们提供有价值内容的能力,我们非常感激。

问题

如果您在阅读本书的任何方面遇到问题,可以通过questions@packtpub.com联系我们,我们会尽力解决问题。

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

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

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

无论您是否已经使用 Spring Security,或者对将软件的基本使用提升到更复杂的下一个级别感兴趣,您在这本书中都能找到帮助。在本章中,我们将涵盖以下主题:

  • 虚构安全审计的结果

  • 基于 web 的应用程序的一些常见安全问题

  • 几个核心软件安全术语和概念

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

安全审计

在你作为吉姆·鲍勃圆形裤子在线日历(JBCPCalendar.com)的软件开发人员的工作中,早晨很早,你在喝第一杯咖啡的过程中收到了以下来自你上司的电子邮件:

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

关于示例应用程序

虽然我们在本书中逐步进行的一个虚构场景,但应用程序的设计和我们对其所做的更改是基于 Spring-based 应用程序的真实世界使用情况。日历应用程序允许用户创建和查看事件:

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

应用程序被设计为简单,以便我们可以专注于安全的重要方面,而不是陷入对象关系映射 (ORM)和复杂 UI 技术的细节中。我们期待你会参考附录中的其他补充材料(本书补充材料部分)来覆盖作为示例代码一部分提供的一些基本功能。

代码是用 Spring 和 Spring Security 4.2 编写的,但将许多示例适应到 Spring Security 的其他版本相对容易。参考第十六章 16.html、迁移到 Spring Security 4.2中的讨论,了解 Spring Security 3 和 4.2 之间的详细变化,以帮助将示例翻译为 Spring Security 4 的语法。

请不要将这个应用程序作为构建真实在线日历应用程序的基础。它故意被构建为简单,并专注于我们在本书中说明的概念和配置。

JBCP 日历应用程序架构

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

你可以在附录的补充材料部分找到有关 MVC 架构的额外材料。

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

在典型的利用 Spring Security 的网络应用程序中,Web 层是许多配置和代码增强发生的地方。例如,EventsController类用于将 HTTP 请求转换为将事件持久化到数据库中。如果你没有太多 Web 应用程序和 Spring MVC 的经验,仔细审查基线代码并确保你理解它是明智的,在我们进入更复杂的主题之前。再次强调,我们试图使网站尽可能简单,日历应用程序的构建只是为了提供一个合理的标题和轻量级的结构。

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

服务层封装了应用程序的业务逻辑。在我们的示例应用程序中,我们使用DefaultCalendarService作为非常轻量级的外观,覆盖数据访问层,以说明关于保护应用程序服务方法的特定要点。服务层还用于在单个方法调用内操作 Spring Security API 和我们的日历 API。我们将在第三章 03.html、自定义认证中详细讨论这一点。

在一个典型的 Web 应用程序中,这个层次将包含业务规则验证、业务对象的组合和分解,以及诸如审计的交叉关注点。

数据访问层封装了负责操作数据库表内容的代码。在许多 Spring 应用程序中,这就是您会看到 ORM(如 Hibernate 或 JPA)使用的地方。它向服务层暴露基于对象的 API。在我们的示例应用程序中,我们使用基本的 JDBC 功能来实现对内存中 H2 数据库的持久化。例如,JdbcEventDao用于将事件对象保存到数据库中。

在一个典型的 Web 应用程序中,会使用更全面的数据访问解决方案。由于 ORM(对象关系映射),以及更一般的数据访问,对一些开发者来说可能比较困惑,因此这是我们选择尽可能简化清晰明了的区域。

应用程序技术

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

与示例代码集成的主要方法是提供与 Gradle 兼容的项目。由于许多 IDE 与 Gradle 集成丰富,用户应该能够将代码导入支持 Gradle 的任何 IDE。由于许多开发者使用 Gradle,我们认为这是包装示例的最直接方法。无论您熟悉的开发环境是什么,希望您能找到一种方法来完成这本书中的示例。

许多 IDE 提供 Gradle 工具,可以自动为您下载 Spring 和 Spring Security 4.2 的 Javadoc 和源代码。然而,可能有时这是不可能的。在这种情况下,您需要下载 Spring 4.2 和 Spring Security 4.2 的完整版本。Javadoc 和源代码是顶级的。如果您感到困惑或需要更多信息,示例可以为您提供额外的支持或信心,以帮助您的学习。访问附录中的补充材料部分,即附加参考资料,以查找有关 Gradle 的额外信息,包括运行示例、获取源代码和 Javadoc,以及不使用 Gradle 构建项目的替代方案。

审查审计结果

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

应用程序审计结果

这个应用程序表现出以下不安全行为:

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

  • 不当或不存在授权使用

  • 缺少数据库凭据安全

  • 个人身份信息或敏感信息容易访问或未加密

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

  • 风险等级:高

我们建议,在这些问题得到解决之前,该应用程序应下线。

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

第三方安全专家通常被公司(或其合作伙伴或客户)雇佣,通过结合白帽黑客、源代码审查和与应用程序开发人员和架构师正式或非正式的交谈,审计他们软件安全的效果。

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

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

在你的职业生涯中某个时候可能会遇到的两个具体安全标准是支付卡行业数据安全标准PCI DSS)和健康保险隐私和责任法案HIPAA)隐私规则。这两个标准旨在通过结合流程和软件控制来确保特定敏感信息(如信用卡和医疗信息)的安全。许多其他行业和国家有关于敏感信息或个人可识别信息PII)类似的规则。不遵循这些标准不仅是不好的实践,还可能在你或你的公司发生安全漏洞时暴露你或你的公司承担重大责任(更不用说坏新闻了)。

收到安全审计的结果可能是一次大开眼界的经历。按照要求改进软件可以是一个自我教育和软件改进的完美机会,并允许您实施导致安全软件的实践和政策。

让我们回顾一下审计员的调查结果,并详细制定一个解决它们的计划。

认证

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

  • 基于凭证的认证:当你登录基于网页的邮箱账户时,你很可能会提供你的用户名和密码。邮箱提供商将其用户名与数据库中的已知用户匹配,并验证你的密码与他们的记录相符。这些凭证是邮箱系统用来验证你是系统有效用户的东西。首先,我们将使用这种认证方式来保护 JBCP 日历应用程序的敏感区域。从技术上讲,邮箱系统不仅可以在数据库中检查凭证,还可以在任何地方进行检查,例如,企业目录服务器如微软活动目录。本书涵盖了这类集成的大部分内容。

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

  • 硬件认证:早上启动你的车时,你将金属钥匙插入点火器并转动它来启动汽车。虽然这可能感觉与另外两个例子不同,但是钥匙上的凸起和点火开关中的滚珠正确匹配,作为一种硬件认证形式。

实际上有数十种认证方式可以应用于软件和硬件安全问题,每种方式都有其优缺点。我们将在本书的第一半部分回顾这些方法,并将其应用于 Spring Security。我们的应用程序缺乏任何类型的认证,这就是审计包括无意中提升权限风险的原因。

通常,一个软件系统会被划分为两个高层次领域,例如未认证(或匿名)和已认证,如下面的屏幕截图所示:

匿名区域的应用程序功能是独立于用户身份的功能(想想一个在线应用程序的欢迎页面)。

匿名区域不会做以下这些事情:

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

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

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

系统的未认证区域旨在供所有人使用,甚至是那些我们尚未明确识别的用户。然而,可能是在这些区域出现了对已识别用户的其他功能(例如,无处不在的欢迎 {First Name}文本)。通过使用 Spring Security 标签库,完全支持向已认证用户显示内容的选择性,并在第十一章 细粒度访问控制 中进行了介绍。

我们将在第二章 开始使用 Spring Security 中解决这个问题,并使用 Spring Security 的自动配置能力实现基于表单的认证。之后,我们将探讨执行认证的各种其他方式(这通常涉及与企业或其他外部认证存储系统的集成)。

授权

不当或不存在使用授权

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

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

  • 第一个方面是将一个已认证的主体映射到一个或多个权限(通常称为角色)。例如,您网站的临时用户可能被视为具有访客权限,而网站管理员可能被分配管理权限。

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

一个受保护的资源可能是系统中应基于用户权限而有条件地可用的任何方面。

基于 Web 的应用程序的安全资源可能是个别 Web 页面,网站的整个部分,或个别页面的部分。相反,安全业务资源可能是类上的方法调用或个别业务对象。

你可能想象有一个权限检查,它会检查主体,查找其用户账户,并确定主体是否实际上是管理员。如果这个权限检查确定试图访问受保护区域的主体实际上是管理员,那么请求将会成功。然而,如果主体没有足够的权限,请求应该被拒绝。

让我们 closer look at a particular example of a secured resource, the All Events page. The All Events page requires administrative access (after all, we don't want regular users viewing other users' events), and as such, looks for a certain level of authority in the principal accessing it.

如果我们思考当一个网站管理员试图访问受保护资源时决策可能是如何做出的,我们会想象实际权限与所需权限的检查可以用集合论简洁地表达。我们可能会选择用维恩图表示这个决定:

用户权限(用户和管理员)和所需权限(管理员)之间有一个交集,所以用户被提供访问权限。

与未经授权的用户相比如下:

权限集合是分开的,没有公共元素。所以,用户被拒绝访问页面。因此,我们已经演示了访问资源授权的基本原则。

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

数据库凭据安全

数据库凭据不安全或容易访问。通过检查应用程序源代码和配置文件,审计员注意到用户密码以明文形式存储在配置文件中,这使得恶意用户能够轻松访问服务器并访问应用程序。

由于应用程序包含个人和财务数据,恶意用户能够访问任何数据可能会使公司面临身份盗窃或篡改的风险。对我们来说,保护访问应用程序所使用的凭据应该是首要任务,并且确保安全的一个关键一步是确保一个失败点不会使整个系统受到威胁。

我们将检查 Spring Security 中用于凭据存储的数据库访问层配置,这在第四章“基于 JDBC 的认证”中讨论。在这一章中,我们还将探讨内置技术以提高存储在数据库中的密码的安全性。

敏感信息

可识别或敏感信息容易访问或未加密。审计员注意到系统中一些重要且敏感的数据完全是未加密或未在任何地方遮蔽的。幸运的是,有一些简单的设计模式和工具可以让我们安全地保护这些信息,并且 Spring Security 支持基于注解的 AOP。

传输层保护

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

虽然在线应用程序包含私人信息,在现实世界中,没有 SSL 保护的运行是不可想象的,不幸的是,JBCP 日历正是这种情况。SSL 保护确保浏览器客户端与 Web 应用程序服务器之间的通信安全,防止多种篡改和窥探。

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

使用 Spring Security 4.2 解决安全问题

Spring Security 4.2 提供了丰富的资源,使得许多常见的安 全实践可以简单地声明或配置。在接下来的章节中,我们将结合源代码和应用程序配置的更改来解决安全审计员提出(还有更多)的所有关注问题,从而确信我们的日历应用程序是安全的。

使用 Spring Security 4.2,我们将能够做出以下更改来增加我们应用程序的安全性:

  • 将系统中的用户划分为用户类

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

  • 为用户类分配用户角色

  • 在全球范围内对应用程序资源应用认证规则

  • 在应用程序架构的所有层次上应用授权规则

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

为什么使用 Spring Security?

Spring Security 存在于 Java 第三方库的宇宙中,填补了 Spring Framework 最初引入时所填补的空白。像Java Authentication and Authorization Service (JAAS)或Java EE Security这样的标准确实提供了一些执行某些认证和授权功能的方法,但 Spring Security 之所以获胜,是因为它以简洁和合理的方式包含了您需要实现端到端应用程序安全解决方案的所有内容。

此外,Spring Security 吸引了许多人,因为它提供了与许多常见企业认证系统的外盒集成;因此,它可以在很少的努力(超出配置)下适应大多数情况。

它被广泛使用,因为没有其他主流框架真正像它这样!

总结

在本章中,我们回顾了一个未受保护的 Web 应用程序的常见风险点和示例应用程序的基本架构。我们还讨论了保护应用程序的策略。

在下一章中,我们将探讨如何快速设置 Spring Security 并了解它的工作原理。

第二章:开始使用 Spring Security

在本章中,我们将对 Spring Security 应用最小的配置来开始解决我们的第一个发现-由于缺乏 URL 保护而不经意间提升了权限,以及第一章中讨论的安全审计不安全应用程序的剖析中的通用认证。然后,我们将在此基础上构建,为我们的用户提供定制化的体验。本章旨在让您开始使用 Spring Security,并为您提供执行任何其他安全相关任务的基础。

在本章中,我们将介绍以下主题:

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

  • 学习如何定制登录和登出体验

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

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

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

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

你好,Spring Security

虽然 Spring Security 的配置可能非常复杂,但该产品的创建者考虑周到,为我们提供了一个非常简单的机制,通过这个机制可以以一个强有力的基础启用软件的大部分功能。从这个基础出发,进一步的配置将允许对应用程序的安全行为进行细粒度的详细控制。

我们将从第一章的不安全应用程序的剖析中的未受保护的日历应用程序开始,将其转变为一个使用基本用户名和密码认证的安全网站。这种认证仅仅是为了说明启用我们的 Web 应用程序的 Spring Security 步骤;您将看到这种方法中有明显的缺陷,这将导致我们进行进一步的配置细化。

导入示例应用程序

我们鼓励您将chapter02.00-calendar项目导入您的 IDE,并通过从本章获取源代码来跟随,如附录附加参考资料中的使用 JBCP 日历示例代码一节所述。

对于每个章节,您会发现有代表书中检查点的代码多个版本。这使得您可以很容易地将您的作品与正确答案进行比较。在每个章节的开头,我们将导入该章节的第一个版本作为起点。例如,在本章中,我们从chapter02.00-calendar开始,第一个检查点将是chapter02.01-calendar。在附录附加参考资料中,所以一定要查阅它以获取详细信息。

更新您的依赖项

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

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

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

    build.gradle:
    dependencies {
        compile "org.springframework.security:spring-security-  
        config:${springSecurityVersion}"
        compile "org.springframework.security:spring-security- 
        core:${springSecurityVersion}"
        compile "org.springframework.security:spring-security- 
        web:${springSecurityVersion}"
        ...
    }

使用 Spring 4.3 和 Spring Security 4.2

Spring 4.2 是一致使用的。我们提供的示例应用程序展示了前一个选项的示例,这意味着您不需要进行任何额外的工作。

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

    build.gradle
    // Spring Security IO with ensures correct Springframework versions
    dependencyManagement {
         imports {
            mavenBom 'io.spring.platform:platform-bom:Brussels-${springIoVersion}'
        }
    }
    dependencies {
        ...
    }

如果您正在使用 Spring Tool Suite,每次更新build.gradle文件时,请确保您右键点击项目,导航到 Gradle | 刷新 Gradle 项目,并选择确定以更新所有依赖项。

关于 Gradle 如何处理传递依赖项以及 BOM 的信息,请参考附录中补充材料部分列出的 Gradle 文档。

实现 Spring Security XML 配置文件

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

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

    //src/main/java/com/packtpub/springsecurity/configuration/
    SecurityConfig.java

    @Configuration
    @EnableWebSecurity
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
        @Override
        public void configure(final AuthenticationManagerBuilder auth) throws Exception     
        {
            auth.inMemoryAuthentication().withUser("user1@example.com")
            .password("user1").roles("USER");
        }
        @Override
        protected void configure(final HttpSecurity http) throws Exception {
            http.authorizeRequests()
                    .antMatchers("/**").access("hasRole('USER')")
                    // equivalent to <http auto-config="true">
                    .and().formLogin()
                    .and().httpBasic()
                    .and().logout()
                    // CSRF is enabled by default (will discuss later)
                    .and().csrf().disable();
        }
    }

如果你使用的是 Spring Tool Suite,你可以通过按 F3 轻松查看 WebSecurityConfigurerAdapter。记住,下一个检查点(chapter02.01-calendar)有一个可行的解决方案,所以文件也可以从那里复制。

这是确保我们的 Web 应用程序使用最小标准配置安全所需的唯一 Spring Security 配置。这种使用 Spring Security 特定 Java 配置的配置方式被称为Java 配置

让我们花一分钟来分析这个配置,以便我们能了解发生了什么。在 configure(HttpSecurity) 方法中,HttpSecurity 对象创建了一个 Servlet 过滤器,该过滤器确保当前登录的用户与适当的角色关联。在这个实例中,过滤器将确保用户与 ROLE_USER 关联。重要的是要理解,角色的名称是任意的。稍后,我们将创建一个具有 ROLE_ADMIN 的用户,并允许此用户访问当前用户无法访问的额外 URL。

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

我们给出的例子和解释有些牵强。一个内存中的认证存储在生产环境中是行不通的。然而,它让我们能够快速启动。随着本书的进行,我们将逐步改进对 Spring Security 的理解,同时更新我们的应用程序以使用生产级别的安全配置。

从 Spring 3.1 开始,对 Java 配置 的通用支持已添加到 Spring 框架中。自从 Spring Security 3.2 发布以来,就有了 Spring Security Java 配置支持,这使用户能够不使用任何 XML 轻松配置 Spring Security。如果你熟悉第六章 LDAP 目录服务 和 Spring Security 文档,那么你应该会在它和 Security Java Configuration 支持之间找到很多相似之处。

更新你的 web.xml 文件

接下来的步骤涉及对 web.xml 文件进行一系列更新。有些步骤已经完成,因为应用程序已经使用 Spring MVC。然而,我们会回顾这些要求,以确保在您使用不支持 Spring 的应用程序中理解更基本的 Spring 要求。

ContextLoaderListener 类

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

    //src/main/java/c/p/s/web/configuration/WebAppInitializer

    public class WebAppInitializer extends   
    AbstractAnnotationConfigDispatcherServletInitializer {
        @Override
        protected Class<?>[] getRootConfigClasses() {
            return new Class[] { JavaConfig.class, SecurityConfig.class,    
            DataSourceConfig.class };
        }
        ...
    }

更新后的配置现在将从此 WAR 文件的类路径中加载SecurityConfig.class

ContextLoaderListener 与 DispatcherServlet 对比

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

    //src/main/java/c/p/s/web/configuration/WebAppInitializer

    public class WebAppInitializer extends     
    AbstractAnnotationConfigDispatcherServletInitializer {
        ...
        @Override
        protected Class<?>[] getServletConfigClasses() {
            return new Class[] { WebMvcConfig.class };
        }
        ...
        @Override
        public void onStartup(final ServletContext servletContext) throws  
        ServletException {
            // Registers DispatcherServlet
            super.onStartup(servletContext);
        }
    }

DispatcherServlet类创建了o.s.context.ApplicationContext,它是根ApplicationContext接口的子接口。通常,Spring MVC 特定组件是在DispatcherServletApplicationContext接口中初始化的,而其余的则是由ContextLoaderListener加载的。重要的是要知道,子ApplicationContext中的 Bean(如由DispatcherServlet创建的)可以引用父ApplicationContext中的 Bean(如由ContextLoaderListener创建的),但父ApplicationContext接口不能引用子ApplicationContext中的 Bean。

以下图表说明了子 Bean可以引用根 Bean,但根 Bean不能引用子 Bean

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

springSecurityFilterChain 过滤器

下一步是配置springSecurityFilterChain以拦截所有请求,通过创建AbstractSecurityWebApplicationInitializer的实现。确保springSecurityFilterChain首先声明至关重要,以确保在调用任何其他逻辑之前请求是安全的。为了确保springSecurityFilterChain首先加载,我们可以使用如下配置中的@Order(1)

    //src/main/java/c/p/s/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 过滤器,它将所有工作委派给ApplicationContext根目录下的一个 Spring bean,该 bean 必须实现javax.servlet.Filter。由于默认情况下是通过名称查找 bean,使用<filter-name>值,我们必须确保我们使用springSecurityFilterChain作为<filter-name>的值。我们可以在以下代码片段中找到o.s.web.filter.DelegatingFilterProxy类对于我们web.xml文件的工作伪代码:

    public class DelegatingFilterProxy implements Filter {
      void doFilter(request, response, filterChain) {
        Filter delegate = applicationContet.getBean("springSecurityFilterChain")
        delegate.doFilter(request,response,filterChain);
      }
    }

FilterChainProxy

当与 Spring Security 一起使用时,o.s.web.filter.DelegatingFilterProxy将委派给 Spring Security 的o.s.s.web.FilterChainProxy接口,该接口是在我们的最小security.xml文件中创建的。FilterChainProxy类允许 Spring Security 条件性地将任意数量的 Servlet 过滤器应用于 Servlet 请求。我们将在书的其余部分了解更多关于 Spring Security 过滤器的内容,以及它们在确保我们的应用程序得到适当保护方面的作用。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都是 Spring Security 的前门,当在 Web 应用程序中使用时,您会在尝试了解发生了什么时添加一个调试点。

运行受保护的应用程序

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

太棒了!我们使用 Spring Security 在应用程序中实现了一个基本的安全层。在此阶段,您应该能够使用user1@example.com作为用户和user1作为密码登录。您将看到日历欢迎页面,该页面从高层次描述了应用程序在安全性方面的预期。

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

常见问题

许多用户在将 Spring Security 首次实现到他们的应用程序时遇到了麻烦。下面列出了一些常见问题和建议。我们希望确保您能够运行示例应用程序并跟随教程!

  • 在将 Spring Security 放入应用程序之前,请确保您能够构建和部署应用程序。

  • 如有需要,请回顾一些关于您 Servlet 容器的入门示例和文档。

  • 通常使用 IDE(如 Eclipse)运行您的 Servlet 容器是最简单的。不仅部署通常是无缝的,控制台日志也易于查看以查找错误。您还可以在战略位置设置断点,以便在异常触发时更好地诊断错误。

  • 请确保您使用的 Spring 和 Spring Security 版本匹配,并且没有意外的 Spring JAR 作为您应用程序的一部分残留。如前所述,当使用 Gradle 时,最好在依赖管理部分声明 Spring 依赖项。

稍微加工一下

停在这个步骤,思考一下我们刚刚构建的内容。你可能已经注意到了一些明显的问题,这需要一些额外的工作和了解 Spring Security 产品知识,我们的应用程序才能准备好上线。尝试列出一个你认为在安全实现准备好公开面对网站之前需要做的更改清单。

应用 Hello World Spring Security 实现速度之快让人眼花缭乱,并为我们提供了登录页面、用户名和基于密码的认证,以及在我们日历应用程序中自动拦截 URL。然而,自动配置设置提供的与我们最终目标之间的差距如下所述:

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

  • 用户没有明显的方式登出。我们已经锁定了应用程序中的所有页面,包括欢迎页面,潜在的用户可能想以匿名方式浏览该页面。我们需要重新定义所需的角色以适应匿名、认证和行政用户。

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

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

        auth.inMemoryAuthentication().withUser("user1@example.com")
        .password("user1").roles("USER");
  • 你可以看到用户名和密码就在文件里。我们不太可能想要为系统中的每个用户在文件中添加一个新的声明!为了解决这个问题,我们需要用另一种认证方式更新配置。

我们将在本书的第一半中探索不同的认证选项。

登出配置

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

  1. 如下更新 Spring Security 配置:
        //src/main/java/com/packtpub/springsecurity/configuration/
        SecurityConfig.java

        http.authorizeRequests()
        ...
       .logout()
       .logoutUrl("/logout")
       .logoutSuccessUrl("/login?logout");
  1. 你必须为用户提供一个可以点击的链接以登出。我们将更新header.html文件,以便在每一页上出现Logout链接:
        //src/main/webapp/WEB-INF/templates/fragments/header.html

        <div id="navbar" ...>
         ...
           <ul class="nav navbar-nav pull-right">
             <li><a id="navLogoutLink" th:href="@{/logout}">
               Logout</a></li>
           </ul>
            ...
        </div>
  1. 最后一步是更新login.html文件,当logout参数存在时,显示一条表示登出成功的消息:
        //src/main/webapp/WEB-INF/templates/login.html

        <div th:if="${param.logout != null}" class="alert 
        alert-success"> You have been logged out.</div>
          <label for="username">Username</label>
          ...

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

页面没有正确重定向。

如果你还没有这么做,重启应用程序并在 Firefox 中访问http://localhost:8080;你会看到一个错误,如下面的屏幕截图所示:

哪里出了问题?问题在于,由于 Spring Security 不再渲染登录页面,我们必须允许所有人(而不仅仅是USER角色)访问登录页面。如果不允许访问登录页面,会发生以下情况:

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

  2. Spring Security 发现欢迎页面需要USER角色,而我们尚未认证,因此它将浏览器重定向到登录页面。

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

  4. Spring Security 发现登录页面需要USER角色,而我们还没有认证,所以它将浏览器重定向到登录页面。

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

  6. Spring Security 发现登录页面需要USER角色,如图所示:

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

基于表达式的授权。

你可能已经注意到,允许所有人访问远不如我们期望的简洁。幸运的是,Spring Security 可以利用Spring 表达式语言SpEL)来确定用户是否有授权。在下面的代码片段中,你可以看到使用 SpEL 与 Spring Security 时的更新:

    //src/main/java/com/packtpub/springsecurity/configuration/
    SecurityConfig.java

    http.authorizeRequests()
        .antMatchers("/").access("hasAnyRole('ANONYMOUS', 'USER')")
        .antMatchers("/login/*").access("hasAnyRole('ANONYMOUS', 'USER')")
        .antMatchers("/logout/*").access("hasAnyRole('ANONYMOUS', 'USER')")
        .antMatchers("/admin/*").access("hasRole('ADMIN')")
        .antMatchers("/events/").access("hasRole('ADMIN')")
        .antMatchers("/**").access("hasRole('USER')")

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

access属性从hasAnyRole('ANONYMOUS', 'USER')更改为permitAll()可能看起来并不重要,但这只是 Spring Security 表达式强大功能的冰山一角。我们将在书的第二部分更详细地讨论访问控制和 Spring 表达式。运行应用程序以验证更新是否有效。

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

有条件地显示认证信息。

目前,我们的应用程序没有关于我们是否登录的任何指示。事实上,它看起来好像我们总是登录,因为Logout链接总是显示。在本节中,我们将演示如何使用 Thymeleaf 的 Spring Security 标签库显示认证用户的用户名,并根据条件显示页面的部分内容。我们通过执行以下步骤来实现:

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

           dependency{
              ...
              compile 'org.thymeleaf.extras:thymeleaf-
              extras-springsecurity4'
         }
  1. 接下来,我们需要如下向 Thymeleaf 引擎添加 SpringSecurityDialect
        //src/com/packtpub/springsecurity/web/configuration/
        ThymeleafConfig.java

            @Bean
            public SpringTemplateEngine templateEngine(
             final ServletContextTemplateResolver resolver)   
            {
                SpringTemplateEngine engine = new SpringTemplateEngine();
               engine.setTemplateResolver(resolver);
 engine.setAdditionalDialects(new HashSet<IDialect>() {{ add(new LayoutDialect()); add(new SpringSecurityDialect()); }});                return engine;
            }
  1. 更新 header.html 文件以利用 Spring Security 标签库。你可以按照如下方式找到更新:
        //src/main/webapp/WEB-INF/templates/fragments/header.html

            <html xmlns:th="http://www.thymeleaf.org" 
 xmlns:sec="http://www.thymeleaf.org/thymeleaf- 
            extras-springsecurity4">
            ...
        <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>
            ...

sec:authorize 属性确定用户是否以 isAuthenticated() 值认证,并在用户认证时显示 HTML 节点,如果用户没有认证,则隐藏节点。access 属性应该非常熟悉,来自 antMatcher().access() 元素。实际上,这两个组件都利用了相同的 SpEL 支持。Thymeleaf 标签库中有不使用表达式的属性。然而,使用 SpEL 通常是更受欢迎的方法,因为它更强大。

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

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

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

登录后的行为自定义。

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

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

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

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

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

  1. 第一步是配置defaultSuccessUrl()方法,它在formLogin()方法之后链式调用。大胆地更新security.xml文件,使用/default而不是上下文根:
        //src/main/java/com/packtpub/springsecurity/configuration/
        SecurityConfig.java

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

            // imports omitted
            @Controller 
            public class DefaultController {
           @RequestMapping("/default") 
             public String defaultAfterLogin(HttpServletRequest request) { 
                 if (request.isUserInRole("ADMIN")) { 
                     return "redirect:/events/"; 
                 } 
                 return "redirect:/"; 
             }
        }

在 Spring Tool Suite 中,你可以使用Shift + Ctrl + O 来自动添加缺少的导入。

关于DefaultController及其工作方式有一点需要注意。首先是 Spring Security 使HttpServletRequest参数意识到当前登录的用户。在这个实例中,我们能够不依赖 Spring Security 的任何 API 来检查用户属于哪个角色。这是好的,因为如果 Spring Security 的 API 发生变化,或者我们决定要切换我们的安全实现,我们需要更新的代码就会更少。还应注意的是,尽管我们用 Spring MVC 控制器实现这个控制器,但我们的defaultSuccessUrl()方法如果需要,可以由任何控制器实现(例如,Struts,一个标准的 servlet 等)处理。

  1. 如果你希望总是去到defaultSuccessUrl()方法,你可以利用defaultSuccessUrl()方法的第二个参数,这是一个Boolean用于始终使用。我们不会在我们的配置中这样做,但你可以如下看到一个例子:
        .defaultSuccessUrl("/default", true)
  1. 你现在可以尝试一下了。重新启动应用程序并直接转到我的事件页面,然后登录;你会发现你在我的事件页面。

  2. 然后,退出并尝试以user1@example.com的身份登录。

  3. 你应该在欢迎页面。退出并以admin1@example.com的身份登录,然后你会被

    被发送到所有事件页面。

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

总结

在本章中,我们已经应用了非常基础的 Spring Security 配置,解释了如何自定义用户的登录和登出体验,并演示了如何在我们的网络应用程序中显示基本信息,例如用户名。

在下一章中,我们将讨论 Spring Security 中的认证是如何工作的,以及我们如何可以根据自己的需求来定制它。

第三章:自定义认证

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

在本章中,我们将介绍以下主题:

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

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

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

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

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

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

JBCP 日历架构

在附录中,附加参考资料

由于本章是关于将 Spring Security 与自定义用户和 API 集成的,我们将从对 JBCP 日历应用程序中的域模型的快速介绍开始。

日历用户对象

我们的日历应用程序使用一个名为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对象,其中包含有关每个事件的详细信息,如下所示:

    //src/main/java/com/packtpub/springsecurity/domain/Event.java

    public class Event {
       private Integer id;
       private String summary;
       private String description;
       private Calendar when;
       private CalendarUser owner;
       private CalendarUser attendee;
       ... accessor methods omitted ..
    }

日历服务接口

我们的应用程序包含一个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,用于管理当前登录的用户,如下所示:

    //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是一个总是返回相同用户的存根。这意味着无论谁登录,My Events 页面都会显示相同的用户。让我们更新我们的应用程序,利用当前 Spring Security 用户的用户名,来确定在 My Events 页面上显示哪些事件。

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

请按照以下步骤操作:

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

@Component注解与在com/packtpub/springsecurity/web/configuration/WebMvcConfig.java中找到的@ComponentScan注解一起使用,用于自动创建 Spring bean,而不是为每个 bean 创建显式的 XML 或 Java 配置。您可以在static.springsource.org/spring/docs/current/spring-framework-reference/html/中了解更多关于 Spring 扫描类路径的信息。

请查看以下代码片段:

        //src/main/java/com/packtpub/springsecurity/service/UserContextStub.java

        ...
        //@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;
        @Autowired
        public SpringSecurityUserContext(CalendarService calendarService, 
        UserDetailsService userDetailsService) {
           this.calendarService = calendarService;
           this.userDetailsService = userDetailsService;
        }
        public CalendarUser getCurrentUser() {
           SecurityContext context = SecurityContextHolder.getContext();
           Authentication authentication = context.getAuthentication();
           if (authentication == null) {
             return null;
           }
           String email = authentication.getName();
           return calendarService.findUserByEmail(email);
        }
        public void setCurrentUser(CalendarUser user) {
           throw new UnsupportedOperationException();
        }
        }

我们的代码从当前 Spring Security 的Authentication对象中获取用户名,并利用该用户名通过电子邮件地址查找当前的CalendarUser对象。由于我们的 Spring Security 用户名是一个电子邮件地址,因此我们能够使用电子邮件地址将CalendarUser与 Spring Security 用户关联起来。请注意,如果我们打算关联账户,通常我们希望能够用我们生成的键来做这件事,而不是可能改变的东西(也就是说,电子邮件地址)。我们遵循只向应用程序返回我们的域对象的良好实践。这确保了我们的应用程序只认识我们的CalendarUser对象,从而与 Spring Security 解耦。

这段代码可能看起来与我们使用sec:authorize="isAuthenticated()"时出奇地相似。

在第二章Spring Security 入门中使用的tag属性,来显示当前用户的用户名。实际上,Spring Security 标签库正是像我们在这里一样使用SecurityContextHolder。我们本可以使用我们的UserContext接口将当前用户放在HttpServletRequest上,从而摆脱对 Spring Security 标签库的依赖。

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

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

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

  4. 退出应用程序,然后用user1@example.com作为用户名和user1作为密码重复这些步骤。

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

使用 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");

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

当从 XML 配置转换为基于 Java 的配置时,Spring Security 中存在一个限制,即 Spring Security DSL 目前不支持暴露多个 bean。关于这个问题已经打开了一个 JIRA,链接为jira.spring.io/browse/SPR-13779.

让我们看看如何通过执行以下步骤来管理 Spring Security 中的用户:

  1. 为了通过基于 Java 的配置暴露UserDetailsManager,我们需要在WebSecurityConfigurerAdapter DSL 之外创建InMemoryUserDetailsManager
        //src/main/java/com/packtpub/springsecurity/configuration/
        SecurityConfig.java

        @Bean
        @Override
        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;
        }
  1. 一旦我们在 Spring 配置中暴露了UserDetailsManager接口,我们所需要做的就是更新我们现有的CalendarService实现,DefaultCalendarService,以在 Spring Security 中添加用户。对DefaultCalendarService.java文件进行以下更新:
        //src/main/java/com/packtpub/springsecurity/service/
        DefaultCalendarService.java

        public int createUser(CalendarUser user) {
            List<GrantedAuthority> authorities = AuthorityUtils.
            createAuthorityList("ROLE_USER");
            UserDetails userDetails = new User(user.getEmail(),
            user.getPassword(), authorities);
           // create a Spring Security user
           userDetailsManager.createUser(userDetails);
           // create a CalendarUser
           return userDao.createUser(user);
        }
  1. 为了利用UserDetailsManager,我们首先将CalendarUser转换为 Spring Security 的UserDetails对象。

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

登录新用户到应用程序

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

    //src/main/java/com/packtpub/springsecurity/service/
    SpringSecurityUserContext.java

    public void setCurrentUser(CalendarUser user) {
      UserDetails userDetails = userDetailsService.
      loadUserByUsername(user.getEmail());
      Authentication 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接口提供了UserDetailsManager对象的功能的一个子集,通过用户名查找。

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

重要的是,Spring Security 不能被指示忽略一个 URL(即使用permitAll()方法),正如在第二章《开始使用 Spring Security》中讨论的那样,其中访问或设置了SecurityContextHolder。这是因为 Spring Security 将忽略该请求,因此不会为后续请求持久化SecurityContext。允许访问使用SecurityContextHolder的 URL 的正确方法是指定antMatchers()方法的access属性(即antMatchers(¦).permitAll())。

值得一提的是,我们本可以直接通过创建一个新的o.s.s.core.userdetails.User对象来转换CalendarUser,而不是在UserDetailsService中查找。例如,下面的代码也可以认证用户:

List<GrantedAuthority> authorities =
AuthorityUtils.createAuthorityList("ROLE_USER");
UserDetails userDetails = new User("username","password",authorities); Authentication authentication = new UsernamePasswordAuthenticationToken ( userDetails,userDetails.getPassword(),userDetails.getAuthorities());
SecurityContextHolder.getContext()
.setAuthentication(authentication);

这种方法的优点在于,我们无需再次访问数据存储。在我们这个案例中,数据存储是一个内存中的数据存储,但这也可能是由一个数据库支持的,这可能会带来一些安全风险。这种方法的一个缺点是我们无法复用代码太多。由于这种方法调用不频繁,我们选择复用代码。通常,最佳做法是单独评估每种情况,以确定哪种方法最合适。

更新 SignupController

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

//src/main/java/com/packtpub/springsecurity/web/controllers/
SignupController.java

@RequestMapping(value="/signup/new", method=RequestMethod.POST)
public String signup(@Valid SignupForm signupForm,
BindingResult result, RedirectAttributes redirectAttributes) {
... existing validation ¦
user.setPassword(signupForm.getPassword());
int id = calendarService.createUser(user);
user.setId(id);
userContext.setCurrentUser(user);
redirectAttributes.addFlashAttribute("message", "Success");
return "redirect:/";
}

如果你还没有这么做,请重新启动应用程序,访问http://localhost:8080/,创建一个新的用户,并查看新用户是否自动登录。

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

创建自定义 UserDetailsService 对象

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

日历用户详细信息服务类

到目前为止,我们需要两种不同的用户表示:一种用于 Spring Security 做出安全决策,另一种用于我们的应用程序将我们的领域对象关联起来。创建一个名为CalendarUserDetailsService的新类,使 Spring Security 意识到我们的CalendarUser对象。这将确保 Spring Security 可以根据我们的领域模型做出决策。按照如下方式创建一个名为CalendarUserDetailsService.java的新文件:

//src/main/java/com/packtpub/springsecurity/core/userdetails/
CalendarUserDetailsService.java

// imports and package declaration omitted

@Component
public class CalendarUserDetailsService implements
UserDetailsService {
private final CalendarUserDao calendarUserDao;
@Autowired
public CalendarUserDetailsService(CalendarUserDao
   calendarUserDao) {
   this.calendarUserDao = calendarUserDao;
}
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);
}
}

在 Spring Tool Suite 中,您可以使用Shift+Ctrl+O快捷键轻松添加缺少的导入。另外,您还可以从下一个检查点(chapter03.03-calendar)复制代码。

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

然后我们将CalendarUser转换为由用户实现的UserDetails

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

配置 UserDetailsService

现在我们已经有一个新的UserDetailsService对象,让我们更新 Spring Security 配置以使用它。由于我们利用类路径扫描和@Component注解,我们的CalendarUserDetailsService类自动添加到 Spring 配置中。这意味着我们只需要更新 Spring Security 以引用我们刚刚创建的CalendarUserDetailsService类。我们还可以删除configure()userDetailsService()方法,因为我们现在提供了自己的UserDetailsService实现。按照如下方式更新SecurityConfig.java文件:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java

@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
    ...
}
@Bean
@Override
public UserDetailsManager userDetailsService() {
    ...
}

删除对 UserDetailsManager 的引用

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

//src/main/java/com/packtpub/springsecurity/service/
DefaultCalendarService.java

public class DefaultCalendarService implements CalendarService {
   private final EventDao eventDao;
   private final CalendarUserDao userDao;
   @Autowired
   public DefaultCalendarService(EventDao eventDao,CalendarUserDao userDao) {
       this.eventDao = eventDao;
       this.userDao = userDao;
   }
   ...
   public int createUser(CalendarUser user) {
       return userDao.createUser(user);
   }
}

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

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

日历用户详细信息对象

我们已经成功消除了同时管理 Spring Security 用户和我们自己的CalendarUser对象的需求。然而,我们仍然需要不断在两者之间进行转换,这很麻烦。相反,我们将创建一个CalendarUserDetails对象,该对象可以被称为UserDetailsCalendarUser。使用以下代码更新CalendarUserDetailsService

//src/main/java/com/packtpub/springsecurity/core/userdetails/
CalendarUserDetailsService.java

public UserDetails loadUserByUsername(String username) throws
UsernameNotFoundException {
...
return new CalendarUserDetails(user);
}
private final class CalendarUserDetails extends CalendarUser 
implements UserDetails {
CalendarUserDetails(CalendarUser user) {
   setId(user.getId());
   setEmail(user.getEmail());
   setFirstName(user.getFirstName());
   setLastName(user.getLastName());
   setPassword(user.getPassword());
}
public Collection<? extends GrantedAuthority>
   getAuthorities() {
   return CalendarUserAuthorityUtils.createAuthorities(this);
}
public String getUsername() {
   return getEmail();
}
public boolean isAccountNonExpired() { return true; }
public boolean isAccountNonLocked() { return true; }
public boolean isCredentialsNonExpired() { return true; }
public boolean isEnabled() { return true; }
}

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

简化SpringSecurityUserContext

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

public class SpringSecurityUserContext implements UserContext {
public CalendarUser getCurrentUser() {
   SecurityContext context = SecurityContextHolder.getContext();
   Authentication authentication = context.getAuthentication();
   if(authentication == null) {
      return null;
   }
   return (CalendarUser) authentication.getPrincipal();
}

public void setCurrentUser(CalendarUser user) {
   Collection authorities =
     CalendarUserAuthorityUtils.createAuthorities(user);
   Authentication 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.html文件:

    //src/main/resources/templates/fragments/header.html

    <ul class="nav navbar-nav pull-right" 
 sec:authorize="isAuthenticated()">
       <li id="greeting">
           <p class="navbar-text">Welcome <div class="navbar-text"   
           th:text="${#authentication.getPrincipal().getName()}">
           User</div></p>
       </li>

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

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

重启应用程序,访问http://localhost:8080/,登录以查看更新。 Instead of seeing the current user's email, you should now see their first and last names.(您现在应该看到的是当前用户的姓名,而不是电子邮件地址。)

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

创建一个自定义的AuthenticationProvider对象

Spring Security 委托一个AuthenticationProvider对象来确定用户是否已认证。这意味着我们可以编写自定义的AuthenticationProvider实现来告知 Spring Security 如何以不同方式进行认证。好消息是 Spring Security 提供了一些AuthenticationProvider对象,所以大多数时候你不需要创建一个。事实上,到目前为止,我们一直在使用 Spring Security 的o.s.s.authentication.dao.DaoAuthenticationProvider对象,它比较UserDetailsService返回的用户名和密码。

日历用户认证提供者

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

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

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

    //src/main/java/com/packtpub/springsecurity/authentication/
    CalendarUserAuthenticationProvider.java

    // ¦ imports omitted ...

    @Component
    public class CalendarUserAuthenticationProvider implements
    AuthenticationProvider {
    private final CalendarService calendarService;
    @Autowired
    public CalendarUserAuthenticationProvider
    (CalendarService    calendarService) {
       this.calendarService = calendarService;
    }
    public Authentication authenticate(Authentication
       authentication) throws AuthenticationException {
           UsernamePasswordAuthenticationToken token =   
           (UsernamePasswordAuthenticationToken) 
       authentication;
       String email = token.getName();
       CalendarUser user = null;
       if(email != null) {
         user = 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);
    }
    public boolean supports(Class<?> authentication) {
       return UsernamePasswordAuthenticationToken
         .class.equals(authentication);
     }
    }

记得在 Eclipse 中你可以使用Shift+Ctrl+O快捷键轻松添加缺失的导入。另外,你也可以从chapter03.05-calendar中复制实现。

在 Spring Security 可以调用authenticate方法之前,supports方法必须对将要传递进去的Authentication类返回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

 @Autowired CalendarUserAuthenticationProvider cuap;        @Override
        public void configure(AuthenticationManagerBuilder auth) 
        throws Exception {
           auth.authenticationProvider(cuap);
        }
  1. 重启应用程序并确保一切仍然正常工作。作为用户,我们并没有察觉到任何不同。然而,作为开发者,我们知道CalendarUserDetails已经不再需要;我们仍然能够显示当前用户的姓名和姓氏,Spring Security 仍然能够利用CalendarUser进行认证。

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

使用不同参数进行认证

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

域名是一种定义用户范围的方式。例如,如果我们一次性部署了一个应用但多个客户都在使用这个部署,每个客户可能都需要一个名为admin的用户。通过在用户对象中添加一个域名,我们可以确保每个用户都是独一无二的,同时还能满足这一需求。

DomainUsernamePasswordAuthenticationToken

当用户进行认证时,Spring Security 会将一个Authentication对象提交给AuthenticationProvider,其中包含用户提供的信息。当前的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以使用域名字段,如下所示:
        //src/main/java/com/packtpub/springsecurity/authentication/
        CalendarUserAuthenticationProvider.java

        public Authentication authenticate(Authentication authentication) 
        throws AuthenticationException {
             DomainUsernamePasswordAuthenticationToken token =
             (DomainUsernamePasswordAuthenticationToken) authentication;
        String userName = token.getName();
        String domain = token.getDomain();
        String email = userName + "@" + domain;
        ... previous validation of the user and password ...
        return new DomainUsernamePasswordAuthenticationToken(user,
        password, domain, authorities);
        }
        public boolean supports(Class<?> authentication) {
          return DomainUsernamePasswordAuthenticationToken
          .class.equals(authentication);
        }
  1. 我们首先更新supports方法,以便 Spring Security 会将DomainUsernamePasswordAuthenticationToken传递到我们的authenticate方法中。

  2. 然后我们利用域名信息来创建我们的电子邮件地址和进行认证,就像我们之前所做的那样。坦白说,这个例子有些牵强。然而,这个例子能够说明如何使用一个附加参数进行认证。

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

在登录页面上添加域

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

    //src/main/resources/templates/login.html

    ...
    <label for="username">Username</label>
    <input type="text" id="username" name="username"/>
    <label for="password">Password</label>
    <input type="password" id="password" name="password"/>
    <label for="domain">Domain</label>
    <input type="text" id="domain" name="domain"/>
    ¦

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

DomainUsernamePasswordAuthenticationFilter

Spring Security 提供了一系列作为用户认证控制器的 servlet 过滤器。这些过滤器作为FilterChainProxy对象的代理之一,我们在第二章中讨论过,Spring Security 入门。以前,formLogin()方法指导 Spring Security 使用o.s.s.web.authentication.UsernamePasswordAuthenticationFilter作为登录控制器。过滤器的工作是执行以下任务:

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

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

  • 请求 Spring Security 验证UsernamePasswordAuthenticationToken

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

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

        //src/main/java/com/packtpub/springsecurity/web/authentication/
        DomainUsernamePasswordAuthenticationFilter.java

        public final class
        DomainUsernamePasswordAuthenticationFilter extends 
         UsernamePasswordAuthenticationFilter {
        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");
           // authRequest.isAuthenticated() = false since no
           //authorities are specified
           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

@Override
protected void configure(final HttpSecurity http) throws Exception {
   http.authorizeRequests()
       ...
       .and().exceptionHandling()
           .accessDeniedPage("/errors/403")
           .authenticationEntryPoint(
               loginUrlAuthenticationEntryPoint())
       .and().formLogin()
           .loginPage("/login/form")
           .loginProcessingUrl("/login")
           .failureUrl("/login/form?error")
           .usernameParameter("username")
           .passwordParameter("password")
           .defaultSuccessUrl("/default", true)
           .permitAll()
         ...
          // Add custom UsernamePasswordAuthenticationFilter
 .addFilterAt( domainUsernamePasswordAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) ; }
@Bean public DomainUsernamePasswordAuthenticationFilter domainUsernamePasswordAuthenticationFilter()
 throws Exception {   DomainUsernamePasswordAuthenticationFilter dupaf = new DomainUsernamePasswordAuthenticationFilter(
                            super.authenticationManagerBean());
   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 LoginUrlAuthenticationEntryPoint loginUrlAuthenticationEntryPoint(){
   return new LoginUrlAuthenticationEntryPoint("/login/form");
}

前一个代码段配置了我们在 Spring Security 配置中的标准 bean。我们展示这个是为了表明它是可以做到的。然而,在本书的其余部分,我们将标准 bean 配置放在自己的文件中,因为这样可以减少配置的冗余。如果你遇到困难,或者不喜欢输入所有这些内容,你可以从 chapter03.06-calendar 复制它。

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

  • 我们覆盖了 defaultAuthenticationEntryPoint,并添加了对 o.s.s.web.authentication.LoginUrlAuthenticationEntryPoint 的引用,它决定了当请求受保护的资源且用户未认证时会发生什么。在我们这个案例中,我们被重定向到了一个登录页面。

  • 我们移除了 formLogin() 方法,并使用 .addFilterAt() 方法将我们的自定义过滤器插入到 FilterChainProxy 中。这个位置表示 FilterChain 代理的委托考虑的顺序,且不能与另一个过滤器重叠,但可以替换当前位置的过滤器。我们用自定义过滤器替换了 UsernamePasswordAuthenticationFilter

  • 我们为我们的自定义过滤器添加了配置,该配置引用了由 configure(AuthenticationManagerBuilder) 方法创建的认证管理器。

以下图表供您参考:

现在你可以重新启动应用程序,并尝试以下步骤,如前所示的图表,来理解所有部分是如何组合在一起的:

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

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

  3. LoginUrlAuthenticationEntryPoint 对象将会把用户重定向到登录页面。输入用户名 admin1,域名 example.com,以及密码 admin1

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

  5. DomainUserPasswordAuthenticationFilter 对象提交 DomainUsernamePasswordAuthenticationTokenCalendarUserAuthenticationProvider

  6. CalendarUserAuthenticationProvider 接口验证 DomainUsernamePasswordAuthenticationToken,然后返回一个认证的 DomainUsernamePasswordAuthenticationToken 对象(也就是说,isAuthenticated() 返回 true)。

  7. DomainUserPasswordAuthenticationFilter 对象用 DomainUsernamePasswordAuthenticationToken 更新 SecurityContext,并将其放在 SecurityContextHolder 上。

你的代码应该看起来像 chapter03.06-calendar

应该使用哪种认证方式?

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

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

  • UserDetailsService:创建一个自定义的UserDetailsService对象是一个简单的机制,它允许 Spring Security 根据我们自定义的领域模型做出安全决策。它还提供了一种机制,以便与其他 Spring Security 特性进行钩接。例如,Spring Security 在使用第七章记住我服务中介绍的内置记住我支持时需要UserDetailsService。当认证不是基于用户名和密码时,UserDetailsService对象不起作用。

  • AuthenticationProvider:这是扩展 Spring Security 最灵活的方法。它允许用户使用任何我们希望的参数进行认证。然而,如果我们希望利用如 Spring Security 的记住我等特性,我们仍然需要UserDetailsService

总结

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

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

在下一章中,我们将探讨一些基于 JDBC 的认证的内置支持。

第四章: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 认证的基本步骤。

所需的依赖项

我们的应用程序已经定义了本章所需的所有必要依赖项。然而,如果你正在使用 Spring Security 的 JDBC 支持,你可能会希望在你的build.gradle文件中列出以下依赖项。重要的是要强调,你将使用的 JDBC 驱动将取决于你正在使用的哪个数据库。请查阅你的数据库供应商的文档,了解需要为你的数据库安装哪个驱动。

请记住,所有的 Spring 版本需要一致,所有的 Spring Security 版本也需要一致(这包括传递依赖版本)。如果你在自己的应用程序中遇到难以解决的问题,你可以在build.gradle中定义依赖管理部分来强制执行这一点,如第二章,《使用 Spring Security 入门》所示。如前所述,使用示例代码时,你不需要担心这个问题,因为我们已经为你设置了必要的依赖项。

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

    //build.gradle

    dependencies {
    ...
    // Database:
 compile('org.springframework.boot:spring-boot-starter-jdbc') compile('com.h2database:h2')    // Security:
 compile('org.springframework.boot:spring-boot-starter-security') testCompile('org.springframework.security:spring-security-test')       ....
    }

使用 H2 数据库

这个练习的第一部分涉及设置一个基于 Java 的 H2 关系数据库实例,其中包含 Spring Security 的默认模式。我们将配置 H2 在内存中运行,使用 Spring 的EmbeddedDatabase配置特性——一种比

手动设置数据库。你可以在 H2 网站上的www.h2database.com/找到更多信息。

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

提供的 JDBC 脚本

我们已经在src/main/resources/database/h2/目录下提供了所有用于在 H2 数据库中创建模式和数据的 SQL 文件。所有以security为前缀的文件是为了支持 Spring Security 的默认 JDBC 实现。所有以calendar为前缀的 SQL 文件是 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文件,声明我们使用 JDBCUserDetailsManager实现,而不是我们在第二章,开始使用 Spring Security中配置的 Spring Security 内存中的UserDetailsService实现。这是通过简单地更改UserDetailsManager声明来完成的,如下所示:

    //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java

    ¦
    @Bean
    @Override
    public UserDetailsManager userDetailsService() {
 JdbcUserDetailsManager manager = new JdbcUserDetailsManager(); manager.setDataSource(dataSource); return manager;    }
    ¦

我们将替换之前的configure(AuthenticationManagerBuilder)方法及其所有子元素,使用如前一个代码片段所示的userDetailsService()方法。

默认的 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','user1',1);
    insert into users (username,password,enabled)
       values ('admin1@example.com','admin1',1);
    insert into users (username,password,enabled)
       values ('user2@example.com','admin1',1);
    insert into users (username,password,enabled)
       values ('disabled1@example.com','disabled1',0);

定义用户权限

您可能已经注意到没有指示用户是管理员还是普通用户。下一个文件指定了用户与相应权限的直接映射。如果一个用户没有映射到权限,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 不允许用户登录并提供错误消息Reason: User is disabled

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

UserDetailsManager接口

我们在第三章,自定义认证中已经利用了 Spring Security 中的InMemoryUserDetailsManager类,在SpringSecurityUserContext实现的UserContext中查找当前的CalendarUser应用程序。这使我们能够确定在查找 My Events 页面的活动时应使用哪个CalendarUser。 第三章,自定义认证还演示了如何更新DefaultCalendarService.java文件以利用InMemoryUserDetailsManager,以确保我们创建CalendarUser时创建了一个新的 Spring Security 用户。本章正好重用了相同的代码。唯一的区别是UserDetailsManager实现由 Spring Security 的JdbcUserDetailsManager类支持,该类使用数据库而不是内存数据存储。

UserDetailsManager还提供了哪些其他功能?

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

方法 描述
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) 它更改当前登录用户的密码。为了使操作成功,用户必须提供正确的密码。

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

基于组的访问控制

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

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

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

这种将常见权限集打包的方法在以下场景中很有帮助:

  • 您需要将用户划分为具有组之间一些重叠角色的社区。

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

  • 您有很多用户,而且不需要对用户级别的授权进行配置。

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

GBAC 是市场上几乎所有受保护的操作系统或软件包中常见的做法。微软 活动目录AD)是基于大规模 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.java

    private static String 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";    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
 auth .jdbcAuthentication() .dataSource(dataSource) .groupAuthoritiesByUsername( GROUP_AUTHORITIES_BY_USERNAME_QUERY );    }

使用 GBAC JDBC 脚本

接下来,我们需要更新在启动时加载的脚本。我们需要删除security-user-authorities.sql映射,以便用户不再通过直接映射来获取他们的权限。然后我们需要添加两个额外的 SQL 脚本。更新DataSourcebean 配置以加载 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(256) 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(256) 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';
    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';
    ...

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

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

支持自定义模式

新用户在开始使用 Spring Security 时,通常会通过将 JDBC 用户、组或角色映射适应现有的模式。即使遗留数据库不符合 Spring Security 预期的模式,我们仍然可以配置JdbcDaoImpl以与之对应。

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

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

确定正确的 JDBC SQL 查询

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

**命名空间查询属性名称** **描述** **预期的 SQL 列**
users-by-username-query 返回与用户名匹配的一个或多个用户;只使用第一个用户。 Username (string)Password (string)Enabled (Boolean)
authorities-by-username-query 直接向用户返回一个或多个授予的权限。通常在禁用 GBAC 时使用。 Username (string)GrantedAuthority (string)
group-authorities-by-username-query 返回通过组成员身份提供给用户的授予权限和组详细信息。当启用 GBAC 时使用。 Group Primary Key (任何)Group Name (任何)GrantedAuthority (字符串)

请注意,在某些情况下,返回的列没有被默认的JdbcUserDetailsManager实现使用,但它们无论如何都必须返回。

更新加载的 SQL 脚本

我们需要初始化具有自定义架构的DataSource,而不是使用 Spring Security 的默认架构。按照以下方式更新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();
    }

请注意,我们已经移除了所有以security开头的脚本,并将它们替换为calendar-authorities.sql

日历用户权限 SQL

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

    //src/main/resources/database/h2/calendar-authorities.sql

    create table calendar_user_authorities (
       id bigint identity,
       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

    import org.springframework.jdbc.core.JdbcOperations;
    ...
    public class DefaultCalendarService implements CalendarService {
       ...
       private final JdbcOperations jdbcOperations;
       @Autowired
          public DefaultCalendarService(EventDao eventDao, 
          CalendarUserDao userDao, JdbcOperations jdbcOperations) {
           ...
           this.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.java

 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";    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth
       .jdbcAuthentication()
       .dataSource(dataSource)
 .usersByUsernameQuery(USERS_BY_USERNAME_QUERY) .authoritiesByUsernameQuery( AUTHORITIES_BY_USERNAME_QUERY );    }

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

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

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

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

配置安全密码。

你可能会记得,在第一章,《不安全应用程序的剖析》,的安全审计中,存储在明文中的密码的安全是审计员的首要任务。实际上,在任何一个安全系统中,密码的安全是验证主体信任和权威性的关键方面。一个完全安全的系统的设计者必须确保密码以恶意用户几乎不可能妥协的方式存储。

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

  • 密码不应当以明文(纯文本)形式存储。

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

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

对于大多数应用程序来说,最适合这些要求的是单向编码,也就是密码的哈希。使用密码学哈希可以提供诸如安全和唯一性等重要特性,这对于正确验证用户非常重要,而且一旦哈希,密码就不能从存储的值中提取。

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

存储其他类型的敏感信息

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

您可能已经在思考,鉴于我们使用 SQL 来为 H2 数据库填充用户这一显然不切实际的方法,我们是如何编码密码的?H2 数据库,或者大多数其他数据库,并没有将加密方法作为内置数据库函数提供。

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

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

密码编码器(PasswordEncoder)方法

Spring Security 中的密码散列是由o.s.s.authentication.encoding.PasswordEncoder接口的实现定义的。通过AuthenticationManagerBuilder元素中的passwordEncoder()方法配置密码编码器是简单的,如下所示:

    auth
       .jdbcAuthentication()
       .dataSource(dataSource)
       .usersByUsernameQuery(CUSTOM_USERS_BY_USERNAME_QUERY)
       .authoritiesByUsernameQuery(CUSTOM_AUTHORITIES_BY_USERNAME_QUERY)
 .passwordEncoder(passwordEncoder());

您会高兴地了解到,Spring Security 随带有一系列passwordEncoder的实现,适用于不同的需求和安全要求。

下面的表格提供了一系列内置实现类及其优点。请注意,所有实现都位于o.s.s.authentication.encoding包中:

实现类 描述 哈希值
PlaintextPasswordEncoder 它将密码编码为明文;这是默认选项。 &lt;p>plaintext
Md4PasswordEncoderPasswordEncoder 这个编码器使用MD4散列算法。MD4散列算法不是一个安全的算法——不建议使用这个编码器。 md4
Md5PasswordEncoderPassword 这个编码器使用MD5单向编码算法。
ShaPasswordEncoderPasswordEncoder 这个编码器使用SHA单向编码算法。此编码器可以支持可配置的编码强度级别。 sha``sha-256
LdapShaPasswordEncoder 在与 LDAP 身份验证存储集成时使用的LdapShaLdapSsha算法的实现。我们将在第六章,LDAP 目录服务中了解更多关于这个算法,届时我们将覆盖 LDAP。 {sha}``{ssha}

与其他 Spring Security 领域的许多方面一样,也可以通过实现PasswordEncoder来引用 bean 定义,以提供更精确的配置,并允许PasswordEncoder通过依赖注入与其他 bean 连接。对于 JBCP 日历应用程序,我们需要使用这种 bean 引用方法来哈希新创建用户的密码。

让我们通过以下步骤了解为 JBCP 日历应用程序配置基本密码编码的过程。

配置密码编码

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

配置 PasswordEncoder 方法

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

    //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java

    @Bean
    public ShaPasswordEncoder passwordEncoder(){
       return new ShaPasswordEncoder(256);
    }

您会注意到我们使用的是SHA-256 PasswordEncoder实现。这是一个高效的单向加密算法,通常用于密码存储。

使 Spring Security 了解 PasswordEncoder 方法

我们需要配置 Spring Security 以引用PasswordEncoder,这样它可以在用户登录时对呈现的密码进行编码和比较。只需添加一个passwordEncoder方法,并参考我们在上一步定义的 bean ID:

    //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java

    @Override
    public void configure(AuthenticationManagerBuilder auth) 
    throws Exception {
    auth
       .jdbcAuthentication()
       .dataSource(dataSource)
       .usersByUsernameQuery(CUSTOM_USERS_BY_USERNAME_QUERY)
       .authoritiesByUsernameQuery(
           CUSTOM_AUTHORITIES_BY_USERNAME_QUERY)
 .passwordEncoder(passwordEncoder())     ;
    }

如果您在此时尝试应用程序,您会发现之前有效的登录凭据现在被拒绝。这是因为存储在数据库中的密码(使用calendar-users.sql脚本加载)不是以与密码编码器匹配的hash形式存储。我们需要将存储的密码更新为哈希值。

存储密码的哈希

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

这意味着用户无法登录我们的应用程序。为了解决这个问题,我们将更新在启动时加载的 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文件简单地将现有密码更新为其预期的哈希值,如下所示:

   update calendar_users set password =      
   '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

    import org.springframework.security.authentication.encoding.PasswordEncoder;
    // other imports omitted
    public class DefaultCalendarService implements CalendarService {
       ...
       private final PasswordEncoder passwordEncoder;
       @Autowired
       public DefaultCalendarService(EventDao eventDao, 
       CalendarUserDao userDao, JdbcOperations jdbcOperations, 
       PasswordEncoder passwordEncoder) {
       ...
       this.passwordEncoder = passwordEncoder;
       }
       ...
       public int createUser(CalendarUser user) {
           String encodedPassword = passwordEncoder.
           encodePassword(user.getPassword(), null);
           user.setPassword(encodedPassword);
           ...
          return userId;
       }
    }

不太安全

启动应用程序。尝试使用user1作为密码创建一个新用户。退出应用程序,然后按照欢迎页面的说明打开 H2 控制台并查看所有用户的密码。你注意到新创建用户和user1@example.com的散列值是相同的值吗?我们现在发现另一个用户的密码有点令人不安。我们将使用一种名为加盐的技术来解决这个问题。

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

你想给密码加些盐吗?如果安全审计员检查数据库中编码的密码,他会发现一些仍然让他担心网站安全的东西。让我们检查以下几个用户的存储用户名和密码值:

用户名 明文密码 散列密码
admin1@example.com admin1 25f43b1486ad95a1398e3eeb3d83bc4010015fcc9bed b35b432e00298d5021f7
user1@example.com user1 0a041b9462caa4a31bac3567e0b6e6fd9100787db2ab 433d96f6d178cabfce90

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

用户名 明文密码 散列密码
hacker@example.com user1 0a041b9462caa4a31bac3567e0b6e6fd9100787d b2ab433d96f6d178cabfce90

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

虽然猜测一个密码很困难,但黑客可以提前计算出所有的散列值并将散列值与原始密码的映射存储起来。然后,通过查找散列值来确定原始密码,只需常数时间即可。这是一种名为彩虹表的黑客技术。

向加密密码中添加另一层安全性的一个常见且有效的方法是使用盐值。盐值是一个第二个明文组件,它在与明文密码连接后进行哈希之前,以确保必须使用两个因素来生成(从而比较)哈希密码值。适当选择的盐值可以保证没有任何两个密码会有相同的哈希值,从而防止了我们审计员所担忧的情况,并避免了多种常见的暴力破解密码技术。

最佳实践的盐值通常属于以下三个类别之一:

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

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

  • 它们与用户密码记录一起明文或双向加密

记住,因为salt添加到明文密码中,所以它不能单向加密——应用程序需要能够查找或推导出给定用户记录的适当salt值,以便计算密码的hash,并与进行身份验证时存储的用户hash进行比较。

在 Spring Security 中使用盐值

Spring Security 3.1 提供了一个新的加密模块,该模块包含在spring-security-core模块中,也可以在spring-security-crypto中单独使用。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

对那些熟悉 Spring Security 3.0 的人来说,salt曾经是通过o.s.s.authentication.dao.SaltSource提供的。尽管仍然支持,但本书不演示这种机制,因为它不是提供salt的首选机制。

更新 Spring Security 配置

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

    //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java

    @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

    import org.springframework.security.authentication.encoding.PasswordEncoder;
    import org.springframework.security.crypto.password.PasswordEncoder;

    // other imports omitted

    public class DefaultCalendarService implements CalendarService {
    ...      
    public int createUser(CalendarUser user) {
       String encodedPassword = passwordEncoder.encode(user.getPassword());
       user.setPassword(encodedPassword);
       ...
       return userId;
    }
    }

尝试使用加盐密码

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

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

现在 Spring Security 会生成一个随机的salt,然后将其与密码结合后再进行哈希处理。接着,它将这个随机的salt添加到明文密码的前面,以便进行密码校验。存储的密码可以总结如下:

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

这是对新创建密码进行哈希处理的伪代码。

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

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

    storedPassword = datasource.lookupPassword(username)
    salt, expectedHash = extractSaltAndHash(storedPassword)
    actualHash = hash(salt+inputedPassword)
    authenticated = (expectedHash == actualHash)

总结

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

我们还学会了如何将 Spring Security 的 JDBC 支持与现有的数据库集成,以及如何通过哈希处理和使用随机生成的salt来保护我们的密码。

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

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

在上一章中,我们介绍了如何利用 Spring Security 内置的 JDBC 支持。在本章中,我们将介绍 Spring Data 项目,以及如何利用 JPA 对关系数据库进行身份验证。我们还将探讨如何使用 MongoDB 对文档数据库进行身份验证。本章的示例代码基于第四章的 Spring Security 设置,基于 JDBC 的身份验证,并已更新以去除对 SQL 的需求,并使用 ORM 处理所有数据库交互。

在本章中,我们将介绍以下主题:

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

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

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

  • 如何为处理 Spring Data 集成提供更多灵活性自定义 Spring Security

  • 理解 Spring Data 项目

Spring Data 项目的使命是为数据访问提供熟悉的、一致的基于 Spring 的编程模型,同时保留底层数据提供商的独特特性。

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

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

  • 从仓库方法名称派生动态查询

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

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

  • 集成自定义仓库代码的能力

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

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

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

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

以下表格描述了组成 Spring Data 项目的的主要模块:

模块 描述
Spring Data Commons 将核心 Spring 概念应用于所有 Spring Data 项目
Spring Data Gemfire 提供从 Spring 应用程序轻松配置和访问 Gemfire 的支持
Spring Data JPA 使实现基于 JPA 的仓库变得容易
Spring Data Key Value 基于映射的仓库和 SPIs,可轻松构建键值存储的 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 模块

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 {
       ...
    // REMOVE: compile('org.springframework.boot:spring-boot-starter-jdbc')
 compile('org.springframework.boot:spring-boot-starter-data-jpa')       ...
    }

请注意我们移除了spring-boot-starter-jdbc依赖。spring-boot-starter-data-jpa依赖将包含所有必要的依赖项,以便将我们的领域对象与使用 JPA 的嵌入式数据库连接。

将 JBCP 日历更新为使用 Spring Data JPA

为了熟悉 Spring Data,我们首先将 JBCP 日历 SQL 转换为使用 ORM,使用 Spring Data JPA 启动器。

创建和维护 SQL 可能相当繁琐。在前几章中,当我们想在数据库中创建一个新的CalendarUser表时,我们必须编写大量的样板代码,如下所示:

    //src/main/java/com/packtpub/springsecurity/
    dataaccess/JdbcCalendarUserDao.java

    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());
    }
 KeyHoldener keyHolder = new GeratedKeyHolder(); this.jdbcOperations.update(new PreparedStatementCreator() { public PreparedStatement createPreparedStatement
       (Connection connection)
       throws SQLException { PreparedStatement ps = connection.prepareStatement("insert into 
         calendar_users (email, password, first_name, last_name) 
         values (?, ?, ?, ?)", new String[] {  
          "id" });
 ps.setString(1, userToAdd.getEmail()); ps.setString(2, userToAdd.getPassword()); ps.setString(3, userToAdd.getFirstName()); ps.setString(4, userToAdd.getLastName()); return ps; } }, keyHolder);    return keyHolder.getKey().intValue();
    }

创建这个对象,技术上我们需要 12 行代码来执行操作。

现在,使用 Spring Data JPA,相同的实现可以减少到以下代码片段:

    //src/main/java/com/packtpub/springsecurity/dataaccess/JpaCalendarUserDao.java

    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.findOne(0)); userToAdd.setRoles(roles); CalendarUser result = repository.save(userToAdd); repository.flush();     return result.getId();
    }

现在,使用 JPA 创建这个对象,技术上我们需要五行代码来执行操作。我们现在需要的代码量不到原来执行相同操作的一半。

重新配置数据库配置

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

我们可以首先删除 DataSourceConfig.java 文件,因为我们将会利用 Spring Boot 对嵌入式 H2 数据库的内置支持。我们还需要删除 JavaConfig.java 文件中对 DataSourceConfig.java 的引用,因为目前 @Import 注解中有对 JavaConfig.java 的引用。

初始化数据库

现在,我们可以删除 src/main/resources/database 目录及其目录下的所有内容。这个目录包含几个 .sql 文件,我们将合并并将它们移动到下一步:

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

    //src/main/resources/data.sql:
  • 查看以下 SQL 语句,描述了 user1 的密码:
        insert into calendar_users(id,username,email,password,
        first_name,last_name) 
        values(0,'user1@example.com','user1@example.com',
        '$2a$04$qr7RWyqOnWWC1nwotUW1nOe1RD5.
        mKJVHK16WZy6v49pymu1WDHmi','User','1');
  • 查看以下 SQL 语句,描述了 admin1 的密码:
        insert into calendar_users(id,username,email,password,
        first_name,last_name) 
        values (1,'admin1@example.com','admin1@example.com',
        '$2a$04$0CF/Gsquxlel3fWq5Ic/ZOGDCaXbMfXYiXsviTNMQofWRXhvJH3IK',
        'Admin','1');
  • 查看以下 SQL 语句,描述了 user2 的密码:
        insert into calendar_users(id,username,email,password,first_name,
        last_name)
        values (2,'user2@example.com','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,when,summary,description,owner,attendee)
        values (100,'2017-07-03 20:30:00','Birthday Party',
        'This is going to be a great birthday',0,1);
        insert into events (id,when,summary,description,owner,attendee) 
        values (101,'2017-12-23 13:00:00','Conference Call','Call with 
        the client',2,0);
        insert into events (id,when,summary,description,owner,attendee) 
        values (102,'2017-09-14 11:30:00','Vacation',
        'Paragliding in Greece',1,2);

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

    # Embedded Database
    datasource:
    url: jdbc:h2:mem:dataSource;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
    driverClassName: org.h2.Driver
    username: sa
    password:
    continue-on-error: true
 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

import javax.persistence.*; @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 when;
@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;
  1. 我们需要创建一个 Role.java 文件,内容如下:
//src/main/java/com/packtpub/springsecurity/domain/Role.java

import javax.persistence.*;
@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;
  1. Role 对象将用于将权限映射到我们的 CalendarUser 表。现在我们已经有一个 Role.java 文件,让我们来映射我们的 CalendarUser.java 文件:
//src/main/java/com/packtpub/springsecurity/domain/CalendarUser.java

import javax.persistence.*;
import java.io.Serializable;
import java.util.Set;
@Entity @Table(name = "calendar_users") public class CalendarUser implements Serializable {
 @Id @GeneratedValue(strategy = GenerationType.AUTO)   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 的位置,以及结构、引用和关联映射注解。

在此阶段,应用程序将无法运行,但这仍然可以被视为我们在继续转换下一步之前的标记点。

您应该从 chapter05.02-calendar 的源代码开始。

Spring Data 仓库

接下来,我们将通过执行以下步骤向 Spring Data 添加所需接口,以将我们所需的 CRUD 操作映射到嵌入式数据库:

  1. 我们首先在新的包中添加一个新的接口,该包将是com.packtpub.springsecurity.repository。新文件将称为CalendarUserRepository.java,如下所示:
        //com/packtpub/springsecurity/repository/CalendarUserRepository.java

        package com.packtpub.springsecurity.repository;
        import com.packtpub.springsecurity.domain.CalendarUser;
        import org.springframework.data.jpa.repository.JpaRepository;

        public interface CalendarUserRepository
               extends JpaRepository<CalendarUser, Integer> {
           CalendarUser findByEmail(String email);
        }

这将允许我们对CalendarUser对象执行标准的 CRUD 操作,如find()save()delete()

  1. 现在我们可以继续在同一存储库包中添加一个新的接口,该包将是com.packtpub.springsecurity.repository,新文件将称为EventRepository.java
            //com/packtpub/springsecurity/repository/EventRepository.java

            package com.packtpub.springsecurity.repository;
            import com.packtpub.springsecurity.domain.Event;
            import org.springframework.data.jpa.repository.JpaRepository;

            public interface EventRepository extends JpaRepository<Event, 
            Integer> {}

这将允许我们对Event对象执行标准的 CRUD 操作,如find()save()delete()

  1. 最后,我们将在同一存储库包中添加一个新的接口,该包将是com.packtpub.springsecurity.repository,新文件将称为RoleRepository.java。这个CrudRepository接口将用于管理与给定的CalendarUser相关的安全角色的Role对象:
            //com/packtpub/springsecurity/repository/

            package com.packtpub.springsecurity.repository;
            import com.packtpub.springsecurity.domain.Event;
            import org.springframework.data.jpa.repository.JpaRepository;

            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

        package com.packtpub.springsecurity.dataaccess;
        import com.packtpub.springsecurity.domain.CalendarUser;
        import com.packtpub.springsecurity.domain.Event;
 import com.packtpub.springsecurity.repository.EventRepository;        import org.springframework.beans.factory.annotation.Autowired;
        import org.springframework.data.domain.Example;
        import org.springframework.stereotype.Repository;
        import org.springframework.transaction.annotation.Transactional;
        ...
        @Repository
         public class JpaEventDao implements EventDao {
 private EventRepository repository;           @Autowired
 public JpaEventDao(EventRepository repository) { if (repository == null) { throw new IllegalArgumentException("repository 
                    cannot be null"); } this.repository = repository;           }
           @Override
           @Transactional(readOnly = true)
           public Event getEvent(int eventId) {
 return repository.findOne(eventId);           }
           @Override
           public int createEvent(final Event event) {
               ...
               final Calendar when = event.getWhen();
               if(when == null) {
                   throw new IllegalArgumentException("event.getWhen() 
                   cannot be null");
               }
 Event newEvent = repository.save(event);              ...
           }
           @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();           }
        }
  1. 在此阶段,我们需要重构 DAO 类以支持我们创建的新CrudRepository接口。让我们从重构JdbcCalendarUserDao.java文件开始。首先,我们可以将文件重命名为JpaCalendarUserDao.java,以表示此文件使用 JPA,而不是标准的 JDBC:
        //com/packtpub/springsecurity/dataaccess/JpaCalendarUserDao.java

        package com.packtpub.springsecurity.dataaccess;
        ... omitted for brevity ...
        @Repository
        public class JpaCalendarUserDao
               implements CalendarUserDao {
 private CalendarUserRepository userRepository; private RoleRepository roleRepository; @Autowired public JpaCalendarUserDao(CalendarUserRepository repository, 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; }           @Override
           @Transactional(readOnly = true)
           public CalendarUser getUser(final int id) {
 return userRepository.findOne(id);           }
           @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.findOne(0)); userToAdd.setRoles(roles); CalendarUser result = userRepository.save(userToAdd); userRepository.flush();              return result.getId();
           }
        }

正如您在前面的代码中所看到的,使用 JPA 所需的更新片段要比使用 JDBC 所需的代码少得多。这意味着我们可以专注于业务逻辑,而不必担心管道问题。

  1. 接下来,我们继续重构JdbcEventDao.java文件。首先,我们可以将文件重命名为JpaEventDao.java,以表示此文件使用 JPA,而不是标准的 JDBC,如下所示:
//com/packtpub/springsecurity/dataaccess/JpaEventDao.java

package com.packtpub.springsecurity.dataaccess;
... omitted for brevity ...
@Repository
public class JpaEventDao implements EventDao {
 private EventRepository repository;   @Autowired
 public JpaEventDao(EventRepository repository) { if (repository == null) { throw new IllegalArgumentException("repository 
           cannot be null"); } this.repository = repository; }   @Override
   @Transactional(readOnly = true)
   public Event getEvent(int eventId) {
 return repository.findOne(eventId);   }
   @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.getWhen();
       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();   }
}

在前面的代码中,使用 JPA 存储库的更新片段已加粗,因此现在EventCalendarUser对象被映射到我们的底层 RDBMS。

此时应用程序无法工作,但仍然可以认为这是一个标记点,在我们继续转换的下一步之前。

在此阶段,你的源代码应该与chapter05.03-calendar相同。

应用服务

剩下要做的唯一事情是配置 Spring Security 以使用新的工件。

我们需要编辑DefaultCalendarService.java文件,并只删除用于向新创建的User对象添加USER_ROLE的剩余代码,如下所示:

    //com/packtpub/springsecurity/service/DefaultCalendarService.java

    package com.packtpub.springsecurity.service;
    ... omitted for brevity ...
    @Repository
    public class DefaultCalendarService implements CalendarService {
       @Override
       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;
       }
    }

用户详细信息服务对象

让我们来看看以下步骤,以添加UserDetailsService对象:

  1. 现在,我们需要添加一个新的UserDetailsService对象的实现,我们将使用我们的CalendarUserRepository接口再次对用户进行身份验证和授权,使用相同的底层 RDBMS,但使用我们新的 JPA 实现,如下所示:
        //com/packtpub/springsecurity/service/UserDetailsServiceImpl.java

        package com.packtpub.springsecurity.service;
        ... omitted for brevity ...
        @Service
        public class UserDetailsServiceImpl
             implements UserDetailsService {
 @Autowired private CalendarUserRepository userRepository; @Override @Transactional(readOnly = true) public UserDetails loadUserByUsername(final String username)           throws UsernameNotFoundException {            CalendarUser user = userRepository.findByEmail(username);
           Set<GrantedAuthority> grantedAuthorities = new HashSet<>();
 for (Role role : user.getRoles()){ grantedAuthorities.add(new SimpleGrantedAuthority
               (role.getName())); } return new org.springframework.security.core.userdetails.User( user.getEmail(), user.getPassword(), grantedAuthorities); }        }

  1. 现在,我们需要配置 Spring Security 以使用我们自定义的UserDetailsService对象,如下所示:
       //com/packtpub/springsecurity/configuration/SecurityConfig.java

        package com.packtpub.springsecurity.configuration;
        ... omitted for brevity ...
        @Configuration
        @EnableWebSecurity
        public class SecurityConfig extends WebSecurityConfigurerAdapter {\
 @Autowired private UserDetailsService userDetailsService;           @Override
          public void configure(AuthenticationManagerBuilder auth) 
          throws Exception {
          auth
 .userDetailsService(userDetailsService)           .passwordEncoder(passwordEncoder());
           }
 @Bean @Override public UserDetailsService userDetailsService() { return new UserDetailsServiceImpl(); }           ...
        }
  1. 启动应用程序并尝试登录应用程序。现在任何配置的用户都可以登录并创建新事件。您还可以创建新用户,并能够立即以新用户身份登录。

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

从关系型数据库(RDBMS)重构为文档数据库

幸运的是,有了 Spring Data 项目,一旦我们有了 Spring Data 实现,大部分困难的工作已经完成。现在,只需要进行一些实现特定的重构更改。

使用 MongoDB 的文档数据库实现

我们现在将着手将我们的 RDBMS 实现(使用 JPA 作为我们的 ORM 提供者)重构为使用 MongoDB 作为底层数据库提供者的文档数据库实现。MongoDB(来自 humongous)是一个免费且开源的跨平台面向文档的数据库程序。它被归类为一个 NoSQL 数据库程序,MongoDB 使用类似 JSON 的文档和模式。MongoDB 由 MongoDB Inc.开发,位于github.com/mongodb/mongo

更新我们的依赖项

我们已经包含了本章所需的所有依赖项,所以您不需要对build.gradle文件进行任何更新。然而,如果您只是将 Spring Data JPA 支持添加到您自己的应用程序中,您需要在build.gradle文件中添加spring-boot-starter-data-jpa作为依赖项,如下所示:

    //build.gradle
    // JPA / ORM / Hibernate:
    //compile('org.springframework.boot:spring-boot-starter-data-jpa')
    // H2 RDBMS
    //runtime('com.h2database:h2')
    // MongoDB:

 compile('org.springframework.boot:spring-boot-starter-data-mongodb') compile('de.flapdoodle.embed:de.flapdoodle.embed.mongo')

请注意,我们已经移除了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 日历项目。让我们先重新配置数据库以使用 Flapdoodle 嵌入式 MongoDB 数据库。之前,当我们更新这个项目的依赖时,我们添加了一个 Flapdoodle 依赖项,该项目得到了一个嵌入式 MongoDB 数据库,我们可以自动使用它,而不是安装 MongoDB 的完整版本。为了与 JBCP 应用程序保持一致,我们需要更改我们数据库的名称。使用 Spring Data,我们可以使用 YAML 配置来更改 MongoDB 配置,如下所示:

    //src/main/resources/application.yml

    spring
    # MongoDB
 data: mongodb:         host: localhost
 database: dataSource

对于我们当前需求最重要的配置是更改数据库名称为dataSource,这个名称与本书中我们一直在使用的名称相同。

初始化 MongoDB 数据库

使用 JPA 实现时,我们使用了data.sql文件来初始化数据库中的数据。对于 MongoDB 实现,我们可以删除data.sql文件,并用我们称之为MongoDataInitializer.java的 Java 配置文件来替代它:

    //src/main/java/com/packtpub/springsecurity/configuration/
    MongoDataInitializer.java

    ¦
    @Configuration
    public class MongoDataInitializer {
       @Autowired
       private RoleRepository roleRepository;
       @Autowired
       private CalendarUserRepository calendarUserRepository;
       @Autowired
       private EventRepository eventRepository;
 @PostConstruct       public void setUp() {
 calendarUserRepository.deleteAll(); roleRepository.deleteAll(); eventRepository.deleteAll(); seedRoles(); seedCalendarUsers(); seedEvents();       }
       CalendarUser user1, admin, user2;
       {
 user1 = 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");
           admin_role = new Role(1, "ROLE_ADMIN");
           user_role = roleRepository.save(user_role);
           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", new 
           GregorianCalendar(2017,6,3,6,36,00), user, admin);
 // Event 2           Event event2 = new Event(101, "Conference Call", 
           "Call with the client",new 
           GregorianCalendar(2017,11,23,13,00,00),user2, user);
 // Event 3           Event event3 = new Event(102, "Vacation",
           "Paragliding in Greece",new GregorianCalendar(2017,8,14,11,30,00),
           admin, user2);
           // Save Events
 eventRepository.save(event1); eventRepository.save(event2); eventRepository.save(event3);       }
       private void seedCalendarUsers(){
           // user1
           user1.addRole(user_role);
          // admin2
           admin.addRole(user_role);
           admin.addRole(admin_role);
           // user2
           user2.addRole(user_role);
 calendarUserRepository.save(user1); calendarUserRepository.save(admin); calendarUserRepository.save(user2);       }
    }

这将在加载时执行,并将将相同的数据种子到我们的 MongoDB 中,就像我们使用我们的 H2 数据库一样。

使用 MongoDB 映射领域对象

首先,让我们将我们的Event.java文件映射到领域对象,以便将每个领域对象保存为我们的 MongoDB 数据库中的文档。这可以通过执行以下步骤来实现:

  1. 在文档数据库中,领域对象映射有所不同,但相同的 ORM 概念仍然适用。让我们从 Event JPA 实现开始,然后看看如何将我们的Entity转换为文档映射:
        //src/main/java/com/packtpub/springsecurity/domain/Event.java

         ...
 import javax.persistence.*; @Entity @Table(name = "events")        public class Event implements Serializable{
 @Id @GeneratedValue(strategy = GenerationType.AUTO)           private Integer id;
           private String summary;
           private String description;
           private Calendar when;
 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name="owner", referencedColumnName="id")           private CalendarUser owner;
 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name="attendee", referencedColumnName="id")           private CalendarUser attendee;
           ¦
  1. 在基于实体的 JPA 映射中,我们需要使用六个不同的注解来创建所需的映射。现在,在基于文档的 MongoDB 映射中,我们需要更改所有的先前映射注解。下面是我们完全重构的Event.java文件的示例:
        //src/main/java/com/packtpub/springsecurity/domain/Event.java

 import org.springframework.data.annotation.Id; import org.springframework.data.annotation.PersistenceConstructor; import org.springframework.data.domain.Persistable; import org.springframework.data.mongodb.core.mapping.DBRef; import org.springframework.data.mongodb.core.mapping.Document;        ...
 @Document(collection="events")        public class Event implements Persistable<Integer>, Serializable{
 @Id             private Integer id;
             private String summary;
             private String description;
             private Calendar when;
 @DBRef           private CalendarUser owner;
 @DBRef           private CalendarUser attendee;
 @PersistenceConstructor           public Event(Integer id,
                 String summary,
                 String description,
                 Calendar when,
                 CalendarUser owner,
                 CalendarUser attendee) {
                  ...
          }

在上述代码中,我们可以看到一些值得注意的更改:

  1. 首先,我们需要声明类为@o.s.d.mongodb.core.mapping.Document类型,并为这些文档提供集合名称。

  2. 接下来,Event类必须实现o.s.d.domain.Persistable接口,为我们的文档提供主键类型(Integer)。

  3. 现在,我们将我们的领域 ID 注解更改为@o.s.d.annotation.Id,以定义领域主键。

  4. 之前,我们必须将我们的所有者和参与者CalendarUser对象映射到两个不同的映射注解。

  5. 现在,我们只需要定义两种类型为@o.s.d.mongodb.core.mapping.DBRef,并允许 Spring Data 处理底层引用。

  6. 我们必须添加的最后一个注解定义了一个特定的构造函数,用于将新文档添加到我们的文档中,通过使用@o.s.d.annotation.PersistenceConstructor注解。

  7. 现在我们已经回顾了从 JPA 转换到 MongoDB 所需的更改,让我们从Role.java文件开始转换另一个领域对象:

        //src/main/java/com/packtpub/springsecurity/domain/Role.java

        ...
        import org.springframework.data.annotation.Id;
        import org.springframework.data.annotation.PersistenceConstructor;
        import org.springframework.data.domain.Persistable;
        import org.springframework.data.mongodb.core.mapping.Document;
 @Document(collection="role")        public class Role implements Persistable<Integer>, Serializable {
 @Id            private Integer id;
            private String name;
            public Role(){}
 @PersistenceConstructor        public Role(Integer id, String name) {
            this.id = id;
            this.name = name;
         }
  1. 我们需要重构的最后一个领域对象是我们的CalendarUser.java文件。毕竟,这是这个应用程序中最复杂的领域对象:
        //src/main/java/com/packtpub/springsecurity/domain/CalendarUser.java

        ...
        import org.springframework.data.annotation.Id;
        import org.springframework.data.annotation.PersistenceConstructor;
        import org.springframework.data.domain.Persistable;
        import org.springframework.data.mongodb.core.mapping.DBRef;
        import org.springframework.data.mongodb.core.mapping.Document;
 @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);
          public CalendarUser() {}
 @PersistenceConstructor          public CalendarUser(Integer id,String email, String password,
          String firstName,String lastName) {
             this.id = id;
             this.firstName = firstName;
             this.lastName = lastName;
             this.email = email;
             this.password = password;
           }

正如你所见,将我们的领域对象从 JPA 重构为 MongoDB 的努力相当简单,并且比 JPA 配置需要的注解配置要少。

Spring Data 对 MongoDB 的仓库

现在我们只需要对从 JPA 实现到 MongoDB 实现进行少量更改即可重构。我们将从重构我们的CalendarUserRepository.java文件开始,通过更改我们仓库所扩展的接口,如下所示:

    //com/packtpub/springsecurity/repository/CalendarUserRepository.java

    ...
 import org.springframework.data.mongodb.repository.MongoRepository;    public interface CalendarUserRepository extends MongoRepository
    <CalendarUser, Integer> {
       ...

这个相同的更改需要应用到EventRepository.java文件和RoleRepository.java文件上。

如果你需要帮助进行这些更改,请记住chapter05.05的源代码将有完整的代码供您参考。

MongoDB 中的数据访问对象

在我们的EventDao接口中,我们需要创建一个新的Event对象。使用 JPA,我们的对象 ID 可以自动生成。使用 MongoDB,有几种方式可以分配主键标识符,但为了这个演示,我们只需使用原子计数器,如下所示:

    //src/main/java/com/packtpub/springsecurity/dataaccess/MongoEventDao.java

    ...
 import java.util.concurrent.atomic.AtomicInteger;    @Repository
    public class MongoEventDao implements EventDao {
      // Simple Primary Key Generator
 private AtomicInteger eventPK = new AtomicInteger(102);       ...
       @Override
       public int createEvent(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 项目及其部分功能。我们还看到了从使用 SQL 的遗留 JDBC 代码到使用 JPA 的 ORM,以及从使用 Spring Data 的 JPA 实现到使用 Spring Data 的 MongoDB 实现的重构过程。我们还覆盖了配置 Spring Security 以利用关系数据库中的 ORM Entity和文档数据库中的配置。

在下一章中,我们将探讨 Spring Security 对基于 LDAP 的认证的内置支持。

第六章:LDAP 目录服务

在本章中,我们将回顾轻量级目录访问协议LDAP)并学习如何将其集成到 Spring Security 启用的应用程序中,为感兴趣的各方提供认证、授权和用户信息服务。

在本章中,我们将介绍以下主题:

  • 学习与 LDAP 协议和服务器实现相关的一些基本概念

  • 在 Spring Security 中配置自包含 LDAP 服务器

  • 启用 LDAP 认证和授权

  • 理解 LDAP 搜索和用户匹配背后的模型

  • 从标准 LDAP 结构中检索额外的用户详细信息

  • 区分 LDAP 认证方法并评估每种类型的优缺点

  • 显式使用Spring bean声明配置 Spring Security LDAP

  • 连接到外部 LDAP 目录

  • 探索对 Microsoft AD 的内置支持

  • 我们还将探讨如何在处理自定义 AD 部署时为 Spring Security 定制更多灵活性

理解 LDAP

LDAP 起源于 30 多年前的概念性目录模型-类似于组织结构图和电话簿的结合。如今,LDAP 越来越多地被用作集中企业用户信息、将成千上万的用户划分为逻辑组以及在不同系统之间统一共享用户信息的方法。

出于安全考虑,LDAP 常被用于实现集中化的用户名和密码验证-用户的凭据存储在 LDAP 目录中,代表用户对目录进行认证请求。这使得管理员的管理工作得到简化,因为用户凭据-登录 ID、密码及其他详细信息-都存储在 LDAP 目录的单一位置中。此外,诸如组织结构、团队分配、地理位置和企业层级等信息,都是基于用户在目录中的位置来定义的。

LDAP

到目前为止,如果你以前从未使用过 LDAP,你可能会想知道它是什么。我们将通过 Apache Directory Server 2.0.0-M231.5 示例目录中的屏幕截图来展示一个 LDAP 架构示例,如下面的屏幕截图所示:

从特定用户条目uid=admin1@example.com(在前面的屏幕截图中突出显示)开始,我们可以通过在这个树节点开始并向上升级来推断admin1的组织成员资格。我们可以看到用户aeinstein是组织单位(ou=users)的成员,而这个单位本身是域example.com的一部分(在前面的屏幕截图中显示的缩写dc代表域组件)。在这个之前是 LDAP 树本身的组织元素(DITRoot DSE),这在 Spring Security 的上下文中与我们无关。用户aeinstein在 LDAP 层次结构中的位置在语义上和定义上都是有意义的-你可以想象一个更复杂的层次结构,轻松地说明一个大型组织的组织和部门界限。

沿着树向下走到一个单独的叶节点形成的从上到下的完整路径是由沿途的所有中间节点组成的一个字符串,就像admin1的节点路径一样,如下所示:

    uid=admin1,ou=users,dc=example,dc=com

前面的节点路径是唯一的,被称为节点的** Distinguished Name** (DN)。Distinguished Name 类似于数据库的主键,允许在复杂的树结构中唯一标识和定位一个节点。在 Spring Security LDAP 集成中,我们将看到节点的 DN 在认证和搜索过程中被广泛使用。

请注意,在与admin1相同组织级别的列表中还有几个其他用户。所有这些用户都被假设为与admin1处于相同的组织位置。尽管这个例子中的组织结构相对简单和平坦,但 LDAP 的结构是任意灵活的,可能有多个嵌套层次和逻辑组织。

Spring Security LDAP 支持由 Spring LDAP 模块提供(www.springsource.org/ldap),该模块实际上是从 Spring 框架核心和 Spring Security 项目分离出来的一个独立项目。它被认为是稳定的,并提供了一组有助于包装标准 Java LDAP 功能的封装器。

常见的 LDAP 属性名称

树中的每个实际条目都是由一个或多个对象类定义的。对象类是组织的一个逻辑单位,将一组语义上相关的属性组合在一起。通过将树中的条目声明为特定对象类的一个实例,如一个人,LDAP 目录的组织者就能够向目录的用户提供一个清晰的指示,表明目录中的每个元素代表什么。

LDAP 有一套丰富的标准模式,涵盖可用的 LDAP 对象类及其适用的属性(以及其他大量信息)。如果您计划进行广泛的 LDAP 工作,强烈建议您查阅一本好的参考指南,例如书籍《Zytrax OpenLDAP》的附录(www.zytrax.com/books/ldap/ape/),或《Internet2 Consortium 的与人员相关的模式指南》(middleware.internet2.edu/eduperson/)。

在前一部分中,我们了解到 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 用户密码:此属性存储与该属性关联的person对象的密码。它通常是使用SHA或其他类似方法进行单向散列的。 userPassword=plaintext``userPassword={SHA}cryptval

然而,前表中的属性通常是的目录树的组织属性,因此,它们可能形成各种搜索表达式或映射,以便配置 Spring Security 与 LDAP 服务器进行交互。

记住,有数百个标准的 LDAP 属性-这些只是你在与一个完全填充的 LDAP 服务器集成时可能会看到的很小的一部分。

更新我们的依赖项

我们已经为您本章所需的所有依赖项,所以你不需要对你的build.gradle文件做任何更新。然而,如果你只是想为你的应用程序添加 LDAP 支持,你需要在build.gradle中添加spring-security-ldap作为依赖项,如下所示:

    //build.gradle

    dependencies {
    // LDAP:
    compile('org.springframework.boot:spring-boot-starter-data-ldap')
    compile("org.springframework.ldap:spring-ldap-core")
    compile("org.springframework.security:spring-security-ldap")
 compile("org.springframework:spring-tx")    compile("com.unboundid:unboundid-ldapsdk")
       ...
    }

由于 Gradle 的一个艺术品解析问题,spring-tx必须被引入,否则 Gradle 会获取一个较旧的版本,无法使用。

如前所述,Spring Security 的 LDAP 支持是建立在 Spring LDAP 之上的。Gradle 会自动将这些依赖作为传递依赖引入,因此无需明确列出。

如果你在你的网络应用程序中使用ApacheDS运行 LDAP 服务器,正如我们在我们的日历应用程序中所做的那样,你需要添加 ApacheDS 相关的 JAR 包依赖。由于这些更新已经被包含在我们的示例应用程序中,所以无需对示例应用程序进行这些更新。请注意,如果你连接到一个外部的 LDAP 服务器,这些依赖是不必要的:

//build.gradle

    compile 'org.apache.directory.server:apacheds-core:2.0.0-M23'
    compile 'org.apache.directory.server:apacheds-protocol-ldap:2.0.0-M23'
    compile 'org.apache.directory.server:apacheds-protocol-shared:2.0.0
    -M23'

配置嵌入式 LDAP 集成

现在让我们启用基于 LDAP 的 JBCP 日历应用程序认证。幸运的是,这是一个相对简单的练习,使用嵌入式 LDAP 服务器和一个示例 LDIF 文件。在这个练习中,我们将使用为这本书创建的 LDIF 文件,旨在捕获许多与 LDAP 和 Spring Security 相关的常见配置场景。我们还包含了一些其他示例 LDIF 文件,其中一些来自 Apache DS 2.0.0-M23,还有一个来自 Spring Security 单元测试,你可以选择实验它们。

配置 LDAP 服务器引用

第一步是配置嵌入式 LDAP 服务器。Spring Boot 会自动配置一个嵌入式 LDAP 服务器,但我们还需要稍微调整一下配置。对你的application.yml文件进行以下更新:

      //src/main/resources/application.yml

      spring:
      ## LDAP
 ldap: embedded: 
 ldif: classpath:/ldif/calendar.ldif base-dn: dc=jbcpcalendar,dc=com port: 33389

你应该从chapter06.00-calendar的源代码开始。

我们从classpath加载calendar.ldif文件,并使用它来填充 LDAP 服务器。root属性使用指定的 DN 声明 LDAP 目录的根。这应该与我们在使用的 LDIF 文件中的逻辑根 DN 相对应。

请注意,对于嵌入式 LDAP 服务器,base-dn属性是必需的。如果没有指定或指定不正确,你可能会在 Apache DS 服务器的初始化过程中收到几个奇怪的错误。还要注意,ldif资源应该只加载一个ldif,否则服务器将无法启动。Spring Security 要求一个资源,因为使用诸如classpath*:calendar.ldif的东西不能提供所需要的确切排序。

我们将在 Spring Security 配置文件中重新使用这里定义的 bean ID,当我们声明 LDAP 用户服务和其他配置元素时。在使用内置 LDAP 模式时,<ldap-server>声明上的所有其他属性都是可选的。

启用 LDAP AuthenticationProviderNext 接口

接下来,我们需要配置另一个AuthenticationProvider接口,以将用户凭据与 LDAP 提供者进行核对。只需更新 Spring Security 配置,使用o.s.s.ldap.authentication.LdapAuthenticationProvider引用,如下所示:

    //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java

    @Override
    public void configure(AuthenticationManagerBuilder auth)
    throws Exception {
       auth
 .ldapAuthentication() .userSearchBase("") .userSearchFilter("(uid={0})") .groupSearchBase("ou=Groups") .groupSearchFilter("(uniqueMember={0})") .contextSource(contextSource()) .passwordCompare() .passwordAttribute("userPassword");    }
    @Bean
    public DefaultSpringSecurityContextSource contextSource() {
 return new DefaultSpringSecurityContextSource( Arrays.asList("ldap://localhost:33389/"), "dc=jbcpcalendar,dc=com");
    }

我们稍后会讨论这些属性。现在,先让应用程序恢复正常运行,然后尝试使用admin1@example.com作为用户名和admin1作为密码登录。你应该可以登录!

您的源代码应该看起来像chapter05.01-calendar

调试内置 LDAP

你很可能会遇到嵌入式 LDAP 的难以调试的问题。Apache DS 通常对其错误信息不太友好,在 Spring Security 嵌入式模式下更是如此。如果你在尝试通过浏览器访问应用程序时遇到404错误,有很大可能性是没有正确启动。如果你无法运行这个简单示例,需要检查以下几点:

  • 确保在您的configuration文件中的DefaultSpringSecurityContextSource声明上设置了baseDn属性,并确保它与在启动时加载的 LDIF 文件中定义的根匹配。如果您遇到引用缺失分区错误,很可能是漏掉了root属性或与您的 LDIF 文件不匹配。

  • 请注意,嵌入式 LDAP 服务器启动失败并不是致命失败。为了诊断加载 LDIF 文件时的错误,您需要确保适当的日志设置,包括 Apache DS 服务器的日志记录,至少在错误级别启用。LDIF 加载器位于org.apache.directory.server.protocol.shared.store包下,应使用此包来启用 LDIF 加载错误的日志记录。

  • 如果应用服务器非正常关闭,你可能需要删除临时目录(Windows 系统中的%TEMP%或 Linux 系统中的/tmp)中的某些文件,以便再次启动服务器。关于这方面的错误信息(幸运的是)相当清晰。不幸的是,内置的 LDAP 不如内置的 H2 数据库那么无缝且易于使用,但它仍然比尝试下载和配置许多免费的外部 LDAP 服务器要容易得多。

一个出色的工具,用于调试或访问一般 LDAP 服务器的是 Apache Directory Studio 项目,该项目提供独立版本和 Eclipse 插件版本。免费下载可在directory.apache. Org/studio/找到。如果你想跟随本书,现在可能想下载 Apache Directory Studio 2.0.0-M23。

了解 Spring LDAP 认证如何工作

我们看到我们能够使用在 LDAP 目录中定义的用户登录。但是,当用户发出登录请求时,在 LDAP 中实际上会发生什么?LDAP 认证过程有三个基本步骤:

  1. 将用户提供的凭据与 LDAP 目录进行认证。

  2. 基于用户在 LDAP 中的信息,确定其GrantedAuthority对象。

  3. 从 LDAP 条目预加载用户信息到一个自定义的UserDetails对象中,供应用程序进一步使用。

验证用户凭据

对于第一步,即对 LDAP 目录进行认证,一个自定义认证提供者被连接到AuthenticationManagero.s.s.ldap.authentication.LdapAuthenticationProvider接口接受用户提供的凭据,并将它们与 LDAP 目录进行验证,如下面的图所示:

我们可以看到o.s.s.ldap.authentication.LdapAuthenticator接口定义了一个委派,以允许提供者以可定制的方式提出认证请求。我们到目前为止隐式配置的实现,o.s.s.ldap.authentication.BindAuthenticator,尝试使用用户的凭据以登录到 LDAP 服务器,好像是用户自己建立连接一样。对于内嵌服务器,这对于我们的认证需求是充分的;然而,外部 LDAP 服务器可能更严格,在这些服务器上,用户可能不允许绑定到 LDAP 目录。幸运的是,存在一种替代的认证方法,我们将在本章后面探索。

如前图所示,请注意,搜索是在由DefaultSpringSecurityContextSource参考的baseDn属性创建的 LDAP 上下文中执行的。对于内嵌服务器,我们不使用这些信息,但对于外部服务器参考,除非提供baseDn,否则会使用匿名绑定。对于需要有效凭据才能搜索 LDAP 目录的组织来说,保留对目录中信息公共可用的某些控制是非常常见的,因此,在现实世界场景中baseDn几乎总是必需的。baseDn属性代表具有对目录进行绑定并执行搜索的有效访问权限的用户的全 DN。

使用 Apache Directory Studio 演示认证过程

我们将通过使用 Apache Directory Studio 1.5 连接到我们的内嵌 LDAP 实例并执行 Spring Security 正在执行的相同步骤来演示认证过程是如何工作的。在整个模拟中我们将使用user1@example.com。这些步骤将有助于确保对幕后发生的事情有坚实的基础,并有助于在您遇到难以确定正确配置的情况下提供帮助。

确保日历应用程序已经启动并运行。接下来,启动 Apache Directory Studio 1.5 并关闭欢迎屏幕。

匿名绑定到 LDAP

第一步是以匿名方式绑定到 LDAP。由于我们没有在DefaultSpringSecurityContextSource对象上指定baseDnpassword属性,因此绑定是匿名的。在 Apache Directory Studio 中,使用以下步骤创建一个连接:

  1. 点击文件 | 新建 | LDAP 浏览器 | LDAP 连接。

  2. 点击下一步。

  3. 输入以下信息,然后点击下一步:

    • 连接名称:calendar-anonymous

    • 主机名:localhost

    • 端口:33389

  4. 我们没有指定baseDn,因此选择无认证作为认证方法。

  5. 点击完成。

您可以安全地忽略指示没有默认架构信息的存在的消息。现在您应该可以看到,您已经连接到了内嵌的 LDAP 实例。

搜索用户

现在我们已经有了一个连接,我们可以使用它来查找我们希望绑定的用户的 DN,通过执行以下步骤:

  1. 右键点击DIT并选择新建 | 新搜索。

  2. 输入搜索基础dc=jbcpcalendar,dc=com。这对应于我们的DefaultSpringSecurityContextSource对象的baseDn属性,我们指定的。

  3. 输入过滤器uid=user1@example.com。这对应于我们为AuthenticationManagerBuilderuserSearchFilter方法指定的值。注意我们包括了括号,并用{0}值替换了我们尝试登录的用户名。

  4. 点击搜索。

  5. 点击我们搜索返回的单个结果的 DN。现在您可以看到我们的 LDAP 用户被显示出来。注意这个 DN 与我们搜索的值匹配。记住这个 DN,因为它将在我们下一步中使用。

以用户身份绑定到 LDAP

现在我们已经找到了我们用户的完整 DN,我们需要尝试以该用户身份绑定到 LDAP 以验证提交的密码。这些步骤与我们已经完成的匿名绑定相同,只是我们将指定我们要认证的用户的凭据。

在 ApacheDS 中,使用以下步骤创建一个连接:

  1. 选择文件 | 新建 | LDAP 浏览器 | LDAP 连接。

  2. 点击下一步。

  3. 输入以下信息,然后点击下一步:

    • 连接名称:calendar-user1

    • 主机名:localhost

    • 端口:33389

  4. 将认证方法保留为简单认证。

  5. 从我们的搜索结果中输入 DN 作为Bind DN。值应该是uid=admin1@example.com,ou=Users,dc=jbcpcalendar,dc=com

  6. Bind密码应该是登录时提交的用户密码。在我们这个案例中,我们希望使用admin1来进行成功的认证。如果输入了错误的密码,我们将无法连接,Spring Security 会报告一个错误。

  7. 点击完成。

当 Spring Security 能够成功绑定提供的用户名和密码时,它会确定这个用户的用户名和密码是正确的(这类似于我们能够创建一个连接)。Spring Security 然后将继续确定用户的角色成员资格。

确定用户角色成员资格

在用户成功对 LDAP 服务器进行身份验证后,下一步必须确定授权信息。授权是由主体的角色列表定义的,LDAP 身份验证用户的角色成员资格是根据以下图表所示确定的:

我们可以看到,在用户对 LDAP 进行身份验证后,LdapAuthenticationProvider委托给LdapAuthoritiesPopulatorDefaultLdapAuthoritiesPopulator接口将尝试在 LDAP 层次结构的另一个条目或其下查找已验证用户的 DN。在用户角色分配的位置搜索的 DN 定义在groupSearchBase方法中;在我们的示例中,我们将此设置为groupSearchBase("ou=Groups")。当用户的 DN 位于groupSearchBase DN 下方的 LDAP 条目中时,在该条目中找到用户 DN 的属性用于赋予他们角色。

如何将 Spring Security 角色与 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 目录中走一遍登录过程。

使用 Apache Directory Studio 确定角色

我们现在将尝试使用 Apache Directory Studio 确定我们的用户角色。使用我们之前创建的calendar-user1连接,执行以下步骤:

  1. DIT上右键点击,选择新建 | 新搜索。

  2. 输入搜索基础ou=Groups,dc=jbcpcalendar,dc=com。这对应于我们为AuthenticationManagerBuilder对象指定的DefaultSpringSecurityContextSource对象中的baseDn属性,加上我们为AuthenticationManagerBuilder对象指定的groupSearchBase属性。

  3. 输入过滤器uniqueMember=uid=user1@example.com,ou=Users,dc=jbcpcalendar,dc=com。这对应于默认的groupSearchFilter属性(uniqueMember={0})。注意我们已经用我们在上一步骤中找到的用户的全 DN 替换了{0}值。

  4. 点击搜索。

  5. 你会观察到,在我们的搜索结果中只有User组返回。点击我们搜索返回的单个结果的 DN。现在你可以在 Apache DS 中看到User组。注意该组有一个uniqueMember属性,包含了我们的用户和其他用户的全 DN。

现在,Spring Security 会为每个搜索结果创建一个GrantedAuthority对象,通过将找到的组的名称强制转换为大写并在组名称前加上ROLE_前缀。伪代码看起来类似于以下代码片段:

    foreach group in groups:

    authority = ("ROLE_"+group).upperCase()

    grantedAuthority = new GrantedAuthority(authority)

Spring LDAP 和你的灰质一样灵活。请记住,虽然这是一种组织 LDAP 目录以与 Spring Security 兼容的方法,但典型的使用场景正好相反——一个已经存在的 LDAP 目录需要与 Spring Security 进行集成。在许多情况下,你将能够重新配置 Spring Security 以处理 LDAP 服务器的层次结构;然而,关键是你需要有效地规划并理解 Spring 在查询时如何与 LDAP 合作。用你的大脑,规划用户搜索和组搜索,并提出你能想到的最优计划——尽量保持搜索的范围最小和尽可能精确。

你能描述一下我们的admin1@example.com用户登录结果会有何不同吗?如果你此刻感到困惑,我们建议你稍作休息,尝试使用 Apache Directory Studio 浏览嵌入式 LDAP 服务器,该服务器通过运行应用程序进行配置。如果你尝试按照之前描述的算法自己搜索目录,那么你可能会更容易掌握 Spring Security 的 LDAP 配置流程。

映射 UserDetails 的额外属性

最后,一旦 LDAP 查询为用户分配了一组GrantedAuthority对象,o.s.s.ldap.userdetails.LdapUserDetailsMapper将咨询o.s.s.ldap.userdetails.UserDetailsContextMapper,以检索任何其他详细信息,以填充应用程序使用的UserDetails对象。

使用AuthenticationManagerBuilder,到目前为止,我们已经配置了LdapUserDetailsMapper将用于从 LDAP 目录中用户的条目中获取信息,并填充UserDetails对象:

我们马上看到如何配置UserDetailsContextMapper从标准的 LDAP personinetOrgPerson对象中获取大量信息。带有基础LdapUserDetailsMapper,存储的不仅仅是usernamepasswordGrantedAuthority

尽管在 LDAP 用户认证和详细信息检索的背后涉及更多的机械设备,但你会注意到整个过程似乎与我们在第四章中研究的 JDBC 认证(认证用户并填充GrantedAuthority) somewhat similar(有所相似)。与 JDBC 认证一样,可以执行 LDAP 集成的高级配置。让我们深入了解一下有什么可能性!

高级 LDAP 配置

一旦我们超越了 LDAP 集成的基础知识,Spring Security LDAP 模块中还有许多其他配置能力,这些能力仍然符合WebSecurityConfigurerAdapter风格的配置。这包括检索用户个人信息、用户认证的额外选项以及将 LDAP 用作与标准DaoAuthenticationProvider类结合的UserDetailsService接口。

JBCP LDAP 用户示例

我们在 JBCP 日历LDIF文件中提供了许多不同的用户。以下快速参考表可能会帮助您进行高级配置练习或自我探索:

用户名/密码 角色(们) 密码编码
admin1@example.com/admin1 ROLE_ADMIN, ROLE_USER 纯文本
user1@example.com/user1 ROLE_USER 纯文本
shauser@example.com/shauser ROLE_USER {sha}
sshauser@example.com/sshauser ROLE_USER {ssha}
hasphone@example.com/hasphone ROLE_USER 纯文本(在telephoneNumber属性中)

我们将在下一节解释为什么密码编码很重要。

密码对比与绑定认证

某些 LDAP 服务器将被配置为不允许某些个别用户直接绑定到服务器,或者不允许使用匿名绑定(到目前为止我们一直在用于用户搜索的绑定方式)。这在希望限制能够从目录中读取信息的用户集的大型组织中较为常见。

在这些情况下,标准的 Spring Security LDAP 认证策略将不起作用,必须使用替代策略,由o.s.s.ldap.authentication.PasswordComparisonAuthenticator实现:

BindAuthenticator):

PasswordComparisonAuthenticator接口连接到 LDAP,搜索与用户提供的用户名匹配的 DN。然后将用户提供的密码与匹配的 LDAP 条目上的userPassword属性进行比较。如果编码的密码匹配,用户将被认证,流程继续,与BindAuthenticator类似。

配置基本的密码比较

配置密码比较认证而不是绑定认证,只需在AuthenticationManagerBuilder声明中添加一个方法即可。更新SecurityConfig.java文件,如下所示:

    //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java

    @Override
    public void configure(AuthenticationManagerBuilder auth)
       throws Exception {
       auth
         .ldapAuthentication()
         .userSearchBase("")
         .userSearchFilter("(uid={0})")
         .groupSearchBase("ou=Groups")
         .groupSearchFilter("(uniqueMember={0})")
         .contextSource(contextSource())
 .passwordCompare() .passwordEncoder(new LdapShaPasswordEncoder()) .passwordAttribute("userPassword");    }

PasswordCompareConfigurer类通过声明passwordCompare方法来使用,该类使用PlaintextPasswordEncoder进行密码编码。要使用SHA-1密码算法,我们需要设置一个密码编码器,我们可以使用o.s.s.a.encoding.LdapShaPasswordEncoderSHA支持(回想我们在第四章,基于 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 2307中详细记录,作为网络信息服务使用的 LDAP 方法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 目录 potentially potentially 包含比用户名、密码和角色更多的个人信息。Spring Security 附带了两种从标准 LDAP 对象架构 - personinetOrgPerson 中提取更多用户数据的方法。

隐式配置 UserDetailsContextMapper

为了配置一个不同的 UserDetailsContextMapper 实现,而不是默认的实现,我们只需要声明我们想要 LdapAuthenticationProvider 返回哪个 LdapUserDetails 类。安全命名空间解析器足够智能,可以根据请求的 LdapUserDetails 接口类型实例化正确的 UserDetailsContextMapper 实现。

让我们重新配置我们的 SecurityConfig.java 文件,以使用 inetOrgPerson 映射器版本。更新 SecurityConfig.java 文件,如下所示:

    //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java

    @Override
    public void configure(AuthenticationManagerBuilder auth)
    throws Exception {
       auth
           .ldapAuthentication()
           .userSearchBase("")
           .userSearchFilter("(uid={0})")
           .groupSearchBase("ou=Groups")
           .groupSearchFilter("(uniqueMember={0})")
 .userDetailsContextMapper( new InetOrgPersonContextMapper())           .contextSource(contextSource())
           .passwordCompare()
              // Supports {SHA} and {SSHA}
               .passwordEncoder(new LdapShaPasswordEncoder())
               .passwordAttribute("userPassword");
    }

如果我们移除 passwordEncoder 方法,那么使用 SHA 密码的 LDAP 用户将无法进行身份验证。

如果你重新启动应用程序并尝试以 LDAP 用户身份登录,你会看到什么都没有变化。实际上,UserDetailsContextMapper 在幕后已经更改为在用户目录条目中可用 inetOrgPerson 架构属性时读取附加详细信息。

尝试使用 admin1@example.com 作为 usernameadmin1 作为 password 进行身份验证。它应该无法进行身份验证。

查看附加用户详细信息

为了在这个领域帮助你,我们将向 JBCP 日历应用程序添加查看当前账户的能力。我们将使用这个页面来展示如何使用更丰富的个人和 inetOrgPerson LDAP 架构为您的 LDAP 应用程序提供额外的(可选)信息。

你可能注意到这一章带有一个额外的控制器,名为 AccountController。你可以看到相关的代码,如下所示:

    //src/main/java/com/packtpub/springsecurity/web/controllers/AccountController.java

    ...
    @RequestMapping("/accounts/my")
    public String view(Model model) {
    Authentication authentication = SecurityContextHolder.
    getContext().getAuthentication();
    // null check on authentication omitted
    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";
    }
    ...

前面的代码将通过LdapAuthenticationProviderAuthentication对象中检索存储在UserDetails对象(主体)中,并确定它是哪种类型的LdapUserDetailsImplinterface。页面代码本身将根据已绑定到用户认证信息的UserDetails对象类型显示各种详细信息,正如我们在下面的 JSP 代码中所看到的那样。我们已经包括了 JSP:

    //src/main/resources/templates/accounts/show.html

    <dl>
       <dt>Username</dt>
       <dd id="username" th:text="${user.username}">ChuckNorris</dd>
       <dt>DN</dt>
       <dd id="dn" th:text="${user.dn}"></dd>
       <span th:if="${isLdapPerson}">
           <dt>Description</dt>
           <dd id="description" th:text="${user.description}"></dd>
           <dt>Telephone</dt>
           <dd id="telephoneNumber" th:text="${user.telephoneNumber}"></dd>
           <dt>Full Name(s)</dt>
           <span th:each="cn : ${user.cn}">
           <dd th:text="${cn}"></dd>
           </span>
       </span>
       <span th:if="${isLdapInetOrgPerson}">
           <dt>Email</dt>
           <dd id="email" th:text="${user.mail}"></dd>
           <dt>Street</dt>
           <dd id="street" th:text="${user.street}"></dd>
       </span>
    </dl>

实际需要做的工作只是在我们header.html文件中添加一个链接,如下面的代码片段所示:

    //src/main/resources/templates/fragments/header.html

    <li>
    <p class="navbar-text">Welcome &nbsp;
 <a id="navMyAccount" th:href="@{/accounts/my}">         <div class="navbar-text" th:text="${#authentication.name}">
         User</div>
 </a>    </p>
    </li>

我们增加了以下两个用户,您可以使用它们来检查可用数据元素的区别:

用户名 密码 类型
shainet@example.com shainet inetOrgPerson
shaperson@example.com shaperson person

您的代码应该像chapter05.03-calendar

通过在右上角点击用户名,重新启动服务器并检查各种用户类型的账户详情页面。你会注意到,当UserDetails类配置为使用inetOrgPerson时,尽管返回的是o.s.s.ldap.userdetails.InetOrgPerson,但字段可能填充也可能不填充,这取决于目录条目的可用属性。

实际上,inetOrgPerson有更多我们在这个简单页面上说明的属性。您可以在RFC 2798中查看完整列表,《inetOrgPerson 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

    @Override
    public void configure(AuthenticationManagerBuilder auth)
    throws Exception {
       auth
         .ldapAuthentication()
         .userSearchBase("")
         .userSearchFilter("(uid={0})")
        .groupSearchBase("ou=Groups")
         .groupSearchFilter("(uniqueMember={0})")
         .userDetailsContextMapper(new InetOrgPersonContextMapper())
         .contextSource(contextSource())
         .passwordCompare()
            .passwordAttribute("telephoneNumber");
    }

我们可以重新启动服务器,并尝试使用hasphone@example.com作为username0123456789作为password(电话号码)属性进行登录。

您的代码应该像chapter05.04-calendar

当然,这种基于PasswordComparisonAuthenticator的认证方式具有我们之前讨论过的所有风险;然而,了解它是明智的,以防在 LDAP 实现中遇到它。

使用 LDAP 作为 UserDetailsService

需要指出的一点是,LDAP 也可以用作UserDetailsService。正如我们将在书中稍后讨论的,UserDetailsService是启用 Spring Security 基础架构中各种其他功能所必需的,包括记住我和 OpenID 认证功能。

我们将修改我们的AccountController对象,使其使用LdapUserDetailsService接口来获取用户。在这样做之前,请确保删除以下代码片段中的passwordCompare方法:

    //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java

    @Override
    public void configure(AuthenticationManagerBuilder auth)
    throws Exception {
       auth
         .ldapAuthentication()
         .userSearchFilter("(uid={0})")
         .groupSearchBase("ou=Groups")
         .userDetailsContextMapper(new InetOrgPersonContextMapper())
         .contextSource(contextSource());
    }

配置 LdapUserDetailsService

将 LDAP 配置为UserDetailsService的功能与配置 LDAPAuthenticationProvider非常相似。与 JDBCUserDetailsService一样,LDAPUserDetailsService接口被配置为<http>声明的兄弟。请对SecurityConfig.java文件进行以下更新:

    //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java

    @Bean
    @Override
    public UserDetailsService userDetailsService() {
       return super.userDetailsService();
   }

从功能上讲,o.s.s.ldap.userdetails.LdapUserDetailsService的配置几乎与LdapAuthenticationProvider完全相同,不同之处在于这里没有尝试使用主体的用户名来绑定 LDAP。相反,DefaultSpringSecurityContextSource提供的凭据本身就是参考,用来执行用户查找。

不要犯一个非常常见的错误,即如果你打算使用 LDAP 本身来验证用户,就不要将AuthenticationManagerBuilder配置为引用LdapUserDetailsServiceUserDetailsService!如前所述,由于安全原因,通常无法从 LDAP 中检索password属性,这使得UserDetailsService对于认证毫无用处。如前所述,LdapUserDetailsService使用与DefaultSpringSecurityContextSource声明一起提供的baseDn属性来获取其信息-这意味着它不会尝试将用户绑定到 LDAP,因此可能不会如你所预期的那样运行。

更新 AccountController 以使用 LdapUserDetailsService

现在我们将更新AccountController对象,使其使用LdapDetailsUserDetailsService接口来查找它显示的用户:

    //src/main/java/com/packtpub/springsecurity/web/controllers/AccountController.java

    @Controller
    public class AccountController {
    private final UserDetailsService userDetailsService;
    @Autowired
    public AccountController(UserDetailsService userDetailsService) {
       this.userDetailsService = userDetailsService;
    }
    @RequestMapping("/accounts/my")
    public String view(Model model) {
       Authentication authentication = SecurityContextHolder.
       getContext().getAuthentication();
       // null check omitted
       String principalName = authentication.getName();
       Object principal = userDetailsService.
       loadUserByUsername(principalName);
       ...
    }
    }

显然,这个例子有点傻,但它演示了如何使用LdapUserDetailsService。请重新启动应用程序,使用usernameadmin1@example.compasswordadmin1来尝试一下。你能弄清楚如何修改控制器以显示任意用户的信息吗?

你能弄清楚应该如何修改安全设置以限制管理员访问吗?

你的代码应该看起来像chapter05.05-calendar

将 Spring Security 与外部 LDAP 服务器集成

测试了与嵌入式 LDAP 服务器的基本集成之后,你可能会想要与一个外部 LDAP 服务器进行交互。幸运的是,这非常直接,并且可以使用稍微不同的语法,外加我们提供给设置嵌入式 LDAP 服务器的相同的DefaultSpringSecurityContextSource指令来实现。

更新 Spring Security 配置以连接到端口33389的外部 LDAP 服务器,如下所示:

    //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java

    @Override
    public void configure(AuthenticationManagerBuilder auth)
    throws Exception {
       auth
        .ldapAuthentication()
         .userSearchFilter("(uid={0})")
         .groupSearchBase("ou=Groups")
         .userDetailsContextMapper(new InetOrgPersonContextMapper())
         //.contextSource(contextSource())
 .contextSource() .managerDn("uid=admin,ou=system") .managerPassword("secret") .url("ldap://localhost:33389/dc=jbcpcalendar,dc=com");    }

这里的主要区别(除了 LDAP URL 之外)在于提供了账户的 DN 和密码。账户(实际上是可选的)应该被允许绑定到目录并在所有相关的 DN 上执行用户和组信息的搜索。这些凭据应用于 LDAP 服务器 URL 后,用于在 LDAP 安全系统中的其余 LDAP 操作。

请注意,许多 LDAP 服务器还支持通过 SSL 加密的 LDAP(LDAPS)——这当然是从安全角度考虑的首选,并且得到了 Spring 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

    @Override
    public void configure(AuthenticationManagerBuilder auth)
    throws Exception {
       auth
         .ldapAuthentication()
         .userSearchBase("")
         .userSearchFilter("(uid={0})")
         .groupSearchBase("ou=Groups")
         .groupSearchFilter("(uniqueMember={0})")
         .userDetailsContextMapper(new InetOrgPersonContextMapper())
 .contextSource() .managerDn("uid=admin,ou=system") .managerPassword("secret") .url("ldap://localhost:10389/dc=jbcpcalendar,dc=com") .root("dc=jbcpcalendar,dc=com") .ldif("classpath:/ldif/calendar.ldif")           .and()
               .passwordCompare()
                .passwordEncoder(new LdapShaPasswordEncoder())
                .passwordAttribute("userPassword")
       ;
    }

如果这还不能让你信服,可以尝试使用 Apache Directory Studio 启动一个 LDAP 服务器,并把它里面的calendar.ldif文件导入进去。这样你就可以连接到外部的 LDAP 服务器了。然后重启应用程序,使用usernameshauser@example.compasswordshauser来尝试这个。

你的代码应该看起来像chapter05.06-calendar

显式 LDAP bean 配置

在本节中,我们将引导您完成一系列必要的 bean 配置,以显式配置与外部 LDAP 服务器的连接和实现对外部服务器进行身份验证所需的LdapAuthenticationProvider接口。与其他显式 bean-based 配置一样,除非您发现自己处于业务或技术要求无法支持安全命名空间配置方式的情况,否则您真的应该避免这样做。如果是这种情况,请继续阅读!

配置外部 LDAP 服务器引用

为了实现此配置,我们将假设我们有一个本地 LDAP 服务器正在端口10389上运行,具有与上一节中提供的DefaultSpringSecurityContextSource接口对应的相同配置。所需的 bean 定义已经在SecurityConfig.java文件中提供。实际上,为了保持事情简单,我们提供了整个SecurityConfig.java文件。请查看以下代码片段中的 LDAP 服务器参考:

    //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java

    @Bean
    public DefaultSpringSecurityContextSource contextSource() {return new    
    DefaultSpringSecurityContextSource(
       Arrays.asList("ldap://localhost:10389/"), 
       "dc=jbcpcalendar,dc=com"){{
          setUserDn("uid=admin,ou=system");
          setPassword("secret");
    }};
    }

接下来,我们需要配置LdapAuthenticationProvider,这有点复杂。

配置LdapAuthenticationProvider接口

如果您已经阅读并理解了本章中的解释,描述了 Spring Security LDAP 认证背后的原理,这个 bean 配置将完全可理解,尽管有点复杂。我们将使用以下特性配置LdapAuthenticationProvider

  • 用户凭据绑定认证(不进行密码比较)

  • UserDetailsContextMapper中使用InetOrgPerson

请查看以下步骤:

  1. 让我们开始吧-我们首先探索已经配置好的LdapAuthenticationProvider接口,如下所示:
        //src/main/java/com/packtpub/springsecurity/configuration/
        SecurityConfig.java

        @Bean
        public LdapAuthenticationProvider authenticationProvider 
        (BindAuthenticator ba,LdapAuthoritiesPopulator lap,
         \UserDetailsContextMapper cm){
            return new LdapAuthenticationProvider(ba, lap){{
              setUserDetailsContextMapper(cm);
           }};
        }
  1. 下一个为我们提供的 bean 是BindAuthenticator,支持FilterBasedLdapUserSearchbean 用于在 LDAP 目录中定位用户 DN,如下所示:
        //src/main/java/com/packtpub/springsecurity/configuration/
        SecurityConfig.java

        @Bean
        public BindAuthenticator bindAuthenticator
        (FilterBasedLdapUserSearch userSearch)
        {
            return new BindAuthenticator(contextSource()){{
               setUserSearch(userSearch);
           }};
       }
        @Bean
        public FilterBasedLdapUserSearch filterBasedLdapUserSearch(){
           return new FilterBasedLdapUserSearch("", 
           //user-search-base "(uid={0})", //user-search-filter
           contextSource()); //ldapServer
        }

最后,LdapAuthoritiesPopulatorUserDetailsContextMapper执行我们本章早些时候探讨的角色:

            //src/main/java/com/packtpub/springsecurity/configuration/
            SecurityConfig.java

            @Bean
            public LdapAuthoritiesPopulator authoritiesPopulator(){
               return new DefaultLdapAuthoritiesPopulator(contextSource(),
               "ou=Groups"){{
                  setGroupSearchFilter("(uniqueMember={0})");
           }};
        }
        @Bean
        public userDetailsContextMapper userDetailsContextMapper(){
           return new InetOrgPersonContextMapper();
        }
  1. 在下一步中,我们必须更新 Spring Security 以使用我们显式配置的LdapAuthenticationProvider接口。更新SecurityConfig.java文件以使用我们的新配置,确保您删除旧的ldapAuthentication方法,如下所示:
        //src/main/java/com/packtpub/springsecurity/configuration/
        SecurityConfig.java

 @Autowired private LdapAuthenticationProvider authenticationProvider;        @Override
        public void configure(AuthenticationManagerBuilder auth)
        throws Exception {
 auth.authenticationProvider(authenticationProvider);        }

至此,我们已经使用显式的 Spring bean 表示法完全配置了 LDAP 身份验证。在 LDAP 集成中使用此技术在某些情况下是有用的,例如当安全命名空间不暴露某些配置属性,或者需要提供针对特定业务场景的自定义实现类时。我们将在本章后面探讨这样一个场景,即如何通过 LDAP 连接到 Microsoft AD。

  1. 请启动应用程序并尝试使用usernameshauser@example.compasswordshauser的配置。假设您有一个外部运行的 LDAP 服务器,或者您保留了对配置的内存中DefaultSpringSecurityContextSource对象,一切应该仍然可以正常工作。

您的代码应该看起来像chapter05.07-calendar

将角色发现委派给 UserDetailsService

一种填充可用于显式 bean 配置的用户角色的技术是实现UserDetailsService中按用户名查找用户的支持,并从此来源获取GrantedAuthority对象。配置像替换带有ldapAuthoritiesPopulator ID 的 bean 一样简单,使用一个更新的UserDetailsServiceLdapAuthoritiesPopulator对象,带有对UserDetailsService的引用。确保您在SecurityConfig.java文件中进行以下更新,并确保您移除之前的ldapAuthoritiesPopulatorbean 定义:

    //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java

    //@Bean
    //public LdapAuthoritiesPopulator authoritiesPopulator(){
        //return new DefaultLdapAuthoritiesPopulator(contextSource(),
       //"ou=Groups"){{
              //setGroupSearchFilter("(uniqueMember={0})");
        //   }};
      //}
    @Bean
    public LdapAuthoritiesPopulator authoritiesPopulator(
       UserDetailsService userDetailsService){ 
 return new UserDetailsServiceLdapAuthoritiesPopulator
         (userDetailsService);
    }

我们还需要确保我们已经定义了userDetailsService。为了简单起见,请添加如下所示的内存UserDetailsService接口:

    //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java

    @Bean
    @Override
    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;
    }

现在您应该能够使用admin1@example.com作为usernameadmin1作为password进行身份验证。当然,我们也可以用这种在内存中的UserDetailsService接口替换我们在第四章《基于 JDBC 的认证》和第五章《使用 Spring Data 的认证》中讨论的基于 JDBC 或 JPA 的接口。

您的代码应该看起来像chapter05.08-calendar

您可能会注意到这种方法在管理上的问题是,用户名和角色必须在 LDAP 服务器和UserDetailsService使用的存储库中进行管理-这可能对于大型用户基础来说不是一个可扩展的模型。

这种情况更常见的使用方式是在需要通过 LDAP 身份验证来确保受保护应用程序的用户是有效的企业用户,但应用程序本身希望存储授权信息。这使得潜在的应用程序特定数据不会出现在 LDAP 目录中,这可以是一个有益的关注点分离。

通过 LDAP 集成微软 Active Directory

微软 AD 的一个方便的功能不仅仅是它与基于微软 Windows 的网络架构的无缝集成,而且还因为它可以配置为使用 LDAP 协议暴露 AD 的内容。如果您在一个大量利用微软 Windows 的公司工作,那么您很可能要针对您的 AD 实例进行任何 LDAP 集成。

根据您对微软 AD 的配置(以及目录管理员的配置意愿,以支持 Spring Security LDAP),您可能会在将 AD 信息映射到 Spring Security 系统中的用户GrantedAuthority对象上遇到困难,而不是在认证和绑定过程中遇到困难。

在我们 LDAP 浏览器中的 JBCP 日历企业 AD LDAP 树与以下屏幕截图相似:

这里您看不到的是我们之前在样本 LDAP 结构中看到的ou=Groups;这是因为 AD 将组成员资格存储在用户自身的 LDAP 条目的属性中。

让我们用最近学到的显式 bean 配置知识来编写一个LdapAuthoritiesPopulator的实现,这个实现可以从用户的memberOf属性中获取GrantedAuthority。在下一节中,你可以找到这个章节示例代码中提供的ActiveDirectoryLdapAuthoritiesPopulator.java文件:

    //src/main/java/com/packtpub/springsecurity/ldap/userdetails/ad/
    ActiveDirectoryLdapAuthoritiesPopulator.java

    public final class ActiveDirectoryLdapAuthoritiesPopulator
    implements LdapAuthoritiesPopulator {
       public Collection<? extends GrantedAuthority>
         getGrantedAuthorities(DirContextOperations userData, String
          username) {
           String[] groups = userData.getStringAttributes("memberOf");
           List<GrantedAuthority> authorities = new 
            ArrayList<GrantedAuthority>();
         for (String group : groups) {
           LdapRdn authority = new DistinguishedName(group).removeLast();
           authorities.add(new SimpleGrantedAuthority
           (authority.getValue()));
       }
       return authorities;
    }
    }

现在,我们需要修改我们的配置以支持我们的 AD 结构。假设我们是从前一部分详细介绍的 bean 配置开始的,做以下更新:

    //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java

    @Bean
    public DefaultSpringSecurityContextSource contextSource() {
       return new DefaultSpringSecurityContextSource(Arrays.asList
       ("ldap://corp.jbcpcalendar.com/"), "dc=corp,dc=jbcpcalendar,
        dc=com"){{     
             setUserDn("CN=Administrator,CN=Users," +                  
             "DC=corp,DC=jbcpcalendar,DC=com");
             setPassword("admin123!");
       }};
    }
    @Bean
    public LdapAuthenticationProvider authenticationProvider(                                    
    BindAuthenticator ba, LdapAuthoritiesPopulator lap){
       // removed UserDetailsContextMapper
       return new LdapAuthenticationProvider(ba, lap);
    }
    @Bean
    public FilterBasedLdapUserSearch filterBasedLdapUserSearch(){
       return new FilterBasedLdapUserSearch("CN=Users", //user-search-base
 "(sAMAccountName={0})", //user-search-filter       contextSource()); //ldapServer
    }
    @Bean
    public LdapAuthoritiesPopulator authoritiesPopulator(){
 return new ActiveDirectoryLdapAuthoritiesPopulator();    }

如果你定义了它,你将希望在SecurityConfig.java文件中删除UserDetailsService声明。最后,你还需要从AccountController中删除对UserDetailsService的引用。

sAMAccountName属性是我们在标准 LDAP 条目中使用的uid属性的 AD 等效物。尽管大多数 AD LDAP 集成可能比这个例子更复杂,但这应该能给你一个起点,让你跳进去并探索你对 Spring Security LDAP 集成的内部工作原理的概念理解;即使是支持一个复杂的集成也会容易得多。

如果你想要运行这个示例,你需要一个运行中的 AD 实例,其模式与屏幕截图中显示的模式匹配。另一种选择是调整配置以匹配你的 AD 模式。玩转 AD 的一个简单方法是安装Active Directory Lightweight Directory Services,可以在www.microsoft.com/download/en/details.aspx?id=14683找到。你的代码应该看起来像chapter05.09-calendar

Spring Security 4.2 中的内置 AD 支持

Spring Security 在 Spring Security 3.1 中增加了 AD 支持。事实上,前一部分的ActiveDirectoryLdapAuthoritiesPopulator类就是基于新增加的支持。为了使用 Spring Security 4.2 中的内置支持,我们可以用以下配置替换我们的整个SecurityConfig.java文件:

    //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;
    }

当然,如果你打算使用它,你需要确保将其连接到AuthenticationManager。我们已经完成了这一点,但你可以在以下代码片段中找到配置的样子:

    //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java

    @Autowired
    private AuthenticationProvider authenticationProvider;
    @Override
    public void configure(AuthenticationManagerBuilder auth)
    throws Exception {
       auth.authenticationProvider(authenticationProvider);
   }

关于提供的ActiveDirectoryLdapAuthenticationProvider类,以下几点需要注意:

  • 需要进行身份验证的用户必须能够绑定到 AD(没有管理员用户。

  • 默认的方法是搜索用户的memberOf属性来填充用户的权限。

  • 用户必须包含一个名为userPrincipalName的属性,其格式为username@<domain>。这里,<domain>ActiveDirectoryLdapAuthenticationProvider的第一个构造参数。这是因为,在绑定发生之后,就是这样找到memberOf查找上下文的。

由于现实世界中发生的复杂 LDAP 部署,内置支持很可能会提供一个指导,告诉你如何与自定义 LDAP 架构集成。

摘要

我们看到,当请求时,LDAP 服务器可以可靠地提供认证和授权信息,以及丰富的用户配置文件信息。在本章中,我们介绍了 LDAP 术语和概念,以及 LDAP 目录可能如何通常组织以与 Spring Security 配合工作。我们还探索了从 Spring Security 配置文件中配置独立(嵌入式)和外部 LDAP 服务器的方法。

我们讨论了将用户对 LDAP 仓库的认证和授权,以及随后映射到 Spring Security 参与者。我们还了解了 LDAP 中认证方案、密码存储和安全机制的差异,以及它们在 Spring Security 中的处理方式。我们还学会了将用户详细属性从 LDAP 目录映射到UserDetails对象,以便在 LDAP 和 Spring 启用应用程序之间进行丰富的信息交换。我们还明确地为 LDAP 配置了 bean,并讨论了这种方法的优缺点。

我们还讨论了与 AD 的集成。

在下一章中,我们将讨论 Spring Security 的记住我功能,该功能允许用户会话在关闭浏览器后仍然安全地保持。

第七章:记住我服务

在本章中,我们将添加一个应用程序即使在会话过期且浏览器关闭后也能记住用户的功能。本章将涵盖以下主题:

  • 讨论什么是记住我

  • 学习如何使用基于令牌的记住我功能

  • 讨论记住我有多安全,以及使其更安全的各种方法

  • 启用基于持久性的记住我功能,以及使用它时要考虑的额外问题

  • 介绍整体的记住我架构

  • 学习如何创建一个限制在用户 IP 地址上的自定义记住我实现

什么是记住我?

为网站的常客提供的一个便利功能是记住我功能。此功能允许用户在浏览器关闭后选择被记住。在 Spring Security 中,这是通过在用户浏览器中存储一个记住我 cookie 来实现的。如果 Spring Security 识别到用户正在出示一个记住我 cookie,那么用户将自动登录应用程序,无需输入用户名或密码。

什么是 cookie?

Cookie 是客户端(即 Web 浏览器)保持状态的一种方式。有关 cookie 的更多信息,请参考其他在线资源,例如维基百科(en.wikipedia.org/wiki/HTTP_cookie)。

Spring Security 在本章提供了以下两种不同的策略,我们将在此讨论:

  • 第一个是基于令牌的记住我功能,它依赖于加密签名

  • 第二个方法,基于持久性的记住我功能,需要一个数据存储(数据库)

如我们之前提到的,我们将在本章中详细讨论这些策略。为了启用记住我功能,必须显式配置记住我功能。让我们先尝试基于令牌的记住我功能,看看它如何影响登录体验的流程。

依赖项

基于令牌的记住我部分除了第第二章 Spring Security 入门中的基本设置外,不需要其他依赖项。然而,如果你正在使用基于持久性的记住我功能,你需要在你的pom.xml文件中包含以下额外的依赖项。我们已经在章节的示例中包含了这些依赖项,所以不需要更新示例应用程序:

    //build.gradle

    dependencies {
    // JPA / ORM / Hibernate:
 compile('org.springframework.boot:spring-boot-starter-data-jpa')    // H2 RDBMS
 runtime('com.h2database:h2')       ...
    }

基于令牌的记住我功能

Spring Security 提供了记住我功能的两种不同实现。我们将首先探索如何设置基于令牌的记住我服务。

配置基于令牌的记住我功能

完成此练习将允许我们提供一种简单且安全的方法,使用户在较长时间内保持登录。开始时,请执行以下步骤:

  1. 修改SecurityConfig.java配置文件,添加rememberMe方法。

请查看以下代码片段:

        //src/main/java/com/packtpub/springsecurity/configuration/
        SecurityConfig.java

        @Override
        protected void configure(HttpSecurity http) throws Exception {
           ...
           http.rememberMe().key("jbcpCalendar")
           ...
        }

你应该从chapter07.00-calendar开始。

  1. 如果我们现在尝试运行应用程序,我们会发现流程中没有不同。这是因为我们还需要在登录表单中添加一个字段,允许用户选择此功能。编辑login.html文件,并添加一个复选框,如下面的代码片段所示:
        //src/main/resources/templates/login.html

        <input type="password" id="password" name="password"/>
 <label for="remember-me">Remember Me?</label> <input type="checkbox" id="remember-me" name="remember_me" value="true"/>
        <div class="form-actions">
           <input id="submit" class="btn" name="submit" type="submit" 
           value="Login"/>
        </div>

您的代码应该看起来像chapter07.01-calendar

  1. 当我们下次登录时,如果选择了记住我框,则在用户的浏览器中设置了记住我 cookie。

Spring Security 理解它应该通过检查 HTTP 参数remember_me来记住用户。

在 Spring Security 3.1 及更早版本中,记住我表单字段的默认参数是spring_security_remember_me。现在,在 Spring Security 4.x 中,默认的记住我表单字段是remember-me。这可以通过rememberMeParameter方法来覆盖。

  1. 如果用户然后关闭他的浏览器,重新打开它以登录 JBCP 日历网站的认证页面,他/她不会第二次看到登录页面。现在试试自己-选择记住我选项登录,将主页添加到书签中,然后重新启动浏览器并访问主页。您会看到,您会立即成功登录,而无需再次提供登录凭据。如果这种情况出现在您身上,这意味着您的浏览器或浏览器插件正在恢复会话。

先尝试关闭标签页,然后再关闭浏览器。

另一个有效的方法是使用浏览器插件,如Firebugaddons.mozilla.org/en-US/firefox/addon/firebug/),以删除JSESSIONIDcookie。这通常可以在开发和验证您网站上此类功能时节省时间和烦恼。

登录后选择记住我,你应该会看到已经设置了两个 cookie,JSESSIONIDremember-me,如下面的截图所示:

基于令牌的记住我功能是如何工作的

记住我功能在用户的浏览器中设置一个 cookie,包含一个 Base64 编码的字符串,包含以下内容:

  • 用户名

  • 过期日期/时间

  • expiration日期/时间的 MD5 散列值、usernamepassword以及rememberMe方法的key属性。

这些被组合成一个单一的 cookie 值,存储在浏览器中供以后使用。

MD5

MD5 是几种著名的加密散列算法之一。加密散列算法计算具有任意长度的输入数据的最紧凑且唯一的文本表示,称为摘要。这个摘要可以用来确定是否应该信任一个不可信的输入,通过将不可信输入的摘要与预期输入的有效摘要进行比较。

以下图表说明了它是如何工作的:

例如,许多开源软件网站允许镜像站点分发它们的软件,以帮助提高下载速度。然而,作为软件的用户,我们希望确保软件是真实的,并且不包含任何病毒。软件分发商将计算并在其网站上发布与他们已知的好版本软件对应的预期 MD5 校验和。然后,我们可以从任何位置下载文件。在安装软件之前,我们对下载的文件计算不信任的 MD5 校验和。然后,我们将不信任的 MD5 校验和与预期的 MD5 校验和进行比较。如果这两个值匹配,我们就知道可以安全地安装我们下载的文件。如果这两个值不匹配,我们不应该信任下载的文件并删除它。

尽管无法从哈希值中获取原始数据,但 MD5 算法存在多种攻击风险,包括利用算法本身的弱点以及彩虹表攻击。彩虹表通常包含数百万输入值预先计算的哈希值。这使得攻击者可以在彩虹表中查找哈希值,并确定实际的(未哈希)值。Spring Security 通过在哈希值中包括过期日期、用户的密码和记住我键来对抗这种风险。

记住我签名

我们可以看到 MD5 如何确保我们下载了正确的文件,但这与 Spring Security 的记住我服务有何关联呢?与下载的文件类似,cookie 是不信任的,但如果我们能验证来自我们应用程序的签名,我们就可以信任它。当带有记住我 cookie 的请求到来时,其内容被提取,期望的签名与 cookie 中找到的签名进行比较。计算期望签名的步骤在下图中说明:

记住我 cookie 包含用户名过期时间和一个签名。Spring Security 将从中提取用户名过期时间。然后使用来自 cookie 的username通过UserDetailsService查找密码密钥已知,因为它是通过rememberMe方法提供的。现在所有参数都知道了,Spring Security 可以使用用户名过期时间密码密钥计算期望的签名。然后,它将期望签名与 cookie 中的签名进行比较。

如果两个签名匹配,我们可以确信用户名过期日期是有效的。不知道记住我密钥(只有应用程序知道)和用户密码(只有这个用户知道)的情况下伪造签名几乎是不可能的。这意味着如果签名匹配且令牌没有过期,用户可以登录。

您可能已经预见到,如果用户更改了他们的用户名或密码,设置的任何记住我令牌将不再有效。确保如果您允许用户更改账户这些部分,您要向用户提供适当的消息。在本章后面,我们将查看一个仅依赖于用户名而非密码的替代记住我实现。

请注意,仍然可以区分已通过记住我 cookie 进行身份验证的用户和提供用户名和密码(或等效)凭据的用户。当我们调查记住我功能的安全性时,我们将很快尝试这一点。

基于令牌的记住我配置指令

以下是对记住我功能默认行为进行更改的两个常见配置更改:

属性 描述
key 定义用于生成记住我 cookie 签名时使用的唯一键。
tokenValiditySeconds 定义时间长度(以秒为单位)。记住我 cookie 将被视为用于身份验证的有效 cookie。它还用于设置 cookie 的过期时间戳。

正如您可能从讨论 cookie 内容是如何散列中推断出key属性对记住我功能的安全性至关重要。确保您选择的键很可能是您应用程序唯一的,并且足够长,以至于它不能轻易被猜测。

考虑到本书的目的,我们保留了键值相对简单,但如果你在自己的应用程序中使用记住我,建议你的键包含应用程序的唯一名称,并且至少 36 个随机字符长。密码生成工具(在 Google 中搜索“在线密码生成器”)是获得假随机字母数字和特殊字符混合来组成你的记住我键的好方法。对于存在于多个环境中的应用程序(例如开发、测试和生产),记住我 cookie 值也应该包括这个事实。这将防止在测试过程中无意中使用错误的环境的记住我 cookie!

生产应用程序中的一个示例键值可能与以下内容相似:

    prodJbcpCalendar-rmkey-paLLwApsifs24THosE62scabWow78PEaCh99Jus

tokenValiditySeconds方法用于设置记住我令牌在自动登录功能中不再被接受的时间秒数,即使它本身是一个有效的令牌。相同的属性也用于设置用户浏览器上记住我 cookie 的最大生命周期。

记住我会话 cookie 的配置

如果tokenValiditySeconds设置为-1,登录 cookie 将被设置为会话 cookie,用户关闭浏览器后它不会持续存在。令牌将在用户不关闭浏览器的情况下,有效期为两周的不可配置长度。不要将此与存储用户会话 ID 的 cookie 混淆——它们名称相似,但完全是两回事!

您可能注意到我们列出的属性非常少。别担心,我们将在本章中花时间介绍一些其他配置属性。

记住我是否安全?

任何为了用户方便而添加的安全相关特性都有可能使我们精心保护的网站面临安全风险。默认形式的记住我功能,存在用户 cookie 被拦截并恶意用户重复使用的风险。以下图表说明了这可能如何发生:

使用 SSL(在附录附加参考资料中有所涉及)和其他网络安全技术可以减轻这类攻击,但要注意还有其他技术,比如跨站脚本攻击XSS),可能会窃取或破坏记住的用户会话。虽然这对用户方便,但如果我们不慎使用记住的会话,可能会导致财务或其他个人信息被无意修改或可能被盗用。

虽然本书没有详细讨论恶意用户行为,但在实现任何安全系统时,了解可能试图攻击您客户或员工的用户所采用的技术是很重要的。XSS 就是这样的技术,但还有很多其他技术。强烈建议您查阅OWASP 前十名文章www.owasp.org/index.php/Category:OWASP_Top_Ten_Project)获取一个不错的列表,并且也可以获取一本关于网络应用安全性的参考书籍,在这本书中,许多演示的技术都适用于任何技术。

保持方便和安全之间平衡的一种常见方法是识别网站上可能存在个人或敏感信息的职能位置。然后,您可以使用fullyAuthenticated表达式确保这些位置通过检查用户角色以及他们是否使用完整用户名和密码进行身份验证的保护。我们将在下一节更详细地探讨这一特性。

记住我功能的授权规则

我们将在第十一章细粒度访问控制中全面探讨高级授权技术,不过,重要的是要意识到可以根据记住的认证会话与否来区分访问规则。

假设我们想要限制尝试访问 H2 admin 控制台的用户只能是使用用户名和密码认证的管理员。这与其他主要面向消费者的商业网站的行为类似,这些网站在输入密码之前限制对网站高级部分的访问。请记住,每个网站都是不同的,所以不要盲目地将此类规则应用于您的安全网站。对于我们的示例应用程序,我们将专注于保护 H2 数据库控制台。更新SecurityConfig.java文件以使用关键词fullyAuthenticated,确保尝试访问 H2 数据库的记住用户被拒绝访问。这显示在下面的代码片段中:

    //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java

    @Override
    protected void configure(HttpSecurity http) throws Exception {
       ...
       http.authorizeRequests()
 .antMatchers("/admin/*") .access("hasRole(ADMIN) and isFullyAuthenticated()")       ...
       http.rememberMe().key("jbcpCalendar")
    }

现有的规则保持不变。我们增加了一条规则,要求查询账户信息时必须拥有适当的GrantedAuthority of ROLE_ADMIN,并且用户已经完全认证;也就是说,在这个认证会话期间,他们实际上提供了一个用户名和密码或其他合适的凭据。注意这里 SpEL 逻辑运算符的语法-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 文件放在了resources文件夹中,位置与第三章 自定义认证中的位置相同。您可以在下面的代码片段中查看模式定义:

    //src/main/resources/schema.sql

    ...
    create table persistent_logins (
       username varchar_ignorecase(100) not null,
       series varchar(64) primary key,
       token varchar(64) not null,
       last_used timestamp not null
    );
    ...

使用记住我模式初始化数据源

Spring Data 将自动使用schema.sql初始化嵌入式数据库,如前一部分所述。请注意,但是,对于 JPA,为了创建模式并使用data.sql文件来种子数据库,我们必须确保设置了ddl-auto到 none,如下面的代码所示:

    //src/main/resources/application.yml

    spring:
    jpa:
       database-platform: org.hibernate.dialect.H2Dialect
       hibernate:
 ddl-auto: none

配置基于持久化的记住我功能

最后,我们需要对rememberMe声明进行一些简要的配置更改,以指向我们正在使用的数据源,如下面的代码片段所示:

   //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java

   @Autowired
   @SuppressWarnings("SpringJavaAutowiringInspection")
 private DataSource dataSource;    @Autowired
 private PersistentTokenRepository persistentTokenRepository;    @Override
    protected void configure(HttpSecurity http) throws Exception {
       ...
       http.rememberMe()
           .key("jbcpCalendar")
 .tokenRepository(persistentTokenRepository)       ...
    }
 @Bean public PersistentTokenRepository persistentTokenRepository() { JdbcTokenRepositoryImpl db = new JdbcTokenRepositoryImpl(); db.setDataSource(dataSource); return db; }

这就是我们需要做的,以便切换到基于持久化的记住我认证。大胆地启动应用程序并尝试一下。从用户的角度来看,我们感觉不到任何区别,但我们知道支持这个功能的实现已经发生了变化。

您的代码应该看起来像chapter07.03-calendar

持久化基于的记住我功能是如何工作的?

持久化基于的记住我服务不是验证 cookie 中的签名,而是验证令牌是否存在于数据库中。每个持久记住我 cookie 包括以下内容:

  • 序列标识符:这标识了用户的初始登录,并且每次用户自动登录到原始会话时都保持一致。

  • 令牌值:每次用户使用记住我功能进行身份验证时都会变化的唯一值。

请查看以下图表:

当记住我 cookie 提交时,Spring Security 将使用o.s.s.web.authentication.rememberme.PersistentTokenRepository实现来查找期望的令牌值和使用提交序列标识的过期时间。然后,它将比较 cookie 中的令牌值与期望的令牌值。如果令牌没有过期且两个令牌匹配,用户被认为是认证的。将生成一个新的记住我 cookie,具有相同的序列标识符、新的令牌值和更新的过期日期。

如果在数据库中找到了提交的序列令牌,但令牌不匹配,可以假设有人偷了记住我 cookie。在这种情况下,Spring Security 将终止这些记住我令牌,并警告用户他们的登录已经被泄露。

存储的令牌可以在数据库中找到,并通过 H2 控制台查看,如下面的屏幕截图所示:

JPA 基础持久化令牌存储库

正如我们之前章节所看到的,使用 Spring Data 项目来映射我们的数据库可以大大简化我们的工作。因此,为了保持一致性,我们将重构我们的基于 JDBC 的PersistentTokenRepository接口,该接口使用JdbcTokenRepositoryImpl,改为基于 JPA 的。我们将通过执行以下步骤来实现:

  1. 首先,让我们创建一个领域对象来保存持久登录,如下面的代码片段所示:
        //src/main/java/com/packtpub/springsecurity/domain/
        PersistentLogin.java 

        import org.springframework.security.web.authentication.rememberme.
        PersistentRememberMeToken;
        import javax.persistence.*;
        import java.io.Serializable;
        import java.util.Date;
        @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();
           }
          ...
  1. 接下来,我们需要创建一个o.s.d.jpa.repository.JpaRepository仓库实例,如下面的代码片段所示:
        //src/main/java/com/packtpub/springsecurity/repository/
        RememberMeTokenRepository.java

        import com.packtpub.springsecurity.domain.PersistentLogin;
        import org.springframework.data.jpa.repository.JpaRepository;
        import java.util.List;
        public interface RememberMeTokenRepository extends  
        JpaRepository<PersistentLogin, String> {
            PersistentLogin findBySeries(String series);
            List<PersistentLogin> findByUsername(String username);
        }
  1. 现在,我们需要创建一个自定义的PersistentTokenRepository接口来替换Jdbc实现。我们必须重写四个方法,但代码应该相当熟悉,因为我们所有操作都将使用 JPA:
         //src/main/java/com/packtpub/springsecurity/web/authentication/
         rememberme/JpaPersistentTokenRepository.java:

         ...
         public class JpaPersistentTokenRepository implements 
         PersistentTokenRepository {
               private RememberMeTokenRepository rememberMeTokenRepository;
               public JpaPersistentTokenRepository
               (RememberMeTokenRepository rmtr) {
                  this.rememberMeTokenRepository = rmtr;
           }
           @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);
               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.delete(tokens);
           }
        }
  1. 现在,我们需要在SecurityConfig.java文件中做些修改,以声明新的PersistentTokenTokenRepository接口,但其余的配置与上一节保持不变,如下面的代码片段所示:
            //src/main/java/com/packtpub/springsecurity/configuration/
            SecurityConfig.java

            //@Autowired
            //@SuppressWarnings("SpringJavaAutowiringInspection")
            //private DataSource dataSource;
            @Autowired
 private PersistentTokenRepository persistentTokenRepository;            ...
 @Bean public PersistentTokenRepository persistentTokenRepository( RememberMeTokenRepository rmtr) { return new JpaPersistentTokenRepository(rmtr); }
  1. 这就是我们将 JDBC 更改为基于 JPA 的持久化记住我认证所需要做的一切。现在启动应用程序并尝试一下。从用户的角度来看,我们并没有注意到任何区别,但我们知道支持这一功能的实现已经发生了变化。

你的代码应该看起来像chapter07.04-calendar

自定义 RememberMeServices

到目前为止,我们使用了一个相当简单的PersistentTokenRepository实现。我们使用了基于 JDBC 和基于 JPA 的实现。这为 cookie 持久化提供了有限的控制;如果我们想要更多控制,我们将把我们自己的PersistentTokenRepository接口包装在RememberMeServices中。Barry Jaspan 有一篇关于改进持久登录 Cookie 最佳实践的优秀文章(jaspan.com/improved_persistent_login_cookie_best_practice)。Spring Security 有一个略有修改的版本,如前所述,称为PersistentTokenBasedRememberMeServices,我们可以将其包装在我们的自定义PersistentTokenRepository接口中,并在我们的记住我服务中使用。

在下一节中,我们将把我们的现有PersistentTokenRepository接口包装在PersistentTokenBasedRememberMeServices中,并使用rememberMeServices方法将其连接到我们的记住我声明:

    //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java

    //@Autowired
    //private PersistentTokenRepository persistentTokenRepository;
    @Autowired
    private RememberMeServices rememberMeServices;
    @Override
    protected void configure(HttpSecurity http) throws Exception {
       ...
       http.rememberMe()
           .key("jbcpCalendar")
 .rememberMeServices(rememberMeServices)       ...
    }
 @Bean public RememberMeServices rememberMeServices
    (PersistentTokenRepository ptr){ PersistentTokenBasedRememberMeServices rememberMeServices = new 
       PersistentTokenBasedRememberMeServices("jbcpCalendar", 
userDetailsService, ptr);
 rememberMeServices.setAlwaysRemember(true); return rememberMeServices; }

你的代码应该看起来像chapter07.05-calendar

基于数据库的持久令牌是否更安全?

就像TokenBasedRememberMeServices一样,持久化令牌可能会因 cookie 窃取或其他中间人技术而受到威胁。正如附录中提到的,使用 SSL 可以绕过中间人技术。如果你正在使用 Servlet 3.0 环境(即 Tomcat 7+),Spring Security 会将 cookie 标记为HttpOnly,这将有助于减轻在应用程序中出现 XSS 漏洞时 cookie 被窃取的风险。要了解更多关于HttpOnly属性的信息,请参阅本章前面提供的关于 cookie 的外部资源。

使用基于持久化的记住我功能的一个优点是我们可以检测 cookie 是否被泄露。如果正确的一系列令牌和一个不正确的令牌被呈现,我们知道使用该系列令牌的任何记住我功能都应被视为被泄露,我们应该终止与它关联的任何会话。由于验证是状态 ful 的,我们还可以在不更改用户密码的情况下终止特定的记住我功能。

清理过期的记住我会话

使用基于持久化的记住我功能的缺点是,没有内置的支持来清理过期的会话。为了做到这一点,我们需要实现一个后台进程来清理过期的会话。我们在本章的示例代码中包含了用于执行清理的代码。

为了简洁起见,我们显示一个不执行验证或错误处理的版本,如下面的代码片段所示。你可以在本章的示例代码中查看完整版本:

    //src/main/java/com/packtpub/springsecurity/web/authentication/rememberme/
    JpaTokenRepositoryCleaner.java

    public class JpaTokenRepositoryImplCleaner
    implements Runnable {
       private final RememberMeTokenRepository repository;
       private final long tokenValidityInMs;
       public JpaTokenRepositoryImplCleaner(RememberMeTokenRepository 
       repository, 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. repository = repository;
           this.tokenValidityInMs = tokenValidityInMs;
       }
           public void run() {
           long expiredInMs = System.currentTimeMillis() 
           - tokenValidityInMs;             
              try {
               Iterable<PersistentLogin> expired = 
               rememberMeTokenRepository
               .findByLastUsedAfter(new Date(expiredInMs));
               for(PersistentLogin pl: expired){
                   rememberMeTokenRepository.delete(pl);
               }
           } catch(Throwable t) {...}
       }
    }

本章的示例代码还包括一个简单的 Spring 配置,每十分钟执行一次清理器。如果你不熟悉 Spring 的任务抽象并且想学习,那么你可能想阅读更多关于它在 Spring 参考文档中的内容:docs.spring.io/spring/docs/current/spring-framework-reference/html/scheduling.html。你可以在以下代码片段中找到相关的配置。为了清晰起见,我们将这个调度器放在JavaConfig.java文件中:

    //src/main/java/com/packtpub/springsecurity/configuration/
    JavaConfig.java@Configuration

    @Import({SecurityConfig.class})
 @EnableScheduling    public class JavaConfig {
 @Autowired private RememberMeTokenRepository rememberMeTokenRepository; @Scheduled(fixedRate = 10_000) public void tokenRepositoryCleaner(){ Thread trct = new Thread(new JpaTokenRepositoryCleaner(
 rememberMeTokenRepository, 60_000L));
 trct.start(); }    }

请记住,此配置不是集群友好的。因此,如果部署到集群,清理器将针对应用程序部署到的每个 JVM 执行一次。

启动应用程序并尝试更新。提供的配置将确保每十分钟执行一次清理器。你可能想让清理任务更频繁地运行,通过修改@Scheduled声明来清理最近使用的记住我令牌。然后,你可以创建几个记住我令牌,并通过在 H2 数据库控制台查询它们来查看它们是否被删除。

你的代码应该看起来像chapter07.06-calendar

记住我架构

我们已经介绍了TokenBasedRememberMeServicesPersistentTokenBasedRememberMeServices的基本架构,但我们还没有描述总体架构。让我们看看所有 remember-me 部件是如何组合在一起的。

以下图表说明了验证基于令牌的 remember-me 令牌过程中涉及的不同组件:

与 Spring Security 的任何一个过滤器一样,RememberMeAuthenticationFilter是从FilterChainProxy内部调用的。RememberMeAuthenticationFilter的工作是检查请求,如果它感兴趣,就采取行动。RememberMeAuthenticationFilter接口将使用RememberMeServices实现来确定用户是否已经登录。RememberMeServices接口通过检查 HTTP 请求中的 remember-me cookie,然后使用我们之前讨论过的基于令牌的验证或基于持久性的验证来验证。如果令牌检查无误,用户将登录。

Remember-me 与用户生命周期

RememberMeServices的实现在整个用户生命周期中(认证用户的会话生命周期)的几个点被调用。为了帮助您理解 remember-me 功能,了解 remember-me 服务在生命周期功能通知的时间点可能会有所帮助:

操作 应该发生什么? 调用的 RememberMeServices 方法
登录成功 实现设置 remember-me cookie(如果已发送form参数) loginSuccess
登录失败 如果存在,实现应取消 cookie loginFailed
用户登出 如果存在,实现应取消 cookie logout

RememberMeServices接口上没有logout方法。相反,每个RememberMeServices实现也实现了LogoutHandler接口,该接口包含了logout方法。通过实现LogoutHandler接口,每个RememberMeServices实现可以在用户登出时执行必要的清理工作。

了解RememberMeServices在哪里以及如何与用户的生命周期相关联,在我们开始创建自定义认证处理程序时将非常重要,因为我们需要确保任何认证处理器一致地对待RememberMeServices,以保持这种功能的有效性和安全性。

限制 remember-me 功能到 IP 地址

让我们把我们对记住我架构的理解付诸实践。一个常见的要求是,任何记住我令牌都应与创建它的用户的 IP 地址绑定。这为记住我功能增加了额外的安全性。为此,我们只需要实现一个自定义的PersistentTokenRepository接口。我们将要做的配置更改将说明如何配置自定义的RememberMeServices。在本节中,我们将查看IpAwarePersistentTokenRepository,该类包含在章节源代码中。IpAwarePersistentTokenRepository接口确保内部将系列标识与当前用户的 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();
    return series + attributes.getRequest().getRemoteAddr();
    }

我们可以在此基础上构建方法,强制保存的令牌中包含在系列标识中的 IP 地址,如下所示:

    public void createNewToken(PersistentRememberMeToken token) {
      String ipSeries = ipSeries(token.getSeries());
      PersistentRememberMeToken ipToken = tokenWithSeries(token, ipSeries);
      this.delegateRepository.createNewToken(ipToken);
    }

你可以看到我们首先创建了一个新的系列,并将其与 IP 地址连接起来。tokenWithSeries方法只是一个创建具有所有相同值的新令牌的助手,除了新的系列。然后我们将包含 IP 地址的新系列标识的新令牌提交给delegateRepsository,这是PersistentTokenRepository的原始实现。

无论何时查找令牌,我们都要求将当前用户的 IP 地址附加到系列标识上。这意味着用户无法获取不同 IP 地址的用户的令牌:

    public PersistentRememberMeToken getTokenForSeries(String seriesId) {
       String ipSeries = ipSeries(seriesId);
       PersistentRememberMeToken ipToken = delegateRepository.
       getTokenForSeries(ipSeries);
       return tokenWithSeries(ipToken, seriesId);
    }

剩余的代码非常相似。内部我们构建的系列标识将附加到 IP 地址上,外部我们只展示原始系列标识。通过这样做,我们实施了这样的约束:只有创建了记住我令牌的用户才能使用它。

让我们回顾一下本章示例代码中包含的 Spring 配置,用于IpAwarePersistentTokenRepository。在以下代码片段中,我们首先创建了一个IpAwarePersistentTokenRepository声明,它包装了一个新的JpaPersistentTokenRepository声明。然后通过实例化OrderedRequestContextFilter接口来初始化一个RequestContextFilter类:

    //src/main/java/com/packtpub/springsecurity/web/configuration/WebMvcConfig.java

    @Bean
    public IpAwarePersistentTokenRepository 
    tokenRepository(RememberMeTokenRepository rmtr) {
       return new IpAwarePersistentTokenRepository(
               new JpaPersistentTokenRepository(rmtr)
       );
    }
    @Bean
    public OrderedRequestContextFilter requestContextFilter() {
       return new OrderedRequestContextFilter();
    }

为了让 Spring Security 使用我们的自定义RememberMeServices,我们需要更新我们的安全配置以指向它。接着,在SecurityConfig.java中进行以下更新:

    //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java

     @Override
     protected void configure(HttpSecurity http) throws Exception {
       ...
       // remember me configuration
      http.rememberMe()
           .key("jbcpCalendar")
 .rememberMeServices(rememberMeServices);     }
    @Bean
 public RememberMeServices rememberMeServices
    (PersistentTokenRepository ptr){
       PersistentTokenBasedRememberMeServices rememberMeServices = new 
       PersistentTokenBasedRememberMeServices("jbcpCalendar", 
       userDetailsService, ptr);
       return rememberMeServices;
    }

现在,大胆尝试启动应用程序。您可以使用第二台计算机和插件(如 Firebug),来操作您的 remember-me cookie。如果您尝试从一个计算机使用 remember-me cookie 在另一台计算机上,Spring Security 现在将忽略 remember-me 请求并删除相关 cookie。

您的代码应类似于chapter07.07-calendar

请注意,基于 IP 的 remember-me 令牌如果用户位于共享或负载均衡的网络基础架构后面,例如多 WAN 企业环境,可能会出现意外行为。然而,在大多数场景下,向 remember-me 功能添加 IP 地址为用户提供了一个额外的、受欢迎的安全层。

自定义 cookie 和 HTTP 参数名称

好奇的用户可能会想知道 remember-me 表单字段的预期值是否可以更改为 remember-me,或者 cookie 名称是否可以更改为 remember-me,以使 Spring Security 的使用变得模糊。这个更改可以在两个位置中的一个进行。请按照以下步骤查看:

  1. 首先,我们可以在rememberMe方法中添加额外的方法,如下所示:
        //src/main/java/com/packtpub/springsecurity/configuration/
        SecurityConfig.java

        http.rememberMe()
               .key("jbcpCalendar")
 .rememberMeParameter("jbcpCalendar-remember-me") .rememberMeCookieName("jbcpCalendar-remember-me");
  1. 此外,既然我们已经将自定义的RememberMeServices实现声明为 Spring bean,我们只需定义更多的属性来更改复选框和 cookie 名称,如下所示:
        //src/main/java/com/packtpub/springsecurity/configuration/
        SecurityConfig.java

        @Bean
        public RememberMeServices rememberMeServices
        (PersistentTokenRepository ptr){
           PersistentTokenBasedRememberMeServices rememberMeServices = new 
           PersistentTokenBasedRememberMeServices("jbcpCalendar", 
           userDetailsService, ptr);
 rememberMeServices.setParameter("obscure-remember-me"); rememberMeServices.setCookieName("obscure-remember-me");           return rememberMeServices;
        }
  1. 不要忘记将login.html页面更改为设置复选框form字段的名称,并与我们声明的参数值相匹配。接着,按照以下内容更新login.html
        //src/main/resources/templates/login.html

        <input type="checkbox" id="remember" name=" obscure-remember-me" 
        value="true"/>
  1. 我们鼓励您在此处进行实验,以确保您了解这些设置之间的关系。大胆尝试启动应用程序并尝试一下。

您的代码应类似于chapter07.08-calendar

总结

本章解释并演示了 Spring Security 中 remember-me 功能的用法。我们从最基本的设置开始,学习了如何逐步使该功能更加安全。具体来说,我们了解了基于令牌的 remember-me 服务以及如何对其进行配置。我们还探讨了基于持久性的 remember-me 服务如何提供额外的安全功能,它是如何工作的,以及在使用它们时需要考虑的额外因素。

我们还介绍了创建自定义 remember-me 实现的过程,该实现将 remember-me 令牌限制为特定的 IP 地址。我们还看到了使 remember-me 功能更加安全的各种其他方法。

接下来是基于证书的认证,我们将讨论如何使用受信任的客户端证书来进行认证。

第八章:使用 TLS 的客户端证书认证

尽管用户名和密码认证极其普遍,正如我们在第一章《不安全应用程序的剖析》和第二章《Spring Security 入门》中讨论的,存在允许用户呈现不同类型凭证的认证形式。Spring Security 也迎合了这些要求。在本章中,我们将超越基于表单的认证,探索使用可信客户端证书的认证。

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

  • 学习客户端证书认证是如何在用户的浏览器和符合要求的服器之间进行协商的。

  • 配置 Spring Security 以使用客户端证书认证用户

  • 了解 Spring Security 中客户端证书认证的架构

  • 探索与客户端证书认证相关的高级配置选项

  • 回顾客户端证书认证的优点、缺点和常见故障排除步骤

客户端证书认证是如何工作的?

客户端证书认证需要服务器请求信息以及浏览器响应,以协商客户端(即用户的浏览器)与服务器应用程序之间的可信认证关系。这种信任关系是通过使用可信和可验证凭据的交换建立起来的,这些凭据被称为证书

与我们迄今为止所看到的大部分内容不同,在客户端证书认证中,Servlet 容器或应用服务器本身通常负责通过请求证书、评估它并接受它作为有效认证来协商浏览器与服务器之间的信任关系。

客户端证书认证也被称为相互认证,是安全套接层SSL)协议及其继承者传输层安全TLS)协议的一部分。由于相互认证是 SSL 和 TLS 协议的一部分,因此需要一个 HTTPS 连接(使用 SSL 或 TLS 加密)才能使用客户端证书认证。有关 Spring Security 中 SSL/TLS 支持的详细信息,请参阅我们在附录附加参考资料中的讨论和 SSL/TLS 的实现。在 Tomcat(或您一直用来跟随示例的应用服务器)中设置 SSL/TLS 是实现客户端证书认证的必要条件。与附录附加参考资料中的内容一样,在本章剩余部分我们将 SSL/TLS 简称为 SSL。

下面的序列图说明了客户端浏览器与 Web 服务器协商 SSL 连接并验证用于相互认证的客户端证书的信任时的交互:

我们可以看到,两个证书(服务器和客户端证书)的交换为双方提供了认证,证明双方是已知的并且可以被信任继续安全地对话。为了清晰起见,我们省略了 SSL 握手的一些细节和证书本身的检查;然而,我们鼓励你进一步阅读有关 SSL 和 TLS 协议以及证书的一般内容,因为这些主题有很多很好的参考指南。关于客户端证书展示,可以阅读RFC 5246传输层安全(TLS)协议版本 1.2tools.ietf.org/html/rfc5246),如果你想要了解更多细节,SL 和 TLS:设计和管理安全系统,Eric Rescorla,Addison-Wesleywww.amazon.com/SSL-TLS-Designing-Building-Systems/dp/0201615983)对协议及其实现有非常详细的回顾。

客户端证书认证的另一个名称是 X.509 认证。术语 X.509 来源于 ITU-T 组织最初发布的 X.509 标准,用于基于 X.500 标准的目录(你可能还记得第六章,LDAP 目录服务中提到的 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命令生成密钥对。客户端证书密钥对的区别在于,它需要密钥库对浏览器可用,并需要将客户端的公钥加载到服务器的信任库中(我们稍后会解释这是什么)。

如果你现在不想生成自己的密钥,你可以跳到下一节,并使用示例章节中的./src/main/resources/keys文件夹中的示例证书。否则,按照如下方式创建客户端密钥对:

keytool -genkeypair -alias jbcpclient -keyalg RSA -validity 365 -keystore jbcp_clientauth.p12 -storetype PKCS12

你可以在 Oracle 的网站上找到关于keytool的额外信息,以及所有的配置选项,链接在这里 docs.oracle.com/javase/8/docs/technotes/tools/unix/keytool.html/keytool.html

keytool的大部分参数对于这个用例来说是相当任意的。然而,当提示设置客户端证书的第一个和最后一个名字(所有者的 DN 的部分,即 common name)时,请确保第一个提示的答案与我们在 Spring Security JDBC 存储中设置的用户相匹配。例如,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 虚拟机不会默认信任它,因为它并非由可信的证书机构分配。

让我们来看看以下步骤:

  1. 我们需要迫使 Tomcat 识别证书为可信证书。我们通过导出密钥对的公钥并将其添加到 Tomcat 信任库来实现。

  2. 如果你现在不想执行这一步,你可以使用.src/main/resources/keys中的现有信任库,并跳到本节后面的server.xml配置部分。

  3. 我们将公钥导出到一个名为jbcp_clientauth.cer的标准证书文件中,如下所示:

 keytool -exportcert -alias jbcpclient -keystore jbcp_clientauth.p12 
      -storetype PKCS12 -storepass changeit -file jbcp_clientauth.cer
  1. 接下来,我们将把证书导入信任库(这将创建信任库,但在典型的部署场景中,你可能已经在信任库中有一些其他证书):
 keytool -importcert -alias jbcpclient -keystore tomcat.truststore 
      -file jbcp_clientauth.cer

前面的命令将创建一个名为tomcat.truststore的信任库,并提示你输入密码(我们选择了密码changeit)。你还将看到一些关于证书的信息,并最终被要求确认你是否信任该证书,如下所示:

 Owner: CN=admin1@example.com, OU=JBCP Calendar, O=JBCP, L=Park City,
      ST=UT, C=US Issuer: CN=admin1@example.com, OU=JBCP Calendar, O=JBCP, L=Park City,
      ST=UT, C=US Serial number: 464fc10c Valid from: Fri Jun 23 11:10:19 MDT 2017 until: Thu Feb 12 10:10:19 
      MST 2043      //Certificate fingerprints:

 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 Signature algorithm name: SHA256withRSA Version: 3      //Extensions

 #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
 ]
 ]
 Trust this certificate? [no]: yes

记住新tomcat.truststore文件的位置,因为我们将需要在 Tomcat 配置中引用它。

密钥库和信任库之间有什么区别?

Java 安全套接字扩展JSSE)文档将密钥库定义为私钥及其对应公钥的存储机制。密钥库(包含密钥对)用于加密或解密安全消息等。信任库旨在存储验证身份时信任的通信伙伴的公钥(与证书认证中使用的信任库类似)。然而,在许多常见的管理场景中,密钥库和信任库被合并为单个文件(在 Tomcat 中,这可以通过使用连接器的keystoreFiletruststoreFile属性来实现)。这些文件本身的格式可以完全相同。实际上,每个文件可以是任何 JSSE 支持的密钥库格式,包括Java 密钥库JKS)、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"
/>

server.xml文件可以在TOMCAT_HOME/conf/server.xml找到。如果你使用 Eclipse 或 Spring Tool Suite 与 Tomcat 交互,你会找到一个名为Servers的项目,包含server.xml。例如,如果你使用的是 Tomcat 8,你 Eclipse 工作区中的路径可能类似于/Servers/Tomcat v7.0 Serverlocalhost-config/server.xml

  1. 这应该是触发 Tomcat 在建立 SSL 连接时请求客户端证书的剩余配置。当然,你希望确保你用完整的路径替换了<CERT_PATH><KEYSTORE_PATH>。例如,在基于 Unix 的操作系统上,路径可能看起来像这样:/home/mickknutson/packt/chapter8/keys/tomcat.keystore

  2. 大胆尝试启动 Tomcat,确保服务器在日志中没有错误地启动。

还有方法可以配置 Tomcat,使其可选地使用客户端证书认证——我们将在本章后面启用这个功能。现在,我们要求使用客户端证书才能甚至连接到 Tomcat 服务器。这使得诊断你是否正确设置了这一点变得更容易!

在 Spring Boot 中配置 Tomcat

我们还可以配置 Spring Boot 中的内嵌 Tomcat 实例,这是我们本章剩余时间将如何与 Tomcat 工作的方式。

配置 Spring Boot 使用我们新创建的证书,就像 YAML 条目的属性一样简单,如下面的代码片段所示:

    server:
    port: 8443
    ssl:
       key-store: "classpath:keys/jbcp_clientauth.p12"
       key-store-password: changeit
       keyStoreType: PKCS12
       keyAlias: jbcpclient
       protocol: TLS

最后一步是将证书导入客户端浏览器。

将证书密钥对导入浏览器

根据你使用的浏览器,导入证书的过程可能会有所不同。我们将为 Firefox、Chrome 和 Internet Explorer 的安装提供说明,但如果您使用的是其他浏览器,请查阅其帮助部分或您最喜欢的搜索引擎以获得帮助。

使用 Firefox

执行以下步骤,在 Firefox 中导入包含客户端证书密钥对的密钥库:

  1. 点击编辑|首选项。

  2. 点击高级按钮。

  3. 点击加密标签。

  4. 点击查看证书按钮。证书管理器窗口应该打开。

  5. 点击您的证书标签。

  6. 点击导入...按钮。

  7. 浏览到你保存jbcp_clientauth.p12文件的位置并选择它。你将需要输入你创建文件时使用的密码(即changeit)。

客户端证书应该被导入,你会在列表上看到它。

使用 Chrome

执行以下步骤,在 Chrome 中导入包含客户端证书密钥对的密钥库:

  1. 点击浏览器工具栏上的扳手图标。

  2. 选择设置。

  3. 点击显示高级设置...。

  4. 在 HTTPS/SSL 部分,点击管理证书...按钮。

  5. 在您的证书标签中,点击导入...按钮。

  6. 浏览到您保存jbcp_clientauth.p12文件的位置并选择它。

  7. 您需要输入创建文件时使用的密码(即changeit)。

  8. 点击确定。

使用 Internet Explorer

由于 Internet Explorer 与 Windows 操作系统紧密集成,因此导入密钥库稍微容易一些。让我们来看看以下步骤:

  1. 在 Windows 资源管理器中双击jbcp_clientauth.p12文件。证书导入向导窗口应该会打开。

  2. 点击下一步,接受默认值,直到您需要输入证书密码为止。

  3. 输入证书密码(即changeit)并点击下一步。

  4. 接受默认的自动选择证书存储选项并点击下一步。

  5. 点击完成。

为了验证证书是否正确安装,您需要执行另一系列步骤:

  1. 在 Internet Explorer 中打开工具菜单 (Alt + X)。

  2. 点击互联网选项菜单项。

  3. 点击内容标签。

  4. 点击证书按钮。

  5. 如果还没有选择,点击个人标签。您应该在这里看到证书列表。

完成测试

现在,您应该能够使用客户端证书连接到 JBCP 日历网站。导航到https://localhost:8443/,注意使用 HTTPS 和8443。如果一切设置正确,当您尝试访问网站时,应该会提示您输入证书——在 Firefox 中,证书显示如下:

然而,您会发现,如果您尝试访问网站的保护区域,例如我的活动部分,您会被重定向到登录页面。这是因为我们还没有配置 Spring Security 来识别证书中的信息——在这个阶段,客户端和服务器之间的协商已经在 Tomcat 服务器本身停止了。

您应该从chapter08.00-calendar开始编写代码。

解决客户端证书认证问题

不幸的是,如果我们说第一次正确配置客户端证书认证很容易,没有出错,那就是在骗您。事实是,尽管这是一个非常强大且优秀的的安全装置,但浏览器和 web 服务器制造商的文档都很差,而且当出现错误信息时,充其量是令人困惑,最差的情况是具有误导性。

请记住,到目前为止,我们根本没有让 Spring Security 参与进来,所以调试器很可能帮不了您(除非您手头有 Tomcat 源代码)。有一些常见的错误和需要检查的事情。

当您访问网站时,没有提示您输入证书。这可能有多种可能的原因,这也是最难解决的问题之一。以下是一些需要检查的内容:

  1. 确保证书已安装在您正在使用的浏览器客户端中。有时,如果您之前尝试访问该网站并被拒绝,您可能需要重启整个浏览器(关闭所有窗口)。

  2. 确保你正在访问服务器的 SSL 端口(在开发环境中通常是8443),并且在你的 URL 中选择了 HTTPS 协议。在不安全的浏览器连接中不会呈现客户端证书。确保浏览器也信任服务器的 SSL 证书,即使你不得不强制它信任自签名的证书。

  3. 确保您已在您的 Tomcat 配置中添加了clientAuth指令(或您正在使用的任何应用程序服务器的等效配置)。

  4. 如果其他方法都失败了,请使用网络分析器或包嗅探器,如 Wireshark (www.wireshark.org/) 或 Fiddler2 (www.fiddler2.com/),以查看通过网络的流量和 SSL 密钥交换(首先与您的 IT 部门确认-许多公司不允许在他们的网络上使用这类工具)。

  5. 如果您使用的是自签名的客户端证书,请确保公钥已导入服务器的信任存储中。如果您使用的是 CA 分配的证书,请确保 CA 被 JVM 信任,或者 CA 证书已导入服务器的信任存储中。

  6. 特别是,Internet Explorer 根本不报告客户端证书失败的详细信息(它只报告一个通用的“页面无法显示”错误)。如果您看到的问题可能与客户端证书有关,请使用 Firefox 进行诊断。

在 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
    http.x509().userDetailsService(userDetailsService);

请注意.x509()方法引用了我们现有的userDetailsService()配置。为了简单起见,我们使用了在第五章中介绍的UserDetailsServiceImpl实现,关于使用 Spring Data 进行认证。然而,我们很容易用其他任何实现来替换它(即在第四章中介绍的基于 LDAP 或 JDBC 的实现,关于基于 JDBC 的认证)。

重启应用程序后,系统会再次提示您提供客户端证书,但这次,您应该能够访问需要授权的网站区域。如果您启用了日志(如果有),从日志中可以看到您已以admin1@example.com用户身份登录。

你的代码应该看起来像chapter08.01-calendar

Spring Security是如何使用证书信息的?

如前所述,Spring Security 在证书交换中的作用是提取 presented certificate 中的信息,并将用户的凭据映射到用户服务。我们在使用.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()
   .userDetailsService(userDetailsService)
 .subjectPrincipalRegex("CN=(.*?),");

我们可以看到,这个正则表达式会将admin1@example.com值作为主体名称匹配。这个正则表达式必须包含一个匹配组,但可以配置以支持您应用程序的用户名和 DN 发行需求。例如,如果您组织证书的 DN 包括emailuserid字段,正则表达式可以修改为使用这些值作为认证主体的名称。

Spring Security客户端证书认证是如何工作的

让我们通过以下图表回顾一下涉及客户端证书评审和评估的各种参与者,以及将之转化为 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处理认证令牌的非常简短的说明:

虽然我们不会详细介绍它们,但 Spring Security 支持许多其他预认证机制。一些例子包括 Java EE 角色映射(J2eePreAuthenticatedProcessingFilter),WebSphere 集成(WebSpherePreAuthenticatedProcessingFilter)和 Site Minder 风格认证(RequestHeaderAuthenticationFilter)。如果你理解了客户端证书认证的流程,理解这些其他认证类型要容易得多。

处理未经认证请求的AuthenticationEntryPoint

由于X509AuthenticationFilter如果在认证失败将继续处理请求,我们需要处理用户未能成功认证并请求受保护资源的情况。Spring Security 允许开发人员通过插入自定义o.s.s.web.AuthenticationEntryPoint实现来定制这种情况。在默认的表单登录场景中,LoginUrlAuthenticationEntryPoint用于将用户重定向到登录页面,如果他们被拒绝访问受保护的资源且未经认证。

相比之下,在典型的客户端证书认证环境中,其他认证方法根本不被支持(记住 Tomcat 在任何 Spring Security 表单登录发生之前都会期望证书)。因此,保留重定向到表单登录页面的默认行为是没有意义的。相反,我们将修改入口点,简单地返回一个HTTP 403 Forbidden消息,使用o.s.s.web.authentication.Http403ForbiddenEntryPoint。在你的SecurityConfig.java文件中,进行以下更新:

    //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java

    @Autowired
 private Http403ForbiddenEntryPoint forbiddenEntryPoint;    http.exceptionHandling()
 .authenticationEntryPoint(forbiddenEntryPoint)       .accessDeniedPage("/errors/403");
    ...
    @Bean
    public Http403ForbiddenEntryPoint forbiddenEntryPoint(){
       return new Http403ForbiddenEntryPoint();
    }

现在,如果一个用户尝试访问一个受保护的资源并且无法提供有效的证书,他们将看到以下页面,而不是被重定向到登录页面:

你的代码现在应该看起来像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(AuthenticationManager  
    authenticationManager){
       return new X509AuthenticationFilter(){{
           setAuthenticationManager(authenticationManager);
       }};
    }
   @Bean
    public PreAuthenticatedAuthenticationProvider    
    preauthAuthenticationProvider(AuthenticationUserDetailsService   
    authenticationUserDetailsService){
       return new PreAuthenticatedAuthenticationProvider(){{
         setPreAuthenticatedUserDetailsService(authenticationUserDetailsService);
       }};
    }
    @Bean
    public UserDetailsByNameServiceWrapper   
    authenticationUserDetailsService(UserDetailsService userDetailsService){
       return new UserDetailsByNameServiceWrapper(){{
           setUserDetailsService(userDetailsService);
       }};
    }

我们还需要删除x509()方法,将x509Filter添加到我们的过滤器链中,并将我们的AuthenticationProvider实现添加到AuthenticationManger中:

    //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java

    @Override
    protected void configure(HttpSecurity http) throws Exception {
       http.x509()
 //.userDetailsService(userDetailsService)           .x509AuthenticationFilter(x509Filter());
    ...
    }
    @Override
    public void configure(AuthenticationManagerBuilder auth)
    throws Exception {
       auth
 .authenticationProvider(preAuthAuthenticationProvider)         .userDetailsService(userDetailsService)
         .passwordEncoder(passwordEncoder());
    }

现在,尝试一下应用程序。从用户的角度来看,并没有发生太多变化,但作为开发者,我们已经为许多额外的配置选项打开了大门。

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

基于 Bean 配置的其他功能

使用基于 Spring Bean 的配置提供了通过暴露未通过安全命名空间样式配置的 bean 属性而获得的其他功能。

X509AuthenticationFilter上可用的额外属性如下:

属性 描述 默认值
continueFilterChainOnUnsuccessfulAuthentication 如果为 false,失败的认证将抛出异常,而不是允许请求继续。这通常会在预期并且需要有效证书才能访问受保护的站点时设置。如果为 true,即使认证失败,过滤器链也将继续。 true
checkForPrincipalChanges 如果为真,过滤器将检查当前认证的用户名是否与客户端证书中呈现的用户名不同。如果是这样,将执行新的证书的认证,并使 HTTP 会话无效(可选,参见下一个属性)。如果为假,一旦用户认证成功,他们即使提供不同的凭据也将保持认证状态。 false
invalidateSessionOn PrincipalChange 如果为真,且请求中的主体发生变化,将在重新认证之前使用户的 HTTP 会话无效。如果为假,会话将保持不变——请注意这可能引入安全风险。 true

PreAuthenticatedAuthenticationProvider实现有几个有趣的属性可供我们使用,如下表所示:

属性 描述 默认值
preAuthenticatedUser DetailsService 此属性用于从证书中提取的用户名构建完整的UserDetails对象。
throwExceptionWhen TokenRejected 如果为真,当令牌构建不正确(不包含用户名或证书)时,将抛出BadCredentialsException异常。在仅使用证书的环境中通常设置为true

除了这些属性,还有许多其他机会可以实现接口或扩展与证书认证相关的类,以进一步自定义您的实现。

实现客户端证书认证时的考虑

客户端证书认证虽然非常安全,但并不适合所有人,也不适用于每种情况。

以下是客户端证书认证的优点:

  • 证书建立了一个双方(客户端和服务器)互相信任和可验证的框架,以确保双方都是他们所说的自己。

  • 如果正确实现,基于证书的认证比其他形式的认证更难伪造或篡改。

  • 如果使用得到良好支持的浏览器并正确配置,客户端证书认证可以有效地作为单点登录解决方案,实现对所有基于证书的安全应用的透明登录。

以下是客户端证书认证的缺点:

  • 证书的使用通常要求整个用户群体都拥有证书。这可能导致用户培训负担和行政负担。大多数在大规模部署基于证书的认证的组织必须为证书维护、过期跟踪和用户支持提供足够的自助和帮助台支持。

  • 使用证书通常是一个要么全部要么全无的事务,这意味着由于 web 服务器配置的复杂性或应用程序支持不足,不提供混合模式认证和支持非证书用户。

  • 证书的使用可能不会得到您用户群体中所有用户的支持,包括使用移动设备的用户。

  • 正确配置支持基于证书认证的基础设施可能需要高级的 IT 知识。

正如你所见,客户端证书认证既有优点也有缺点。当正确实现时,它可以为用户提供非常方便的访问方式,并具有极具吸引力的安全性和不可否认性属性。你需要确定你的具体情况以判断这种认证方式是否合适。

摘要

在本章中,我们研究了客户端基于证书认证的架构、流程以及 Spring Security 的支持。我们涵盖了客户端证书(相互)认证的概念和总体流程。我们探讨了配置 Apache Tomcat 以支持自签名的 SSL 和客户端证书场景的重要步骤。

我们还学习了如何配置 Spring Security 以理解客户端呈现的基于证书的凭据。我们涵盖了与证书认证相关的 Spring Security 类的架构。我们还知道如何配置 Spring bean 风格的客户端证书环境。我们还讨论了这种认证方式的优缺点。

对于不熟悉客户端证书的开发人员来说,他们可能会对这种环境中的许多复杂性感到困惑。我们希望这一章节使得这个复杂主题变得更容易理解和实现!在下一章节中,我们将讨论如何使用 OpenID 实现单点登录。

第九章:向 OAuth 2 敞开大门

OAuth 2 是一种非常流行的可信身份管理形式,允许用户通过一个可信的提供商来管理他们的身份。这一方便的功能为用户提供了将密码和个人信息存储在可信的 OAuth 2 提供商处的安全性,必要时可以披露个人信息。此外,支持 OAuth 2 的网站提供了用户提供的 OAuth 2 凭据确实是他们所说的那个人的信心。

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

  • 学习在 5 分钟内设置自己的 OAuth 2 应用程序

  • 配置 JBCP 日历应用程序,实现 OAuth 2 的快速实施

  • 学习 OAuth 2 的概念架构以及它如何为你的网站提供可信的用户访问

  • 实现基于 OAuth 2 的用户注册

  • 实验 OAuth 2 属性交换以实现用户资料功能

  • 展示我们如何触发与先前 OAuth 2 提供商的自动认证

  • 检查基于 OAuth 2 的登录所提供的安全性

充满希望的 OAuth 2 世界

作为应用程序开发者,你可能经常听到 OAuth 2 这个词。OAuth 2 已在全球 Web 服务和软件公司中得到广泛采用,是这些公司互动和共享信息方式的核心部分。但它到底是什么呢?简而言之,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 服务提供商集成。

这种通过可信第三方登录的方式已经存在很长时间了,以许多不同的形式存在(例如,Microsoft Passport 在网上成为一段时间内较为知名的集中登录服务)。OAuth 2 的显著优势在于,OAuth 2 提供商只需实现公开的 OAuth 2 协议,即可与任何寻求与 OAuth 2 集成登录的网站兼容。

你可以参考 OAuth 2.0 规范:tools.ietf.org/html/rfc6749

以下图表说明了网站在登录过程中集成 OAuth 2 与例如 Facebook OAuth 2 提供商之间的高级关系:

我们可以看到,提交表单 POST 将启动对 OAuth 提供者的请求,导致提供者显示一个授权对话框,询问用户是否允许jbcpcalendar从您的 OAuth 提供者账户中获取特定信息的权限。这个请求包含一个名为codeuri参数。一旦授权,用户将被重定向回jbcpcalendarcode参数将包含在uri参数中。然后,请求再次重定向到 OAuth 提供者,以授权jbcpcalendar。OAuth 提供者随后响应一个access_token,该access_token可用于访问jbcpcalendar被授予访问权的用户 OAuth 信息。

不要盲目信任 OAuth 2 !

在这里,你可以看到一个可能会误导系统用户的根本假设。我们可以注册一个 OAuth 2 提供者的账户,这样我们就可以假装是 James Gosling,尽管显然我们不是。不要错误地假设,仅仅因为一个用户有一个听起来令人信服的 OAuth 2(或 OAuth 2 代理提供者),就不需要额外的身份验证方式,认为他就是那个真实的人。换一种方式考虑,如果有人来到你的门前,只是声称他是 James Gosling,你会不会在核实他的身份证之前就让他进来?

启用了 OAuth 2 的应用程序然后将用户重定向到 OAuth 2 提供者,用户向提供者展示他的凭据,提供者负责做出访问决定。一旦提供者做出了访问决定,提供者将用户重定向到原始网站,现在可以确信用户的真实性。一旦尝试过,OAuth 2 就很容易理解了。现在让我们把 OAuth 2 添加到 JBCP 日历登录屏幕上!

注册 OAuth 2 应用程序

为了充分利用本节中的练习(并能够测试登录),您需要创建一个具有服务提供者的应用程序。目前,Spring Social 支持 Twitter、Facebook、Google、LinkedIn 和 GitHub,而且支持列表还在增加。

为了充分利用本章中的练习,我们建议您至少拥有 Twitter 和 GitHub 的账户。我们已经为jbcpcalendar应用设置了账户,我们将在本章剩余时间里使用它。

使用 Spring Security 启用 OAuth 认证

我们可以看到,在接下来的几章中,外部认证提供者之间有一个共同的主题。Spring Security 为实际开发在 Spring 生态系统之外的提供者集成提供了方便的包装器。

在这种情况下,Spring Social 项目(projects.spring.io/spring-social/)为 Spring Security OAuth 2 功能提供了基础的 OAuth 2 提供者发现和请求/响应协商。

额外的必需依赖

让我们来看看以下步骤:

  1. 为了使用 OAuth,我们需要包含特定提供者的依赖及其传递依赖。这可以通过更新build.gradle文件在 Gradle 中完成,如下代码片段所示:
        //build.gradle

        compile("org.springframework.boot:spring-boot-starter-
        social-facebook")
        compile("org.springframework.boot:spring-boot-starter-
        social-linkedin")
        compile("org.springframework.boot:spring-boot-starter-
        social-twitter")
  1. 使用 Spring Boot 包括了对 Facebook、Twitter 和 LinkedIn 启动依赖的引用,如前文代码片段所示。要添加其他提供者,我们必须包含提供者的依赖并指定版本。这可以通过更新build.gradle文件在 Gradle 中完成,如下代码片段所示:
        //build.gradle

        compile("org.springframework.social:spring-social-google:
        latest.release ")
        compile("org.springframework.social:spring-social-github:
        latest.release ")
        compile("org.springframework.social:spring-social-linkedin:
        latest.release ")

你应该从chapter09.00-calendar的源代码开始。

  1. 当编写 OAuth 登录表单时,我们需要将usernamepassword字段替换为 OAuth 字段。现在请对您的login.html文件进行以下更新:
        //src/main/resources/templates/login.html

         <div class="form-actions">
            <input id="submit" class="btn" name="submit" type="submit" 
            value="Login"/>
           </div>
         </form>
       <br/>
         <h3>Social Login</h3>
       <br />
        <form th:action="@{/signin/twitter}" method="POST"
        class="form-horizontal">
         <input type="hidden" name="scope" value="public_profile" />
        <div class="form-actions">
        <input id="twitter-submit" class="btn" type="submit" 
        value="Login using  
        Twitter"/>
         </div>
        </form>
       </div>
  1. 我们可以对注册表单进行类似的编辑,如下代码片段所示:
         //src/main/resources/templates/signup/form.html

        </fieldset>
        </form>
         <br/>
           <h3>Social Login</h3>
         <br/>
 <form th:action="@{/signin/twitter}" method="POST" 
           class="form-horizontal">
 <input type="hidden" name="scope" value="public_profile" />        <div class="form-actions">
         <input id="twitter-submit" class="btn" type="submit" 
         value="Login using Twitter"/>
        </div>
        </form>
         </div>

你会注意到我们已经添加了一个范围字段来定义我们在认证过程中感兴趣的 OAuth 2 详细信息。

OAuth 2.0 API 范围:范围允许提供商定义客户端应用程序可访问的 API 数据。当提供商创建一个 API 时,他们会为每个表示的 API 定义一个范围和动作。一旦创建了 API 并定义了范围,客户端应用程序在启动授权流程时可以请求这些定义的权限,并将它们作为范围请求参数的一部分包含在访问令牌中。

每个提供商可能有略有不同的 API 范围,例如r_basicprofiler_emailaddress,但 API 范围也限于应用程序配置。因此,一个应用程序可能只请求访问电子邮件或联系人,而不是整个用户资料或如发帖到用户墙等提供商动作。

你会注意到我们没有为 OAuth 2 登录提供记住我选项。这是由于事实,从提供商到网站以及返回的重定向会导致记住我复选框值丢失,因此当用户成功认证后,他们不再有记住我选项被标记。这虽然不幸,但最终增加了 OAuth 2 作为我们网站登录机制的安全性,因为 OAuth 2 强制用户在每次登录时与提供商建立一个可信关系。

在 Spring Security 中配置 OAuth 2 支持

使用Spring Social,我们可以为拦截提供商表单提交启用 OAuth 2 特定的提供商端点。

本地用户连接存储库(UserConnectionRepository)

UsersConnectionRepository接口是用于管理用户与服务提供商连接的全球存储的数据访问接口。它提供了适用于多个用户记录的数据访问操作,如下代码片段所示:

    //src/main/java/com/packtpub/springsecurity/configuration/SocialConfig.java

    @Autowired

    private UsersConnectionRepository usersConnectionRepository;

    @Autowired

     private ProviderConnectionSignup providerConnectionSignup;

    @Bean

    public ProviderSignInController providerSignInController() {

       ((JdbcUsersConnectionRepository) usersConnectionRepository)

       .setConnectionSignUp(providerConnectionSignup);

       ...

    }

为提供商详情创建本地数据库条目

Spring Security 提供了支持,将提供者详情保存到一组单独的数据库表中,以防我们想在本地数据存储中保存用户,但不想将那些数据包含在现有的User表中:

    //src/main/java/com/packtpub/springsecurity/configuration/
    SocialDatabasePopulator.java

    @Component
    public class SocialDatabasePopulator
    implements InitializingBean {
       private final DataSource dataSource;
       @Autowired
    public SocialDatabasePopulator(final DataSource dataSource) {
    this.dataSource = dataSource;
     }
    @Override
    public void afterPropertiesSet() throws Exception {
       ClassPathResource resource = new ClassPathResource(
       "org/springframework/social/connect/jdbc/
       JdbcUsersConnectionRepository.sql");
       executeSql(resource);
     }
    private void executeSql(final Resource resource) {
     ResourceDatabasePopulator populator = new ResourceDatabasePopulator();
     populator.setContinueOnError(true);
     populator.addScript(resource);
     DatabasePopulatorUtils.execute(populator, dataSource);
     }
  }

这个InitializingBean接口在加载时执行,并将执行位于类路径中的spring-social-core-[VERSION].jar文件内的JdbcUsersConnectionRepository.sql,将以下模式种子到我们的本地数据库中:

    spring-social-core-  [VERSION].jar#org/springframework/social/connect/jdbc/
    JdbcUsersConnectionRepository.sql

    create table UserConnection(
      userId varchar(255) not null,
      providerId varchar(255) not null,
      providerUserId varchar(255),
      rank int not null,
      displayName varchar(255),
      profileUrl varchar(512),
      imageUrl varchar(512),
      accessToken varchar(512) not null,
      secret varchar(512),
      refreshToken varchar(512),
      expireTime bigint,
      primary key (userId, providerId, providerUserId));

      create unique index UserConnectionRank on UserConnection(userId, providerId,  
      rank);

现在我们已经有一个表来存储提供者详情,我们可以配置ConnectionRepository在运行时保存提供者详情。

自定义 UserConnectionRepository 接口

我们需要创建一个UserConnectionRepository接口,我们可以利用JdbcUsersConnectionRepository作为实现,它是基于我们加载时生成的JdbcUsersConnectionRepository.sql模式:

      //src/main/java/com/packtpub/springsecurity/configuration/

      DatabaseSocialConfigurer.java

      public class DatabaseSocialConfigurer extends SocialConfigurerAdapter {

        private final DataSource dataSource;

        public DatabaseSocialConfigurer(DataSource dataSource) {

         this.dataSource = dataSource;

       }

      @Override

      public UsersConnectionRepository getUsersConnectionRepository(

      ConnectionFactoryLocator connectionFactoryLocator) {

          TextEncryptor textEncryptor = Encryptors.noOpText();

          return new JdbcUsersConnectionRepository(

          dataSource, connectionFactoryLocator, textEncryptor);

     }

      @Override

     public void addConnectionFactories(ConnectionFactoryConfigurer config,

     Environment env) {

          super.addConnectionFactories(config, env);

       }

   }

现在,每次用户连接到注册的提供者时,连接详情将被保存到我们的本地数据库中。

连接注册流程

为了将提供者详情保存到本地存储库,我们创建了一个ConnectionSignup对象,这是一个命令,在无法从Connection映射出userid的情况下注册新用户,允许在提供者登录尝试期间从连接数据隐式创建本地用户配置文件:

    //src/main/java/com/packtpub/springsecurity/authentication/
    ProviderConnectionSignup.java

    @Service
     public class ProviderConnectionSignup implements ConnectionSignUp {
        ...; 
    @Override
    public String execute(Connection<?> connection) {
       ...
     }
    }

执行 OAuth 2 提供商连接工作流

为了保存提供者详情,我们需要从提供者获取可用细节,这些细节通过 OAuth 2 连接可用。接下来,我们从可用细节创建一个CalendarUser表。注意我们需要至少创建一个GrantedAuthority角色。在这里,我们使用了CalendarUserAuthorityUtils#createAuthorities来创建ROLE_USER GrantedAuthority

    //src/main/java/com/packtpub/springsecurity/authentication/
    ProviderConnectionSignup.java

    @Service
    public class ProviderConnectionSignup implements ConnectionSignUp {
         ...
    @Override
    public String execute(Connection<?> connection) {
        UserProfile profile = connection.fetchUserProfile();
        CalendarUser user = new CalendarUser();
        if(profile.getEmail() != null){
             user.setEmail(profile.getEmail());
          }
        else if(profile.getUsername() != null){
             user.setEmail(profile.getUsername());
         }
        else {
             user.setEmail(connection.getDisplayName());
         }
             user.setFirstName(profile.getFirstName());
             user.setLastName(profile.getLastName());
             user.setPassword(randomAlphabetic(32));
             CalendarUserAuthorityUtils.createAuthorities(user);
             ...
         }
      }

添加 OAuth 2 用户

既然我们已经从我们的提供者详情中创建了CalendarUser,我们需要使用CalendarUserDao将那个User账户保存到我们的数据库中。然后我们返回CalendarUser的电子邮件,因为这是我们一直在 JBCP 日历中使用的用户名:

//src/main/java/com/packtpub/springsecurity/authentication/
ProviderConnectionSignup.java

@Service
public class ProviderConnectionSignup
implements ConnectionSignUp {
 @Autowired private CalendarUserDao calendarUserDao;  @Override
 public String execute(Connection<?> connection) {...
calendarUserDao.createUser(user); return user.getEmail();
   }
}

现在,我们已经根据提供者详情在数据库中创建了一个本地User账户。

这是一个额外的数据库条目,因为我们已经在之前的UserConnection表中保存了提供者详情。

OAuth 2 控制器登录流程

现在,为了完成SocialConfig.java配置,我们需要构建ProviderSignInController,它使用ConnectionFactoryLocatorusersConnectionRepositorySignInAdapter进行初始化。ProviderSignInController接口是一个用于处理提供者用户登录流程的 Spring MVC 控制器。对/signin/{providerId}的 HTTP POST请求会使用{providerId}启动用户登录。提交对/signin/{providerId}?oauth_token&oauth_verifier||code的 HTTP GET请求将接收{providerId}身份验证回调并建立连接。

ServiceLocator接口用于创建ConnectionFactory实例。此工厂支持通过providerIdapiType查找,基于 Spring Boot 的AutoConfiguration中包含的服务提供商:

    //src/main/java/com/packtpub/springsecurity/configuration/SocialConfig.java

    @Autowired
    private ConnectionFactoryLocator connectionFactoryLocator;
    @Bean
    public ProviderSignInController providerSignInController() {
        ...
        return new ProviderSignInController(connectionFactoryLocator,
        usersConnectionRepository, authSignInAdapter());
    }

这将允许拦截特定提供商uri的提交,并开始 OAuth 2 连接流程。

自动用户认证

让我们来看看以下步骤:

  1. ProviderSignInController控制器使用一个认证SignInAdapter进行初始化,该适配器用于通过使用指定 ID 登录本地用户账户来完成提供商登录尝试:
        //src/main/java/com/packtpub/springsecurity/configuration/
        SocialConfig.java

        @Bean
        public SignInAdapter authSignInAdapter() {
           return (userId, connection, request) -> {
             SocialAuthenticationUtils.authenticate(connection);
             return null;
           };
         }
  1. 在前面的代码片段中,在SingInAdapterbean 中,我们使用了一个自定义认证工具方法,以UsernamePasswordAuthenticationToken的形式创建了一个Authentication对象,并基于 OAuth 2 提供商返回的详情将其添加到SecurityContext中:
        //src/main/java/com/packtpub/springsecurity/authentication/
        SocialAuthenticationUtils.java

        public class SocialAuthenticationUtils {
       public static void authenticate(Connection<?> connection) {
         UserProfile profile = connection.fetchUserProfile();
         CalendarUser user = new CalendarUser();
         if(profile.getEmail() != null){
             user.setEmail(profile.getEmail());
           }
         else if(profile.getUsername() != null){
             user.setEmail(profile.getUsername());
          }
         else {
             user.setEmail(connection.getDisplayName());
           }
             user.setFirstName(profile.getFirstName());
             user.setLastName(profile.getLastName());
             UsernamePasswordAuthenticationToken authentication = new  
             UsernamePasswordAuthenticationToken(user, null,        
             CalendarUserAuthorityUtils.createAuthorities(user));
             SecurityContextHolder.getContext()
             .setAuthentication(authentication);
           }
        }

连接到提供商所需的最详细信息是创建提供商应用时获得的应用程序 ID 和密钥:

        //src/main/resources/application.yml:

        spring
        ## Social Configuration:
        social:
        twitter:
 appId: cgceheRX6a8EAE74JUeiRi8jZ
 appSecret: XR0J2N0Inzy2y2poxzot9oSAaE6MIOs4QHSWzT8dyeZaaeawep
  1. 现在我们有了连接到 Twitter JBCP 日历所需的所有详细信息,我们可以启动 JBCP 日历并使用 Twitter 提供商登录。

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

  1. 在此阶段,您应该能够使用 Twitter 的 OAuth 2 提供商完成完整的登录。发生的重定向如下,首先,我们启动以下屏幕快照所示的 OAuth 2 提供商登录:

我们随后被重定向到服务提供商授权页面,请求用户授予jbcpcalendar应用以下屏幕快照所示的权限:

  1. 授权jbcpcalendar应用后,用户被重定向到jbcpcalendar应用,并使用提供商显示名称自动登录:

  1. 在此阶段,用户存在于应用程序中,并且具有单个GrantedAuthorityROLE_USER认证和授权,但如果导航到我的事件,用户将被允许查看此页面。然而,CalendarUser中不存在任何事件:

  1. 尝试为该用户创建事件,以验证用户凭据是否正确创建在CalendarUser表中。

  2. 为了验证提供商详情是否正确创建,我们可以打开 H2 管理控制台并查询USERCONNECTION表,以确认已保存以下屏幕快照所示的标准连接详情:

  1. 此外,我们还可以验证已填充了服务提供商详情的CALENDAR_USERS表:

现在我们已经在本地数据库中注册了用户,并且我们还可以根据对特定提供者详细信息的授权访问与注册提供者进行交互。

额外的 OAuth 2 提供者

我们已经成功集成了一个 OAuth 2 提供者,使用 Spring Social 当前支持的三个当前提供者之一。还有几个其他提供者可用;我们将添加更多提供者,以便用户有多一个选择。Spring Social 目前原生支持 Twitter,Facebook 和 LinkedIn 提供者。包括其他提供者将需要额外的库来实现此支持,这将在本章后面部分介绍。

让我们看看以下步骤:

  1. 为了将 Facebook 或 LinkedIn 提供者添加到 JBCP 日历应用程序中,需要设置其他应用程序属性,并且每个配置的提供者将自动注册:

OAuth 2 用户注册问题

如果在支持多个提供者的情况下,需要解决的一个问题是在各个提供者返回的详细信息之间的用户名冲突。

如果您使用列表中的每个提供者登录到 JBCP 日历应用程序,然后查询存储在 H2 中的数据,您会发现基于用户账户详细信息,数据可能相似,如果不是完全相同。

在下面的USERCONNECTION表中,我们可以看到来自每个提供者的USERID列数据是相似的:

CALENDARUSER表中,我们有两个可能的问题。首先,用于EMAIL的用户详细信息对于某些提供者来说并不是电子邮件。其次,两个不同提供者的用户标识符仍然可能相同:

我们不会深入探讨检测和解决这个可能问题的各种方法,但值得在未来参考中注意。

注册非标准 OAuth 2 提供者

为了包括其他提供者,我们需要执行一些额外的步骤来将自定义提供者包括到登录流程中,如下所示:

  1. 对每个提供者,我们需要在我们的build.gradle文件中包括提供者依赖项,如下所示:
        //build.gradle

        dependencies {
          ...
          compile("org.springframework.social:spring-social-google:
          ${springSocialGoogleVersion}")
          compile("org.springframework.social:spring-social-github:
          ${springSocialGithubVersion}")
        }
  1. 接下来,我们将使用以下为每个提供者的appIdappSecret键将提供者注册到 JBCP 日历应用程序:
        //src/main/resources/application.yml

        spring:
          social:
            # Google
 google:
 appId: 947438796602-uiob88a5kg1j9mcljfmk00quok7rphib.apps.
                 googleusercontent.com
 appSecret: lpYZpF2IUgNXyXdZn-zY3gpR
           # Github
 github:
 appId: 71649b756d29b5a2fc84
 appSecret: 4335dcc0131ed62d757cc63e2fdc1be09c38abbf
  1. 每个新提供者必须通过添加相应的ConnectionFactory接口进行注册。我们可以为每个新提供者添加一个新的ConnectionFactory条目到自定义的DatabaseSocialConfigurer.java文件中,如下所示:
        //src/main/java/com/packtpub/springsecurity/configuration/
        DatabaseSocialConfigurer.java

        public class DatabaseSocialConfigurer 
        extends SocialConfigurerAdapter {
           ...
        @Override
        public void addConnectionFactories(
        ConnectionFactoryConfigurer config, Environment env) {
               super.addConnectionFactories(config, env);

            // Adding GitHub Connection with properties
           // from application.yml
 config.addConnectionFactory(
 new GitHubConnectionFactory(
 env.getProperty("spring.social.github.appId"),
 env.getProperty("spring.social.github.appSecret")));
          // Adding Google Connection with properties
         // from application.yml
 config.addConnectionFactory(
 new GoogleConnectionFactory(
 env.getProperty("spring.social.google.appId"),
 env.getProperty("spring.social.google.appSecret")));
             }
         }
  1. 现在我们可以将新的登录选项添加到我们的login.html文件和form.html注册页面,为每个新提供者包括一个新的<form>标签:
        //src/main/resources/templates/login.html

        <h3>Social Login</h3>
        ...
 <form th:action="@{/signin/google}" method="POST"        class="form-horizontal">
        <input type="hidden" name="scope" value="profile" />
        <div class="form-actions">
           <input id="google-submit" class="btn" type="submit" 
           value="Login using  
           Google"/>
        </div>
      </form>
     <br />

 <form th:action="@{/signin/github}" method="POST"       class="form-horizontal">
       <input type="hidden" name="scope" value="public_profile" />
       <div class="form-actions">
         <input id="github-submit" class="btn" type="submit"
         value="Login using  
         Github"/>
       </div>
     </form&gt;
  1. 现在,我们有了连接到 JBCP 日历额外提供者的所需详细信息。我们可以重新启动 JBCP 日历应用程序,并尝试使用额外的 OAuth 2.0 提供商登录。现在登录时,我们应该会看到额外的提供商选项,如下面的屏幕截图所示:

OAuth 2.0 安全吗?

由于 OAuth 2.0 支持依赖于 OAuth 2.0 提供者的可信度以及提供者响应的可验证性,因此安全性是至关重要的,以便应用程序对用户的 OAuth 2.0 登录有信心。

幸运的是,OAuth 2.0 规范的设计者非常清楚这个担忧,并实施了一系列验证步骤,以防止响应伪造、重放攻击和其他类型的篡改,如下所述:

  • 响应伪造由于结合了由 OAuth 2.0 启用的网站在初始请求之前创建的共享密钥,以及响应本身的一路哈希消息签名而得以防止。没有访问共享密钥-和签名算法的恶意用户-篡改任何响应字段中的数据将生成无效的响应。

  • 重放攻击由于包括了 nonce(一次性使用的随机密钥)而得以防止,该密钥应该被 OAuth 2.0 启用的网站记录,因此它永远不能被重新使用。这样,即使用户试图重新发行响应 URL,也会失败,因为接收网站会确定 nonce 已经被先前使用,并将请求无效。

  • 可能导致用户交互被破坏的最有可能的攻击形式是一个中间人攻击,在这种攻击中,恶意用户可以拦截用户与他们计算机和 OAuth 2.0 提供商之间的交互。在这种情况下的假设攻击者可能处于记录用户浏览器与 OAuth 2.0 提供商之间的对话,以及当请求发起时记录密钥的立场。在这种情况下,攻击者需要非常高的复杂性水平,以及 OAuth 2.0 签名规范的相对完整的实现-简而言之,这不太可能以任何常规性发生。

总结

在本章中,我们回顾了 OAuth 2.0,这是一种相对较新的用户认证和凭据管理技术。OAuth 2.0 在网络上有非常广泛的应用,并且在过去的两年内在可用性和接受度上取得了很大的进步。大多数现代网络上的面向公众的网站都应该计划支持某种形式的 OAuth 2.0,JBCP 日历应用程序也不例外!

在本书中,我们学习了 OAuth 2.0 认证机制及其高级架构和关键术语。我们还了解了 JBCP 日历应用程序中的 OAuth 2.0 登录和自动用户注册。

我们还介绍了使用 OAuth 2.0 的自动登录以及 OAuth 2.0 登录响应的安全性。

我们介绍了使用 Spring Security 实现的最简单的单点登录机制之一。其中一个缺点是它不支持单点登出标准的机制。在下一章中,我们将探讨 CAS,另一种支持单点登出的标准单点登录协议。

第十章:使用中央认证服务的单点登录

在本章中,我们将探讨如何使用中央认证服务CAS)作为 Spring Security 基础应用程序的单点登录门户。

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

  • 学习关于 CAS,其架构以及它如何使系统管理员和任何大小的组织受益

  • 了解如何重新配置 Spring Security 以处理认证请求的拦截并重定向到 CAS

  • 配置 JBCP 日历应用程序以使用 CAS 单点登录

  • 了解如何执行单一登出,并配置我们的应用程序以支持它

  • 讨论如何使用 CAS 代理票证认证服务,并配置我们的应用程序以利用代理票证认证

  • 讨论如何使用推荐的 war 覆盖方法定制JA-SIG CAS服务器

  • 将 CAS 服务器与 LDAP 集成,并通过 CAS 将数据从 LDAP 传递到 Spring Security

介绍中央认证服务

CAS 是一个开源的单点登录服务器,为组织内的基于 web 的资源提供集中访问控制和认证。对于管理员来说,CAS 的好处是显而易见的,它支持许多应用程序和多样化的用户社区。好处如下:

  • 资源(应用程序)的个人或组访问可以在一个位置进行配置

  • 对各种认证存储(用于集中用户管理)的广泛支持,为广泛的跨机器环境提供单一的认证和控制点

  • 通过 CAS 客户端库为基于 web 和非基于 web 的 Java 应用程序提供广泛的认证支持

  • 通过 CAS 提供单一引用点用户凭据( via CAS),因此 CAS 客户端应用程序无需了解用户的凭据,或知道如何验证它们

在本章中,我们将不太多关注 CAS 的管理,而是关注认证以及 CAS 如何为我们的网站用户充当认证点。尽管 CAS 通常在企业或教育机构的内部网络环境中看到,但它也可以在诸如 Sony Online Entertainment 公共面向网站等高知名度位置找到使用。

高级 CAS 认证流程

在较高层次上,CAS 由 CAS 服务器组成,这是确定认证的中心 web 应用程序,还有 CAS 服务,这是使用 CAS 服务器进行认证的不同的 web 应用程序。CAS 的基本认证流程通过以下动作进行:

  1. 用户尝试访问网站上的受保护资源。

  2. 用户通过浏览器从 CAS 服务请求登录到 CAS 服务器。

  3. CAS 服务器负责用户认证。如果用户尚未认证到 CAS 服务器,它会请求用户提供凭证。在下面的图中,用户被呈现一个登录页面。

  4. 用户提交凭证(即用户名和密码)。

  5. 如果用户的凭证有效,CAS 服务器将通过浏览器重定向一个服务票证。服务票证是一次性使用的令牌,用于标识用户。

  6. CAS 服务调用 CAS 服务器来验证票证是否有效,是否已过期等。注意这一步不是通过浏览器进行的。

  7. CAS 服务器回应一个断言,表示信任已经建立。如果票证可以接受,信任已经建立,用户可以通过正常的授权检查继续操作。

视觉上,它表现为以下 diagram:

我们可以看到,CAS 服务器与安全应用程序之间有很高的交互性,在建立用户的信任之前需要进行几次数据交换握手。这种复杂性的结果是一个相当难以通过常见技术进行欺骗的单点登录协议(假设已经实施了其他网络安全措施,如使用 SSL 和网络监控)。

既然我们已经了解了一般情况下 CAS 认证是如何工作的,现在让我们看看它如何应用于 Spring Security。

Spring Security 和 CAS

Spring Security 与 CAS 有很强的集成能力,尽管它不像我们在这本书的后半部分所探讨的 OAuth2 和 LDAP 集成那样紧密地集成在安全命名空间配置风格中。相反,大部分配置依赖于从安全命名空间元素到 bean 声明的 bean 导线和引用配置。

使用 Spring Security 进行 CAS 认证的两个基本部分包括以下内容:

  • 替换标准的AuthenticationEntryPoint实现,该实现通常处理将未认证的用户重定向到登录页面的操作,改为将用户重定向到 CAS 服务器。

  • 处理服务票证,当用户从 CAS 服务器重定向回受保护的资源时,通过使用自定义 servlet 过滤器

关于 CAS 的一个重要理解是,在典型的部署中,CAS 旨在替代您应用程序的所有其他登录机制。因此,一旦我们为 Spring Security 配置了 CAS,我们的用户必须将 CAS 作为唯一身份验证机制来使用。在大多数情况下,这并不是问题;如我们在上一节中讨论的,CAS 旨在代理身份验证请求到一个或多个身份验证存储(类似于 Spring Security 委托数据库或 LDAP 进行身份验证时)。从之前的图表中,我们可以看到,我们的应用程序不再检查其自己的身份验证存储来验证用户。相反,它通过使用服务票证来确定用户。然而,如我们稍后讨论的,最初,Spring Security 仍然需要一个数据存储来确定用户的授权。我们将在本章后面讨论如何移除这个限制。

在完成与 Spring Security 的基本 CAS 集成后,我们可以从主页上删除登录链接,并享受自动重定向到 CAS 登录界面的便利,在此界面中我们尝试访问受保护的资源。当然,根据应用程序的不同,允许用户明确登录(以便他们可以看到自定义内容等)也可能很有好处。

必需的依赖项

在我们进展太远之前,我们应该确保我们的依赖项已经更新。我们可以看到,以下是我们添加的依赖项列表,以及关于何时需要它们的注释:

    //build.gradle

    dependencies {
    // CAS:
    compile('org.springframework.security:spring-security-cas')
    ...
    }

安装和配置 CAS

CAS 的好处之一是有一个非常 dedicated 的团队,他们为开发高质量的软件和准确、简洁的文档做出了出色的 job。如果您选择跟随本章中的示例,建议您阅读适合您 CAS 平台的入门手册。您可以在apereo.github.io/cas/5.1.x/index.html找到此手册。

为了使集成尽可能简单,我们为本章 included 了一个 CAS 服务器应用程序,可以在 Spring Tool Suite 或 IntelliJ 中部署,还可以附带日历应用程序。本章中的示例将假设 CAS 部署在https://localhost:9443/cas/,日历应用程序部署在https://localhost:8443/。为了使 CAS 正常工作,必须使用 HTTPS。关于设置 HTTPS 的详细说明,请参阅附录附加参考资料

本章中的示例是使用最新的 CAS 服务器版本(写作时为 5.1.2)编写的。请注意,在 5.x 版本中,对 CAS 的某些后端类进行了重大更改。因此,如果您使用的是服务器的前一个版本,这些说明可能会有所不同或显著不同。

接下来,我们配置用于 CAS 认证的组件。

你应该从chapter10.00-calendarchapter10.00-cas-server开始章节,引入源代码。

配置基本的 CAS 集成

由于 Spring Security 命名空间不支持 CAS 配置,我们需要实现很多步骤才能让基本设置工作。为了了解发生了什么,你可以参考以下图表。

不用担心现在就理解整个图表,因为我们将其分解成小块,以便更容易消化:

创建 CAS ServiceProperties 对象

Spring Security 设置依赖于一个o.s.s.cas.ServicePropertiesbean 来存储关于 CAS 服务的常见信息。ServiceProperties对象在协调各种 CAS 组件之间的数据交换中扮演角色-它被用作一个数据对象来存储共享的(并且预期是匹配的)Spring CAS 堆栈中的各个参与者的 CAS 配置设置。你可以查看以下代码段中包含的配置:

    //src/main/java/com/packtpub/springsecurity/configuration/CasConfig.java

    static{
    System.setProperty("cas.server", "https://localhost:9443/cas");
     System.setProperty("cas.server.login", 
     "https://localhost:9443/cas/login");
    System.setProperty("cas.service", 
     "https://localhost:8443");
    System.setProperty("cas.service.login", 
    "https://localhost:8443/login");
     }
    @Value("#{systemProperties['cas.service.login']}")
    private String calendarServiceLogin;
    @Bean
    public ServiceProperties serviceProperties(){
     return new ServiceProperties(){{
    setService(calendarServiceLogin);
     }};
    }

你可能注意到了,我们利用系统属性使用了名为${cas.service}${cas.server}的变量。这两个值都可以包含在你的应用程序中,Spring 会自动将它们替换为在PropertySources配置中提供的值。这是一种常见的策略,当部署 CAS 服务时,由于 CAS 服务器很可能从开发环境过渡到生产环境,所以 CAS 服务器可能会发生变化。在这个实例中,我们默认使用localhost:9443作为 CAS 服务器,localhost:8443作为日历应用程序。当应用程序部署到生产环境时,可以通过系统参数来覆盖这个配置。另外,配置可以外部化到一个 Java 属性文件中。任一机制都允许我们适当外部化配置。

添加 CasAuthenticationEntryPoint 对象

如本章开头简要提到的,Spring Security 使用一个o.s.s.web.AuthenticationEntryPoint接口来请求用户的凭据。通常,这涉及到将用户重定向到登录页面。对于 CAS,我们需要将用户重定向到 CAS 服务器以请求登录。当我们重定向到 CAS 服务器时,Spring Security 必须包含一个service参数,指示 CAS 服务器应该发送服务票证的位置。幸运的是,Spring Security 提供了o.s.s.cas.web.CasAuthenticationEntryPoint对象,专门为此目的设计。示例应用程序中的配置如下:

    //src/main/java/com/packtpub/springsecurity/configuration/CasConfig.java

    @Value("#{systemProperties['cas.server.login']}")
    private String casServerLogin;
    @Bean
    public CasAuthenticationEntryPoint casAuthenticationEntryPoint(){
     return new CasAuthenticationEntryPoint(){{
     setServiceProperties(serviceProperties());
     setLoginUrl(casServerLogin);
     }};
    }

CasAuthenticationEntryPoint对象使用ServiceProperties类来指定用户认证后要发送服务票据的位置。CAS 允许根据配置对每个用户、每个应用程序进行选择性授权。我们将在配置处理该 URL 的 servlet 过滤器时立即检查这个 URL 的详细信息。接下来,我们需要更新 Spring Security 以使用具有casAuthenticationEntryPoint ID 的 bean。将以下内容更新到我们的SecurityConfig.java文件中:

    //src/main/java/com/packtpub/springsecurity/configuration/
    SecurityConfig.java

    @Autowired
    private CasAuthenticationEntryPoint casAuthenticationEntryPoint;
    @Override
    protected void configure(HttpSecurity http) throws Exception {
      ...
    // Exception Handling
     http.exceptionHandling()
     .authenticationEntryPoint(casAuthenticationEntryPoint)
     .accessDeniedPage("/errors/403");
    ...

最后,我们需要确保CasConfig.java文件被 Spring 加载。更新SecurityConfig.java文件,如下所示:

    //src/main/java/com/packtpub/springsecurity/configuration/
    SecurityConfig.java

    @Configuration
    @EnableWebSecurity(debug = true)
    @EnableGlobalAuthentication
    @Import(CasConfig.class)
    public class SecurityConfig extends WebSecurityConfigurerAdapter {

你需要做的最后一件事是删除现有的UserDetailsService对象作为AuthenticationManageruserDetailsService实现,因为它不再需要,因为CasAuthenticationEntryPointSecurityConfig.java文件中取代了它:

    src/main/java/com/packtpub/springsecurity/configuration/
    SecurityConfig.java
    @Override
    public void configure(AuthenticationManagerBuilder auth)
    throws Exception {
    super.configure(auth);
    //auth.userDetailsService(userDetailsService)
     // .passwordEncoder(passwordEncoder());
    }

如果你在这个时候启动应用程序并尝试访问“我的事件”页面,你将会立即被重定向到 CAS 服务器进行认证。CAS 的默认配置允许任何用户名与密码相等的用户进行认证。所以,你应该能够使用用户名admin1@example.com和密码admin1@example.com(或user1@example.com/user1@example.com)登录。

然而,你会注意到,即使在登录之后,你也会立即被重定向回 CAS 服务器。这是因为尽管目标应用程序能够接收到票据,但它无法进行验证,因此 CAS 将AccessDeniedException对象处理为对票据的拒绝。

使用 CasAuthenticationProvider 对象证明真实性

如果你一直跟随本书中 Spring Security 的逻辑流程,那么你应该已经知道接下来会发生什么——Authentication令牌必须由一个适当的AuthenticationProvider对象进行检查。CAS 也不例外,因此,这个谜题的最后一片拼图就是在AuthenticationManager内部配置一个o.s.s.cas.authentication.CasAuthenticationProvider对象。

让我们来看看以下步骤:

  1. 首先,我们将在CasConfig.java文件中声明 Spring bean,如下所示:
        //src/main/java/com/packtpub/springsecurity/configuration/
        CasConfig.java

        @Bean
        public CasAuthenticationProvider casAuthenticationProvider() {
           CasAuthenticationProvider casAuthenticationProvider = new
           CasAuthenticationProvider();
           casAuthenticationProvider.setTicketValidator(ticketValidator());
           casAuthenticationProvider.setServiceProperties
           (serviceProperties());
           casAuthenticationProvider.setKey("casJbcpCalendar");
           casAuthenticationProvider.setAuthenticationUserDetailsService(
             userDetailsByNameServiceWrapper);
             return casAuthenticationProvider;
        }
  1. 接下来,我们将在SecurityConfig.java文件中配置对新AuthenticationProvider对象的引用,该文件包含我们的AuthenticationManager声明:
        //src/main/java/com/packtpub/springsecurity/configuration/
        SecurityConfig.java

        @Autowired
        private CasAuthenticationProvider casAuthenticationProvider;
        @Override
        public void configure(final AuthenticationManagerBuilder auth)
        throws Exception   
        {
         auth.authenticationProvider(casAuthenticationProvider);
        }
  1. 如果你之前练习中有任何其他AuthenticationProvider引用,请记得将它们与 CAS 一起移除。所有这些更改都在前面的代码中有所展示。现在,我们需要处理CasAuthenticationProvider类中的其他属性和 bean 引用。ticketValidator属性指的是org.jasig.cas.client.validation.TicketValidator接口的实现;由于我们使用的是 CAS 3.0 认证,我们将声明一个org.jasig.cas.client.validation.Cas30ServiceTicketValidator实例,如下所示:
        //src/main/java/com/packtpub/springsecurity/configuration/
        CasConfig.java

        @Bean
        public Cas30ProxyTicketValidator ticketValidator(){
         return new Cas30ProxyTicketValidator(casServer);
        }

这个类提供的构造参数应该(再次)指的是访问 CAS 服务器的 URL。你会注意到,在这个阶段,我们已经从org.springframework.security包中移出,进入到org.jasig,这是 CAS 客户端 JAR 文件的一部分。在本章后面,我们将看到TicketValidator接口也有实现(仍在 CAS 客户端的 JAR 文件中),支持使用 CAS 的其他认证方法,例如代理票和 SAML 认证。

接下来,我们可以看到key属性;这个属性仅用于验证UsernamePasswordAuthenticationToken的完整性,可以任意定义。

正如我们在第八章《使用 TLS 的客户端证书认证》中所看到的,authenticationUserDetailsService属性指的是一个o.s.s.core.userdetails.AuthenticationUserDetailsService对象,该对象用于将Authentication令牌中的用户名信息转换为完全填充的UserDetails对象。当前实现通过查找 CAS 服务器返回的用户名并使用UserDetailsService对象查找UserDetails来实现这一转换。显然,这种技术只有在确认Authentication令牌的完整性未被破坏时才会使用。我们将此对象配置为对我们CalendarUserDetailsService实现的UserDetailsService接口的引用:

    //src/main/java/com/packtpub/springsecurity/configuration/CasConfig.java

    @Bean
    public UserDetailsByNameServiceWrapper
    authenticationUserDetailsService(
      final UserDetailsService userDetailsService){
      return new UserDetailsByNameServiceWrapper(){{
      setUserDetailsService(userDetailsService);
      }};
    }

你可能会好奇为什么没有直接引用UserDetailsService接口;原因在于,正如 OAuth2 一样,之后将会有额外的先进配置选项,这将允许使用 CAS 服务器的信息来填充UserDetails对象。

你的代码应该看起来像chapter10.01-calendarchapter10.01-cas-server

此时,我们应该能够启动 CAS 服务器和 JBCP 日历应用程序。然后你可以访问https://localhost:8443/,并选择所有事件,这将引导你到 CAS 服务器。之后你可以使用用户名admin1@example.com和密码admin1@example.com登录。验证成功后,你将被重定向回 JBCP 日历应用程序。干得好!

如果您遇到问题,很可能是由于不正确的 SSL 配置。请确保您已经按照附录中的附加参考材料所述设置了信任库文件为tomcat.keystore

单点登出

您可能会注意到,如果您从应用程序中登出,会得到登出确认页面。然而,如果您点击受保护的页面,比如我的事件页面,您仍然会被认证。问题在于,登出仅在本地发生。所以,当您请求 JBCP 日历应用程序中的另一个受保护资源时,会从 CAS 服务器请求登录。由于用户仍然登录到 CAS 服务器,它会立即返回一个服务票据,并将用户重新登录到 JBCP 日历应用程序。

这也就意味着,如果用户已经通过 CAS 服务器登录了其他应用程序,由于我们的日历应用程序不知道其他应用程序的情况,他们仍然会对那些应用程序进行身份验证。幸运的是,CAS 和 Spring Security 为这个问题提供了一个解决方案。正如我们可以从 CAS 服务器请求登录一样,我们也可以请求登出。您可以看到以下关于在 CAS 中登出工作方式的的高级示意图:

以下步骤解释了单点登出是如何进行的:

  1. 用户请求从 Web 应用程序登出。

  2. 然后 Web 应用程序通过浏览器重定向到 CAS 服务器,请求登出 CAS。

  3. CAS 服务器识别用户,然后向每个已认证的 CAS 服务发送登出请求。请注意,这些登出请求不是通过浏览器发生的。

  4. CAS 服务器通过提供原始的服务票据来指示哪个用户应该登出,该票据用于登录用户。然后应用程序负责确保用户登出。

  5. CAS 服务器向用户显示登出成功页面。

配置单点登出

单点登出的配置相对简单:

  1. 第一步是在我们的SecurityConfig.java文件中指定一个logout-success-url属性,该属性是 CAS 服务器的登出 URL。这意味着在本地登出后,我们将自动将用户重定向到 CAS 服务器的登出页面:
        //src/main/java/com/packtpub/springsecurity/configuration/
        SecurityConfig.java

        @Value("#{systemProperties['cas.server']}/logout")
        private static String casServerLogout;
        @Override
        protected void configure(final HttpSecurity http)
        throws Exception {
         ...
         http.logout()
        .logoutUrl("/logout")
        .logoutSuccessUrl(casServerLogout)
        .permitAll();
        }

由于我们只有一个应用程序,所以这是我们需要的,以使看起来像是在发生单点登出。这是因为我们在重定向到 CAS 服务器登出页面之前已经从我们的日历应用程序中登出。这意味着当 CAS 服务器将登出请求发送给日历应用程序时,用户已经登出了。

  1. 如果有多个应用程序,用户从另一个应用程序登出,CAS 服务器会将登出请求发送给我们的日历应用程序,而不会处理登出事件。这是因为我们的应用程序没有监听这些登出事件。解决方案很简单;我们必须创建一个SingleSignoutFilter对象,如下所示:
        //src/main/java/com/packtpub/springsecurity/configuration/
        CasConfig.java

        @Bean
        public SingleSignOutFilter singleSignOutFilter() {
           return new SingleSignOutFilter();
        }
  1. 接下来,我们需要让 Spring Security 意识到我们SecurityCOnfig.java文件中的singleLogoutFilter对象,通过将其作为<custom-filter>元素包括在内。将单次登出过滤器放在常规登出之前,以确保它接收到登出事件,如下所示:
        //src/main/java/com/packtpub/springsecurity/configuration/
        SecurityConfig.java

        @Autowired
        private SingleSignOutFilter singleSignOutFilter;
        @Override
        protected void configure(HttpSecurity http) throws Exception {
          ...
         http.addFilterAt(casFilter, CasAuthenticationFilter.class);
         http.addFilterBefore(singleSignOutFilter, LogoutFilter.class);
        // Logout
        http.logout()
         .logoutUrl("/logout")
         .logoutSuccessUrl(casServerLogout)
         .permitAll();
        }
  1. 在正常情况下,我们需要对web.xmlApplicationInitializer文件进行一些更新。然而,对于我们的日历应用程序,我们已经对我们的CasConfig.java文件进行了更新,如下所示:
        //src/main/java/com/packtpub/springsecurity/configuration/
        CasConfig.java

        @Bean
        public ServletListenerRegistrationBean
        <SingleSignOutHttpSessionListener>
        singleSignOutHttpSessionListener() {
          ServletListenerRegistrationBean<SingleSignOutHttpSessionListener> 
          listener = new     
          ServletListenerRegistrationBean<>();
          listener.setEnabled(true);
          listener.setListener(new SingleSignOutHttpSessionListener());
          listener.setOrder(1);
          return listener;
        }
        @Bean
        public FilterRegistrationBean 
        characterEncodingFilterRegistration() {
          FilterRegistrationBean registrationBean = 
          new FilterRegistrationBean
          (characterEncodingFilter());
          registrationBean.setName("CharacterEncodingFilter");
          registrationBean.addUrlPatterns("/*");
          registrationBean.setOrder(1);
          return registrationBean;
        }
        private CharacterEncodingFilter characterEncodingFilter() {
           CharacterEncodingFilter filter = new CharacterEncodingFilter(
             filter.setEncoding("UTF-8");
             filter.setForceEncoding(true);
             return filter;
        }

首先,我们添加了SingleSignoutHttpSessionListener对象,以确保删除服务票证与HttpSession的映射。我们还添加了CharacterEncodingFilter,正如 JA-SIG 文档所推荐的那样,以确保在使用SingleSignOutFilter时字符编码正确。

  1. 继续启动应用程序并尝试登出。你会观察到你实际上已经登出了。

  2. 现在,尝试重新登录并直接访问 CAS 服务器的登出 URL。对于我们设置,URL 是https://localhost:9443/cas/logout

  3. 现在,尝试访问 JBCP 日历应用程序。你会观察到,在没有重新认证的情况下,你无法访问该应用程序。这证明了单次登出是有效的。

你的代码应该看起来像chapter10.02-calendarchapter10.02-cas-server

集群环境

我们没有在单次登出初始图中提到的一件事是如何执行登出。不幸的是,它是通过将服务票证与HttpSession的映射作为内存映射存储来实现的。这意味着在集群环境中,单次登出将无法正确工作:

考虑以下情况:

  • 用户登录到集群成员 A

  • 集群成员 A验证服务票证

  • 然后,在内存中记住服务票证与用户会话的映射

  • 用户请求从CAS 服务器登出

CAS 服务器向 CAS 服务发送登出请求,但集群成员 B接到了登出请求。它在它的内存中查找,但没有找到服务票证 A的会话,因为它只存在于集群成员 A中。这意味着,用户没有成功登出。

寻求此功能的用户可能需要查看 JA-SIG JIRA 队列和论坛中解决此问题的方案。实际上,一个工作补丁已经提交到了issues.jasig.org/browse/CASC-114。记住,论坛和 JA-SIG JIRA 队列中有许多正在进行讨论和提案,所以在决定使用哪个解决方案之前,你可能想要四处看看。关于与 CAS 的集群,请参考 JA-SIG 在wiki.jasig.org/display/CASUM/Clustering+CAS的集群文档。

无状态服务的代理票证认证

使用 CAS 集中我们的认证似乎很适合 web 应用程序,但如果我们想使用 CAS 调用 web 服务呢?为了支持这一点,CAS 有一个代理票证(PT)的概念。以下是它如何工作的图表:

流程与标准的 CAS 认证流程相同,直到以下事情发生:

  1. 当包含一个额外参数时,服务票证被验证,这个参数叫做代理票证回调 URL(PGT URL)。

  2. CAS 服务器通过HTTPS调用PGT URL来验证PGT URL是否如它所声称的那样。像大多数 CAS 一样,这是通过与适当 URL 执行 SSL 握手来完成的。

  3. CAS 服务器提交代理授权票PGT)和代理授权票我欠你PGTIOU)到PGT URL,通过HTTPS确保票证提交到它们声称的来源。

  4. PGT URL接收到两个票证,并必须存储PGTIOUPGT的关联。

  5. CAS 服务器最终在步骤 1中返回一个响应,其中包括用户名和PGTIOU

  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(){
          pgtStorage().cleanUp();
        }
  1. ProxyGrantingTicketStorageImpl实现是一个内存中映射,将 PGTIOU 映射到 PGT。正如登出时一样,这意味着在集群环境中使用此实现会有问题。参考 JA-SIG 文档,确定如何在集群环境中设置:[wiki.jasig.org/display/CASUM/Clustering+CAS](https://wiki.jasig.org/display/CASUM/Clustering+CAS)

  2. 我们还需要定期通过调用其cleanUp()方法来清理ProxyGrantingTicketStorage。正如你所看到的,Spring 的任务抽象使这非常简单。你可以考虑调整配置,清除Ticket在一个适合你环境的单独线程池中。更多信息,请参考 Spring 框架参考文档中任务执行调度部分:static.springsource.org/spring/docs/current/spring-framework-reference/html/scheduling.html

  3. 现在我们需要使用我们刚刚创建的ProxyGrantingTicketStorage。我们只需要更新ticketValidator方法,使其引用我们的存储并知道 PGT URL。对CasConfig.java进行以下更新:

        //src/main/java/com/packtpub/springsecurity/configuration/
        CasConfig.java

        @Value("#{systemProperties['cas.calendar.service']}/pgtUrl")
        private String calendarServiceProxyCallbackUrl;
        @Bean
        public Cas30ProxyTicketValidator ticketValidator(){
          Cas30ProxyTicketValidator tv = new 
          Cas30ProxyTicketValidator(casServer);
          tv.setProxyCallbackUrl(calendarServiceProxyCallbackUrl);
          tv.setProxyGrantingTicketStorage(pgtStorage());
          return tv;
            }
  1. 我们需要做的最后更新是我们的CasAuthenticationFilter对象,当 PGT URL 被调用时,将 PGTIOU 存储到 PGT 映射中我们的ProxyGrantingTicketStorage实现。确保proxyReceptorUrl属性与Cas20ProxyTicketValidator对象的proxyCallbackUrl属性相匹配,以确保 CAS 服务器将票证发送到我们的应用程序正在监听的 URL。在security-cas.xml中进行以下更改:
        //src/main/java/com/packtpub/springsecurity/configuration/
        CasConfig.java

        @Bean
        public CasAuthenticationFilter casFilter() {
           CasAuthenticationFilter caf = new CasAuthenticationFilter();
        caf.setAuthenticationManager(authenticationManager);
        caf.setFilterProcessesUrl("/login");
        caf.setProxyGrantingTicketStorage(pgtStorage());
        caf.setProxyReceptorUrl("/pgtUrl");
         return caf;
        }

既然我们已经有了一个 PGT,我们该怎么办呢?服务票证是一次性使用的令牌。然而,PGT 可以用来生成 PT。让我们看看我们可以如何使用 PGT 创建一个 PT。

您会注意到proxyCallBackUrl属性与我们的上下文相关proxyReceptorUrl属性的绝对路径相匹配。由于我们将我们的基本应用程序部署到https://${cas.service }/,我们proxyReceptor URL 的完整路径将是https://${cas.service }/pgtUrl

使用代理票证

我们现在可以使用我们的 PGT 创建一个 PT 来验证它对一个服务。这个操作在本书中包含的EchoController类中非常简单地演示了。您可以在以下代码片段中看到相关的部分。有关更多详细信息,请参阅示例的源代码:

    //src/main/java/com/packtpub/springsecurity/web/controllers/
    EchoController.java

    @ResponseBody
   @RequestMapping("/echo")
    public String echo() throws UnsupportedEncodingException {
      final CasAuthenticationToken token = (CasAuthenticationToken)
     SecurityContextHolder.getContext().getAuthentication();
    final String proxyTicket = token.getAssertion().getPrincipal()
    .getProxyTicketFor(targetUrl);
    return restClient.getForObject(targetUrl+"?ticket={pt}",
    String.class, proxyTicket);
    }

这个控制器是一个构造的例子,它将获取一个 PT,用于验证对当前登录用户的所有事件进行 RESTful 调用的请求。然后它将 JSON 响应写入页面。让一些用户感到困惑的是,EchoController对象实际上正在对同一应用程序中的MessagesController对象进行 RESTful 调用。这意味着日历应用程序对自己进行 RESTful 调用

大胆地访问https://localhost:8443/echo来看它的实际效果。这个页面看起来很像 CAS 登录页面(除了 CSS)。这是因为控制器试图回显我们的“我的事件”页面,而我们的应用程序还不知道如何验证 PT。这意味着它被重定向到 CAS 登录页面。让我们看看我们如何可以验证代理票证。

您的代码应该看起来像chapter10.03-calendarchapter10.03-cas-server

验证代理票证

让我们来看看以下步骤,了解验证代理票证的方法:

  1. 我们首先需要告诉ServiceProperties对象我们希望验证所有票证,而不仅仅是那些提交到filterProcessesUrl属性的票证。对CasConfig.java进行以下更新:
        //src/main/java/com/packtpub/springsecurity/configuration/
        CasConfig.java

        @Bean
        public ServiceProperties serviceProperties(){
          return new ServiceProperties(){{
             setService(calendarServiceLogin);
             setAuthenticateAllArtifacts(true);
          }};
        }
  1. 然后我们需要更新我们的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 casFilter() {
          CasAuthenticationFilter caf = new CasAuthenticationFilter();
          caf.setAuthenticationManager(authenticationManager);
          caf.setFilterProcessesUrl("/login");
          caf.setProxyGrantingTicketStorage(pgtStorage());
          caf.setProxyReceptorUrl("/pgtUrl");
          caf.setServiceProperties(serviceProperties());
          caf.setAuthenticationDetailsSource(new        
          ServiceAuthenticationDetailsSource(serviceProperties())
        );
         return caf;
        }
  1. 我们还需要确保我们使用的是Cas30ProxyTicketValidator对象,而不是Cas30ServiceTicketValidator实现,并指出我们想要接受哪些代理票证。我们将配置我们的接受来自任何 CAS 服务的代理票证。在生产环境中,您可能希望考虑只限制那些可信的 CAS 服务:
        //src/main/java/com/packtpub/springsecurity/configuration/
        CasConfig.java

        @Bean
        public Cas30ProxyTicketValidator ticketValidator(){
          Cas30ProxyTicketValidator tv = new 
          Cas30ProxyTicketValidator(casServer);
          tv.setProxyCallbackUrl(calendarServiceProxyCallbackUrl);
          tv.setProxyGrantingTicketStorage(pgtStorage());
          tv.setAcceptAnyProxy(true);
          return tv;
        }
  1. 最后,我们希望能够为我们的CasAuthenticationProvider对象提供一个缓存,这样我们就不需要为每个服务调用而访问 CAS 服务:
        //src/main/java/com/packtpub/springsecurity/configuration/
        CasConfig.java

        @Bean
        public CasAuthenticationProvider casAuthenticationProvider() {
         CasAuthenticationProvider cap = new CasAuthenticationProvider();
         cap.setTicketValidator(ticketValidator());
         cap.setServiceProperties(serviceProperties());
         cap.setKey("casJbcpCalendar");
         cap.setAuthenticationUserDetailsService
         (userDetailsByNameServiceWrapper);
         cap.setStatelessTicketCache(ehCacheBasedTicketCache());
         return cap;
       }
      @Bean
      public EhCacheBasedTicketCache ehCacheBasedTicketCache() {
        EhCacheBasedTicketCache cache = new EhCacheBasedTicketCache();
        cache.setCache(ehcache());
        return cache;
      }
     @Bean(initMethod = "initialise", destroyMethod = "dispose")
     public Cache ehcache() {
       Cache cache = new Cache("casTickets", 50, true, false, 3_600,  900);
       return cache;
     }
  1. 正如您可能已经猜到的那样,缓存需要我们章节开头提到的ehcache依赖。接着重新启动应用程序,并再次访问https://localhost:8443/echo。这次,您应该看到一个 JSON 响应,响应我们的事件页面调用。

您的代码应该看起来像chapter10.04-calendarchapter10.04-cas-server

定制 CAS 服务器

本节中的所有更改都将是针对 CAS 服务器,而不是日历应用程序。本节仅旨在介绍配置 CAS 服务器的入门,因为详细的设置确实超出了本书的范围。正如日历应用程序的更改一样,我们鼓励您跟随本章中的更改。更多信息,您可以参考 JA-SIG CAS 维基百科页面在wiki.jasig.org/display/CAS/Home

CAS WAR 覆盖

定制 CAS 的首选方式是使用 Maven 或 Gradle War 覆盖。通过这种机制,您可以从 UI 到认证 CAS 服务的方法改变一切。WAR 覆盖的概念很简单。您添加一个 WAR 覆盖cas-server-webapp作为一个依赖,然后提供额外的文件,这些文件将与现有的 WAR 覆盖合并。有关关于 CAS WAR 覆盖的更多信息,请参考 JA-SIG 文档在wiki.jasig.org/display/CASUM/Best+Practice+-+Setting+Up+CAS+Locally+using+the+Maven2+WAR+Overlay+Method

CAS 内部认证是如何工作的?

在我们深入讨论 CAS 配置之前,我们将简要说明 CAS 认证处理的标准行为。以下图表应帮助你理解允许 CAS 与我们的内置 LDAP 服务器通信所需的配置步骤:

虽然之前的图表描述了 CAS 服务器本身内部认证的流程,但如果你正在实现 Spring Security 和 CAS 之间的集成,你可能需要调整 CAS 服务器的配置。因此,理解 CAS 认证的高级工作原理是很重要的。

CAS 服务器的org.jasig.cas.authentication.AuthenticationManager接口(不要与 Spring Security 中同名的接口混淆)负责根据提供的凭据对用户进行认证。与 Spring Security 类似,凭据的实际处理委托给一个(或多个)实现org.jasig.cas.authentication.handler.AuthenticationHandler接口的处理类(我们认识到 Spring Security 中相应的接口是AuthenticationProvider)。

最后,org.jasig.cas.authentication.principal.CredentialsToPrincipalResolver接口用于将传入的凭据转换为完整的org.jasig.cas.authentication.principal.Principal对象(在 Spring Security 中实现UserDetailsService时,会有类似的行为)。

虽然这不是对 CAS 服务器后台功能的全面回顾,但这应该能帮助你理解接下来的几个练习中的配置步骤。我们鼓励你阅读 CAS 的源代码,并参考在 JA-SIG CAS 维基百科页面上的网络文档,网址为www.ja-sig.org/wiki/display/CAS

配置 CAS 以连接到我们的内置 LDAP 服务器。

默认配置的org.jasig.cas.authentication.principal.UsernamePasswordCredentialsToPrincipalResolver对象不允许我们返回属性信息并展示 Spring Security CAS 集成的这一特性,因此我们建议使用一个允许这样做的实现。

如果你已经完成了上一章的 LDAP 练习,那么配置和使用一个简单的认证处理程序(尤其是org.jasig.cas.adaptors.ldap.BindLdapAuthenticationHandler)会很容易,它与我们在上一章中使用的内置 LDAP 服务器通信。我们将引导你通过配置 CAS,使其在以下指南中返回用户 LDAP 属性。

所有的 CAS 配置都将在 CAS 安装的WEB-INF/deployerConfigContext.xml文件中进行,通常涉及将类声明插入到已经存在的配置文件段中。我们已经从cas-server-webapp中提取了默认的WEB-INF/deployerConfigContext.xml文件,并将其放在了cas-server/src/main/webapp/WEB-INF中。

如果这份文件的内容对你来说很熟悉,那是因为 CAS 像 JBCP 日历一样,也是使用 Spring 框架来进行配置的。我们建议如果你想要深入理解这些配置设置是如何工作的,最好使用一个好的 IDE 并且有一个方便的 CAS 源代码参考。记住,在本节以及所有引用到WEB-INF/deployerConfigContext.xml的部分,我们指的是 CAS 安装,而不是 JBCP 日历。

让我们来看看以下步骤:

  1. 首先,我们将在SimpleTestUsernamePasswordAuthenticationHandler对象的位置添加一个新的BindLdapAuthenticationHandler对象,该对象将尝试将用户绑定到 LDAP(正如我们在第六章,LDAP 目录服务中所做的那样)。

  2. AuthenticationHandler接口将被放置在authenticationManagerbean 的authenticationHandlers属性中:

        //cas-server/src/main/webapp/WEB-INF/deployerConfigContext.xml

        <property name="authenticationHandlers">
        <list>
         ... remove ONLY
        SimpleTestUsernamePasswordAuthenticationHandler ...
        <bean class="org.jasig.cas.adaptors
        .ldap.BindLdapAuthenticationHandler">
        <property name="filter" value="uid=%u"/>
        <property name="searchBase" value="ou=Users"/>
        <property name="contextSource" ref="contextSource"/>
         </bean>
        </list>
        </property>

别忘了删除对SimpleTestUsernamePasswordAuthenticationHandler对象的引用,或者至少将其定义移到BindLdapAuthenticationHandler对象之后,否则,你的 CAS 认证将不会使用 LDAP,而是使用代理处理器!

  1. 你会注意到对一个contextSourcebean 的引用;这定义了org.springframework.ldap.core.ContextSource实现,CAS 将使用它来与 LDAP 进行交互(是的,CAS 也使用 Spring LDAP)。我们将在文件的末尾使用 Spring Security 命名空间来简化其定义,如下所示:
    //cas-server/src/main/webapp/WEB-INF/deployerConfigContext.xml

    <sec:ldap-server id="contextSource"  
     ldif="classpath:ldif/calendar.ldif" root="dc=jbcpcalendar,dc=com" />
    </beans>

这创建了一个使用随本章提供的calendar.ldif文件的嵌入式 LDAP 实例。当然,在生产环境中,你希望指向一个真实的 LDAP 服务器。

  1. 最后,我们需要配置一个新的org.jasig.cas.authentication.principal.CredentialsToPrincipalResolver对象。这个对象负责将用户提供的凭据(CAS 已经使用BindLdapAuthenticationHandler对象进行认证的)翻译成一个完整的org.jasig.cas.authentication.principal.Principal认证主体。你会注意到这个类中有许多配置选项,我们将略过它们。当你深入探索 CAS 时,你可以自由地研究它们。

  2. 删除UsernamePasswordCredentialsToPrincipalResolver,并向 CASauthenticationManagerbean 的credentialsToPrincipalResolvers属性中添加以下内联 bean 定义:

        //cas-server/src/main/webapp/WEB-INF/deployerConfigContext.xml

       <property name="credentialsToPrincipalResolvers">
        <list>
        <!-- REMOVE UsernamePasswordCredentialsToPrincipalResolver -->
        <bean class="org.jasig.cas.authentication.principal
        .HttpBasedServiceCredentialsToPrincipalResolver" />
        <bean class="org.jasig.cas.authentication.principal
        .CredentialsToLDAPAttributePrincipalResolver">
        <property name="credentialsToPrincipalResolver">
        <bean class="org.jasig.cas.authentication.principal
        .UsernamePasswordCredentialsToPrincipalResolver"/>
        </property>
        <property name="filter" value="(uid=%u)"/>
        <property name="principalAttributeName" value="uid"/>
        <property name="searchBase" value="ou=Users"/>
        <property name="contextSource" ref="contextSource"/>
        <property name="attributeRepository" ref="attributeRepository"/>
        </bean>
        </list>
        </property>

你会注意到,与 Spring Security LDAP 配置一样,CAS 中有很多同样的行为,原则是基于 DN 在目录的子树下基于属性匹配进行搜索。

请注意,我们尚未亲自为 ID 为attributeRepository的 bean 配置,这应该指的是org.jasig.services.persondir.IPersonAttributeDao的一个实现。CAS 随带有一个默认配置,其中包括这个接口的一个简单实现org.jasig.services.persondir.support.StubPersonAttributeDao,这将足以直到我们在后面的练习中配置基于 LDAP 的属性。

您的代码应该看起来像chapter10.05-calendarchapter10.05-cas-server

所以,现在我们已经在大 CAS 中配置了基本的 LDAP 身份验证。在这个阶段,您应该能够重新启动 CAS,启动 JBCP 日历(如果它还没有运行),并使用admin1@example.com/adminuser1@example.com/user1对它进行身份验证。去尝试看看它是否有效。如果它不起作用,尝试检查日志并将您的配置与示例配置进行比较。

如第五章中所讨论的,使用 Spring Data 进行身份验证,您可能会遇到启动应用程序时出现问题,无论临时目录apacheds-spring-security是否仍然存在。如果应用程序似乎不存在,检查日志并查看是否需要删除apacheds-spring-security目录。

从 CAS 断言获取 UserDetails 对象

直到这一点,我们一直通过从我们的InMemoryUserDetailsManager对象获取角色来使用 CAS 进行身份验证。然而,我们可以像对待 OAuth2 一样,从 CAS 断言中创建UserDetails对象。第一步是配置 CAS 服务器以返回附加属性。

在 CAS 响应中返回 LDAP 属性

我们知道 CAS 可以在 CAS 响应中返回用户名,但它也可以在 CAS 响应中返回任意属性。让我们看看我们如何更新 CAS 服务器以返回附加属性。再次强调,本节中的所有更改都在 CAS 服务器中,而不是在日历应用程序中。

将 LDAP 属性映射到 CAS 属性

第一步需要我们将 LDAP 属性映射到 CAS 断言中的属性(包括我们期望包含用户GrantedAuthorityrole属性)。

我们将在 CAS 的deployerConfigContext.xml文件中添加另一段配置。这一新的配置是必需的,以指导 CAS 如何将来自 CASPrincipal对象的属性映射到 CASIPersonAttributes对象,这最终将作为票证验证的一部分序列化。这个 bean 配置应该替换相同名称的 bean-即attributeRepository-如下所示:

    //cas-server/src/main/webapp/WEB-INF/deployerConfigContext.xml

    <bean id="attributeRepository" class="org.jasig.services.persondir
    .support.ldap.LdapPersonAttributeDao">
    <property name="contextSource" ref="contextSource"/>
    <property name="requireAllQueryAttributes" value="true"/>
    <property name="baseDN" value="ou=Users"/>
    <property name="queryAttributeMapping">
    <map>
     <entry key="username" value="uid"/>
    </map>
     </property>
    <property name="resultAttributeMapping">
    <map>
    <entry key="cn" value="FullName"/>
    <entry key="sn" value="LastName"/>
    <entry key="description" value="role"/>
    </map>
    </property>
    </bean>

这里的幕后功能确实令人困惑——本质上,这个类的目的是将Principal映射回 LDAP 目录。(这是queryAttributeMapping属性,它将Principalusername字段映射到 LDAP 查询中的uid属性。)提供的baseDNJava Bean 属性使用 LDAP 查询(uid=user1@example.com)进行搜索,并从匹配的条目中读取属性。这些属性使用resultAttributeMapping属性中的键/值对映射回Principal。我们认识到,LDAP 的cnsn属性被映射到有意义的名称,并且description属性被映射到用于确定我们用户角色的属性。

复杂性的一部分源于这样一个事实:这部分功能被包装在一个名为Person Directory的单独项目中(www.ja-sig.org/wiki/display/PD/Home),该项目旨在将关于一个人的多个信息源聚合到一个单一的视图中。Person Directory的设计如此,它并不直接与 CAS 服务器绑定,并且可以作为其他应用程序的一部分被复用。这种设计选择的一个缺点是,它使得 CAS 配置的一些方面比最初看起来要复杂。

排查 CAS 中的 LDAP 属性映射问题

我们很想设置与第六章中使用的 Spring Security LDAP 相同的查询类型(LDAP 目录服务),以便能够将Principal映射到完整的 LDAP 别名,然后使用该 DN 通过匹配groupOfUniqueNames条目的uniqueMember属性来查找组成员。不幸的是,CAS LDAP 代码目前还没有这种灵活性,导致结论,更高级的 LDAP 映射将需要对 CAS 的基本类进行扩展。

授权 CAS 服务访问自定义属性

接下来,我们将需要授权任何通过 HTTPS 访问这些属性的 CAS 服务。为此,我们可以更新RegisteredServiceImpl,其描述为仅允许 HTTPS URL(在InMemoryServiceRegistryDaoImpl中),如下所示:

    //cas-server/src/main/webapp/WEB-INF/deployerConfigContext.xml

    <bean class="org.jasig.cas.services.RegisteredServiceImpl">
      <property name="id" value="1" />
      <property name="name" value="HTTPS" />
      <property name="description" value="Only Allows HTTPS Urls" />
      <property name="serviceId" value="https://**" />
      <property name="evaluationOrder" value="10000002" />
      <property name="allowedAttributes">
      <list>
        <value>FullName</value>
        <value>LastName</value>
        <value>role</value>
     </list>
    </property>
    </bean>

从 CAS 获取 UserDetails

当我们第一次将 CAS 与 Spring Security 集成时,我们配置了UserDetailsByNameServiceWrapper,它简单地将呈现给 CAS 的用户名转换为从UserDetailsService获取的UserDetails对象,我们所引用的(在我们的案例中,它是InMemoryUserDetailsManager)。现在既然 CAS 正在引用 LDAP 服务器,我们可以设置LdapUserDetailsService,正如我们在第六章末尾讨论的那样(LDAP 目录服务),并且一切都会正常工作。请注意,我们已经回到修改日历应用程序,而不是 CAS 服务器。

GrantedAuthorityFromAssertionAttributesUser对象

现在我们已经修改了 CAS 服务器以返回自定义属性,接下来我们将尝试 Spring Security CAS 集成的另一个功能-从 CAS 断言本身填充UserDetails的能力!实际上,这就像将AuthenticationUserDetailsService实现更改为o.s.s.cas.userdetails.GrantedAuthorityFromAssertionAttributesUserDetailsService对象一样简单,该对象的任务是读取 CAS 断言,查找某个属性,并将该属性的值直接映射到用户的GrantedAuthority对象。假设有一个名为 role 的属性将随断言返回。我们只需在CaseConfig.xml文件中配置一个新的authenticationUserDetailsService bean(确保替换之前定义的authenticationUserDetailsService bean):

    //src/main/java/com/packtpub/springsecurity/configuration/CasConfig.java

    @Bean
    public AuthenticationUserDetailsService userDetailsService(){
       GrantedAuthorityFromAssertionAttributesUserDetailsService uds
       = new GrantedAuthorityFromAssertionAttributesUserDetailsService(
       new String[]{"role"}
    );
     return uds;
    }

你还需要将从SecurityConfig.java文件中的userDetailsService bean 删除,因为现在它不再需要了。

使用 SAML 1.1 的替代票证认证。

安全断言标记语言SAML)是一个使用结构化 XML 断言的标准、跨平台身份验证协议。SAML 被许多产品支持,包括 CAS(实际上,我们将在后面的章节中查看 Spring Security 本身对 SAML 的支持)。

虽然标准的 CAS 协议可以扩展以返回属性,但 SAML 安全断言 XML 方言解决了属性传递的一些问题,使用了我们之前描述的 CAS 响应协议。幸运的是,在CasSecurity.java中配置的TicketValidator实现从 CAS 票证验证切换到 SAML 票证验证就像改变以下ticketValidator一样简单:

    //src/main/java/com/packtpub/springsecurity/configuration/CasConfig.java

    @Bean
    public Saml11TicketValidator ticketValidator(){
      return new Saml11TicketValidator(casServer);
    }

你会注意到再也没有对 PGT URL 的引用。这是因为Saml11TicketValidator对象不支持 PGT。虽然两者都可以存在,但我们选择删除任何对代理票证认证的引用,因为我们不再使用代理票证认证。如果你不想在本练习中删除它,不用担心;只要你的ticketValidator bean ID 与之前的代码片段相似,它就不会阻止我们的应用程序运行。

通常,建议使用 SAML 票证验证而不是 CAS 2.0 票证验证,因为它增加了更多的非否认功能,包括timestamp验证,并以标准方式解决了属性问题。

重新启动 CAS 服务器和 JBCP 日历应用程序。然后你可以访问https://localhost:8443,并看到我们的日历应用程序可以从 CAS 响应中获取UserDetails

你的代码现在应该看起来像chapter10.06-calendarchapter10.06-cas-server

属性检索有什么用?

记住,CAS 为我们的应用程序提供了一层抽象,消除了我们应用程序直接访问用户存储库的能力,而是强制所有此类访问通过 CAS 作为代理进行。

这非常强大!这意味着我们的应用程序不再关心用户存储在什么类型的存储库中,也不必担心如何访问它们——这进一步证实了通过 CAS 进行身份验证足以证明用户应该能够访问我们的应用程序。对于系统管理员来说,这意味着如果 LDAP 服务器被重新命名、移动或进行其他调整,他们只需要在单一位置——CAS 中重新配置它。通过 CAS 集中访问允许在组织的整体安全架构中具有高度的灵活性和适应性。

这个故事讲述了从 CAS 获取属性的有用性;现在所有通过 CAS 验证的应用程序对用户有相同的视图,并且可以在任何 CAS 启用的环境中一致地显示信息。

请注意,一旦验证通过,Spring Security CAS 不再需要 CAS 服务器,除非用户需要重新验证。这意味着存储在应用程序中用户Authentication对象中的属性和其他用户信息可能会随时间变得过时,并且可能与源 CAS 服务器不同步。请注意适当地设置会话超时,以避免这个潜在的问题!

额外的 CAS 功能

CAS 提供了通过 Spring Security CAS 包装器暴露之外的高级配置功能。其中一些包括以下功能:

  • 为在 CAS 服务器上配置的时间窗口内访问多个 CAS 安全应用程序的用户提供透明的单点登录。应用程序可以通过在TicketValidator上设置renew属性为true来强制用户向 CAS 进行身份验证;在用户试图访问应用程序的受保护区域时,您可能希望在自定义代码中有条件地设置此属性。

  • 获取服务票证的 RESTful API。

  • JA-SIG 的 CAS 服务器也可以作为 OAuth2 服务器。如果你想想,这是有道理的,因为 CAS 与 OAuth2 非常相似。

  • 为 CAS 服务器提供 OAuth 支持,以便它可以获取委派 OAuth 提供者(即 Google)的访问令牌,或者使 CAS 服务器本身成为 OAuth 服务器。

我们鼓励您探索 CAS 客户端和服务器的全部功能,并向 JA-SIG 社区论坛中的热心人士提问!

总结

在本章中,我们学习了关于 CAS 单点登录门户的知识,以及它是如何与 Spring Security 集成的,我们还涵盖了 CAS 架构以及在 CAS 启用环境中参与者之间的通信路径。我们还看到了 CAS 启用应用程序对应用开发人员和系统管理员的益处。我们还学习了如何配置 JBCP 日历与基本 CAS 安装进行交互。我们还涵盖了 CAS 的单一登出支持的用途。

我们同样了解了代理票证认证是如何工作的,以及如何利用它来认证无状态服务。

我们还涵盖了更新 CAS 以与 LDAP 交互,以及将 LDAP 数据与我们的 CAS 启用应用程序共享的任务。我们还学习了如何使用行业标准的 SAML 协议实现属性交换。

我们希望这一章是对单点登录世界的一个有趣的介绍。市场上还有许多其他单点登录系统,大部分是商业的,但 CAS 无疑是开源 SSO 世界中的领导者之一,是任何组织构建 SSO 能力的一个优秀平台。

在下一章中,我们将学习更多关于 Spring Security 授权的内容。

第十一章:细粒度访问控制

在本章中,我们首先将探讨两种实现细粒度授权的方法-可能影响应用程序页面部分的内容。接下来,我们将查看 Spring Security 通过方法注解和使用基于接口的代理来实现业务层安全的方法。然后,我们将回顾注解 based 安全的一个有趣功能,它允许基于角色的数据过滤。最后,我们将查看类 based 代理与接口 based 代理的区别。

在本章的进程中,我们将介绍以下主题:

  • 配置并在不同的方法上进行实验,以实现在页面上根据用户请求的安全上下文进行内容授权检查

  • 执行配置和代码注解,使调用者预授权成为我们应用程序业务层安全的关键部分

  • 实现方法级安全性的几种不同方法,并回顾每种类型的优缺点

  • 在集合和数组上使用方法级注解实现数据过滤

  • 在 Spring MVC 控制器上实现方法级安全,避免配置antMatcher()方法和<intercept-url>元素

Gradle 依赖项

有许多可选依赖项可能需要,这取决于您决定使用哪些功能。许多这些依赖项已在 Spring Boot 的启动父级中注释掉。您会发现我们的build.gradle文件已经包括了以下所有依赖项:

    //build.gradle
    // Required for JSR-250 based security:
    // JSR-250 Annotations

 compile ('javax.annotation:javax.annotation-api:1.3')    // Already provided by Spring Boot
 *// compile('cglib:cglib-nodep')*    // Already provided by Spring Boot
    // Required for protect-pointcut
 *// compile('org.aspectj:aspectjweaver')*

集成 Spring 表达式语言(SpEL)

Spring Security 利用Spring 表达式语言SpEL)集成,以便轻松阐述各种授权要求。如果您还记得,我们在第二章 开始使用 Spring Security中已经查看了 SpEL 的使用,当时我们定义了我们的antMatcher()方法:

    .antMatchers("/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
principal 允许访问当前Authentication对象的 principal 属性的权限。正如在第三章 自定义认证中讨论的,这通常是一个UserDetails实例。
authentication SecurityContext 接口返回的 SecurityContextHolder 类的 getContext() 方法获取当前 Authentication 对象。
permitAll 总是返回 true
denyAll 总是返回 false
isAnonymous() 如果当前主体是匿名(没有认证),则返回 true。
isRememberMe() 如果当前主体是使用记住我功能进行认证的,则返回 true
isAuthenticated() 如果用户不是匿名用户(也就是说,他们已经认证),则返回 true
isFullyAuthenticated() 如果用户通过记住我以外的其他方式进行认证,则返回 true
hasPermission(Object target, Object permission) 如果用户有权限访问给定权限的指定对象,则返回 true
hasPermission( String targetId, String targetType, Object permission) 如果用户有权限访问给定类型和权限的指定标识符,则返回 true

我们提供了一些使用这些 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

    fullyAuthenticated() and hasAnyRole('ADMIN','ROOT')

    // allow if Authentication.getName() equals admin

    authentication.name == 'admin'

WebSecurityExpressionRoot

o.s.s.web.access.expression.WebSecurityExpressionRoot 类为我们提供了一些额外的属性。这些属性,连同前面提到的标准属性,可以在 antMatchers() 方法的访问属性中以及 JSP/Thymeleaf 的 <sec:authorize> 标签的 access 属性中使用,我们稍后会讨论这些:

表达式 描述
request 当前的 HttpServletRequest 方法。
hasIpAddress(String... ipAddress) 如果当前 IP 地址匹配 ipAddress 值,则返回 true。这可以是一个确切的 IP 地址或者 IP 地址/子网掩码。

使用请求属性

request 属性相对容易理解,但我们提供了一些示例代码。请记住,这些示例都可以放在 antMatchers() 方法的访问属性中或者 <sec:authorize> 元素的访问属性中:

    // allows only HTTP GETrequest.method == 'GET'
    // allow anyone to perform a GET, but
    // other methods require ROLE_ADMIN

    request.method == 'GET' ? permitAll : hasRole('ADMIN')

使用 hasIpAddress 方法

hasIpAddress 方法并没有 request 属性那么简单明了。hasIpAddress 会很容易匹配一个确切的 IP 地址;例如,以下代码如果当前用户 IP 地址是 192.168.1.93,则允许访问:

    hasIpAddress('192.168.1.93')

然而,这并不是非常有用。相反,我们可以定义以下代码,这也将匹配我们的 IP 地址以及我们子网中的任何其他 IP 地址:

    hasIpAddress('192.168.1.0/24')

问题是:这是如何计算的?关键是要理解如何计算网络地址及其掩码。要了解如何进行计算,我们可以看一个具体的例子。我们从 Linux 终端启动 ifconfig 来查看我们的网络信息(Windows 用户可以在命令提示符中输入 ipconfig /all):

$ ifconfig wlan0     Link encap:Ethernet HWaddr a0:88:b4:8b:26:64 inet addr:192.168.1.93 Bcast:192.168.1.255 Mask:255.255.255.0

查看以下图表:

我们可以看到我们的掩码的前三个八位字节是255。这意味着我们的IP 地址的前三个八位字节属于网络地址。在我们的计算中,这意味着剩下的八位字节是0

然后我们可以通过首先将每个八位字节转换为二进制数,然后计算其中有多少个一来进行掩码计算。在我们的实例中,得到24

这意味着我们的 IP 地址将匹配192.168.1.0/24。有关更多信息的良好资源是思科的文档,可在www.cisco.com/c/en/us/support/docs/ip/routing-information-protocol-rip/13788-3.html找到。

方法访问控制表达式根类

方法访问控制表达式(SpEL)还提供了一些额外的属性,可以通过o.s.s.access.expression.method.MethodSecurityExpressionRoot类使用:

表达式 描述
target 指的是this或被保护的当前对象。
returnObject 指的是注解方法的返回对象。
filterObject 可以在与@PreFilter@PostFilter结合使用时,用于集合或数组上,只包含与表达式匹配的元素。filterObject对象代表集合或数组的循环变量。
#<methodArg> 可以通过在参数名称前加上#来引用任何方法参数。例如,一个名为id的方法参数可以使用#id来引用。

如果这些表达式的描述看起来有点简略,不用担心;我们将在本章后面通过一些例子来详细说明。

我们希望您已经对 Spring Security 的 SpEL 支持的力量有了大致的了解。要了解更多关于 SpEL 的信息,请参考 Spring 的参考文档:docs.spring.io/spring/docs/current/spring-framework-reference/html/expressions.html

页面级授权

页面级授权指的是基于特定用户请求上下文的应用程序功能的可用性。与我们在第二章 开始使用 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 授权规则来渲染内容的功能。这是通过使用authorizeRequests()方法和antMatchers()方法来实现的。

如果有多个 HTTP 元素,authorizeRequests()方法将使用当前匹配的 HTTP 元素的规则。

例如,我们可以确保所有事件链接只在适当的时候显示,即对于管理员用户——回想一下我们之前定义的访问规则如下:

    .antMatchers("/events/").hasRole("ADMIN")

更新header.html文件以利用此信息,并根据条件渲染到所有事件页面的链接:

//src/main/resources/templates/fragments/header.html

<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity4">
...
<li sec:authorize-url="/events/">
<a id="navEventsLink" th:href="@{/events/}">All Events</a></li>

这将确保除非用户有足够的权限访问所声明的 URL,否则不显示标签的内容。进一步细化授权检查是可能的,通过在 URL 之前包含方法属性,如下所示:

    <li sec:authorize-url="GET /events/">
    <a id="navEventsLink" th:href="@{/events/}">All Events</a></li>

使用authorize-url属性在代码块上定义授权检查是方便的,因为它将实际授权检查的知识从您的页面抽象出来,并将其保存在您的安全配置文件中。

请注意,HTTP 方法应与您安全antMatchers()方法中指定的大小写相匹配,否则它们可能不会像您期望的那样匹配。另外,请注意 URL 应始终相对于 Web 应用程序上下文根(如您的 URL 访问规则)。

对于许多目的来说,使用<sec>标签的authorize-url属性足以正确地仅当用户被允许查看时显示与链接或操作相关的内容。记住,这个标签不仅仅需要包围一个链接;如果用户没有权限提交它,它甚至可以包围整个表单。

使用 SpEL 的条件渲染

当与<sec>标签一起使用时,可以控制 JSP 内容的显示,这是一种更灵活的方法。让我们回顾一下我们在第二章Spring Security 入门中学到的内容。我们可以通过更改我们的header.html文件,如下所示,将我的事件链接隐藏在任何未认证的用户中:

    //src/main/resources/templates/fragments/header.html

    <li sec:authorize="isAuthenticated()"> 
    <a id="navMyEventsLink" th:href="@{/events/my}">My Events</a></li>

SpEL 评估是由与antMatchers()方法访问声明规则中使用的表达式相同的代码在后台执行的(假设已经配置了表达式)。因此,从使用<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/WelcomeController.java

    @ModelAttribute ("showCreateLink")
    public boolean showCreateLink(Authentication authentication) {
      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对象,就像我们在第[3]章自定义认证中做的那样。同时请注意,如果我们不是使用 Spring MVC,我们完全可以直接设置HttpServletRequest属性,而不是在模型中填充它。我们在请求中填充的属性随后将可用于我们的 JSP,就像在使用带有 Spring MVC 的ModelAndView对象时一样。

接下来,我们需要在我们的index.html文件中使用HttpServletRequest属性来确定是否应显示创建活动的链接。更新index.html,如下所示:

    //src/main/resources/templates/header.html

    <li th:if="${showCreateLink}"><a id="navCreateEventLink"   
    th:href="@{events/form}">...</li>

现在,启动应用程序,使用admin1@example.com作为用户名,admin1作为密码登录,然后访问所有活动页面。你应该再也看不到主导航中的创建活动链接了(尽管它仍然在页面上)。

你的代码应该看起来像这样:chapter11.02-calendar

WebInvocationPrivilegeEvaluator

有时应用程序可能不会使用 JSP 编写,需要能够根据 URL 确定访问权限,就像我们用<... sec:authorize-url="/events/">做的那样。这可以通过使用o.s.s.web.access.WebInvocationPrivilegeEvaluator接口来实现,这个接口也是 JSP 标签库背后的同一个接口。在下面的代码片段中,我们通过用名为showAdminLink的属性填充我们的模型来演示它的使用。我们可以使用@Autowired注解来获取WebInvocationPrivilegeEvaluator

//src/main/java/com/packtpub/springsecurity/web/controllers/WelcomeController.java

    @ModelAttribute ("showAdminLink")
    public boolean showAdminLink(Authentication authentication) {
       return webInvocationPrivilegeEvaluator.
       isAllowed("/admin/", authentication);
    }

如果你正在使用的框架不是由 Spring 管理的,@Autowire将无法为你提供WebInvocationPrivilegeEvaluator。相反,你可以使用 Spring 的org.springframework.web.context.WebApplicationContextUtils接口来获取WebInvocationPrivilegeEvaluator的一个实例,如下所示:

    ApplicationContext context = WebApplicationContextUtils
     .getRequiredWebApplicationContext(servletContext);
    WebInvocationPrivilegeEvaluator privEvaluator =
    context.getBean(WebInvocationPrivilegeEvaluator.class)

为了尝试一下,更新index.html以使用showAdminLink请求属性,如下所示:

//src/main/resources/templates/header.html

    <li th:if="${showAdminLink}">
     <a id="h2Link" th:href="@{admin/h2/}" target="_blank">
     H2 Database Console</a>
    ...
    </li>

重新启动应用程序并在登录之前查看欢迎页面。H2 链接应该是不可见的。以admin1@example.com/admin1的身份登录,你应该就能看到它。

你的代码应该看起来像chapter11.03-calendar

在页面内进行授权配置的最佳方式是什么?

在 Spring Security 4 中,Thymeleaf Spring Security <sec>标签的重大进展消除了许多关于在库先前版本中使用此标签的担忧。在许多情况下,标签的authorize-url属性能适当地隔离代码,使其不受授权规则变化的影响。你应该在以下场景中使用标签的authorize-url属性:

  • 这个标签阻止了可以通过单个 URL 明确识别的显示功能

  • 标签的内容可以明确地隔离到一个单独的 URL

不幸的是,在典型的应用程序中,你能够频繁使用标签的authorize-url属性的可能性相当低。现实情况是,应用程序通常比这更复杂,在决定渲染页面的部分时需要更复杂的逻辑。

使用 Thymeleaf Spring Security 标签库来基于安全标准声明渲染页面的部分区域是很有诱惑力的。然而,(在许多情况下)这样做并不是一个好主意,原因如下:

  • 标签库不支持复杂条件超出角色成员。例如,如果我们的应用程序在UserDetails实现中包含了自定义属性、IP 过滤器、地理位置等,这些都不支持标准的<sec>标签。

  • 这些可能会通过自定义标签或使用 SpEL 表达式来支持。即使在這種情況下,页面也更可能直接与业务逻辑相关联,而不是通常所鼓励的。

  • <sec>标签必须在每页中引用它被使用的页面。这可能导致预期为公共规则集之间潜在的不一致,这些规则集可能分布在不同的物理页面之间。一个好的面向对象系统设计建议将条件规则评估放在一个地方,从应该应用的地方逻辑上引用。

  • 有可能(并且我们通过包含常用的页面头部的示例来说明)封装和复用页面的一部分以减少这类问题的发生,但在复杂的应用程序中几乎不可能完全消除。

  • 没有办法在编译时验证规定的规则的正确性。虽然在典型的基于 Java 的对象导向系统中可以使用编译时常量,但标签库在典型使用中要求硬编码角色名称,而简单的拼写错误可能会在一段时间内未被发现。

  • 公平地说,这样的错误可以通过对运行中的应用程序进行全面的函数测试轻松捕捉到,但使用标准的 Java 组件单元测试技术来测试它们要容易得多。

  • 我们可以看到,尽管基于模板的条件内容渲染方法很方便,但存在一些显著的缺点。

所有这些问题都可以通过在控制器中使用代码来解决,该代码可用于将数据推送到应用程序视图模型。此外,在代码中执行高级授权确定允许重用、编译时检查以及模型、视图和控制器之间的适当逻辑分离。

方法级安全

到目前为止,本书的主要关注点是保护 JBCP 日历应用程序的面向网络的部分;然而,在实际规划安全系统时,应同样关注保护允许用户访问系统最核心部分的服务方法——即数据。

为什么我们要分层保护?

让我们花一分钟来了解为什么即使我们已经保护了我们的 URL,确保方法的安全也是重要的。启动 JBCP 日历应用程序。使用 user1@example.com 作为用户名和 user1 作为密码登录,然后访问所有事件页面。你会看到自定义的访问被拒绝页面。现在,在浏览器的 URL 末尾添加 .json,使 URL 变为 https://localhost:8443/events/.json。你现在会看到一个带有与 HTML 所有事件页面相同数据的 JSON 响应。这部分数据只应由管理员可见,但我们通过找到一个配置不正确的 URL 来绕过它。

我们还可以查看我们不拥有且未受邀的活动的详细信息。将 .json 替换为 102,使 URL 变为 https://localhost:8443/events/102。你现在会看到一个在“我的活动”页面中未列出的午餐事件。这不应该对我们可见,因为我们不是管理员,而且这不是我们的活动。

如您所见,我们的 URL 规则不足以完全保护我们的应用程序。这些攻击甚至不需要利用更复杂的问题,例如容器处理 URL 标准化方式的差异。简而言之,通常存在绕过基于 URL 的安全性的方法。让我们看看向我们的业务层添加安全层如何帮助我们解决新的安全漏洞。

保护业务层

Spring Security 具备为应用程序中任何由 Spring 管理的 bean 调用添加一层授权(或基于授权的数据修剪)的能力。虽然许多开发者关注的是网络层的安全性,但业务层的安全性同样重要,因为恶意用户可能能够穿透网络层的安全或通过非 UI 前端(如 Web 服务)访问服务。

让我们查看以下逻辑图,了解为什么我们感兴趣于应用第二层安全:

Spring Security 用于保护方法的主要技术有以下两种:

  • 预授权:这种技术确保在允许执行的方法执行之前满足某些约束条件,例如,如果用户具有特定的 GrantedAuthority,如 ROLE_ADMIN。未能满足声明的约束条件意味着方法调用将失败。

  • 后授权:这种技术确保在方法返回后,调用者仍然满足声明的约束条件。这种方法很少使用,但可以为一些复杂、相互连接的业务层方法提供额外的安全层。

预授权和后授权技术为在经典、面向对象设计中通常称为前提条件和后置条件提供了正式化的支持。前提条件和后置条件允许开发者在运行时检查,声明方法执行过程中某些约束必须始终成立。在安全预授权和后授权的情况下,业务层开发者通过将期望的运行时条件作为接口或类 API 声明的一部分来编码,对特定方法的安全配置有一个明确的决策。正如你所想象的,这需要大量的深思熟虑,以避免意想不到的后果!

添加@PreAuthorize 方法注解

我们的第一个设计决策将是通过确保用户必须以ADMIN用户身份登录后才能访问getEvents()方法,在业务层增强方法安全性。这是通过在服务接口定义中的方法上添加一个简单的注解来完成的,如下所示:

    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/SecurityConfig.java

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

验证方法安全性

你相信就这样容易吗?用用户名user1@example.com和密码user1登录,尝试访问https://localhost:8443/events/.json。现在你应该看到访问被拒绝的页面。

你的代码应该看起来像chapter11.04-calendar

如果你查看 Tomcat 控制台,你会看到一个非常长的堆栈跟踪,以以下输出开始:

    DEBUG ExceptionTranslationFilter - Access is denied 
    (user is not anonymous); delegating to AccessDeniedHandler
    org.s.s.access.AccessDeniedException: Access is denied
    at org.s.s.access.vote.AffirmativeBased.decide
    at org.s.s.access.intercept.AbstractSecurityInterceptor.
    beforeInvocation
    at org.s.s.access.intercept.aopalliance.
    MethodSecurityInterceptor.invoke
    ...
    at $Proxy16.getEvents
    at com.packtpub.springsecurity.web.controllers.EventsController.events

基于访问被拒绝页面,以及堆栈跟踪明确指向getEvents方法的调用,我们可以看出用户因为缺少ROLE_ADMINGrantedAuthority而被适当地拒绝了访问业务方法的权限。如果你用用户名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 的信息,请参阅static.springsource.org/spring/docs/current/spring-framework-reference/html/aop.html#aop-introduction-proxies的 Spring 参考文档。

除了@PreAuthorize注解之外,还有几种其他方法可以在方法上声明安全预授权要求。我们可以研究这些不同的方法来保护方法,然后评估在不同情况下它们的优缺点。

JSR-250 兼容的标准化规则

JSR-250 通用注解为 Java 平台定义了一系列注解,其中一些与安全相关,旨在在 JSR-250 兼容的运行时环境中可移植。Spring Framework 作为 Spring 2.x 版本的的一部分,包括 Spring Security 框架,变得与 JSR-250 兼容。

尽管 JSR-250 注解不如 Spring 原生注解表达能力强,但它们的优点是它们提供的声明在实现 Java EE 应用服务器的不同环境中是兼容的,例如 Glassfish 或服务导向的运行时框架,如Apache Tuscany。根据您的应用程序的需求和可移植性要求,您可能决定减少特异性与代码的可移植性之间的权衡是值得的。

为了实现我们第一个示例中指定的规则,我们通过执行以下步骤进行一些更改:

  1. 首先,我们需要更新我们的SecurityConfig文件以使用 JSR-250 注解:
        //src/main/java/com/packtpub/springsecurity/configuration/
        SecurityConfig.java

        @Configuration
        @EnableWebSecurity
 @EnableGlobalMethodSecurity(jsr250Enabled = true)        public class SecurityConfig extends WebSecurityConfigurerAdapter {
  1. 最后,需要将@PreAuthorize注解更改为@RolesAllowed注解。正如我们所预期的,@RolesAllowed注解不支持 SpEL 表达式,因此我们按照如下方式编辑CalendarService
        @RolesAllowed("ROLE_ADMIN")
        List<Event> getEvents();
  1. 重新启动应用程序,以user1@example.com/user1的身份登录,尝试访问http://localhost:8080/events/.json。您应该再次看到访问被拒绝的页面。

您的代码应如下所示:chapter11.05-calendar

请注意,还可以使用 Java 5 标准字符串数组注解语法提供允许的GrantedAuthority名称列表:

    @RolesAllowed({"ROLE_USER","ROLE_ADMIN"})
    List<Event> getEvents();

还有 JSR-250 指定的两个额外的注解,分别是@PermitAll@DenyAll,它们的功能如你所料,允许或拒绝所有对所述方法的请求。

类级别的注解

请注意,方法级别的安全注解也可以应用于类级别!如果提供,方法级别的注解将始终覆盖类级别指定的注解。这在你需要为整个类指定安全策略时很有帮助。在使用此功能时,请确保与良好的注释和编码标准相结合,以便开发人员非常清楚类及其方法的安全特性。

使用 Spring 的@Secured 注解的方法安全

Spring 本身提供了一种更简单的注解风格,与 JSR-250 的@RolesAllowed注解类似。@Secured注解在功能和语法上与@RolesAllowed相同。唯一的显著差异是它不需要外部依赖,不能被其他框架处理,并且这些注解的处理必须通过@EnableGlobalMethodSecurity注解的另一个属性明确启用:

    //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java

    @EnableWebSecurity(debug = true)
    @EnableGlobalMethodSecurity(securedEnabled=true)
    public class SecurityConfig extends WebSecurityConfigurerAdapter {

由于@Secured函数与 JSR 标准@RolesAllowed注解相同,所以在新的代码中没有真正的强制性理由使用它,但在较老的 Spring 代码中可能会遇到它。

包含方法参数的方法安全规则

从逻辑上讲,在约束中引用方法参数的编写规则对于某些类型的操作来说似乎是合理的。例如,对我们来说限制findForUser(int userId)方法以满足以下约束可能是合理的:

  • userId参数必须等于当前用户的 ID

  • 用户必须是管理员(在这种情况下,用户可以看到任何事件)

虽然我们可以很容易地看到如何修改规则,以限制方法调用仅限于管理员,但不清如何确定用户是否正在尝试更改自己的密码。

幸运的是,Spring Security 方法注解使用的 SpEL 绑定支持更复杂的表达式,包括包含方法参数的表达式。你还需要确保已经在SecurityConfig文件中启用了前缀和后缀注解,如下所示:

    //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java

    @Configuration
    @EnableWebSecurity
    @EnableGlobalMethodSecurity(prePostEnabled = true)
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
    Lastly, we can update our CalendarService interface as follows:
    @PreAuthorize("hasRole('ADMIN') or principal.id == #userId")  
    List<Event> findForUser(int userId);

在这里,我们可以看到我们已经用我们在第一个练习中使用的 SpEL 指令增强了对主体的 ID 和userId方法参数的检查(#userId,方法参数名称前缀有一个#符号)。这个强大的方法参数绑定的特性应该能激发你的创造力,并允许你用非常精确的逻辑规则来保护方法调用。

由于自第三章以来的自定义身份验证设置,我们的主体目前是一个CalendarUser实例 自定义身份验证。这意味着主体具有我们CalendarUser应用程序上的所有属性。如果我们没有进行这种自定义,只有UserDetails对象的属性才可用。

SpEL 变量使用哈希(#)前缀引用。一个重要的注意事项是,为了在运行时可用,必须在编译后保留调试符号表信息。保留调试符号表信息的常用方法如下所示:

  • 如果您正在使用javac编译器,您在构建类时需要包含-g标志。

  • 在使用 Ant 中的<javac>任务时,添加属性debug="true"

  • 在 Gradle 中,确保在运行主方法或bootRun任务时添加--debug

  • 在 Maven 中,确保maven.compiler.debug=true属性(默认值为true)。

查阅您的编译器、构建工具或 IDE 文档,以获取在您的环境中配置相同设置的帮助。

启动你的应用程序,并尝试使用user1@example.com作为用户名和user1作为密码登录。在欢迎页面,请求My Eventsemail=admin1@example.com)链接以看到“访问被拒绝”的页面。再次尝试My Eventsemail=user1@example.com)以使其工作。请注意,在“我的事件”页面上显示的用户与当前登录的用户匹配。现在,尝试相同的步骤并以admin1@example.com/admin1登录。你将能够看到两个页面,因为你是以具有ROLE_ADMIN权限的用户登录。

你的代码应该看起来像chapter11.06-calendar

包含返回值的方法安全规则。

正如我们能够利用方法的参数一样,我们也可以利用方法调用的返回值。让我们更新getEvent方法,以满足返回值的以下约束:

  • 参与者的 ID 必须是当前用户的 ID。

  • 所有者的 ID 必须是当前用户的 ID。

  • 用户必须是管理员(在这种情况下,用户可以看到任何事件是有效的)。

将以下代码添加到CalendarService接口中:

    @PostAuthorize("hasRole('ROLE_ADMIN') or " + "principal.username ==   
    returnObject.owner.email or " +
    "principal.username == returnObject.attendee.email")
    Event getEvent(int eventId);

现在,尝试使用用户名user1@example.com和密码user1登录。接下来,尝试通过欢迎页面的链接访问午餐事件。你现在应该会看到“访问被拒绝”的页面。如果你使用用户名user2@example.com和密码user2登录,由于user2@example.com是午餐事件的参与者,事件将如预期显示。

你的代码应该看起来像chapter11.07-calendar

使用基于角色的过滤来保护方法数据。

最后两个依赖于 Spring Security 的注解是@PreFilter@PostFilter,它们用于将安全过滤规则应用于集合或数组(仅限@PostFilter)。这种功能被称为安全修剪或安全修剪,涉及在运行时使用principal的安全凭证来有选择地从一组对象中移除成员。正如你所期望的,这种过滤是使用注解声明中的 SpEL 表达式完成的。

我们将通过一个 JBCP 日历的例子来工作,因为我们想要过滤getEvents方法,使其只返回这个用户被允许看到的活动。为了做到这一点,我们移除了任何现有的安全注解,并在我们的CalendarService接口中添加了@PostFilter注解,如下所示:

    @PostFilter("principal.id == filterObject.owner.id or " + 
    "principal.id == filterObject.attendee.id")
    List<Event> getEvents();

你的代码应该看起来像这样:chapter11.08-calendar

删除antMatchers()方法,限制对/events/URL的访问,以便我们可以测试我们的注解。启动应用程序,登录使用用户名user1@example.com和密码user1后查看所有活动页面,你会观察到只显示与我们用户相关联的活动。

filterObject作为循环变量,指的是当前活动,Spring Security 将遍历我们服务返回的List<Event>,并修改它,使其只包含匹配我们的 SpEL 表达式的Event对象。

通常,@PostFilter方法的行为如下。为了简洁起见,我们称集合为方法返回值,但请注意@PostFilter既适用于集合也适用于数组方法返回类型。

filterObject对象被重新绑定到 SpEL 上下文中,对于集合中的每个元素。这意味着如果你的方法返回一个包含 100 个元素的集合,SpEL 表达式将对每个元素进行评估。

SpEL 表达式必须返回一个布尔值。如果表达式计算结果为真,对象将保留在集合中,而如果表达式计算结果为假,对象将被移除。

在大多数情况下,你会发现集合后过滤可以节省你编写样板代码的复杂性,这些代码你本来可能就会写。注意你要理解@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

大多数使用 Spring Security 的 Java 5 消费者可能会选择使用 JSR-250 注解,以实现最大程度的兼容性,并在 IT 组织中重用他们的业务类(和相关约束)。在需要时,这些基本声明可以被与代码绑定到 Spring Security 实现的注解所替代。

如果您在支持注解的环境中使用 Spring Security(Java 1.4 或更早),不幸的是,您的选择相当有限,只能使用方法安全强制。即使在这种情况,AOP 的使用为我们提供了一个相当丰富的环境,我们可以开发基本的安全声明。

基于注解的安全性实际考虑

需要考虑的一件事是,当返回现实世界应用的集合时,很可能会有某种形式的分页。这意味着我们的@PreFilter@PostFilter注解不能作为选择返回哪些对象的唯手段。相反,我们需要确保我们的查询只选择用户允许访问的数据。这意味着安全注解变成了重复检查。然而,重要的是要记住我们在这章开头学到的教训;我们希望保护层,以防一个层能够被绕过。

摘要

在本章中,我们已经涵盖了标准 Spring Security 实现中处理授权的大部分剩余领域。我们已经学到了足够多的知识,可以彻底检查 JBCP 日历应用程序,并验证在应用程序的所有层中是否已经设置了适当的授权检查,以确保恶意用户无法操纵或访问他们无法访问的数据。

我们开发了两种微授权技术,分别是使用 Thymeleaf Spring Security 标签库和 Spring MVC 控制器数据绑定,基于授权或其他安全标准过滤页面内容。我们还探索了几种方法,在应用程序的业务层中保护业务功能和数据,并支持一个紧密集成到代码中的丰富声明式安全模型。我们还学习了如何保护我们的 Spring MVC 控制器以及接口和类代理对象之间的区别。

至此,我们已经涵盖了大多数在标准、安全的网络应用开发场景中可能遇到的 Spring Security 功能。

在下一章中,我们将讨论 Spring Security 的 ACL(域对象模型)模块。这将允许我们显式声明授权,而不是依赖现有数据。

访问控制列表

在本章中,我们将讨论复杂的话题访问控制列表ACL),它可以提供一个丰富的域对象实例级授权模型。Spring Security 附带了一个健壮但复杂的访问控制列表模块,可以满足小型到中型实现的合理需求。

在本章中,我们将介绍以下主题:

  • 理解 ACL 的概念模型

  • 回顾 Spring Security ACL 模块中 ACL 概念的术语和应用

  • 构建和支持 Spring ACL 所需的数据库架构

  • 通过注解和 Spring Bean 配置 JBCP 日历以使用 ACL 安全的企业方法

  • 执行高级配置,包括自定义 ACL 权限、ACL 启用的 JSP 标签检查和方法安全、可变 ACL 和智能缓存

  • 审查 ACL 部署的架构考虑和计划场景

ACL 的概念模块

非网络层安全拼图的最后一块是业务对象级别的安全,应用于或低于业务层。在这个层次上,使用一种称为 ACL 的技术实现安全。用一句话总结 ACL 的目标-ACL 允许基于组、业务对象和逻辑操作的独特组合指定一组权限。

例如,JBCP 日历的 ACL 声明可能声明给定用户对其自己的事件具有写入权限。这可以表示如下:

用户名 对象 权限
mick event_01 read, write
ROLE_USER event_123 read
ANONYMOUS 任何事件 none

您可以看到,这个 ACL 对人类来说是极易读的-mick有读取和写入自己事件(event_01)的权限;其他注册用户可以读取mick的事件,但匿名用户不能。这种规则矩阵,简而言之,就是 ACL 试图将安全系统及其业务数据合成代码、访问检查和元数据的组合。大多数真正的 ACL 支持系统具有极其复杂的 ACL 列表,在整个系统可能会有数百万条记录。尽管这听起来非常复杂,但使用有能力的安全库进行适当的预先推理和实施可以使 ACL 管理变得可行。

如果您使用的是 Microsoft Windows 或 Unix/Linux-based 计算机,您每天都会体验到 ACL 的魔力。大多数现代计算机操作系统在其文件存储系统中使用 ACL 指令,允许基于用户或组、文件或目录以及权限的组合来授予权限。在 Microsoft Windows 中,您可以通过右键单击文件并查看其安全属性(属性 | 安全)来查看文件的一些 ACL 功能,如下面的屏幕截图所示:

您将能够看到,ACL 的输入组合在您通过各种组或用户和权限导航时是可见且直观的。

Spring Security 中的访问控制列表(ACL)

Spring Security 支持针对 secured system 中个别用户的个别域对象的 ACL 驱动的授权检查。就像在 OS 文件系统示例中一样,可以使用 Spring Security ACL 组件构建逻辑树结构,包括业务对象和组或主体。请求者和请求对象上权限(继承或明确)的交集用于确定允许的访问。

用户在接触 Spring Security 的 ACL(访问控制列表)功能时,常常会因其复杂性而感到不知所措,加上相关文档和示例的相对匮乏,这种情况更是加剧。这还因为设置 ACL 基础架构可能相当复杂,有许多相互依赖性,并且依赖于基于 bean 的配置机制,这与 Spring Security 的其他部分大不相同(正如您在设置初始配置时所看到的)。

Spring Security ACL 模块是为了提供一个合理的基线而编写的,但是打算在功能上进行大量扩展的用户可能会遇到一系列令人沮丧的限制和设计选择,这些限制和设计选择在 Spring Security 的早期阶段首次引入后(在很大程度上)一直没有得到纠正。不要让这些限制让您气馁!ACL 模块是一种在您的应用程序中嵌入丰富访问控制的有效方式,并进一步审视和保护用户行为和数据。

在我们深入配置 Spring Security ACL 支持之前,我们需要回顾一些关键的术语和概念。

在 Spring ACL 系统中,安全身份的主要单位是安全身份SID)。SID 是一个逻辑构造,可以用来抽象单个主题或组(GrantedAuthority)的身份。您构建的 ACL 数据模型中定义的SIDs对象用作确定特定主体的允许访问级别的明确和派生访问控制规则的基础。

如果使用SIDs在 ACL 系统中定义参与者,那么安全方程的另一部分就是被保护对象本身的定义。单个受保护对象的识别称为对象身份(unsurprisingly)。默认的 Spring ACL 实现要求在单个对象实例级别定义 ACL 规则,这意味着,如果需要,系统中的每个对象都可以有各自的访问规则。

个别访问规则被称为访问控制条目ACEs)。一个 ACE 是以下因素的组合:

  • 适用于演员的 SID

  • 规则适用的对象身份

  • 应应用于给定SID和所述对象身份的权限

  • 是否应该允许或拒绝给定SID和对象身份的声明权限

Spring ACL 系统的整体目的是评估每个受保护的方法调用,并根据适用的 ACE 确定是否应该允许被方法操作的对象或对象。适用的 ACE 在运行时根据调用者和参与其中的对象进行评估。

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

让我们通过启用 Spring Security ACL 组件的过程,对 JBCP 日历应用程序进行简单的演示。

基本配置 Spring Security ACL 支持

虽然我们之前暗示过,在 Spring Security 中配置 ACL 支持需要基于 bean 的配置(确实如此),但如果你愿意,你可以保留更简单的安全 XML 命名空间配置同时使用 ACL 支持。在本章的剩余示例中,我们将关注基于 Java 的配置。

Gradle 依赖关系

与本书的大部分章节一样,我们需要添加一些依赖项才能使用本章的功能。可以查看以下内容,了解我们添加的依赖项及其需要的时机:

    build.gradle
    dependencies {
       // ACL
       compile('org.springframework.security:spring-security-acl')
      compile('net.sf.ehcache:ehcache')
       ...
    }

定义一个简单的目标场景

我们的简单目标场景是只授予user2@example.com对生日派对事件的阅读权限。其他所有用户将无法访问任何事件。你会注意到这与我们其他例子有所不同,因为user2@example.com与其他任何事件都没有关联。

虽然设置 ACL 检查有几种方法,但我们更喜欢遵循本章中方法级注解使用的基于注解的方法。这很好地将 ACL 的使用从实际的接口声明中抽象出来,并允许在稍后的日期(如果你愿意)用其他东西替换角色声明(如果你选择的话)。

我们将向CalendarService.getEvents方法添加一个注解,根据当前用户对事件的权限过滤每个事件:

    src/main/java/com/packtpub/springsecurity/service/CalendarService.java
    @PostFilter("hasPermission(filterObject, 'read')")
    List<Event> getEvents();

你应该从chapter12.00-calendar开始。

向 H2 数据库添加 ACL 表

我们首先需要做的是向我们的内存中添加所需的支持持久 ACL 条目的表和数据。为此,我们将添加一个新的 SQL DDL 文件及其对应的数据到schema.sql中的嵌入式数据库声明。我们将在本章后面分解这些文件。

我们在此章节的源代码中包括了以下schema.sql文件,该文件基于 Spring Security 参考附录中的架构文件,即附加参考材料

src/main/resources/schema.sql
-- ACL Schema --
create table acl_sid (
id bigint generated by default as identity(start with 100) 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 100) 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) );

前面的代码将导致以下数据库架构:

你可以看到SIDsOBJECT_IDENTITY和 ACEs 的概念是如何直接映射到数据库架构的。从概念上讲,这是方便的,因为我们可以将我们对 ACL 系统的心理模型以及它是如何执行的直接映射到数据库。

如果你将此与随 Spring Security 文档提供的 H2 数据库架构进行了交叉引用,你会注意到我们做了一些常见的用户陷阱的调整。这些如下:

  • ACL_CLASS.CLASS列更改为500个字符,默认值为100。一些长完全限定类名不适合100个字符。

  • 给外键命名一些有意义的名称,以便故障诊断更加容易。

如果您使用其他数据库,如 Oracle,您将不得不将 DDL 翻译成特定于您数据库的 DDL 和数据类型。

一旦我们配置了 ACL 系统的其余部分,我们将回到数据库中设置一些基本的 ACE,以最原始的形式证明 ACL 功能。

配置 SecurityExpressionHandler

我们需要配置<global-method-security>以启用注解(我们将基于预期的 ACL 权限进行注解),并引用一个自定义的访问决策管理器。

我们还需要提供一个o.s.s.access.expression.SecurityExpressionHandler实现,使其知道如何评估权限。更新您的SecurityConfig.java配置,如下所示:

    src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java 
 @EnableGlobalMethodSecurity(prePostEnabled = true)    @Import(AclConfig.class)
    public class SecurityConfig extends WebSecurityConfigurerAdapter {

这是对我们在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; 
    }

即使在我们 scenario 中有一个相对简单的 ACL 配置,也有许多必须设置的依赖项。如我们之前提到的,Spring Security ACL 模块默认包含一组组件,您可以组装这些组件以提供一套不错的 ACL 功能。请注意,以下图表中引用的所有组件都是框架的一部分:

AclPermissionCacheOptimizer 对象

DefaultMethodSecurityExpressionHandler 对象有两个依赖。AclPermissionCacheOptimizer 对象用于用单个 JDBC 选择语句为对象集合的所有 ACL 填充缓存。本章包含的相对简单的配置可以按照如下方式进行检查:

     src/main/java/com/packtpub/springsecurity/configuration/AclConfig.java
     @Bean
    public AclPermissionCacheOptimizer permissionCacheOptimizer(){
       return new AclPermissionCacheOptimizer(aclService());
    }

优化 AclPermission 缓存

然后 DefaultMethodSecurityExpressionHandler 对象委派给一个 PermissionEvalulator 实例。在本章中,我们使用 ACL 以便我们使用的 bean AclPermissionEvaluator,它将读取我们在数据库中定义的 ACL。您可以查看提供的 permissionEvaluator 配置,如下所示:

src/main/java/com/packtpub/springsecurity/configuration/AclConfig.java
@Bean
public AclPermissionEvaluator permissionEvaluator(){
   return new AclPermissionEvaluator(aclService());
}

JdbcMutableAclService 对象

在此点,我们看到了两次带有 aclService ID 的 th 引用。aclService ID 解析为一个负责将有关通过 ACL 受保护的对象的信息翻译成预期 ACE 的 o.s.s.acls.model.AclService 实现:

src/main/java/com/packtpub/springsecurity/configuration/AclConfig.java
@Autowired 
private DataSource dataSource;
@Bean
public JdbcMutableAclService aclService(){
   return new JdbcMutableAclService(dataSource,
                                     lookupStrategy(),
                                     aclCache());
}

我们将使用 o.s.s.acls.jdbc.JdbcMutableAclService,这是 o.s.s.acls.model.AclService 的默认实现。这个实现开箱即用,准备好使用我们在本练习的最后一步定义的架构。JdbcMutableAclService 对象还将使用递归 SQL 和后处理来理解对象和 SID 层次结构,并确保这些层次结构的表示被传递回 AclPermissionEvaluator

基本查找策略类

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.java
@Bean
public LookupStrategy lookupStrategy(){
   return new BasicLookupStrategy(
           dataSource,
           aclCache(),
           aclAuthorizationStrategy(),
           consoleAuditLogger());
}

现在,BasicLookupStrategy 是一个相当复杂的生物。记住它的目的是将需要保护的 ObjectIdentity 声明列表翻译成实际适用的数据库中的 ACE 列表。由于 ObjectIdentity 声明可以是递归的,这证明是一个非常具有挑战性的问题,并且一个可能会经历大量使用的系统应考虑生成的 SQL 对数据库性能的影响。

使用最低公倍数查询

请注意,BasicLookupStrategy 旨在通过严格遵循标准 ANSI SQL 语法与所有数据库兼容,特别是 left [outer] joins。一些较老的数据库(特别是 Oracle8i)不支持这种连接语法,所以请确保您验证 SQL 的语法和结构与您特定的数据库兼容!

肯定还有更多高效的数据库依赖方法执行层次查询,使用非标准 SQL,例如,Oracle 的CONNECT BY语句和其他许多数据库(包括 PostgreSQL 和 Microsoft SQL Server)的公共表表达式CTE)功能。

正如你在第四章的例子中学习到的,基于 JDBC 的认证,使用自定义架构为JdbcDaoImpl实现的UserDetailsService属性暴露出来,允许配置BasicLookupStrategy使用的 SQL。查阅 Javadoc 和源代码本身,看看它们是如何使用的,这样它们就可以正确地应用到你的自定义架构上。

我们可以看到LookupStrategy需要引用与 AclService 使用的相同的 JDBCdataSource实例。其他三个引用让我们几乎到达依赖链的末端。

EhCacheBasedAclCache

o.s.s.acls.model.AclCache接口声明了一个缓存ObjectIdentity到 ACL 映射的接口,以防止重复(且昂贵)的数据库查询。Spring Security 只包含一个AclCache的实现,使用了第三方库Ehcache

Ehcache是一个开源的基于内存和磁盘的缓存库,在许多开源和商业 Java 产品中被广泛使用。正如本章前面提到的,Spring Security 包含一个 ACL 缓存的默认实现,它依赖于一个配置好的Ehcache实例,它使用这个实例来存储 ACL 信息,而不是从数据库中读取 ACL。

虽然深入配置Ehcache不是我们本节想要覆盖的内容,但我们会介绍 Spring ACL 如何使用缓存,并带你走过一个基本的默认配置。

设置Ehcache很简单——我们只需声明o.s.s.acls.domain.EhCacheBasedAclCache以及从 Spring Core 中它的两个依赖 bean,这些 bean 管理Ehcache的实例化和暴露几个有用的配置属性。像我们的其他 bean 一样,我们在AclConfig.java中已经提供了以下的配置:

src/main/java/com/packtpub/springsecurity/configuration/AclConfig.java
@Bean
public EhCacheBasedAclCache aclCache(){
   return new EhCacheBasedAclCache(ehcache(),
           permissionGrantingStrategy(),
           aclAuthorizationStrategy()
           );
}

@Bean
public PermissionGrantingStrategy permissionGrantingStrategy(){
   return new DefaultPermissionGrantingStrategy(consoleAuditLogger());
}

@Bean
public Ehcache ehcache(){
   EhCacheFactoryBean cacheFactoryBean = new EhCacheFactoryBean();
   cacheFactoryBean.setCacheManager(cacheManager());
   cacheFactoryBean.setCacheName("aclCache");
   cacheFactoryBean.setMaxBytesLocalHeap("1M");
   cacheFactoryBean.setMaxEntriesLocalHeap(0L);
   cacheFactoryBean.afterPropertiesSet();
   return cacheFactoryBean.getObject();
}

@Bean
public CacheManager cacheManager(){
   EhCacheManagerFactoryBean cacheManager = new EhCacheManagerFactoryBean();
   cacheManager.setAcceptExisting(true);   cacheManager.setCacheManagerName(CacheManager.getInstance().getName());
   cacheManager.afterPropertiesSet();
return cacheManager.getObject();
}

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.java
@Bean
public ConsoleAuditLogger consoleAuditLogger(){
   return new ConsoleAuditLogger();
}

AclAuthorizationStrategyImpl接口

需要解决的最后依赖关系是对o.s.s.acls.domain.AclAuthorizationStrategy接口的实现,该接口在从数据库加载 ACL 时实际上没有任何直接的职责。相反,实现此接口负责确定是否允许对 ACL 或 ACE 进行运行时更改,具体取决于更改的类型。我们稍后会在讲解可变 ACL 时解释更多,因为逻辑流程既有点复杂,又与完成初始配置无关。最终的配置要求如下:

src/main/java/com/packtpub/springsecurity/configuration/AclConfig.java
@Bean
public AclAuthorizationStrategy aclAuthorizationStrategy() {
   return new AclAuthorizationStrategyImpl(
           new SimpleGrantedAuthority("ROLE_ADMINISTRATOR")
   );
}

您可能想知道 ID 为adminAuthority的 bean 的引用是做什么的-AclAuthorizationStrategyImpl提供了指定在可变 ACL 上允许特定操作的GrantedAuthority的能力。我们将在本章后面覆盖这些内容。

最后,我们需要更新我们的SecurityConfig.java文件,以加载我们的AclConfig.java文件,如下所示:

src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
@Import(AclConfig.class) public class SecurityConfig extends WebSecurityConfigurerAdapter {

我们终于完成了 Spring Security ACL 实现的初始配置。下一步也是最后一步,要求我们将一个简单的 ACL 和 ACE 插入到 H2 数据库中并测试它!

创建简单 ACL 条目

回想一下,我们非常简单的场景是只允许user2@example.com访问生日派对事件,并确保其他事件无法访问。您可能发现回顾几页到数据库架构图有助于了解我们要插入的数据以及原因。

我们已经在示例应用程序中包含了一个名为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 关联的SID进行初始化。请记住SID可以是角色或用户-我们在这里填充角色和user2@example.com

  2. 虽然角色的SID对象很简单,但用户的SID对象并不是那么清晰。对我们来说,用户名用于SID。要了解更多关于如何为角色和用户解析SID,请参阅o.s.s.acls.domain.SidRetrievalStrategyImpl。如果默认值不符合您的需求,可以将自定义的o.s.s.acls.model.SidRetrievalStrategy默认值注入到AclPermissionCacheOptimizerAclPermissionEvaluator中。在我们的示例中,我们不需要这种自定义,但是如果需要,知道它是可用的:

        src/main/resources/data.sql
        insert into acl_sid (id, principal, sid) values (20, true,  
        'user2@example.com');
        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对象的外键(尽管不是通过数据库约束强制执行)。

  • 拥有者SIDuser2@example.com,这是一个外键,20,通过OWNER_SID列连接到ACL_SID

表示具有100(生日事件)、101102 ID 的事件的 SQL 如下:

    src/main/resources/data.sql
    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);
    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);
    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
    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进行以下更新来保护直接访问事件:

    src/main/java/com/packtpub/springsecurity/service/CalendarService.java
    @PostAuthorize("hasPermission(filterObject, 'read') " +
    "or hasPermission(filterObject, 'admin_read')")
    Event getEvent(int eventId);

现在,我们已经有了基于 ACL 的安全性的基本工作设置(尽管是一个非常简单的场景)。让我们继续解释一下我们在这次演练中看到的概念,然后回顾一下在典型 Spring ACL 实现中你应该考虑的几个问题。

您的代码应如下所示chapter12.01-calendar

值得注意的是,我们在创建事件时并没有创建新的 ACL 条目。因此,在当前状态下,如果您创建一个事件,您将收到一个类似于以下的错误:

在 Spring Security 应用程序执行期间发生异常!无法为对象身份 org.springframework.security.acls.domain.ObjectIdentityImpl[Type: com.packtpub.springsecurity.domain.Event; Identifier: 103]找到 ACL 信息

高级 ACL 主题

我们在配置 ACL 环境时简要介绍的一些高级主题与 ACE 权限及使用GrantedAuthority指示器来帮助 ACL 环境确定是否允许对 ACL 进行某些类型的运行时更改有关。现在我们已经有了一个工作环境,我们将回顾这些更高级的主题。

权限是如何工作的

权限不过是表示为整数中的位的单个逻辑标识符。访问控制条目根据位掩码向SIDs授予权限,该位掩码是适用于该访问控制条目的所有权限的逻辑与。

默认的权限实现,o.s.s.acls.domain.BasePermission,定义了一系列代表常见 ACL 授权动词的整数值。这些整数值对应于整数中的单个位设置,所以一个值为BasePermissionWRITE,整数值为1的位掩码值为212

以下图表进行了说明:

我们可以看到,示例权限位掩码的整数值为3,这是由于将读取写入权限应用到权限值上。前面图表中显示的所有标准整数单个权限值都是在BasePermission对象中作为静态常量定义的。

BasePermission中包含的逻辑常量只是 ACE 中常用权限的一个合理基线,并且在 Spring Security 框架内没有语义意义。在非常复杂的 ACL 实现中,通常会发明自己的自定义权限,用领域或业务依赖的权限来补充最佳实践示例。

经常让用户感到困惑的一个问题是,在实际应用中位掩码是如何使用的,因为许多数据库要么不支持位逻辑,要么不支持以可扩展的方式实现位逻辑。Spring ACL 旨在通过将计算与位掩码相关的适当权限的更多负载放在应用程序上,而不是放在数据库上,来解决这一问题。

重要的是要回顾解析过程,在这个过程中我们看到AclPermissionEvaluator是如何解析声明在方法本身上的权限(在我们的例子中,使用@PostFilter注解)以得到真实的 ACL 权限的。

以下图表说明了 Spring ACL 执行的过程,以将声明的权限与请求主体的相关 ACEs 进行评估:

我们发现AclPermissionEvaluator依赖于实现两个接口的类,o.s.s.acls.model.ObjectIdentityRetrievalStrategyo.s.s.acls.model.SidRetrievalStrategy,以检索适合授权检查的ObjectIdentitySIDs。关于这些策略的重要一点是,默认实现类实际上是如何根据授权检查的上下文确定要返回的ObjectIdentitySIDs对象的。

ObjectIdentity对象有两个属性,typeidentifier,它们是从运行时检查的对象派生的,并用于声明 ACE 条目。默认的ObjectIdentityRetrievalStrategy接口使用完全限定类名来填充type属性。identifier属性用实际对象实例上调用具有Serializable getId()签名的方法的返回值填充。

由于你的对象不需要实现接口以与 ACL 检查兼容,开发人员实现 Spring Security ACL 时对需要实现具有特定签名的方法的惊讶是可以理解的。提前规划并确保你的领域对象包含这个方法!你也可以实现自己的ObjectIdentityRetrievalStrategy类(或继承内置实现)以调用你选择的方法。不幸的是,方法的名字和类型签名是不可配置的。

不幸的是,AclImpl的实际实现直接比较了我们@PostFilter注解中指定的 SpEL 表达式中的权限和我们数据库中存储的 ACE 上的权限,而没有使用位逻辑。Spring Security 社区正在争论这是否是无意中发生的还是按预期工作的,但无论如何,当你声明具有多种权限组合的用户时,你必须小心,因为要么必须为AclEntryVoter配置所有权限的组合,要么 ACE 需要忽略权限字段是用来存储多个值的意图,而每个 ACE 存储一个权限。

如果你想用我们简单的场景来验证这一点,将我们授予user2@example.com SID 的READ权限更改为位掩码组合ReadWrite,这翻译为一个值为3。这将在data.sql文件中更新,如下所示:

    src/main/resources/data.sql
    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类并声明您自己的权限。在这里,我们将讨论一个非常简单的场景,即创建一个名为ADMIN_READ的新 ACL 权限。这是一个仅授予管理员的权限,用于保护只有管理员才能读取的资源。虽然这个例子对于 JBCP 日历应用程序来说有些牵强,但在处理个人可识别信息(例如,社会保障号码等——回想我们在第一章,不安全应用程序的解剖学中讨论的 PII)的情况下,这种自定义权限的使用是非常常见的。

让我们开始进行支持此更改所需的更改,执行以下步骤:

  1. 第一步是扩展BasePermission类,用我们自己的com.packtpub.springsecurity.acls.domain.CustomPermission类,如下所示:
        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);
           }
        }
  1. 接下来,我们需要配置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);
        }
  1. 接下来,我们需要覆盖我们BasicLookupStrategyAclPermissionEvaluator接口的默认PermissionFactory实例,使用自定义的DefaultPermissionFactory接口。请按照以下步骤更新您的security-acl.xml文件:
src/main/java/com/packtpub/springsecurity/configuration/AclConfig.java
@Bean
public AclPermissionEvaluator permissionEvaluator(){
   AclPermissionEvaluator pe = new
                               AclPermissionEvaluator(aclService());
 pe.setPermissionFactory(permissionFactory());   return pe;
}
@Bean
public LookupStrategy lookupStrategy(){
   BasicLookupStrategy ls = new BasicLookupStrategy(
                                       dataSource,
                                       aclCache(),
                                      aclAuthorizationStrategy(),
                                      consoleAuditLogger());
 ls.setPermissionFactory(permissionFactory());   return ls;
}
  1. 我们还需要添加 SQL 查询,以利用新权限向admin1@example.com授予对会议电话(acl_object_identity ID 为 31)事件的访问权限。请对data.sql进行以下更新:
        src/main/resources/data.sql
        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来引用。

  1. 最后一步是更新我们的CalendarService 的 getEvents()方法,以利用我们的新权限,如下所示:
        @PostFilter("hasPermission(filterObject, 'read') " + "or    
        hasPermission(filterObject, 'admin_read')")
        List<Event> getEvents();

在所有这些配置就绪之后,我们可以重新启动网站并测试自定义 ACL 权限。根据我们配置的示例数据,当各种可用用户点击类别时,会发生以下情况:

用户名/密码 生日派对事件 电话会议事件 其他事件
```
```
```

我们可以看到,即使在我们使用简单示例的情况下,我们现在也能够在非常有限的方式上扩展 Spring ACL 功能,以说明这个细粒度访问控制系统的力量。

你的代码应该看起来像chapter12.03-calendar

启用 ACL 权限评估

我们在第二章中看到了 Spring Security JSP 标签库提供了将认证相关数据暴露给用户的功能,以及基于多种规则限制用户能看到的内容。在这本书中,我们一直使用的是建立在 Spring Security 之上的 Thymeleaf 安全标签库。

这个相同的标签库也可以与 ACL 启用的系统无缝交互!从我们的简单实验中,我们已经围绕主页上的前两个类别配置了一个简单的 ACL 授权场景。让我们来看看以下步骤,学习如何在 Thymeleaf 页面中启用 ACL 权限评估:

  1. 首先,我们需要从我们的CalendarService接口中的getEvents()方法移除@PostFilter注解,以便给我们的 JSP 标签库一个过滤掉不允许显示的事件的机会。现在就移除@PostFilter,如下所示:
| ```

1.  现在我们已经移除了`@PostFilter`,我们可以使用`<sec:authorize-acl>`标签来隐藏用户实际上没有访问权限的事件。回顾一下前一部分的表格,作为对我们迄今为止配置的访问规则的刷新!

1.  我们将用**`<sec:authorize-acl>`**标签包裹每个事件的显示,声明要检查的对象的权限列表:

``` |

我们可以看到,即使在我们使用简单示例的情况下,我们现在也能够在非常有限的方式上扩展 Spring ACL 功能,以说明这个细粒度访问控制系统的力量。

你的代码应该看起来像`chapter12.03-calendar`。

# 启用 ACL 权限评估

我们在第二章中看到了 Spring Security JSP 标签库提供了将认证相关数据暴露给用户的功能,以及基于多种规则限制用户能看到的内容。在这本书中,我们一直使用的是建立在 Spring Security 之上的 Thymeleaf 安全标签库。

这个相同的标签库也可以与 ACL 启用的系统无缝交互!从我们的简单实验中,我们已经围绕主页上的前两个类别配置了一个简单的 ACL 授权场景。让我们来看看以下步骤,学习如何在 Thymeleaf 页面中启用 ACL 权限评估:

1.  首先,我们需要从我们的`CalendarService`接口中的`getEvents()`方法移除`@PostFilter`注解,以便给我们的 JSP 标签库一个过滤掉不允许显示的事件的机会。现在就移除`@PostFilter`,如下所示:

  1. 思考一下这里想要发生的事情-我们想要用户只看到他们实际具有READADMIN_READ(我们的自定义权限)访问的项。然而,为了使用标签库,我们需要使用权限掩码,该掩码可以从以下表格中引用:
```
```
```
```

在幕后,标签实现利用了本章早些时候讨论过的相同的SidRetrievalStrategyObjectIdentityRetrievalStrategy接口。因此,访问检查的计算遵循与 ACL 启用的方法安全投票相同的 workflow。正如我们即将看到的,标签实现还将使用相同的PermissionEvaluator

我们已经用一个引用DefaultMethodSecurityExpressionHandlerexpressionHandler元素配置了我们的GlobalMethodSecurityConfigurationDefaultMethodSecurityExpressionHandler实现认识我们的AclPermissionEvaluator接口,但我们还必须让 Spring Security 的 web 层认识AclPermissionEvaluator。如果你仔细思考,这种对称性是合理的,因为保护和 HTTP 请求是保护两种非常不同的资源。幸运的是,Spring Security 的抽象使这一切变得相当简单。

  1. 添加一个引用我们已经定义的 ID 为permissionEvaluator的 bean 的DefaultWebSecurityExpressionHandler处理器:

1.  现在我们已经移除了`@PostFilter`,我们可以使用`<sec:authorize-acl>`标签来隐藏用户实际上没有访问权限的事件。回顾一下前一部分的表格,作为对我们迄今为止配置的访问规则的刷新!

1.  我们将用**`<sec:authorize-acl>`**标签包裹每个事件的显示,声明要检查的对象的权限列表:

  1. 现在,更新SecurityConfig.java以引用我们的webExpressionHandler实现,如下所示:

1.  思考一下这里想要发生的事情-我们想要用户只看到他们实际具有`READ`或`ADMIN_READ`(我们的自定义权限)访问的项。然而,为了使用标签库,我们需要使用权限掩码,该掩码可以从以下表格中引用:

| ```

你可以看到这些步骤与我们向方法安全添加权限处理的方式非常相似。这次简单一些,因为我们能够重用已经配置的具有`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 的其他支持表的一般维护(处理`SIDs`、`ObjectIdentity`和域对象类名)。

在本书的早期部分提到,`AclAuthorizationStrategyImpl`类允许我们为可变 ACL 上的操作指定管理角色。这些是在 bean 配置中作为构造函数的一部分提供的。构造函数参数及其含义如下:

| **参数编号** | **它做什么?** |
| --- | --- |
| 1 | 表示主体需要具有的权限,以在运行时获取 ACL 保护对象的所有权 |
| 2 | 表示主体需要具有的权限,以在运行时更改 ACL 保护对象的审计 |
| 3 | 表示主体需要具有的权限,以在运行时对 ACL 保护的对象进行任何其他类型的更改(创建、更新和删除) |

可能会有所困惑,我们只指定了一个构造函数参数,尽管列出了三个参数。`AclAuthorizationStrategyImpl`类还可以接受一个`GrantedAuthority`,然后将其用于所有三个参数。如果我们要对所有操作使用相同的`GrantedAuthority`,这非常方便。

`JdbcMutableAclService`接口包含用于在运行时操作 ACL 和 ACE 数据的一系列方法。尽管这些方法本身相对容易理解(`createAcl`、`updateAcl`和`deleteAcl`),但即使是高级 Spring Security 用户,配置和使用`JdbcMutableAclService`的正确方式也往往很难掌握。

让我们修改`CalendarService`以创建新事件的新的 ACL。

# 向新创建的事件添加 ACL

目前,如果用户创建了一个新事件,它将不会在所有事件视图中显示给用户,因为我们使用`<sec:authorize-acl>`标签只显示用户有权访问的事件对象。让我们更新我们的`DefaultCalendarService`接口,以便当用户创建一个新事件时,他们被授予读取该事件的权限,并且它将显示在所有事件页面上。

让我们来看看以下步骤,以将 ACL 添加到新创建的事件中:

1.  第一步是更新我们的构造函数以接受`MutableAclService 和 UserContext`:

``` |
| ```

1.  然后,我们需要更新我们的`createEvent`方法,以也为当前用户创建 ACL。进行以下更改:

``` |

在幕后,标签实现利用了本章早些时候讨论过的相同的`SidRetrievalStrategy`和`ObjectIdentityRetrievalStrategy`接口。因此,访问检查的计算遵循与 ACL 启用的方法安全投票相同的 workflow。正如我们即将看到的,标签实现还将使用相同的`PermissionEvaluator`。

我们已经用一个引用`DefaultMethodSecurityExpressionHandler`的`expressionHandler`元素配置了我们的`GlobalMethodSecurityConfiguration`。`DefaultMethodSecurityExpressionHandler`实现认识我们的`AclPermissionEvaluator`接口,但我们还必须让 Spring Security 的 web 层认识`AclPermissionEvaluator`。如果你仔细思考,这种对称性是合理的,因为保护和 HTTP 请求是保护两种非常不同的资源。幸运的是,Spring Security 的抽象使这一切变得相当简单。

1.  添加一个引用我们已经定义的 ID 为`permissionEvaluator`的 bean 的`DefaultWebSecurityExpressionHandler`处理器:

  1. JdbcMutableAclService接口使用当前用户作为创建的MutableAcl接口的默认所有者。我们选择再次显式设置所有者,以展示如何覆盖此设置。

  2. 然后我们添加一个新的 ACE 并保存我们的 ACL。就是这样。

  3. 启动应用程序,使用user1@example.com/user1登录。

  4. 访问“所有事件”页面,发现目前没有列出任何事件。然后,创建一个新事件,下次访问“所有事件”页面时它将显示出来。如果你以其他任何用户身份登录,事件将不会在“所有事件”页面上显示。但是,对于其他用户来说,它可能是可见的,因为我们还没有对其他页面应用安全性。再次强调,我们鼓励你自己尝试保护这些页面。

你的代码应该看起来像chapter12.05-calendar

典型 ACL 部署的考虑因素

实际上,在真正的商业应用程序中部署 Spring ACL 往往相当复杂。我们总结了 Spring ACL 的覆盖范围,并提出了一些在大多数 Spring ACL 实现场景中出现的问题。

ACL 的可扩展性与性能建模

对于中小型应用程序,添加 ACL 是相当可管理的,尽管它增加了数据库存储和运行时性能的开销,但影响可能不是很大。然而,根据 ACL 和 ACE 建模的粒度,中型到大型应用程序的数据库行数可能非常惊人,甚至让最有经验的数据库管理员都感到任务繁重。

假设我们要将 ACL 扩展到 JBCP 日历应用程序的扩展版本。假设用户可以管理账户,向事件发布图片,以及从一个事件中添加/删除用户。我们将按照以下方式建模数据:

  • 所有用户都有账户。

  • 10%的用户能够管理一个事件。平均每个用户能够管理的事件数量将是两个。

  • 事件将按客户 secured(只读),但还需要由管理员(读写)访问。

  • 所有客户的 10%将被允许发布图片。每个用户的平均发布数量将是 20。

  • 发布的图片将按用户 secured(读写),以及管理员。发布的图片对所有其他用户将是只读的。

考虑到我们对 ACL 系统的了解,我们知道数据库表具有以下可扩展性属性:

表格 与数据相关联 可扩展性说明
ACL_CLASS 每种域类需要一行。
ACL_SID 是(用户) 每个角色(GrantedAuthority)需要一行。如果按用户账户个别域对象进行安全保护,则每个用户账户需要一行。
ACL_OBJECT_IDENTITY 是(按类的安全域对象实例) 每个安全域对象实例需要一行。
ACL_ENTRY 是(按域对象实例个别 ACL 条目) 每个 ACL 条目需要一行;对于单个域对象可能需要多行。

我们可以看到ACL_CLASS实际上并没有可扩展性担忧(大多数系统将具有少于 1,000 个域类)。ACL_SID表将根据系统中的用户数量线性扩展。这可能不是一个问题,因为其他与用户相关的表也将以这种方式扩展(用户帐户等)。|

关注的两个表是ACL_OBJECT_IDENTITYACL_ENTRY。如果我们为个别客户建模订单所需的估计行数,我们得出以下估计:|

Table 每个事件的 ACL 数据 每个图片帖子的 ACL 数据
ACL_OBJECT_IDENTITY 每个事件需要一行。 每个帖子需要一行。
ACL_ENTRY 对于所有者(用户SID)的读取访问需要三行,其中一行是必需的(对于管理组SID的读取访问需要两行,对于事件也是如此) 四行-一行对于用户组SID的读取访问是必需的,所有者需要一行写入访问,管理组SID需要两行(与事件一样)

然后我们可以从前一页的使用假设中获取数据,并以下方式计算 ACL 可扩展性矩阵:|

Table/Object 扩展因子 估计(低) 估计(高)
Users 10,000 1,000,000
Events # Users * 0.1 * 2 2,000 200,000
Picture Posts # Users * 0.1 * 20 20,000 2,000,000
ACL_SID # Users 10,000 1,000,000
ACL_OBJECT_IDENTITY # Events + # Picture Posts 220,000 2,200,000
ACL_ENTRY (# Events * 3) + (# Picture Posts * 4) 86,000 8,600,000

从这些基于典型 ACL 实现中可能涉及和受保护的业务对象子集的预测,您可以看到,用于存储 ACL 信息的数据库行的数量可能会与您的实际业务数据线性增长(或更快)。特别是在大型系统规划中,预测您可能使用的 ACL 数据量非常重要。在非常复杂的系统中,与 ACL 存储相关的数百万行数据是很常见的。|

不要低估定制开发成本|

在 Spring ACL 安全环境中使用通常需要大量的开发工作,超出我们迄今为止描述的配置步骤。我们示例配置场景有以下限制:|

  • 没有提供响应事件或修改权限的操作修改的设施|

  • 并非所有的应用程序都在使用权限。例如,我的事件页面和直接导航到事件都不受保护|

应用程序没有有效地使用 ACL 层次结构。如果我们为整个网站实施 ACL 安全,这些限制将对功能产生重大影响。这就是为什么在计划在整个应用程序中实施 Spring ACL 时,你必须仔细审查所有操作领域数据的地方,并确保这些地方正确更新 ACL 和 ACE 规则,以及无效化缓存。通常,方法和数据的保护发生在服务或业务应用程序层,而维护 ACL 和 ACE 所需的生命周期钩子发生在数据访问层。

如果你处理的是一个相对标准的应用程序架构,并且功能得到了适当的隔离和封装,那么这些更改很可能有一个容易识别的中心位置。另一方面,如果你处理的架构已经退化(或者最初就没有设计好),那么在数据操作代码中添加 ACL 功能和支持钩子可能会非常困难。

如之前所暗示的,重要的是要记住 Spring ACL 架构自 Acegi 1.x 时代以来并没有发生显著变化。在那段时间里,许多用户尝试实现它,并在 Spring Security JIRA 存储库(jira.springframework.org/)中记录和文档化了几个重要的限制,其中许多在 Spring Security 3 中仍未得到解决。对于这些问题(如果它们适用于你的情况),可能需要进行大量的自定义编码才能绕过。

以下是一些最重要且常见遇到的问题:

  • ACL 基础架构需要一个数值主键。对于使用 GUID 或 UUID 主键的应用程序(这在现代数据库中更常见,因为它们在支持方面更有效率),这可能是一个重大限制。

  • 在撰写此内容时,JIRA 问题 SEC-1140 记录了默认 ACL 实现不正确使用位运算符比较权限位掩码的问题。我们在权限部分的章节中已经讨论过这个问题。

  • 在配置 Spring ACL 方法和 Spring Security 其他部分之间存在几处不一致。通常,你可能会在类委托或属性未通过 DI 暴露的区域遇到问题,这需要一个耗时且维护成本高昂的重写和覆盖策略。

  • 权限位掩码实现为整数,因此有 32 个可能的位。将默认位分配扩展以表示单个对象属性的权限(例如,为读取员工的社会安全号码分配一个位)是比较常见的。复杂的部署可能会使每个领域对象有超过 32 个属性,在这种情况下,唯一的选择就是围绕这个限制重新设计您的领域对象。

根据您特定应用程序的要求,您很可能会遇到额外的问题,尤其是在实现某些类型的自定义时需要更改的类数量方面。

我应该使用 Spring Security ACL 吗?

正如整体应用 Spring Security 的细节高度依赖于业务场景一样,Spring ACL 支持的应用也同样如此。实际上,由于 Spring ACL 支持与业务方法和领域对象紧密耦合,这种依赖性在 Spring ACL 支持方面往往更为明显。我们希望本章关于 Spring ACL 的指南能够解释分析 Spring ACL 在应用程序中使用所需的重要高级和低级配置与概念,并帮助您确定并将其功能与现实世界的使用相匹配。

总结

在本章中,我们重点讨论了基于 ACL 的安全以及 Spring ACL 模块如何实现这种安全类型的具体细节。

我们回顾了 ACL 的基本概念,以及它们在授权方面可能非常有效的许多原因。此外,您还学习了与 Spring ACL 实现相关的关键概念,包括 ACEs、SIDs 和对象身份。我们检查了支持分层 ACL 系统的数据库模式和逻辑设计。我们配置了所有必需的 Spring beans 以启用 Spring ACL 模块,并增强了一个服务接口以使用注解方法授权。然后,我们将我们数据库中现有的用户和网站本身使用的业务对象与一组示例 ACE 声明和支持数据相连接。我们回顾了 Spring ACL 权限处理的概念。我们扩展了对 Spring Security Thymeleaf 标签库和 SpEL 表达式语言(用于方法安全)的了解,以利用 ACL 检查。我们讨论了可变 ACL 概念,并回顾了在可变 ACL 环境中所需的基本配置和自定义代码。我们开发了一个自定义 ACL 权限并配置应用程序以展示其有效性。我们配置并分析了使用Ehcache缓存管理器以减少 Spring ACL 对数据库的影响。我们分析了在复杂业务应用程序中使用 Spring ACL 系统的影响和设计考虑。

这结束了我们关于 Spring Security ACL 的讨论。在下一章中,我们将更深入地了解 Spring Security 是如何工作的。

第十二章:自定义授权

在本章中,我们将为 Spring Security 的关键授权 API 编写一些自定义实现。一旦我们完成这些工作,我们将使用对自定义实现的理解来了解 Spring Security 的授权架构是如何工作的。

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

  • 了解授权是如何工作的

  • 编写一个自定义的SecurityMetaDataSource,而不是antMatchers()方法,由数据库支持。

  • 创建一个自定义 SpEL 表达式

  • 实现一个自定义的PermissionEvaluator对象,允许我们的权限被封装

授权请求

与认证过程一样,Spring Security 提供了一个o.s.s.web.access.intercept.FilterSecurityInterceptor servlet 过滤器,负责决定是否接受特定请求。在调用过滤器时,主体已经被认证,所以系统知道一个有效的用户已经登录;记住我们在第三章,自定义认证中实现了List<GrantedAuthority> getAuthorities()方法,该方法返回主体的一个权限列表。通常,授权过程将使用这个方法(由Authentication接口定义)的信息来确定,对于一个特定的请求,是否应允许该请求。

请记住,授权是一个二进制决策——用户要么有权访问受保护的资源,要么没有。在授权方面没有模棱两可。

智能面向对象设计在 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)

  • 用户提交的凭证(如果有的话)

  • 正在访问的受保护资源

  • 系统的配置参数,以及资源本身

AccessDecisionManager的实现还负责将被请求资源的访问声明(在代码中表示为o.s.s.access.ConfigAttribute接口的实现)传递给选民。对于 Web URL,选民将了解关于资源的访问声明的信息。如果我们查看我们非常基本的配置文件的 URL 拦截声明,我们将看到ROLE_USER被声明为用户正在尝试访问的资源的访问配置,如下所示:

    .antMatchers("/**").hasRole("USER");

根据选民的了解,它将决定用户是否应该有权访问资源。Spring Security 允许选民做出三种决定之一,其逻辑定义映射到接口中的常量,如下表所示:

决策类型 描述
授权 (ACCESS_GRANTED) 选民建议给予对资源的访问权限。
拒绝 (ACCESS_DENIED) 选民建议拒绝对资源的访问。

| 弃权 (ACCESS_ABSTAIN) | 选民弃权(不对资源的访问做出决定)。这可能发生的原因很多,比如:

  • 选民没有确凿的信息

  • 选民无法对这种类型的请求做出决定

|

正如您可能从与访问决策相关的对象和接口的设计中猜测的那样,Spring Security 的这部分已经被设计为可以应用于不仅仅是 Web 领域的验证和访问控制场景。当我们在本章后面讨论方法级安全时,我们将遇到选民和访问决策管理器。

当我们将所有这些内容组合在一起时,Web 请求的默认授权检查的整体流程类似于以下图表:

我们可以看到,ConfigAttribute的抽象允许从配置声明中传递数据(保存在o.s.s.web.access.intercept.DefaultFilterinvocationSecurityMetadataSource接口中)给负责处理ConfigAttribute的选民,而不需要任何中介类了解ConfigAttribute的内容。这种关注点的分离为构建新类型的安全声明(例如我们稍后将在方法安全中看到的声明)提供了坚实的基础,同时利用相同的访问决策模式。

访问决策聚合的配置

Spring Security 实际上允许在安全命名空间中配置AccessDecisionManager<http>元素上的access-decision-manager-ref属性允许你指定一个 Spring bean 引用,以引用AccessDecisionManager的实现。Spring Security 随货提供了这个接口的三个实现,全部在o.s.s.access.vote包中,如下所示:

类名 描述
AffirmativeBased 如果有任何投票者授予访问权限,则立即授予访问权限,不顾之前的拒绝。
ConsensusBased 由多数票(批准或拒绝)决定AccessDecisionManager的决策。决胜票和空票(仅包含弃权)的处理是可配置的。
UnanimousBased 所有投票者必须授予访问权限,否则,拒绝访问。

配置一个 UnanimousBased 访问决策管理器

如果我们想要修改我们的应用程序以使用访问决策管理器,我们需要进行两项修改。为此,我们需要在我们的SecurityConfig.java文件中的http元素中添加accessDecisionManager条目,如下所示:

    //src/main/java/com/packtpub/springsecurity/configuration/
    SecurityConfig.java

    http.authorizeRequests()
         .anyRequest()
         .authenticated()
         .accessDecisionManager(accessDecisionManager());

这是一个标准的 Spring bean 引用,所以这应该对应于 bean 的id属性。我们然后可以定义UnanimousBased bean,如下面的代码片段所示。请注意,我们实际上不会在练习中使用这个配置:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java

@Bean
public AccessDecisionManager accessDecisionManager() {
   List<AccessDecisionVoter<? extends Object>> decisionVoters
           = Arrays.asList(
           new AuthenticatedVoter(),
           new RoleVoter(),
           new WebExpressionVoter()
   );

   return new UnanimousBased(decisionVoters);
}

您可能想知道decisionVoters属性是关于什么。这个属性在我们声明自己的AccessDecisionManager之前是自动配置的。默认的AccessDecisionManager类需要我们声明一个投票者列表,这些投票者被咨询以做出认证决策。这里列出的两个投票者是安全命名空间配置提供的默认值。

Spring Security 没有包含多种投票者,但它实现一个新的投票者是微不足道的。正如我们将在本章后面看到的,在大多数情况下,创建自定义投票者是不必要的,因为这通常可以通过自定义表达式或甚至自定义o.s.s.access.PermissionEvaluator来实现。

这里提到的两个投票者实现如下:

类名 描述 示例
o.s.s.access.vote.RoleVoter 检查用户具有匹配的声明角色。期望属性定义一个以逗号分隔的名称列表。前缀是期望的,但可选配置。 access="ROLE_USER,ROLE_ADMIN"

| o.s.s.access.vote.AuthenticatedVoter | 支持特殊声明,允许通配符匹配:

  • IS_AUTHENTICATED_FULLY 如果提供了新的用户名和密码,则允许访问。

  • IS_AUTHENTICATED_REMEMBERED 如果用户使用记住我功能进行了认证,则允许访问。

  • IS_AUTHENTICATED_ANONYMOUSLY 如果用户是匿名的,则允许访问

access="IS_AUTHENTICATED_ANONYMOUSLY"

基于表达式的请求授权

正如您可能预期的那样,SpEL 处理由不同的Voter实现提供,即o.s.s.web.access.expression.WebExpressionVoter,它知道如何评估 SpEL 表达式。WebExpressionVoter类为这个目的依赖于SecurityExpressionHandler接口的实现。SecurityExpressionHandler接口负责评估表达式以及提供在表达式中被引用的安全特定方法。这个接口的默认实现暴露了定义在o.s.s.web.access.expression.WebSecurityExpressionRoot类中的方法。

这些类之间的流程和关系在下方的图表中显示:

既然我们知道如何请求授权工作,那么通过实现一些关键接口的几个自定义实现来巩固我们的理解吧。

自定义请求授权

Spring Security 授权的真正力量在于它如何适应定制化需求。让我们探索几个场景,以帮助加深对整体架构的理解。

动态定义 URL 的访问控制

Spring Security 为将ConfigAttribute对象映射到资源提供了多种方法。例如,antMatchers()方法确保它对开发人员来说简单易用,以便在他们 web 应用程序中限制对特定 HTTP 请求的访问。在幕后,o.s.s.acess.SecurityMetadataSource的实现被填充了这些映射,并查询它以确定为了被授权对任何给定的 HTTP 请求进行操作需要什么。

虽然antMatchers()方法非常简单,但有时可能希望提供一个自定义机制来确定 URL 映射。一个这样的例子可能是如果一个应用程序需要能够动态提供访问控制规则。让我们展示将我们的 URL 授权配置移动到数据库需要做什么。

配置 RequestConfigMappingService

第一步是能够从数据库中获取必要的信息。这将替换从我们的安全豆配置中读取antMatchers()方法的逻辑。为了实现这一点,章节示例代码中包含了JpaRequestConfigMappingService,它将从数据库中获取表示为RequestConfigMapping的 ant 模式和表达式的映射。这个相当简单的实现如下所示:

    // src/main/java/com/packtpub/springsecurity/web/access/intercept/
   JpaRequestConfigMappingService.java

    @Repository("requestConfigMappingService")
    public class JpaRequestConfigMappingService
    implements RequestConfigMappingService {
       @Autowired
   private SecurityFilterMetadataRepository securityFilterMetadataRepository;

   @Autowired
   public JpaRequestConfigMappingService(
           SecurityFilterMetadataRepository sfmr
   ) {
       this.securityFilterMetadataRepository = sfmr;
   }

   @Override
   public List<RequestConfigMapping> getRequestConfigMappings() {
       List<RequestConfigMapping> rcm =
           securityFilterMetadataRepository
               .findAll()
               .stream()
               .sorted((m1, m2) -> {
               return m1.getSortOrder() - m2.getSortOrder()
               })
               .map(md -> {
                   return new RequestConfigMapping(
                            new AntPathRequestMatcher 
                             (md.getAntPattern()),
                             new SecurityConfig 
                             (md.getExpression()));
              }).collect(toList());
       return rcm;
   }
}

需要注意的是,就像antMatchers()方法一样,顺序很重要。因此,我们确保结果按sort_order列排序。服务创建了一个AntRequestMatcher,并将其关联到SecurityConfig,这是一个ConfigAttribute实例。这将为 HTTP 请求到ConfigAttribute对象的映射提供支持,这些对象可以被 Spring Security 用来保护我们的 URL。

我们需要创建一个域对象,以便 JPA 将其映射如下:

// src/main/java/com/packtpub/springsecurity/domain/SecurityFilterMetadata.java

@Entity
@Table(name = "security_filtermetadata")
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> {}

为了让新服务能工作,我们需要初始化我们的数据库,包括架构和访问控制映射。和实现服务一样,我们的架构相当简单:

// src/main/resources/schema.sql

...
create table security_filtermetadata (
 id         INTEGER GENERATED BY DEFAULT AS IDENTITY,
 ant_pattern VARCHAR(1024) NOT NULL unique,
 expression VARCHAR(1024) NOT NULL,
 sort_order INTEGER NOT NULL,
 PRIMARY KEY (id) 
);

然后我们可以使用相同的antMatchers()映射从我们的SecurityConfig.java文件来生成schema.sql文件:

// src/main/resources/data.sql

*--* Security Filter Metadata *--* 
insert into security_filtermetadata(id,ant_pattern,expression,sort_order) values (110, '/admin/h2/**','permitAll',10);

insert into security_filtermetadata(id,ant_pattern,expression,sort_order) values (115, '/','permitAll',15);

insert into security_filtermetadata(id,ant_pattern,expression,sort_order) values (120, '/login/*','permitAll',20);

insert into security_filtermetadata(id,ant_pattern,expression,sort_order) values (140, '/logout','permitAll',30);

insert into security_filtermetadata(id,ant_pattern,expression,sort_order) values (130, '/signup/*','permitAll',40);

insert into security_filtermetadata(id,ant_pattern,expression,sort_order) values (150, '/errors/**','permitAll',50);

insert into security_filtermetadata(id,ant_pattern,expression,sort_order) values (160, '/admin/**','hasRole("ADMIN")',60);

insert into security_filtermetadata(id,ant_pattern,expression,sort_order) values (160, '/events/','hasRole("ADMIN")',60);

insert into security_filtermetadata(id,ant_pattern,expression,sort_order) values (170, '/**','hasRole("USER")',70);

此时,你的代码应该以chapter13.00-calendar开始。

自定义 SecurityMetadataSource 实现

为了让 Spring Security 了解我们的 URL 映射,我们需要提供一个自定义的FilterInvocationSecurityMetadataSource实现。FilterInvocationSecurityMetadataSource包扩展了SecurityMetadataSource接口,对于特定的 HTTP 请求,它提供了 Spring Security 确定是否应授予访问权限所需的信息。让我们看看如何利用我们的RequestConfigMappingService接口来实现一个SecurityMetadataSource接口:

    //src/main/java/com/packtpub/springsecurity/web/access/intercept/
    FilterInvocationServiceSecurityMetadataSource.java

    @Component("filterInvocationServiceSecurityMetadataSource")
    public class FilterInvocationServiceSecurityMetadataSource implements
    FilterInvocationSecurityMetadataSource, InitializingBean{
           ¦ constructor and member variables omitted ...

       public Collection<ConfigAttribute> getAllConfigAttributes() {
           return this.delegate.getAllConfigAttributes();
       }

       public Collection<ConfigAttribute> getAttributes(Object object) {
           return this.delegate.getAttributes(object);
       }

       public boolean supports(Class<?> clazz) {
           return this.delegate.supports(clazz);
       }

       public void afterPropertiesSet() throws Exception {
       List<RequestConfigMapping> requestConfigMappings =
       requestConfigMappingService.getRequestConfigMappings();
       LinkedHashMap requestMap = new 
       LinkedHashMap(requestConfigMappings.size());
       for(RequestConfigMapping requestConfigMapping 
       requestConfigMappings) {
           RequestMatcher matcher = 
               requestConfigMapping.getMatcher();
           Collection<ConfigAttribute> attributes =
                   requestConfigMapping.getAttributes();
           requestMap.put(matcher,attributes);
       }
           this.delegate =
           new 
           ExpressionBasedFilterInvocationSecurityMetadataSource
          (requestMap,expressionHandler);
       }
    }

我们可以使用我们的RequestConfigMappingService接口创建一个RequestMatcher对象的映射到ConfigAttribute对象的映射。然后我们将工作委托给ExpressionBasedFilterInvocationSecurityMetadataSource的一个实例。为了简单起见,当前的实现将需要重新启动应用程序以获取更改。然而,通过一些小的改动,我们可以避免这种不便。

注册一个自定义 SecurityMetadataSource

现在,剩下要做的就是配置FilterInvocationServiceSecurityMetadataSource。唯一的问题是 Spring Security 不支持直接配置自定义的FilterInvocationServiceSecurityMetadataSource接口。这并不太难,因此我们将在SecurityConfig文件中用我们的FilterSecurityInterceptor注册这个SecurityMetadataSource

    // src/main/java/com/packtpub/springsecurity/configuration/
    SecurityConfig.java

   @Override
    public void configure(final WebSecurity web) throws Exception {
       ...
       final HttpSecurity http = getHttp();
       web.postBuildAction(() -> {
       FilterSecurityInterceptor fsi = http.getSharedObject
       (FilterSecurityInterceptor.class);
       fsi.setSecurityMetadataSource(metadataSource);
       web.securityInterceptor(fsi);
       });
    }

这设置了我们自定义的SecurityMetadataSource接口,将其作为默认元数据源与FilterSecurityInterceptor对象关联。

移除我们的 antMatchers()方法

现在既然数据库正在被用来映射我们的安全配置,我们可以在SecurityConfig.java文件中删除antMatchers()方法。大胆地删除它们,使得配置看起来类似于以下的代码片段:

    // src/main/java/com/packtpub/springsecurity/configuration/
    SecurityConfig.java

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

    // No interceptor methods
    // http.authorizeRequests()
    //     .antMatchers("/").permitAll()
         ...

    http.formLogin()
         ...

    http.logout()
         ...

如果你使用了http antMatchers表达式中的任何一个,那么自定义表达式处理程序将不会被调用。

现在你应该能够启动应用程序并测试,以确保我们的 URL 已经被正确保护。我们的用户不会注意到任何区别,但我们知道现在我们的 URL 映射已经保存在数据库中了。

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

创建一个自定义表达式

o.s.s.access.expression.SecurityExpresssionHandler接口是 Spring Security 如何抽象 Spring 表达式的创建和初始化的方式。就像SecurityMetadataSource接口一样,有一个用于创建 web 请求表达式的实现,还有一个用于保护方法的实现。在本节中,我们将探讨如何轻松添加新表达式。

配置自定义 SecurityExpressionRoot

假设我们想要支持一个名为isLocal的自定义 web 表达式,如果主机是 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,我们首先需要创建一个定义我们的isLocal方法的WebSecurityExpressionRoot子类:

    //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头值以绕过这样的约束。

配置自定义 SecurityExpressionHandler

为了让我们的新方法变得可用,我们需要创建一个使用我们新根对象的定制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();

       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 文档:static.springsource.org/spring/docs/current/spring-framework-reference/html/expressions.html

配置和使用 CustomWebSecurityExpressionHandler

让我们来看看配置CustomWebSecurityExpressionHandler的以下步骤:

  1. 我们现在需要配置CustomWebSecurityExpressionHandler。幸运的是,这可以通过使用 Spring Security 命名空间配置支持很容易地完成。在SecurityConfig.java文件中添加以下配置:
    // src/main/java/com/packtpub/springsecurity/configuration/
    SecurityConfig.java

    http.authorizeRequests()
       .expressionHandler(customWebSecurityExpressionHandler);
  1. 现在,让我们更新我们的初始化 SQL 查询以使用新的表达式。更新data.sql文件,要求用户为ROLE_ADMIN,并且请求来自本地机器。你会注意到,由于 SpEL 支持 Java Bean 约定,我们能够写本地而不是isLocal
       // src/main/resources/data.sql

      insert into security_filtermetadata(id,ant_pattern,expression,sort_order) 
      values (160, '/admin/**','local and hasRole("ADMIN")',60);
  1. 重新启动应用程序,使用localhost:8443/admin/h2admin1@example.com/admin1访问 H2 控制台,以查看管理控制台。如果使用127.0.0.1:8443/admin/h2admin1@example.com admin1访问 H2 控制台,将显示访问被拒绝的页面。

你的代码应该看起来像chapter13.02-calendar

CustomWebSecurityExpressionHandler 的替代方案

使用自定义表达式而不是CustomWebSecurityExpressionHandler接口的另一种方法是在 web 上添加一个@Component,如下所示:

    // src/main/java/com/packtpub/springsecurity/web/access/expression/
    CustomWebExpression.java

    @Component
     public class CustomWebExpression {
       public boolean isLocal(Authentication authentication,
                          HttpServletRequest request) {
       return "localhost".equals(request.getServerName());
   }
}

现在,让我们更新我们的初始化 SQL 查询,以使用新的表达式。你会注意到,由于 SpEL 支持 Java Bean 约定,我们能够直接引用@Component

// src/main/resources/data.sql

insert into security_filtermetadata(id,ant_pattern,expression,sort_order) values (160, '/admin/**','@customWebExpression.isLocal(authentication, request) and hasRole("ADMIN")',60);

方法安全性是如何工作的?

方法安全性的访问决策机制-是否允许给定请求-与 web 请求访问的访问决策逻辑概念上是相同的。AccessDecisionManager轮询一组AccessDecisionVoters,每个都可以提供允许或拒绝访问的决定,或者弃权。AccessDecisionManager的具体实现聚合了投票者的决定,并得出一个总体的决定来允许方法调用。

由于 servlet 过滤器的可用性使得拦截(以及总结拒绝)可安全请求相对简单,web 请求访问决策过程较为简单。由于方法调用可以来自任何地方,包括 Spring Security 没有直接配置的代码区域,Spring Security 设计者选择使用 Spring 管理的 AOP 方法来识别、评估和保护方法调用。

以下高级流程图展示了在方法调用授权决策中涉及的主要参与者:

我们可以看到,Spring Security 的o.s.s.access.intercept.aopalliance.MethodSecurityInterceptor被标准的 Spring AOP 运行时调用,以拦截感兴趣的方法调用。从这里,是否允许一个方法调用的逻辑相对直接,如之前的流程图所示。

在这个阶段,我们可能会对方法安全特性性能感到好奇。显然,MethodSecurityInterceptor不能为应用程序中的每个方法调用都进行调用——那么方法或类上的注解是如何导致 AOP 拦截的呢?

首先,默认情况下,AOP 代理不会为所有 Spring 管理的 bean 调用。相反,如果 Spring Security 配置中定义了@EnableGlobalMethodSecurity,将注册一个标准的 Spring AOP o.s.beans.factory.config.BeanPostProcessor,该处理器将内省 AOP 配置,以查看是否有任何 AOP 顾问指示需要代理(及拦截)。这个工作流程是标准的 Spring AOP 处理(称为 AOP 自动代理),本质上并没有任何特定于 Spring Security 的功能。所有注册的BeanPostProcessor在 spring ApplicationContext初始化之后运行,此时 Spring bean 配置已经完成。

AOP 自动代理功能查询所有注册的PointcutAdvisor,以查看是否有关键的 AOP 切点可以解析应该应用 AOP 建议的方法调用。Spring Security 实现了o.s.s.access.intercept.aopalliance.MethodSecurityMetadataSourceAdvisor类,该类检查所有配置的方法安全,并设置适当的 AOP 拦截。请注意,只有声明了方法安全规则的接口或类才会被 AOP 代理!

请注意,强烈建议您在接口上声明 AOP 规则(和其他安全注解),而不是在实现类上。虽然使用 CGLIB 代理与 Spring 可用,但使用类可能会意外地改变应用程序的行为,并且从语义上讲,使用 AOP 在接口上声明安全(比在类上)通常更正确。MethodSecurityMetadataSourceAdvisor将决定影响方法的建议委托给一个o.s.s.access.method.MethodSecurityMetadataSource实例。每种方法安全注解都有自己的MethodSecurityMetadataSource实现,该实现用于依次内省每个方法和类,并添加在运行时执行的 AOP 建议。

以下图表展示了这一过程是如何发生的:

根据您应用程序中配置的 Spring bean 数量,以及您拥有的受保护方法注解的数量,添加方法安全代理可能会增加初始化 ApplicationContext 所需的时间。然而,一旦 Spring 上下文初始化完成,对个别代理 bean 的性能影响可以忽略不计。

既然我们已经了解了如何使用 AOP 将 Spring Security 应用于实际场景,那么接下来让我们通过创建一个自定义PermissionEvaluator来加深对 Spring Security 授权的理解。

创建自定义 PermissionEvaluator

在上一章中,我们展示了如何使用 Spring Security 内置的PermissionEvaluator实现,AclPermissionEvaluator,来限制对应用程序的访问。虽然这个实现很强大,但很多时候可能会比必要的更复杂。我们还发现 SpEL 可以制定复杂的表达式来保护我们的应用程序。虽然简单,使用复杂表达式的一个缺点是逻辑不集中。幸运的是,我们可以很容易地创建一个自定义PermissionEvaluator,它能够集中我们的授权逻辑,同时避免使用 ACLs 的复杂性。

日历权限评估器

下面是一个不包含任何验证的我们自定义PermissionEvaluator的简化版本:

//src/main/java/com/packtpub/springsecurity/access/CalendarPermissionEvaluator.java

public final class CalendarPermissionEvaluator implements PermissionEvaluator {
   private final EventDao eventDao;

   public CalendarPermissionEvaluator(EventDao eventDao) {
       this.eventDao = eventDao;
   }

   public boolean hasPermission(Authentication authentication, Object 
   targetDomainObject, Object permission) {
       // should do instanceof check since could be any domain object
       return hasPermission(authentication, (Event) targetDomainObject, permission);
   }

   public boolean hasPermission(Authentication authentication, 
   Serializable targetId, String targetType,
           Object permission) {
       // missing validation and checking of the 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;
}

配置应该类似于第十二章的配置,访问控制列表,不同之处在于我们现在使用的是我们的CalendarPermissionEvaluator类,而不是AclPermissionEvaluator

接下来,我们在SecurityConfig.java中添加以下配置,通知 Spring Security 使用我们的自定义ExpressionHandler

    //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
    http.authorizeRequests().expressionHandler
    (customWebSecurityExpressionHandler);

在配置中,我们确保prePostEnabled被启用,并将配置指向我们的ExpressionHandler定义。再次强调,配置应该与第十一章的配置非常相似,细粒度访问控制

保护我们的 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)。将显示访问被拒绝的页面。然而,我们希望能够像ROLE_ADMIN用户一样访问所有事件。

自定义 PermissionEvaluator 的好处

只有一个方法被保护,更新注解以检查用户是否有ROLE_ADMIN角色或权限将是微不足道的。然而,如果我们保护了所有使用事件的我们的服务方法,这将会变得非常繁琐。相反,我们只需更新我们的CalendarPermissionEvaluator。做出以下更改:

private boolean hasPermission(Authentication authentication, Event event, Object permission) {
   if(event == null) {
       return true;
   }
   GrantedAuthority adminRole =
           new SimpleGrantedAuthority("ROLE_ADMIN");
   if(authentication.getAuthorities().contains(adminRole)) {
       return true;
   }
   ...
}

现在,重新启动应用程序并重复前面的练习。这次,电话会议事件将成功显示。你可以看到,将我们的授权逻辑封装起来可以非常有用。然而,有时扩展表达式本身可能是有用的。

你的代码应该看起来像chapter13.03-calendar

摘要

阅读本章后,你应该对 Spring Security 如何为 HTTP 请求和方法工作有一个坚实的基础。有了这个知识,以及提供的具体示例,你也应该知道如何扩展授权以满足你的需求。特别是,在本章中,我们介绍了 Spring Security 的 HTTP 请求和方法的授权架构。我们还展示了如何从数据库配置受保护的 URL。

我们还看到了如何创建一个自定义PermissionEvaluator对象和自定义 Spring Security 表达式。

在下一章中,我们将探讨 Spring Security 如何进行会话管理。我们还将了解如何使用它来限制对我们应用程序的访问。

第十三章:会话管理

本章讨论 Spring Security 的会话管理功能。它从举例说明 Spring Security 如何防御会话固定开始。然后我们将讨论并发控制如何被利用来限制按用户许可的软件的访问。我们还将看到会话管理如何被利用进行管理功能。最后,我们将探讨HttpSession在 Spring Security 中的使用以及我们如何控制其创建。

以下是在本章中将会讨论的主题列表:

  • 会话管理/会话固定

  • 并发控制

  • 管理已登录用户

  • 如何使用HttpSession在 Spring Security 中以及如何控制其创建

  • 如何使用DebugFilter类发现HttpSession的创建位置

配置会话固定保护

因为我们正在使用配置命名空间的风格,会话固定保护已经为我们配置好了。如果我们想要显式配置它以反映默认设置,我们会这样做:

    http.sessionManagement()
    .sessionFixation().migrateSession();

会话固定保护是框架的一个特性,除非你试图充当恶意用户,否则你很可能会注意到它。我们将向你展示如何模拟一个会话窃取攻击;在我们这样做之前,了解会话固定做什么以及它防止的攻击类型是很重要的。

理解会话固定攻击

会话固定是一种攻击方式,恶意用户试图窃取系统的未经验证用户的会话。这可以通过使用各种技术来完成,这些技术使攻击者获得用户的唯一会话标识(例如,JSESSIONID)。如果攻击者创建一个包含用户JSESSIONID标识的 cookie 或 URL 参数,他们就可以访问用户的会话。

尽管这显然是一个问题,但通常情况下,如果一个用户未经验证,他们还没有输入任何敏感信息。如果用户验证后仍然使用相同的会话标识,这个问题变得更加严重。如果验证后仍然使用相同的标识,攻击者可能现在甚至不需要知道用户的用户名或密码就能访问到验证用户的会话!

到此为止,你可能会不屑一顾,认为这在现实世界中极不可能发生。实际上,会话窃取攻击经常发生。我们建议你花些时间阅读一下由开放网络应用安全项目OWASP)组织发布的关于这个主题的非常有益的文章和案例研究(www.owasp.org/)。特别是,你可能想要阅读 OWASP top 10 列表。攻击者和恶意用户是真实存在的,如果你不了解他们常用的技术,也不知道如何避免它们,他们可能会对你用户、应用程序或公司造成真正的损害。

以下图表说明了会话固定攻击是如何工作的:

既然我们已经了解了这种攻击是如何工作的,我们将看看 Spring Security 能做些什么来防止它。

使用 Spring Security 预防会话固定攻击

如果我们能够防止用户在认证前拥有的相同会话在认证后被使用,我们就可以有效地使攻击者对会话 ID 的了解变得无用。Spring Security 会话固定保护通过在用户认证时明确创建新会话并使他们的旧会话失效来解决此问题。

让我们来看一下以下的图表:

我们可以看到一个新的过滤器o.s.s.web.session.SessionManagementFilter负责评估特定用户是否新认证。如果用户是新的认证,一个配置的o.s.s.web.authentication.session.SessionAuthenticationStrategy接口决定了要做什么。o.s.s.web.authentication.session.SessionFixationProtectionStrategy将创建一个新会话(如果用户已经有一个),并将现有会话的内容复制到新会话中。这就差不多结束了——看起来很简单。然而,正如我们之前看到的图表所示,它有效地阻止了恶意用户在未知用户认证后重新使用会话 ID。

模拟会话固定攻击

此时,你可能想了解模拟会话固定攻击涉及什么:

  1. 你首先需要在SecurityConfig.java文件中禁用会话固定保护,通过将sessionManagement()方法作为http元素的子项添加。

你应该从chapter14.00-calendar的代码开始。

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

    //src/main/java/com/packtpub/springsecurity/configuration/
    SecurityConfig.java

    http.sessionManagement().sessionFixation().none();

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

  1. 接下来,你需要打开两个浏览器。我们将在 Google Chrome 中初始化会话,从中窃取它,然后我们的攻击者将在 Firefox 中使用窃取的会话登录。我们将使用 Google Chrome 和 Firefox 的 Web 开发者插件来查看和操作 Cookie。Firefox 的 Web 开发者插件可以从addons.mozilla.org/en-US/firefox/addon/web-developer/下载。Google Chrome 的 Web 开发者工具是内置的。

  2. 在 Google Chrome 中打开 JBCP 日历主页。

  3. 接下来,从主菜单中,导航到编辑 | 首选项 | 底层设置。在隐私类别下,点击内容设置...按钮。接下来,在 Cookie 设置中,点击所有 Cookie 和站点数据...按钮。最后,在搜索框中输入localhost,如下所示:

  1. 选择 JSESSIONID cookie,将内容值复制到剪贴板,并登录 JBCP 日历应用程序。如果您重复查看 Cookie 信息命令,您会发现您登录后 JSESSIONID 没有改变,使您容易受到会话固定攻击!

  2. 在 Firefox 中,打开 JBCP 日历网站。您会被分配一个会话 cookie,您可以通过按 Ctrl + F2 打开底部的 Cookie 控制台来查看,然后输入 cookie list [enter] 以显示当前页面的 cookie。

  3. 为了完成我们的黑客攻击,我们将点击编辑 Cookie 选项,并粘贴我们从 Google Chrome 复制到剪贴板的 JSESSIONID cookie,如下图所示:

  1. 请记住,最新版本的 Firefox 也包括网络开发者工具。但是,您需要确保您使用的是扩展程序,而不是内置的,因为它提供了额外的功能。

我们的会话固定黑客攻击完成了!如果您现在在 Firefox 中重新加载页面,您将看到您以使用 Google Chrome 登录的同一用户身份登录,但不知道用户名和密码。您担心恶意用户了吗?

现在,重新启用会话固定保护并再次尝试此练习。您将看到,在这种情况下,用户登录后 JSESSIONID 发生了变化。根据我们对会话固定攻击发生方式的理解,这意味着我们已将不知情的用户成为这种攻击受害者的可能性降低。干得好!

谨慎的开发人员应该注意,窃取会话 cookie 有很多方法,其中一些(如 XSS)可能会使即使启用了会话固定保护的网站也变得脆弱。请咨询 OWASP 网站,以获取有关预防这类攻击的额外资源。

比较会话固定保护选项

session-fixation-protection 属性的以下三个选项允许您更改其行为,如下所示:

属性值 描述
none() 此选项禁用会话固定保护(除非其他 sessionManagement() 属性非默认),并且不配置 SessionManagementFilter
migrateSession() 当用户认证并分配新会话时,确保将旧会话的所有属性移动到新会话。
newSession() 当用户认证成功后,将创建一个新会话,不会迁移旧会话(未认证)的任何属性。

在大多数情况下,migrateSession() 的默认行为对于希望在用户认证后保留用户会话重要属性(如点击兴趣和购物车)的网站将是适当的。

限制每个用户的并发会话数

在软件行业,软件通常按用户数出售。这意味着,作为软件开发者,我们有兴趣确保每个用户只存在一个会话,以防止账户共享。Spring Security 的并发会话控制确保单一用户不能同时拥有超过固定数量的活跃会话(通常是 1 个)。确保这个最大限制得到执行涉及几个组件协同工作,以准确追踪用户会话活动的变化。

让我们配置这个特性,回顾一下它如何工作,然后测试它!

配置并发会话控制

既然我们已经理解了并发会话控制中涉及的不同组件,那么设置它应该更有意义。让我们查看以下步骤来配置并发会话控制:

  1. 首先,你按照如下方式更新你的security.xml文件:
        // src/main/java/com/packtpub/springsecurity/configuration/
        SecurityConfig.java

        http.sessionManagement().maximumSessions(1)
  1. 接下来,我们需要在SecurityConfig.java部署描述符中启用o.s.s.web.session.HttpSessionEventPublisher,以便 Servlet 容器将通过HttpSessionEventPublisher通知 Spring Security 关于会话生命周期事件,如下所示:
        // src/main/java/com/packtpub/springsecurity/configuration/ 
        SecurityConfig.java

        @Bean
        public HttpSessionEventPublisher httpSessionEventPublisher() {
            return new HttpSessionEventPublisher();
        }

有了这两个配置项,并发会话控制现在将被激活。让我们看看它实际做了什么,然后我们将展示如何测试它。

理解并发会话控制

并发会话控制使用o.s.s.core.session.SessionRegistry来维护一个活跃 HTTP 会话列表以及与之关联的认证用户。当会话被创建和过期时,注册表会根据HttpSessionEventPublisher发布的会话生命周期事件实时更新,以跟踪每个认证用户的活跃会话数量。

请参考以下图表:

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 中执行任何操作。你会看到一个指示你的会话已过期的消息,如下面的屏幕截图所示:

如果你在使用这个应用程序时收到这条消息,你可能会感到困惑。这是因为显然这并不是一种友好的方式,用来通知一次只能有一个用户访问应用程序。然而,它确实说明会话已被软件强制过期。

并发会话控制对于新接触 Spring Security 的用户来说通常是一个很难理解的概念。许多用户试图在不真正理解它如何工作以及它的好处的情况下实现它。如果你正在尝试启用这个强大的功能,但它似乎并没有像你期望的那样工作,请确保你已经正确配置了所有内容,然后回顾本节中的理论解释-希望它们能帮助你理解可能出错了什么!

当会话过期事件发生时,我们可能需要将用户重定向到登录页面,并给他们一个消息来指出出了什么问题。

配置过期会话重定向

幸运的是,有一个简单的方法可以将用户重定向到一个友好的页面(通常是登录页面),当他们在并发会话控制中被标记时-只需指定expired-url属性,并将其设置为应用程序中的有效页面。如下更新你的security.xml文件:

    //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java

    http.sessionManagement()
    .maximumSessions(1)
 .expiredUrl("/login/form?expired")    ;

在我们的应用程序的情况下,这将把用户重定向到标准的登录表单。然后我们将使用查询参数来显示一个友好的消息,表明我们确定他们有多个活动会话,应该重新登录。更新你的login.html页面,使用此参数来显示我们的消息:

    //src/main/resources/templates/login.html

    ...
    <div th:if="${param.expired != null}" class="alert alert-success">
    <strong>Session Expired</strong>
   <span>You have been forcibly logged out due to multiplesessions 
   on the same account (only one activesession per user is allowed).</span>
   </div>
    <label for="username">Username</label>

然后尝试通过在 Google Chrome 和 Firefox 中分别以admin1@example.com/admin1的身份登录用户。这次,你应该会看到一个带有自定义错误消息的登录页面。

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

并发控制常见问题

登录同一用户时不会触发登出事件的原因有几个。第一个原因是在使用自定义UserDetails(如我们在第三章,自定义认证中做的那样)时,而equalshashCode方法没有得到正确实现。这是因为默认的SessionRegistry实现使用内存映射来存储UserDetails。为了解决这个问题,你必须确保你已经正确实现了hashCode和 equals 方法。

第二个问题发生在重启应用程序容器时,而用户会话被持久化到磁盘上。当容器重新启动后,已经使用有效会话登录的用户将登录。然而,用于确定用户是否已经登录的SessionRegistry内存映射将会是空的。这意味着 Spring Security 会报告用户没有登录,尽管用户实际上已经登录了。为了解决这个问题,需要一个自定义的SessionRegistry,同时禁用容器内的会话持久化,或者你必须实现一个特定于容器的解决方案,以确保在启动时将持久化的会话填充到内存映射中。

另一个原因是,在撰写本文时,对于记住我功能还没有实现并发控制。如果用户使用记住我功能进行身份验证,那么这种并发控制将不会被强制执行。有一个 JIRA 问题是用来实现这个功能的,如果你的应用程序需要记住我功能和并发控制,那么请参考它以获取任何更新:jira.springsource.org/browse/SEC-2028

我们将要讨论的最后一个常见原因是,在默认的SessionRegistry实现下,并发控制在集群环境中将无法工作。如前所述,默认实现使用一个内存映射。这意味着如果user1登录到应用程序服务器 A,他们登录的事实将与该服务器相关联。因此,如果user1然后认证到应用程序服务器 B,之前关联的认证对应用程序服务器 B 来说是未知的。

阻止认证,而不是强制登出

Spring Security 还可以阻止用户如果已经有一个会话的情况下登录到应用程序。这意味着,Spring Security 不是强制原始用户登出,而是阻止第二个用户登录。配置更改如下所示:

    //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java

    http.sessionManagement()
    .maximumSessions(1)
    .expiredUrl("/login/form?expired")
 .maxSessionsPreventsLogin(true);

进行更新后,使用 Google Chrome 登录日历应用程序。现在,尝试使用相同的用户名尝试使用 Firefox 登录日历应用程序。你应该会看到我们自定义的错误信息,来自我们的login.html文件。

你的代码现在应该看起来像chapter14.04-calendar

这种方法的缺点可能不经过深思熟虑不容易看出。试着在不登出的情况下关闭 Google Chrome,然后再次打开它。现在,尝试再次登录应用程序。你会观察到无法登录。这是因为当浏览器关闭时,JSESSIONID cookie 被删除。然而,应用程序并不知道这一点,所以用户仍然被认为是认证的。你可以把这看作是一种内存泄漏,因为HttpSession仍然存在,但是没有指向它(JSESSIONID cookie 已经消失了)。直到会话超时,我们的用户才能再次认证。幸运的是,一旦会话超时,我们的SessionEventPublisher接口将把用户从我们的SessionRegistry接口中移除。我们从这一点可以得出的结论是,如果用户忘记登出并关闭浏览器,他们将无法再次登录应用程序,直到会话超时。

就像在第七章 记住我服务 中一样,这个实验如果浏览器在关闭后决定记住一个会话,可能就不会工作。通常,如果插件或浏览器被配置为恢复会话,这种情况会发生。在这种情况下,你可能想手动删除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();
        }
  1. 我们已经将SessionConfig.java文件的导入添加到了SecurityConfig.java文件中。所以,我们只需要在我们的SecurityConfig.java文件中引用自定义配置。用以下代码片段替换当前的sessionManagementmaximumSessions配置:
        //src/main/java/com/packtpub/springsecurity/configuration/
        SecurityConfig.java

        http.sessionManagement()
        .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;
    @Autowired
     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";
     }
      @DeleteMapping(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 MVC 自动获取当前的 Spring Security Authentication。如果我们没有使用 Spring MVC,我们也可以从SecurityContextHolder获取当前的Authentication,如在第三章中自定义认证所讨论的。然后使用主体来获取当前用户的所有SessionInformation对象。通过遍历我们sessions.html文件中的SessionInformation对象,如下所示,轻松显示信息:

//src/main/resources/templates/sessions.html

...
<tr th:each="session : ${sessions}">
<td th:text="${#calendars.format(session.lastRequest, 'yyyy-MM-dd HH:mm')}">
</td>
<td th:text="${session.sessionId}"></td>
<td>
<form action="#" th:action="@{'/user/sessions/{id}'(id=${session.sessionId})}"
th:method="delete" cssClass="form-horizontal">
<input type="submit" value="Delete" class="btn"/>
</form>
</td>
</tr>
...

现在你可以安全地启动 JBCP 日历应用程序,并使用user1@example.com/user1在 Google Chrome 中登录。然后,使用 Firefox 登录,并点击右上角的user1@example.com链接。接下来,您将在显示上看到两个会话列表,如下面的屏幕截图所示:

在 Firefox 中,点击第一个会话的删除按钮。这会将请求发送到我们UserSessionsControllerdeleteSession方法。这表示会话应该被终止。现在,在 Google Chrome 内导航到任何页面。您将看到自定义消息,称会话已被强制终止。虽然消息可以更新,但我们看到这对于用户终止其他活动会话是一个很好的功能。

其他可能的用途包括允许管理员列出和管理所有活动会话,显示网站上的活动用户数,甚至扩展信息以包括诸如 IP 地址或位置信息之类的内容。

Spring Security 如何使用 HttpSession 方法?

我们已经讨论过 Spring Security 如何使用SecurityContextHolder来确定当前登录的用户。然而,我们还没有解释 Spring Security 是如何自动填充SecurityContextHolder的。这个秘密在于o.s.s.web.context.SecurityContextPersistenceFilter过滤器和o.s.s.web.context.SecurityContextRepository接口。让我们来看看下面的图表:

下面是对前述图表中每个步骤的解释:

  1. 在每次网络请求的开始,SecurityContextPersistenceFilter负责通过SecurityContextRepository获取当前的SecurityContext实现。

  2. 紧接着,它在SecurityContextHolder上设置了SecurityContext

  3. 对于随后的网络请求,SecurityContext可以通过SecurityContextHolder获得。例如,如果一个 Spring MVC 控制器或CalendarService想要访问SecurityContext,它可以通过SecurityContextHolder来访问。

  4. 然后,在每个请求的末尾,SecurityContextPersistenceFilterSecurityContextHolder中获取SecurityContext

  5. 紧接着,SecurityContextPersistenceFilter在每次请求结束时将SecurityContext保存到SecurityContextRepository中。这确保了如果在 web 请求期间的任何时刻更新了SecurityContext(也就是说,如在第三章 自定义认证中用户创建新账户时),SecurityContext会被保存。

  6. 最后,SecurityContextPersistenceFilter清除了SecurityContextHolder

现在产生的问题是这与HttpSession有什么关系?这一切都是通过默认的SecurityContextRepository实现联系在一起的,该实现使用HttpSession

HttpSessionSecurityContextRepository 接口

默认实现的SecurityContextRepositoryo.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

在实践中,控制会话的创建可能比最初看起来要困难。这是因为属性只控制了 Spring Security 对HttpSession使用的一部分。它不适用于应用程序中的其他组件,比如 JSP。为了帮助找出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
        @Enable WebSecurity(debug = true)
        public class SecurityConfig extends WebSecurityConfigurerAdapter {
           ...
          http.sessionManagement()
         .sessionCreationPolicy(SessionCreationPolicy.NEVER);
  1. 启动应用程序时,你应该会看到类似以下代码写入标准输出。如果你还没有做,确保你已经为 Spring Security 调试器类别启用日志记录:
            ********************************************************************  
            **********       Security debugging is enabled.             *************
            **********   This may include sensitive information.     *************
            **********     Do not use in a production system!         *************
            ********************************************************************
  1. 现在,清除你的 cookies(这可以在 Firefox 中通过Shift + Ctrl + Delete完成),启动应用程序,直接导航到http://localhost:8080。当我们像章节早期那样查看 cookies 时,我们可以看到尽管我们声明 Spring Security 不应该创建HttpSession,但JSESSIONID仍然被创建了。再次查看日志,你会看到创建HttpSession的代码调用栈如下:
            ************************************************************
            2017-07-25 18:02:31.802 INFO 71368 --- [nio-8080-exec-1] 
            Spring Security Debugger                 :
            ************************************************************
            New HTTP session created: 2A708D1C3AAD508160E6189B69D716DB
  1. 在这个实例中,我们的 JSP 页面负责创建新的HttpSession方法。实际上,所有 JSP 默认都会创建新的HttpSession方法,除非你在每个 JSP 文件的顶部包含以下代码:
        <%@ page session="false" %>

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

  • 同步器令牌

  • 点击劫持

我们将了解如何使用以下方法包含各种 HTTP 头以保护常见安全漏洞:

  • Cache-Control

  • Content-Type Options

  • HTTP 严格传输安全HSTS

  • X-Frame-Options

  • X-XSS-Protection

在阅读这一章之前,你应该已经对 Spring Security 的工作原理有了了解。这意味着你应该已经能够在一个简单的 web 应用程序中设置身份验证和授权。如果你还不能做到这一点,你需要在继续学习这一章之前确保你已经阅读了 第三章,自定义身份验证。如果你牢记 Spring Security 的基本概念并且理解你正在集成的框架,那么集成其他框架就相对简单了。

安全漏洞

在互联网时代,有很多可能被利用的漏洞。要了解更多关于基于 web 的漏洞,一个很好的资源是开放网络应用安全项目OWASP),它的网址是 www.owasp.org.

除了是一个了解各种漏洞的伟大资源外,OWASP 还根据行业趋势对最 10 个漏洞进行了分类。

跨站脚本攻击

跨站脚本攻击涉及已经被注入到信任网站的恶意脚本。

XSS 攻击发生在一个攻击者利用一个允许未经审查的输入发送到网站的给定 web 应用程序时,通常以基于浏览器的脚本的形式,然后由网站的不同用户执行。

基于向网站提供验证过的或未编码的信息,攻击者可以利用很多形式。

这个问题核心在于期望用户信任网站发送的信息。最终用户的浏览器没有办法知道这个脚本不应该被信任,因为它来自一个它们正在浏览的网站。因为它认为脚本来自一个信任的来源,恶意脚本就可以访问浏览器中保留的与该网站一起使用的任何 cookie、会话令牌或其他敏感信息。

XSS 攻击可以通过以下序列图来描述:

跨站请求伪造

CSRF 攻击通过诱骗受害者提交恶意请求来攻击受害者。这种攻击继承或劫持受害者的身份和特权,并在受害者的名义上执行未经授权的功能和访问。

对于网络应用程序,大多数浏览器会自动包含与该网站关联的凭据,这包括用户会话、Cookie、IP 地址、Windows 域凭据等等。

因此,如果一个用户当前在一个网站上已认证,那么该网站将无法区分由受害者发送的伪造请求和合法的法院请求。

CSRF 攻击针对的是在服务器上引起状态变化的功能,比如更改受害者的电子邮件地址或密码,或者进行金融交易。

这迫使受害者获取对攻击者不利的数据,因为攻击者不会收到响应;受害者会。因此,CSRF 攻击针对的是状态更改请求。

以下序列图详细说明了 CSRF 攻击是如何发生的:

为了尝试防止 CSRF,可以采取几种不同的设计措施,然而,诸如秘密 Cookie、HTTP POST 请求、多步骤交易、URL 重写和 HTTPS 等措施,绝不可能防止此类攻击。

OWASP 的前 10 大安全漏洞列表详细介绍了 CSRF,作为第八常见的攻击,详情请见www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)

同步器令牌

解决这个问题的一种方法是使用同步器令牌模式。这个解决方案要求每个请求除了我们的会话 Cookie 外,还需要一个作为 HTTP 参数的随机生成的令牌。当提交一个请求时,服务器必须查找参数的预期值并将其与请求中的实际值进行比较。如果值不匹配,请求应该失败。

《跨站请求伪造(CSRF)预防速查表》建议使用同步器令牌模式作为防止 CSRF 攻击的可行解决方案:www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet#General_Recommendation:_Synchronizer_Token_Pattern

放宽期望值意味着只要求每个更新状态的 HTTP 请求中包含令牌。由于同源策略可以确保恶意网站无法读取响应,因此这样做是安全的。另外,我们不希望在每个 HTTP GET请求中包含随机令牌,因为这可能导致令牌泄露。

让我们看看例子会如何改变。假设生成的随机令牌以 HTTP 参数named _csrf的形式存在。例如,转账请求如下所示:

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参数的正确值(必须在恶意网站上显式提供)并且在服务器将实际令牌与预期令牌比较时,传输将会失败。

以下图表显示了同步令牌模式的标准用例:

在 Spring Security 中的同步器令牌支持

Spring Security 提供了默认启用的同步器令牌支持。您可能在前几章中注意到,在我们的SecurityConfig.java文件中,我们禁用了 CSRF 保护,如下面的代码片段所示:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java

protected void configure(HttpSecurity http) throws Exception {
...
// CSRF protection is enabled by default.
http.csrf().disable(); ...
}

到目前为止,在本书中,我们已经禁用了同步器令牌保护,以便我们可以专注于其他安全问题。

如果我们在这个时候启动应用程序,我们可以走过安全流程,但不会有任何页面的同步器令牌支持被添加。

您应该从chapter16.00-calendar的代码开始。

何时使用 CSRF 保护

建议您对任何可以由浏览器或普通用户处理的请求使用 CSRF 保护。如果您只是创建一个被非浏览器客户端使用的服务,您很可能会想要禁用 CSRF 保护。

CSRF 保护与 JSON

一个常见的问题是:我需要为 JavaScript 发出的 JSON 请求提供保护吗?简短的答案是,视情况而定。然而,您必须非常小心,因为存在可以影响 JSON 请求的 CSRF 利用方式。例如,恶意用户可以使用以下表单创建一个 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>This will produce the following JSON structure{ "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 与无状态浏览器应用程序

如果您的应用程序是无状态的,那并不意味着您就受到了保护。实际上,如果用户不需要在网页浏览器中为特定请求执行任何操作,他们仍然可能受到 CSRF 攻击的威胁。

例如,考虑一个使用自定义 cookie 的应用程序,它包含所有认证状态,而不是JSESSIONIDcookie。当发生 CSRF 攻击时,自定义 cookie 将按照我们之前例子中JSESSIONIDcookie 的方式随请求发送。

使用基本认证的用户也容易受到 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.w3.org/Protocols/rfc2616/rfc2616-sec15.html#sec15.1.3)。

配置 CSRF 保护

下一步是在你的应用程序中包含 Spring Security 的 CSRF 保护。一些框架通过使用户会话无效来处理无效的 CSRF 令牌,但这会带来它自己的问题。相反,默认情况下,Spring Security 的 CSRF 保护将产生 HTTP 403 禁止访问。这可以通过配置AccessDeniedHandler以不同的方式处理InvalidCsrfTokenException来自定义。

出于被动原因,如果你使用 XML 配置,必须使用<csrf>元素显式启用 CSRF 保护。查阅<csrf>元素的文档以获取其他自定义设置。

SEC-2347 被记录下来,以确保 Spring Security 4.x 的 XML 命名空间配置将默认启用 CSRF 保护(github.com/spring-projects/spring-security/issues/2574)。

默认的 CSRF 支持

使用 Java 配置时,CSRF 保护默认启用。查阅csrf()的 Javadoc 以获取有关如何配置 CSRF 保护的其他自定义设置。

为了在这个配置中详细说明,我们将在SecurityConfig.java文件中添加 CSRS 方法,如下所示:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java
    @Override
    public void configure(HttpSecurity http) throws Exception {
 http.csrf();    }

提交中包含 CSRF 令牌

最后一步是确保你在所有的PATCHPOSTPUTDELETE方法中包含 CSRF 令牌。一种实现方法是使用_csrf请求属性来获取当前的CsrfToken令牌。以下是在 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>

使用 Spring Security JSP 标签库包含 CSRF 令牌

如果启用了 CSRF 保护,此标记将插入一个带有正确名称和值的秘密表单字段,以供 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+,并且你用@EnableWebMvcSecurity替换@EnableWebSecurityCsrfToken令牌会自动包含在内(我们一直在处理CsrfRequestDataValue令牌)。

因此,在这本书中,我们已经使用 Thymeleaf 为所有的网页页面。如果我们启用 Spring Security 中的 CSRF 支持,Thymeleaf 默认具有 CSRF 支持。

你应该从chapter16.01-calendar的代码开始。

如果我们启动 JBCP 日历应用程序并导航到登录页面https://localhost:8443/login.html,我们可以查看生成的login.html页面的源代码,如下所示:

    <form method="POST" action="/login" ...>
      ...
 <input type="hidden" name="_csrf" value="e86c9744-5b7d-4d5f-81d5-450463222908">
    </form>

Ajax 和 JSON 请求

如果你使用 JSON,那么不可能在 HTTP 参数中提交 CSRF 令牌。相反,你可以在 HTTP 头中提交令牌。一个典型的模式是将 CSRF 令牌包括在你的<meta>HTML 标签中。一个在 JSP 中的示例如下:

    <html>
       <head>
 <meta name="_csrf" content="${_csrf.token}"/>         <!-- default header name is X-CSRF-TOKEN -->
 <meta name="_csrf_header" content="${_csrf.headerName}"/>         ...
       </head>
     ¦

instead of manually creating the meta tags, you can use the simpler csrfMetaTags tag from the Spring Security JSP tag library.

csrfMetaTags标签

如果启用了 CSRF 保护,这个标签将插入包含 CSRF 保护令牌表单字段、头部名称和 CSRF 保护令牌值的元标签。这些元标签对于在应用程序中的 JavaScript 中使用 CSRF 保护非常有用。

你应该在 HTML <head></head>块中放置csrfMetaTags标签,你通常会在其他元标签中放置其他元标签。一旦使用这个标签,你可以轻松地使用 JavaScript 访问表单字段名、头部名称和令牌值,如下所示:

<html>
   <head>
       ...
 <sec:csrfMetaTags />       <script type="text/javascript" language="javascript">
 var csrfParameter = $("meta[name='_csrf_parameter']").attr("content"); var csrfHeader = $("meta[name='_csrf_header']").attr("content"); var csrfToken = $("meta[name='_csrf']").attr("content");           ...
       <script>
   </head>
   ...

如果未启用 CSRF 保护,csrfMetaTags不会输出任何内容。

jQuery 使用

You can then include the token within all of your Ajax requests. If you were using jQuery, this could be done with the following code snippet:

$(function () {
var token = $("meta[name='_csrf']").attr("content");
var header = $("meta[name='_csrf_header']").attr("content");
$(document).ajaxSend(function(e, xhr, options) {
   xhr.setRequestHeader(header, token);
});
});

使用 cujoJS 的 rest.js 模块

作为 jQuery 的替代品,我们建议使用 cujoJS 的rest.js模块。rest.js模块提供了高级支持,用于以 RESTful 方式处理 HTTP 请求和响应。其核心功能是能够对 HTTP 客户端进行上下文化处理,通过将拦截器链接到客户端来添加所需的行为,如下所示:

    var client = rest.chain(csrf, {
    token: $("meta[name='_csrf']").attr("content"),
    name: $("meta[name='_csrf_header']").attr("content")
    });

配置的客户端可以与应用程序中需要对 CSRF 受保护资源进行请求的任何组件共享。rest.js与 jQuery 之间的一个重要区别是,仅使用配置的客户端发出的请求将包含 CSRF 令牌,而在 jQuery 中,所有请求都将包含令牌。能够确定哪些请求接收到令牌有助于防止泄露 CSRF 令牌给第三方。

有关rest.js的更多信息,请参考rest.js参考文档。

(github.com/cujojs/rest/tree/master/docs).

CSRF 注意事项

在 Spring Security 中实现 CSRF 时,有几个注意事项你需要知道。

超时

一个问题是在HttpSession方法中存储了预期的 CSRF 令牌,所以一旦HttpSession方法过期,您配置的AccessDeniedHandler处理器将会接收到InvalidCsrfTokenException。如果您正在使用默认的AccessDeniedHandler处理器,浏览器将得到一个 HTTP 403,并显示一个糟糕的错误信息。

你可能会问为什么预期的CsrfToken令牌不是存储在 cookie 中。这是因为已知存在一些利用方式,其中头部(指定 cookie)可以由另一个域设置。

这是同样的原因,Ruby on Rails 不再在存在 X-Requested-With 头时跳过 CSRF 检查 (weblog.rubyonrails.org/2011/2/8/csrf-protection-bypass-in-ruby-on-rails/).

Web 应用安全委员会(Web Application Security Consortium) (www.webappsec.org)有一个详细的线程,讨论使用 CSRF 和 HTTP 307 重定向来执行 CSRF cookie 利用。

有关如何执行利用的具体细节,请参阅这个www.webappsec.org线程:lists.webappsec.org/pipermail/websecurity_lists.webappsec.org/2011-February/007533.html

另一个缺点是,通过移除状态(超时),您失去了在某些东西被泄露时强制终止令牌的能力。

减轻活动用户遇到超时的一种简单方法是有一些 JavaScript,让用户知道他们的会话即将过期。用户可以点击一个按钮来继续并刷新会话。

另外,指定一个自定义的AccessDeniedHandler处理器可以让您以任何喜欢的方式处理InvalidCsrfTokenException,正如我们接下来代码中所看到的:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java

@Override
public void configure(HttpSecurity http) throws Exception {
 http.exceptionHandling() .accessDeniedHandler(accessDeniedHandler); }
@Bean
public CustomAccessDeniedHandler accessDeniedHandler(){
 return new CustomAccessDeniedHandler(); }

登录

为了防止伪造登录请求,登录表单也应该受到 CSRF 攻击的保护。由于CsrfToken令牌存储在HttpSession中,这意味着一旦访问CsrfToken属性,就会立即创建一个HttpSession方法。虽然这在 RESTful/无状态架构中听起来很糟糕,但现实是状态是实现实际安全所必需的。如果没有状态,如果令牌被泄露,我们无能为力。实际上,CSRF 令牌的大小相当小,对架构的影响应该可以忽略不计。

攻击者可能会伪造一个请求,使用攻击者的凭据将受害者登录到目标网站,这被称为登录 CSRF(en.wikipedia.org/wiki/Cross-site_request_forgery#Forging_login_requests)。

登出

添加 CSRF 将更新LogoutFilter过滤器,使其只使用 HTTPPOST。这确保了登出需要 CSRF 令牌,并且恶意用户不能强制登出您的用户。

一种方法是使用<form>标签进行登出。如果你想要一个 HTML 链接,你可以使用 JavaScript 让链接执行 HTTPPOST(可以是一个隐藏的表单)。对于禁用 JavaScript 的浏览器,你可以选择让链接带用户到执行 HTTPPOST的登出确认页面。

如果你想使用 HTTPGET进行登出,你可以这样做,但请记住,这通常不推荐。例如,以下 Java 配置将在任何 HTTP 方法请求登出 URL 模式时执行登出:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java

@Override
protected void configure(HttpSecurity http) throws Exception {
 http.logout() .logoutRequestMatcher( new AntPathRequestMatcher("/logout")); }

安全 HTTP 响应头

下面的部分讨论了 Spring Security 为将各种安全头添加到响应中提供的支持。

默认安全头

Spring Security 允许用户轻松地注入默认的安全头,以帮助保护他们的应用程序。以下是由 Spring Security 提供的当前默认安全头的列表:

  • Cache-Control

  • Content-Type Options

  • HTTP 严格传输安全

  • X-Frame-Options

  • X-XSS-Protection

虽然每个头都被认为是最佳实践,但应注意的是,并非所有客户端都使用这些头,因此鼓励进行额外测试。出于被动原因,如果你使用 Spring Security 的 XML 命名空间支持,你必须显式启用安全头。所有默认头都可以通过没有子元素的<headers>元素轻松添加。

SEC-2348被记录下来,以确保 Spring Security 4.x 的 XML 命名空间配置将默认启用安全头(github.com/spring-projects/spring-security/issues/2575)。

如果你使用 Spring Security 的 Java 配置,所有的默认安全头都会被默认添加。它们可以通过 Java 配置禁用,如下所示:

//src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java

@Override
protected void configure(HttpSecurity http) throws Exception {
 http.headers().disable(); }

下面的代码将安全头添加到响应中。当使用WebSecurityConfigurerAdapter的默认构造函数时,这是默认激活的。接受WebSecurityConfigurerAdapter提供的默认值,或者只调用headers()方法而不调用其他方法,等效于以下代码片段:

@Override
protected void configure(HttpSecurity http) throws Exception {
   http
 .headers() .contentTypeOptions() .and() .xssProtection() .and() .cacheControl() .and() .httpStrictTransportSecurity() .and() .frameOptions()         .and()
     ...;
}

一旦你指定了任何应该包括的头,那么只有这些头会被包括。例如,以下配置仅包括对 X-Frame-Options 的支持:

@Override
protected void configure(HttpSecurity http) throws Exception {
   ...
 http.headers().frameOptions(); }

Cache-Control

在过去,Spring Security 要求你必须为你的网络应用程序提供自己的 Cache-Control 方法。当时这看起来是合理的,但是浏览器缓存已经发展到包括对安全连接的缓存。这意味着一个用户可能查看一个认证过的页面,登出后,恶意用户就可以利用浏览器历史记录来查看缓存的页面。

为了帮助减轻这个问题,Spring Security 增加了对 Cache-Control 的支持,它将以下头部信息插入到你的响应中:

Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0

仅仅添加 headers() 方法而没有子元素将会自动添加 Cache-Control 和其他很多保护选项。然而,如果你只想要 Cache-Control,你可以使用 Spring Security 的 Java 配置方法,如下所示:

@Override
protected void configure(HttpSecurity http) throws Exception {
   http.headers()
 .cacheControl(); }

如果你想要缓存特定的响应,你的应用程序可以选择性地调用 HttpServletResponse.setHeader(String,String) 来覆盖 Spring Security 设置的头部。这对于确保诸如 CSS、JavaScript 和图片等被正确缓存很有用。

在使用 Spring Web MVC 时,这通常是在你的配置中完成的。例如,以下配置将确保为你的所有资源设置缓存头部:

@EnableWebMvc
public class WebMvcConfiguration
extends WebMvcConfigurerAdapter {
   @Override
   public void addResourceHandlers(
                   ResourceHandlerRegistry registry) {
 registry .addResourceHandler("/resources/**") .addResourceLocations("/resources/") .setCachePeriod(3_155_6926);   }
   // ...
}

Content-Type 选项

历史上,包括 Internet Explorer 在内的浏览器会尝试使用内容嗅探来猜测请求的内容类型。这允许浏览器通过猜测未指定内容类型的资源的內容类型来改进用户体验。例如,如果浏览器遇到一个没有指定内容类型的 JavaScript 文件,它将能够猜测内容类型并执行它。

还有许多其他的事情需要做,比如只在一个独特的域中显示文档,确保设置 Content-Type 头部,对文档进行清理等等,当允许内容上传时。然而,这些措施超出了 Spring Security 提供的范围。重要的是指出,在禁用内容嗅探时,你必须指定内容类型,以便一切正常工作。

内容嗅探的问题在于,这允许恶意用户使用多语言(一个可以作为多种内容类型有效的文件)来执行 XSS 攻击。例如,一些网站可能允许用户向网站提交一个有效的 PostScript 文档并查看它。恶意用户可能会创建一个同时是有效的 JavaScript 文件的 PostScript 文档,并利用它执行 XSS 攻击(webblaze.cs.berkeley.edu/papers/barth-caballero-song.pdf)。

可以通过向我们的响应中添加以下头部来禁用内容嗅探:

    X-Content-Type-Options: nosniff

Cache-Control元素一样,nosniff指令在没有子元素的情况下使用headers()方法时默认添加。在 Spring Security Java 配置中,X-Content-Type-Options头默认添加。如果您想对头部有更精细的控制,您可以显式指定内容类型选项,如下代码所示:

@Override
protected void configure(HttpSecurity http) throws Exception {
   http.headers()
       .contentTypeOptions();
}

HTTP 严格传输安全

当您输入您的银行网站时,您是输入mybank.example.com,还是输入https://mybank.example.com?如果您省略了 HTTPS 协议,您可能会受到中间人攻击的潜在威胁。即使网站执行重定向到https://**my**bank.example.com,恶意用户仍然可以拦截最初的 HTTP 请求并操纵响应(重定向到https://**mi**bank.example.com并窃取他们的凭据)。

许多用户省略了 HTTPS 协议,这就是为什么创建了 HSTS。

根据RFC6797,HSTS 头部仅注入到 HTTPS 响应中。为了使浏览器认可该头部,浏览器必须首先信任签署用于建立连接的 SSL 证书的 CA,而不仅仅是 SSL 证书(tools.ietf.org/html/rfc6797)。

一旦mybank.example.com被添加为 HSTS 主机,浏览器就可以提前知道任何对mybank.example.com的请求都应该被解释为https://mybank.example.com。这大大减少了发生中间人攻击的可能性。

一个网站被标记为 HSTS 主机的途径之一是将主机预加载到浏览器中。另一个途径是在响应中添加Strict-Transport-Security头部。例如,下面的内容将指导浏览器将域名视为 HSTS 主机一年(一年大约有31,536,000秒):

    Strict-Transport-Security: max-age=31536000 ; includeSubDomains

可选的includeSubDomains指令告知 Spring Security,子域名(如secure.mybank.example.com)也应该被视为一个 HSTS 域名。

与其它头部一样,当在headers()方法中没有子元素指定时,Spring Security 将前一个头部添加到响应中,但当你使用 Java 配置时,它会自动添加。您还可以仅使用 HSTS 头部与hsts()方法一起使用,如下面的代码所示:

@Override
protected void configure(HttpSecurity http) throws Exception {
   http.headers()
 .hsts(); }

X-Frame-Options

允许您的网站被添加到框架中可能是一个安全问题。例如,通过巧妙的 CSS 样式,用户可能会被诱骗点击他们本不想点击的东西。

www.youtube.com/watch?v=3mk0RySeNsU观看 Clickjacking 视频演示。

例如,一个已登录其银行的用户的可能会点击一个授予其他用户访问权限的按钮。这种攻击称为 Clickjacking。

www.owasp.org/index.php/Clickjacking阅读更多关于 Clickjacking 的信息。

处理 Clickjacking 的另一种现代方法是使用内容安全策略。Spring Security 不提供对此的支持,因为该规范尚未发布,而且相当复杂。然而,你可以使用静态头功能来实现这一点。要了解此问题的最新动态以及如何使用 Spring Security 实现它,请参阅SEC-2117github.com/spring-projects/spring-security/issues/2342

有许多方法可以缓解 Clickjacking 攻击。例如,为了保护老式浏览器不受 Clickjacking 攻击,你可以使用破帧代码。虽然不是完美的,但破帧代码对于老式浏览器来说是最好的做法。

解决 Clickjacking 的更现代方法是使用X-Frame-Options头,如下所示:

    X-Frame-Options: DENY

X-Frame-Options响应头指示浏览器防止任何在响应中包含此头的站点被渲染在框架内。与其他响应头一样,当没有子元素的headers()方法被指定时,此头会自动包含。你还可以明确指定 frame-options 元素以控制要添加到响应中的哪些头,如下所示:

@Override
protected void configure(HttpSecurity http) throws Exception {
   http.headers()
 .frameOptions(); }

如果你想要更改X-Frame-Options头的值,那么你可以使用一个XFrameOptionsHeaderWriter实例。

一些浏览器内置了对过滤掉反射型 XSS 攻击的支持。这绝不是万无一失的,但它确实有助于 XSS 保护。

过滤通常默认启用,因此添加头 just 确保它已启用,并指示浏览器在检测到 XSS 攻击时应该做什么。例如,过滤器可能会尝试以最不具侵入性的方式更改内容以仍然呈现一切。有时,这种类型的替换本身可能成为一个 XSS 漏洞。相反,最好阻止内容,而不是尝试修复它。为此,我们可以添加以下头:

    X-XSS-Protection: 1; mode=block

当使用headers()方法且没有子元素时,默认包含此标题。我们可以使用xssProtection元素明确地声明,如下所示:

@Override
protected void configure(HttpSecurity http) throws Exception {
   http.headers()
       .xssProtection();
}

自定义头

Spring Security 具有机制,使其方便地向你的应用程序添加更多常见的 security headers。然而,它还提供了挂载点,以启用添加自定义头。

静态头

有时你可能希望向你的应用程序中注入自定义的安全头,但这些头并不是开箱即用的。例如,也许你希望提前支持内容安全策略,以确保资源只从同一来源加载。由于内容安全策略的支持尚未最终确定,浏览器使用两个常见的扩展头之一来实现此功能。这意味着我们将需要注入策略两次。以下代码段显示了头部的示例:

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

当使用 Java 配置时,这些头可以使用header()方法添加到响应中,如下所示:

@Override
protected void configure(HttpSecurity http) throws Exception {
   http.headers()
       .addHeaderWriter(
         new StaticHeadersWriter(
               "X-Content-Security-Policy",
               "default-src 'self'"))
       .addHeaderWriter(
           new StaticHeadersWriter(
               "X-WebKit-CSP",
               "default-src 'self'"));
}

HeadersWriter实例

当命名空间或 Java 配置不支持您想要的头时,您可以创建一个自定义HeadersWriter实例,甚至提供HeadersWriter的自定义实现。

让我们来看一个使用自定义实例XFrameOptionsHeaderWriter的例子。也许你想允许相同源的内容框架。这可以通过将策略属性设置为SAMEORIGIN轻松支持,但让我们来看一个更明确的例子,使用ref属性,如下面的代码片段所示:

@Override
protected void configure(HttpSecurity http) throws Exception {
   http.headers()
       .addHeaderWriter(
           new XFrameOptionsHeaderWriter(
               XFrameOptionsMode.SAMEORIGIN));
}

DelegatingRequestMatcherHeaderWriter

有时,您可能只想为某些请求写入头。例如,也许您只想保护登录页面不被框架。您可以使用DelegatingRequestMatcherHeaderWriter类来实现。当使用 Java 配置时,可以用以下代码完成:

@Override
protected void configure(HttpSecurity http) throws Exception {
 DelegatingRequestMatcherHeaderWriter headerWriter = new DelegatingRequestMatcherHeaderWriter( new AntPathRequestMatcher("/login"), new XFrameOptionsHeaderWriter());   http.headers()
       .addHeaderWriter(headerWriter);
}

总结

在本章中,我们介绍了几种安全漏洞,并使用了 Spring Security 来规避这些漏洞。阅读本章后,你应该理解 CSRF 的威胁以及使用同步令牌来预防 CSRF。

您还应该知道如何使用Cache-ControlContent-Type Options、HSTS、X-Frame-OptionsX-XSS-Protection方法,将各种 HTTP 头包含在内,以保护免受常见安全漏洞的侵害。

在下一章中,我们将讨论如何从 Spring Security 3.x 迁移到 Spring Security 4.2。

第十五章:迁移到 Spring Security 4.2。

在本书的最后一章中,我们将回顾与从 Spring Security 3 迁移到 Spring Security 4.2 的常见迁移问题有关的信息。我们将花更多的时间讨论 Spring Security 3 和 Spring Security 4 之间的差异,因为这是大多数用户将遇到的难题。这是由于从 Spring Security 3 更新到 Spring Security 4.2 包含大量的非被动重构。

在本章末尾,我们还将突出显示 Spring Security 4.2 中可以找到的一些新功能。然而,我们并没有明确涵盖从 Spring Security 3 到 Spring Security 4.2 的变化。这是因为通过解释 Spring Security 3 和 Spring Security 4 之间的差异,用户应该能够轻松地更新到 Spring Security 4.2,因为 Spring Security 4.2 的变化是被动的。

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

  • 回顾 Spring Security 4.2 中的重要增强功能。

  • 了解您现有 Spring 版本中所需的配置更改。

  • 当将它们迁移到 Spring Security 4.2 时,回顾 Spring Security 3 应用程序。

  • 说明 Spring Security 4 中重要类和包的整体移动情况。

  • 突出显示 Spring Security 4.2 中的一些新功能。一旦完成了本章的复习,你将处于一个很好的位置,可以将从 Spring Security 3 迁移到 Spring Security 4.2 的现有应用程序。

  • 从 Spring Security 3 迁移。

你可能正在计划将一个现有应用程序迁移到 Spring Security 4.2,或者你可能正在尝试为 Spring Security 3 应用程序添加功能,并在这本书的页面中寻找指导。我们将在本章中尝试解决你们的两个问题。

首先,我们将概述 Spring Security 3 和 4.2 之间的关键差异,包括功能和配置。其次,我们将提供一些关于映射配置或类名更改的指导。这将使你更好地能够将书中的示例从 Spring Security 4.2 回退到 Spring Security 3(适用的)。

一个非常重要的迁移注意事项是,Spring Security 3+ 强制要求迁移到 Spring Framework 4 和 Java 5 (1.5) 或更高版本。请注意,在许多情况下,迁移这些其他组件可能对您的应用程序的影响比 Spring Security 的升级要大!

引言。

随着应用程序的利用方式不断发展,Spring Security 也必须做出相应的更新。在重大发布版本中,Spring Security 团队抓住了机会,进行了一些非被动的更改,主要关注以下几点:

在 JIRA 中可以找到 3.x 和 4.x 之间非被动更改的完整列表:jira.spring.io/browse/SEC-2916?jql=project%20%3D%20SEC%20AND%20fixVersion%20in%20(4.0.0%2C%204.0.0.M1%2C%204.0.0.M2%2C%204.0.0.RC1%2C%204.0.0.RC2)%20AND%20labels%20%3D%20passivity.

示例迁移

Spring Security 团队创建了一个示例项目,展示了从 3.x 迁移到 4.x 时的所有更改,并将在 GitHub 上提供该项目。

示例包括 XML 和 JavaConfig 示例,可以在github.com/spring-projects/spring-security-migrate-3-to-4/找到。

在 Spring Security 4.2 中的增强功能

在 Spring Security 4.2 中有很多值得注意的更改,此版本还带来了对 Spring Framework 5 的早期支持。你可以找到 4.2.0.M1、4.2.0.RC1 和 4.2.0.RELEASE 的更改日志,涵盖了超过 80 个问题。社区贡献了绝大多数这些功能。

在 Spring Security 4.2 中进行了重大改进,自 Spring Security 3 以来,包括以下特性和它们的支持号码:

网络改进:

以下项目与 Spring Security 与基于 Web 的应用程序的交互相关:

  • #3812: Jackson 支持

  • #4116: 引用策略

  • #3938: 添加 HTTP 响应分割预防

  • #3949: 为@AuthenticationPrincipal添加了 bean 引用支持

  • #3978: 支持使用新添加的RequestAttributeAuthenticationFilter的 Standford WebAuth 和 Shibboleth。

  • #4076: 文档代理服务器配置

  • #3795: ConcurrentSessionFilter支持InvalidSessionStrategy

  • #3904: 添加CompositeLogoutHandler

Spring Security 配置改进:

以下项目与 Spring Security 的配置相关:

  • #3956: 默认角色前缀的集中配置。详情请看问题

  • #4102: 在WebSecurityConfigurerAdapter中自定义默认配置

  • #3899: concurrency-control@max-sessions支持无限会话。

  • #4097: intercept-url@request-matcher-ref为 XML 命名空间添加了更强大的请求匹配支持

  • #3990: 支持从 Map(如 YML)构建RoleHierarchy

  • #4062: 自定义cookiePathCookieCsrfTokenRepository

  • #3794: 允许在SessionManagementConfigurer上配置InvalidSessionStrategy

  • #4020: 修复defaultMethodExpressionHandler暴露的 beans 可以防止方法安全

在 Spring Security 4.x 中的其他更改

以下项目是一些值得注意的其他更改,其中许多可能会影响升级到 Spring Security 4.x:

  • #4080: Spring 5

  • 4095 - 添加UserBuilder

  • #4018:在csrf()被调用后进行修复,未来的MockMvc调用使用原始的CsrfTokenRepository

  • 常规依赖版本更新

请注意,列出的数字指的是 GitHub 的 pull 请求或问题。

其他更微小的变化,包括代码库和框架配置的整体重构和清理,使整体结构和使用更具意义。Spring Security 的作者在登录和 URL 重定向等领域增加了可扩展性,尤其是之前不存在扩展性的地方。

如果你已经在 Spring Security 3 环境中工作,如果你没有推动框架的边界,可能不会找到升级的强烈理由。然而,如果你在 Spring Security 3 的可扩展点、代码结构或可配置性方面发现了局限性,那么你会欢迎我们在本章剩余部分详细讨论的许多小变化。

Spring Security 4 中的配置更改

Spring Security 4 中的许多变化将在基于 XML 的配置的命名空间风格中可见。本章将主要覆盖基于 Java 的配置,但也会注意一些值得注意的基于 XML 的变化。尽管本章无法详细涵盖所有的小变化,但我们将尝试涵盖那些在您迁移到 Spring Security 4 时最可能影响您的变化。

废弃内容

在 Spring Security 4 中移除了一大批废弃内容,以清理混乱。

以下是对 XML 和 JavaConfig 废弃内容的最终提交,其中包含 177 个更改文件,新增 537 处,删除 5023 处:github.com/spring-projects/spring-security/commit/6e204fff72b80196a83245cbc3bd0cd401feda00

如果你使用 XML 命名空间或基于 Java 的配置,在许多情况下,你会避免废弃问题。如果你(或你使用的非 Spring 库)没有直接使用 API,那么你将不会受到影响。你可以很容易地在你的工作区中搜索这些列出的废弃内容。

Spring Security 核心模块的废弃内容

本节描述了spring-security-core模块中所有的废弃 API。

org.springframework.security.access.SecurityConfig

SecurityConfig.createSingleAttributeList(String)接口已被SecurityConfig.createList(String¦ )取代。这意味着如果你有这样的内容:

     List<ConfigAttribute> attrs = SecurityConfig.createSingleAttributeList
     ("ROLE_USER");

它需要用以下代码替换:

    List<ConfigAttribute> attrs = SecurityConfig.createList("ROLE_USER");

UserDetailsServiceWrapper

UserDetailsServiceWrapper已被RoleHierarchyAuthoritiesMapper取代。例如,你可能有这样的内容:

@Bean
public AuthenticationManager authenticationManager(List<AuthenticationProvider> providers) {
      return new ProviderManager(providers);
}
@Bean
public AuthenticationProvider authenticationProvider(UserDetailsServiceWrapper userDetailsService) {
      DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
      provider.setUserDetailsService(userDetailsService);
      return provider;
}
@Bean
public UserDetailsServiceWrapper userDetailsServiceWrapper(RoleHierarchy roleHierarchy) {
      UserDetailsServiceWrapper wrapper = new UserDetailsServiceWrapper();
      wrapper.setRoleHierarchy(roleHierarchy);
      wrapper.setUserDetailsService(userDetailsService());
      return wrapper;
}

它需要被替换成类似这样的内容:

@Bean
public AuthenticationManager authenticationManager(List<AuthenticationProvider> providers) {
      return new ProviderManager(providers);
}
@Bean
public AuthenticationProvider authenticationProvider(UserDetailsService userDetailsService, GrantedAuthoritiesMapper authoritiesMapper) {
      DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
      provider.setUserDetailsService(userDetailsService);
      provider.setAuthoritiesMapper(authoritiesMapper);
      return provider;
}
@Bean
public RoleHierarchyAuthoritiesMapper roleHierarchyAuthoritiesMapper(RoleHierarchy roleHierarchy) {
      return new RoleHierarchyAuthoritiesMapper(roleHierarchy);
}

UserDetailsWrapper

UserDetailsWrapper因使用RoleHierarchyAuthoritiesMapper而被废弃。通常用户不会直接使用UserDetailsWrapper类。然而,如果他们这样做,他们可以使用RoleHierarchyAuthoritiesMapper,例如,下面代码可能存在:

    UserDetailsWrapper authenticate = new UserDetailsWrapper
    (userDetails, roleHiearchy);

如果如此,则需要用以下代码片段替换:

    Collection<GrantedAuthority> allAuthorities = roleHiearchy.
    getReachableGrantedAuthorities(userDetails.getAuthorities());
    UserDetails authenticate = new User(userDetails.getUsername(), 
    userDetails.getPassword(), allAuthorities);

抽象访问决策管理器

AbstractAccessDecisionManager的默认构造函数以及setDecisionVoters方法已被废弃。自然而然,这影响了AffirmativeBasedConsensusBasedUnanimousBased子类。例如,您可能使用以下代码片段:

    AffirmativeBased adm = new AffirmativeBased();
    adm.setDecisionVoters(voters);

如果如此,它需要更改为以下代码片段:

    AffirmativeBased adm = new AffirmativeBased(voters);

认证异常

AuthenticationException中接受extraInformation的构造函数已被移除,以防止意外泄露UserDetails对象。具体来说,我们移除了以下代码:

    public AccountExpiredException(String msg, Object extraInformation) {
      ...
    }

这影响了子类AccountStatusExceptionAccountExpiredExceptionBadCredentialsExceptionCredentialsExpiredExceptionDisabledExceptionLockedExceptionUsernameNotFoundException。如果您使用这些构造函数中的任何一个,只需移除附加参数。例如,以下代码片段更改了:

    new LockedException("Message", userDetails);

上述代码片段应更改为以下代码片段:

    new LockedException("Message");

匿名认证提供者

AnonymousAuthenticationProvider的默认构造函数和setKey方法因使用构造器注入而被废弃。例如,您可能有以下代码片段:

    AnonymousAuthenticationProvider provider = new 
    AnonymousAuthenticationProvider();
    provider.setKey(key);

上述代码片段应更改为以下代码:

    AnonymousAuthenticationProvider provider = new 
    AnonymousAuthenticationProvider(key);

认证详情源实现类

AuthenticationDetailsSourceImpl类因编写自定义AuthenticationDetailsSource而被废弃。例如,您可能有以下内容:

    AuthenticationDetailsSourceImpl source = new 
    AuthenticationDetailsSourceImpl();
    source.setClazz(CustomWebAuthenticationDetails.class);

您应该直接实现AuthenticationDetailsSource类以返回CustomSource对象:

public class CustomWebAuthenticationDetailsSource implements AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails> {
      public WebAuthenticationDetails buildDetails(HttpServletRequest context) {
            return new CustomWebAuthenticationDetails(context);
      }
}

认证提供者管理器

ProviderManager类移除了废弃的默认构造函数和相应的设置器方法,改为使用构造器注入。它还移除了clearExtraInformation属性,因为AuthenticationException异常已经移除了额外信息属性。

例如,您可能像以下内容一样:

ProviderManager provider = new ProviderManager();
provider.setParent(parent);
provider.setProviders(providers);
provider.setClearExtraInformation(true);

如果如此,上述代码应更改为以下代码:

ProviderManager provider = new ProviderManager(providers, parent);

由于AuthenticationException异常已经移除了额外信息属性,因此移除了clearExtraInformation属性。对此没有替代方案。

记住我认证提供者

RememberMeAuthenticationProvider类移除了默认构造函数和setKey方法,改为使用构造器注入。例如,查看以下代码:

    RememberMeAuthenticationProvider provider = new 
    RememberMeAuthenticationProvider();
    provider.setKey(key);

上述代码片段应迁移至以下内容:

    RememberMeAuthenticationProvider provider = new 
    RememberMeAuthenticationProvider(key);

授权实体实现类

GrantedAuthorityImpl已被SimpleGrantedAuthority所取代,或者实现你自己的GrantAuthority对象。例如:

    new GrantedAuthorityImpl(role);

这应该替换为以下内容:

    new SimpleGrantedAuthority(role);

InMemoryDaoImpl

InMemoryDaoImpl已被InMemoryUserDetailsManager所取代。例如:

InMemoryDaoImpl uds = new InMemoryDaoImpl();
uds.setUserProperties(properties);

这应该被替换为:

InMemoryUserDetailsManager uds = new InMemoryUserDetailsManager(properties);
spring-security-web

spring-security-web模块中的弃用

本节描述了spring-security-web模块中所有弃用的 API。

FilterChainProxy

FilterChainProxy移除了setFilterChainMap方法,改为使用构造注入。例如,你可能有以下内容:

FilterChainProxy filter = new FilterChainProxy();
filter.setFilterChainMap(filterChainMap);

它应该被替换为:

FilterChainProxy filter = new FilterChainProxy(securityFilterChains);

FilterChainProxy也移除了getFilterChainMap,改为使用getFilterChains,例如:

    FilterChainProxy securityFilterChain = ...
    Map<RequestMatcher,List<Filter>> mappings = 
    securityFilterChain.getFilterChainMap();
    for(Map.Entry<RequestMatcher, List<Filter>> entry : mappings.entrySet()) {
          RequestMatcher matcher = entry.getKey();
          boolean matches = matcher.matches(request);
          List<Filter> filters = entry.getValue();
    }

这应该替换为以下代码:

    FilterChainProxy securityFilterChain = ...
    List<SecurityFilterChain> mappings = securityFilterChain.getFilterChains();
    for(SecurityFilterChain entry : mappings) {
          boolean matches = entry.matches(request);
          List<Filter> filters = entry.getFilters();
    }

ExceptionTranslationFilter

ExceptionTranslationFilter的默认构造函数和setAuthenticationEntryPoint方法已被移除,改为使用构造注入:

ExceptionTranslationFilter filter = new ExceptionTranslationFilter();
filter.setAuthenticationEntryPoint(entryPoint);
filter.setRequestCache(requestCache);

这可以用以下代码替换:

    ExceptionTranslationFilter filter = new 
    ExceptionTranslationFilter(entryPoint, requestCache);

AbstractAuthenticationProcessingFilter

AbstractAuthenticationProcessingFilter类的successfulAuthentication(HttpServletRequest,HttpServletResponse,Authentication)方法已被移除。所以,你的应用程序可能重写了以下方法:

    protected void successfulAuthentication(HttpServletRequest request, 
    HttpServletResponse response, Authentication authResult) throws IOException,    
    ServletException {
    }

应替换为以下代码:

    protected void successfulAuthentication(HttpServletRequest request,
     HttpServletResponse response, FilterChain chain, Authentication 
     authResult) throws IOException, ServletException {
    }

AnonymousAuthenticationFilter

AnonymousAuthenticationFilter类的默认构造函数和setKeysetPrincipal方法已被移除,改为使用构造注入。例如,看看以下代码片段:

    AnonymousAuthenticationFilter filter = new 
    AnonymousAuthenticationFilter();
    filter.setKey(key);
    filter.setUserAttribute(attrs);

这应该替换为以下代码:

    AnonymousAuthenticationFilter filter = new   
    AnonymousAuthenticationFilter(key,attrs.getPassword(),
    attrs.getAuthorities());

LoginUrlAuthenticationEntryPoint

LoginUrlAuthenticationEntryPoint的默认构造函数和setLoginFormUrl方法已被移除,改为使用构造注入。例如:

    LoginUrlAuthenticationEntryPoint entryPoint = new 
    LoginUrlAuthenticationEntryPoint();
    entryPoint.setLoginFormUrl("/login");

这应该替换为以下代码:

    LoginUrlAuthenticationEntryPoint entryPoint = new   
    LoginUrlAuthenticationEntryPoint(loginFormUrl);

PreAuthenticatedGrantedAuthoritiesUserDetailsService

PreAuthenticatedGrantedAuthoritiesUserDetailsService接口移除了createuserDetails,改为createUserDetails

新方法在案例中进行了更正(U而不是u)。

这意味着如果你有一个PreAuthenticatedGrantedAuthoritiesUserDetailsService类的子类,它重写了createuserDetails,例如SubclassPreAuthenticatedGrantedAuthoritiesUserDetailsService扩展了PreAuthenticatedGrantedAuthoritiesUserDetailsService

{
      @Override
      protected UserDetails createuserDetails(Authentication token,
                  Collection<? extends GrantedAuthority> authorities) {
            // customize
      }
}

它应该更改为重写createUserDetails

public class SubclassPreAuthenticatedGrantedAuthoritiesUserDetailsService extends PreAuthenticatedGrantedAuthoritiesUserDetailsService {
      @Override
      protected UserDetails createUserDetails(Authentication token,
                  Collection<? extends GrantedAuthority> authorities) {
            // customize
      }
}

AbstractRememberMeServices

AbstractRememberMeServices及其子类PersistentTokenBasedRememberMeServicesTokenBasedRememberMeServices移除了默认构造函数、setKeysetUserDetailsService方法,改为使用构造注入。

PersistentTokenBasedRememberMeServices

AbstractRememberMeServices及其子类PreAuthenticatedGrantedAuthoritiesUserDetailsService的更改使得用法类似于以下示例:

PersistentTokenBasedRememberMeServices services = new PersistentTokenBasedRememberMeServices();
services.setKey(key);
services.setUserDetailsService(userDetailsService);
services.setTokenRepository(tokenRepository);

但实现用法现在应替换为:

PersistentTokenBasedRememberMeServices services = new PersistentTokenBasedRememberMeServices(key, userDetailsService, tokenRepository);

RememberMeAuthenticationFilter

RememberMeAuthenticationFilter的默认构造函数、setAuthenticationManagersetRememberMeServices方法已被移除,改为使用构造器注入,如下:

RememberMeAuthenticationFilter filter = new RememberMeAuthenticationFilter();
filter.setAuthenticationManager(authenticationManager);
filter.setRememberMeServices(rememberMeServices);

这应该替换为:

RememberMeAuthenticationFilter filter = new RememberMeAuthenticationFilter(authenticationManager,rememberMeServices);

TokenBasedRememberMeServices

AbstractRememberMeServices及其子类PersistentTokenBasedRememberMeServicesTokenBasedRememberMeServices移除了默认构造函数、setKeysetUserDetailsService方法,改为使用构造器注入。例如:

TokenBasedRememberMeServices services = new TokenBasedRememberMeServices();
services.setKey(key);
services.setUserDetailsService(userDetailsService);

这应该替换为:

TokenBasedRememberMeServices services = new TokenBasedRememberMeServices(key, userDetailsService);

ConcurrentSessionControlStrategy

ConcurrentSessionControlStrategy已被替换为ConcurrentSessionControlAuthenticationStrategy。以前,ConcurrentSessionControlStrategy无法与SessionFixationProtectionStrategy解耦。现在它完全解耦了。例如:

ConcurrentSessionControlStrategy strategy = new ConcurrentSessionControlStrategy(sessionRegistry);

这可以替换为:

List<SessionAuthenticationStrategy> delegates = new ArrayList<SessionAuthenticationStrategy>();
delegates.add(new ConcurrentSessionControlAuthenticationStrategy(sessionRegistry));
delegates.add(new SessionFixationProtectionStrategy());
delegates.add(new RegisterSessionAuthenticationStrategy(sessionRegistry));
CompositeSessionAuthenticationStrategy strategy = new CompositeSessionAuthenticationStrategy(delegates);

SessionFixationProtectionStrategy

SessionFixationProtectionStrategy移除了setRetainedAttributes方法,改为让用户继承SessionFixationProtectionStrategy并重写extractAttributes方法。查看以下代码:

SessionFixationProtectionStrategy strategy = new SessionFixationProtectionStrategy();
strategy.setRetainedAttributes(attrsToRetain);

它应该替换为:

public class AttrsSessionFixationProtectionStrategy extends SessionFixationProtectionStrategy {
      private final Collection<String> attrsToRetain;
      public AttrsSessionFixationProtectionStrategy(
                  Collection<String> attrsToRetain) {
            this.attrsToRetain = attrsToRetain;
      }
      @Override
      protected Map<String, Object> extractAttributes(HttpSession session) {
            Map<String,Object> attrs = new HashMap<String, Object>();
            for(String attr : attrsToRetain) {
                  attrs.put(attr, session.getAttribute(attr));
            }
            return attrs;
      }
}
SessionFixationProtectionStrategy strategy = new AttrsSessionFixationProtectionStrategy(attrsToRetain);

BasicAuthenticationFilter

BasicAuthenticationFilter的默认构造函数、setAuthenticationManagersetRememberMeServices方法已被移除,改为使用构造器注入:

BasicAuthenticationFilter filter = new BasicAuthenticationFilter();
filter.setAuthenticationManager(authenticationManager);
filter.setAuthenticationEntryPoint(entryPoint);
filter.setIgnoreFailure(true);

这应该替换为:

BasicAuthenticationFilter filter = new BasicAuthenticationFilter(authenticationManager,entryPoint);

使用这个构造函数会自动将ignoreFalure设置为true

SecurityContextPersistenceFilter

SecurityContextPersistenceFilter移除了setSecurityContextRepository,改为使用构造器注入。例如:

SecurityContextPersistenceFilter filter = new SecurityContextPersistenceFilter();
filter.setSecurityContextRepository(securityContextRepository);

这应该替换为:

SecurityContextPersistenceFilter filter = new SecurityContextPersistenceFilter(securityContextRepository);

RequestCacheAwareFilter

RequestCacheAwareFilter移除了setRequestCache,改为使用构造器注入。例如:

RequestCacheAwareFilter filter = new RequestCacheAwareFilter();
filter.setRequestCache(requestCache);

这应该替换为:

RequestCacheAwareFilter filter = new RequestCacheAwareFilter(requestCache);

ConcurrentSessionFilter

ConcurrentSessionFilter移除了默认构造函数、setExpiredUrlsetSessionRegistry方法,改为使用构造器注入。例如:

ConcurrentSessionFilter filter = new ConcurrentSessionFilter();
filter.setSessionRegistry(sessionRegistry);
filter.setExpiredUrl("/expired");

这应该替换为:

ConcurrentSessionFilter filter = new ConcurrentSessionFilter(sessionRegistry,"/expired");

SessionManagementFilter

SessionManagementFilter移除了setSessionAuthenticationStrategy方法,改为使用构造器注入。例如:

SessionManagementFilter filter = new SessionManagementFilter(securityContextRepository);
filter.setSessionAuthenticationStrategy(sessionAuthenticationStrategy);

这应该替换为:

SessionManagementFilter filter = new SessionManagementFilter(securityContextRepository, sessionAuthenticationStrategy);

RequestMatcher

RequestMatcher及其实现已从org.springframework.security.web.util包移动到org.springframework.security.web.util.matcher。具体如下:

org.springframework.security.web.util.RequestMatcher  org.springframework.security.web.util.matcher.RequestMatcher
org.springframework.security.web.util.AntPathRequestMatcher org.springframework.security.web.util.matcher.AntPathRequestMatcher
org.springframework.security.web.util.AnyRequestMatcher org.springframework.security.web.util.matcher.AnyRequestMatcher.INSTANCE
org.springframework.security.web.util.ELRequestMatcher org.springframework.security.web.util.matcher.ELRequestMatcher
org.springframework.security.web.util.IpAddressMatcher org.springframework.security.web.util.matcher.IpAddressMatcher
org.springframework.security.web.util.RequestMatcherEditor  org.springframework.security.web.util.matcher.RequestMatcherEditor
org.springframework.security.web.util.RegexRequestMatcher org.springframework.security.web.util.matcher.RegexRequestMatcher

WebSecurityExpressionHandler

WebSecurityExpressionHandler已被移除,改为使用SecurityExpressionHandler<FilterInvocation>

这意味着你可能有以下内容:

WebSecurityExpressionHandler handler = ...

这需要更新为:

SecurityExpressionHandler<FilterInvocation> handler = ...

你可以这样实现WebSecurityExpressionHandler

public class CustomWebSecurityExpressionHandler implements WebSecurityExpressionHandler {
      ...
}

然后它必须更新为:

public class CustomWebSecurityExpressionHandler implements SecurityExpressionHandler<FilterInvocation> {
     ...
}

@AuthenticationPrincipal

org.springframework.security.web.bind.annotation.AuthenticationPrincipal已被弃用,改为org.springframework.security.core.annotation.AuthenticationPrincipal。例如:

import org.springframework.security.web.bind.annotation.AuthenticationPrincipal;
// ...

@RequestMapping("/messages/inbox")
public ModelAndView findMessagesForUser(@AuthenticationPrincipal CustomUser customUser) {
      // .. find messages for this user and return them ...
}

这应该替换为:

import org.springframework.security.core.annotation.AuthenticationPrincipal;
// ...

@RequestMapping("/messages/inbox")
public ModelAndView findMessagesForUser(@AuthenticationPrincipal CustomUser customUser) {
      // .. find messages for this user and return them ...
}

迁移默认过滤器 URL

许多 servlet 过滤器的默认 URL 被更改为帮助防止信息泄露。

有很多 URL 被更改,以下提交包含了 125 个更改的文件,共有 8,122 个增加和 395 个删除:github.com/spring-projects/spring-security/commit/c67ff42b8abe124b7956896c78e9aac896fd79d9

JAAS

遗憾的是,我们没有篇幅讨论 Spring Security 的 JAAS 集成。然而,在 Spring Security 的示例中包含了一个 JAAS 样本应用程序,可以在docs.spring.io/spring-security/site/docs/current/reference/htmlsingle/#jaas-sample找到。实际上,还有关于 JAAS 集成的优秀文档,可以在 Spring Security 的参考资料中找到,链接为docs.spring.io/spring-security/site/docs/current/reference/htmlsingle/#jaas。当查看 JAAS 参考文档时,你会注意到,从 Spring Security 4.2 开始,支持使用 JAAS 登录模块与任意的 JAAS 配置实现。Spring Security 4.2 还在<http>元素中添加了jaas-api-provision属性,确保了对于可能依赖于 JAAS 主题的应用程序,JAAS 主题被填充。

摘要

本章回顾了将现有 Spring Security 3 项目升级到 Spring Security 4.2 时您将发现的主要和小幅变化。在本章中,我们回顾了框架的主要增强功能,这些功能可能会促使进行升级。我们还检查了升级要求、依赖关系和常见的代码、配置更改,这些更改可能会在升级后阻止应用程序运行。我们还涵盖了 Spring Security 作者在代码库重构过程中进行的高级代码组织变化调查。

如果你是第一次阅读这一章节,我们希望你能回到书的其余部分,并使用这一章节作为指南,使你的 Spring Security 4.2 升级尽可能顺利地进行!

第十六章:使用 OAuth 2 和 JSON Web Tokens 的微服务安全

在本章中,我们将探讨基于微服务的架构,并查看 OAuth 2 与JSON Web TokensJWT)在 Spring 基础应用程序中扮演的安全角色。

以下是在本章中将要覆盖的主题列表:

  • 单体应用和微服务之间的通用区别

  • 比较服务导向架构SOA)与微服务

  • OAuth 2 的概念架构及其如何为您的服务提供可信的客户端访问

  • OAuth 2 访问令牌的类型

  • OAuth 2 的授权类型

  • 检查 JWT 及其一般结构

  • 实现资源服务器和认证服务器,以授予客户端访问 OAuth 2 资源的权限

  • 实现 RESTful 客户端以通过 OAuth 2 授权流程访问资源

我们在这章中要覆盖的内容还有很多,但在我们详细介绍如何开始利用 Spring Security 实现 OAuth 2 和 JWT 之前,我们首先想要创建一个没有 Thymeleaf 或其他基于浏览器的用户界面的日历应用程序的基本线。

在移除所有 Thymeleaf 配置和资源后,各种控制器已转换为JAX-RS REST控制器。

你应该从chapter16.00-calendar的代码开始。

微服务是什么?

微服务是一种允许开发物理上分离的模块化应用程序的架构方法,这些应用程序是自主的,支持敏捷性、快速开发、持续部署和扩展。

应用程序作为一组服务构建,类似于 SOA,这样服务可以通过标准 API 进行通信,例如 JSON 或 XML,这允许聚合语言不可知的服务。基本上,服务可以用最适合创建服务任务的编程语言编写。

每个服务在其自己的进程中运行,且与位置无关,因此它可以在访问网络的任何位置运行。

单体应用

微服务方法与传统的单体软件方法相反,后者由紧密集成的模块组成,这些模块不经常发货,必须作为一个单一单元进行扩展。本书中的传统 Java EE 应用程序和日历应用程序就是单体应用的例子。请查看以下图表,它描述了单体架构:

尽管单体方法对于某些组织和某些应用来说非常适合,但对于需要在其生态系统中具有更多灵活性和可伸缩性的公司来说,微服务越来越受欢迎。

微服务

微服务架构是一系列小型离散服务的集合,每个服务实现特定的业务功能。这些服务运行自己的进程,并通过 HTTP API 进行通信,通常使用 RESTful 服务方法。这些服务是为了只服务于一个特定的业务功能而创建的,比如用户管理、行政角色、电子商务购物车、搜索引擎、社交媒体集成等。请查看以下描述微服务架构的图表:

每个s服务可以独立于应用程序中的其他服务和企业中的其他系统进行部署、升级、扩展、重启和移除。

因为每个服务都是独立于其他服务创建的,所以它们可以分别用不同的编程语言编写,并使用不同的数据存储。集中式服务管理实际上是不存在的,这些服务使用轻量级的 HTTP、REST 或 Thrift API 进行相互之间的通信。

Apache Thrift 软件框架可以从 thrift.apache.org 下载。它是一个用于开发可扩展的跨语言服务的框架,结合了软件栈和代码生成引擎,以高效、无缝地在 C++、Java、Python、PHP、Ruby、Erlang、Perl、Haskell、C#、Cocoa、JavaScript、Node.js、Smalltalk 和其他语言之间构建服务。

面向服务的架构

你可能会问自己,“这不是和 SOA 一样吗?” 不完全是,你可以说是微服务实现了 SOA 最初承诺的东西。

面向服务架构(SOA)是一种软件设计风格,其中服务通过计算机网络上的语言无关的通信协议暴露给其他组件。

面向服务架构(SOA)的基本原则是独立于供应商、产品和技术的。

服务的定义是一个可以远程访问、独立操作和更新的离散功能单元,例如在线获取信用卡账单。

尽管相似,但 SOA 和微服务仍然是不同类型的架构。

典型的 SOA 通常在部署单体内部实现,并且更受平台驱动,而微服务可以独立部署,因此,在所有维度上提供更多的灵活性。

当然,关键区别在于规模;单词“微”说明了一切。微服务通常比传统的 SOA 服务要小得多。正如 Martin Fowler 所说:

“我们应该将 SOA 视为微服务的超集。”

-Martin Fowler

微服务安全

微服务可以提供极大的灵活性,但也会引入必须解决的问题。

服务通信

单体应用程序使用进程间的内存通信,而微服务通过网络进行通信。向网络通信的转变不仅涉及到速度问题,还有安全性问题。

紧密耦合

微服务使用许多数据存储而不是几个。这创造了微服务与紧密集成的服务之间的隐式服务合同的机会。

技术复杂性

微服务可能会创建额外的复杂性,这可能会造成安全漏洞。如果团队没有正确的经验,那么管理这些复杂性可能会迅速变得无法管理。

OAuth 2 规范

有时会有一种误解,认为 OAuth 2 是 OAuth 1 的演变,但它是完全不同的方法。OAuth1 规范要求签名,因此你必须使用加密算法来创建生成和验证那些在 OAuth 2 中不再需要的签名。OAuth 2 的加密现在由 TLS 处理,这是强制性的。

OAuth 2 RFC-6749, OAuth 2.0 授权框架(tools.ietf.org/html/rfc6749):

OAuth 2.0 授权框架允许第三方应用程序获取对 HTTP 服务的有限访问, either on behalf of a resource owner by orchestrating an approval interaction between the resource owner and the HTTP service, or by allowing the third-party application to obtain access on its own behalf.

本规范取代并使RFC 5849, The OAuth 1.0 Protocol(tools.ietf.org/html/rfc5849)描述的 OAuth 1.0 协议过时.*

为了正确理解如何使用 OAuth 2,我们需要确定某些角色以及这些角色之间的协作。让我们定义参与 OAuth 2 授权过程的每个角色:

  • 资源所有者:资源所有者是能够授权位于资源服务器上的受保护资源的实体。

  • 授权服务器:授权服务器在成功验证资源所有者并获取授权后,向客户端发放访问令牌的一个集中的安全网关。

  • 资源服务器:资源服务器是托管受保护资源的服务器,并能够使用 OAuth 2 访问令牌来解析和响应受保护资源请求。

  • 微服务客户端:客户端是代表资源所有者请求受保护资源的应用程序,但需要他们的授权。

访问令牌

一个 OAuth 2 访问令牌,在代码示例中通常被称为access_token,代表一个客户端可以用来访问 API 的凭据。

访问令牌

访问令牌通常具有限定的生命周期,当在每次请求的 HTTP 请求头中包含此令牌时,它被用来允许客户端访问受保护的资源。

刷新令牌

刷新令牌具有更长的生命周期,当访问令牌过期时用来获取新的访问令牌,而无需再次向服务器发送凭据。

授权类型

授权类型是客户端用来获取代表授权的access_token的方法。根据应用程序的不同需求,有不同的授权类型允许不同类型的访问。每种授权类型都可以支持不同的 OAuth 2 流程,而无需担心实现的技术方面。

授权码

授权码授权类型,定义在RFC 6749的第4.1节(tools.ietf.org/html/rfc6749)中,是一种基于重定向的流程,浏览器从授权服务器接收一个授权码,并将其发送给客户端。客户端随后与授权服务器交互,用这个授权码交换access_token,可选的还有id_tokenrefresh_token。客户端现在可以使用这个access_token代表用户调用受保护的资源。

隐式

隐式授权类型,定义在RFC 6749的第4.1节(tools.ietf.org/html/rfc6749)中,与授权码授权类型相似,但客户端应用程序直接接收access_token,而无需authorization_code。这是因为通常在浏览器内运行、比在服务器上运行的客户端应用程序信任度较低的客户端应用程序,不能信任其拥有client_secret(授权码授权类型中需要)。隐式授权类型由于信任限制,不会将刷新令牌发送给应用程序。

密码凭证

资源所有者密码授权类型,定义在RFC 6749的第4.3节(tools.ietf.org/html/rfc6749)中,可以直接作为授权许可来获取access_token,可选的还有refresh_token。这种许可在用户与客户端之间有高度信任,且其他授权许可流程不可用时使用。这种许可类型通过用长期有效的access_tokenrefresh_token交换凭据,消除了客户端存储用户凭据的需要。

客户端证书

客户端证书授权,定义在RFC 6749的第4.4节(tools.ietf.org/html/rfc6749#section-4.4)中,适用于非交互式客户端(CLI)、守护进程或其他服务。客户端可以通过使用提供的凭据(客户端 ID 和客户端密钥)进行身份验证,直接向授权服务器请求access_token

JSON Web 令牌

JWT 是一个开放标准,RFC 7519 (tools.ietf.org/html/rfc7519),定义了一个紧凑且自包含的格式,用于在 JSON 对象的形式下安全地在各方之间传输信息。由于其是数字签名的,这些信息可以被验证和信任。JWT 可以使用秘密(使用基于哈希的消息认证码HMAC算法)或使用Rivest-Shamir-AdlemanRSA)加密算法的公钥/私钥对进行签名。

JWT RFC- 7519 (tools.ietf.org/html/ rfc7519):

JSON Web Token (JWT)是一个紧凑、URL 安全的方式来表示要在两个方之间转移的主张。JWT 中的主张以 JSON 对象的形式作为 JSON Web 签名(JWS)结构的载荷或作为 JSON Web 加密(JWE)结构的明文,使主张可以被数字签名或完整性保护 Message Authentication Code (MAC)和/或加密.

JWT 用于携带与持有令牌的客户端的身份和特征(声明)相关的信息。JWT 是一个容器,并且由服务器签名,以避免客户端篡改。此令牌在认证过程中创建,并在进行任何处理之前由授权服务器验证。资源服务器使用此令牌允许客户端将其“身份卡”呈现给资源服务器,并允许资源服务器以无状态、安全的方式验证令牌的有效性和完整性。

令牌结构

JWT 的结构遵循以下三部分结构,包括头部、载荷和签名:

    [Base64Encoded(HEADER)] . [Base64Encoded (PAYLOAD)] . [encoded(SIGNATURE)]

编码 JWT

以下代码片段是基于客户端请求返回的完整编码access_token

     eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1MDk2MTA2ODks
    InVzZXJfbmFtZSI6InVzZXIxQGV4YW1wbGUuY29tIiwiYXV0aG9yaXRpZXMiOlsi
    Uk9MRV9VU0VSIl0sImp0aSI6Ijc1NTRhZGM4LTBhMjItNDBhYS05YjQ5LTU4MTU2N
    DBhNDUzNyIsImNsaWVudF9pZCI6Im9hdXRoQ2xpZW50MSIsInNjb3BlIjpb
    Im9wZW5pZCJdfQ.iM5BqXj70ET1e5uc5UKgws1QGDv6NNZ4iVEHimsp1Pnx6WXuFwtpHQoerH_F-    
    pTkbldmYWOwLC8NBDHElLeDi1VPFCt7xuf5Wb1VHe-uwslupz3maHsgdQNGcjQwIy7_U-  
    SQr0wmjcc5Mc_1BWOq3-pJ65bFV1v2mjIo3R1TAKgIZ091WG0e8DiZ5AQase
    Yy43ofUWrJEXok7kUWDpnSezV96PDiG56kpyjF3x1VRKPOrm8CZuylC57wclk-    
    BjSdEenN_905sC0UpMNtuk9ENkVMOpa9_Redw356qLrRTYgKA-qpRFUpC-3g5
    CXhCDwDQM3jyPvYXg4ZW3cibG-yRw

头部

我们的access_token JWT 的编码头部是base64编码的,如下面的代码所示:

    eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9

通过解码编码头部,我们得到以下载荷:

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

载荷

我们的access_token JWT 的编码载荷是 base64 编码的,如下所示:

    eyJleHAiOjE1MDk2MTA2ODksInVzZXJfbmFtZSI6InVzZXIxQGV4YW1wbGUuY29
    tIiwiYXV0aG9yaXRpZXMiOlsiUk9MRV9VU0VSIl0sImp0aSI6Ijc1NTR
    hZGM4LTBhMjItNDBhYS05YjQ5LTU4MTU2NDBhNDUzNyIsImNsaWVudF9pZCI6I
    m9hdXRoQ2xpZW50MSIsInNjb3BlIjpbIm9wZW5pZCJdfQ

通过解码编码载荷,我们得到以下载荷声明:

    {
      "exp": 1509610689,  
      "jti": "7554adc8-0a22-40aa-9b49-5815640a4537",
      "client_id": "oauthClient1",
      "authorities": [
         "ROLE_USER"
        ],
         "scope": [
        "openid"
       ],
      "user_name": "user1@example.com"
    }

签名

授权服务器使用私钥对我们的access_token进行了编码,如下面的代码所示:

    iM5BqXj70ET1e5uc5UKgws1QGDv6NNZ4iVEHimsp1Pnx6WXuFwtpHQoerH_F-          
    pTkbldmYWOwLC8NBDHElLeDi1VPFCt7xuf5Wb1VHe-uwslupz3maHsgdQNGcjQwIy7_U-   
    SQr0wmjcc5Mc_1BWOq3-pJ65bFV1v2mjIo3R1TAKgIZ091WG0e8DiZ5AQaseYy43ofUWrJEXok7kUWDpn
    SezV96PDiG56kpyjF3x1VRKPOrm8CZuylC57wclk-    
    BjSdEenN_905sC0UpMNtuk9ENkVMOpa9_Redw356qLrRTYgKA-qpRFUp
    C-3g5CXhCDwDQM3jyPvYXg4ZW3cibG-yRw

以下是创建 JWT 签名的伪代码:

    var encodedString = base64UrlEncode(header) + ".";
    encodedString += base64UrlEncode(payload);
    var privateKey = "[-----PRIVATE KEY-----]";
    var signature = SHA256withRSA(encodedString, privateKey);
    var JWT = encodedString + "." + base64UrlEncode(signature);

Spring Security 中的 OAuth 2 支持

Spring Security OAuth 项目提供了使用 Spring Security 进行 OAuth 2 授权的支持,使用标准的 Spring 框架和 Spring Security 编程模型以及配置习惯。

资源所有者

资源所有者可以是一个或多个来源,在 JBCP 日历的上下文中,它将拥有日历应用程序作为资源所有者。JBCP 日历除了配置资源服务器外,不需要有任何特定的配置来表示其所有权。

资源服务器

@EnableResourceServer注解表示容器应用程序的意图,启用一个 Spring Security 过滤器,该过滤器通过传入的 OAuth2 令牌来验证请求:

    //src/main/java/com/packtpub/springsecurity/configuration/
    OAuth2ResourceServerConfig.java

    @EnableResourceServer
    public class OAuth2ResourceServerConfig
    extends ResourceServerConfigurerAdapter {...}

@EnableResourceServer注解表示容器应用程序的意图,启用一个OAuth2AuthenticationProcessingFilter过滤器,该过滤器通过传入的 OAuth 2 令牌来验证请求。OAuth2AuthenticationProcessingFilter过滤器需要使用@EnableWebSecurity注解在应用程序中的某个位置启用 web 安全。@EnableResourceServer注解注册了一个硬编码@Order3的自定义WebSecurityConfigurerAdapter类。由于 Spring Framework 的技术限制,目前无法更改这个WebSecurityConfigurerAdapter类的顺序。为了解决这个限制,建议不要使用其他顺序为3的安全适配器,否则 Spring Security 会在你设置相同顺序的一个时提出抗议:

//o.s.s.OAuth 2.config.annotation.web.configuration.ResourceServerConfiguration.class

    @Configuration
    public class ResourceServerConfiguration
       extends WebSecurityConfigurerAdapter implements Ordered {
 private int order = 3;           ...
        }

授权服务器

为了启用授权服务器功能,我们在配置中包含了@EnableAuthorizationServer注解。添加此注解将在上下文中放入o.s.s.OAuth 2.provider.endpoint.AuthorizationEndpoint接口和o.s.s.OAuth 2.provider.endpoint.TokenEndpoint接口。开发者需要负责使用@EnableWebSecurity配置保护AuthorizationEndpoint/oauth/authorize)。TokenEndpoint/oauth/token)将基于 OAuth 2 客户端凭据自动使用 HTTP 基本身份验证进行保护:

    //src/main/java/com/packtpub/springsecurity/configuration/
    OAuth2AuthorizationServerConfig.java

    @Configuration
    @EnableAuthorizationServer
    public class OAuth 2AuthorizationServerConfig {...}

RSA JWT 访问令牌转换器密钥对

为了创建一个安全的 JWT 编码签名,我们将创建一个自定义 RSA keystore,我们将其用于创建自定义o.s.s.OAuth 2.provider.token.storeJwtAccessTokenConverter接口:

$ keytool -genkey -alias jbcpOAuth 2client -keyalg RSA \
-storetype PKCS12 -keystore jwtConverterStore.p12 \
-storepass changeit \
-dname "CN=jwtAdmin1@example.com,OU=JBCP Calendar,O=JBCP,L=Park City,S=Utah,C=US"

这将创建一个名为jwtConverterStore.p12PKCS12证书,需要将其复制到./src/main/resources/key目录中。

OAuth 2 资源配置属性

我们希望通过提供keyPair属性来外部化配置我们的 JWT 资源,包括keystorealiasstorePassword,正如你在我们的application.yml文件中看到的,位于src/main/resources/application.yml

    # OAuth 2 Configuration:
    security:
    OAuth 2:
       # Resource Config:
       resource:
         jwt:
 keyPair: keystore: keys/jwtConverterStore.p12 alias: jbcpOAuth 2client storePassword: changeit

OAuth 2 客户端配置属性

我们需要为客户端认证、授权和 OAuth 2 范围配置客户端详细信息,正如你在application.yml文件中所看到的,位于src/main/resources/application.yml

# OAuth 2 Configuration:
security:
OAuth 2:
   # Client Config:
   client:
     # Basic Authentication credentials for OAuth 2
 clientId: oauthClient1 clientSecret: oauthClient1Password authorizedGrantTypes: password,refresh_token scope: openid

JWT 访问令牌转换器

创建 JWT 令牌的最后一步是创建一个自定义JwtAccessTokenConverter,它将使用生成的 RSA 证书为我们的 JWT 签名。为此,我们需要拉取我们的 keyPair 配置,并配置一个自定义JwtAccessTokenConverter,正如在 OAuth2AuthorizationServerConfig.java 文件中所看到的:

    //src/main/java/com/packtpub/springsecurity/configuration/
    OAuth2AuthorizationServerConfig.java

    public class OAuth2AuthorizationServerConfig {
       @Value("${security.OAuth 2.resource.jwt.keyPair.keystore}")
       private String keystore;
       @Value("${security.OAuth 2.resource.jwt.keyPair.alias}")
       private String keyPairAlias;
     @Value("${security.OAuth 2.resource.jwt.keyPair.storePassword}")
       private String keyStorePass;
       @Bean
       public JwtAccessTokenConverter jwtAccessTokenConverter() {
           JwtAccessTokenConverter converter = new
           JwtAccessTokenConverter();
           KeyPair keyPair = new KeyStoreKeyFactory
           (new ClassPathResource(keystore),
           keyStorePass.toCharArray() ).getKeyPair(keyPairAlias);
           converter.setKeyPair(keyPair);
           return converter;
       }
    }

用户详情服务对象

我们将使用CalendarUser凭据为客户端分配一个授权的GrantedAuthority。为了做到这一点,我们必须要么配置我们的CalendarUserDetailsService类,要么通过在下面的CalendarUserDetailsService.java文件中指定名称userDetailsService来实现,正如你所看到的:

    //src/main/java/com/packtpub/springsecurity/core/userdetails/
    CalendarUserDetailsService.java
 @Component("userDetailsService")    public class CalendarUserDetailsService
    implements UserDetailsService {...}

为我们的@Component注解定义自定义名称的另一个替代方案是定义一个@Bean声明,我们可以通过在SecurityConfig.java文件中使用以下条目来实现:

    //src/main/java/com/packtpub/springsecurity/configuration/SecurityConfig.java

    @Bean
    public CalendarUserDetailsService userDetailsService
    (CalendarUserDao calendarUserDao) {
       return new CalendarUserDetailsService(calendarUserDao);
    }

运行 OAuth 2 服务器应用程序

此时,我们可以启动应用程序,并准备好发送 OAuth 2 请求。

此时,你的代码应该看起来像这样:chapter16.01-calendar

服务器请求

我们可以使用命令行工具,如cURLHTTPie,来测试应用程序,或者你也可以使用像 Postman 这样的 REST 客户端插件来向服务器发送请求。

HTTPie: 一个像 cURL 的面向人类的 CLI 工具,HTTPie(发音为 aitch-tee-tee-pie)是一个命令行 HTTP 客户端。它的目标是使与 Web 服务的 CLI 交互尽可能地人性化。它提供了一个简单的 HTTP 命令,使用简单自然的语法发送任意的 HTTP 请求,并显示彩色输出。HTTPie可用于测试、调试和与 HTTP 服务器进行交互(httpie.org)。

令牌请求

当我们初次请求令牌时,我们应该得到一个类似于以下的成功响应:

    $ http -a oauthClient1:oauthClient1Password -f POST
    localhost:8080/oauth/token     
    grant_type=password username=user1@example.com password=user1 
    HTTP/1.1 200
    Cache-Control: no-cache, no-store, max-age=0, must-revalidate
    Cache-Control: no-store
    Content-Type: application/json;charset=UTF-8
    Date: Thu, 09 Nov 2017 20:29:26 GMT
    Expires: 0
    Pragma: no-cache
    Pragma: no-cache
    Transfer-Encoding: chunked
    X-Application-Context: application:default
    X-Content-Type-Options: nosniff
    X-Frame-Options: DENY
    X-XSS-Protection: 1; mode=block 
    {
 "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1MT
    AzMDI1NjYsInVzZXJfbmFtZSI6InVzZXIxQGV4YW1wbGUuY29tIiwiYXV0aG9yaXRpZ
    XMiOlsiUk9MRV9VU0VSIl0sImp0aSI6ImYzNzYzMWI4LWI0OGEtNG
    Y1MC1iNGQyLTVlNDk1NTRmYzZjZSIsImNsaWVudF9pZCI6Im9hdXRoQ
    2xpZW50MSIsInNjb3BlIjpbIm9wZW5pZCJdfQ.d5I2ZFX9ia_43eeD5X3JO6i_uF1Zw-    
    SaZ1CWbphQlYI3oCq6Xr9Yna5fvvosOZoWjb8pyo03EPVCig3mobhO6AF
    18802XOlBRx3qb0FGmHZzDoPw3naTDHlhE97ctlIFIcuJVqi34T60cvii
    uXmcE1tJ-H6-7AB04-wZl_WaucoO8-K39GvPyVabWBfSpfv0nbhh_XMNiB
    PnN8u5mqSKI9xGjYhjxXspRyy--    
    zXx50Nqj1aYzxexy8Scawrtt2F87o1IesOodoPEQGTgVVieIilplwkMLhMvJfxhyMOt
    ohR63XOGBSI4dDz58z3zOlk9P3k2Uq5FmkqwNNkduKceSw","expires_in": 43199,
    "jti": "f37631b8-b48a-4f50-b4d2-5e49554fc6ce","refresh_token":    
    "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJ1c2VyM
    UBleGFtcGxlLmNvbSIsInNjb3BlIjpbIm9wZW5pZCJdLCJhdGkiOiJmMzc2MzF
    iOC1iNDhhLTRmNTAtYjRkMi01ZTQ5NTU0ZmM2Y2UiLCJleHAiOjE1MTI4NTEzNjYs
    ImF1dGhvcml0aWVzIjpbIlJPTEVfVVNFUiJdLCJqdGkiOiJjODM2OGI4NS0xNTk5L
    TQ0NTgtODQ2Mi1iNGFhNDg1OGIzY2IiLCJjbGllbnRfaWQiOiJvYXV0aENsaWVudDEifQ.
    RZJ2GbEvcmFbZ3SVHmtFnSF_O2kv- 
    TmN56tddW2GkG0gIRr612nN5DVlfWDKorrftmmm64x8bxuV2CcFx8Rm4SSWuoYv
    j4oxMXZzANqXWLwj6Bei4z5uvuu00g6PtJvy5Twjt7GWCvEF82PBoQL-  
    bTM3RNSKmPnYPBwOGaRFTiSTdKsHCcbrg-   
    H84quRKCjXTl7Q6l8ZUxAf1eqWlOYEhRiGHtoULzdOvL1_W0OoWrQds1EN5g
    AuoTTSI3SFLnEE2MYu6cNznJFgTqmVs1hYmX1hiXUhmCq9nwYpWei-  
    bu0MaXCa9LRjDRl9E6v86vWJiBVzd9qQilwTM2KIvgiG7w", "scope": "openid",
    "token_type": "bearer"
    }

具体来说,我们已经获得了一个可以在后续请求中使用的访问令牌。以下是我们将用作持有者的access_token

 eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1MTAzMDI1
    NjYsInVzZXJfbmFtZSI6InVzZXIxQGV4YW1wbGUuY29tIiwiYXV0aG9yaXRpZXM
    iOlsiUk9MRV9VU0VSIl0sImp0aSI6ImYzNzYzMWI4LWI0OGEtNGY1MC1iNGQyL
    TVlNDk1NTRmYzZjZSIsImNsaWVudF9pZCI6Im9hdXRoQ2xpZW50MSIsInNjb
    3BlIjpbIm9wZW5pZCJdfQ.d5I2ZFX9ia_43eeD5X3JO6i_uF1Zw-   
    SaZ1CWbphQlYI3oCq6Xr9Yna5fvvosOZoWjb8pyo03EPVCig3mobhO6AF18802XO
    lBRx3qb0FGmHZzDoPw3naTDHlhE97ctlIFIcuJVqi34T60cviiuXmcE1tJ-H6-7AB04-wZl_WaucoO8-   
    K39GvPyVabWBfSpfv0nbhh_XMNiBPnN8u5mqSKI9xGjYhjxXspRyy--   
    zXx50Nqj1aYzxexy8Scawrtt2F87o1IesOodoPEQGTgVVieIilplwkMLhMvJfxhyMOto
    hR63XOGBSI4dDz58z3zOlk9P3k2Uq5FmkqwNNkduKceSw

现在我们将使用access_token,并使用该令牌以以下格式初始化对服务器的额外请求:

$ http localhost:8080/ "Authorization: Bearer [access_token]"

当我们添加第一次请求中收到的access_token时,我们应该得到以下请求:

 $ http localhost:8080/ 'Authorization: Bearer    
    eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1MTAzMD
    I1NjYsInVzZXJfbmFtZSI6InVzZXIxQGV4YW1wbGUuY29tIiwiYXV0aG9yaXRp
    ZXMiOlsiUk9MRV9VU0VSIl0sImp0aSI6ImYzNzYzMWI4LWI0OGEtNGY1MC1iNGQyLT
    VlNDk1NTRmYzZjZSIsImNsaWVudF9pZCI6Im9hdXRoQ2xpZW50MSIsInNjb3BlIjpb
    Im9wZW5pZCJdfQ.d5I2ZFX9ia_43eeD5X3JO6i_uF1Zw-    
    SaZ1CWbphQlYI3oCq6Xr9Yna5fvvosOZoWjb8pyo03EPVCig3mobhO6AF18802XOl
    BRx3qb0FGmHZzDoPw3naTDHlhE97ctlIFIcuJVqi34T60cviiuXmcE1tJ-H6-7AB04-wZl_WaucoO8-   
    K39GvPyVabWBfSpfv0nbhh_XMNiBPnN8u5mqSKI9xGjYhjxXspRyy--   
    zXx50Nqj1aYzxexy8Scawrtt2F87o1IesOodoPEQGTgVVieIilplwkMLhMvJf  
    xhyMOtohR63XOGBSI4dDz58z3zOlk9P3k2Uq5FmkqwNNkduKceSw'    HTTP/1.1 200
    Cache-Control: no-cache, no-store, max-age=0, must-revalidate
    Content-Length: 55
    Content-Type: text/plain;charset=UTF-8
    Date: Thu, 09 Nov 2017 20:44:00 GMT
    Expires: 0
    Pragma: no-cache
    X-Application-Context: application:default
    X-Content-Type-Options: nosniff
    X-Frame-Options: DENY
    X-XSS-Protection: 1; mode=block
    {'message': 'welcome to the JBCP Calendar Application'}

我们可以继续使用相同的access_token进行后续请求,例如获取当前用户的日历事件:

    $ http localhost:8080/events/my 'Authorization: Bearer    
    eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1MTAzMDI1NjYsI
    nVzZXJfbmFtZSI6InVzZXIxQGV4YW1wbGUuY29tIiwiYXV0aG9yaXRpZXMiOlsiU
    k9MRV9VU0VSIl0sImp0aSI6ImYzNzYzMWI4LWI0OGEtNGY1MC1iNGQyLTVlNDk1NT
    RmYzZjZSIsImNsaWVudF9pZCI6Im9hdXRoQ2xpZW50MSIsInNjb3BlIjpbIm9wZW5pZ
    CJdfQ.d5I2ZFX9ia_43eeD5X3JO6i_uF1Zw-    
    SaZ1CWbphQlYI3oCq6Xr9Yna5fvvosOZoWjb8pyo03EPVCig3mobhO6AF18802XO
    lBRx3qb0FGmHZzDoPw3naTDHlhE97ctlIFIcuJVqi34T60cviiuXmcE1tJ-H6-7AB04-wZl_WaucoO8-   
    K39GvPyVabWBfSpfv0nbhh_XMNiBPnN8u5mqSKI9xGjYhjxXspRyy--  
    zXx50Nqj1aYzxexy8Scawrtt2F87o1IesOodoPEQGTgVVieIilplwkMLhMvJfxhyMOtohR63
    XOGBSI4dDz58z3zOlk9P3k2Uq5FmkqwNNkduKceSw'
    HTTP/1.1 200
    Cache-Control: no-cache, no-store, max-age=0, must-revalidate
    Content-Type: application/json;charset=UTF-8
    Date: Thu, 09 Nov 2017 20:57:17 GMT
    Expires: 0
    Pragma: no-cache
    Transfer-Encoding: chunked
    X-Application-Context: application:default
    X-Content-Type-Options: nosniff
    X-Frame-Options: DENY
    X-XSS-Protection: 1; mode=block
 { "currentUser": [ { "description": "This is going to be a great birthday", "id": 100, "summary": "Birthday Party", 
 "when": 1499135400000 } ] }

现在我们已经准备好为客户端发放access_tokens的 OAuth 2 服务器,我们可以创建一个微服务客户端来与我们的系统交互。

微服务客户端

我们通过添加@EnableOAuth2Client注解使我们的新客户端应用程序作为一个 OAuth 2 客户端启动。添加@EnableOAuth2Client注解将允许这个应用程序从一台或多台 OAuth2 授权服务器检索和使用授权码授予。使用客户端凭据授予的客户端应用程序不需要AccessTokenRequest或受限于范围的RestOperations(对于应用程序来说,状态是全局的),但它们仍然应该使用过滤器触发OAuth2RestOperations在需要时获取一个令牌。使用密码授予的应用程序在使用RestOperations方法之前需要设置OAuth2ProtectedResourceDetails中的认证属性,我们稍后会进行配置。让我们来看看以下步骤,看看是如何完成的:

  1. 我们需要设置一些将在以下JavaConfig.java文件中用于配置客户端的属性:
    //src/main/java/com/packtpub/springsecurity/configuration/JavaConfig.java

    @Configuration
 @EnableOAuth 2Client    public class JavaConfig {
       @Value("${oauth.token.uri}")
       private String tokenUri;
       @Value("${oauth.resource.id}")
       private String resourceId;
       @Value("${oauth.resource.client.id}")
       private String resourceClientId;
       @Value("${oauth.resource.client.secret}")
       private String resourceClientSecret;
      @Value("${oauth.resource.user.id}")
      private String resourceUserId;
      @Value("${oauth.resource.user.password}")
      private String resourceUserPassword;
      @Autowired
      private DataSource dataSource;
     ...
    }
  1. 除了我们需要执行 OAuth 2 RESTful 操作的几个标准属性外,我们还需要创建一个dataSource来保存将在初始请求时检索并在后续操作中使用的给定资源的oauth_client_token。现在让我们为管理oauth_client_token创建一个ClientTokenServices,如以下JavaConfig.java文件所示:
    //src/main/java/com/packtpub/springsecurity/configuration/JavaConfig.java

    @Bean
   public ClientTokenServices clientTokenServices() {
     return new JdbcClientTokenServices(dataSource);
    }
  1. 现在我们创建一个OAuth2RestTemplate,它将管理 OAuth2 通信。我们将从创建一个ResourceOwnerPasswordResourceDetails来持有资源连接详细信息开始,然后构建一个OAuth2RestTemplate作为客户端请求的OAuth2RestOperations使用:
//src/main/java/com/packtpub/springsecurity/configuration/JavaConfig.java

@Bean
public OAuth2RestOperationsOAuth2RestOperations() {
   ResourceOwnerPasswordResourceDetails resource =
                     new ResourceOwnerPasswordResourceDetails();
   resource.setAccessTokenUri(tokenUri);
   resource.setId(resourceId);
   resource.setClientId(resourceClientId);
   resource.setClientSecret(resourceClientSecret);
   resource.setGrantType("password");
   resource.setScope(Arrays.asList("openid"));
   resource.setUsername(resourceUserId);
   resource.setPassword(resourceUserPassword);
   return new OAuth 2RestTemplate(resource);
}

配置 OAuth 2 客户端

自从我们启用了@EnableOAuth2Client注解并设置了一个ResourceOwnerPasswordResourceDetails对象后,我们需要配置用于连接资源服务器和认证服务器的属性:

    //src/main/resources/application.yml

    oauth:
    url: ${OAUTH_URL:http://localhost:8080}
    token:
       uri: ${OAUTH_URL:http://localhost:8080}/oauth/token
    resource:
       id: microservice-test
       # Client BASIC Authentication for Authentication Server
       client:
         id: ${OAUTH_CLIENT_ID:oauthClient1}
         secret: ${OAUTH_CLIENT_SECRET:oauthClient1Password}
       # Resource Password Credentials
       user:
         id: ${OAUTH_USER_ID:user1@example.com}
         password: ${OAUTH_USER_PASSWORD:user1}

现在我们已经有了这些组件,可以开始使用OAuth2RestOperations对象发送请求。我们将首先创建一个RestController来拉取远程详细信息,并将其作为 RESTful 请求的结果显示,正如我们在OAuth2EnabledEventsController.java文件中所展示的那样:

    //src/main/java/com/packtpub/springsecurity/web/controllers/
    OAuth2EnabledEventsController.java

    @RestController
    public class OAuth2EnabledEventsController {
       @Autowired
       private OAuth2RestOperations template;
       @Value("${base.url:http://localhost:8888}")
       private String baseUrl;
       @Value("${oauth.url:http://localhost:8080}")
       private String baseOauthUrl;
       @GetMapping("/events/my")
      public String eventsMy() {
          @SuppressWarnings("unchecked")
          String result = template.getForObject(baseOauthUrl+"/events/my",
          String.class);
          return result;
       }
    }

现在我们应为客户端应用拥有相同的代码库。

你的代码应看起来像chapter16.01-calendar-client

我们需要确保chapter16.01-calendar应用正在运行,并准备好接收来自客户端的 OAuth 2 请求。然后我们可以启动chapter16.01-calendar-client应用,该应用将暴露几个 RESTful 端点,包括一个访问配置用户事件(位于远程资源上的/events/my)的端点,并通过运行http://localhost:8888/events/my返回以下结果:

    {
    "currentUser": [
   {
     "id": 100,
     "summary": "Birthday Party",
     "description": "This is going to be a great birthday",
     "when": 1499135400000
   }
    ]
    }

摘要

在本章中,你学习了单体应用和微服务之间的通用区别,并将服务导向架构(SOA)与微服务进行了比较。你还了解了 OAuth 2 的概念性架构以及它是如何为你的服务提供可信的客户端访问的,并学习了 OAuth 2 访问令牌的类型以及 OAuth 2 授权类型的类型。

我们检查了 JWT 以及它们的通用结构,实现了一个资源服务器和认证服务器,用于向客户端授予访问 OAuth 2 资源的权限,并实现了一个 RESTful 客户端,通过 OAuth 2 授权流程来获取资源。

第十七章:附加参考资料

在本附录中,我们将介绍一些我们认为有帮助(并且大部分未文档化)但内容过于全面而无法插入各章节的参考资料。

使用 JBCP 日历示例代码开始

正如我们在第一章中描述的,《一个不安全应用程序的剖析》,我们假设你已经安装了一个 JDK。您可以从 Oracle 的网站www.oracle.com/technetwork/java/javase/downloads/index.html下载一个 JDK。为了运行代码示例,你需要安装 JDK 8。代码库使用了许多 JDK 8 的功能,这些功能与 JDK 7 不兼容,而且没有尝试解决 IDE 以及项目依赖项的各种 JDK 9 问题。

Gradle 构建工具

本书中的所有代码都是使用 Gradle 构建工具构建的,并且是按章节组织的多模块构建。您可以在gradle.org/install/找到获取 Gradle 的说明和选项。

由于源代码的根目录已经安装了 Gradle 包装器,因此不需要本地安装 Gradle。Gradle 包装器可以在任何子模块中安装。您可以在docs.gradle.org/current/userguide/gradle_wrapper.html找到有关 Gradle 包装器的更多信息。

下载示例代码

您可以从您在www.packtpub.com购买的所有 Packt 书籍的示例代码文件中下载。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

Gradle IDE 插件

代码库已经配置了 IntelliJ 和 Eclipse IDE 插件。这意味着 Gradle 可以创建所有必要的 IDE 项目文件,而不是手动导入代码库,尽管你并非强制使用这些插件。

要使用这些插件,请打开一个终端或命令提示符窗口,切换到代码库的根目录。要在 OSX 或 Linux 上执行插件,请发出以下命令:

$ ./gradlew idea

运行此任务后,每个目录中都会有几个 IDEA 项目文件,如下面的屏幕截图所示:

如果您是在 Windows 机器上,您将发出以下命令:

C:\jbcdcalendar> gradlew.bat idea

之前的示例执行了gradlew脚本,这是 Gradle 包装器,然后给出了创建 IDE 文件的命令。IntelliJ 项目文件是通过idea任务创建的,而 STS 或任何基于 Eclipse 的 IDE 的项目文件是通过 eclipse 任务创建的。

运行 Eclipse 任务后,每个目录中都会有几个 Eclipse 项目文件和目录:

IntelliJ IDEA

本书中使用的大多数图表都来自 Jet Brains 的 IntelliJ IDEA (www.jetbrains.com/idea/). IDEA 对多模块 Gradle 构建提供了很好的支持。

IDEA 将允许你导入一个现有项目,或者你可以简单地从源代码基的根目录打开build.gradle文件,IDEA 将为你创建必要的工作区文件。

一旦你使用 Gradle idea任务创建了 IDEA 项目文件,你就可以使用以下截图所示的导入项目选项来导入整个项目:

然后,你将被提示选择各种选项,以确定 IDEA 如何执行此 Gradle 构建,如下截图所示:

对于 IDEA 导入 Gradle 项目的一个特别说明

在前面的列表中,你会注意到有一个使用 Gradle 包装器任务配置的选项,以及被选中的默认 Gradle 包装器(推荐)选项。唯一的区别是使用 Gradle 包装器任务配置选项,这将在每个项目目录中创建一个 Gradle 包装器实例。如果你想在终端或命令行上执行构建命令,又不想安装 Gradle 的本地版本,这将很有帮助。否则,IDEA 为所有项目处理 Gradle 包装器调用。

一旦项目被导入,你将能够使用任何章节进行工作,布局如下截图所示:

Spring Tool Suite 或 Eclipse

如果使用 STS,我们假设你有访问Spring Tool Suite (STS) 3.9.1 的权限。你可以从spring.io/tools/sts下载 STS。STS 3.9.1 基于 Eclipse Oxygen 1a (4.7.1a)并且你可以在www.eclipse.org/ide/找到更多关于 Oxygen 版本的详细信息。

创建一个新的工作区

为了最小化与你的环境之间的差异,最好创建一个新的工作区,可以通过执行以下步骤来实现:

  1. 当你第一次打开 STS 时,它会提示你输入工作区位置。如果你之前使用过 STS,你可能需要去文件|切换工作区|其他来创建一个新的工作区。我们建议输入不包含任何空格的工作区位置。例如,请查看以下截图:

  1. 一旦你创建了一个新的工作区,你将想要通过点击欢迎标签上的关闭按钮来退出欢迎屏幕,如下截图所示:

一个示例代码结构

示例代码以.zip文件的形式组织,包含了一个多模块 Gradle 项目的文件夹。每个文件夹都命名为chapterNN,其中NN是章节号。每个chapterNN文件夹还包括了包含每个里程碑项目的文件夹,文件夹格式为chapterNN.mm-calendar,其中NN是章节号,mm是该章节内的里程碑。为了简化操作,我们建议您将源代码解压到一个不包含任何空格的路径。每个里程碑都是章节内的检查点,允许您轻松地将自己的代码与书中的代码进行比较。例如,chapter02.03-calendar包含了第二章,Spring Security 入门,日历应用程序中的里程碑号03。前一个项目的位置将是~/jbcpcalendar/chapter02/chapter02.03-calendar

第一章一个不安全应用程序的剖析和第二章Spring Security 入门作为 Spring IO 项目创建,没有使用 Spring Boot 作为项目基础。第三章自定义认证将日历项目转换为 Spring Boot 代码库,在第五章使用 Spring Data 进行认证中,JDBC 被 Spring Data 作为持久化机制所替代。

为了使每个章节尽可能独立,书中的大部分章节都是基于第九章,开放 OpenID,或第十五章,额外的 Spring Security 特性构建的。这意味着在大多数情况下,您可以阅读第九章,开放 OpenID,然后跳到书的其它部分。然而,这也意味着开始每个章节时,使用章节里程碑00的源代码非常重要,而不是继续使用上一章节的代码。这确保了您的代码从章节开始的地方开始。

虽然您可以在不执行任何步骤的情况下完成整本书,但我们建议您从每个章节的里程碑00开始,并实现书中的步骤。这将确保您能从书中获得最大的收益。您可以使用里程碑版本来复制大量代码,或者在遇到问题时比较您的代码。

导入示例代码

在我们可以将这个 Gradle 项目导入 Eclipse 之前,您必须在 Eclipse 市场安装一个 Gradle 插件。撰写本书时只有两个选项。一个是 Gradle IDE 包(marketplace.eclipse.org/content/gradle-ide-pack),但这个项目没有得到维护,如果您安装这个插件,Eclipse 会警告您,并建议您迁移到Buildship Gradle 集成插件(marketplace.eclipse.org/content/buildship-gradle-integration)。安装后,您将有一个导入现有 Gradle 项目的选项。

从我们全新的工作空间开始,执行以下步骤:

  1. 点击文件|导入,选择现有 Gradle 项目,如下图所示:

  1. 点击下一步>,如下图所示:

  1. 点击下一步>,如下图所示:

除非你想使用 Gradle 的本地安装,否则请确保保持默认设置。

  1. 浏览您导出代码的位置,并选择代码的父文件夹。您将看到所有项目列表。您可以选择您感兴趣的项目,也可以选择所有项目。如果您决定导入所有项目,您可以轻松地关注当前章节,因为命名约定将确保项目按它们在书中出现的顺序排序:

  1. 点击完成。所选的所有项目将被导入。如果你不经常使用 Gradle,它将需要一点时间来下载你的依赖项。

需要连接互联网以下载依赖项。

每个部分的README.md文件中都可以找到运行项目的更新后的说明。这确保了,即使 STS 进行更新,代码仍然可以使用最新的工具进行构建和运行。

运行示例

为了在 IDEA 或 STS 中运行示例应用程序,需要做一些必要的事情。在所有项目中,Gradle 中已经配置了 Tomcat 插件,以帮助您更快地开始。

在 IDEA 中启动示例

可以通过为每个项目创建运行/调试配置条目来运行里程碑项目。

从我们全新的工作空间开始,执行以下步骤:

  1. 点击文件|运行,选择编辑配置...,如下图所示:

  1. 您将看到添加新配置的选项。在左上角选择加号 (+) 符号来选择一个新的 Gradle 配置,如下图所示:

  1. 现在,您可以给它起一个名字,比如chapter15.00 (bootRun),并选择这个配置的实际里程碑目录。最后,在“任务”选项下输入bootRun以执行,如下图所示:

  1. 选择您想要执行的配置;点击绿色的运行按钮,或者使用以下截图中显示的Shift + F10键:

Gradle 任务

在第一章,一个不安全应用程序的剖析,和第二章,Spring Security 入门中,运行项目的 Gradle 任务将是tomcatRun。本书其余章节中,使用了 Spring Boot,并且启动项目的 Gradle 任务将是bootRun

在 STS 中启动示例

在 STS 中,还会创建一个运行配置,每个里程碑项目都需要包含相同的信息,如下图所示:

在 STS 中使用 HTTPS

书中某些章节示例代码(即第八章,使用 TLS 的客户端证书认证,第九章,开放到 OAuth2,和第十章,与中央认证服务单点登录)需要使用 HTTPS,以便示例代码能够工作。

所有项目都已配置为运行 HTTPS;大部分配置都在属性文件或 YAML 文件中管理。

现在,当您从 Gradle 在嵌入式 Tomcat 服务器上运行示例代码时,您可以连接到http://localhost:8080https://localhost:8443

Tomcat 中的 HTTPS 设置

在本节中,我们将概述如何在 Tomcat 中设置 HTTPS,以向我们的应用程序提供 TLS。所有包含的项目都在一个内嵌的 Tomcat 实例中运行,但我们还将介绍证书创建过程以及运行这些应用程序在独立的 Tomcat 实例中的一些技巧。

生成服务器证书

如果您还没有证书,您必须首先生成一个。如果您愿意,可以跳过这一步,并使用位于本书示例源代码中etc目录下的tomcat.keystore文件。在命令提示符下输入以下命令行:

$ 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

大多数值都是可以自解释的,但您需要确保对“您的名字是什么?”的回答是您将要从哪个主机访问您的网络应用程序。这是确保 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/javase/9/tools/keytool.htm。如果您遇到问题,您可能还会发现CAS SSL 故障排除和参考指南很有帮助(apereo.github.io/cas/5.1.x/installation/Troubleshooting-Guide.html)。

配置 Tomcat 连接器以使用 SSL

在本节中,我们将讨论如何通过执行以下步骤来配置 Tomcat 8.5 连接器以使用 SSL:

  1. 打开随下载提供的server.xml文件

    Tomcat 8.5。您可以在 Tomcat 服务器的主目录的conf目录中找到此文件。在您的server.xml文件中找到以下条目:

    <!--
    <Connector port="8443" protocol="HTTP/1.1" SSLEnabled="true" maxThreads="150"    
    scheme="https" secure="true" clientAuth="false" sslProtocol="TLS" />
  1. 取消注释连接器,并将keystoreFile属性的值更改为前一部分中 keystore 的位置。同时,确保更新keystorePass属性的值为您生成 keystore 时使用的密码。以下代码段显示了一个示例,但请确保更新keystoreFilekeystorePass的两个值:
    <Connector port="8443" protocol="HTTP/1.1" SSLEnabled="true"  maxThreads="150"    
    scheme="https" secure="true" clientAuth="false" sslProtocol="TLS"
    keystoreFile="/home/mickknutson/packt/etc/tomcat.keystore"
    keystorePass="changeit"/>
  1. 现在您应该能够启动 Tomcat 并通过https://locahost:8443/访问它。有关在 Tomcat 上配置 SSL 的更多信息,请参阅tomcat.apache.org/tomcat-8.5-doc/ssl-howto.html上的SSL 配置如何操作

基础 Tomcat SSL 终止指南

本节旨在帮助设置 Tomcat 以在使用 SSL 终止时使用 SSL。想法是外部实体(例如负载均衡器)管理 SSL 连接,而不是 Tomcat。这意味着客户端(即网页浏览器)到负载均衡器的连接通过 HTTPS 并且是安全的。负载均衡器到 Tomcat 的连接通过 HTTP 并且不安全。为了提供任何安全层次,负载均衡器到 Tomcat 的连接应该通过私有网络进行。

这种设置引起的问题是,Tomcat 现在会认为客户端使用 HTTP,因此会发送重定向,就好像有一个 HTTP 连接一样。为了解决这个问题,您可以修改配置,指示 Tomcat 它位于代理服务器后面。

以下示例是一个完整的连接器,将用于使用客户端证书验证的 Tomcat 部署:

    //server.xml

    <Connector
    scheme="https"
    secure="true"
    proxyPort="443"
    proxyHost="example.com"
    port="8443"
    protocol="HTTP/1.1"
    redirectPort="443"
    maxThreads="750"
    connectionTimeout="20000" />

server.xml文件可以在TOMCAT_HOME/conf/server.xml找到。如果您使用 Eclipse 或 Spring Tool Suite 与 Tomcat 交互,您将在包含server.xml文件的Servers项目中找到它。例如,如果您使用 Tomcat 8.5,则在 Eclipse 工作区中的路径可能类似于/Servers/Tomcat v8.5 Serverlocalhost-config/server.xml

请注意,这里没有提到keystore,因为 Tomcat 不管理 SSL 连接。这种设置将重写HttpServletRequest对象,使其相信连接是 HTTPS,从而正确执行重定向。但是,它仍然会接受 HTTP 连接。如果客户端也可以建立 HTTP 连接,可以创建一个单独的连接器-一个不包括 HTTPS 设置的连接器。然后,代理服务器可以根据原始请求是 HTTP 还是 HTTPS,将请求发送到适当的连接器。

更多信息,请参考 Tomcat Proxy How To 文档位于

请参考 tomcat.apache.org/tomcat-8.5-doc/proxy-howto.html。如果你正在处理不同的应用程序,你可以参考他们的文档来了解如何与代理服务器一起工作。

本部分包含了一个列出了书中使用的技术和概念的附加资源列表:

以下是一些 UI 技术:

  • JSP: 您可以在 Oracle 的网站上找到关于 JSPs 的更多信息,网址为javaee.github.io/tutorial/overview008.html#BNACM

  • Thymeleaf: 它是一个现代且吸引人的框架,为 JSPs 提供了极佳的选择。额外的好处是它提供了对 Spring 和 Spring Security 的即开箱支持。您可以在www.thymeleaf.org 找到更多关于 Thymeleaf 的信息。

posted @ 2024-05-24 10:56  绝不原创的飞龙  阅读(26)  评论(0编辑  收藏  举报