first commit

This commit is contained in:
张乾
2024-10-16 06:37:41 +08:00
parent 633f45ea20
commit 206fad82a2
3590 changed files with 680090 additions and 0 deletions

View File

@ -0,0 +1,100 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
00 开篇词 Spring Security为你的应用安全与职业之路保驾护航
你好我是鉴湘拉勾教育专栏《Spring Cloud 原理与实战》《Spring Boot 实战开发》《Spring 响应式编程实战》《ShardingSphere 核心原理精讲》的作者。
我拥有 10 年以上大型 Java EE 和分布式系统构建及优化经验,曾带领 100+ 人团队完成了基于 Spring 家族技术体系的亿级用户应用系统建设,对基于 Spring 框架进行系统开发和维护有着丰富的实践经验。
在从业生涯中,我带过不少项目,无论是传统的电商类系统,或者是互联网相关的智能终端平台,都需要考虑安全性问题。任何一个安全漏洞,都可能给一个产品乃至整个公司带来无法挽回的损失。
因此,作为系统的设计者和实现者,如何时刻确保系统具备高度的安全性,成了一个非常重要的问题。
说到安全性你可能会想到加解密算法、HTTPS 协议等常见的技术体系,但系统安全是一个综合性的主题,并非简单采用一些技术体系就能构建有效的解决方案。
我们以一个分布式环境下的应用场景为例。假设你要开发一个工单系统,而生成工单所依赖的用户订单信息维护在第三方订单系统中。为了生成工单,就必须让工单系统读取订单系统中的用户订单信息。
那么问题来了,工单系统如何获得用户的授权呢?一般我们想到的方法是用户将自己在订单管理平台上用户名和密码告诉工单系统,然后工单系统通过用户名和密码登录到订单管理平台并读取用户的订单记录,整个过程如下图所示:
订单系统用户认证和授权交互示意图
上述方案看起来没有什么问题,但如果你仔细分析一下,就会发现这个流程在安全性上存在一些漏洞。
比如,一旦用户修改了订单管理平台的密码,工单系统就无法正常访问了。为此,我们需要引入诸如 OAuth2 协议完成分布式环境下的认证和授权。
我们通过一张图简单对比一下 OAuth2 协议与传统实现方案:
OAuth2 协议与传统实现方案的对比图
但是想要实现 OAuth2 协议并没有那么简单。OAuth2 协议涉及的技术体系非常复杂需要综合考虑用户认证、密码加解密和存储、Token 生成和校验、分布式 Session 和公私钥管理以及完成各个客户端的权限管理。这时就需要引入专门的安全性开发框架而Spring Security 就是这样一款开发框架。
Spring Security 专门提供了 UAAUser Account and Authentication用户账户和认证服务封装了 OAuth2 协议用于管理用户账户、OAuth2 客户端以及用于鉴权的 Token。而 UAA 也只是 Spring Security 众多核心功能中的一部分。
你为什么需要学习这门课程?
Spring Security 是 Spring 家族中一款历史比较悠久的开发框架,针对 Web 应用程序提供了一系列强大的安全性功能体系。
事实上,对于 Web 应用程序而言,除了分布式环境下的认证和授权漏洞之外,常见的安全性问题还包括跨站点脚本攻击、跨站点请求伪造、敏感数据暴露、缺乏方法级访问控制等。针对这些安全性问题,我们需要全面设计并实现对应的安全性功能,而 Spring Security 已经为开发人员提供了相应的解决方案,包括:
用户信息管理;
敏感信息加解密;
用户认证;
权限控制;
跨站点请求伪造保护;
跨域支持;
全局安全方法;
单点登录;
……
同时,在普遍倡导用户隐私和数据价值的当下,掌握各种安全性相关技术的开发人员和架构师也成了稀缺人才,越发受到行业的认可和重视。
(职位信息来源:拉勾网)
对于开发人员而言,如何使用各种技术体系解决安全性问题是一大困惑。经验丰富的开发人员需要熟练使用 Spring Security 框架来应对业务发展的需求。例如,全面掌握 Spring Security 框架提供的认证、授权、方法级安全访问、OAuth2、JWT 等核心功能,构建自己对系统安全性设计的知识体系和解决方案。
而对于架构师而言,难点在于如何基于框架提供的功能并结合具体的业务场景,对框架进行扩展和定制化开发。这就需要他们对 Spring Security 中的用户认证和访问授权等核心功能的设计原理有充分的了解,能够从源码级别剖析框架的底层实现机制,进而满足更深层次的需求。
随着各种安全性问题的不断发生,可以说,安全性技术是构建个人技术体系不可缺少的一个环节,对于提升你的职业门槛也是一个重要的加分项。
这门课程是如何设计的?
任何技术体系的学习,都是一个边踩坑边前进的过程。我在从业生涯中,总结了一些使用 Spring Security 框架解决安全性问题时常见的、不可避免的痛点,大致可以分为以下几种。
看上去简单实则复杂Spring Security 的一大特点是内置了很多基础功能,用起来很容易,让你觉得开发起来好像很快很简单,但实际上这些内置功能大多采用了默认实现机制,例如用户登录和登出、密码加解密等。它将系统开发的复杂度隐藏得很深,如果你不了解框架的核心内容,也就无法理解这些默认实现机制。因此,一旦在开发过程中出现问题,你会觉得一头雾水,定位问题和解决问题的难度也会加大。
扩展性强大但不易掌握Spring Security 中提供的默认实现机制不一定能满足不同业务场景的需求这就需要我们通过框架开发扩展功能。Spring Security 提供了面向认证、授权的开放式接口,也提供了过滤器等一系列扩展性功能。这些功能都很强大,但在使用过程中你会发现只有充分理解这些功能背后的设计原理,才能合理利用它们。盲目使用这些扩展性功能只会导致系统不稳定。
技术体系和组件众多Spring Security 提供了一大批功能组件这些功能组件构成了庞大的技术体系。你会发现好不容易学会了一个组件碰到新的组件还是需要重新学习导致学习效率很低并且容易出错。同时Spring Security 中的很多功能都是集成了市面上的一些开源组件和方案,如果你不了解这些组件和方案,在使用过程中很可能出现一些莫名其妙的问题,影响开发节奏。
基于以上问题我根据自己多年的架构经验以及对安全性、Spring Security 框架的理解和实践,总结了一套由浅入深的学习路径,不仅可以带你掌握 Spring Security 框架的全局,还从实战角度出发,帮助你高效掌握基于 Spring Security 框架的系统安全性设计方法和开发技巧。
本专栏分为以下四大模块,能够帮助你解决前面提到的开发上的痛点。
模块一:基础功能篇。这部分我将介绍 Spring Security 的一些基础性功能,包括认证、授权和加密。这些功能是 Spring Security 框架的入口,可以说使用这个框架就离不开这些功能,而框架的其他功能也都依赖于这些基础特性,所以我不仅会介绍它们的使用方法,还将对原理进行展开。通过这一模块的学习,你可以在日常开发过程中完成对用户身份的认证、访问的授权以及集成加密机制,从而为 Web 应用程序添加基础的安全性功能。
模块二:高级主题篇。这部分功能面向特定需求,用于构建比较复杂的应用场景,包括过滤器、跨站点请求伪造保护、跨域资源共享,以及针对非 Web 应用程序的全局方法安全机制。通过这一模块的学习,一方面,针对 Web 应用程序,你可以使用过滤器来定制化各种安全性策略,并集成跨站请求伪造保护和跨域访问等功能。另一方面,针对非 Web 类应用程序,你也可以使用 Spring Security 来完成方法级别的安全控制。
模块三OAuth2 与微服务篇。这部分内容关注微服务开发框架Spring Cloud 与 Spring Security 之间的整合,我们将对 OAuth2 协议和 JWT 全面展开讲解,并使用这些技术体系构建安全的微服务系统,以及单点登录系统。微服务是目前系统开发的主流技术体系,而微服务的安全性也是日常开发的一个重要主题。通过这一模块的学习,你可以掌握如何为目前主流的微服务架构添加安全性,并使用 OAuth2 协议和 JWT 实现对服务级别的授权访问。
模块四:框架扩展篇。这部分内容是对 Spring Security 框架在应用上的一些扩展,包括在 Spring Security 中引入全新的响应式编程技术,以及如何对应用程序安全性进行测试的系统方法。通过这一模块的学习,你将掌握对 Spring Security 框架所提供的各项功能进行测试的系统方法。同时,随着 Spring 5 的发布也涌现出了响应式编程这种新型技术体系,新版本的 Spring Security 中也提供了对响应式编程的全面支持,本专栏对响应式 Spring Security 也做了系统介绍。
从学习的角度来讲Spring Security 中的一些功能看上去比较简单,但用起来并非容易全面掌握,经常会因为某一些细小的配置导致整个功能无法使用,能发现问题但不容易找到原因。因此,虽然 Spring Security 让你只花 20% 的时间就可以解决 80% 的问题,但是剩下 20% 的问题需要我们通过系统性的学习去弄懂,而学习 Spring Security 是有一定的方法和套路的。这就需要有系统化的讲解过程,也是本专栏价值所在。
此外,各个 Spring Security 核心组件以及使用方式我都会按照完整的案例分析给出详细的代码实现方案方便你进行学习和改造。课程配套代码你可以在https://github.com/lagouEdAnna/SpringSecurity-jianxiang进行下载。
讲师寄语
在现代互联网应用系统开发的过程中,安全性越来越受到重视。如何确保系统没有安全性漏洞是一大挑战,我们需要引入对应的技术体系和开发框架,以满足不断增长的安全性需求。面对这种困境,引入 Spring 家族生态中的重要组成部分——Spring Security 开源框架,可以称得上是一个绝佳选择。

View File

@ -0,0 +1,140 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
01 顶级框架Spring Security 是一款什么样的安全性框架?
在开篇词中,我们描述了 Web 应用程序的安全性需求,并引出了专门用来满足这些需求的 Spring Security 框架。Spring Security 是 Spring 家族中历史比较悠久的框架,具备完整而强大的功能体系。从今天开始,我们将正式学习 Spring Security并围绕它的功能体系展开讨论。
初识 Spring Security
其实在 Spring Boot 出现之前Spring Security 就已经诞生多年了。但 Spring Security 的发展一直都不是很顺利,主要问题在于应用程序中集成和配置 Spring Security 框架的过程比较复杂。但是随着 Spring Boot 的兴起,基于 Spring Boot 所提供的针对 Spring Security 的自动配置方案,开发人员可以零配置使用 Spring Security。如果想要在 Spring Boot 应用程序中使用 Spring Security只需要在 Maven 工程的 pom 文件中添加如下依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
接下来,让我们构建一个简单的 HTTP 端点,如下所示:
@RestController
public class DemoController {
@GetMapping("/hello")
public String hello() {
return "Hello World!";
}
}
现在,启动这个 Spring Boot 应用程序,然后通过浏览器访问”/hello”端点。你可能希望得到的是”Hello World!“这个输出结果,但事实上,浏览器会跳转到一个如下所示的登录界面:
Spring Security 内置的登录界面
那么,为什么会弹出这个登录界面呢?原因就在于我们添加了 spring-boot-starter-security 依赖之后Spring Security 为应用程序自动嵌入了用户认证机制。
接下来,我们围绕这个登录场景,分析如何获取登录所需的用户名和密码。我们注意到在 Spring Boot 的控制台启动日志中,出现了如下所示的一行日志:
Using generated security password: 707d7469-631f-4d92-ab71-3809620fe0dc
这行日志就是 Spring Security 生成的一个密码而用户名则是系统默认的“user”。通过输入正确的用户名和密码浏览器就会输出”Hello World!“这个响应结果。你可以做一些尝试。
上述过程演示的就是 Spring Security 提供的认证功能,也是 Spring Security 众多功能中的一项基础功能。接下来,就让我们一同挖掘 Spring Security 中的完整功能体系。
Spring Security 功能体系
Spring Security 提供的是一整套完整的安全性解决方案。面向不同的业务需求和应用场景Spring Security 分别提供了对应的安全性功能,在接下来的内容中,我们将从单体应用、微服务架构以及响应式系统这三个维度对这些功能展开讨论。
Spring Security 与单体应用
在软件系统中我们可以把需要访问的内容定义为是一种资源Resource而安全性设计的核心目标就是对这些资源进行保护确保对它们的访问是安全可控的。例如在一个 Web 应用程序中,对外暴露的 HTTP 端点就可以被理解为是资源。对于资源的安全性访问业界也存在一些常见的技术体系。在讲解这些技术体系之前我们先来理解在安全领域中非常常见但又容易混淆的两个概念即认证Authentication和授权Authorization
首先我们需要明确,所谓认证,解决的是“你是谁”这一个问题,也就是说对于每一次访问请求,系统都能判断出访问者是否具有合法的身份标识。
一旦明确 “你是谁”,下一步就可以判断“你能做什么”,这个步骤就是授权。通用的授权模型大多基于权限管理体系,即对资源、权限、角色和用户的一种组合处理。
如果我们将认证和授权结合起来,就构成了对系统中的资源进行安全性管理的最常见解决方案,即先判断资源访问者的有效身份,再来确定其是否有对这个资源进行访问的合法权限,如下图所示:
基于认证和授权机制的资源访问安全性示意图
上图代表的是一种通用方案而不同的应用场景和技术体系下可以衍生出很多具体的实现策略。Web 应用系统中的认证和授权模型与上图类似,但在具体设计和实现过程中也有其特殊性。
针对认证,这部分的需求相对比较明确。显然我们需要构建一套完整的存储体系来保存和维护用户信息,并且确保这些用户信息在处理请求的过程中能够得到合理的利用。
而针对授权,情况可能会比较复杂。对于某一个特定的 Web 应用程序而言,我们面临的第一个问题是如何判断一个 HTTP 请求是否具备访问自己的权限。更进一步,就算这个请求具备访问该应用程序的权限,但并不意味着该请求能够访问应用程序所有的 HTTP 端点。某些核心功能需要具备较高的权限才能访问,而有些则不需要。这就是我们需要解决的第二个问题,如何对访问的权限进行精细化管理?如下图所示:
Web 应用程序访问授权效果示意图
在上图中,我们假设该请求具备对应用程序中端点 2、3、4 的访问权限,但不具备访问端点 1 的权限。想要达到这种效果,一般的做法是引入角色体系。我们对不同的用户设置不同等级的角色,角色等级不同对应的访问权限也不同。而每一个请求都可以绑定到某一个角色,也就具备了访问权限。
接下来,我们把认证和授权结合起来,梳理出 Web 应用程序访问场景下的安全性实现方案,如下图所示:
单体服务下的认证和授权整合示意图
结合示意图我们可以看到,通过请求传递用户凭证完成用户认证,然后根据该用户信息中具备的角色信息获取访问权限,并最终完成对 HTTP 端点的访问授权。
围绕认证和授权,我们还需要一系列的额外功能确保整个流程得以实现。这些功能包括用于密码保护的加解密机制、用于实现方法级的安全访问,以及支持跨域等,这些功能在我们专栏的后续内容中都会一一展开讨论。
Spring Security 与微服务架构
微服务架构的情况要比单体应用复杂很多,因为涉及了服务与服务之间的调用关系。我们继续沿用“资源”这个概念,对应到微服务系统中,服务提供者充当的角色就是资源的服务器,而服务消费者就是客户端。所以各个服务本身既可以是客户端,也可以作为资源服务器,或者两者兼之。
接下来,我们把认证和授权结合起来,梳理出微服务访问场景下的安全性实现方案,如下图所示:
微服务架构下的认证和授权整合示意图
可以看到,与单体应用相比,在微服务架构中需要把认证和授权的过程进行集中化管理,所以在上图中出现了一个授权中心。 授权中心会获取客户端请求中所带有的身份凭证信息,然后基于凭证信息生成一个 Token这个 Token 中就包含了权限范围和有效期。
客户端获取 Token 之后就可以基于这个 Token 发起对微服务的访问。这个时候,服务的提供者需要对这个 Token 进行认证,并根据 Token 的权限范围和有效期从授权中心获取该请求能够访问的特定资源。在微服务系统中,对外的资源表现形式同样可以理解为一个个 HTTP 端点。
上图中关键点就在于构建用于生成和验证 Token 的授权中心为此我们需要引入OAuth2 协议。OAuth2 协议为我们在客户端程序和资源服务器之间设置了一个授权层,并确保 Token 能够在各个微服务中进行有效传递,如下图所示:
OAuth2 协议在服务访问场景中的应用
OAuth2 是一个相对复杂的协议综合应用摘要认证、签名认证、HTTPS 等安全性手段,需要提供 Token 生成和校验以及公私钥管理等功能,同时需要开发者入驻并进行权限粒度控制。一般我们应该避免自行实现这类复杂的协议,倾向于借助于特定工具以免重复造轮子。而 Spring Security 为我们提供了实现这一协议的完整解决方案,我们可以使用该框架完成适用于微服务系统中的认证授权机制。
Spring Security 与响应式系统
随着 Spring 5 的发布我们迎来了响应式编程Reactive Programming的全新发展时期。响应式编程是 Spring 5 最核心的新功能,也是 Spring 家族目前重点推广的技术体系。Spring 5 的响应式编程模型以 Project Reactor 库为基础,后者则实现了响应式流规范。
事实上Spring Boot 从 2.x 版本开始也全面依赖 Spring 5。同样在 Spring Security 中用户账户体系的建立、用户认证和授权、方法级别的安全访问、OAuth2 协议等传统开发模式下具备的安全性功能都具备对应的响应式版本。
小结与预告
本讲是整个专栏内容的第一讲,我们通过一个简单的示例引入了 Spring Security 框架,并基于日常开发的安全需求,全面剖析了 Spring Security 框架具备的功能体系。不同的功能对应不同的应用场景,在普通的单体应用、微服务架构、响应式系统中都可以使用 Spring Security 框架提供的各种功能确保系统的安全性。
本讲内容总结如下:
这里给你留一道思考题:针对单体应用和微服务架构,你能分别描述它们所需要的认证和授权机制吗?
接下来我们将正式进入到 Spring Security 框架各项功能的学习过程中,首先介绍的就是用户认证功能。下一讲,我们将讨论如何基于 Spring Security 对用户进行有效的认证。

View File

@ -0,0 +1,275 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
02 用户认证:如何使用 Spring Security 构建用户认证体系?
上一讲中,我们引入了 Spring Security 框架,并梳理了它的各项核心功能。从今天开始,我们就对这些功能一一展开讲解,首先要讨论的就是用户认证功能。用户认证涉及用户账户体系的构建,也是实现授权管理的前提。在 Spring Security 中,实现用户认证的方式有很多,下面我们就结合框架提供的配置体系进行梳理。
Spring Security 配置体系
在 Spring Security 中,因为认证和授权等功能通常都不止有一种实现方法,所以框架开发了一套完整的配置体系来对这些功能进行灵活设置。开发人员在使用认证和授权等功能时就依赖于如何合理利用和扩展这套配置体系。
例如,针对用户账户存储这个切入点,就可以设计出多种不同的策略。我们可以把用户名和密码保存在内存中,作为一种轻量级的实现方式。更常见的,也可以把这些认证信息存储在关系型数据库中。当然,如果我们使用了 LDAP 协议,那么文件系统也是一种不错的存储媒介。
显然,针对这些可选择的实现方式,需要为开发人员提供一种机制以便他们能够根据自身的需求进行灵活的设置,这就是配置体系的作用。
同时,你应该也注意到了,在上一讲的示例中,我们没有进行任何的配置也能让 Spring Security 发挥作用这就说明框架内部的功能采用了特定的默认配置。就用户认证这一场景而言Spring Security 内部就初始化了一个默认的用户名“user”并且在应用程序启动时自动生成一个密码。当然通过这种方式自动生成的密码在每次启动应用时都会发生变化并不适合面向正式的应用。
我们可以通过翻阅框架的源代码https://github.com/spring-projects/spring-security来进一步理解 Spring Security 中的一些默认配置。在 Spring Security 中,初始化用户信息依赖的配置类是 WebSecurityConfigurer 接口,该接口实际上是一个空接口,继承了更为基础的 SecurityConfigurer 接口。
在日常开发中,通常不需要我们自己实现这个接口,而是使用 WebSecurityConfigurerAdapter 类来简化该配置类的使用方式。而在 WebSecurityConfigurerAdapter 中我们发现了如下所示的 configure 方法:
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin().and()
.httpBasic();
}
上述代码就是 Spring Security 中作用于用户认证和访问授权的默认实现,这里用到了多个常见的配置方法。再次回想上一讲中我们讲到的,一旦在代码类路径中引入 Spring Security 框架之后,访问任何端点时就会弹出一个登录界面用来完成用户认证。认证是授权的前置流程,认证结束之后就可以进入到授权环节。
结合这些配置方法,我们来简单分析一下这种默认效果是如何实现的:
首先,通过 HttpSecurity 类的 authorizeRequests() 方法对所有访问 HTTP 端点的 HttpServletRequest 进行限制;
然后anyRequest().authenticated() 语句指定了对于所有请求都需要执行认证,也就是说没有通过认证的用户就无法访问任何端点;
接着formLogin() 语句用于指定使用表单登录作为认证方式,也就是会弹出一个登录界面;
最后httpBasic() 语句表示可以使用 HTTP 基础认证Basic Authentication方法来完成认证。
在日常开发过程中,我们可以继承 WebSecurityConfigurerAdapter 类并且覆写上述的 configure() 方法来完成配置工作。而在 Spring Security 中,存在一批类似于 WebSecurityConfigurerAdapter 的配置类。
配置体系是开发人员使用 Spring Security 框架的主要手段之一关于配置体系的讨论会贯穿我们整个专栏的始终。随着内容深度的演进Spring Security 所提供的全面而灵活的配置功能也将一一展现在你的面前。
实现 HTTP 基础认证和表单登录认证
在上文中,我们提到了 httpBasic() 和 formLogin() 这两种用于控制用户认证的实现手段分别代表了HTTP 基础认证和表单登录认证。在构建 Web 应用程序时,我们也可以在 Spring Security 提供的认证机制的基础上进行扩展,以满足日常开发需求。
HTTP 基础认证
HTTP 基础认证的原理比较简单,只需通过 HTTP 协议的消息头携带用户名和密码进行登录验证。在上一讲中,我们已经通过浏览器简单验证了用户登录操作。今天,我们将引入 Postman 这款可视化的 HTTP 请求工具来对登录的请求和响应过程做进一步分析。
在 Postman 中我们直接访问http://localhost:8080/hello端点会得到如下所示的响应
{
"timestamp": "2021-02-08T03:45:21.512+00:00",
"status": 401,
"error": "Unauthorized",
"message": "",
"path": "/hello"
}
显然,响应码 401 告诉我们没有访问该地址的权限。同时在响应中出现了一个“WWW-Authenticate”消息头其值为“Basic realm=“Realm””这里的 Realm 表示 Web 服务器中受保护资源的安全域。
现在,让我们来执行 HTTP 基础认证可以通过设置认证类型为“Basic Auth”并输入对应的用户名和密码来完成对 HTTP 端点的访问,设置界面如下所示:
使用 Postman 完成 HTTP 基础认证信息的设置
现在查看 HTTP 请求,可以看到 Request Header 中添加了 Authorization 标头格式为Authorization:<type> <credentials>。这里的 type 就是“Basic”而 credentials 则是这样一个字符串:
dXNlcjo5YjE5MWMwNC1lNWMzLTQ0YzctOGE3ZS0yNWNkMjY3MmVmMzk=
这个字符串就是将用户名和密码组合在一起,再经过 Base64 编码得到的结果。而我们知道 Base64 只是一种编码方式,并没有集成加密机制,所以本质上传输的还是明文形式。
当然,想要在应用程序中启用 HTTP 基础认证还是比较简单的,只需要在 WebSecurityConfigurerAdapter 的 configure 方法中添加如下配置即可:
protected void configure(HttpSecurity http) throws Exception {
http.httpBasic();
}
HTTP 基础认证比较简单,没有定制的登录页面,所以单独使用的场景比较有限。在使用 Spring Security 时,我们一般会把 HTTP 基础认证和接下来要介绍的表单登录认证结合起来一起使用。
表单登录认证
在 WebSecurityConfigurerAdapter 的 configure() 方法中,一旦配置了 HttpSecurity 的 formLogin() 方法,就启动了表单登录认证,如下所示:
protected void configure(HttpSecurity http) throws Exception {
http.formLogin();
}
formLogin() 方法的执行效果就是提供了一个默认的登录界面,如下所示:
Spring Security 默认的登录界面
我们已经在上一讲中看到过这个登录界面。对于登录操作而言,这个登录界面通常都是定制化的,同时,我们也需要对登录的过程和结果进行细化控制。此时,我们就可以通过如下所示的配置内容来修改系统的默认配置:
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.formLogin()
.loginPage("/login.html")//自定义登录页面
.loginProcessingUrl("/action")//登录表单提交时的处理地址
.defaultSuccessUrl("/index");//登录认证成功后的跳转页面
}
可以看到,这里我们对登录界面、登录请求处理地址以及登录成功后的跳转界面进行了定制化。
配置 Spring Security 用户认证体系
讲完配置体系,现在让我们回到用户认证场景。因为 Spring Security 默认提供的用户名是固定的,而密码会随着每次应用程序的启动而变化,所以很不灵活。在 Spring Boot 中,我们可以通过在 application.yml 配置文件中添加如下所示的配置项来改变这种默认行为:
spring:
security:
user:
name: spring
password: spring_password
现在让我们重启应用,就可以使用上述用户名和密码完成登录。基于配置文件的用户信息存储方案简单直接,但显然也缺乏灵活性,因为我们无法在系统运行时动态加载对应的用户名和密码。因此,在现实中,我们主要还是通过使用 WebSecurityConfigurerAdapter 配置类来改变默认的配置行为。
通过前面的内容中,我们已经知道可以通过 WebSecurityConfigurerAdapter 类的 configure(HttpSecurity http) 方法来完成认证。认证过程涉及 Spring Security 中用户信息的交互,我们可以通过继承 WebSecurityConfigurerAdapter 类并且覆写其中的 configure(AuthenticationManagerBuilder auth) 的方法来完成对用户信息的配置工作。请注意这是两个不同的 configure() 方法。
针对 WebSecurityConfigurer 配置类我们首先需要明确配置的内容。实际上初始化用户信息非常简单只需要指定用户名Username、密码Password和角色Role这三项数据即可。在 Spring Security 中,基于 AuthenticationManagerBuilder 工具类为开发人员提供了基于内存、JDBC、LDAP 等多种验证方案。
接下来,我们就围绕 AuthenticationManagerBuilder 提供的功能来实现多种用户信息存储方案。
使用基于内存的用户信息存储方案
我们先来看如何使用 AuthenticationManagerBuilder 完成基于内存的用户信息存储方案。实现方法就是调用 AuthenticationManagerBuilder 的 inMemoryAuthentication 方法,示例代码如下:
@Override
protected void configure(AuthenticationManagerBuilder builder) throws Exception {
builder.inMemoryAuthentication()
.withUser("spring_user").password("password1").roles("USER")
.and()
.withUser("spring_admin").password("password2").roles("USER", "ADMIN");
}
从上面的代码中我们可以看到系统中存在“spring_user”和“spring_admin”这两个用户其密码分别是”password1”和”password2”在角色上也分别代表着普通用户 USER 以及管理员 ADMIN。
请注意,这里的 roles() 方法背后使用的还是authorities() 方法。通过 roles() 方法Spring Security 会在每个角色名称前自动添加“ROLE_”前缀所以我们也可以通过如下所示的代码实现同样的功能
@Override
protected void configure(AuthenticationManagerBuilder builder) throws Exception {
builder.inMemoryAuthentication()
.withUser("spring_user").password("password1").authorities("ROLE_USER")
.and()
.withUser("spring_admin").password("password2").authorities("ROLE_USER", "ROLE_ADMIN");
}
可以看到,基于内存的用户信息存储方案实现也比较简单,但同样缺乏灵活性,因为用户信息是写死在代码里的。所以,我们接下来就要引出另一种更为常见的用户信息存储方案——数据库存储。
使用基于数据库的用户信息存储方案
既然是将用户信息存储在数据库中,势必需要创建表结构。我们可以在 Spring Security 的源文件org/springframework/security/core/userdetails/jdbc/users.ddl中找到对应的 SQL 语句,如下所示:
create table users(username varchar_ignorecase(50) not null primary key,password varchar_ignorecase(500) not null,enabled boolean not null);
create table authorities (username varchar_ignorecase(50) not null,authority varchar_ignorecase(50) not null,constraint fk_authorities_users foreign key(username) references users(username));
create unique index ix_auth_username on authorities (username,authority);
一旦我们在自己的数据库中创建了这两张表,并添加了相应的数据,就可以直接通过注入一个 DataSource 对象进行用户数据的查询,如下所示:
@Autowired
DataSource dataSource;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.jdbcAuthentication().dataSource(dataSource)
.usersByUsernameQuery("select username, password, enabled from Users " + "where username=?")
.authoritiesByUsernameQuery("select username, authority from UserAuthorities " + "where username=?")
.passwordEncoder(new BCryptPasswordEncoder());
}
这里使用了 AuthenticationManagerBuilder 的 jdbcAuthentication 方法来配置数据库认证方式,内部则使用了 JdbcUserDetailsManager 这个工具类。在该类中,就定义了各种用于数据库查询的 SQL 语句,以及使用 JdbcTemplate 完成数据库访问的具体实现方法。
请你注意这里我们用到了一个passwordEncoder() 方法,这是 Spring Security 中提供的一个密码加解密器我们会在“密码安全Spring Security 中包含哪些加解密技术?”一讲中进行详细的讨论。
小结与预告
这一讲我们详细介绍了如何使用 Spring Security 构建用户认证体系的系统方法。在 Spring Security 中,认证相关的功能都是可以通过配置体系进行定制化开发和管理的。通过简单的配置方法,我们可以组合使用 HTTP 基础认证和表单登录认证,也可以分别基于内存以及基于数据库方案来存储用户信息,这些功能都是 Spring Security 内置的。
本讲内容总结如下:
最后我想给你留一道思考题:你知道在 Spring Security 中有哪几种存储用户信息的实现方案吗?欢迎在留言区和我分享你的想法。

View File

@ -0,0 +1,667 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
03 认证体系:如何深入理解 Spring Security 用户认证机制?
上一讲,我们给出了 Spring Security 中实现用户认证的系统方法,可以发现整个实现过程还是比较简单的,开发人员只需要通过一些配置方法就能完成复杂的处理逻辑。这种简单性得益于 Spring Security 对用户认证过程的提炼和抽象。今天我们就围绕这个话题展开讨论,继续探究 Spring Security 中的用户和认证对象,以及如何基于这些对象完成定制化的用户认证方案。
Spring Security 中的用户和认证
Spring Security 中的认证过程由一组核心对象组成,大致可以分成两大类,一类是用户对象,一类是认证对象,下面我们来具体了解一下。
Spring Security 中的用户对象
Spring Security 中的用户对象用来描述用户并完成对用户信息的管理涉及UserDetails、GrantedAuthority、UserDetailsService 和 UserDetailsManager这四个核心对象。
UserDetails描述 Spring Security 中的用户。
GrantedAuthority定义用户的操作权限。
UserDetailsService定义了对 UserDetails 的查询操作。
UserDetailsManager扩展 UserDetailsService添加了创建用户、修改用户密码等功能。
这四个对象之间的关联关系如下图所示,显然,对于由 UserDetails 对象所描述的一个用户而言,它应该具有 1 个或多个能够执行的 GrantedAuthority
Spring Security 中的四大核心用户对象
结合上图,我们先来看承载用户详细信息的 UserDetails 接口,如下所示:
public interface UserDetails extends Serializable {
//获取该用户的权限信息
Collection<? extends GrantedAuthority> getAuthorities();
//获取密码
String getPassword();
//获取用户名
String getUsername();
//判断该账户是否已失效
boolean isAccountNonExpired();
//判断该账户是否已被锁定
boolean isAccountNonLocked();
//判断该账户的凭证信息是否已失效
boolean isCredentialsNonExpired();
//判断该用户是否可用
boolean isEnabled();
}
通过 UserDetails我们可以获取用户相关的基础信息并判断其当前状态。同时我们也可以看到 UserDetails 中保存着一组 GrantedAuthority 对象。而 GrantedAuthority 指定了一个方法用来获取权限信息,如下所示:
public interface GrantedAuthority extends Serializable {
//获取权限信息
String getAuthority();
}
UserDetails 存在一个子接口 MutableUserDetails从命名上不难看出后者是一个可变的 UserDetails而可变的内容就是密码。MutableUserDetails 接口的定义如下所示:
interface MutableUserDetails extends UserDetails {
//设置密码
void setPassword(String password);
}
如果我们想要在应用程序中创建一个 UserDetails 对象,可以使用如下所示的链式语法:
UserDetails user = User.withUsername("jianxiang")
.password("123456")
.authorities("read", "write")
.accountExpired(false)
.disabled(true)
.build();
Spring Security 还专门提供了一个 UserBuilder 对象来辅助构建 UserDetails使用方式也类似
User.UserBuilder builder =
User.withUsername("jianxiang");
UserDetails user = builder
.password("12345")
.authorities("read", "write")
.accountExpired(false)
.disabled(true)
.build();
在 Spring Security 中,针对 UserDetails 专门提供了一个 UserDetailsService该接口用来管理 UserDetails定义如下
public interface UserDetailsService {
//根据用户名获取用户信息
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
而 UserDetailsManager 继承了 UserDetailsService并提供了一批针对 UserDetails 的操作接口,如下所示:
public interface UserDetailsManager extends UserDetailsService {
//创建用户
void createUser(UserDetails user);
//更新用户
void updateUser(UserDetails user);
//删除用户
void deleteUser(String username);
//修改密码
void changePassword(String oldPassword, String newPassword);
//判断指定用户名的用户是否存在
boolean userExists(String username);
}
这样,几个核心用户对象之间的关联关系就很清楚了,接下来我们需要进一步明确具体的实现过程。
我们来看 UserDetailsManager 的两个实现类,一个是基于内存存储的 InMemoryUserDetailsManager一个是基于关系型数据库存储的 JdbcUserDetailsManager。
这里,我们以 JdbcMemoryUserDetailsManager 为例展开分析,它的 createUser 方法如下所示:
public void createUser(final UserDetails user) {
validateUserDetails(user);
getJdbcTemplate().update(createUserSql, ps -> {
ps.setString(1, user.getUsername());
ps.setString(2, user.getPassword());
ps.setBoolean(3, user.isEnabled());
int paramCount = ps.getParameterMetaData().getParameterCount();
if (paramCount > 3) {
ps.setBoolean(4, !user.isAccountNonLocked());
ps.setBoolean(5, !user.isAccountNonExpired());
ps.setBoolean(6, !user.isCredentialsNonExpired());
}
});
if (getEnableAuthorities()) {
insertUserAuthorities(user);
}
}
可以看到,这里直接使用了 Spring 框架中的 JdbcTemplate 模板工具类实现了数据的插入,同时完成了 GrantedAuthority 的存储。
UserDetailsManager 是一条相对独立的代码线,为了完成用户信息的配置,还存在另一条代码支线,即 UserDetailsManagerConfigurer。该类维护了一个 UserDetails 列表,并提供了一组 withUser 方法完成用户信息的初始化,如下所示:
private final List<UserDetails> users = new ArrayList<>();
public final C withUser(UserDetails userDetails) {
this.users.add(userDetails);
return (C) this;
}
而 withUser 方法返回的是一个 UserDetailsBuilder 对象,该对象内部使用了前面介绍的 UserBuilder 对象,因此可以实现类似 .withUser(“spring_user”).password(“password1”).roles(“USER”) 这样的链式语法,完成用户信息的设置。这也是上一讲中,我们在介绍基于内存的用户信息存储方案时使用的方法。
作为总结,我们也梳理了 Spring Security 中与用户对象相关的一大批实现类,它们之间的关系如下图所示:
Spring Security 中用户对象相关类结构图
Spring Security 中的认证对象
有了用户对象,我们就可以讨论具体的认证过程了,首先来看认证对象 Authentication如下所示
public interface Authentication extends Principal, Serializable {
//安全主体具有的权限
Collection<? extends GrantedAuthority> getAuthorities();
//证明主体有效性的凭证
Object getCredentials();
//认证请求的明细信息
Object getDetails();
//主体的标识信息
Object getPrincipal();
//认证是否通过
boolean isAuthenticated();
//设置认证结果
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}
认证对象代表认证请求本身并保存该请求访问应用程序过程中涉及的各个实体的详细信息。在安全领域请求访问该应用程序的用户通常被称为主体Principal在 JDK 中存在一个同名的接口,而 Authentication 扩展了这个接口。
显然Authentication 只代表了认证请求本身,而具体执行认证的过程和逻辑需要由专门的组件来负责,这个组件就是 AuthenticationProvider定义如下
public interface AuthenticationProvider {
//执行认证,返回认证结果
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
//判断是否支持当前的认证对象
boolean supports(Class<?> authentication);
}
讲到这里,你可能会认为 Spring Security 是直接使用 AuthenticationProvider 接口完成用户认证的,其实不然。如果你翻阅 Spring Security 的源码,会发现它使用了 AuthenticationManager 接口来代理 AuthenticationProvider 提供的认证功能。这里,我们以 InMemoryUserDetailsManager 中的 changePassword 为例,分析用户认证的执行过程(为了展示简洁,部分代码做了裁剪):
public void changePassword(String oldPassword, String newPassword) {
Authentication currentUser = SecurityContextHolder.getContext()
.getAuthentication();
if (currentUser == null) {
throw new AccessDeniedException(
"Can't change password as no Authentication object found in context "
+ "for current user.");
}
String username = currentUser.getName();
if (authenticationManager != null) {
authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(
username, oldPassword));
}
else {
}
MutableUserDetails user = users.get(username);
if (user == null) {
throw new IllegalStateException("Current user doesn't exist in database.");
}
user.setPassword(newPassword);
}
可以看到,这里使用了 AuthenticationManager 而不是 AuthenticationProvider 中的 authenticate() 方法来执行认证。同时,我们也注意到这里出现了 UsernamePasswordAuthenticationToken 类,这就是 Authentication 接口的一个具体实现类,用来存储用户认证所需的用户名和密码信息。
同样作为总结,我们也梳理了 Spring Security 中与认证对象相关的一大批核心类,它们之间的关系如下所示:
Spring Security 中认证的对象相关类结构图
实现定制化用户认证方案
通过前面的分析我们明确了用户信息存储的实现过程实际上是可以定制化的。Spring Security 所做的工作只是把常见的、符合一般业务场景的实现方式嵌入到了框架中。如果有特殊的场景,开发人员完全可以实现自定义的用户信息存储方案。
现在,我们已经知道 UserDetails 接口代表着用户详细信息,而负责对 UserDetails 进行各种操作的则是 UserDetailsService 接口。因此,实现定制化用户认证方案主要就是实现 UserDetails 和 UserDetailsService 这两个接口。
扩展 UserDetails
扩展 UserDetails 的方法就是直接实现该接口,例如我们可以构建如下所示的 SpringUser 类:
public class SpringUser implements UserDetails {
private static final long serialVersionUID = 1L;
private Long id;
private final String username;
private final String password;
private final String phoneNumber;
//省略 getter/setter
@Override
public String getUsername() {
return username;
}
@Override
public String getPassword() {
return password;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Arrays.asList(new SimpleGrantedAuthority("ROLE_USER"));
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
显然,这里使用了一种最简单的方法来满足 UserDetails 中各个接口的实现需求。一旦我们构建了这样一个 SpringUser 类,就可以创建对应的表结构存储类中定义的字段。同时,我们也可以基于 Spring Data JPA 来创建一个自定义的 Repository如下所示
public interface SpringUserRepository extends CrudRepository<SpringUser, Long> {
SpringUser findByUsername(String username);
}
SpringUserRepository 扩展了 Spring Data 中的 CrudRepository 接口,并提供了一个方法名衍生查询 findByUsername。
扩展 UserDetailsService
接着,我们来实现 UserDetailsService 接口,如下所示:
@Service
public class SpringUserDetailsService
implements UserDetailsService {
@Autowired
private SpringUserRepository repository;
@Override
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
SpringUser user = repository.findByUsername(username);
if (user != null) {
return user;
}
throw new UsernameNotFoundException(
"SpringUser '" + username + "' not found");
}
}
我们知道 UserDetailsService 接口只有一个 loadUserByUsername 方法需要实现。因此,我们基于 SpringUserRepository 的 findByUsername 方法,根据用户名从数据库中查询数据。
扩展 AuthenticationProvider
扩展 AuthenticationProvider 的过程就是提供一个自定义的 AuthenticationProvider 实现类。这里我们以最常见的用户名密码认证为例,梳理自定义认证过程所需要实现的步骤,如下所示:
自定义 AuthenticationProvider 的实现流程图
上图中的流程并不复杂,首先我们需要通过 UserDetailsService 获取一个 UserDetails 对象,然后根据该对象中的密码与认证请求中的密码进行匹配,如果一致则认证成功,反之抛出一个 BadCredentialsException 异常。示例代码如下所示:
@Component
public class SpringAuthenticationProvider implements AuthenticationProvider {
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public Authentication authenticate(Authentication authentication) {
String username = authentication.getName();
String password = authentication.getCredentials().toString();
UserDetails user = userDetailsService.loadUserByUsername(username);
if (passwordEncoder.matches(password, user.getPassword())) {
return new UsernamePasswordAuthenticationToken(username, password, u.getAuthorities());
} else {
throw new BadCredentialsException("The username or password is wrong!");
}
}
@Override
public boolean supports(Class<?> authenticationType) {
return authenticationType.equals(UsernamePasswordAuthenticationToken.class);
}
}
这里我们同样使用了 UsernamePasswordAuthenticationToken 来传递用户名和密码,并使用一个 PasswordEncoder 对象校验密码。
整合定制化配置
最后,我们创建一个 SpringSecurityConfig 类,该类继承了 WebSecurityConfigurerAdapter 配置类。这次,我们将使用自定义的 SpringUserDetailsService 来完成用户信息的存储和查询,需要对原有配置策略做一些调整。调整之后的完整 SpringSecurityConfig 类如下所示:
@Configuration
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService springUserDetailsService;
@Autowired
private AuthenticationProvider springAuthenticationProvider;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(springUserDetailsService)
.authenticationProvider(springAuthenticationProvider);
}
}
这里我们注入了 SpringUserDetailsService 和 SpringAuthenticationProvider并将其添加到 AuthenticationManagerBuilder 中,这样 AuthenticationManagerBuilder 将基于这个自定义的 SpringUserDetailsService 完成 UserDetails 的创建和管理,并基于自定义的 SpringAuthenticationProvider 完成用户认证。
小结与预告
这一讲我们基于 Spring Security 提供的用户认证功能分析了其背后的实现过程。我们的切入点在于分析与用户和认证相关的各个核心类,并梳理它们之间的交互过程。另一方面,我们还通过扩展 UserDetailsService 和 AuthenticationProvider 接口的方式来实现定制化的用户认证方案。
本讲内容总结如下:
最后给你留一道思考题:基于 Spring Security如何根据用户名和密码实现一套定制化的用户认证解决方案欢迎在留言区和我分享你的想法。

View File

@ -0,0 +1,471 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
04 密码安全Spring Security 中包含哪些加解密技术?
通过前面两讲内容的学习,相信你已经掌握了 Spring Security 中的用户认证体系。用户认证的过程通常涉及密码的校验因此密码的安全性也是我们需要考虑的一个核心问题。Spring Security 作为一款功能完备的安全性框架,一方面提供了用于完成认证操作的 PasswordEncoder 组件,另一方面也包含一个独立而完整的加密模块,方便在应用程序中单独使用。
PasswordEncoder
我们先来回顾一下整个用户认证流程。在 AuthenticationProvider 中,我们需要使用 PasswordEncoder 组件验证密码的正确性,如下图所示:
PasswordEncoder 组件与认证流程之间的关系
在“用户认证:如何使用 Spring Security 构建用户认证体系?”一讲中我们也介绍了基于数据库的用户信息存储方案:
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.jdbcAuthentication().dataSource(dataSource)
.usersByUsernameQuery("select username, password, enabled from Users " + "where username=?")
.authoritiesByUsernameQuery("select username, authority from UserAuthorities " + "where username=?")
.passwordEncoder(new BCryptPasswordEncoder());
}
请注意,在上述方法中,我们通过 jdbcAuthentication() 方法验证用户信息时一定要集成加密机制,也就是使用 passwordEncoder() 方法嵌入一个 PasswordEncoder 接口的实现类。
PasswordEncoder 接口
在 Spring Security 中PasswordEncoder 接口代表的是一种密码编码器其核心作用在于指定密码的具体加密方式以及如何将一段给定的加密字符串与明文之间完成匹配校验。PasswordEncoder 接口定义如下:
public interface PasswordEncoder {
//对原始密码进行编码
String encode(CharSequence rawPassword);
//对提交的原始密码与库中存储的加密密码进行比对
boolean matches(CharSequence rawPassword, String encodedPassword);
//判断加密密码是否需要再次进行加密,默认返回 false
default boolean upgradeEncoding(String encodedPassword) {
return false;
}
}
Spring Security 内置了一大批 PasswordEncoder 接口的实现类,如下所示:
Spring Security 中的 PasswordEncoder 实现类
我们对上图中比较常见的几个 PasswordEncoder 接口展开叙述。
NoOpPasswordEncoder以明文形式保留密码不对密码进行编码。这种 PasswordEncoder 通常只用于演示,不应该用于生产环境。
StandardPasswordEncoder使用 SHA-256 算法对密码执行哈希操作。
BCryptPasswordEncoder使用 bcrypt 强哈希算法对密码执行哈希操作。
Pbkdf2PasswordEncoder使用 PBKDF2 算法对密码执行哈希操作。
下面我们以 BCryptPasswordEncoder 为例,看一下它的 encode 方法,如下所示:
public String encode(CharSequence rawPassword) {
String salt;
if (random != null) {
salt = BCrypt.gensalt(version.getVersion(), strength, random);
} else {
salt = BCrypt.gensalt(version.getVersion(), strength);
}
return BCrypt.hashpw(rawPassword.toString(), salt);
}
可以看到,上述 encode 方法执行了两个步骤,首先使用 Spring Security 提供的 BCrypt 工具类生成盐Salt然后根据盐和明文密码生成最终的密文密码。这里有必要对加盐的概念做一些展开所谓加盐就是在初始化明文数据时由系统自动往这个明文里添加一些附加数据然后散列。引入加盐机制是为了进一步保证加密数据的安全性单向散列加密以及加盐思想也被广泛应用于系统登录过程中的密码生成和校验。
同样,在 Pbkdf2PasswordEncoder 中,也是通过对密码加盐之后进行哈希,然后将结果作为盐再与密码进行哈希,多次重复此过程,生成最终的密文。
介绍完 PasswordEncoder 的基本结构,我们继续来看它的应用方式。如果我们想在应用程序中使用某一个 PasswordEncoder 实现类,通常只需要通过它的构造函数创建一个实例,例如:
PasswordEncoder p = new StandardPasswordEncoder();
PasswordEncoder p = new StandardPasswordEncoder("secret");
PasswordEncoder p = new SCryptPasswordEncoder();
PasswordEncoder p = new SCryptPasswordEncoder(16384, 8, 1, 32, 64);
而如果想要使用 NoOpPasswordEncoder除了构造函数之外还可以通过它的 getInstance() 方法来获取静态实例,如下所示:
PasswordEncoder p = NoOpPasswordEncoder.getInstance()
自定义 PasswordEncoder
尽管 Spring Security 已经为我们提供了丰富的 PasswordEncoder但你也可以通过实现这个接口来设计满足自身需求的任意一种密码编解码和验证机制。例如我们可以编写如下所示的一个 PlainTextPasswordEncoder
public class PlainTextPasswordEncoder implements PasswordEncoder {
@Override
public String encode(CharSequence rawPassword) {
return rawPassword.toString();
}
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return rawPassword.equals(encodedPassword);
}
}
PlainTextPasswordEncoder 的功能与 NoOpPasswordEncoder 类似,没有对明文进行任何处理。如果你想使用某种算法集成 PasswordEncoder就可以实现类似如下所示的 Sha512PasswordEncoder这里使用了 SHA-512 作为加解密算法:
public class Sha512PasswordEncoder implements PasswordEncoder {
@Override
public String encode(CharSequence rawPassword) {
return hashWithSHA512(rawPassword.toString());
}
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
String hashedPassword = encode(rawPassword);
return encodedPassword.equals(hashedPassword);
}
private String hashWithSHA512(String input) {
StringBuilder result = new StringBuilder();
try {
MessageDigest md = MessageDigest.getInstance("SHA-512");
byte [] digested = md.digest(input.getBytes());
for (int i = 0; i < digested.length; i++) {
result.append(Integer.toHexString(0xFF & digested[i]));
}
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("Bad algorithm");
}
return result.toString();
}
}
上述代码中hashWithSHA512() 方法就使用了前面提到的单向散列加密算法来生成消息摘要Message Digest其主要特点在于单向不可逆和密文长度固定同时也具备碰撞少的优点即明文的微小差异就会导致所生成密文完全不同SHASecure Hash Algorithm以及MD5Message Digest 5都是常见的单向散列加密算法 JDK 自带的 MessageDigest 类中已经包含了默认实现我们直接调用方法即可
代理式 DelegatingPasswordEncoder
在前面的讨论中我们都基于一个假设即在对密码进行加解密过程中只会使用到一个 PasswordEncoder如果这个 PasswordEncoder 不满足我们的需求那么就需要替换成另一个 PasswordEncoder这就引出了一个问题如何优雅地应对这种变化呢
在普通的业务系统中由于业务系统也在不断地变化替换一个组件可能并没有很高的成本但对于 Spring Security 这种成熟的开发框架而言在设计和实现上不能经常发生变化因此在新/ PasswordEncoder 的兼容性以及框架自身的稳健性和可变性之间需要保持一种平衡为了实现这种平衡性Spring Security 提供了 DelegatingPasswordEncoder
虽然 DelegatingPasswordEncoder 也实现了 PasswordEncoder 接口但事实上它更多扮演了一种代理组件的角色这点从命名上也可以看出来DelegatingPasswordEncoder 将具体编码的实现根据要求代理给不同的算法以此实现不同编码算法之间的兼容并协调变化如下图所示
DelegatingPasswordEncoder 的代理作用示意图
下面我们来看一下 DelegatingPasswordEncoder 类的构造函数如下所示
public DelegatingPasswordEncoder(String idForEncode,
Map<String, PasswordEncoder> idToPasswordEncoder) {
if (idForEncode == null) {
throw new IllegalArgumentException("idForEncode cannot be null");
}
if (!idToPasswordEncoder.containsKey(idForEncode)) {
throw new IllegalArgumentException("idForEncode " + idForEncode + "is not found in idToPasswordEncoder " + idToPasswordEncoder);
}
for (String id : idToPasswordEncoder.keySet()) {
if (id == null) {
continue;
}
if (id.contains(PREFIX)) {
throw new IllegalArgumentException("id " + id + " cannot contain " + PREFIX);
}
if (id.contains(SUFFIX)) {
throw new IllegalArgumentException("id " + id + " cannot contain " + SUFFIX);
}
}
this.idForEncode = idForEncode;
this.passwordEncoderForEncode = idToPasswordEncoder.get(idForEncode);
this.idToPasswordEncoder = new HashMap<>(idToPasswordEncoder);
}
该构造函数中的 idForEncode 参数决定 PasswordEncoder 的类型,而 idToPasswordEncoder 参数决定判断匹配时兼容的类型。显然idToPasswordEncoder 必须包含对应的 idForEncode。
我们再来看这个构造函数的调用入口。在 Spring Security 中,存在一个创建 PasswordEncoder 的工厂类 PasswordEncoderFactories如下所示
public class PasswordEncoderFactories {
@SuppressWarnings("deprecation")
public static PasswordEncoder createDelegatingPasswordEncoder() {
String encodingId = "bcrypt";
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put(encodingId, new BCryptPasswordEncoder());
encoders.put("ldap", new org.springframework.security.crypto.password.LdapShaPasswordEncoder());
encoders.put("MD4", new org.springframework.security.crypto.password.Md4PasswordEncoder());
encoders.put("MD5", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("MD5"));
encoders.put("noop", org.springframework.security.crypto.password.NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
encoders.put("SHA-1", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-1"));
encoders.put("SHA-256", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-256"));
encoders.put("sha256", new org.springframework.security.crypto.password.StandardPasswordEncoder());
encoders.put("argon2", new Argon2PasswordEncoder());
return new DelegatingPasswordEncoder(encodingId, encoders);
}
private PasswordEncoderFactories() {}
}
可以看到,在这个工厂类中初始化了一个包含所有 Spring Security 中支持 PasswordEncoder 的 Map。而且我们也明确了框架默认使用的就是 key 为“bcrypt”的 BCryptPasswordEncoder。
通常,我们可以通过以下方法来使用这个 PasswordEncoderFactories 类:
PasswordEncoder passwordEncoder =
PasswordEncoderFactories.createDelegatingPasswordEncoder();
另一方面PasswordEncoderFactories 的实现方法为我们自定义 DelegatingPasswordEncoder 提供了一种途径,我们也可以根据需要创建符合自己需求的 DelegatingPasswordEncoder如下所示
String idForEncode = "bcrypt";
Map encoders = new HashMap<>();
encoders.put(idForEncode, new BCryptPasswordEncoder());
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
encoders.put("sha256", new StandardPasswordEncoder());
PasswordEncoder passwordEncoder =
new DelegatingPasswordEncoder(idForEncode, encoders);
请注意,在 Spring Security 中,密码的标准存储格式是这样的:
{id}encodedPassword
这里的 id 就是 PasswordEncoder 的种类,也就是前面提到的 idForEncode 参数。假设密码原文为“password”经过 BCryptPasswordEncoder 进行加密之后的密文就变成了这样一个字符串:
$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
最终存储在数据库中的密文应该是这样的:
{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
以上实现过程可以通过查阅 DelegatingPasswordEncoder 的 encode() 方法得到验证:
@Override
public String encode(CharSequence rawPassword) {
return PREFIX + this.idForEncode + SUFFIX + this.passwordEncoderForEncode.encode(rawPassword);
}
我们继续来看 DelegatingPasswordEncoder 的 matcher 方法,如下所示:
@Override
public boolean matches(CharSequence rawPassword, String prefixEncodedPassword) {
if (rawPassword == null && prefixEncodedPassword == null) {
return true;
}
//取出 PasswordEncoder 的 id
String id = extractId(prefixEncodedPassword);
//根据 PasswordEncoder 的 id 获取对应的 PasswordEncoder
PasswordEncoder delegate = this.idToPasswordEncoder.get(id);
//如果找不到对应的 PasswordEncoder则使用默认 PasswordEncoder 进行匹配判断
if (delegate == null) {
return this.defaultPasswordEncoderForMatches
.matches(rawPassword, prefixEncodedPassword);
}
//从存储的密码字符串中抽取密文,去掉 id
String encodedPassword = extractEncodedPassword(prefixEncodedPassword);
//使用对应 PasswordEncoder 针对密文进行匹配判断
return delegate.matches(rawPassword, encodedPassword);
}
上述方法的流程还是很明确的,至此,我们对 DelegatingPasswordEncoder 的实现原理就讲清楚了,也进一步理解了 PasswordEncoder 的使用过程。
Spring Security 加密模块
正如我们在开头介绍的,使用 Spring Security 时通常涉及用户认证的部分会用到加解密技术。但就应用场景而言加解密技术是一种通用的基础设施类技术不仅可以用于用户认证也可以用于其他任何涉及敏感数据处理的场景。因此Spring Security 也充分考虑到了这种需求专门提供了一个加密模式Spring Security Crypto ModuleSSCM
请注意,尽管 PasswordEncoder 也属于这个模块的一部分,但这个模块本身是高度独立的,我们可以脱离于用户认证流程来使用这个模块。
Spring Security 加密模块的核心功能有两部分首先就是加解密器Encryptors典型的使用方式如下
BytesEncryptor e = Encryptors.standard(password, salt);
上述方法使用了标准的 256 位 AES 算法对输入的 password 字段进行加密,返回的是一个 BytesEncryptor。同时我们也看到这里需要输入一个代表盐值的 salt 字段,而这个 salt 值的获取就可以用到 Spring Security 加密模块的另一个功能——键生成器Key Generators使用方式如下所示
String salt = KeyGenerators.string().generateKey();
上述键生成器会创建一个 8 字节的密钥,并将其编码为十六进制字符串。
如果将加解密器和键生成器结合起来,我们就可以实现通用的加解密机制,如下所示:
String salt = KeyGenerators.string().generateKey();
String password = "secret";
String valueToEncrypt = "HELLO";
BytesEncryptor e = Encryptors.standard(password, salt);
byte [] encrypted = e.encrypt(valueToEncrypt.getBytes());
byte [] decrypted = e.decrypt(encrypted);
在日常开发过程中,你可以根据需要调整上述代码并嵌入到我们的系统中。
小结与预告
对于一个 Web 应用程序而言一旦需要实现用户认证势必涉及用户密码等敏感信息的加密。为此Spring Security 专门提供了 PasswordEncoder 组件对密码进行加解密。Spring Security 内置了一批即插即用的 PasswordEncoder并通过代理机制完成了各个组件的版本兼容和统一管理。这种设计思想也值得我们学习和借鉴。当然作为一款通用的安全性开发框架Spring Security 也提供了一个高度独立的加密模块应对日常开发需求。
本讲内容总结如下:
这里给你留一道思考题:你能描述 DelegatingPasswordEncoder 所起到的代理作用吗?欢迎在留言区和我分享你的思考。

View File

@ -0,0 +1,425 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
05 访问授权:如何对请求的安全访问过程进行有效配置?
通过前面几讲的介绍,相信你已经对 Spring Security 中的认证流程有了更全面的了解。认证是实现授权的前提和基础,通常我们在执行授权操作时需要明确目标用户,只有明确目标用户才能明确它所具备的角色和权限,用户、角色和权限也是 Spring Security 中所采用的授权模型,今天我就和你一起探讨授权模型的实现过程以及在日常开发过程中的应用方式。
Spring Security 中的权限和角色
实现访问授权的基本手段是使用配置方法,我们已经在“用户认证:如何基于 Spring Security 对用户进行有效认证?”一讲中介绍了 Spring Security 中的配置体系,你可以回顾学习。配置方法的处理过程同样位于 WebSecurityConfigurerAdapter 类中,但使用的是另一个 configure(HttpSecurity http) 方法,示例代码如下所示:
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests().anyRequest().authenticated()
.and()
.formLogin()
.and()
.httpBasic();
}
同样,在 02 讲中我们也已经看到过上述代码,这是 Spring Security 中作用于访问授权的默认实现方法。
基于权限进行访问控制
我们先来回顾一下 03 讲“账户体系:如何深入理解 Spring Security 的认证机制?”中介绍的用户对象以及它们之间的关联关系:
Spring Security 中的核心用户对象
上图中的 GrantedAuthority 对象代表的就是一种权限对象,而一个 UserDetails 对象具备一个或多个 GrantedAuthority 对象。通过这种关联关系,实际上我们就可以对用户的权限做一些限制,如下所示:
使用权限实现访问控制示意图
如果用代码来表示这种关联关系,可以采用如下所示的实现方法:
UserDetails user = User.withUsername("jianxiang")
.password("123456")
.authorities("create", "delete")
.build();
可以看到这里我们创建了一个名为“jianxiang”的用户该用户具有“create”和“delete”这两个权限。在 Spring Security 中,提供了一组针对 GrantedAuthority 的配置方法。例如:
hasAuthority(String),允许具有特定权限的用户进行访问;
hasAnyAuthority(String),允许具有任一权限的用户进行访问。
你可以使用上述两个方法来判断用户是否具备对应的访问权限,我们在 WebSecurityConfigurerAdapter 的 configure 方法中添加如下代码:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.httpBasic();
http.authorizeRequests().anyRequest().hasAuthority("CREATE");
}
这段代码的作用是对于任何请求只有权限为“CREATE”才能采用访问。如果我们修改一下代码
http.authorizeRequests().anyRequest().hasAnyAuthority("CREATE", "DELETE");
此时只要具备“CREATE”和“DELETE”中任意一种权限的用户都能进行访问。
这两个方法实现起来都比较简单但局限性也很大因为我们无法基于一些来自环境和业务的参数灵活控制访问规则。为此Spring Security 还提供了一个 access() 方法,该方法允许开发人员传入一个表达式进行更加细粒度的权限控制。
这里,我们将引入 SpEL它是 Spring Expression Language 的简称,是 Spring 框架提供的一种动态表达式语言。基于 SpEL只要该表达式的返回值是 trueaccess() 方法就会允许用户访问。如下示例:
http.authorizeRequests().anyRequest().access("hasAuthority('CREATE')");
上述代码与使用 hasAuthority() 方法的效果是完全一致的但如果是更为复杂的场景access() 方法的优势就很明显了。我们可以灵活创建一个表达式,然后通过 access() 方法确定最后的结果,示例代码如下所示:
String expression = "hasAuthority('CREATE') and !hasAuthority('Retrieve')";
http.authorizeRequests().anyRequest().access(expression);
上述代码的效果是只有拥有“CREATE”权限且不拥有“Retrieve”权限的用户才能进行访问。
基于角色进行访问控制
讨论完权限我们再来看角色你可以把角色看成是拥有多个权限的一种数据载体如下图所示这里我们分别定义了两个不同的角色“User”和“Admin”它们拥有不同的权限
使用角色实现访问控制示意图
讲到这里,你可能会认为 Spring Security 应该提供了一个独立的数据结构来承载角色的含义。但事实上,在 Spring Security 中并没有定义类似“GrantedRole”这种专门用来定义用户角色的对象而是复用了 GrantedAuthority 对象。事实上以“ROLE_”为前缀的 GrantedAuthority 就代表了一种角色,因此我们可以使用如下方式初始化用户的角色:
UserDetails user = User.withUsername("jianxiang")
.password("123456")
.authorities("ROLE_ADMIN")
.build();
上述代码相当于为用户“jianxiang”指定了“ADMIN”这个角色。为了给开发人员提供更好的开发体验Spring Security 还提供了另一种简化的方法来指定用户的角色,如下所示:
UserDetails user = User.withUsername("jianxiang")
.password("123456")
.roles("ADMIN")
.build();
在“用户认证:如何基于 Spring Security 对用户进行有效认证?”一讲中,我们介绍使用基于内存的用户信息存储方案时就已经看到过这种使用方法,你可以做一些回顾。
和权限配置一样Spring Security 也通过使用对应的 hasRole() 和 hasAnyRole() 方法来判断用户是否具有某个角色或某些角色,使用方法如下所示:
http.authorizeRequests().anyRequest().hasRole("ADMIN");
当然,针对角色,我们也可以使用 access() 方法完成更为复杂的访问控制。而 Spring Security 还提供了其他很多有用的控制方法供开发人员进行灵活使用。作为总结,下表展示了常见的配置方法及其作用:
配置方法
作用
anonymous()
允许匿名访问
authenticated()
允许认证用户访问
denyAll()
无条件禁止一切访问
hasAnyAuthority(String)
允许具有任一权限的用户进行访问
hasAnyRole(String)
允许具有任一角色的用户进行访问
hasAuthority(String)
允许具有特定权限的用户进行访问
hasIpAddress(String)
允许来自特定 IP 地址的用户进行访问
hasRole(String)
允许具有特定角色的用户进行访问
permitAll()
无条件允许一切访问
Spring Security 中的配置方法列表
使用配置方法控制访问权限
讨论完权限和角色,让我们回到 HTTP 请求和响应过程。我们知道确保访问安全的手段是对访问进行限制,只有那些具有访问权限的请求才能被服务器处理。那么问题就来了,如何让 HTTP 请求与权限控制过程关联起来呢?答案还是使用 Spring Security 所提供的配置方法。Spring Security 提供了三种强大的匹配器Matcher来实现这一目标分别是MVC 匹配器、Ant 匹配器以及正则表达式匹配器。
为了验证这些匹配器的配置方法,我们提供了如下所示的一个 Controller
@RestController
public class TestController {
@GetMapping("/hello_user")
public String helloUser() {
return "Hello User!";
}
@GetMapping("/hello_admin")
public String helloAdmin() {
return "Hello Admin!";
}
@GetMapping("/other")
public String other() {
return "Other!";
}
}
同时,我们也创建两个具有不同角色的用户,如下所示:
UserDetails user1 = User.withUsername("jianxiang1")
.password("12345")
.roles("USER")
.build();
UserDetails user2 = User.withUsername("jianxiang2")
.password("12345")
.roles("ADMIN")
.build();
接下来,我们将基于这个 Controller 中暴露的各个 HTTP 端点,对三种不同的匹配器一一展开讲解。
MVC 匹配器
MVC 匹配器的使用方法比较简单,就是基于 HTTP 端点的访问路径进行匹配,如下所示:
http.authorizeRequests()
.mvcMatchers("/hello_user").hasRole("USER")
.mvcMatchers("/hello_admin").hasRole("ADMIN");
现在如果你使用角色为“USER”的用户“jianxiang1”来访问“/hello_admin”端点那么将会得到如下所示的响应
{
"status":403,
"error":"Forbidden",
"message":"Forbidden",
"path":"/hello_admin"
}
显然MVC 匹配器已经生效了,因为“/hello_admin”端点只有角色为“ADMIN”的用户才能访问。如果你使用拥有“ADMIN”角色的“jianxiang2”来访问这个端点就可以得到正确的响应结果。
你可能会问,我们通过 MVC 匹配器只指定了这两个端点的路径,那剩下的“/other”路径呢答案就是没有被 MVC 匹配器所匹配的端点,其访问不受任何的限制,效果相当于如下所示的配置:
http.authorizeRequests()
.mvcMatchers("/hello_user").hasRole("USER")
.mvcMatchers("/hello_admin").hasRole("ADMIN");
.anyRequest().permitAll();
显然,这种安全访问控制策略不是特别合理,更好的做法是对那些没有被 MVC 匹配器所匹配到的请求也加以控制,需要进行认证之后才能被访问,实现方式如下所示:
http.authorizeRequests()
.mvcMatchers("/hello_user").hasRole("USER")
.mvcMatchers("/hello_admin").hasRole("ADMIN");
.anyRequest().authenticated();
讲到这里,又出现了一个新问题:如果一个 Controller 中存在两个路径完全一样的 HTTP 端点呢?
这种情况是存在的,因为对于 HTTP 端点而言,就算路径一样,只要所使用的 HTTP 方法不同那就是不同的两个端点。针对这种场景MVC 匹配器还提供了重载的 mvcMatchers 方法,如下所示:
mvcMatchers(HttpMethod method, String... patterns)
这样,我们就可以把 HTTP 方法作为一个访问的维度进行控制,示例代码如下所示:
http.authorizeRequests()
.mvcMatchers(HttpMethod.POST, "/hello").authenticated()
.mvcMatchers(HttpMethod.GET, "/hello").permitAll()
.anyRequest().denyAll();
在上面这段配置代码中,如果一个 HTTP 请求使用了 POST 方法来访问“/hello”端点那么就需要进行认证。而对于使用 GET 方法来访问“/hello”端点的请求则全面允许访问。最后其余访问任意路径的所有请求都会被拒绝。
同时,如果我们想要对某个路径下的所有子路径都指定同样的访问控制,那么只需要在该路径后面添加“*”号即可,示例代码如下所示:
http.authorizeRequests()
.mvcMatchers(HttpMethod.GET, "/user/*").authenticated()
通过上述配置方法,如果我们访问“/user/jianxiang”“/user/jianxiang/status”等路径时都会匹配到这条规则。
Ant 匹配器
Ant 匹配器的表现形式和使用方法与前面介绍的 MVC 匹配器非常相似,它也提供了如下所示的三个方法来完成请求与 HTTP 端点地址之间的匹配关系:
antMatchers(String patterns)
antMatchers(HttpMethod method)
antMatchers(HttpMethod method, String patterns)
从方法定义上不难明白,我们可以组合指定请求的 HTTP 方法以及匹配的模式,例如:
http.authorizeRequests()
.antMatchers( "/hello").authenticated();
虽然从使用方式上看Ant 匹配器和 MVC 匹配器并没有什么区别,但在日常开发过程中,我想推荐你使用 MVC 匹配器而不是 Ant 匹配器,原因就在于 Ant 匹配器在匹配路径上有一些风险,主要体现在对于”/“的处理上。为了更好地说明,我举一个简单的例子。
基于上面的这行配置,如果你发送一个这样的 HTTP 请求:
http://localhost:8080/hello
你肯定认为 Ant 匹配器是能够匹配到这个端点的,但结果却是:
{
"status":401,
"error":"Unauthorized",
"message":"Unauthorized",
"path":"/hello"
}
现在,如果你把 HTTP 请求调整为这样,请注意,我们在请求地址最后添加了一个”/”符号,那么就会得到正确的访问结果:
http://localhost:8080/hello/
显然Ant 匹配器处理请求地址的方式有点让人感到困惑,而 MVC 匹配器则没有这个问题,无论在请求地址最后是否存在“/”符号,它都能完成正确的匹配。
正则表达式匹配器
最后我要介绍的是正则表达式匹配器,同样,它也提供了如下所示的两个配置方法:
regexMatchers(HttpMethod method, String regex)
regexMatchers(String regex)
使用这一匹配器的主要优势在于它能够基于复杂的正则表达式对请求地址进行匹配,这是 MVC 匹配器和 Ant 匹配器无法实现的,你可以看一下如下所示的这段配置代码:
http.authorizeRequests()
.mvcMatchers("/email/{email:.*(.+@.+\\.com)}")
.permitAll()
.anyRequest()
.denyAll();
可以看到,这段代码就对常见的邮箱地址进行了匹配,只有输入的请求是一个合法的邮箱地址才能允许访问。
小结与预告
这一讲我们关注的是对请求访问进行授权,而这个过程需要明确 Spring Security 中的用户、权限和角色之间的关联关系。一旦我们对某个用户设置了对应的权限和角色那么就可以通过各种配置方法来有效控制访问权限。为此Spring Security 也提供了 MVC 匹配器、Ant 匹配器以及正则表达式匹配器来实现复杂的访问控制。
本讲内容总结如下:
最后我想给你留一道思考题:在 Spring Security 中,你知道用户角色与用户权限之间有什么区别和联系吗?欢迎你在留言区和我分享自己的观点。

View File

@ -0,0 +1,463 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
06 权限管理:如何剖析 Spring Security 的授权原理?
上一讲,我们分析了 Spring Security 中提供的授权功能。你可以发现使用这一功能的方法很简单,只需要基于 HttpSecurity 对象提供的一组工具方法就能实现复杂场景下的访问控制。但是易于使用的功能往往内部实现都没有表面看起来那么简单今天我就来和你一起深入分析授权功能背后的实现机制。针对授权功能Spring Security 在实现过程中采用了很多优秀的设计理念和实现技巧,值得我们深入学习。
Spring Security 授权整体流程
我们先来简单回顾一下上一讲的内容。我们知道在 Spring Security 中,实现对所有请求权限控制的配置方法只需要如下所示的一行代码:
http.authorizeRequests();
我们可以结合 HTTP 请求的响应流程来理解这行代码的执行效果。当一个 HTTP 请求来到 Servlet 容器时,会被容器拦截,并添加一些附加的处理逻辑。在 Servlet 中这种处理逻辑就是通过过滤器Filter来实现的多个过滤器按照一定的顺序组合在一起就构成了一个过滤器链。关于过滤器的详细讨论我们会在 08 讲“管道过滤:如何基于 Spring Security 过滤器扩展安全性?”中展开,在本讲中,我们只需要知道 Spring Security 同样也基于过滤器拦截请求,从而实现对访问权限的限制即可。
在 Spring Security 中,存在一个叫 FilterSecurityInterceptor 的拦截器,它位于整个过滤器链的末端,核心功能是对权限控制过程进行拦截,以此判定该请求是否能够访问目标 HTTP 端点。FilterSecurityInterceptor 是整个权限控制的第一个环节,我们把它称为拦截请求。
我们对请求进行拦截之后,下一步就要获取该请求的访问资源,以及访问这些资源需要的权限信息。我们把这一步骤称为获取权限配置。在 Spring Security 中,存在一个 SecurityMetadataSource 接口,该接口保存着一系列安全元数据的数据源,代表权限配置的抽象。我们在上一讲中已经通过配置方法设置了很多权限信息,例如:
http.authorizeRequests().anyRequest().hasAuthority("CREATE");
请注意http.authorizeRequests() 方法的返回值是一个 ExpressionInterceptUrlRegistryanyRequest() 方法返回值是一个 AuthorizedUrl而 hasAuthority() 方法返回的又是一个 ExpressionInterceptUrlRegistry。这些对象在今天的内容中都会介绍到。
SecurityMetadataSource 接口定义了一组方法来操作这些权限配置具体权限配置的表现形式是ConfigAttribute 接口。通过 ExpressionInterceptUrlRegistry 和 AuthorizedUrl我们能够把配置信息转变为具体的 ConfigAttribute。
当我们获取了权限配置信息后,就可以根据这些配置决定 HTTP 请求是否具有访问权限也就是执行授权决策。Spring Security 专门提供了一个 AccessDecisionManager 接口完成该操作。而在 AccessDecisionManager 接口中,又把具体的决策过程委托给了 AccessDecisionVoter 接口。AccessDecisionVoter 可以被认为是一种投票器,负责对授权决策进行表决。
以上三个步骤构成了 Spring Security 的授权整体工作流程,可以用如下所示的时序图表示:
Spring Security 的授权整体工作流程
接下来,我们基于这张类图分别对拦截请求、获取权限配置、执行授权决策三个步骤逐一展开讲解。
拦截请求
作为一种拦截器FilterSecurityInterceptor 实现了对请求的拦截。我们先来看它的定义,如下所示:
public class FilterSecurityInterceptor extends AbstractSecurityInterceptor implements Filter
FilterSecurityInterceptor 实现了 Servlet 的 Filter 接口,所以本质上也是一种过滤器,并实现了 Filter 接口的 invoke 方法。在它的 invoke 方法中FilterSecurityInterceptor 自身并没有执行任何特殊的操作,只是获取了 HTTP 请求并调用了基类 AbstractSecurityInterceptor 中的 beforeInvocation() 方法对请求进行拦截:
public void invoke(FilterInvocation fi) throws IOException, ServletException {
InterceptorStatusToken token = super.beforeInvocation(fi);
super.afterInvocation(token, null);
}
AbstractSecurityInterceptor 中的 beforeInvocation() 方法非常长,我们把它裁剪之后,可以得到如下所示的主流程代码:
protected InterceptorStatusToken beforeInvocation(Object object) {
//获取 ConfigAttribute 集合
Collection< ConfigAttribute > attributes = this.obtainSecurityMetadataSource()
.getAttributes(object);
//获取认证信息
Authentication authenticated = authenticateIfRequired();
//执行授权
try {
this.accessDecisionManager.decide(authenticated, object, attributes);
}
catch (AccessDeniedException accessDeniedException) {
}
}
可以看到,上述操作从配置好的 SecurityMetadataSource 中获取当前请求所对应的 ConfigAttribute即权限信息。那么这个 SecurityMetadataSource 又是怎么来的呢?
获取访问策略
我们注意到在 FilterSecurityInterceptor 中定义了一个 FilterInvocationSecurityMetadataSource 变量,并通过一个 setSecurityMetadataSource() 方法进行注入,显然,这个变量就是一种 SecurityMetadataSource。
MetadataSource
通过翻阅 FilterSecurityInterceptor 的调用关系,我们发现初始化该类的地方是在 AbstractInterceptUrlConfigurer 类中,如下所示:
private FilterSecurityInterceptor createFilterSecurityInterceptor(H http,
FilterInvocationSecurityMetadataSource metadataSource,
AuthenticationManager authenticationManager) throws Exception {
FilterSecurityInterceptor securityInterceptor = new FilterSecurityInterceptor();
securityInterceptor.setSecurityMetadataSource(metadataSource);
securityInterceptor.setAccessDecisionManager(getAccessDecisionManager(http));
securityInterceptor.setAuthenticationManager(authenticationManager);
securityInterceptor.afterPropertiesSet();
return securityInterceptor;
}
而 FilterInvocationSecurityMetadataSource 对象的创建则是基于 AbstractInterceptUrlConfigurer 中提供的抽象方法:
abstract FilterInvocationSecurityMetadataSource createMetadataSource(H http);
这个方法的实现过程由 AbstractInterceptUrlConfigurer 的子类 ExpressionUrlAuthorizationConfigurer 提供,如下所示:
@Override
ExpressionBasedFilterInvocationSecurityMetadataSource createMetadataSource(H http) {
LinkedHashMap<RequestMatcher, Collection<ConfigAttribute>> requestMap = REGISTRY.createRequestMap();
return new ExpressionBasedFilterInvocationSecurityMetadataSource(requestMap,
getExpressionHandler(http));
}
请你注意这里有个REGISTRY 对象,它的类型是 ExpressionInterceptUrlRegistry。这和前面介绍的内容相对应我们在前面已经提到 http.authorizeRequests() 方法的返回值类型就是这个 ExpressionInterceptUrlRegistry。
ExpressionInterceptUrlRegistry
我们继续看 ExpressionInterceptUrlRegistry 中 createRequestMap() 的实现过程,如下所示:
final LinkedHashMap<RequestMatcher, Collection<ConfigAttribute>> createRequestMap() {
LinkedHashMap<RequestMatcher, Collection<ConfigAttribute>> requestMap = new LinkedHashMap<>();
for (UrlMapping mapping : getUrlMappings()) {
RequestMatcher matcher = mapping.getRequestMatcher();
Collection<ConfigAttribute> configAttrs = mapping.getConfigAttrs();
requestMap.put(matcher, configAttrs);
}
return requestMap;
}
这段代码把配置的 http.authorizeRequests() 转化为 UrlMappings然后进一步转换为 RequestMatcher 与 Collection<ConfigAttribute> 之间的映射关系。那么,创建这些 UrlMappings 的入口又在哪里呢?同样也是在 ExpressionUrlAuthorizationConfigurer 中的 interceptUrl 方法,如下所示:
private void interceptUrl(Iterable<? extends RequestMatcher> requestMatchers,
Collection<ConfigAttribute> configAttributes) {
for (RequestMatcher requestMatcher : requestMatchers) {
REGISTRY.addMapping(new AbstractConfigAttributeRequestMatcherRegistry.UrlMapping(
requestMatcher, configAttributes));
}
}
AuthorizedUrl
我们进一步跟踪代码的运行流程,发现上述 interceptUrl() 方法的调用入口是在如下所示的 access() 方法中:
public ExpressionInterceptUrlRegistry access(String attribute) {
if (not) {
attribute = "!" + attribute;
}
interceptUrl(requestMatchers, SecurityConfig.createList(attribute));
return ExpressionUrlAuthorizationConfigurer.this.REGISTRY;
}
结合上一讲的内容,我们不难理解这个 access() 方法的作用。请注意,这个方法位于 AuthorizedUrl 类中,而我们执行 http.authorizeRequests().anyRequest() 方法的返回值就是这个 AuthorizedUrl。在该类中定义了一批我们已经熟悉的配置方法例如 hasRole、hasAuthority 等,而这些方法在内部都是调用了上面这个 access() 方法:
public ExpressionInterceptUrlRegistry hasRole(String role) {
return access(ExpressionUrlAuthorizationConfigurer.hasRole(role));
}
public ExpressionInterceptUrlRegistry hasAuthority(String authority) {
return access(ExpressionUrlAuthorizationConfigurer.hasAuthority(authority));
}
讲到这里,获取访问策略的流程就基本完成了,我们得到了一组代表权限的 ConfigAttribute 对象。
执行授权决策
执行授权决策的前提是获取认证信息,因此,我们在 FilterSecurityInterceptor 的拦截流程中发现了如下一行执行认证操作的代码:
Authentication authenticated = authenticateIfRequired();
这里的 authenticateIfRequired() 方法执行认证操作,该方法实现如下:
private Authentication authenticateIfRequired() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
authentication = authenticationManager.authenticate(authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
return authentication;
}
可以看到认证逻辑并不复杂,首先根据上下文对象中是否存在 Authentication 对象来判断当前用户是否已通过认证。如果尚未通过身份认证,则调用 AuthenticationManager 进行认证,并把 Authentication 存储到上下文对象中。关于用户认证流程的详细介绍你可以回顾“认证体系:如何深入理解 Spring Security 的认证机制?”中的内容。
AccessDecisionManager
AccessDecisionManager 是用来进行授权决策的入口,其最核心的方法就是如下所示的 decide() 方法,前面我们已经看到了这个方法的执行过程:
this.accessDecisionManager.decide(authenticated, object, attributes);
而在前面介绍 AbstractInterceptUrlConfigurer 类时,我们同样发现了获取和创建 AccessDecisionManager 的对应方法:
private AccessDecisionManager getAccessDecisionManager(H http) {
if (accessDecisionManager == null) {
accessDecisionManager = createDefaultAccessDecisionManager(http);
}
return accessDecisionManager;
}
private AccessDecisionManager createDefaultAccessDecisionManager(H http) {
AffirmativeBased result = new AffirmativeBased(getDecisionVoters(http));
return postProcess(result);
}
显然,如果没有设置自定义的 AccessDecisionManager默认会创建一个 AffirmativeBased 实例。AffirmativeBased 的 decide() 方法如下所示:
public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException {
int deny = 0;
for (AccessDecisionVoter voter : getDecisionVoters()) {
int result = voter.vote(authentication, object, configAttributes);
switch (result) {
case AccessDecisionVoter.ACCESS_GRANTED:
return;
case AccessDecisionVoter.ACCESS_DENIED:
deny++;
break;
default:
break;
}
}
if (deny > 0) {
throw new AccessDeniedException(messages.getMessage(
"AbstractAccessDecisionManager.accessDenied", "Access is denied"));
}
checkAllowIfAllAbstainDecisions();
}
可以看到,这里把真正计算是否具有访问权限的过程委托给了一组 AccessDecisionVoter 对象,只要其中有任意一个的结果是拒绝,就会抛出一个 AccessDeniedException。
AccessDecisionVoter
AccessDecisionVoter 同样是一个接口,提供了如下所示的 vote() 方法:
int vote(Authentication authentication, S object,
Collection<ConfigAttribute> attributes);
我们再次在 AbstractInterceptUrlConfigurer 类中找到了获取 AccessDecisionVoter 的 getDecisionVoters() 抽象方法定义,如下所示:
abstract List<AccessDecisionVoter<?>> getDecisionVoters(H http);
同样是在它的子类 ExpressionUrlAuthorizationConfigurer 中,我们找到了这个抽象方法的具体实现:
@Override
List<AccessDecisionVoter<?>> getDecisionVoters(H http) {
List<AccessDecisionVoter<?>> decisionVoters = new ArrayList<>();
WebExpressionVoter expressionVoter = new WebExpressionVoter();
expressionVoter.setExpressionHandler(getExpressionHandler(http));
decisionVoters.add(expressionVoter);
return decisionVoters;
}
可以看到,这里创建的 AccessDecisionVoter 实际上都是 WebExpressionVoter它的 vote() 方法如下所示:
public int vote(Authentication authentication, FilterInvocation fi, Collection<ConfigAttribute> attributes) {
WebExpressionConfigAttribute weca = findConfigAttribute(attributes);
EvaluationContext ctx = expressionHandler.createEvaluationContext(authentication, fi);
ctx = weca.postProcess(ctx, fi);
return ExpressionUtils.evaluateAsBoolean(weca.getAuthorizeExpression(), ctx) ? ACCESS_GRANTED: ACCESS_DENIED;
}
这里出现了一个 SecurityExpressionHandler看类名就可以发现与 Spring 中的表达式语言相关,它会构建一个用于评估的上下文对象 EvaluationContext。而 ExpressionUtils.evaluateAsBoolean() 方法就是根据从 WebExpressionConfigAttribute 获取的授权表达式,以及这个 EvaluationContext 上下文对象完成最终结果的评估:
public static boolean evaluateAsBoolean(Expression expr, EvaluationContext ctx) {
try {
return expr.getValue(ctx, Boolean.class);
}
catch (EvaluationException e) {
}
}
显然,最终的评估过程只是简单使用了 Spring 所提供的 SpEL 表达式语言。
作为总结,我们把这一流程中涉及的核心组件以类图的形式进行了梳理,如下图所示:
Spring Security 授权相关核心类图
小结与预告
这一讲我们关注的是 Spring Security 授权机制的实现原理,我们把整个授权过程拆分成拦截请求、获取访问策略和执行授权决策这三大步骤。针对每一个步骤,都涉及了一组核心类及其它们之间的交互关系。针对这些核心类的讲解思路是围绕着上一讲介绍的基本配置方法展开讨论的,确保实际应用能与源码分析衔接在一起。
本讲内容总结如下:
最后给你留一道思考题:在 Spring Security 中,你能简要描述整个授权机制的执行过程吗?

View File

@ -0,0 +1,680 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 案例实战:使用 Spring Security 基础功能保护 Web 应用
前面几讲我们系统地介绍了 Spring Security 的认证和授权功能,这是该框架为我们提供的最基础、也是最常用的安全性功能。作为阶段性的总结,今天我们就把前面几讲的内容整合在一起,基于 Spring Security 的认证和授权功能保护 Web 应用程序。
案例设计和初始化
在今天的案例中,我们将构建一个简单但完整的小型 Web 应用程序。当合法用户成功登录系统之后浏览器会跳转到一个系统主页并展示一些个人健康档案HealthRecord数据。
案例设计
这个 Web 应用程序将采用经典的三层架构即Web 层、服务层和数据访问层,因此我们会存在 HealthRecordController、HealthRecordService 以及 HealthRecordRepository这是一条独立的代码流程用来完成系统业务逻辑处理。
另一方面,本案例的核心功能是实现自定义的用户认证流程,所以我们需要构建独立的 UserDetailsService 以及 AuthenticationProvider这是另一条独立的代码流程。而在这条代码流程中势必还需要 User 以及 UserRepository 等组件。
我们可以把这两条代码线整合在一起,得到案例的整体设计蓝图,如下图所示:
案例中的业务代码流程和用户认证流程
系统初始化
要想实现上图中的效果,我们需要先对系统进行初始化。这部分工作涉及领域对象的定义、数据库初始化脚本的整理以及相关依赖组件的引入。
针对领域对象,我们重点来看如下所示的 User 类定义:
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
private String username;
private String password;
@Enumerated(EnumType.STRING)
private PasswordEncoderType passwordEncoderType;
@OneToMany(mappedBy = "user", fetch = FetchType.EAGER)
private List<Authority> authorities;
}
可以看到,这里除了指定主键 id、用户名 username 和密码 password 之外,还包含了一个加密算法枚举值 EncryptionAlgorithm。在案例系统中我们将提供 BCryptPasswordEncoder 和 SCryptPasswordEncoder 这两种可用的密码解密器,你可以通过该枚举值进行设置。
同时,我们在 User 类中还发现了一个 Authority 列表。显然,这个列表用来指定该 User 所具备的权限信息。Authority 类的定义如下所示:
@Entity
public class Authority {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
private String name;
@JoinColumn(name = "user")
@ManyToOne
private User user;
}
通过定义不难看出 User 和 Authority 之间是一对多的关系,这点和 Spring Security 内置的用户权限模型是一致的。我们注意到这里使用了一系列来自 JPAJava Persistence APIJava 持久化 API规范的注解来定义领域对象之间的关联关系。关于这些注解的使用方式你可以参考拉勾教育上的[《Spring Data JPA 原理与实战》专栏]进行学习。
基于 User 和 Authority 领域对象,我们也给出创建数据库表的 SQL 定义,如下所示:
CREATE TABLE IF NOT EXISTS `spring_security`.`user` (
`id` INT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(45) NOT NULL,
`password` TEXT NOT NULL,
`password_encoder_type` VARCHAR(45) NOT NULL,
PRIMARY KEY (`id`));
CREATE TABLE IF NOT EXISTS `spring_security`.`authority` (
`id` INT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(45) NOT NULL,
`user` INT NOT NULL,
PRIMARY KEY (`id`));
在运行系统之前,我们同样也需要初始化数据,对应脚本如下所示:
INSERT IGNORE INTO `spring_security`.`user` (`id`, `username`, `password`, `password_encoder_type`) VALUES ('1', 'jianxiang', '$2a$10$xn3LI/AjqicFYZFruSwve.681477XaVNaUQbr1gioaWPn4t1KsnmG', 'BCRYPT');
INSERT IGNORE INTO `spring_security`.`authority` (`id`, `name`, `user`) VALUES ('1', 'READ', '1');
INSERT IGNORE INTO `spring_security`.`authority` (`id`, `name`, `user`) VALUES ('2', 'WRITE', '1');
INSERT IGNORE INTO `spring_security`.`health_record` (`id`, `username`, `name`, `value`) VALUES ('1', 'jianxiang', 'weight', '70');
INSERT IGNORE INTO `spring_security`.`health_record` (`id`, `username`, `name`, `value`) VALUES ('2', 'jianxiang', 'height', '177');
INSERT IGNORE INTO `spring_security`.`health_record` (`id`, `username`, `name`, `value`) VALUES ('3', 'jianxiang', 'bloodpressure', '70');
INSERT IGNORE INTO `spring_security`.`health_record` (`id`, `username`, `name`, `value`) VALUES ('4', 'jianxiang', 'pulse', '80');
请注意,这里初始化了一个用户名为 “jianxiang”的用户同时指定了它的密码为“12345”加密算法为“BCRYPT”。
现在,领域对象和数据层面的初始化工作已经完成了,接下来我们需要在代码工程的 pom 文件中添加如下所示的 Maven 依赖:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
这些依赖包都是很常见的,相信从包名中你就能明白各依赖包的作用。
依赖包参考链接:
spring-boot-starter-data-jpahttps://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-data-jpa
spring-boot-starter-securityhttps://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-security
spring-boot-starter-thymeleafhttps://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-thymeleaf
spring-boot-starter-webhttps://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-web
mysql-connector-javahttps://mvnrepository.com/artifact/mysql/mysql-connector-java
spring-security-testhttps://mvnrepository.com/artifact/org.springframework.security/spring-security-test
实现自定义用户认证
实现自定义用户认证的过程通常涉及两大部分内容,一方面需要使用 User 和 Authority 对象来完成定制化的用户管理,另一方面需要把这个定制化的用户管理嵌入整个用户认证流程中。下面我们分别详细分析。
实现用户管理
我们知道在 Spring Security 中,代表用户信息的就是 UserDetails 接口。我们也在[ 03讲 “认证体系:如何深入理解 Spring Security 的用户认证机制?”]中介绍过 UserDetails 接口的具体定义。如果你想实现自定义的用户信息,扩展这个接口即可。实现方式如下所示:
public class CustomUserDetails implements UserDetails {
private final User user;
public CustomUserDetails(User user) {
this.user = user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return user.getAuthorities().stream()
.map(a -> new SimpleGrantedAuthority(a.getName()))
.collect(Collectors.toList());
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
public final User getUser() {
return user;
}
}
上述 CustomUserDetails 类实现了 UserDetails 接口中约定的所有需要实现的方法。请注意,这里的 getAuthorities() 方法中,我们将 User 对象中的 Authority 列表转换为了 Spring Security 中代表用户权限的SimpleGrantedAuthority 列表。
当然,所有的自定义用户信息和权限信息都是维护在数据库中的,所以为了获取这些信息,我们需要创建数据访问层组件,这个组件就是 UserRepository定义如下
public interface UserRepository extends JpaRepository<User, Integer> {
Optional<User> findUserByUsername(String username);
}
这里只是简单扩展了 Spring Data JPA 中的 JpaRepository 接口,并使用方法名衍生查询机制定义了根据用户名获取用户信息的 findUserByUsername 方法。
现在,我们已经能够在数据库中维护自定义用户信息,也能够根据这些用户信息获取到 UserDetails 对象,那么接下来要做的事情就是扩展 UserDetailsService。自定义 CustomUserDetailsService 实现如下所示:
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public CustomUserDetails loadUserByUsername(String username) {
Supplier<UsernameNotFoundException> s =
() -> new UsernameNotFoundException("Username" + username + "is invalid!");
User u = userRepository.findUserByUsername(username).orElseThrow(s);
return new CustomUserDetails(u);
}
}
这里我们通过 UserRepository 查询数据库来获取 CustomUserDetails 信息,如果传入的用户名没有对应的 CustomUserDetails 则会抛出异常。
实现认证流程
我们再次回顾 AuthenticationProvider 的接口定义,如下所示:
public interface AuthenticationProvider {
//执行认证,返回认证结果
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
//判断是否支持当前的认证对象
boolean supports(Class<?> authentication);
}
实现自定义认证流程要做的也是实现 AuthenticationProvider 中的这两个方法,而认证过程势必要借助于前面介绍的 CustomUserDetailsService。
我们先来看一下 AuthenticationProvider 接口的实现类 AuthenticationProviderService如下所示
@Service
public class AuthenticationProviderService implements AuthenticationProvider {
@Autowired
private CustomUserDetailsService userDetailsService;
@Autowired
private BCryptPasswordEncoder bCryptPasswordEncoder;
@Autowired
private SCryptPasswordEncoder sCryptPasswordEncoder;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = authentication.getCredentials().toString();
//根据用户名从数据库中获取 CustomUserDetails
CustomUserDetails user = userDetailsService.loadUserByUsername(username);
//根据所配置的密码加密算法分别验证用户密码
switch (user.getUser().getPasswordEncoderType()) {
case BCRYPT:
return checkPassword(user, password, bCryptPasswordEncoder);
case SCRYPT:
return checkPassword(user, password, sCryptPasswordEncoder);
}
throw new BadCredentialsException("Bad credentials");
}
@Override
public boolean supports(Class<?> aClass) {
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(aClass);
}
private Authentication checkPassword(CustomUserDetails user, String rawPassword, PasswordEncoder encoder) {
if (encoder.matches(rawPassword, user.getPassword())) {
return new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword(), user.getAuthorities());
} else {
throw new BadCredentialsException("Bad credentials");
}
}
}
AuthenticationProviderService 类虽然看起来比较长,但代码基本都是自解释的。我们首先通过 CustomUserDetailsService 从数据库中获取用户信息并构造成 CustomUserDetails 对象。然后,根据指定的密码加密器对用户密码进行验证,如果验证通过则构建一个 UsernamePasswordAuthenticationToken 对象并返回,反之直接抛出 BadCredentialsException 异常。而在 supports() 方法中指定的就是这个目标 UsernamePasswordAuthenticationToken 对象。
安全配置
最后,我们要做的就是通过 Spring Security 提供的配置体系将前面介绍的所有内容串联起来,如下所示:
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AuthenticationProviderService authenticationProvider;
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SCryptPasswordEncoder sCryptPasswordEncoder() {
return new SCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) {
auth.authenticationProvider(authenticationProvider);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.defaultSuccessUrl("/healthrecord", true);
http.authorizeRequests().anyRequest().authenticated();
}
}
这里注入了已经构建完成的 AuthenticationProviderService并初始化了两个密码加密器 BCryptPasswordEncoder 和 SCryptPasswordEncoder。最后我们覆写了 WebSecurityConfigurerAdapter 配置适配器类中的 configure() 方法,并指定用户登录成功后将跳转到”/main”路径所指定的页面。
对应的,我们需要构建如下所示的 MainController 类来指定”/main”路径并展示业务数据的获取过程如下所示
@Controller
public class HealthRecordController {
@Autowired
private HealthRecordService healthRecordService;
@GetMapping("/healthrecord")
public String main(Authentication a, Model model) {
String userName = a.getName();
model.addAttribute("username", userName);
model.addAttribute("healthRecords", healthRecordService.getHealthRecordsByUsername(userName));
return "health_record.html";
}
}
我们通过 Authentication 对象获取了认证用户信息,同时通过 HealthRecordService 获取了健康档案信息。关于 HealthRecordService 的实现逻辑不是今天内容的重点你可以参考案例源码进行学习https://github.com/lagouEdAnna/SpringSecurity-jianxiang/tree/main/SpringSecurityBasicDemo。
请注意,这里所指定的 health_record.html 位于 resources/templates 目录下,该页面基于 thymeleaf 模板引擎构建,如下所示:
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>健康档案</title>
</head>
<body>
<h2 th:text="'登录用户:' + ${username}" />
<p><a href="/logout">退出登录</a></p>
<h2>个人健康档案:</h2>
<table>
<thead>
<tr>
<th> 健康指标名称 </th>
<th> 健康指标值 </th>
</tr>
</thead>
<tbody>
<tr th:if="${healthRecords.empty}">
<td colspan="2"> 无健康指标 </td>
</tr>
<tr th:each="healthRecord : ${healthRecords}">
<td><span th:text="${healthRecord.name}"> 健康指标名称 </span></td>
<td><span th:text="${healthRecord.value}"> 健康指标值 </span></td>
</tr>
</tbody>
</table>
</body>
</html>
这里我们从 Model 对象中获取了认证用户信息以及健康档案信息,并渲染在页面上。
案例演示
现在,让我们启动 Spring Boot 应用程序并访问http://localhost:8080端点。因为访问系统的任何端点都需要认证所以 Spring Security 会自动跳转到如下所示的登录界面:
用户登录界面
我们分别输入用户名“jianxiang”和密码“12345”系统就会跳转到健康档案主页
健康档案主页
在这个主页中,我们正确获取了登录用户的用户名,并展示了个人健康档案信息。这个结果也证实了自定义用户认证体系的正确性。你可以根据示例代码做一些尝试。
小结与预告
这一讲我们动手实践了“利用 Spring Security 基础功能保护 Web 应用程序”。综合第 2 讲到 6 讲中的核心知识点,我们设计了一个简单而又完整的案例,并通过构建用户管理和认证流程讲解了实现自定义用户认证机制的过程。
本讲内容总结如下:
最后给你留一道思考题:在 Spring Security 中,实现一套自定义的用户认证体系需要哪些开发步骤?

View File

@ -0,0 +1,300 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
08 管道过滤:如何基于 Spring Security 过滤器扩展安全性?
在 06 讲“权限管理:如何剖析 Spring Security 的授权原理?”中,我们介绍 Spring Security 授权流程时提到了过滤器的概念。今天,我们就直面这个主题,详细分析 Spring Security 中的过滤器架构,进一步学习实现自定义过滤器的系统方法。
Spring Security 过滤器架构
过滤器是一种通用机制,在处理 Web 请求的过程中发挥了重要作用。可以说,目前市面上所有的 Web 开发框架都或多或少使用了过滤器完成对请求的处理Spring Security 也不例外。Spring Security 中的过滤器架构是基于 Servlet构建的所以我们先从 Servlet 中的过滤器开始说起。
Servlet 与管道-过滤器模式
和业界大多数处理 Web 请求的框架一样Servlet 中采用的最基本的架构就是管道-过滤器Pipe-Filter架构模式。管道-过滤器架构模式的示意图如下所示:
管道-过滤器架构模式示意图
结合上图我们可以看到,处理业务逻辑的组件被称为过滤器,而处理结果通过相邻过滤器之间的管道进行传输,这样就构成了一个过滤器链。
在 Servlet 中,代表过滤器的 Filter 接口定义如下:
public interface Filter {
public void init(FilterConfig filterConfig) throws ServletException;
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException;
public void destroy();
}
当应用程序启动时Servlet 容器就会调用 init() 方法。这个方法只会在容器启动时调用一次因此包含了初始化过滤器的相关代码。对应的destroy() 方法用于释放该过滤器占有的资源。
一个过滤器组件所包含的业务逻辑应该位于 doFilter() 方法中该方法带有三个参数分别是ServletRequest、ServletResponse 和 FilterChain。这三个参数都很重要我们一一说明。
ServletRequest表示 HTTP 请求,我们使用该对象获取有关请求的详细信息。
ServletResponse表示 HTTP 响应,我们使用该对象构建响应结果,然后将其发送回客户端或沿着过滤器链向后传递。
FilterChain表示过滤器链我们使用该对象将请求转发到链中的下一个过滤器。
请注意,过滤器链中的过滤器是有顺序的,这点非常重要,我们在本讲后续内容中会针对这点展开讲解。
Spring Security 中的过滤器链
在 Spring Security 中,其核心流程的执行也是依赖于一组过滤器,这些过滤器在框架启动后会自动进行初始化,如图所示:
Spring Security 中的过滤器链示意图
在上图中,我们看到了几个常见的 Filter比如 BasicAuthenticationFilter、UsernamePasswordAuthenticationFilter 等,这些类都直接或间接实现了 Servlet 中的 Filter 接口,并完成某一项具体的认证机制。例如,上图中的 BasicAuthenticationFilter 用来验证用户的身份凭证;而 UsernamePasswordAuthenticationFilter 会检查输入的用户名和密码,并根据认证结果决定是否将这一结果传递给下一个过滤器。
请注意,整个 Spring Security 过滤器链的末端是一个 FilterSecurityInterceptor它本质上也是一个 Filter。但与其他用于完成认证操作的 Filter 不同,它的核心功能是实现权限控制,也就是用来判定该请求是否能够访问目标 HTTP 端点。FilterSecurityInterceptor 对于权限控制的粒度可以到方法级别,能够满足前面提到的精细化访问控制。我们在 06 讲“权限管理:如何剖析 Spring Security 的授权原理?”中已经对这个拦截器做了详细的介绍,这里就不再展开了。
通过上述分析,我们明确了在 Spring Security 中,认证和授权这两个安全性需求是通过一系列的过滤器来实现的。而过滤器的真正价值不仅在于实现了认证和授权,更为开发人员提供了一个扩展 Spring Security 框架的有效手段。
实现自定义过滤器
在 Spring Security 中创建一个新的过滤器并不复杂,只需要遵循 Servlet 所提供的 Filter 接口约定即可。
开发过滤器
讲到开发自定义的过滤器,最经典的应用场景就是记录 HTTP 请求的访问日志。如下所示就是一种常见的实现方式:
public class LoggingFilter implements Filter {
private final Logger logger =
Logger.getLogger(AuthenticationLoggingFilter.class.getName());
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
//从 ServletRequest 获取请求数据并记录
String uniqueRequestId = httpRequest.getHeader("UniqueRequestId");
logger.info("成功对请求进行了认证: " + uniqueRequestId);
//将请求继续在过滤器链上进行传递
filterChain.doFilter(request, response);
}
}
这里我们定义了一个 LoggingFilter用来记录已经通过用户认证的请求中包含的一个特定的消息头“UniqueRequestId”通过这个唯一的请求 Id我们可以对请求进行跟踪、监控和分析。在实现一个自定义的过滤器组件时我们通常会从 ServletRequest 中获取请求数据,并在 ServletResponse 中设置响应数据,然后通过 filterChain 的 doFilter() 方法将请求继续在过滤器链上进行传递。
接下来,我们想象这样一种场景,业务上我们需要根据客户端请求头中是否包含某一个特定的标志位,来决定请求是否有效。如图所示:
根据标志位设计过滤器示意图
这在现实开发过程中也是一种常见的应用场景,可以实现定制化的安全性控制。针对这种应用场景,我们可以实现如下所示的 RequestValidationFilter 过滤器:
public class RequestValidationFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
String requestId = httpRequest.getHeader("SecurityFlag");
if (requestId == null || requestId.isBlank()) {
httpResponse.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return;
}
filterChain.doFilter(request, response);
}
}
这里我们从 HttpServletRequest 对象的请求头中获取了“SecurityFlag”标志位否则将直接抛出一个 400 Bad Request 响应结果。根据需要,我们也可以实现各种自定义的异常处理逻辑。
配置过滤器
现在,我们已经实现了几个有价值的过滤器了,下一步就是将这些过滤器整合到 Spring Security 的整个过滤器链中。这里,我想特别强调一点,和 Servlet 中的过滤器一样Spring Security 中的过滤器也是有顺序的。也就是说,将过滤器放置在过滤器链的具体位置需要符合每个过滤器本身的功能特性,不能将这些过滤器随意排列组合。
我们来举例说明合理设置过滤器顺序的重要性。在[“用户认证:如何使用 Spring Security 构建用户认证体系?”]一讲中我们提到了 HTTP 基础认证机制,而在 Spring Security 中,实现这一认证机制的就是 BasicAuthenticationFilter。
如果我们想要实现定制化的安全性控制策略,就可以实现类似前面介绍的 RequestValidationFilter 这样的过滤器,并放置在 BasicAuthenticationFilter 前。这样,在执行用户认证之前,我们就可以排除掉一批无效请求,效果如下所示:
RequestValidationFilter 的位置示意图
上图中的 RequestValidationFilter 确保那些没有携带有效请求头信息的请求不会执行不必要的用户认证。基于这种场景,把 RequestValidationFilter 放在 BasicAuthenticationFilter 之后就不是很合适了,因为用户已经完成了认证操作。
同样,针对前面已经构建的 LoggingFilter原则上我们可以把它放在过滤器链的任何位置因为它只记录了日志。但有没有更合适的位置呢结合 RequestValidationFilter 来看,同样对于一个无效的请求而言,记录日志是没有什么意义的。所以 LoggingFilter 应该放置在 RequestValidationFilter 之后。另一方面,对于日志操作而言,通常只需要记录那些已经通过认证的请求,所以也推荐将 LoggingFilter 放在 BasicAuthenticationFilter 之后。最终,这三个过滤器之间的关系如下图所示:
三个过滤器的位置示意图
在 Spring Security 中,提供了一组可以往过滤器链中添加过滤器的工具方法,包括 addFilterBefore()、addFilterAfter()、addFilterAt() 以及 addFilter() 等,它们都定义在 HttpSecurity 类中。这些方法的含义都很明确,使用起来也很简单,例如,想要实现如上图所示的效果,我们可以编写这样的代码:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.addFilterBefore(
new RequestValidationFilter(),
BasicAuthenticationFilter.class)
.addFilterAfter(
new LoggingFilter(),
BasicAuthenticationFilter.class)
.authorizeRequests()
.anyRequest()
.permitAll();
}
这里,我们使用了 addFilterBefore() 和 addFilterAfter() 方法在 BasicAuthenticationFilter 之前和之后分别添加了 RequestValidationFilter 和 LoggingFilter。
Spring Security 中的过滤器
下表列举了 Spring Security 中常用的过滤器名称、功能以及它们的顺序关系:
Spring Security 中的常见过滤器一览表
这里以最基础的 UsernamePasswordAuthenticationFilter 为例,该类的定义及核心方法 attemptAuthentication 如下所示:
public class UsernamePasswordAuthenticationFilter extends
AbstractAuthenticationProcessingFilter {
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
if (postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException(
"Authentication method not supported: " + request.getMethod());
}
String username = obtainUsername(request);
String password = obtainPassword(request);
if (username == null) {
username = "";
}
if (password == null) {
password = "";
}
username = username.trim();
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
username, password);
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
}
围绕上述方法,我们结合前面已经介绍的认证和授权相关实现原理,可以引出该框架中一系列核心类并梳理它们之间的交互结构,如下图所示:
UsernamePasswordAuthenticationFilter 相关核心类图
上图中的很多类,我们通过名称就能明白它的含义和作用。以位于左下角的 SecurityContextHolder 为例,它是一个典型的 Holder 类,存储了应用的安全上下文对象 SecurityContext而这个上下文对象中就包含了用户的认证信息。
我们也可以大胆猜想,它的内部应该使用 ThreadLocal 确保线程访问的安全性。更具体的,我们已经在“权限管理:如何剖析 Spring Security 的授权原理?”中讲解过 SecurityContext 的使用方法。
正如 UsernamePasswordAuthenticationFilter 中的代码所示,一个 HTTP 请求到达之后,会通过一系列的 Filter 完成用户认证,而具体的工作交由 AuthenticationManager 完成,这个过程又会涉及 AuthenticationProvider 以及 UserDetailsService 等多个核心组件之间的交互。关于 Spring Security 中认证流程的详细描述,你可以参考[认证体系:如何深入理解 Spring Security 的用户认证机制?]做一些回顾。
小结与预告
这一讲我们关注于 Spring Security 中的一个核心组件——过滤器。在请求-响应式处理框架中,过滤器发挥着重要的作用,它用来实现对请求的拦截,并定义认证和授权逻辑。同时,我们也可以根据需要实现各种自定义的过滤器组件,从而实现对 Spring Security 的动态扩展。本讲对 Spring Security 中的过滤器架构和开发方式都做了详细的介绍,你可以反复学习。
本讲内容总结如下:
最后,给你留一道思考题:在 Spring Security 中,你能简单描述使用过滤器实现用户认证的操作过程吗?欢迎你在留言区和我分享自己的观点。

View File

@ -0,0 +1,516 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
09 攻击应对:如何实现 CSRF 保护和跨域 CORS
现在我们已经掌握了 Spring Security 提供的多项核心功能,但正如在开篇词中提到的,我们面临的系统安全性问题不止如此。今天我们就来讨论在日常开发过程中常见的两个安全性话题,即 CSRF 和 CORS。这两个缩写名称看似陌生但和应用程序的每次请求都有关联Spring Security 对它们也提供了良好的开发支持。
使用 Spring Security 提供 CSRF 保护
我们先来看 CSRF。CSRF 的全称是 Cross-Site Request Forgery翻译成中文就是跨站请求伪造。那么究竟什么是跨站请求伪造面对这个问题我们又该如何应对呢请继续往下看。
什么是 CSRF
从安全的角度来讲,你可以将 CSRF 理解为一种攻击手段,即攻击者盗用了你的身份,然后以你的名义向第三方网站发送恶意请求。我们可以使用如下所示的流程图来描述 CSRF
CSRF 运行流程图
具体流程如下:
用户浏览并登录信任的网站 A通过用户认证后会在浏览器中生成针对 A 网站的 Cookie
用户在没有退出网站 A 的情况下访问网站 B然后网站 B 向网站 A 发起一个请求;
用户浏览器根据网站 B 的请求,携带 Cookie 访问网站 A
由于浏览器会自动带上用户的 Cookie所以网站 A 接收到请求之后会根据用户具备的权限进行访问控制,这样相当于用户本身在访问网站 A从而网站 B 就达到了模拟用户访问网站 A 的操作过程。
显然从应用程序开发的角度来讲CSRF 就是系统的一个安全漏洞,这种安全漏洞也在 Web 开发中广泛存在。
基于 CSRF 的工作流程,进行 CSRF 保护的基本思想就是为系统中的每一个连接请求加上一个随机值,我们称之为 csrf_token。这样当用户向网站 A 发送请求时,网站 A 在生成的 Cookie 中就会设置一个 csrf_token 值。而在浏览器发送请求时,提交的表单数据中也有一个隐藏的 csrf_token 值,这样网站 A 接收到请求后,一方面从 Cookie 中提取出 csrf_token另一方面也从表单提交的数据中获取隐藏的 csrf_token将两者进行比对如果不一致就代表这就是一个伪造的请求。
使用 CsrfFilter
在 Spring Security 中,专门提供了一个 CsrfFilter 来实现对 CSRF 的保护。CsrfFilter 拦截请求,并允许使用 GET、HEAD、TRACE 和 OPTIONS 等 HTTP 方法的请求。而针对 PUT、POST、DELETE 等可能会修改数据的其他请求CsrfFilter 则希望接收包含 csrf_token 的消息头。如果这个消息头不存在或包含不正确的 csrf_token 值,应用程序将拒绝该请求并将响应的状态设置为 403。
看到这里,你可能会问,这个 csrf_token 到底长什么样子呢?其实它本质上就是一个字符串。在 Spring Security 中,专门定义了一个 CsrfToken 接口来约定它的格式:
public interface CsrfToken extends Serializable {
//获取消息头名称
String getHeaderName();
//获取应该包含 Token 的参数名称
String getParameterName();
//获取具体的 Token 值
String getToken();
}
而在 CsrfFilter 类中,我们也找到了如下所示的针对 CsrfToken 的处理过程:
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
request.setAttribute(HttpServletResponse.class.getName(), response);
//从 CsrfTokenRepository 中获取 CsrfToken
CsrfToken csrfToken = this.tokenRepository.loadToken(request);
final boolean missingToken = csrfToken == null;
//如果找不到 CsrfToken 就生成一个并保存到 CsrfTokenRepository 中
if (missingToken) {
csrfToken = this.tokenRepository.generateToken(request);
this.tokenRepository.saveToken(csrfToken, request, response);
}
//在请求中添加 CsrfToken
request.setAttribute(CsrfToken.class.getName(), csrfToken);
request.setAttribute(csrfToken.getParameterName(), csrfToken);
if (!this.requireCsrfProtectionMatcher.matches(request)) {
filterChain.doFilter(request, response);
return;
}
//从请求中获取 CsrfToken
String actualToken = request.getHeader(csrfToken.getHeaderName());
if (actualToken == null) {
actualToken = request.getParameter(csrfToken.getParameterName());
}
//如果请求所携带的 CsrfToken 与从 Repository 中获取的不同,则抛出异常
if (!csrfToken.getToken().equals(actualToken)) {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Invalid CSRF token found for "
+ UrlUtils.buildFullRequestUrl(request));
}
if (missingToken) {
this.accessDeniedHandler.handle(request, response,
new MissingCsrfTokenException(actualToken));
}
else {
this.accessDeniedHandler.handle(request, response,
new InvalidCsrfTokenException(csrfToken, actualToken));
}
return;
}
//正常情况下继续执行过滤器链的后续流程
filterChain.doFilter(request, response);
}
整个过滤器执行流程还是比较清晰的,基本就是围绕 CsrfToken 的校验工作。我们注意到这里引入了一个 CsrfTokenRepository这个 Repository 组件实现了对 CsrfToken 的存储管理,其中就包含前面提到的专门针对 Cookie 的 CookieCsrfTokenRepository。从 CookieCsrfTokenRepository 中,首先我们能看到一组常量定义,包括针对 CSRF 的 Cookie 名称、参数名称以及消息头名称,如下所示:
static final String DEFAULT_CSRF_COOKIE_NAME = "XSRF-TOKEN";
static final String DEFAULT_CSRF_PARAMETER_NAME = "_csrf";
static final String DEFAULT_CSRF_HEADER_NAME = "X-XSRF-TOKEN";
CookieCsrfTokenRepository 的 saveToken() 方法也比较简单,就是基于 Cookie 对象进行了 CsrfToken 的设置工作,如下所示:
@Override
public void saveToken(CsrfToken token, HttpServletRequest request,
HttpServletResponse response) {
String tokenValue = token == null ? "" : token.getToken();
Cookie cookie = new Cookie(this.cookieName, tokenValue);
cookie.setSecure(request.isSecure());
if (this.cookiePath != null && !this.cookiePath.isEmpty()) {
cookie.setPath(this.cookiePath);
} else {
cookie.setPath(this.getRequestContext(request));
}
if (token == null) {
cookie.setMaxAge(0);
}
else {
cookie.setMaxAge(-1);
}
cookie.setHttpOnly(cookieHttpOnly);
if (this.cookieDomain != null && !this.cookieDomain.isEmpty()) {
cookie.setDomain(this.cookieDomain);
}
response.addCookie(cookie);
}
在 Spring Security 中CsrfTokenRepository 接口具有一批实现类,除了 CookieCsrfTokenRepository还有 HttpSessionCsrfTokenRepository 等,这里不再一一展开。
了解了 CsrfFilter 的基本实现流程,下面我们继续讨论如何使用它来实现 CSRF 保护。从 Spring Security 4.0 开始,默认启用 CSRF 保护,以防止 CSRF 攻击应用程序。Spring Security CSRF 会针对 POST、PUT 和 DELETE 方法进行防护。因此,对于开发人员而言,实际上你并不需要做什么额外工作就能使用这个功能了。当然,如果你不想使用这个功能,也可以通过如下配置方法进行关闭:
http.csrf().disable();
定制化 CSRF 保护
根据前面的讨论,如果你想获取 HTTP 请求中的 CsrfToken只需要使用如下所示的代码
CsrfToken token = (CsrfToken)request.getAttribute("_csrf");
如果你不想使用 Spring Security 内置的存储方式,而是想基于自身需求把 CsrfToken 存储起来,要做的事情就是实现 CsrfTokenRepository 接口。这里我们尝试把 CsrfToken 保存到关系型数据库中,所以可以通过扩展 Spring Data 中的 JpaRepository 来定义一个 JpaTokenRepository如下所示
public interface JpaTokenRepository extends JpaRepository<Token, Integer> {
Optional<Token> findTokenByIdentifier(String identifier);
}
JpaTokenRepository 很简单,只有一个根据 identifier 获取 Token 的查询方法,而新增接口则是 JpaRepository 默认提供的,我们可以直接使用。
然后,我们基于 JpaTokenRepository 来构建一个 DatabaseCsrfTokenRepository如下所示
public class DatabaseCsrfTokenRepository
implements CsrfTokenRepository {
@Autowired
private JpaTokenRepository jpaTokenRepository;
@Override
public CsrfToken generateToken(HttpServletRequest httpServletRequest) {
String uuid = UUID.randomUUID().toString();
return new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", uuid);
}
@Override
public void saveToken(CsrfToken csrfToken, HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) {
String identifier = httpServletRequest.getHeader("X-IDENTIFIER");
Optional<Token> existingToken = jpaTokenRepository.findTokenByIdentifier(identifier);
if (existingToken.isPresent()) {
Token token = existingToken.get();
token.setToken(csrfToken.getToken());
} else {
Token token = new Token();
token.setToken(csrfToken.getToken());
token.setIdentifier(identifier);
jpaTokenRepository.save(token);
}
}
@Override
public CsrfToken loadToken(HttpServletRequest httpServletRequest) {
String identifier = httpServletRequest.getHeader("X-IDENTIFIER");
Optional<Token> existingToken = jpaTokenRepository.findTokenByIdentifier(identifier);
if (existingToken.isPresent()) {
Token token = existingToken.get();
return new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", token.getToken());
}
return null;
}
}
DatabaseCsrfTokenRepository 类的代码基本都是自解释的,这里借助了 HTTP 请求中的“X-IDENTIFIER”请求头来确定请求的唯一标识从而将这一唯一标识与特定的 CsrfToken 关联起来。然后我们使用 JpaTokenRepository 完成了针对关系型数据库的持久化工作。
最后,想要上述代码生效,我们需要通过配置方法完成对 CSRF 的设置,如下所示,这里直接通过 csrfTokenRepository 方法集成了自定义的 DatabaseCsrfTokenRepository
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf(c -> {
c.csrfTokenRepository(databaseCsrfTokenRepository());
});
}
作为总结,我们可以用如下所示的示意图来梳理整个定制化 CSRF 所包含的各个组件以及它们之间的关联关系:
定制化 CSRF 的相关组件示意图
使用 Spring Security 实现 CORS
介绍完 CSRF我们继续来看 Web 应用程序开发过程中另一个常见的需求——CORS即跨域资源共享Cross-Origin Resource Sharing。那么问题来了什么叫跨域
什么是 CORS
当下的 Web 应用程序开发基本都采用了前后端分离的开发模式数据的获取并非同源所以跨域问题在我们日常开发中特别常见。例如当我们从“test.com”这个域名发起请求时浏览器为了一定的安全因素考虑并不会允许请求去访问“api.test.com”这个域名因为请求已经跨越了两个域名。
请注意,跨域是浏览器的一种同源安全策略,是浏览器单方面限制的,所以仅在客户端运行在浏览器中才需要考虑这个问题。从原理上讲,实际就是浏览器在 HTTP 请求的消息头部分新增一些字段,如下所示:
//浏览器自己设置的请求域名
Origin
//浏览器告诉服务器请求需要用到哪些 HTTP 方法
Access-Control-Request-Method
//浏览器告诉服务器请求需要用到哪些 HTTP 消息头
Access-Control-Request-Headers
当浏览器进行跨域请求时会和服务器端进行一次的握手协议,从响应结果中可以获取如下信息:
//指定哪些客户端的域名允许访问这个资源
Access-Control-Allow-Origin
//服务器支持的 HTTP 方法
Access-Control-Allow-Methods
//需要在正式请求中加入的 HTTP 消息头
Access-Control-Allow-Headers
因此,实现 CORS 的关键是服务器。只要服务器合理设置这些响应结果中的消息头,就相当于实现了对 CORS 的支持,从而支持跨源通信。
使用 CorsFilter
和 CsrfFilter 注解一样,在 Spring 中也存在一个 CorsFilter 过滤器,不过这个过滤器并不是 Spring Security 提供的而是来自Spring Web MVC。在 CorsFilter 这个过滤器中,首先应该判断来自客户端的请求是不是一个跨域请求,然后根据 CORS 配置来判断该请求是否合法,如下所示:
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
if (CorsUtils.isCorsRequest(request)) {
CorsConfiguration corsConfiguration = this.configSource.getCorsConfiguration(request);
if (corsConfiguration != null) {
boolean isValid = this.processor.processRequest(corsConfiguration, request, response);
if (!isValid || CorsUtils.isPreFlightRequest(request)) {
return;
}
}
}
filterChain.doFilter(request, response);
}
上述操作的内容是创建合适的配置类 CorsConfiguration。根据 CorsFilterSpring Security 也在 HttpSecurity 工具类通过提供了 cors() 方法来创建 CorsConfiguration使用方式如下所示
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors(c -> {
CorsConfigurationSource source = request -> {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(Arrays.asList("*"));
config.setAllowedMethods(Arrays.asList("*"));
return config;
};
c.configurationSource(source);
});
}
我们可以通过 setAllowedOrigins() 和 setAllowedMethods() 方法实现对 HTTP 响应消息头的设置。这里将它们都设置成“*”,意味着所有请求都可以进行跨域访问。你也可以根据需要设置特定的域名和 HTTP 方法。
使用 @CrossOrigin 注解
通过 CorsFilter我们实现了全局级别的跨域设置。但有时候我们可能只需要针对某些请求实现这一功能通过 Spring Security 也是可以做到这一点的,我们可以在特定的 HTTP 端点上使用如下所示的 @CrossOrigin 注解:
@Controller
public class TestController {
@PostMapping("/hello")
@CrossOrigin("http://api.test.com:8080")
public String hello() {
return "hello";
}
}
默认情况下,@CrossOrigin 注解允许使用所有的域和消息头,同时会将 Controller 中的方法映射到所有的 HTTP 方法。
小结与预告
这一讲关注的是对 Web 请求安全性的讨论,我们讨论了日常开发过程中常见的两个概念,即 CSRF 和 CORS。这两个概念有时候容易混淆但应对的是完全不同的两种场景。
CSRF 是一种攻击行为,所以我们需要对系统进行保护,而 CORS 更多的是一种前后端开发模式上的约定。在 Spring Security 中,针对这两个场景都提供了对应的过滤器,我们只需要通过简单的配置方法就能在系统中自动集成想要的功能。
本讲主要内容如下:
最后我想给你留一道思考题:在 Spring Security 中,如何定制化一套对 CsrfToken 的处理机制?欢迎你在留言区和我分享观点。

View File

@ -0,0 +1,302 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
10 全局方法:如何确保方法级别的安全访问?
到目前为止,我们已经系统介绍了 Spring Security 中的认证和授权过程。但是请注意,我们讨论的对象是 Web 应用程序,也就是说认证和授权的资源是一系列的 HTTP 端点。那么如果我们开发的不是一个 Web 应用程序呢?认证和授权还能否发挥作用呢?答案是肯定的。今天我们就来讨论针对方法级别的安全访问策略,确保一个普通应用程序中的每个组件都能具备安全性保障。
全局方法安全机制
明确方法级别的安全机制之前,我们先来剖析一个典型的应用程序具备的各层组件。以 Spring Boot 应用程序为例,我们可以采用经典的分层架构,即将应用程序分成 Web 层、Service 层和 Repository 层。请注意,三层架构中的 Service 层组件可能还会调用其他的第三方组件。
在各层组件中,围绕某个业务链路提供了对应的实现方法,我们可以针对这些方法开展安全控制。因此,你可以认为这种安全控制不仅面向 Web 层组件而且是全局方法级别的所以也被称为全局方法安全Global Method Security机制。
那么,全局方法安全机制能为我们带来什么价值呢?通常包括两个方面,即方法调用授权和方法调用过滤。
方法调用授权的含义很明确与端点级别的授权机制一样我们可以用它来确定某个请求是否具有调用方法的权限。如果是在方法调用之前进行授权管理就是预授权PreAuthorization如果是在方法执行完成后来确定是否可以访问方法返回的结果一般称之为后授权PostAuthorization
方法调用过滤本质上类似于过滤器机制,也可以分为 PreFilter 和 PostFilter 两大类。其中预过滤PreFilter用来对该方法的参数进行过滤从而获取其参数接收的内容而后过滤PostFilter则用来判断调用者可以在方法执行后从方法返回结果中接收的内容。
请注意,默认情况下 Spring Security 并没有启用全局方法安全机制。因此,想要启用这个功能,我们需要使用@EnableGlobalMethodSecurity 注解。正如本专栏前面案例所展示的,一般的做法是创建一个独立的配置类,并把这个注解添加在配置类上,如下所示:
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig
请注意,在使用 @EnableGlobalMethodSecurity 注解时我们设置了“prePostEnabled”为 true意味着我们启用了 Pre/PostAuthorization 注解,而默认情况下这些注解也是不生效的。同时,我们也需要知道,在 Spring Security 中为实现全局方法安全机制提供了三种实现方法,除了 Pre/PostAuthorization 注解之外,还可以使用基于 JSR 250 规范的 @RolesAllowed 注解和 @Secured 注解。在本专栏中,我们只讨论最常用的 Pre/PostAuthorization 注解,下面我们来看具体的使用方法。
使用注解实现方法级别授权
针对方法级别授权Spring Security 提供了 @PreAuthorize@PostAuthorize 这两个注解,分别用于预授权和后授权。
@PreAuthorize 注解
先来看 @PreAuthorize 注解的使用场景。假设在一个基于 Spring Boot 的 Web 应用程序中,存在一个 Web 层组件 OrderController该 Controller 会调用 Service 层的组件 OrderService。我们希望对访问 OrderService 层中方法的请求添加权限控制能力即只有具备“DELETE”权限的请求才能执行 OrderService 中的 deleteOrder() 方法,而没有该权限的请求将直接抛出一个异常,如下图所示:
Service 层组件预授权示意图
显然,上述流程针对的是预授权的应用场景,因此我们可以使用 @PreAuthorize 注解,
该注解定义如下:
@Target({ ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface PreAuthorize {
//通过SpEL表达式设置访问控制
String value();
}
可以发现,@PreAuthorize 注解与[ 05 讲“访问授权:如何对请求的安全访问过程进行有效配置?”]中介绍的 access() 方法的原理是一样的,都是通过传入一个 SpEL 表达式来设置访问控制规则。
要想在应用程序中集成 @PreAuthorize 注解,我们可以创建如下所示的安全配置类,在这个配置类上我们添加了 @EnableGlobalMethodSecurity 注解:
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
@Bean
public UserDetailsService userDetailsService() {
UserDetailsService service = new InMemoryUserDetailsManager();
UserDetails u1 = User.withUsername("jianxiang1")
.password("12345")
.authorities("WRITE")
.build();
UserDetails u2 = User.withUsername("jianxiang2")
.password("12345")
.authorities("DELETE")
.build();
service.createUser(u1);
service.createUser(u2);
return service;
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
}
这里我们创建了两个用户“jianxiang1”和“jianxiang2”分别具备“WRITE”和“DELETE”权限。然后我们实现 OrderService 的 deleteOrder() 方法,如下所示:
@Service
public class OrderService {
@PreAuthorize("hasAuthority('DELETE')")
public void deleteOrder(String orderId) {
}
}
可以看到,这里使用了 @PreAuthorize 注解来实现预授权。在该注解中,我们通过熟悉的 hasAuthority(DELETE) 方法来判断请求是否具有“DELETE”权限。
上面介绍的这种情况比较简单,我们再来看一个比较复杂的场景,该场景与用户认证过程进行整合。
假设在 OrderService 中存在一个 getOrderByUser(String user) 方法而出于系统安全性的考虑我们希望用户只能获取自己创建的订单信息也就是说我们需要校验通过该方法传入的“user”参数是否为当前认证的合法用户。这种场景下我们就可以使用 @PreAuthorize 注解:
@PreAuthorize("#name == authentication.principal.username")
public List<Order> getOrderByUser(String user) {
}
这里我们将输入的“user”参数与通过 SpEL 表达式从安全上下文中获取的“authentication.principal.username”进行比对如果相同就执行正确的方法逻辑反之将直接抛出异常。
@PostAuthorize 注解
相较 @PreAuthorize 注解,@PostAuthorize 注解的应用场景可能少见一些。有时我们允许调用者正确调用方法,但希望该调用者不接受返回的响应结果。这听起来似乎有点奇怪,但在那些访问第三方外部系统的应用中,我们并不能完全相信返回数据的正确性,也有对调用的响应结果进行限制的需求,@PostAuthorize 注解为我们实现这类需求提供了很好的解决方案,如下所示:
Service 层组件后授权示意图
为了演示 @PostAuthorize 注解,我们先来设定特定的返回值。假设我们存在如下所示的一个 Author 对象,保存着该作者的姓名和创作的图书作品:
public class Author {
private String name;
private List<String> books;
}
进一步,我们假设系统中保存着如下所示的两个 Author 对象:
Map<String, Author> authors =
Map.of("AuthorA", new Author("AuthorA ",List.of("BookA1", “BookA2)),"AuthorB", new Author("AuthorB", List.of("BookB1")
)
);
现在,我们有这样一个根据姓名获取 Author 对象的查询方法:
@PostAuthorize("returnObject.books.contains('BookA2')")
public Author getAuthorByNames(String name) {
return authors.get(name);
}
可以看到,通过使用 @PostAuthorize 注解我们就能根据返回值来决定授权的结果。在这个示例中借助于代表返回值的“returnObject”对象如果我们使用创作了“BookA2”的“AuthorA”来调用这个方法就能正常返回数据如果使用“AuthorB”就会报 403 异常。
使用注解实现方法级别过滤
针对方法级别过滤Spring Security 同样提供了一对注解,即分别用于预过滤和后过滤的 @PreFilter@PostFilter
@PreFilter 注解
在介绍使用 @PreFilter 注解实现方法级别过滤之前,我们先要明确它与 @PreAuthorize 注解之间的区别。通过预授权,如果方法调用的参数不符合权限规则,那么这个方法就不会被调用。而使用预过滤,方法调用是一定会执行的,但只有那些符合过滤规则的数据才会正常传递到调用链路的下一层组件。
接下来我们看 @PreFilter 注解的使用方法。我们设计一个新的数据模型,并构建如下所示的 Controller 层方法:
@Autowired
private ProductService productService;
@GetMapping("/sell")
public List<Product> sellProduct() {
List<Product> products = new ArrayList<>();
products.add(new Product("p1", "jianxiang1"));
products.add(new Product("p2", "jianxiang2"));
products.add(new Product("p3", "jianxiang3"));
return productService.sellProducts(products);
}
上面代码中的 Product 对象包含了商品的编号和用户名。然后,我们来到 Service 层组件,实现如下所示的方法:
@PreFilter("filterObject.name == authentication.name")
public List<Product> sellProducts(List<Product> products) {
return products;
}
这里我们使用了 @PreFilter 注解对输入数据进行了过滤。通过使用“filterObject”对象我们可以获取输入的 Product 数据然后将“filterObject.name”字段与从安全上下文中获取的“authentication.name”进行比对就能将那些不属于当前认证用户的数据进行过滤。
@PostFilter 注解
同样,为了更好地理解 @PostFilter 注解的含义,我们也将它与 @PostAuthorize 注解进行对比。类似的,通过后授权,如果方法调用的参数不符合权限规则,那么这个方法就不会被调用。如果使用后过滤,方法调用也是一定会执行的,但只有那些符合过滤规则的数据才会正常返回。
@PostFilter 注解的使用方法也很简单,示例如下:
@PostFilter("filterObject.name == authentication.principal.username")
public List<Product> findProducts() {
List<Product> products = new ArrayList<>();
products.add(new Product("p1", "jianxiang1"));
products.add(new Product("p2", "jianxiang2"));
products.add(new Product("p3", "jianxiang3"));
return products;
}
通过 @PostFilter我们指定了过滤的规则为”filterObject.name == authentication.principal.username”也就是说该方法只会返回那些属于当前认证用户的数据其他用户的数据会被自动过滤。
通过上述案例,你可能已经认识到了各个注解之间的微妙关系。比方说,@PreFilter 注解的效果实际上和 @PostAuthorize 注解的效果有点类似,但两者针对数据的处理方向是相反的,即 @PreFilter 注解控制从 Controller 层到 Service 层的数据输入,而 @PostAuthorize 反过来限制了从 Service 层到 Controller 层的数据返回。在日常开发过程中,你需要关注业务场景下数据的流转方向,才能正确选择合适的授权或过滤注解。
小结与预告
这一讲我们关注的重点从 HTTP 端点级别的安全控制转换到了普通方法级别的安全控制。Spring Security 内置了一组非常实用的注解,方便开发人员实现全局方法安全机制,包括用于实现方法级别授权的 @PreAuthorize@PostAuthorize 注解,以及用于实现方法级别过滤的 @PreFilter 注解和 @PostFilter 注解。我们针对这些注解的使用方法也给出了相应的描述和示例代码。
本讲内容总结如下:
这里给你留一道思考题:针对 Spring Security 提供的全局方法安全机制,你能描述方法级别授权和方法级别过滤的区别以及它们各自的应用场景吗?欢迎在留言区写下你的想法。

View File

@ -0,0 +1,662 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
11 案例实战:使用 Spring Security 高级主题保护 Web 应用
前面几讲我们系统介绍了 Spring Security 提供的一些高级功能包括过滤器、CSRF 保护、CORS 以及全局方法这些都是非常实用的功能特性。作为阶段性的总结今天的内容将利用这些功能特性构建在安全领域中的一种典型的认证机制即多因素认证Multi-Factor AuthenticationMFA机制。
案例设计和初始化
在今天的案例中,我们构建多因素认证的思路并不是采用第三方成熟的解决方案,而是基于 Spring Security 的功能特性来自己设计并实现一个简单而完整的认证机制。
多因素认证设计
多因素认证是一种安全访问控制的方法,基本的设计理念在于用户想要访问最终的资源,至少需要通过两种以上的认证机制。
那么我们如何实现多种认证机制呢一种常见的做法是分成两个步骤第一步通过用户名和密码获取一个认证码Authentication Code第二步基于用户名和这个认证码进行安全访问。基于这种多因素认证的基本执行流程如下图所示
多因素认证的实现方式示意图
系统初始化
为了实现多因素认证,我们需要构建一个独立的认证服务 Auth-Service该服务同时提供了基于用户名+密码以及用户名+认证码的认证形式。当然,实现认证的前提是构建用户体系,因此我们需要提供如下所示的 User 实体类:
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
private String username;
private String password;
}
可以看到User 对象中包含了用户名 Username 和密码 Password 的定义。同样的,在如下所示的代表认证码的 AuthCode 对象中包含了用户名 Username 和具体的认证码 Code 字段的定义:
@Entity
public class AuthCode {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
private String username;
private String code;
}
基于 User 和 AuthCode 实体对象,我们也给出创建数据库表的对应 SQL 定义,如下所示:
CREATE TABLE IF NOT EXISTS `spring_security_demo`.`user` (
`id` INT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(45) NULL,
`password` TEXT NULL,
PRIMARY KEY (`id`));
CREATE TABLE IF NOT EXISTS `spring_security_demo`.`auth_code` (
`id` INT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(45) NOT NULL,
`code` VARCHAR(45) NULL,
PRIMARY KEY (`id`));
有了认证服务,接下来我们需要构建一个业务服务 Business-Service该业务服务通过集成认证服务完成具体的认证操作并返回访问令牌Token给到客户端系统。因此从依赖关系上讲Business-Service 会调用 Auth-Service如下图所示
Business-Service 调用 Auth-Service 关系图
接下来,我们分别从这两个服务入手,实现多因素认证机制。
实现多因素认证机制
对于多因素认证机制而言,实现认证服务是基础,但难度并不大,我们往下看。
实现认证服务
从表现形式上看,认证服务也是一个 Web 服务,所以内部需要通过构建 Controller 层组件实现 HTTP 端点的暴露。为此,我们构建了如下所示的 AuthController
@RestController
public class AuthController {
@Autowired
private UserService userService;
//添加User
@PostMapping("/user/add")
public void addUser(@RequestBody User user) {
userService.addUser(user);
}
//通过用户名+密码对用户进行首次认证
@PostMapping("/user/auth")
public void auth(@RequestBody User user) {
userService.auth(user);
}
//通过用户名+认证码进行二次认证
@PostMapping("/authcode/check")
public void check(@RequestBody AuthCode authCode, HttpServletResponse response) {
if (userService.check(authCode)) {
response.setStatus(HttpServletResponse.SC_OK);
} else {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
}
}
}
可以看到,这里除了一个添加用户信息的 HTTP 端点之外,我们分别实现了通过用户名+密码对用户进行首次认证的”/user/auth”端点以及通过用户名+认证码进行二次认证的”/authcode/check”端点。
这两个核心端点背后的实现逻辑都位于 UserService 中,我们先来看其中的 auth() 方法:
public void auth(User user) {
Optional<User> o =
userRepository.findUserByUsername(user.getUsername());
if(o.isPresent()) {
User u = o.get();
if (passwordEncoder.matches(user.getPassword(), u.getPassword())) {
//生成或刷新认证码
generateOrRenewAutoCode(u);
} else {
throw new BadCredentialsException("Bad credentials.");
}
} else {
throw new BadCredentialsException("Bad credentials.");
}
}
上述代码中的关键流程就是在完成用户密码匹配之后的刷新认证码流程,负责实现该流程的 generateOrRenewAutoCode() 方法如下所示:
private void generateOrRenewAutoCode (User u) {
String generatedCode = GenerateCodeUtil.generateCode();
Optional<AuthCode> autoCode = autoCodeRepository.findAuthCodeByUsername(u.getUsername());
if (autoCode.isPresent()) {//如果存在认证码,则刷新该认证码
AuthCode code = autoCode.get();
code.setCode(generatedCode);
} else {//如果没有找到认证码,则生成并保存一个新的认证码
AuthCode code = new AuthCode();
code.setUsername(u.getUsername());
code.setCode(generatedCode);
autoCodeRepository.save(code);
}
}
上述方法的流程也很明确,首先通过调用工具类 GenerateCodeUtil 的 generateCode() 方法生成一个认证码,然后根据当前数据库中的状态决定是否对已有的认证码进行刷新,或者直接生成一个新的认证码并保存。因此,每次调用 UserService 的 auth() 方法就相当于对用户的认证码进行了动态重置。
一旦用户获取了认证码,并通过该认证码访问系统,认证服务就可以对该认证码进行校验,从而确定其是否有效。对认证码进行验证的方法如下所示:
public boolean check(AuthCode authCodeToValidate) {
Optional<AuthCode> authCode = autoCodeRepository.findAuthCodeByUsername(authCodeToValidate.getUsername());
if (authCode.isPresent()) {
AuthCode authCodeInStore = authCode.get();
if (authCodeToValidate.getCode().equals(authCodeInStore.getCode())) {
return true;
}
}
return false;
}
这里的逻辑也很简单,就是把从数据库中获取的认证码与用户传入的认证码进行比对。
至此,认证服务的核心功能已经构建完毕,下面我们来看业务服务的实现过程。
实现业务服务
在业务服务中,势必需要调用认证服务提供的 HTTP 端点来完成用户认证和认证码认证这两个核心的认证操作。因此我们需要构建一个认证服务的客户端组件完成远程调用。在案例中我们参考设计模式中的门面Facade模式的设计理念将这个组件命名为 AuthenticationServerFacade也就是说它是认证服务的一种门面组件定义如下
@Component
public class AuthenticationServerFacade {
@Autowired
private RestTemplate rest;
@Value("${auth.server.base.url}")
private String baseUrl;
public void checkPassword(String username, String password) {
String url = baseUrl + "/user/auth";
User body = new User();
body.setUsername(username);
body.setPassword(password);
HttpEntity<User> request = new HttpEntity<User>(body);
rest.postForEntity(url, request, Void.class);
}
public boolean checkAuthCode(String username, String code) {
String url = baseUrl + "/authcode/check";
User body = new User();
body.setUsername(username);
body.setCode(code);
HttpEntity<User> request = new HttpEntity<User>(body);
ResponseEntity<Void> response = rest.postForEntity(url, request, Void.class);
return response.getStatusCode().equals(HttpStatus.OK);
}
}
这里的 baseUrl 就是认证服务暴露的服务地址,我们使用 RestTemplate 模板类发起对认证服务的远程调用,并根据返回值来判断认证是否通过。
有了 AuthenticationServerFacade我们就可以在业务服务中集成认证服务了。我们在每次请求的处理过程中完成这种集成工作此时需要用到拦截器而这种集成工作显然需要依赖于认证管理器 AuthenticationManager。因此我们可以先来设计并实现如下所示的 CustomAuthenticationFilter 代码结构:
@Component
public class CustomAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private AuthenticationManager manager;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String username = request.getHeader("username");
String password = request.getHeader("password");
String code = request.getHeader("code");
//使用 AuthenticationManager 处理认证过程
}
}
上述代码中第一个需要关注的点是 CustomAuthenticationFilter 所扩展的基类 OncePerRequestFilter。顾名思义OncePerRequestFilter 能够确保在一次请求中只执行一次过滤器逻辑,不会发生多次重复执行的情况。这里我们分别从 HTTP 请求头中获取了用户名 username、密码 password 以及认证码 code 这三个参数,并尝试使用 AuthenticationManager 完成认证。基于[03 讲“认证体系:如何深入理解 Spring Security 的用户认证机制?”]中的讨论,我们知道 AuthenticationManager 的背后实际上使用了 AuthenticationProvider 执行具体的认证操作。
再来回想一下认证服务中提供的两种认证操作,一种是基于用户名和密码完成用户认证,一种是基于用户名和认证码完成针对认证码的认证。因此,我们需要针对这两种操作分别实现不同的 AuthenticationProvider。例如如下所示的 UsernamePasswordAuthenticationProvider 就实现了针对用户名和密码的认证操作:
@Component
public class UsernamePasswordAuthenticationProvider implements AuthenticationProvider {
@Autowired
private AuthenticationServerFacade authServer;
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = String.valueOf(authentication.getCredentials());
//调用认证服务完成认证
authServer.checkPassword(username, password);
return new UsernamePasswordAuthenticationToken(username, password);
}
public boolean supports(Class<?> aClass) {
return UsernamePasswordAuthentication.class.isAssignableFrom(aClass);
}
}
可以看到,这里使用了 AuthenticationServerFacade 门面类来完成对认证服务的远程调用。类似地 ,我们也可以构建针对认证码的 AuthenticationProvider即如下所示的 AuthCodeAuthenticationProvider
@Component
public class AuthCodeAuthenticationProvider implements AuthenticationProvider {
@Autowired
private AuthenticationServerFacade authServer;
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String code = String.valueOf(authentication.getCredentials());
//调用认证服务完成认证
boolean result = authServer.checkAuthCode(username, code);
if (result) {
return new AuthCodeAuthentication(username, code);
} else {
throw new BadCredentialsException("Bad credentials.");
}
}
public boolean supports(Class<?> aClass) {
return AuthCodeAuthentication.class.isAssignableFrom(aClass);
}
}
请注意,无论是 UsernamePasswordAuthenticationProvider 还是 AuthCodeAuthenticationProvider所返回的 UsernamePasswordAuthentication 和 AuthCodeAuthentication 都是自定义的认证信息类,它们都继承了 Spring Security 自带的 UsernamePasswordAuthenticationToken。
现在,让我们回到过滤器组件 CustomAuthenticationFilter并提供对它的完整实现如下所示
@Component
public class CustomAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private AuthenticationManager manager;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String username = request.getHeader("username");
String password = request.getHeader("password");
String code = request.getHeader("code");
//如果认证码为空,说明需要先执行用户名/密码认证
if (code == null) {
Authentication a = new UsernamePasswordAuthentication(username, password);
manager.authenticate(a);
} else {
//如果认证码不为空,则执行认证码认证
Authentication a = new AuthCodeAuthentication(username, code);
manager.authenticate(a);
//如果认证码认证通过,则通过 UUID 生成一个 Token 并添加在响应的消息头中
String token = UUID.randomUUID().toString();
response.setHeader("Authorization", token);
}
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getServletPath().equals("/login");
}
}
CustomAuthenticationFilter 的实现过程比较简单代码也都是自解释的唯一需要注意的是在基于认证码的认证过程通过之后我们会在响应中添加一个“Authorization”消息头并使用 UUID 值作为 Token 进行返回。
针对上述代码,我们可以通过如下所示的类图进行总结:
多因素认证执行核心类图
最后,我们需要通过 Spring Security 中的配置体系确保各个类之间的有效协作。为此,我们构建了如下所示的 SecurityConfig 类:
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomAuthenticationFilter customAuthenticationFilter;
@Autowired
private AuthCodeAuthenticationProvider authCodeAuthenticationProvider;
@Autowired
private UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider;
@Override
protected void configure(AuthenticationManagerBuilder auth) {
auth.authenticationProvider(authCodeAuthenticationProvider)
.authenticationProvider(usernamePasswordAuthenticationProvider);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable();
http.addFilterAt(
customAuthenticationFilter,
BasicAuthenticationFilter.class);
http.authorizeRequests()
.anyRequest().authenticated();
}
@Override
@Bean
protected AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
}
}
上述配置内容中,我们看到可以通过 addFilterAt() 方法添加自定义过滤器。关于过滤器使用方式的更多内容,你也可以参考[08 讲“管道过滤:如何基于 Spring Security 过滤器扩展安全性?”]的内容进行回顾。
关于案例的完整代码你可以在这里进行下载https://github.com/lagouEdAnna/SpringSecurity-jianxiang/tree/main/MultiFactorAuthenticationDemo。
案例演示
现在,让我们分别在本地启动认证服务和业务服务,请注意:认证服务的启动端口是 8080而业务服务的启动端口是 9090。然后我们打开模拟 HTTP 请求的 Postman 并输入相关参数,如下所示:
多因素认证的第一步认证示意图:基于用户名+密码
显然,该请求只传入了用户名和密码,所以会基于 UsernamePasswordAuthenticationProvider 执行认证过程从而为用户“jianxiang”生成认证码。认证码是动态生成的所以每次请求对应的结果都是不一样的我通过查询数据库获取该认证码为“9750”你也可以自己做一些尝试。
有了认证码,相当于完成了多因素认证机制的第一步。接下来,我们再次基于这个认证码构建请求并获取响应结果,如下所示:
多因素认证的第二步认证示意图:基于用户名+认证码
可以看到,通过传入正确的认证码,我们基于 AuthCodeAuthenticationProvider 完成了多因素认证机制中的第二步认证,并最终在 HTTP 响应中生成了一个“Authorization”消息头。
小结与预告
这一讲我们基于多因素认证机制展示了如何利用 Spring Security 中的一些高级主题保护 Web 应用程序的实现方法。多因素认证机制的实现需要构建多个自定义的 AuthenticationProvider并通过拦截器完成对请求的统一处理。相信案例中展示的这些开发技巧会给你的日常开发工作带来帮助。
本讲内容总结如下:
这里给你留一道思考题:在 Spring Security 中,如何利用过滤器实现对用户请求的定制化认证?

View File

@ -0,0 +1,147 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
12 开放协议OAuth2 协议解决的是什么问题?
从今天开始我们针对安全性的讨论将从单体服务上升到微服务架构。对于微服务架构而言安全性设计的最核心考虑点还是认证和授权。由于一个微服务系统中各服务之间存在相互调用的关系因此针对每一个服务我们既需要考虑来自客户端的请求同时也要考虑可能来自另一个服务的请求。安全访问控制就面临着从客户端请求到服务、从服务到服务的多种授权场景。因此我们需要引入专门用于处理分布式环境下的授权体系OAuth2 协议就是应对这种应用场景的有效解决方案。
OAuth2 协议详解
OAuth 是 Open Authorization 的简称,该协议解决的是授权问题而不是认证问题,目前普遍被采用的是 OAuth 2.0 版协议。OAuth2 是一个相对复杂的协议,对涉及的角色和授权模式给出了明确的定义,我们继续往下看。
OAuth2 协议的应用场景
在常见的电商系统中,通常会存在类似工单处理的系统,而工单的生成在使用用户基本信息的同时,势必也依赖于用户的订单记录等数据。为了降低开发成本,假设我们的整个商品订单模块并不是自己研发的,而是集成了外部的订单管理平台,此时为了生成工单记录,就必须让工单系统读取用户在订单管理平台上的订单记录。
在这个场景中,难点在于只有得到用户的授权,才能同意工单系统读取用户在订单管理平台上的订单记录。那么问题就来了,工单系统如何获得用户的授权呢?一般我们想到的方法是用户将自己在订单管理平台上的用户名和密码告诉工单系统,然后工单系统通过用户名和密码登录到订单管理平台并读取用户的订单记录,整个过程如下图所示:
案例系统中用户认证和授权交互示意图
上图中的方案虽然可行,但显然存在几个严重的缺点:
工单系统为了开展后续的服务,会保存用户在订单管理平台上的密码,这样很不安全;如果用户密码不小心被泄露了,就会导致订单管理平台上的用户数据发生泄露;
工单系统拥有了获取用户存储在订单管理平台上所有资料的权限,用户无法限制工单系统获得授权的范围和有效期;
如果用户修改了订单管理平台的密码,那么工单系统就无法正常访问订单管理平台了,这会导致业务中断,但我们又不能限制用户修改密码。
既然这个方案存在如此多的问题那么有没有更好的办法呢答案是肯定的OAuth2 协议的诞生就是为了解决这些问题。
首先,针对密码的安全性,在 OAuth2 协议中密码还是由用户自己保管避免了敏感信息的泄露其次OAuth2 协议中提供的授权具有明确的应用范围和有效期,用户可以根据需要限制工单系统所获取授权信息的作用效果;最后,如果用户对自己的密码等身份凭证信息进行了修改,只需通过 OAuth2 协议重新进行一次授权即可,不会影响到相关联的其他第三方应用程序。
传统认证授权机制与 OAuth2 协议的对比图
OAuth2 协议的角色
OAuth2 协议之所有能够具备这些优势一个主要的原因在于它把整个系统涉及的各个角色及其职责做了很好地划分。OAuth2 协议中定义了四个核心的角色:资源、客户端、授权服务器和资源服务器。
OAuth2 协议中的角色定义
我们可以把 OAuth2 中的角色与现实中的应用场景对应起来。
OAuth2 协议中把需要访问的接口或服务统称为资源Resource每个资源都有一个拥有者Resource Owner也就是案例中的用户。
案例的工单系统代表的是一种第三方应用程序Third-party Application通常被称为客户端Client
与客户端相对应的OAuth2 协议中还存在一个服务提供商案例中的订单管理平台就扮演了这个角色。服务提供商拥有一个资源服务器Resource Server和一个授权服务器Authorization Server其中资源服务器存放着用户资源案例中的订单记录就是一种用户资源而授权服务器的作用就是完成针对用户的授权流程并最终颁发一个令牌也就是我们所说的 Token。
OAuth2 协议的 Token
看到这里,你可能会提问,所谓的访问令牌是什么?令牌是 OAuth2 协议中非常重要的一个概念,本质上也是一种代表用户身份的授权凭证,但与普通的用户名和密码信息不同,令牌具有针对资源的访问权限范围和有效期。如下所示就是一种常见的令牌信息:
{
"access_token": "0efa61be-32ab-4351-9dga-8ab668ababae",
"token_type": "bearer",
"refresh_token": "738c42f6-79a6-457d-8d5a-f9eab0c7cc5e",
"expires_in": 43199,
"scope": "webclient"
}
上述令牌信息中的各个字段都很重要,我们展开分析。
access_token代表 OAuth2 的令牌,当访问每个受保护的资源时,用户都需要携带这个令牌以便进行验证。
token_type代表令牌类型OAuth2 协议中有多种可选的令牌类型,包括 Bearer 类型、MAC 类型等,这里指定的 Bearer 类型是最常见的一种类型。
expires_in用于指定 access_token 的有效时间当超过这个有效时间access_token 将会自动失效。
refresh_token其作用在于当 access_token 过期后,重新下发一个新的 access_token。
scope指定了可访问的权限范围这里指定的是访问 Web 资源的“webclient”。
现在我们已经介绍完令牌,你可能会好奇这样一个令牌究竟有什么用?接下来,我们就来看如何使用令牌完成基于 OAuth2 协议的授权工作流程。整个流程如下图所示:
基于 OAuth2 协议的授权工作流程图
我们可以把上述流程进一步展开梳理。
首先,客户端向用户请求授权,请求中一般包含资源的访问路径、对资源的操作类型等信息。如果用户同意授权,就会将这个授权返回给客户端。
现在,客户端已经获取了用户的授权信息,可以向授权服务器请求访问令牌。
接下来,授权服务器向客户端发放访问令牌,这样客户端就能携带访问令牌访问资源服务器上的资源。
最后,资源服务器获取访问令牌后会验证令牌的有效性和过期时间,并向客户端开放其需要访问的资源。
OAuth2 协议的授权模式
在整个工作流程中,最为关键的是第二步,即获取用户的有效授权。那么如何获取用户授权呢?在 OAuth 2.0 中定义了四种授权方式即授权码模式Authorization Code、简化模式Implicit、密码模式Password Credentials和客户端模式Client Credentials
我们先来看最具代表性的授权码模式。当用户同意授权后,授权服务器返回的只是一个授权码,而不是最终的访问令牌。在这种授权模式下,需要客户端携带授权码去换令牌,这就需要客户端自身具备与授权服务器进行直接交互的后台服务。
授权码模式工作流程图
我们简单梳理一下授权码模式下的执行流程。
首先,用户在访问客户端时会被客户端导向授权服务器,此时用户可以选择是否给予客户端授权。一旦用户同意授权,授权服务器会调用客户端的后台服务提供的一个回调地址,并在调用过程中将一个授权码返回给客户端。客户端收到授权码后进一步向授权服务器申请令牌。最后,授权服务器核对授权码并向客户端发送访问令牌。
这里要注意的是,通过授权码向授权服务器申请令牌的过程是系统自动完成的,不需要用户的参与,用户需要做的就是在流程启动阶段同意授权。
接下来,我们再来看另一种比较常用的密码模式,其授权流程如下图所示:
密码模式工作流程图
可以看到,密码模式比较简单,也更加容易理解。用户要做的就是提供自己的用户名和密码,然后客户端会基于这些用户信息向授权服务器请求令牌。授权服务器成功执行用户认证操作后将会发放令牌。
OAuth2 中的客户端模式和简化模式因为在日常开发过程中应用得不是很多,这里就不详细介绍了。
你可能注意到了,虽然 OAuth2 协议解决的是授权问题但它也应用到了认证的概念这是因为只有验证了用户的身份凭证我们才能完成对他的授权。所以说OAuth2 实际上是一款技术体系比较复杂的协议,综合应用了信息摘要、签名认证等安全性手段,并需要提供令牌以及背后的公私钥管理等功能。
OAuth2 协议与微服务架构
对应到微服务系统中,服务提供者充当的角色就是资源服务器,而服务消费者就是客户端。所以每个服务本身既可以是客户端,也可以作为资源服务器,或者两者兼之。当客户端拿到 Token 之后,该 Token 就能在各个服务之间进行传递。如下图所示:
OAuth2 协议在服务访问场景中的应用
在整个 OAuth2 协议中,最关键的问题就是如何获取客户端授权。就目前主流的微服架构来说,当我们发起 HTTP 请求时,关注的是如何通过 HTTP 协议透明而高效地传递令牌,此时授权码模式下通过回调地址进行授权管理的方式就不是很实用,密码模式反而更加简洁高效。因此,在本专栏中,我们将使用密码模式作为 OAuth2 协议授权模式的默认实现方式。
小结与预告
今天我们进入微服务安全性领域展开了探讨,在这个领域中,认证和授权仍然是最基本的安全性控制手段。通过系统分析微服务架构中的认证和授权解决方案,我们引入了 OAuth2 协议,这也是微服务架构体系下主流的授权协议。我们对 OAuth2 协议具备的角色、授权模式以及与微服务架构之间的集成关系做了详细展开。
本讲内容总结如下:
最后给你留一道思考题:你能描述 OAuth2 协议中所具备的四大角色以及四种授权模式吗?欢迎在留言区和我分享你的收获。

View File

@ -0,0 +1,276 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
13 授权体系:如何构建 OAuth2 授权服务器?
上一讲我们讨论了 OAuth2 协议的详细内容,相信你已经了解了可以使用 OAuth2 协议实现微服务之间访问的授权。但是在此之前,我们需要在微服务系统中构建 OAuth2 授权服务器。今天我们就基于 Spring Security 框架,讨论如何构建这一授权服务器,并基于常用的密码模式生成对应的 Token从而为下一讲中的服务访问控制提供基础。
构建 OAuth2 授权服务器
从表现形式上看OAuth2 授权服务器也是一个独立的微服务,因此构建授权服务器的方法也是创建一个 Spring Boot 应用程序,我们需要引入对应的 Maven 依赖,如下所示:
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
</dependency>
这里的 spring-security-oauth2 就是来自 Spring Security 中的 OAuth2 库。现在 Maven 依赖已经添加完毕,下一步就是构建 Bootstrap 类作为访问的入口:
@SpringBootApplication
@EnableAuthorizationServer
public class AuthorizationServer {
public static void main(String[] args) {
SpringApplication.run(AuthorizationServer.class, args);
}
}
请注意,这里出现了一个新的注解 @EnableAuthorizationServer,这个注解的作用在于为微服务运行环境提供一个基于 OAuth2 协议的授权服务,该授权服务会暴露一系列基于 RESTful 风格的端点(例如 /oauth/authorize 和 /oauth/token供 OAuth2 授权流程使用。
构建 OAuth2 授权服务只是集成 OAuth2 协议的第一步,授权服务器是一种集中式系统,管理着所有与安全性流程相关的客户端和用户信息。因此,接下来我们需要在授权服务器中对这些基础信息进行初始化,而 Spring Security 为我们提供了各种配置类来实现这一目标。
设置客户端和用户认证信息
上一讲我们提到 OAuth2 协议存在四种授权模式,并提到在微服务架构中,密码模式以其简单性得到了广泛的应用。在接下来的内容中,我们就以密码模式为例展开讲解。
在密码模式下,用户向客户端提供用户名和密码,并将用户名和密码发给授权服务器从而请求 Token。授权服务器首先会对密码凭证信息进行认证确认无误后向客户端发放 Token。整个流程如下图所示
密码模式授权流程示意图
请注意,授权服务器在这里执行认证操作的目的是验证传入的用户名和密码是否正确。在密码模式下,这一步是必需的,如果采用其他授权模式,不一定会有用户认证这一环节。
确定采用密码模式后,我们来看为了实现这一授权模式,需要对授权服务器做哪些开发工作。首先我们需要设置一些基础数据,包括客户端信息和用户信息。
设置客户端信息
我们先来看如何设置客户端信息。设置客户端时,用到的配置类是 ClientDetailsServiceConfigurer该配置类用来配置客户端详情服务 ClientDetailsService。用于描述客户端详情的 ClientDetails 接口则包含了与安全性控制相关的多个重要方法,该接口中的部分方法定义如下:
public interface ClientDetails extends Serializable {
//客户端唯一性 Id
String getClientId();
//客户端安全码
String getClientSecret();
//客户端的访问范围
Set<String> getScope();
//客户端可以使用的授权模式
Set<String> getAuthorizedGrantTypes();
}
上述代码中的几个属性都与日常开发工作息息相关。
首先 ClientId 是一个必备属性,用来唯一标识客户的 Id而 ClientSecret 代表客户端安全码。这里的 Scope 用来限制客户端的访问范围,如果这个属性为空,客户端就拥有全部的访问范围。常见的设置方式可以是 webclient 或 mobileclient分别代表 Web 端和移动端。
最后authorizedGrantTypes 代表客户端可以使用的授权模式,可选的范围包括代表授权码模式的 authorization_code、代表隐式授权模式 implicit、代表密码模式的 password 以及代表客户端凭据模式的 client_credentials。这个属性在设置上也可以添加 refresh_token通过刷新操作获取以上授权模式下产生的新 Token。
和实现认证过程类似Spring Security 也提供了 AuthorizationServerConfigurerAdapter 这个配置适配器类来简化配置类的使用方式。我们可以通过继承该类并覆写其中的 configure(ClientDetailsServiceConfigurer clients) 方法进行配置。使用 AuthorizationServerConfigurerAdapter 进行客户端信息配置的基本代码结构如下:
@Configuration
public class SpringAuthorizationServerConfigurer extends AuthorizationServerConfigurerAdapter {
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("spring")
.secret("{noop}spring_secret")
.authorizedGrantTypes("refresh_token", "password", "client_credentials")
.scopes("webclient", "mobileclient");
}
}
可以看到,我们创建了一个 SpringAuthorizationServerConfigure r类来继承 AuthorizationServerConfigurerAdapter并通过 ClientDetailsServiceConfigurer 配置类设置了授权模式为密码模式。在授权服务器中存储客户端信息有两种方式,一种就是如上述代码所示的基于内存级别的存储,另一种则是通过 JDBC 在数据库中存储详情信息。为了简单起见,这里使用了内存级别的存储方式。
同时我们注意到,在设置客户端安全码时使用了”{noop}spring_secret”这种格式。这是因为在 Spring Security 5 中统一使用 PasswordEncoder 对密码进行编码,在设置密码时要求格式为“{id}password”。而这里的前缀“{noop}”就是代表具体 PasswordEncoder 的 id表示我们使用的是 NoOpPasswordEncoder。关于 PasswordEncoder你可以回顾[“密码安全Spring Security 中包含哪些加解密技术?”]一讲中的内容。
我们已经在前面的内容中提到,@EnableAuthorizationServer 注解会暴露一系列的端点,而授权过程是使用 AuthorizationEndpoint 这个端点进行控制的。要想对该端点的行为进行配置,你可以使用 AuthorizationServerEndpointsConfigurer 这个配置类。和 ClientDetailsServiceConfigurer 配置类一样,我们也通过使用 AuthorizationServerConfigurerAdapter 配置适配器类进行配置。
因为我们指定了授权模式为密码模式,而密码模式包含认证环节。所以针对 AuthorizationServerEndpointsConfigurer 配置类需要指定一个认证管理器 AuthenticationManager用于对用户名和密码进行认证。同样因为我们指定了基于密码的授权模式所以需要指定一个自定义的 UserDetailsService 来替换全局的实现。关于 UserDetailsService 我们已经在[“用户认证:如何使用 Spring Security 构建用户认证体系?”]一讲中做了详细的讨论,我们明确可以通过如下代码来配置 AuthorizationServerEndpointsConfigurer
@Configuration
public class SpringAuthorizationServerConfigurer extends AuthorizationServerConfigurerAdapter {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private UserDetailsService userDetailsService;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.authenticationManager(authenticationManager).userDetailsService(userDetailsService);
}
}
至此,客户端设置工作全部完成,我们所做的事情就是实现了一个自定义的 SpringAuthorizationServerConfigurer 配置类并覆写了对应的配置方法。
设置用户认证信息
设置用户认证信息所依赖的配置类是 WebSecurityConfigurer 类Spring Security 同样提供了 WebSecurityConfigurerAdapter 类来简化该配置类的使用方式,我们可以继承 WebSecurityConfigurerAdapter 类并且覆写其中的 configure() 的方法来完成配置工作。
关于 WebSecurityConfigurer 配置类我们首先需要明确配置的内容。实际上设置用户信息非常简单只需要指定用户名User、密码Password和角色Role这三项数据即可如下所示
@Configuration
public class SpringWebSecurityConfigurer extends WebSecurityConfigurerAdapter {
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
@Bean
public UserDetailsService userDetailsServiceBean() throws Exception {
return super.userDetailsServiceBean();
}
@Override
protected void configure(AuthenticationManagerBuilder builder) throws Exception {
builder
.inMemoryAuthentication()
.withUser("spring_user")
.password("{noop}password1")
.roles("USER")
.and()
.withUser("spring_admin")
.password("{noop}password2")
.roles("USER", "ADMIN");
}
}
结合上面的代码我们看到构建了具有不同角色和密码的两个用户请注意”spring_user”代表的角色是一个普通用户”spring_admin”则具有管理员角色。我们在设置密码时同样需要添加前缀“{noop}”。同时,我们还看到 authenticationManagerBean()和 userDetailsServiceBean() 方法分别返回了父类的默认实现,而这里返回的 UserDetailsService 和 AuthenticationManager 在前面设置客户端时会用到。这里使用的用户认证机制你也可以回顾[“用户认证:如何使用 Spring Security 构建用户认证体系?”]中的内容。
生成 Token
现在OAuth2 授权服务器已经构建完毕,启动这个授权服务器,我们就可以获取 Token。我们在构建 OAuth2 服务器时已经提到授权服务器中会暴露一批端点供 HTTP 请求进行访问,而获取 Token 的端点就是http://localhost:8080/oauth/token。在使用该端点时我们需要提供前面配置的客户端信息和用户信息。
这里使用 Postman 来模拟 HTTP 请求,客户端信息设置方式如下图所示:
客户端信息设置示意图
我们在“Authorization”请求头中指定认证类型为“Basic Auth”然后设置客户端名称和客户端安全码分别为“spring”和“spring_secret”。
接下来我们指定针对授权模式的专用配置信息。首先是用于指定授权模式的 grant_type 属性,以及用于指定客户端访问范围的 scope 属性,这里分别设置为 “password”和“webclient”。既然设置了密码模式所以也需要指定用户名和密码用于识别用户身份这里我们以“spring_user”这个用户为例进行设置如下图所示
用户信息设置示意图
在 Postman 中执行这个请求,会得到如下所示的返回结果:
{
"access_token": "0efa61be-32ab-4351-9dga-8ab668ababae",
"token_type": "bearer",
"refresh_token": "738c42f6-79a6-457d-8d5a-f9eab0c7cc5e",
"expires_in": 43199,
"scope": "webclient"
}
可以看到,除了作为请求参数的 scope这个返回结果中还包含了 access_token、token_type、refresh_token 和 expires_in 等属性。这些属性都很重要,我们在上一讲中都做了详细的介绍。当然,因为每次请求生成的 Token 都是唯一的,所以你在尝试时获取的结果可能与我的不同。
小结与预告
对微服务访问进行安全性控制的首要条件是生成一个访问 Token。这一讲我们从构建 OAuth2 服务器讲起,基于密码模式给出了如何设置客户端信息、用户认证信息以及最终生成 Token 的实现过程。这个过程中需要开发人员熟悉 OAuth2 协议的相关概念以及 Spring Security 框架中提供的各项配置功能。
本讲内容总结如下:
最后给你留一道思考题:基于密码模式,你能说明生成 Token 需要哪些具体的开发步骤吗?

View File

@ -0,0 +1,289 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
14 资源保护:如何基于 OAuth2 协议配置授权过程?
上一讲我们学习了如何构建 OAuth2 授权服务器,并掌握了生成 Token 的系统方法。今天我们关注的重点是如何使用 Token 实现对服务访问的具体授权。在日常开发过程中,我们需要对每个服务的不同功能进行不同粒度的权限控制,并且希望这种控制方法足够灵活,能够确保不同服务根据业务场景动态调整权限控制体系。同时,在微服务架构中,我们还需要考虑如何在多个服务中对 Token 进行有效的传播,确保整个服务访问的链路都得到授权管理。借助 Spring Security 框架,实现这些需求都很简单,下面我们就来展开学习。
在微服务中集成 OAuth2 授权机制
我们知道在 OAuth2 协议中单个微服务的定位就是资源服务器。Spring Security 框架为此提供了专门的 @EnableResourceServer 注解。通过在 Bootstrap 类中添加 @EnableResourceServer 注解,相当于声明该服务中的所有内容都是受保护的资源,示例代码如下所示:
@SpringBootApplication
@EnableResourceServer
public class UserApplication {
public static void main(String[] args) {
SpringApplication.run(UserApplication.class, args);
}
}
一旦我们在微服务中添加了 @EnableResourceServer 注解,该服务就会对所有的 HTTP 请求进行验证以确定 Header 部分中是否包含 Token 信息。如果没有 Token 信息,就会直接限制访问;如果有 Token 信息,则通过访问 OAuth2 服务器进行 Token 的验证。那么问题来了,每个微服务是如何与 OAuth2 服务器进行通信并获取传入 Token 的验证结果的呢?
要想回答这个问题,我们需要明确将 Token 传递给 OAuth2 授权服务器的目的是获取该 Token 中包含的用户和授权信息。这样,我们势必需要在各个微服务和 OAuth2 授权服务器之间建立起一种交互关系。我们可以在配置文件中添加如下所示的 security.oauth2.resource.userInfoUri 配置项来实现这一目标:
security:
oauth2:
resource:
userInfoUri: http://localhost:8080/userinfo
这里的 http://localhost:8080/userinfo指向 OAuth2 授权服务器中的一个自定义端点,实现方式如下所示:
@RequestMapping(value = "/userinfo", produces = "application/json")
public Map<String, Object> user(OAuth2Authentication user) {
Map<String, Object> userInfo = new HashMap<>();
userInfo.put("user", user.getUserAuthentication().getPrincipal());
userInfo.put("authorities", AuthorityUtils.authorityListToSet(
user.getUserAuthentication().getAuthorities()));
return userInfo;
}
这个端点的作用就是获取可访问的那些受保护服务的用户信息。这里我们用到了 OAuth2Authentication 类该类保存着用户的身份Principal和权限Authority信息。
当使用 Postman 访问 http://localhost:8080/userinfo 端点时,我们就需要传入一个有效的 Token。这里我们以上一讲中生成的 Token“0efa61be-32ab-4351-9dga-8ab668ababae”为例在 HTTP 请求中添加一个“Authorization”请求头。请注意因为我们使用的是 bearer 类型的 Token所以需要在 access_token 的具体值之前加上“bearer”前缀。当然我们也可以直接在“Authorization”页中选择协议类型为 OAuth 2.0,然后输入 Access Token这样就相当于添加了请求头信息如下图所示
通过 Token 发起 HTTP 请求示意图
在后续的 HTTP 请求中,我们都将以这种方式发起对微服务的调用。该请求的结果如下所示:
{
"user":{
"password":null,
"username":"spring_user",
"authorities":[
{
"autority":"ROLE_USER"
}
],
"accountNonExpired":true,
"accountNonLocker":true,
"credentialsNonExpired":true,
"enabled":true
},
"authorities":[
"ROLE_USER"
]
}
我们知道“0efa61be-32ab-4351-9dga-8ab668ababae”这个 Token 是由“spring_user”这个用户生成的可以看到该结果中包含了用户的用户名、密码以及该用户名所拥有的角色这些信息与我们在上一讲中初始化的“spring_user”用户信息保持一致。我们也可以尝试使用“spring_admin”这个用户来重复上述过程。
在微服务中嵌入访问授权控制
在一个微服务系统中,每个微服务作为独立的资源服务器,对自身资源的保护粒度并不是固定的,可以根据需求对访问权限进行精细化控制。在 Spring Security 中,对访问的不同控制层级进行了抽象,形成了用户、角色和请求方法这三种粒度,如下图所示:
用户、角色和请求方法三种控制粒度示意图
基于上图,我们可以对这三种粒度进行排列组合,形成用户、用户+角色以及用户+角色+请求方法这三种层级,这三种层级能够访问的资源范围逐一递减。用户层级是指只要是认证用户就能访问服务内的各种资源;而用户+角色层级在用户层级的基础上,还要求用户属于某一个或多个特定角色;最后的用户+角色+请求方法层级要求最高,能够对某些 HTTP 操作进行访问限制。接下来我们针对这三个层级展开讨论。
用户层级的权限访问控制
通过上一讲的学习,我们已经熟悉了通过扩展各种 ConfigurerAdapter 配置适配器类来实现自定义配置信息的方法。对于资源服务器而言,也存在一个 ResourceServerConfigurerAdapter 类,而我们的做法同样是继承该类并覆写它的 configure 方法,如下所示:
@Configuration
public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {
@Override
public void configure(HttpSecurity httpSecurity) throws Exception{
httpSecurity.authorizeRequests()
.anyRequest()
.authenticated();
}
}
我们注意到,这个方法的入参是一个 HttpSecurity 对象,而上述配置中的 anyRequest().authenticated() 方法指定了访问该服务的任何请求都需要进行验证。因此,当我们使用普通的 HTTP 请求来访问 user-service 中的任何 URL 时将会得到一个“unauthorized”的 401 错误信息。解决办法就是在 HTTP 请求中设置“Authorization”请求头并传入一个有效的 Token 信息,你可以模仿前面的示例做一些练习。
用户+角色层级的权限访问控制
对于某些安全性要求比较高的资源,我们不应该开放资源访问入口给所有的认证用户,而是需要限定访问资源的角色。针对不同的业务场景,我们可以判断哪些服务涉及核心业务流程,这些服务的 HTTP 端口不应该开放给普通用户而是限定只有角色为“ADMIN”的管理员才能访问该服务。要想达到这种效果实现方式也比较简单就是在HttpSecurity 中通过 antMatchers() 和 hasRole() 方法指定想要限制的资源和角色。我们可以创建一个新 ResourceServerConfiguration 类实例并覆写它的 configure 方法,如下所示:
@Configuration
public class ResourceServerConfiguration extends
ResourceServerConfigurerAdapter{
@Override
public void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity.authorizeRequests()
.antMatchers("/order/**")
.hasRole("ADMIN")
.anyRequest()
.authenticated();
}
}
可以看到,这里使用了 05 讲[“访问授权:如何对请求的安全访问过程进行有效配置?”]中介绍的 Ant 匹配器实现了授权管理。现在如果我们使用角色为“User”的 Token 访问这个服务就会得到一个“access_denied”的错误信息。然后我们使用上一讲中初始化的一个具有“ADMIN”角色的用户“spring_admin”来创建新的 Token并再次访问该服务就能得到正常的返回结果。
用户+角色+操作层级的权限访问控制
更进一步,我们还可以针对某个端点的某个具体 HTTP 方法进行控制。例如如果我们认为对某个微服务中的“user”端点下的资源进行更新的风险很高那么就可以在 HttpSecurity 的 antMatchers() 中添加 HttpMethod.PUT 限定。
@Configuration
public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {
@Override
public void configure(HttpSecurity httpSecurity) throws Exception{
httpSecurity.authorizeRequests()
.antMatchers(HttpMethod.PUT, "/user/**")
.hasRole("ADMIN")
.anyRequest()
.authenticated();
}
}
现在我们使用普通“USER”角色生成的 Token并调用”/order/“端点中的 Update 操作同样会得到“access_denied”的错误信息。而尝试使用“ADMIN”角色生成的 Token 进行访问,就可以得到正常响应。
在微服务中传播 Token
我们知道一个微服务系统势必涉及多个服务之间的调用,并形成一个链路。因为访问所有服务的过程都需要进行访问权限的控制,所以我们需要确保生成的 Token 能够在服务调用链路中进行传播,如下图所示:
微服务中 Token 传播示意图
那么,如何实现上图中的 Token 传播效果呢Spring Security 基于 RestTemplate 进行了封装,专门提供了一个用在 HTTP 请求中传播 Token 的 OAuth2RestTemplate 工具类。想要在业务代码中构建一个 OAuth2RestTemplate 对象,可以使用如下所示的示例代码:
@Bean
public OAuth2RestTemplate oauth2RestTemplate(
OAuth2ClientContext oauth2ClientContext,
OAuth2ProtectedResourceDetails details) {
return new OAuth2RestTemplate(details, oauth2ClientContext);
}
可以看到,通过传入 OAuth2ClientContext 和 OAuth2ProtectedResourceDetails我们就可以创建一个 OAuth2RestTemplate 类。OAuth2RestTemplate 会把从 HTTP 请求头中获取的 Token 保存到一个 OAuth2ClientContext 上下文对象中而OAuth2ClientContext 会把每个用户的请求信息控制在会话范围内以确保不同用户的状态分离。另一方面OAuth2RestTemplate 还依赖于 OAuth2ProtectedResourceDetails 类该类封装了我们在上一讲中介绍过的clientId、客户端安全码 clientSecret、访问范围 scope 等属性。
一旦 OAuth2RestTemplate 创建成功,我们就可以使用它对某一个远程服务进行访问,实现代码如下所示:
@Component
public class OrderServiceClient {
@Autowired
OAuth2RestTemplate restTemplate;
public Order getOrderById(String orderId){
ResponseEntity<Order> result =
restTemplate.exchange(
"http://orderservice/order/{orderId}",
HttpMethod.GET,
null, Order.class, orderId);
Order order = result.getBody();
return order;
}
}
显然,基于这种远程调用方式,我们唯一要做的就是使用 OAuth2RestTemplate 替换原有的 RestTemplate所有关于 Token 传播的细节已经被完整地封装在每次请求中。
小结与预告
这一讲我们的关注点在于对服务访问进行授权。通过今天的学习,我们明确了在微服务中嵌入访问授权控制的三种粒度。同时,在微服务系统中,因为涉及多个服务之间的交互,所以需要实现 Token 在这些服务之间的有效传播,我们可以借助 Spring Security 提供的工具类轻松实现这些需求。
本讲内容总结如下:
最后给你留一道思考题:你能描述对服务访问进行授权的三种层级,以及每个层级对应的实现方法吗?欢迎在留言区分享你的学习收获。

View File

@ -0,0 +1,435 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
15 令牌扩展:如何使用 JWT 实现定制化 Token
上一讲我们详细介绍了在微服务架构中如何使用 Token 对微服务的访问过程进行权限控制,这里的 Token 是类似“b7c2c7e0-0223-40e2-911d-eff82d125b80”的一种字符串结构。显然这种格式的 Token 包含的内容应该是很有限的,那么是否有办法实现更为丰富的 Token 呢?答案是肯定的。
事实上,在 OAuth2 协议中并没有明确规定 Token 具体的组成结构,而在现实应用中,我也不太建议你使用上一讲中我们用到的 Token 格式,而是更倾向于采用 JWT。今天我们就基于 JWT 讨论如何实现定制化 Token 这一话题。
什么是 JWT
JWT 的全称是 JSON Web Token所以它本质上是一种基于 JSON 表示的 Token。JWT 的设计目标就是为 OAuth2 协议中使用的 Token 提供一种标准结构,所以它经常与 OAuth2 协议集成在一起使用。
JWT 的基本结构
从结构上讲JWT 本身由三段信息构成第一段为头部Header第二段为有效负载Payload第三段为签名Signature。如下所示
header. payload. signature
从数据格式上来看,以上三个部分的内容都是一个 JSON 对象。在 JWT 中,每一段 JSON 对象都用 Base64 进行编码,编码后的内容用“.”号连接一起,所以 JWT 本质上就是一个字符串。如下所示即为一个 JWT 字符串的示例:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3NwcmluZy5leGFtcGxlLmNvbSIsInN1YiI6Im1haWx0bzpzcHJpbmdAZXhhbXBsZS5jb20iLCJuYmYiOjE2MTU4MTg2NDYsImV4cCI6MTYxNTgyMjI0NiwiaWF0IjoxNjE1ODE4NjQ2LCJqdGkiOiJpZDEyMzQ1NiIsInR5cCI6Imh0dHBzOi8vc3ByaW5nLmV4YW1wbGUuY29tL3JlZ2lzdGVyIn0.Nweh3OPKl-p0PrSNDUQZ9LkJVWxjAP76uQscYJFQr9w
显然,我们无法从这个经过 Base64 编码的字符串中获取任何有用的信息。业界也存在一些在线生成和解析 JWT 的工具,针对上面这个 JWT 字符串,我们可以通过这些工具获取其包含的原始 JSON 数据,如下所示:
{
alg: "HS256",
typ: "JWT"
}.
{
iss: "https://spring.example.com",
sub: "mailto:[email protected]",
nbf: 1615818646,
exp: 1615822246,
iat: 1615818646,
jti: "id123456",
typ: "https://spring.example.com/register"
}.
[signature]
我们可以清晰地看到一个 JWT 中包含的 Header 部分和 Payload 部分的数据出于安全考虑JWT 解析工具通常都不会展示 Signature 部分数据。
JWT 的优势
JWT 具有很多优秀的功能特性,它的数据表示方式采用语言无关的 JSON 格式可以与各个异构系统进行集成。同时JWT 是一种表示数据的标准,所有人都可以遵循这种标准来传递数据。
在安全领域我们通常用它传递被认证的用户身份信息以便从资源服务器获取资源。同时JWT 在结构上也提供了良好的扩展性,开发人员可以根据需求增加一些额外信息用于处理复杂的业务逻辑。因为 JWT 中的数据都是被加密的,所以它除了可以直接用于认证,也可以处理加密需求。
如何集成 OAuth2 与 JWT
看到这里可能你已经认识到了JWT 和 OAuth2 面向的是不同的应用场景,本身并没有任何关联。但在很多情况下,我们讨论 OAuth2 的实现时,会把 JWT 作为一种认证机制进行使用。
Spring Security 为 JWT 的生成和验证提供了开箱即用的支持。当然,想要发送和消费 JWTOAuth2 授权服务和各个受保护的微服务必须以不同的方式进行配置。整个开发流程与上一讲介绍的生成普通 Token 是一致的,不同之处在于配置的内容和方式。接下来,我们来看如何在 OAuth2 授权服务器中配置 JWT。
对于所有需要用到 JWT 的独立服务来说,首先我们需要在 Maven 的 pom 文件中添加对应的依赖包,如下所示:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-jwt</artifactId>
</dependency>
下一步就是提供一个配置类用于完成 JWT 的生成和转换。事实上,在 OAuth2 协议中专门提供了一个接口用于管理 Token 的存储,这个接口就是 TokenStore而该接口的实现类 JwtTokenStore 则专门用来存储 JWT Token。对应的我们也将创建一个用于配置 JwtTokenStore 的配置类 JWTTokenStoreConfig如下所示
@Configuration
public class JWTTokenStoreConfig {
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter());
}
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey("123456");
return converter;
}
@Bean
public DefaultTokenServices tokenServices() {
DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
defaultTokenServices.setTokenStore(tokenStore());
defaultTokenServices.setSupportRefreshToken(true);
return defaultTokenServices;
}
}
可以看到,这里构建了 JwtTokenStore 对象,而在它的构造函数中传入了一个 JwtAccessTokenConverter。JwtAccessTokenConverters 是一个用来转换 JWT 的转换器,转换的过程需要签名键。创建 JwtTokenStore 后,我们通过 tokenServices 方法返回了已经设置 JwtTokenStore 对象的 DefaultTokenServices。
上述 JWTTokenStoreConfig 的作用就是创建了一系列对象以供 Spring 容器使用,我们什么时候会用到这些对象呢?答案就是在将 JWT 集成到 OAuth2 授权服务的过程中,而这个过程似曾相似。基于 13 讲“[授权体系:如何构建 OAuth2 授权服务器?]”中的讨论,我们可以构建一个配置类来覆写 AuthorizationServerConfigurerAdapter 中的 configure 方法回想原先的这个configure 方法实现如下:
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.authenticationManager(authenticationManager)
.userDetailsService(userDetailsService);
}
集成 JWT 之后,该方法的实现过程则需要调整,如下所示:
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(Arrays.asList(jwtTokenEnhancer, jwtAccessTokenConverter));
endpoints.tokenStore(tokenStore).accessTokenConverter(jwtAccessTokenConverter).tokenEnhancer(tokenEnhancerChain)
.authenticationManager(authenticationManager)
.userDetailsService(userDetailsService);
}
可以看到,这里构建了一个针对 Token 的增强链 TokenEnhancerChain并用到了在 JWTTokenStoreConfig 中创建的 tokenStore、jwtAccessTokenConverter 对象。至此,我们在 OAuth2 协议中集成 JWT 的过程就介绍完了,也就是说现在我们访问 OAuth2 授权服务器时获取的 Token 应该就是 JWT Token。
我们来尝试一下,通过 Postman我们发起了请求并得到了相应的 Token
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzeXN0ZW0iOiJTcHJpbmcgU3lzdGVtIiwidXNlcl9uYW1lIjoic3ByaW5nX3VzZXIiLCJzY29wZSI6WyJ3ZWJjbGllbnQiXSwiZXhwIjoxNjE3NTYwODU0LCJhdXRob3JpdGllcyI6WyJST0xFX1VTRVIiXSwianRpIjoiY2UyYTgzZmYtMjMzMC00YmQ1LTk4MzUtOWIyYzE0N2Y2MTcyIiwiY2xpZW50X2lkIjoic3ByaW5nIn0.Cd_x3r-Fi9hudA2W80amLEga0utPiOJCgBxxLI4Lsb8",
"token_type": "bearer",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzeXN0ZW0iOiJTcHJpbmcgU3lzdGVtIiwidXNlcl9uYW1lIjoic3ByaW5nX3VzZXIiLCJzY29wZSI6WyJ3ZWJjbGllbnQiXSwiYXRpIjoiY2UyYTgzZmYtMjMzMC00YmQ1LTk4MzUtOWIyYzE0N2Y2MTcyIiwiZXhwIjoxNjIwMTA5NjU0LCJhdXRob3JpdGllcyI6WyJST0xFX1VTRVIiXSwianRpIjoiMDA0NjIxY2MtMmRmZi00ZDJiLWE0YWUtNTU5MzM5YzkyYmFhIiwiY2xpZW50X2lkIjoic3ByaW5nIn0.xDhGwhNTq7Iun9yLENaCvh8mrVHkabu3J8sP0NXENq0",
"expires_in": 43199,
"scope": "webclient",
"system": "Spring System",
"jti": "ce2a83ff-2330-4bd5-9835-9b2c147f6172"
}
显然,这里的 access_token 和 refresh_token 已经是经过 Base64 编码的字符串。同样,我们可以通过在线工具来解析这个 JSON 数据格式的内容,如下所示的就是 access_token 的原始内容:
{
alg: "HS256",
typ: "JWT"
}.
{
system: "Spring System",
user_name: "spring_user",
scope: [
"webclient"
],
exp: 1617560854,
authorities: [
"ROLE_USER"
],
jti: "ce2a83ff-2330-4bd5-9835-9b2c147f6172",
client_id: "spring"
}.
[signature]
如何在微服务中使用 JWT
在微服务中使用 JWT 的第一步也是配置工作。我们需要在各个微服务中添加一个 WTTokenStoreConfig 配置类,这个配置类的内容就是创建一个 JwtTokenStore 并构建 tokenServices具体代码在前面已经做了介绍这里不再展开。
配置工作完成后,剩下的问题就是在服务调用链中传播 JWT。在上一讲中我们给出了 OAuth2RestTemplate 这个工具类,该类可以传播普通的 Token。可惜的是它并不能传播基于 JWT 的 Token。从实现原理上OAuth2RestTemplate 也是在 RestTemplate 的基础上做了一层封装,所以我们的思路也是尝试在 RestTemplate 请求中添加对 JWT 的支持。
我们知道, HTTP 请求是通过在 Header 部分中添加一个“Authorization”消息头来完成对 Token 的传递,所以第一步需要能够从 HTTP 请求中获取这个 JWT Token。
然后第二步我们需要将这个 Token 存储在一个线程安全的地方,以便在后续的服务链中进行使用。
第三步,也是最关键的一步,就是在通过 RestTemplate 发起请求时,能够把这个 Token 自动嵌入到所发起的每一个 HTTP 请求中。
整个实现思路如下图所示:
在服务调用链中传播 JWT Token 的三个实现步骤
实现这一思路需要你对 HTTP 请求的过程和原理有一定的理解,在代码实现上也需要有一些技巧,下面我一一展开。
首先,在 HTTP 请求过程中,我们可以通过过滤器 Filter 对所有请求进行过滤。Filter 是 Servlet 中的一个核心组件其基本原理就是构建一个过滤器链并对经过该过滤器链的请求和响应添加定制化的处理机制。Filter 接口的定义如下所示:
public interface Filter {
public void init(FilterConfig filterConfig) throws ServletException;
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException;
public void destroy();
}
通常,我们会实现 Filter 接口中的 doFilter 方法。关于过滤器的详细内容,你可以结合 08 讲“[管道过滤:如何基于 Spring Security 过滤器扩展安全性?]”做一下回顾。基于过滤器,我们可以将 ServletRequest 转化为一个 HttpServletRequest 对象并从该对象中获取“Authorization”消息头示例代码如下所示
@Component
public class AuthorizationHeaderFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
AuthorizationHeaderHolder.getAuthorizationHeader().setAuthorizationHeader(httpServletRequest.getHeader(AuthorizationHeader.AUTHORIZATION_HEADER));
filterChain.doFilter(httpServletRequest, servletResponse);
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {}
@Override
public void destroy() {}
}
请注意,这里我们把从 HTTP 请求中获取的“Authorization”消息头保存到了一个 AuthorizationHeaderHolder 对象中。从命名上看AuthorizationHeader 对象代表的就是 HTTP 中“Authorization” 消息头,而 AuthorizationHeaderHolder 是该消息头对象的持有者。这种命名方式在 Spring 等主流开源框架中非常常见。
一般而言,以 -Holder 结尾的多是一种封装类,用于对原有对象添加线程安全等附加特性。这里的 AuthorizationHeaderHolder 就是这样一个封装类,如下所示:
public class AuthorizationHeaderHolder {
private static final ThreadLocal<AuthorizationHeader> authorizationHeaderContext = new ThreadLocal<AuthorizationHeader>();
public static final AuthorizationHeader getAuthorizationHeader(){
AuthorizationHeader header = authorizationHeaderContext.get();
if (header == null) {
header = new AuthorizationHeader();
authorizationHeaderContext.set(header);
}
return authorizationHeaderContext.get();
}
public static final void setAuthorizationHeader(AuthorizationHeader header) {
authorizationHeaderContext.set(header);
}
}
可以看到,这里使用了 ThreadLocal 确保对 AuthorizationHeader 对象访问的线程安全性AuthorizationHeader 定义如下,用于保存来自 HTTP 请求头的 JWT Token
@Component
public class AuthorizationHeader {
public static final String AUTHORIZATION_HEADER = "Authorization";
private String authorizationHeader = new String();
public String getAuthorizationHeader() {
return authorizationHeader;
}
public void setAuthorizationHeader(String authorizationHeader) {
this.authorizationHeader = authorizationHeader;
}
}
现在,对于每一个 HTTP 请求,我们都能获取其中的 Token 并将其保存在上下文对象中。剩下的唯一问题就是如何通过 RestTemplate 将这个 Token 继续传递到下一个服务中,以便下一个服务也能从 HTTP 请求中获取 Token 并继续向后传递,从而确保 Token 在整个调用链中持续传播。要想实现这一目标,我们需要对 RestTemplate 进行一些设置,如下所示:
@Bean
public RestTemplate getCustomRestTemplate() {
RestTemplate template = new RestTemplate();
List<ClientHttpRequestInterceptor> interceptors = template.getInterceptors();
if (interceptors == null) {
template.setInterceptors(Collections.singletonList(new AuthorizationHeaderInterceptor()));
} else {
interceptors.add(new AuthorizationHeaderInterceptor());
template.setInterceptors(interceptors);
}
return template;
}
RestTemplate 允许开发人员添加自定义的拦截器 Interceptor拦截器本质上与过滤器的功能类似用于对传入的 HTTP 请求进行定制化处理。例如,上述代码中的 AuthorizationHeaderInterceptor 的作用就是在 HTTP 请求的消息头中嵌入保存在 AuthorizationHeaderHolder 中的 JWT Token如下所示
public class AuthorizationHeaderInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(
HttpRequest request, byte[] body, ClientHttpRequestExecution execution)
throws IOException {
HttpHeaders headers = request.getHeaders();
headers.add(AuthorizationHeader.AUTHORIZATION_HEADER, AuthorizationHeaderHolder.getAuthorizationHeader().getAuthorizationHeader());
return execution.execute(request, body);
}
}
至此,在微服务中使用 JWT 的方法已经介绍完毕。关于 JWT 还有一部分内容我们没有介绍,即如何扩展 JWT 中所持有的数据结构,我们会在接下来的案例系统中结合具体的业务场景对这块内容进行补充。
小结与预告
这是介绍微服务安全性知识体系的最后一个课时,关注的是认证问题而不是授权问题,为此我们引入了 JWT 机制。JWT 本质上也是一种 Token只不过提供了标准化的规范定义可以与 OAuth2 协议进行集成。我们使用 JWT 时,也可以将各种信息添加到这种 Token 中,并在微服务访问链路中进行传播。
这里给你留一道思考题:如果想要确保 JWT 在各个微服务中进行有效传播,需要怎么做?
介绍完 JWT下一讲又会介绍一个新的案例。我们将基于 Spring Security 和 Spring Cloud 构建一个 SpringOAuth2 案例系统,并给出微服务架构中实现服务安全访问的详细过程。

View File

@ -0,0 +1,457 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
16 案例实战:基于 Spring Security 和 Spring Cloud 构建微服务安全架构
通过前面课程的学习,我们已经知道 Spring Security 可以集成 OAuth2 协议并实现分布式环境下的访问授权。同时Spring Security 也可以和 Spring Cloud 框架无缝集成,并完成对各个微服务的权限控制。
今天我们将设计一个案例系统,从零构建一个完整的微服务系统,除了演示微服务系统构建过程,还将重点展示 OAuth2 协议以及 JWT 在其中所起到的作用。
案例驱动SpringAppointment
在本课程中,我们通过构建一个相对精简的完整系统,来展示微服务架构相关的设计理念以及各项技术组件,这个案例系统称为 SpringAppointment。
SpringAppointment 包含的业务场景比较简单可以用来模拟就医过程中的预约处理流程。一般而言预约流程势必会涉及三个独立的微服务即就诊卡Card服务、预约Appointment服务以及医生Doctor服务。
我们把以上三个服务统称为业务服务。纵观整个 SpringAppointment 系统除了这三个业务微服务之外还有一批非业务性的基础设施类服务具体包括注册中心服务Eureka、配置中心服务Spring Cloud Config以及 API 网关服务Zuul。关于 Spring Cloud 中基础设施类服务的构建过程不是本专栏的重点,你可以参考拉勾上[《Spring Cloud 原理与实战》]专栏做详细了解。
虽然案例中的各个服务在物理上都是独立的,但就整个系统而言,需要各服务相互协作构成一个完整的微服务系统。也就是说,服务运行时存在一定的依赖性。我们结合系统架构对 SpringAppointment 的运行方式进行梳理,梳理的基本方法就是按照服务列表构建独立服务,并基于注册中心来管理它们之间的依赖关系,如下图所示:
基于注册中心的服务运行时依赖关系图
构建 OAuth2 授权服务
在上图中,我们注意到还存在着案例系统中的最后一个基础设施类微服务,即 OAuth2 授权服务,在这里充当着授权中心的作用。关于 OAuth2 授权服务的具体构建步骤已经在[《授权体系如何在微服务架构中集成OAuth2协议》]做了详细介绍,这里我们直接创建 WebSecurityConfigurerAdapter 的子类 WebSecurityConfigurer
以及 AuthorizationServerConfigurerAdapter 的子类 JWTOAuth2Config实现代码如下所示
@Configuration
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
@Bean
public UserDetailsService userDetailsServiceBean() throws Exception {
return super.userDetailsServiceBean();
}
@Override
protected void configure(AuthenticationManagerBuilder builder) throws Exception {
builder.inMemoryAuthentication().withUser("user").password("{noop}password1").roles("USER").and()
.withUser("admin").password("{noop}password2").roles("USER", "ADMIN");
}
}
@Configuration
public class JWTOAuth2Config extends AuthorizationServerConfigurerAdapter {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private UserDetailsService userDetailsService;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.authenticationManager(authenticationManager).userDetailsService(userDetailsService);
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory().withClient("appointment_client").secret("{noop}appointment_secret")
.authorizedGrantTypes("refresh_token", "password", "client_credentials")
.scopes("webclient", "mobileclient");
}
}
初始化业务服务
在 SpringAppointment 案例系统中,我们需要构建三个业务微服务,即 card-service、appointment-service 和 doctor-service它们都是独立的 Spring Boot 应用程序。在构建业务服务时,我们首先需要完成它们与基础设施类服务集成。因为 API 网关起到的是服务路由作用,所以对于各个业务服务而言是透明的,而其他的注册中心、配置中心和授权中心都需要每个业务服务完成与它们之间的集成。
集成注册中心
对于注册中心 Eureka 而言card-service、appointment-service 和 doctor-service 都是它的客户端,所以需要 spring-cloud-starter-netflix-eureka-client 的依赖,如下所示。
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
然后,我们以 appointment-service 为例,来看它的 Bootstrap 类,如下所示:
@SpringBootApplication
@EnableEurekaClient
public class AppointmentApplication {
public static void main(String[] args) {
SpringApplication.run(AppointmentApplication.class, args);
}
}
这里引入了一个新的注解 @EnableEurekaClient,该注解用于表明当前服务就是一个 Eureka 客户端,这样该服务就可以自动注册到 Eureka 服务器。当然,我们也可以直接使用统一的 @SpringCloudApplication 注解来实现 @SpringBootApplication@EnableEurekaClient这两个注解整合在一起的效果
接下来就是最重要的配置工作appointment-service 中的配置内容如下所示:
spring:
application:
name: appointmentservice
server:
port: 8081
eureka:
client:
registerWithEureka: true
fetchRegistry: true
serviceUrl:
defaultZone: http://localhost:8761/eureka/
显然,这里包含两段配置内容。其中,第一段配置指定了服务的名称和运行时端口。在上面的示例中 appointment-service 的名称通过“spring.application.name=appointmentservice”进行指定也就是说 appointment-service 在注册中心中的名称为 appointmentservice。在后续的示例中我们会使用这一名称获取 appointment-service 在 Eureka 中的各种注册信息。
集成配置中心
要想获取配置服务器中的配置信息,我们首先需要初始化客户端,也就是在将各个业务微服务与 Spring Cloud Config 服务器端进行集成。初始化客户端的第一步是引入 Spring Cloud Config 的客户端组件 spring-cloud-config-client如下所示。
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-client</artifactId>
</dependency>
然后我们需要在配置文件 application.yml 中配置服务器的访问地址,如下所示:
spring:
cloud:
config:
enabled: true
uri: http://localhost:8888
以上配置信息中,我们指定了配置服务器所在的地址,也就是上面的 urihttp://localhost:8888。
一旦我们引入了 Spring Cloud Config 的客户端组件,相当于在各个微服务中自动集成了访问配置服务器中 HTTP 端点的功能。也就是说,访问配置服务器的过程对于各个微服务而言是透明的,即微服务不需要考虑如何从远程服务器获取配置信息,而只需要考虑如何在 Spring Boot 应用程序中使用这些配置信息。而对于常见的关系型数据访问配置而言Spring 已经帮助我们内置了整合过程,我们要做的就是引入相关的依赖组件而已。
我们以 appointment-service 为例来演示数据库访问功能,案例中使用的是 JPA 和 MySQL因此需要在服务中引入相关的依赖如下所示
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
现在,我们就可以使用 JPA 提供的数据访问功能来访问 MySQL 数据库了。
集成授权中心
在业务服务中集成授权中心的实现方法,我们已经在[《14.资源保护如何使用OAuth2协议实现对微服务访问进行授权》]中做了详细介绍,这里做一些简单的回顾。首先,我们需要在 Spring Boot 的启动类上添加 @EnableResourceServer 注解:
@SpringCloudApplication
@EnableResourceServer
public class AppointmentApplication {
public static void main(String[] args) {
SpringApplication.run(AppointmentApplication.class, args);
}
}
然后,我们需要在配置文件中指定授权中心服务的地址:
security:
oauth2:
resource:
userInfoUri: http://localhost:8080/userinfo
最后,要做的就是在每个业务服务中嵌入访问授权控制。我们可以使用用户层级的权限访问控制、用户+角色层级的权限访问控制,以及用户+角色+操作层级的权限访问控制这三种策略中的任意一种来实现这一目标。
集成和扩展 JWT
让我们再次回到 SpringAppointment 案例系统,以用户下单这一业务场景为例,就涉及 appointment-service 同时调用 doctor-service 和 card-service这三个服务之间的交互方式如下图所示
SpringAppointment 案例系统中三个业务微服务的交互方式图
通过这个交互图,实际上我们已经可以梳理出这一场景下的代码结构了,如下所示:
public Appointment generateAppointment(String doctorName, String cardCode) {
Appointment appointment = new Appointment();
//获取远程 Card 信息
CardMapper card = getCard(cardCode);
//获取远程 Doctor 信息
DoctorMapper doctor = getDoctor(doctorName);
appointmentRepository.save(appointment);
return appointment;
}
其中 appointment-service 从 card-service 获取 Card 对象,以及从 doctor-service 中获取 Doctor 对象,这两个步骤都会涉及远程 Web 服务的访问。因此,我们首先需要分别在 card-service 和 doctor-service 服务中创建对应的 HTTP 端点。这一过程不是课程的重点如果你感兴趣可以参考案例源码自己进行学习https://github.com/lagouEdAnna/SpringSecurity-jianxiang/tree/main/SpringAppointment。
集成 JWT
在《15 | 令牌扩展如何使用JWT实现定制化 Token》中我们引入了 JWT 并完成了与 OAuth2 协议的集成,从而实现了定制化的 Token。JWT 同样也需要在整个服务调用链路中进行传递。而持有 JWT 的客户端访问 appointment-service 提供的 HTTP 端点进行下单操作,该服务会验证所传入 JWT 的有效性。然后appointment-service 会再通过网关访问 card-service 和 doctor-service同样这两个服务也会分别对所传入的 JWT 进行验证,并返回相应的结果。
现在,让我们在 appointment-service 中构建一个 CardRestTemplateClient 类会发现它使用了在《15 | 令牌扩展:如何使用 JWT 实现定制化 Token》中所创建的 RestTemplate 对象来发起远程调用,代码如下所示:
@Service
public class CardRestTemplateClient {
@Autowired
RestTemplate restTemplate;
public CardMapper getCardByCardCode(String cardCode) {
ResponseEntity<CardMapper> result =
restTemplate.exchange("http://cardservice/cards/{cardCode}", HttpMethod.GET, null,
CardMapper.class, cardCode);
return result.getBody();
}
}
我们知道在这个 RestTemplate 中,基于 AuthorizationHeaderInterceptor 对请求进行了拦截,从而完成了 JWT 在各个服务中的正确传播。
最后,我们通过 Postman 来验证以上流程的正确性。通过访问 Zuul 中配置的 appointment-service 端点并传入角色为“ADMIN”的用户对应的 Token 信息,可以看到订单记录已经被成功创建。你可以尝试通过生成不同的 Token 来执行这一流程,并验证授权效果。
扩展 JWT
在案例的最后,我们来讨论一下如何扩展 JWT。JWT 具有良好的可扩展性,开发人员可以根据需要在 JWT Token 中添加自己想要添加的各种附加信息。
针对 JWT 的扩展性场景Spring Security 专门提供了一个 TokenEnhancer 接口来对 Token 进行增强Enhance该接口定义如下
public interface TokenEnhancer {
OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication);
}
可以看到这里处理的是一个 OAuth2AccessToken 接口,而该接口有一个默认的实现类 DefaultOAuth2AccessToken。我们可以通过该实现类的 setAdditionalInformation 方法,以键值对的方式将附加信息添加到 OAuth2AccessToken 中,示例代码如下所示:
public class JWTTokenEnhancer implements TokenEnhancer {
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
Map<String, Object> systemInfo = new HashMap<>();
systemInfo.put("system", "Appointment System");
((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(systemInfo);
return accessToken;
}
}
这里我们以硬编码的方式添加了一个“system”属性你也可以根据需要进行相应的调整。
要想使得上述 JWTTokenEnhancer 类能够生效,我们需要对 JWTOAuth2Config 类中的 configure 方法进行重新配置,并将 JWTTokenEnhancer 嵌入到 TokenEnhancerChain 中,如下所示:
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(Arrays.asList(jwtTokenEnhancer, jwtAccessTokenConverter));
endpoints.tokenStore(tokenStore)
.accessTokenConverter(jwtAccessTokenConverter)
.tokenEnhancer(tokenEnhancerChain)
.authenticationManager(authenticationManager)
.userDetailsService(userDetailsService);
}
请注意,我们在这里通过创建一个 TokenEnhancer 的列表将包括 JWTTokenEnhancer 在内的多个 TokenEnhancer 嵌入到 TokenEnhancerChain 中。
现在,我们已经扩展了 JWT Token。那么如何从这个 JWT Token 中获取所扩展的属性呢?方法也比较简单和固定,如下所示:
//获取 JWTToken
RequestContext ctx = RequestContext.getCurrentContext();
String authorizationHeader = ctx.getRequest().getHeader(AUTHORIZATION_HEADER);
String jwtToken = authorizationHeader.replace("Bearer ","");
//解析 JWTToken
String[] split_string = jwtToken.split("\\.");
String base64EncodedBody = split_string[1];
Base64 base64Url = new Base64(true);
String body = new String(base64Url.decode(base64EncodedBody));
JSONObject jsonObj = new JSONObject(body);
//获取定制化属性值
String systemName = jsonObj.getString("system");
我们可以把这段代码嵌入到需要使用到自定义“system”属性的任何场景中。
小结与预告
案例分析是掌握一个框架应用方式的最好方法,对于 OAuth2 协议也是一样。本讲中,我们将 Spring Security 结合 Spring Cloud 构建了一个微服务案例系统 SpringAppointment。然后根据 SpringAppointment 案例中的业务场景划分了各个微服务,并重点介绍了各个业务服务的构建过程。我们一方面展示了业务服务与基础设施服务的集成过程,另一方面也演示了如何集成和扩展 JWT 的实现过程。
最后再给你留一道思考题:在业务系统中如何实现对 JWT 进行定制化的扩展呢?欢迎在留言区和我分享你的收获。

View File

@ -0,0 +1,426 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
17 案例实战:基于 Spring Security 和 OAuth2 实现单点登录
单点登录Single Sign-OnSSO是我们设计和实现 Web 系统时经常需要面临的一个问题,允许用户使用一组凭据来登录多个相互独立但又需要保持统一登录状态的 Web 应用程序。单点登录的实现需要特定的技术和框架,而 Spring Security 也提供了它的解决方案。本课时将基于 OAuth2 协议来构建 SSO。
什么是单点登录?
与其说 SSO 是一种技术体系,不如说它是一种应用场景。因此,我们有必要先来看看 SSO 与本专栏前面所介绍的各种技术体系之间的关联关系。
单点登录与OAuth2协议
假设存在 A 和 B 两个独立的系统,但它们相互信任,并通过单点登录系统进行了统一的管理和维护。那么无论访问系统 A 还是系统 B当用户在身份认证服务器上登录一次以后即可获得访问另一个系统的权限。同时这个过程是完全自动化的SSO 通过实现集中式登录系统来达到这一目标,该系统处理用户的身份认证并与其他应用程序共享该认证信息。
说到这里,你可能会问为什么我们需要实施 SSO 呢?原因很简单,因为它提供了很多优势。下面我们具体分析一下。
首先,借助 SSO 可以确保系统更加安全,我们只需要一台集中式服务器来管理用户身份,而不需要将用户凭证扩展到各个服务,因此能够减少被攻击的维度。
其次,可以想象持续输入用户名和密码来访问不同的服务,是一件让用户感到很困扰的事情。而 SSO 将不同的服务组合在一起,以便用户可以在服务之间进行无缝导航,从而提高用户体验。
同时SSO 也能帮助我们更好地了解客户,因为我们拥有对客户信息的单一视图,能够更好地构建用户画像。
那么,如何构建 SSO 呢?各个公司可能有不同的做法,而采用 Spring Security 和 OAuth2 协议是一个不错的选择,因为实现过程非常简单。虽然 OAuth2 一开始是用来允许用户授权第三方应用访问其资源的一种协议,也就是说其目标不是专门用来实现 SSO但是我们可以利用它的功能特性来变相地实现单点登录这就需要用到 OAuth2 四种授权模式中的授权码模式。关于 OAuth2 协议和授权码模式你可以参考《开放协议OAuth2协议解决的是什么问题》做一些回顾。同时在使用 OAuth2 协议实现SSO时我们也会使用 JWT 来生成和管理 Token关于 JWT你也可以回顾《令牌扩展如何使用JWT实现定制化Token》课时中的内容。
单点登录的工作流程
在具体介绍实现方案之前,我们先对 SSO 的工作流程做一下展开,从而了解典型 SSO 系统背后的设计思想。下图描述了 SSO 流程,可以看到我们有两个应用程序 App1 和 App2以及一个集中式 SSO 服务器。
SSO 工作流程图
结合上图,我们先来看针对 App1 的工作流程。
用户第一次访问 App1。由于用户未登录所以将用户重定向到 SSO 服务器。
用户在 SSO 服务器提供的登录页面上输入用户凭据。SSO 服务器验证凭据并生成 SSO Token然后 SSO 服务器在 Cookie 中保存这个 Token以供用户进行后续登录。
SSO 服务器将用户重定向到 App1。在重定向 URL 中,就会附上这个 SSO Token 作为查询参数。
App1 将 Token 保存在其 Cookie 中并将当前的交互方式更改为已登录的用户。App1 可以通过查询 SSO 服务器或 Token 来获取与用户相关的信息。我们知道 JWT 是可以自定义扩展的,所以这时候就可以利用 JWT 来传递用户信息。
现在,我们再来看一下同一用户尝试访问 App2 的工作流程。
由于应用程序只能访问相同来源的 Cookie它不知道用户已登录到 App2。因此同样会将用户重定向到 SSO 服务器。
SSO 服务器发现该用户已经设置了 Cookie因此它会立即将用户重定向到 App2并在 URL 中附加 SSO Token 作为查询参数。
App2 同样将 Token 存储在 Cookie 中,并将其交互方式更改为已登录用户。
整个流程结束之后,用户浏览器中将设置三个 Cookie每个 Cookie 分别针对 App1、App2 和 SSO Server 域。
关于上述流程,业界存在各种各样的实现方案和工具,包括 Facebook Connect、Open Id Connect、CAS、Kerbos、SAML 等。我们无意对这些具体的工具做详细展开而是围绕到目前为止已经掌握的技术来从零构建SSO服务器端和客户端组件。
实现 SSO 服务器端
基于 Spring Security实现 SSO 服务端的核心工作,还是使用一系列我们已经很熟悉的配置体系,来配置基础的认证授权信息,以及与 OAuth2 协议之间的整合过程。
配置基础认证和授权信息
我们同样通过继承 WebSecurityConfigurerAdapter 类来实现自定义的认证和授权信息配置,这个过程比较简单,完整代码如下所示:
@Configuration
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsServiceBean()).passwordEncoder(passwordEncoder());
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/assets/**", "/css/**", "/images/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.loginPage("/login")
.and()
.authorizeRequests()
.antMatchers("/login").permitAll()
.anyRequest()
.authenticated()
.and().csrf().disable().cors();
}
@Bean
@Override
public UserDetailsService userDetailsServiceBean() {
Collection<UserDetails> users = buildUsers();
return new InMemoryUserDetailsManager(users);
}
private Collection<UserDetails> buildUsers() {
String password = passwordEncoder().encode("12345");
List<UserDetails> users = new ArrayList<>();
UserDetails user_admin = User.withUsername("admin").password(password).authorities("ADMIN", "USER").build();
users.add(user_admin);
return users;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
在上述代码中,我们综合使用了已经掌握的 Spring Security 中与认证、授权、密码管理、CSRF、CORS 相关的多项功能特性通过loginPage()方法指定了 SSO 服务器上的登录界面地址并初始化了一个“admin”用户用来执行登录操作。
配置 OAuth2 授权服务器
然后,我们创建一个 AuthorizationServerConfiguration 类来继承 AuthorizationServerConfigurerAdapter请注意在这个类上需要添加 @EnableAuthorizationServer 注解,如下所示:
@EnableAuthorizationServer
@Configuration
public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {
配置 OAuth2 授权服务器的重点工作是指定需要参与 SSO 的客户端。在[《授权体系如何在微服务架构中集成OAuth2协议》]课时中,我们给出了 Spring Security 中描述客户端详情的 ClientDetails 接口,以及用于管理 ClientDetails 的 ClientDetailsService。基于 ClientDetailsService我们就可以定制化对ClientDetails的创建过程示例代码如下所示
@Bean
public ClientDetailsService inMemoryClientDetailsService() throws Exception {
return new InMemoryClientDetailsServiceBuilder()
//创建 app1 客户端
.withClient("app1")
.secret(passwordEncoder.encode("app1_secret"))
.scopes("all")
.authorizedGrantTypes("authorization_code", "refresh_token")
.redirectUris("http://localhost:8080/app1/login")
.accessTokenValiditySeconds(7200)
.autoApprove(true)
.and()
// 创建 app2 客户端
.withClient("app2")
.secret(passwordEncoder.encode("app2_secret"))
.scopes("all")
.authorizedGrantTypes("authorization_code", "refresh_token")
.redirectUris("http://localhost:8090/app2/login")
.accessTokenValiditySeconds(7200)
.autoApprove(true)
.and()
.build();
}
这里我们通过 InMemoryClientDetailsServiceBuilder 构建了一个基于内存的 ClientDetailsService然后通过这个 ClientDetailsService 创建了两个 ClientDetails分别对应 app1 和 app2。请注意这里指定的 authorizedGrantTypes为代表授权码模式的 “authorization_code”。
同时,我们还需要在 AuthorizationServerConfiguration 类中添加对 JWT 的相关设置:
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.accessTokenConverter(jwtAccessTokenConverter())
.tokenStore(jwtTokenStore());
}
@Bean
public JwtTokenStore jwtTokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter());
}
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
jwtAccessTokenConverter.setSigningKey("123456");
return jwtAccessTokenConverter;
}
这里使用的设置方法我们在[《令牌扩展如何使用JWT实现定制化Token》])课时中都已经介绍过了,这里不再详细展开。
实现 SSO 客户端
介绍完 SSO 服务器端配置,接下来,我们来讨论客户端的实现过程。在客户端中,我们同样创建一个继承了 WebSecurityConfigurerAdapter 的 WebSecurityConfiguration用来设置认证和授权机制如下所示
@EnableOAuth2Sso
@Configuration
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
public void configure(WebSecurity web) throws Exception {
super.configure(web);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.logout()
.and()
.authorizeRequests()
.anyRequest().authenticated()
.and()
.csrf().disable();
}
}
这里唯一需要强调的就是 @EnableOAuth2Sso 注解,这是单点登录相关自动化配置的入口,定义如下:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@EnableOAuth2Client
@EnableConfigurationProperties(OAuth2SsoProperties.class)
@Import({ OAuth2SsoDefaultConfiguration.class, OAuth2SsoCustomConfiguration.class,
ResourceServerTokenServicesConfiguration.class })
public @interface EnableOAuth2Sso {
}
@EnableOAuth2Sso 注解上,我们找到了 @EnableOAuth2Client 注解,代表启用了 OAuth2Client 客户端。同时OAuth2SsoDefaultConfiguration 和 OAuth2SsoCustomConfiguration 用来配置基于 OAuth2 的 SSO 行为,而在 ResourceServerTokenServicesConfiguration 中则配置了基于 JWT 来处理 Token 的相关操作。
接着,我们在 app1 客户端的 application.yml 配置文件中,添加如下配置项:
server:
port: 8080
servlet:
context-path: /app1
这里用到了 server.servlet.context-path 配置项用来设置应用的上下文路径相当于为完整的URL地址添加了一个前缀。这样原本访问“http://localhost:8080/login”的地址就会变成http://localhost:8080/app1/login这是使用 SSO 时的一个常见的技巧。
然后,我们再在配置文件中添加如下配置项:
security:
oauth2:
client:
client-id: app1
client-secret: app1_secret
access-token-uri: http://localhost:8888/oauth/token
user-authorization-uri: http://localhost:8888/oauth/authorize
resource:
jwt:
key-uri: http://localhost:8888/oauth/token_key
这些配置项是针对 OAuth2 协议的专用配置项我们看到了用于设置客户端信息的“client”配置段除了客户端Id和密码之外还指定了用于获取 token 的“access-token-uri”地址以及执行授权的“user-authorization-uri”地址这些都应该指向前面已经创建的 SSO 服务器地址。
另一方面一旦在配置文件中添加了“security.oauth2.resource.jwt”配置项对Token的校验就会使用 JwtTokenStore了这样就能跟SSO服务器端所创建的 JwtTokenStore 进行对应。
到目前为止,我们已经创建了一个 SSO 客户端应用 app1而创建 app2 的过程是完全一样的这里不再展开。完整的代码你可以参考https://github.com/lagouEdAnna/SpringSecurity-jianxiang/tree/main/SpringSsoDemo。
案例演示
最后,让我们演示一下整个单点登录过程。依次启动 SSO 服务器以及 app1 和 app2然后在浏览器中访问 app1 地址http://localhost:8080/app1/system/profile这时候浏览器就会重定向到 SSO 服务器登录页面。
请注意如果我们在访问上述地址时打开了浏览器的“网络”标签并查看其访问路径就可以看到确实是先跳转到了app1的登录页面http://localhost:8080/app1/login然后又重定向到 SSO 服务器。由于用户处于未登录状态,所以最后又重定向到 SSO 服务器的登录界面http://localhost:8888/login整个请求的跳转过程如下图所示
未登录状态访问 app1 时的网络请求跳转流程图
我们在 SSO 服务器的登录界面输入正确的用户名和密码之后就可以认证成功了,这时候我们再看网络请求的过程,如下所示:
登录 app1 过程的网络请求跳转流程图
可以看到,在成功登录之后,授权系统重定向到 app1 中配置的回调地址http://localhost:8080/app1/login。与此同时我们在请求地址中还发现了两个新的参数 code 和 state。app1 客户端就会根据这个 code 来访问 SSO 服务器的/oauth/token 接口来申请 token。申请成功后重定向到 app1 配置的回调地址。
现在,如果你访问 app2与第一次访问 app1 相同,浏览器先重定向到 app2 的登录页面,然后又重定向到 SSO 服务器的授权链接,最后直接就重新重定向到 app2 的登录页面。不同之处在于,此次访问并不需要再次重定向到 SSO 服务器进行登录,而是成功访问 SSO 服务器的授权接口,并携带着 code 重定向到 app2 的回调路径。然后 app2 根据 code 再次访问 /oauth/token 接口拿到 token这样就可以正常访问受保护的资源了。
小结与预告
本课时是相对独立的一部分内容,针对日常开发过程中经常碰到的单点登录场景做了案例的设计和实现。我们可以把各个独立的系统看成一个个客户端,然后基于 OAuth2 协议来实现 SSO。此外本课时还对如何构建 SSO 服务器端和客户端组件,以及两者之间的交互过程进行了详细的介绍。
这里给你留一道思考题:你能结合 OAuth2 协议描述 SSO 的整体工作流程吗?
介绍完 OAuth2 协议以及应用场景之后,我们将引入一个全新的话题,既响应式编程,这是一种技术趋势。下一课时我们将讨论如何为 Spring Security 添加响应式编程特性。

View File

@ -0,0 +1,363 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
18 技术趋势:如何为 Spring Security 添加响应式编程特性?
对于大多数日常业务场景而言软件系统在任何时候都需要确保具备即时响应性。而响应式编程Reactive Programming就是用来构建具有即时响应性的是一种新的编程技术。随着 Spring 5 的发布,我们迎来了响应式编程的全新发展时期。而 Spring Security 作为 Spring 家族的一员,同样实现了一系列的响应式组件。今天我们就将围绕这些组件展开讨论。
什么是响应式编程?
在引入响应式 Spring Security 之前,我们先来介绍响应式编程中的一些基本概念,并给出 Spring 5 中所集成的响应式编程组件。
响应式编程的基本概念
在响应式系统中,任何操作都可以被看作是一种事件,而这些事件构成了数据流。这个数据流对于技术栈而言是一个全流程的概念。也就是说,无论是从底层数据库,向上到达服务层,最后到 Web 层,亦或是在这个流程中所包含的任意中间层组件,整个数据传递链路都应该是采用事件驱动的方式来进行运作。这样,我们就可以不采用传统的同步调用方式来处理数据,而是由位于数据库上游的各层组件自动来执行事件。这就是响应式编程的核心特点。
针对数据流的具体操作方法都定义在响应式流Reactive Stream规范中。在 Java 的世界中,关于响应式流规范的实现也有一些主流的开源框架,包括 RxJava、Vert.x 以及 Project Reactor。
Project Reactor
Spring 5 选择 Project Reactor 作为它的内置响应式编程框架,该框架提供了两种数据流的表示方式,即代表包含 0 到 n 个元素异步序列的 Flux 组件,以及只包含 0 个或 1 个元素的 Mono 组件。我们可以通过一个简单的代码示例来创建一个 Flux 对象,如下所示:
private Flux<Order> getAccounts() {
List<Account> accountList = new ArrayList<>();
Account account = new Account();
account.setId(1L);
account.setAccountCode("DemoCode");
account.setAccountName("DemoName");
accountList.add(account);
return Flux.fromIterable(accountList);
}
在以上代码中,我们通过 Flux.fromIterable() 方法构建了 Flux 对象并进行返回Flux.fromIterable() 是构建 Flux 的一种常用方法。而 Mono 组件也提供了一组有用的方法来创建 Mono 数据流,例如:
private Mono<Account> getAccountById(Long id) {
Account account = new Account();
account.setId(id);
account.setAccountCode("DemoCode");
account.setAccountName("DemoName");
accountList.add(account);
return Mono.just(account);
}
可以看到,这里首先构建一个 Account 对象,然后通过 Mono.just() 方法返回一个 Mono 对象。
Spring WebFlux
同时针对一个完整的应用程序开发过程Spring 5 还专门提供了针对 Web 层的 WebFlux 框架、针对数据访问层的 Spring Data Reactive 框架等。因为 Spring Security 主要用于 Web 应用程序,所以这里对 WebFlux 做一些展开。
想要在 Spring Boot 中使用 WebFlux需要引入如下依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
请注意这里的 spring-boot-starter-webflux 就是构成响应式 Web 应用程序开发的基础。基于 WebFlux 构建响应式 Web 服务的编程模型,开发人员有两种选择。第一种是使用基于 Java 注解的方式,而第二种则是使用函数式编程模型。其中,基于 Java 注解的方式与使用 Spring MVC 完全一致,我们来看一个示例:
@RestController
public class HelloController {
@GetMapping("/")
public Mono<String> hello() {
return Mono.just("Hello!");
}
}
以上代码只有一个地方值得注意,即 hello() 方法的返回值从普通的 String 对象转化为了一个 Mono 对象。这点是完全可以预见的,使用 Spring WebFlux 与 Spring MVC 的不同之处,在于前者使用的类型都是 Reactor 中提供的 Flux 和 Mono 对象,而不是普通的 POJO。
我们知道传统的 Spring MVC 构建在 Java EE 的 Servlet 标准之上,该标准本身就是阻塞式和同步的。而 Spring WebFlux 则是构建在响应式流以及它的实现框架 Project Reactor 之上的一个开发框架,因此可以基于 HTTP 协议来构建异步非阻塞的 Web 服务。
最后,我们来看一下位于底部的容器支持。当你使用 Spring WebFlux 时,你会注意到它默认采用了 Netty 作为运行时容器。这是因为 Spring MVC 是运行在传统的 Servlet 容器之上,而 Spring WebFlux 则需要支持异步的运行环境,比如 Netty、Undertow 以及 Servlet 3.1 之上的 Tomcat 和 Jetty。这里多说一句因为在 Servlet 3.1 中引入了异步 I/O 支持。
引入响应式 Spring Security
对于 Spring Security 而言,引入响应式编程技术同样会对传统实现方法带来一些变化。比方说,在[《认证体系:如何深入理解 Spring Security 的认证机制?]课时中,我们已经知道 UserDetailsService 的作用是用来获取用户信息,你可以把它理解为是一种数据源,这样针对数据源的数据访问过程同样需要支持响应式编程。下面让我们一起来看一下这些变化吧。
响应式用户认证
在响应式 Spring Security 中,提供了一个响应式版本的 UserDetailsService即 ReactiveUserDetailsService定义如下
public interface ReactiveUserDetailsService {
Mono<UserDetails> findByUsername(String username);
}
请注意,这里的 findByUsername() 方法返回的是一个 Mono 对象。ReactiveUserDetailsService 接口有一个实现类 MapReactiveUserDetailsService提供了基于内存的用户信息存储方案实现过程如下所示
public class MapReactiveUserDetailsService implements ReactiveUserDetailsService, ReactiveUserDetailsPasswordService {
private final Map<String, UserDetails> users;
public MapReactiveUserDetailsService(Map<String, UserDetails> users) {
this.users = users;
}
public MapReactiveUserDetailsService(UserDetails... users) {
this(Arrays.asList(users));
}
public MapReactiveUserDetailsService(Collection<UserDetails> users) {
Assert.notEmpty(users, "users cannot be null or empty");
this.users = new ConcurrentHashMap<>();
for (UserDetails user : users) {
this.users.put(getKey(user.getUsername()), user);
}
}
@Override
public Mono<UserDetails> findByUsername(String username) {
String key = getKey(username);
UserDetails result = users.get(key);
return result == null ? Mono.empty() : Mono.just(User.withUserDetails(result).build());
}
@Override
public Mono<UserDetails> updatePassword(UserDetails user, String newPassword) {
return Mono.just(user)
.map(u ->
User.withUserDetails(u)
.password(newPassword)
.build()
)
.doOnNext(u -> {
String key = getKey(user.getUsername());
this.users.put(key, u);
});
}
private String getKey(String username) {
return username.toLowerCase();
}
}
从上面的代码中可以看到,这里使用了一个 Map 来保存用户信息,然后在 findByUsername() 方法中,通过 Mono.just() 方法来返回一个 Mono 对象。然后,我们还注意到在 updatePassword() 方法中用到的 map() 方法,实际上是 Project Reactor 所提供的一个操作符,用于实现对一个对象执行映射操作。
基于 MapReactiveUserDetailsService我们就可以在业务系统中通过以下方式构建一个 ReactiveUserDetailsService
@Bean
public ReactiveUserDetailsService userDetailsService() {
UserDetails u = User.withUsername("john")
.password("12345")
.authorities("read")
.build();
ReactiveUserDetailsService uds = new MapReactiveUserDetailsService(u);
return uds;
}
当然,针对用户认证,响应式 Spring Security 也提供了响应式版本的 ReactiveAuthenticationManager 来执行具体的认证流程。
响应式授权机制
介绍完认证,我们接着来看授权,假设系统中存在这样一个简单的 HTTP 端点:
@RestController
public class HelloController {
@GetMapping("/hello")
public Mono<String> hello(Mono<Authentication> auth) {
Mono<String> message = auth.map(a -> "Hello " + a.getName());
return message;
}
}
这里我们使用了 Spring Webflux 构建了一个响应式端点,注意到 hello() 的返回值是一个 Mono 对象。同时,我们输入的也是一个 Mono 对象,因此,访问这个端点显然是需要认证的。
在《访问授权:如何对请求的安全访问过程进行有效配置?》课时中,我们知道可以通过覆写 WebSecurityConfigurerAdapter 中的 configure(HttpSecurity http) 方法来设置访问权限。这种配置方法在响应式编程体系中就无法再使用了,取而代之的是使用一个叫 SecurityWebFilterChain 的配置接口来完成配置,该接口定义如下:
public interface SecurityWebFilterChain {
//评估交互上下文 ServerWebExchange 是否匹配
Mono<Boolean> matches(ServerWebExchange exchange);
//一组过滤器
Flux<WebFilter> getWebFilters();
}
从命名上看,我们不难理解 SecurityWebFilterChain 代表的实际上是一个过滤器链,而 ServerWebExchange 则是包含请求和响应的一种交互上下文这在响应式环境中是一种固定属性因为我们认为整个交互过程不是在单纯的发送请求和接受响应而是在交换Exchange数据。如果想要使用 SecurityWebFilterChain可以采用类似如下所示的代码示例
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
return http.authorizeExchange()
.pathMatchers(HttpMethod.GET, "/hello").authenticated()
.anyExchange().permitAll()
.and()
.httpBasic()
.and()
.build();
}
这里的 ServerHttpSecurity 可以用来构建 SecurityWebFilterChain 的实例,它的作用类似于非响应式系统中所使用的 HttpSecurity。同时ServerHttpSecurity 也提供了一组我们熟悉的配置方法来设置各种认证和授权机制。
需要注意的是,在响应式系统中,因为处理的对象是 ServerWebExchange而不是传统的 ServerRequest所以在涉及与请求相关的方法命名时都统一做了调整。例如使用了 authorizeExchange() 方法来取代 authorizeRequests(),使用 anyExchange() 取代了 anyRequest(),而这里的 pathMatchers() 方法也可以等同于以前介绍的 mvcMatchers() 方法。
响应式方法级别访问控制
在[《 10 |全局方法:如何确保方法级别的安全访问?》]课时中,我们介绍了 Spring Security 所提供的一个非常强大的功能,即全局安全方法机制。通过这种机制,无论是 Web 服务还是普通应用,都可以基于方法的执行过程来应用授权规则。
在响应式编程中我们称这种方法级别的授权机制为响应式方法安全Reactive Method Security机制以便与传统的全局方法安全机制进行区分。
想要在应用程序中使用响应式方法安全机制,我们还需要专门引入一个新的注解,即 @EnableReactiveMethodSecurity。这个注解与 @EnableGlobalMethodSecurity 注解类似,用来启用响应式安全方法机制:
@Configuration
@EnableGlobalMethodSecurity
public class SecurityConfig
现在,我们来看一下使用响应式方法安全机制的代码示例:
@RestController
public class HelloController {
@GetMapping("/hello")
@PreAuthorize("hasRole('ADMIN')")
public Mono<String> hello() {
return Mono.just("Hello!");
}
}
可以看到,这里使用了 @PreAuthorize 注解并通过”hasRole(ADMIN)“这一 SpEL 表达式来实现基于角色的授权机制。就这个注解的使用方式而言,可以发现,它与传统应用程序中的使用方式是一致的。但不幸的是,就目前而言,响应式方法安全机制还不是很成熟,只提供了 @PreAuthorize@PostAuthorize 这对注解,而 @PreFilter@PostFilter 注解还没有实现。
小结与预告
响应式编程是技术发展趋势,为我们构建高弹性的应用程序提供了一种新的编程模式。作为 Spring 家族中的重要组成部分Spring Security 框架同样提供了对响应式编程的全面支持。本课时对响应式编程的基础概念做了阐述,并给出了 Spring Security 中关于用户认证、授权机制以方法级别访问控制等功能的响应式解决方案。
这里给你留一道思考题:在实现授权机制时,响应式编程模式与传统编程模式有什么区别?

View File

@ -0,0 +1,466 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
19 测试驱动:如何基于 Spring Security 测试系统安全性?
作为整个课程最后一部分内容,我们将讨论基于 Spring Security 的测试解决方案。对于安全性而言,测试是一个难点,也是经常被忽略的一套技术体系。当我们使用 Spring Security 时,如何验证我们所使用的安全性功能是否正确呢?今天的内容将给出详细的答案。
如何对系统的安全性进行测试?
Spring Security 是一款安全性开发框架,提供的是内嵌到业务系统中的基础设施类功能。因此,势必会涉及大量组件之间的依赖关系,这是测试安全性功能所面临的最大挑战,需要采用特定的测试方法。因此,在介绍具体的测试用例之前,我们先来梳理一下安全性测试方法,以及 Spring Security 中为我们提供的测试解决方案。
安全性测试与 Mock 机制
正如前面提到的,验证安全性功能正确性的难点在于组件与组件之间的依赖关系,为了弄清楚这个关系,这里就需要引出测试领域非常重要的一个概念,即 Mock模拟。针对测试组件涉及的外部依赖我们的关注点在于这些组件之间的调用关系以及返回的结果或发生的异常等而不是组件内部的执行过程。因此常见的技巧就是使用 Mock 对象来替代真实的依赖对象,从而模拟真实的调用场景。
我们以一个常见的三层 Web 服务架构为例来进一步解释 Mock 的实施方法。Controller 层会访问 Service 层,而 Service 层又会访问 Repository 层,我们对 Controller 层的端点进行验证时,就需要模拟 Service 层组件的功能。同样,对 Service 层组件进行测试时,也需要假定 Repository 层组件的结果是可以获取的,如下所示:
Web 服务中各层组件与 Mock 对象示意图
对于 Spring Security 而言,上图所展示的原理同样适用,例如我们可以通过模拟用户的方式来测试用户认证和授权功能的正确性。在本讲后面的内容中,我们会给出相关的代码示例。
Spring Security 中的测试解决方案
要想开展单元测试、集成测试以及基于 Mock 的测试,需要有一套完整的技术体系。和 Spring Boot 1.x 版本一样Spring Boot 2.x 同样提供了针对测试的 spring-boot-starter-test 组件。在 Spring Boot 中集成该组件的方法就是在 pom 文件中添加如下依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
通过这个依赖,一系列组件被自动引入到了代码工程的构建路径中,包括 JUnit、JSON Path、AssertJ、Mockito、Hamcrest 等,这些测试组件都非常有用。同时,因为 Spring Boot 程序的入口是 Bootstrap 类,因此专门提供了一个 @SpringBootTest 注解来测试你的 Bootstrap 类。所有配置都会通过 Bootstrap 类去加载,而该注解可以引用 Bootstrap 类的配置。
另一方面Spring Security 也提供了专门用于测试安全性功能的 spring-security-test 组件,如下所示:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
该组件提供了相关的注解来模拟用户登录信息或者调用用户登录的方法,让我们一起来看一下。
测试 Spring Security 功能
测试用户
在使用 Spring Security 时,首先需要测试的无疑是合法的用户。假设我们实现了如下所示的一个简单 Controller
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "Hello";
}
}
一旦我们启用 Spring Security 认证功能,那么对上述“/hello”端点就可以执行两种测试分别面向认证和非认证用户。我们先来看一下针对非认证用户的测试方法
@SpringBootTest
@AutoConfigureMockMvc
public class HelloControllerTests {
@Autowired
private MockMvc mvc;
@Test
public void testUnauthenticatedUser() throws Exception {
mvc.perform(get("/hello"))
.andExpect(status().isUnauthorized());
}
}
这里引入了一个 @AutoConfigureMockMvc 注解,通过将 @SpringBootTest 注解与 @AutoConfigureMockMvc 注解相结合,@AutoConfigureMockMvc 注解在通过 @SpringBootTest 加载的 Spring 上下文环境中会自动装配 MockMvc 这个测试工具类。
顾名思义MockMvc 用来对 WebMVC 的执行过程进行模拟。MockMvc 类提供的基础方法如下所示。
perform执行一个 RequestBuilder 请求,会自动执行 SpringMVC 流程,并映射到相应的 Controller 进行处理。
get/post/put/delete声明发送一个 HTTP 请求的方式,根据 URI 模板和 URI 变量值得到一个 HTTP 请求,支持 GET、POST、PUT、DELETE 等 HTTP 方法。
param添加请求参数发送 JSON 数据时将不能使用这种方式,而应该采用 @ResponseBody 注解。
andExpect添加 ResultMatcher 验证规则,通过对返回的数据进行判断来验证 Controller 执行结果是否正确。
andDo添加 ResultHandler 结果处理器,比如调试时打印结果到控制台。
andReturn最后返回相应的 MvcResult然后执行自定义验证或做异步处理。
在上述代码示例中,我们通过 perform、accept 和 andExpect 方法最终模拟 HTTP 请求的整个过程并验证请求的返回状态是否为非认证。
接下来我们来模拟认证用户的测试场景,测试用例如下所示:
@Test
@WithMockUser
public void testAuthenticatedUser() throws Exception {
mvc.perform(get("/hello"))
.andExpect(content().string("Hello"))
.andExpect(status().isOk());
}
显然,我们在这里看到了一个新的 @WithMockUser 注解,请注意这个注解是 Spring Security 所提供的,专门用来模拟认证用户。现在,既然已经有了认证用户,那么我们就可以验证响应的返回值以及状态,正如上述代码所示。
通过 @WithMockUser 注解我们还可以指定用户的详细信息例如如下所示的代码模拟了一个用户名为“admin”、角色为“USER”和“ADMIN”的认证用户
@WithMockUser(username="admin",roles={"USER","ADMIN"})
进一步,我们还可以通过模拟 UserDetailsService 来提供自定义的 UserDetails 用户信息。为此Spring Security 中专门提供了一个 @WithUserDetails 注解,示例代码如下所示:
@Test
@WithUserDetails("jianxiang")
public void testAuthenticatedUser() throws Exception {
mvc.perform(get("/hello"))
.andExpect(content().string("Hello"))
.andExpect(status().isOk());
}
测试认证
测试完用户,我们接着来测试针对用户的认证过程。为了对整个认证过程有更多的定制化实现,这里专门提供一个 AuthenticationProvider 接口的实现类 MyAuthenticationProvider如下所示
@Component
public class MyAuthenticationProvider implements AuthenticationProvider {
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = String.valueOf(authentication.getCredentials());
if ("jianxiang".equals(username) && "123456".equals(password)) {
return new UsernamePasswordAuthenticationToken(username, password, Arrays.asList());
} else {
throw new AuthenticationCredentialsNotFoundException("Error!");
}
}
@Override
public boolean supports(Class<?> authenticationType) {
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authenticationType);
}
}
现在,我们基于 HTTP 基础认证机制来编写测试用例,如下所示:
@SpringBootTest
@AutoConfigureMockMvc
public class AuthenticationTests {
@Autowired
private MockMvc mvc;
@Test
public void testAuthenticatingWithValidUser() throws Exception {
mvc.perform(get("/hello")
.with(httpBasic("jianxiang","123456")))
.andExpect(status().isOk());
}
@Test
public void testAuthenticatingWithInvalidUser() throws Exception {
mvc.perform(get("/hello")
.with(httpBasic("noexiseduser","123456")))
.andExpect(status().isUnauthorized());
}
}
这里使用了前面介绍的 @AutoConfigureMockMvc 注解和 MockMvc 工具类,然后通过 httpBasic() 方法来实现 HTTP 基础认证。我们分别针对正确和错误的用户名/密码组合来执行 HTTP 请求并根据返回状态对认证结果进行校验。
测试方法安全
前面讨论的内容都是面向 Web 应用,也就是说测试的对象都是 HTTP 端点。那么,如何针对方法级别的安全性进行测试呢?
针对全局方法安全机制,前面介绍的 @WithMockUser 注解 @WithUserDetails 注解实际上也都是可以正常使用的。但因为已经脱离了 Web 环境,所以 MockMvc 工具类显然是无效的。这时候,你要做的事情就是在测试用例中直接注入目标方法即可,我们来看一下代码示例,假设一个非 Web 类的应用程序中存在如下一个 Service 类:
@Service
public class HelloService {
@PreAuthorize("hasAuthority('write')")
public String hello() {
return "Hello";
}
}
可以看到这里使用了 @PreAuthorize 注解限制了只有具备“write”权限的用户才能访问这个方法。
现在让我们编写针对方法访问安全的第一个测试用例,如下所示:
@Autowired
private HelloService helloService;
@Test
void testMethodWithNoUser() {
assertThrows(AuthenticationException.class,
() -> helloService.hello());
}
当我们在没有认证的情况下访问 helloService 的 hello() 方法,应该抛出一个 AuthenticationException 异常,上述测试用例验证了这一点。而如果我们使用一个具备不同权限的认证用户去访问这个方法时,会发生什么呢?对应测试用例如下所示:
@Test
@WithMockUser(authorities = "read")
void testMethodWithUserButWrongAuthority() {
assertThrows(AccessDeniedException.class,
() -> helloService.hello());
}
可能看到这里使用了 @WithMockUser 模拟了一个具有“read”权限的认证用户但因为 @PreAuthorize 注解中指定只有“write”权限的用户才能访问这个方法所以会抛出一个 AccessDeniedException。
最后,我们来测试正常流程下的结果,测试用例如下所示:
@Test
@WithMockUser(authorities = "write")
void testMethodWithUserButCorrectAuthority() {
Stringresult = helloService.hello();
assertEquals("Hello", result);
}
测试 CSRF 和 CORS 配置
基于我们在[《攻击应对:如何实现 CSRF 保护和 CORS》]中的讨论,对于 POST、PUT 和 DELETE 等 HTTP 请求,我们需要添加针对 CSRF 的安全保护。为了测试 CSRF 配置的正确性,假设我们存在这样一个 HTTP 端点,请注意它的 HTTP 方法是 POST
@RestController
public class HelloController {
@PostMapping("/hello")
public String postHello() {
return "Post Hello!";
}
}
现在,我们通过 MockMvc 工具类发起 post 请求,测试用例如下所示:
@Test
public void testHelloUsingPOST() throws Exception {
mvc.perform(post("/hello"))
.andExpect(status().isForbidden());
}
请注意,这个 post 请求并没有携带 CSRF Token所以响应的状态应该是 HTTP 403 Forbidden。
现在,让我们重构上述测试用例,如下所示:
@Test
public void testHelloUsingPOSTWithCSRF() throws Exception {
mvc.perform(post("/hello").with(csrf()))
.andExpect(status().isOk());
}
上述 csrf() 方法的作用就是在请求中添加 CSRF Token显然这时候的响应结果应该是正确的。
讨论完 CSRF我们再来看 CORS。在[《攻击应对:如何实现 CSRF 保护和 CORS》]中,我们已经通过 CorsConfiguration 设置了 HTTP 响应消息头,如下所示:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors(c -> {
CorsConfigurationSource source = request -> {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(Arrays.asList("*"));
config.setAllowedMethods(Arrays.asList("*"));
return config;
};
c.configurationSource(source);
});
}
对上述配置进行测试的方法也很简单,我们通过 MockMvc 发起请求,然后对响应的消息头进行验证即可,测试用例如下所示:
@SpringBootTest
@AutoConfigureMockMvc
public class MainTests {
@Autowired
private MockMvc mvc;
@Test
public void testCORSForTestEndpoint() throws Exception {
mvc.perform(options("/hello")
.header("Access-Control-Request-Method", "POST")
.header("Origin", "http://www.test.com")
)
.andExpect(header().exists("Access-Control-Allow-Origin"))
.andExpect(header().string("Access-Control-Allow-Origin", "*"))
.andExpect(header().exists("Access-Control-Allow-Methods"))
.andExpect(header().string("Access-Control-Allow-Methods", "POST"))
.andExpect(status().isOk());
}
}
可以看到,针对 CORS 配置我们分别获取了响应结果的”Access-Control-Allow-Origin”和”Access-Control-Allow-Methods”消息头并进行了验证。
小结与预告
对于一个应用程序而言,无论其表现形式是否是一个 Web 服务,我们都需要对其进行安全性测试,为此 Spring Security 也提供了专门的测试解决方案,这其中很大程度上依赖于对 Mock 机制的合理应用。本讲对 Spring Security 所提供的用户、认证、全局方法、CSRF 以及 CORS 设计了测试用例,并给出了对应的示例代码。

View File

@ -0,0 +1,61 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
20 结束语 以终为始Spring Security 的学习总结
如今,虽然越来越多的开发人员开始意识到安全问题,但不幸的是,从应用程序设计和开发之初就充分考虑安全性问题并不是一种常见的做法。这种态度应该改变,每个参与软件系统开发的团队或个人都必须从一开始就学会考虑安全性。
Spring Security 是 Spring 家族的重要组成成员,也是业界领先的一款应用程序开发框架,提供了多项核心功能,帮忙我们构建完整的安全解决方案。本专栏作为针对 Spring Security 的一门系统化课程,在课程的最后,我想和你一起回顾和总结一下 Spring Security 核心功能,分享一些我在写作过程中的一些思考和心得。
学习 Spring Security 的意义
在 Java 领域中Spring Security 是应用非常广泛的一个开发框架,也是 Spring 家族中历史比较悠久的一个框架。Spring Security 同样是 Spring Cloud 等综合性开发框架的底层基础框架之一,功能完备且强大,因此在 Spring 家族的其他框架中应用十分广泛。
另一方面,随着 Spring Boot 的大规模流行Spring Security 也迎来了全新的发展时期。基于 Spring Boot 的自动配置原理,以往在传统 Spring 应用程序中集成和配置 Spring Security 框架的复杂过程将成为历史。在日常开发过程中Spring Security 与 Spring Boot 等框架可以无缝集成,为构建完整的安全性解决方案提供保障。
Spring Security 内置了很多强大的功能,针对整个框架,我们可以从以下几个方面对其进行分析和学习:
Spring Security 的体系结构和基本组件,以及如何使用它来保护应用程序;
使用 Spring Security 进行身份验证和授权,以及如何将它们应用于面向生产的应用程序;
如何在应用程序的不同层中使用 Spring Security
在应用程序中使用不同的安全配置方式和最佳实践;
将 Spring Security 用于响应式应用程序;
对安全性解决方案进行测试。
但是,基于我自己的学习过程,以及从周围开发人员接收到的信息,我们在学习如何正确地使用 Spring Security 来保护应用程序免受常见漏洞的攻击时,或多或少都会遇到困难。当然,我们可以在网上找到有关 Spring Security 的所有细节。但是如果你希望在使用框架时花费最少的精力,那么就需要将相关知识按正确、合理的顺序放在一起进行学习,这通常需要大量的时间和经验。因此我设计这门课程的初衷,就是为了帮助你节省学习时间,提高学习效率。
此外,不完整的知识可能导致你设计并实现了难以维护的解决方案,甚至可能暴露安全漏洞。很多时候,当我们去 Review 这些问题时,会发现 Spring Security 的使用方式本身可能就是不合理的。而且,在许多情况下,主要原因还是开发人员对“如何使用 Spring Security”缺乏必要的了解。Spring Security 中的一些功能看上去比较简单,但用起来却经常会因为一些细小的配置,导致整个功能无法使用。即使发现了问题,也不太不容易找到原因。
因此,我决定设计一个系统化的专栏,帮助所有使用 Spring 框架的开发人员理解“如何正确使用 Spring Security”。这门课程应该是一个资源以帮助开发人员逐步了解 Spring Security 框架,希望能为你带来价值,避免在应用程序中引入所有可能的安全漏洞。
下面我们再来回顾一下这门课程具体讲了哪些内容,这些内容又具备哪些特色呢?
Spring Security 这门课有什么特色?
在设计这门课程时我关注的是将框架提供的各项功能进行合理的组织并详细介绍它们的应用方式。课程组织上按照“基础功能篇→高级主题篇→OAuth2 与微服务篇 → 框架扩展篇”的主线来展开内容,呈递进关系。这是本课程的一大特色。我们将 Spring Security 的各项功能按照基础和高级等不同维度进行划分,由浅入深进行讲解。
基础功能篇中,我们介绍 Spring Security 的一些基础性功能,包括认证、授权和加密;
高级主题篇的功能面向特定需求可以用于构建比较复杂的应用场景包括过滤器、CSRF 保护、跨域 CORS以及针对非 Web 应用程序的全局方法安全机制;
而 OAuth2 与微服务篇的内容关注微服务开发框架 Spring Cloud 与 Spring Security 之间的整合,我们对 OAuth2 协议和 JWT 进行了全面的展开,并使用这些技术体系构建了安全的微服务系统,以及单点登录系统。
最后,在框架扩展篇中,我们对 Spring Security 框架在应用上的一些扩展进行讨论,包括在 Spring Security 中引入全新的响应式编程技术,以及如何对应用程序安全性进行测试的系统方法。
课程的第二大特色在于案例驱动。整个专栏分别在基础功能篇、高级主题篇和微服务安全篇中结合本篇内容提供一个完整的案例,分别介绍 Spring Security 的基础认证授权功能、过滤器功能、基于 OAuth2 协议的单点登录和微服务访问授权体系的实战技巧。我们在案例中使用到的很多示例代码都可以直接使用在面向生产的应用程序中。
第三个特色是技术创新。随着 Spring 5 的发布涌现出了响应式编程这种新型技术体系,新版本的 Spring Security 中也提供了对响应式编程的全面支持。本课程对响应式 Spring Security 也做了介绍,这部分内容应该在目前 Spring Security 相关资料中还是首创。
第四个特色是深度和广度并重。从内容广度上,我们对 Spring Security 框架可以说是面面俱到相关知识点娓娓道来。而在内容深度上我们对框架最核心的认证和授权机制进行底层原理的分析让你知其然更值其所以然。Spring Security 内置了很多可扩展性组件,通过对框架底层实现机制的理解和把握,可以帮助我们更好的实现扩展性。
写在最后
整个课程从平时的积累,到酝酿的启动再到上线经历了小半年的时间,伴随着这个过程,我把 Spring Security 的部分源代码系统地梳理了一遍,并对内部的设计思想和实现原理也做了一些提炼和总结。
总体而言Spring Security 是一款代码质量非常高的开源框架其中关于对用户认证和访问授权模型的抽象、内置的过滤器机制、全局方法安全机制、OAuth2 协议,以及响应式编程支持等诸多功能都给我留下了深刻的印象,使我受益良多。相信对于坚持学习到今天的你而言也是一样。