5.1 细粒度的自动配置

在深入了解配置属性之前,我们需要知道,在Spring中有两种不同(但相关)的配置。

  • bean装配:声明在Spring应用上下文中创建哪些应用组件以及它们之间如何互相注入的配置。
  • 属性注入:设置Spring应用上下文中bean的值的配置。

在Spring的XML方式和基于Java的配置中,这两种类型的配置通常会在同一个地方显式声明。在基于Java的配置中,带有@Bean注解的方法一般会同时初始化bean并立即为它的属性设置值。例如,请查看下面这个带有@Bean注解的方法,它会为嵌入式的H2数据库声明一个DataSource:

1
2
3
4
5
6
7
8
@Bean
public DataSource dataSource() {
return new EmbeddedDataSourceBuilder()
.setType(H2)
.addScript("taco_schema.sql")
.addScripts("user_data.sql", "ingredient_data.sql")
.build();
}

在这里,addScript()和addScripts()方法设置了一些String类型的属性,它们是在数据源就绪之后要用到数据库上的SQL脚本。这就是不使用Spring Boot时我们配置DataSource bean的方法,但是借助自动配置的功能,就完全没有必要使用这种方法了。

如果在运行时类路径中能够找到H2依赖,那么Spring Boot会自动在Spring应用上下文中创建对应的DataSource bean。这个bean会运行名为schema.sql和data.sql的脚本。

但是,如果我们想要给SQL脚本使用其他的名称,该怎么办呢?或者,如果我们想要指定两个以上的SQL脚本又该怎么办呢?这就是配置属性能够发挥作用的地方了。但是,在开始使用配置属性之前,我们需要理解这些属性是从哪里来的。

5.1.1 理解Spring的环境抽象

Spring的环境抽象是各种配置属性的一站式服务。它抽取了原始的属性,这样需要这些属性的bean就可以从Spring本身中获取了。Spring环境会拉取多个属性源,包括:

  • JVM系统属性;
  • 操作系统环境变量;
  • 命令行参数;
  • 应用属性配置文件。

它会将这些属性聚合到一个源中,通过这个源可以注入到Spring的bean中。图5.1阐述了来自各个属性源的属性是如何流经Spring的环境抽象进入Spring bean的。

image-20211014081714790

图5.1 Spring环境从各个属性源拉取属性,并让Spring应用上下文中的bean可以使用它们

Spring Boot自动配置的bean都可以通过Spring环境提取的属性进行配置。举个简单的例子,假设我们希望应用底层的Servlet容器使用另外一个端口监听请求,而不再使用8080。为了实现这一点,我们可以在“src/main/resources/application.properties”中将server.port设置成一个不同的端口,如下所示:

1
server.port=9090

在设置属性的时候,我个人更喜欢使用YAML。所以,我通常不会使用application.properties,而是在“src/main/resources/application.yml”中设置server.port的值,如下所示:

1
2
server:
port: 9090

如果你喜欢在外部配置该属性,那么可以在使用命令行参数启动应用的时候指定端口:

1
java -jar tacocloud-0.0.5-SNAPSHOT.jar --server.port=9090

如果你希望应用始终在一个特定的端口启动,那么可以通过操作系统的环境变量进行一次性的设置:

1
export SERVER_PORT=9090

需要注意,在将属性设置为环境变量的时候,命名风格略有不同,这样做是为了适应操作系统对环境变量名称的限制。不过,没有关系,Spring能够将其挑选出来,并将SERVER_PORT解析为server.port。

正如我前面所说,有多种配置属性的方法。当我们学习第14章的时候,你会看到另外一种设置属性的方法,那就是通过中心化的配置服务器实现。实际上,我们可以使用几百个配置属性来调整Spring bean的行为。你已经看到了其中的一部分,比如本章中已经介绍的server.port。

在本章中,我们不可能介绍所有可用的配置属性。尽管如此,我们还是可以了解一些你可能会经常遇到的非常有用的配置属性。我们首先看一下能够调整自动配置的数据源的一些属性。

5.1.2 配置数据源

此时,Taco Cloud应用尚未完成,在该应用准备部署之前,我们还有好几个章节来完善它。因此,使用嵌入式的H2数据库作为数据源非常适合我们的需求,至少就目前来看是这样的。但是,一旦要将应用部署到生产环境中,你可能需要考虑一个更加持久的数据库解决方案。

尽管我们可以显式地配置自己的DataSource,但通常没有必要这样做。相反,通过配置属性设置数据库URL和凭证信息会更加简单。例如,如果你想要开始使用MySQL数据库,那么可以把如下的配置属性添加到application.yml中:

1
2
3
4
5
spring:
datasource:
url: jdbc:mysql://localhost/tacocloud
username: tacodb
password: tacopassword

尽管我们需要将对应的JDBC驱动添加到构建文件中,但是我们不需要指定JDBC驱动类。Spring Boot会根据数据库URL的结构推算出来。然而,如果这样做有问题的话,我们依然可以通过spring.datasource.driver-class-name属性来进行设置:

1
2
3
4
5
6
spring:
datasource:
url: jdbc:mysql://localhost/tacocloud
username: tacodb
password: tacopassword
driver-class-name: com.mysql.jdbc.Driver

Spring Boot在自动化配置DataSource bean的时候,会使用该连接。如果在类路径中存在Tomcat的JDBC连接池,DataSource将使用该连接池。否则,SpringBoot将会在类路径下尝试查找并使用如下的连接池实现:

  • HikariCP
  • Commons DBCP 2

自动配置所能支持的连接池可选方案仅有这些,但是随时欢迎显式配置DataSource bean,这样你可以使用任意喜欢的连接池实现。

在本章前面的内容中,我们建议要有一种方式声明应用启动的时候要执行的数据库初始化脚本。在这种情况下,spring.datasource.schema和spring.datasource.data属性就非常有用了:

1
2
3
4
5
6
7
8
9
spring:
datasource:
schema:
- order-schema.sql
- ingredient-schema.sql
- taco-schema.sql
- user-schema.sql
data:
- ingredients.sql

有的读者可能无法使用显式配置数据源的方式,而是更加倾向于在JNDI中配置数据源并让Spring去那里进行查找。在这种情况下,我们可以使用spring.datasource.jndi-name搭建自己的数据源:

1
2
3
spring:
datasource:
jndi-name: java:/comp/env/jdbc/tacoCloudDS

如果我们设置了spring.datasource.jndi-name属性,其他的数据库连接属性(已经设置了的话)就会被忽略掉。

5.1.3 配置嵌入式服务器

我们已经看到过如何使用server.port属性来配置servlet容器的端口。但是,我还没有展示将server.port设置为0将会出现什么状况:

1
2
server:
port: 0

尽管我们将server.port属性显式设置成了0,但是服务器并不会真的在端口0上启动。相反,它会任选一个可用的端口。在我们运行自动化集成测试的时候,这会非常有用,因为这样能够保证并发运行的测试不会与硬编码的端口号冲突。在第13章中我们将会看到,如果不关心应用在哪个端口启动,那么这种配置方式也非常有用,因为此时应用将会变成通过服务注册中心来进行查找的微服务。

但是,底层服务器的配置并不仅仅局限于一个端口,我们对底层容器常见的一项设置就是让它处理HTTPS请求。为了实现这一点,我们首先要使用JDK的keytool命令行工具生成keystore:

1
keytool -keystore mykeys.jks -genkey -alias tomcat -keyalg RSA

在这个过程中,会询问我们一些关于名称和组织机构相关的问题,大多数问题都无关紧要。但是,它提示输入密码的时候需要记住你所选择的密码。在本例中,我选择使用letmein作为密码。

接下来,我们需要设置一些属性,以便于在嵌入式服务器中启用HTTPS。我们可以在命令行中进行配置,但是这种方式非常不方便,相反,你可能更愿意通过application.properties或application.yml文件来声明配置。在application.yml中,配置属性如下所示:

1
2
3
4
5
6
server:
port: 8443
ssl:
key-store: file:///path/to/mykeys.jks
key-store-password: letmein
key-password: letmein

在这里,我们将server.port设置为8443,这是在开发阶段HTTPS服务器的常用选择。server.ssl.key-store属性应该设置为我们所创建的keystore文件的路径。在这里,它使用了file:// URL,因此会在文件系统中加载,但是,如果你需要将它打包到一个应用JAR文件中,就需要使用“classpath:”URL来引用它。server.ssl.key-store-password和server.ssl.key-password属性都设置成了创建keystore时所设置的密码。

这些属性准备就绪之后,应用就会监听8443端口上的HTTPS请求。因为浏览器之间有所差异,所以你可能会遇到服务器无法验证其身份的警告。在开发阶段,通过localhost提供服务时,这其实无须担心。

5.1.4 配置日志

大多数的应用都会提供某种形式的日志。即便你的应用本身不直接打印任何日志,应用所使用的库肯定也会以日志的形式记录它们的活动。

默认情况下,Spring Boot通过Logback配置日志,日志会以INFO级别写入到控制台中。在运行应用或其他样例的时候,你可能已经在应用日志中发现了大量的INFO级别的条目。

为了完全控制日志的配置,我们可以在类路径的根目录下(在src/main/resources中)创建一个logback.xml文件。如下是一个简单logback.xml文件的样例:

1
2
3
4
5
6
7
8
9
10
11
12
13
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>
%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
</pattern>
</encoder>
</appender>
<logger name="root" level="INFO"/>
<root level="INFO">
<appender-ref ref="STDOUT" />
</root>
</configuration>

除了日志所使用的模式之外,这个Logback配置和没有logback.xml文件时的默认行为几乎是相同的。但是,通过编辑logback.xml文件,我们可以完全控制应用的日志文件。

注意:关于logback.xml文件中都可以声明哪些内容,这超出了本书的范围。你可以参考Logback的文档来了解更多信息。

在日志配置方面,你可能遇到的常见变更就是修改日志级别和指定日志写入到哪个文件中。借助Spring Boot的配置属性功能,我们不用创建logback.xml文件就能完成这些变更。

要设置日志级别,我们可以创建以logging.level作为前缀的属性,随后紧跟着的是我们想要设置日志级别的logger。假设,我们想要将root logging设置为WARN级别,但是希望将Spring Security的日志级别设置为DEBUG。那么,在application.yml中添加如下的条目就能实现我们的要求:

1
2
3
4
5
6
logging:
level:
root: WARN
org:
springframework:
security: DEBUG

我们还可以将Spring Security的包名扁平化到一行中,使其更易于阅读:

1
2
3
4
logging:
level:
root: WARN
org.springframework.security: DEBUG

现在,假设我们想要将日志条目写入到“/var/logs/”中的TacoCloud.log文件中。logging.path和logging.file文件可以按照如下形式进行设置:

1
2
3
4
5
6
7
8
logging:
path: /var/logs/
file: TacoCloud.log
level:
root: WARN
org:
springframework:
security: DEBUG

假设应用具有“/var/logs/”目录的写入权限,那么日志条目会写入到“/var/logs/ TacoCloud.log”文件中,默认情况下,日志文件一旦达到10MB,就会轮换。

5.1.5 使用特定的属性值

在设置属性的时候,我们并非必须要将它们的值设置为硬编码的String或数值。其实,我们还可以从其他的配置属性派生值。

例如,假设(不管基于什么原因)我们想要设置一个名为greeting.welcome的属性,它的值来源于名为spring.application.name的另一个属性。为了实现该功能,在设置greeting.welcome的时候,我们可以使用${}占位符标记:

1
2
greeting:
welcome: ${spring.application.name}

我们甚至可以将占位符嵌入到其他文本中:

1
2
greeting:
welcome: You are using ${spring.application.name}.

我们可以看到,在配置Spring自己的组件时,使用配置属性可以很容易地将值注入这些组件属性中,并且可以细粒度地调整自动配置功能。配置属性并不专属于Spring创建的bean。我们稍微下点功夫就可以在自己的bean中使用配置属性功能。接下来,让我们看一下如何实现。

第5章 使用配置属性

本章内容:

  • 细粒度的自动配置bean
  • 将配置属性用到应用组件上
  • 使用Spring profile

你还记得iPhone刚刚推出时的场景吗?它只是一小块由金属和玻璃组成的板子,完全不符合人们之前对于手机的认知。但是,它开创了现代智能手机的时代,完全改变了通信的方式。尽管触控手机比上一代的翻盖手机在很多方面都更加简单,功能也更强大,但是当iPhone第一次发布的时候,很难想象只有一个按钮的设备该如何用来打电话。

从某种程度上来讲,Spring Boot的自动配置与之类似。自动配置能够极大地简化Spring应用的开发。十多年来,我们都是使用Spring XML设置属性值,然后调用bean实例的setter方法,在使用自动配置之后,我们突然发现在没有显式配置的情况下,如何为bean设置属性变得不那么显而易见了。

幸好,Spring Boot提供了配置属性(configuration property)的方法。其实,配置属性只是Spring应用上下文中bean的属性而已,它们可以通过多个源进行设置,包括JVM系统属性、命令行参数以及环境变量。

在本章中,我们暂缓实现Taco Cloud应用的新特性,将目光转向配置属性的功能。不过,当我们在后面的章节继续实现新特性时,你会发现所学的内容无疑都是有用的。我们首先看一下如何使用配置属性来微调Spring Boot的自动配置。

4.5 小结

  • Spring Security的自动配置是实现基本安全性功能的好办法,但是大多数的应用都需要显式的安全配置,这样才能满足特定的安全需求。
  • 用户详情可以通过用户存储进行管理,它的后端可以是关系型数据库、LDAP或完全自定义实现。
  • Spring Security会自动防范CSRF攻击。
  • 已认证用户的信息可以通过SecurityContext对象(该对象可由SecurityContextHolder. getContext()返回)来获取,也可以借助@AuthenticationPrincipal注解将其注入到控制器中。

本章内容:

4.4 了解用户是谁

通常,仅仅知道用户已登录是不够的,我们一般还需要知道他们是谁,这样才能优化体验。

例如,在OrderController中,在最初创建Order的时候会绑定一个订单的表单,如果我们能够预先将用户的姓名和地址填充到Order中就好了,这样用户就不需要为每个订单都重新输入这些信息了。也许更重要的是,在保存订单的时候应该将Order实体与创建该订单的用户关联起来。

为了在Order实体和User实体之间实现所需的关联,我们需要为Order类添加一个新的属性:

1
2
3
4
5
6
7
8
9
@Data
@Entity
@Table(name="Taco_Order")
public class Order implements Serializable {
...
@ManyToOne
private User user;
...
}

这个属性上的@ManyToOne注解表明一个订单只能属于一个用户,但是,一个用户却可以有多个订单。因为我们使用了Lombok,所以不需要为该属性显式定义访问器方法。

在OrderController中,processOrder()方法负责保存订单。这个方法需要修改以便于确定当前的认证用户是谁,并且要调用Order对象的setUser()方法来建立订单和用户之间的关联。

我们有多种方式确定用户是谁,常用的方式如下:

  • 注入Principal对象到控制器方法中;
  • 注入Authentication对象到控制器方法中;
  • 使用SecurityContextHolder来获取安全上下文;
  • 使用@AuthenticationPrincipal注解来标注方法。

举例来说,我们可以修改processOrder()方法,让它接受一个java.security.Principal类型的参数。然后,我们就可以使用Principal的名称从UserRepository中查找用户了:

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

@Data
@Entity
@Table(name="Taco_Order")
public class Order implements Serializable {
...
@ManyToOne
private User user;
...
}
@PostMapping
public String processOrder(@Valid Order order, Errors errors,
SessionStatus sessionStatus,
Principal principal) {
...
User user = userRepository.findByUsername(
principal.getName());
order.setUser(user);
...
}

这种方式能够正常运行,但是它会在与安全无关的功能中掺杂安全性的代码。我们可以修改processOrder()方法,让它不再接受Principal参数,而是接受Authentication对象作为参数:

1
2
3
4
5
6
7
8
9
@PostMapping
public String processOrder(@Valid Order order, Errors errors,
SessionStatus sessionStatus,
Authentication authentication) {
...
User user = (User) authentication.getPrincipal();
order.setUser(user);
...
}

有了Authentication对象之后,我们就可以调用getPrincipal()来获取principal对象,在本例中,也就是一个User对象。需要注意,getPrincipal()返回的是java.util.Object,所以我们需要将其转换成User。

最整洁的方案可能是在processOrder()中直接接受一个User对象,不过我们需要为其添加@AuthenticationPrincipal注解,这样它才会变成认证的principal:

1
2
3
4
5
6
7
8
9
10
11
12
@PostMapping
public String processOrder(@Valid Order order, Errors errors,
SessionStatus sessionStatus,
@AuthenticationPrincipal User user) {
if (errors.hasErrors()) {
return "orderForm";
}
order.setUser(user);
orderRepo.save(order);
sessionStatus.setComplete();
return "redirect:/";
}

@AuthenticationPrincipal非常好的一点在于它不需要类型转换(前文中的Authentication则需要进行类型转换),同时能够将安全相关的代码仅仅局限于注解本身。在processOrder()得到User对象之后,我们就可以使用它并赋值给Order了。

还有另外一种方式能够识别当前认证用户是谁,但是这种方式有点麻烦,它会包含大量安全性相关的代码。我们可以从安全上下文中获取一个Authentication对象,然后像下面这样获取它的principal:

1
2
3
Authentication authentication =
SecurityContextHolder.getContext().getAuthentication();
User user = (User) authentication.getPrincipal();

尽管这个代码片段充满了安全性相关的代码,但是它与前文所述的其他方法相比有一个优势:它可以在应用程序的任何地方使用,而不仅仅是在控制器的处理器方法中。这使得它非常适合在较低级别的代码中使用。

4.3 保护Web请求

Taco Cloud的安全性需求是用户在设计taco和提交订单之前必须要经过认证。但是,主页、登录页和注册页应该对未认证的用户开放。

为了配置这些安全性规则,需要介绍一下WebSecurityConfigurerAdapter的其他configure()方法:

1
2
3
4
@Override
protected void configure(HttpSecurity http) throws Exception {
...
}

configure()方法接受一个HttpSecurity对象,能够用来配置在Web级别该如何处理安全性。我们可以使用HttpSecurity配置的功能包括:

  • 在为某个请求提供服务之前,需要预先满足特定的条件;
  • 配置自定义的登录页;
  • 支持用户退出应用;
  • 预防跨站请求伪造。

配置HttpSecurity常见的需求就是拦截请求以确保用户具备适当的权限。接下来,我们会确保Taco Cloud的顾客能够满足这些需求。

4.3.1 保护请求

我们需要确保只有认证过的用户才能发起对“/design”和“/orders”的请求,而其他请求对所有用户均可用。如下的configure()实现就能实现这一点:

1
2
3
4
5
6
7
8
9
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/design", "/orders")
.hasRole("ROLE_USER")
.antMatchers(“/”, "/**").permitAll()
;
}

对authorizeRequests()的调用会返回一个对象(ExpressionInterceptUrlRegistry),基于它我们可以指定URL路径和这些路径的安全需求。在本例中,我们指定了两条安全规则:

  • 具备ROLE_USER权限的用户才能访问“/design”和“/orders”;
  • 其他的请求允许所有用户访问。

这些规则的顺序是很重要的。声明在前面的安全规则比后面声明的规则有更高的优先级。如果我们交换这两个安全规则的顺序,那么所有的请求都会有permitAll()的规则,对“/design”和“/orders”声明的规则就不会生效了。

在声明请求路径的安全需求时,hasRole()和permitAll()只是众多方法中的两个。表4.1列出了所有可用的方法。

表4.1 用来定义如何保护路径的配置方法

epub_29101559_32

表4.1中的大多数方法为请求处理提供了基本的安全的规则,但它们是自我限制的,也就是只能支持由这些方法所定义的安全规则。除此之外,我们还可以使用access()方法,通过为其提供SpEL表达式来声明更丰富的安全规则。SpringSecurity扩展了SpEL,包含了多个安全相关的值和函数,如表4.2所示。

表4.2 Spring Security对Spring表达式语言的扩展

epub_29101559_33

我们可以看到,表4.2中大多数的安全规则都对应表4.1中类似的方法。实际上,借助access()方法和hasRole()、permitAll表达式,我们可以将configure()重写为程序清单4.9所示的形式:

程序清单4.9 使用Spring表达式来定义认证规则

1
2
3
4
5
6
7
8
9
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/design", "/orders")
.access("hasRole('ROLE_USER')")
.antMatchers(“/”, "/**").access("permitAll")
;
}

看上去,这似乎也没什么大不了的。毕竟,这些表达式只是模拟了我们之前通过方法调用已经完成的事情。但是,表达式可以更加灵活。例如,假设(基于某些疯狂的原因)我们只允许具备ROLE_USER权限的用户在星期二创建新taco(我们可以将其称为Taco Tuesday),这样就可以重写表达式。如下的代码展现了已修改的configure():

1
2
3
4
5
6
7
8
9
10
11
12
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/design", "/orders")
.access("hasRole('ROLE_USER') && " +
"T(java.util.Calendar).getInstance().get("+
"T(java.util.Calendar).DAY_OF_WEEK) == " +
"T(java.util.Calendar).TUESDAY")
.antMatchers(“/”, "/**").access("permitAll")
;
}

我们可以使用SpEL实现各种各样的安全性限制。我敢打赌,你已经在想象基于SpEL所能实现的那些有趣的安全性限制了。

Taco Cloud应用的权限可以通过简单使用access()和SpEL表达式来实现,如程序清单4.9所示。现在,我们看一下如何自定义登录页以适应Taco Cloud应用的外观。

4.3.2 创建自定义的登录页

默认的登录页已经比最初丑陋的HTTP basic认证对话框好了很多,但是它依然非常简单,并且与Taco Cloud应用其他部分的外观不搭配。

为了替换内置的登录页,我们首先需要告诉Spring Security自定义登录页的路径是什么。这可以通过调用传入到configure()中的HttpSecurity对象的formLogin()方法来实现:

1
2
3
4
5
6
7
8
9
10
11
12
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/design", "/orders")
.access("hasRole('ROLE_USER')")
.antMatchers(“/”, "/**").access("permitAll")
.and()
.formLogin()
.loginPage("/login")
;
}

请注意,在调用formLogin()之前,我们通过and()方法将这一部分的配置与前面的配置连接在一起。and()方法表示我们已经完成了授权相关的配置,并且要添加一些其他的HTTP配置。在开始新的配置区域时,我们可以多次调用and()。

在这个连接之后,我们调用formLogin()开始配置自定义的登录表单。在此之后,对loginPage()的调用声明了我们提供的自定义登录页面的路径。当SpringSecurity断定用户没有认证并且需要登录的时候,它就会将用户重定向到该路径。

现在,我们需要有一个控制器来处理对该路径的请求。因为我们的登录页非常简单,只有一个视图,没有其他内容,所以我们可以很简单地在WebConfig中将其声明为一个视图控制器。在映射到“/”的主页控制器基础之上,如下的addViewControllers()方法声明了登录页面的视图控制器:

1
2
3
4
5
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/").setViewName("home");
registry.addViewController("/login");
}

最后,我们需要自己定义登录页的视图。我们目前使用了Thymeleaf作为模板引擎,所以如下的Thymeleaf就能实现我们的要求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org">
<head>
<title>Taco Cloud</title>
</head>
<body>
<h1>Login</h1>
<img th:src="@{/images/TacoCloud.png}"/>
<div th:if="${error}">
Unable to login. Check your username and password.
</div>
<p>New here? Click
<a th:href="@{/register}">here</a> to register.</p>
<!-- tag::thAction[] -->
<form method="POST" th:action="@{/login}" id="loginForm">
<!-- end::thAction[] -->
<label for="username">Username: </label>
<input type="text" name="username" id="username" /><br/>
<label for="password">Password: </label>
<input type="password" name="password" id="password" /><br/>
<input type="submit" value="Login"/>
</form>
</body>
</html>

这个登录页需要关注的事情就是表单提交到了什么地方以及用户名和密码输入域的名称。默认情况下,Spring Security会在“/login”路径监听登录请求并且预期的用户名和密码输入域的名称为username和password。但这都是可配置的,举例来说,如下的配置自定义了路径和输入域的名称:

1
2
3
4
5
6
.and()
.formLogin()
.loginPage("/login")
.loginProcessingUrl("/authenticate")
.usernameParameter("user")
.passwordParameter("pwd")

在这里,我们声明Spring Security要监听对“/authenticate”的请求来处理登录信息的提交。同时,用户名和密码的字段名应该是user和pwd。

默认情况下,登录成功之后,用户将会被导航到Spring Security决定让用户登录之前的页面。如果用户直接访问登录页,那么登录成功之后用户将会被导航至根路径(例如,主页)。但是,我们可以通过指定默认的成功页来更改这种行为:

1
2
3
4
.and()
.formLogin()
.loginPage("/login")
.defaultSuccessUrl("/design")

按照这个配置,用户直接导航至登录页并且成功登录之后将会被定向到“/design”页面。

另外,我们还可以强制要求用户在登录成功之后统一访问设计页面,即便用户在登录之前正在访问其他页面,在登录之后也会被定向到设计页面,这可以通过为defaultSuccessUrl方法传递第二个参数true来实现:

1
2
3
4
.and()
.formLogin()
.loginPage("/login")
.defaultSuccessUrl("/design", true)

现在,我们已经完成了自定义的登录页面,接下来我们关注认证功能的另一面,那就是如何让用户退出应用。

4.3.3 退出

退出和应用的登录是同等重要的。为了启用退出功能,我们只需在HttpSecurity对象上调用logout方法:

1
2
3
.and()
.logout()
.logoutSuccessUrl("/")

这样会搭建一个安全过滤器,该过滤器会拦截对“/logout”的请求。所以,为了提供退出功能,我们需要为应用的视图添加一个退出表单和按钮:

1
2
3
<form method="POST" th:action="@{/logout}">
<input type="submit" value="Logout"/>
</form>

当用户点击按钮的时候,他们的session将会被清理,这样他们就退出应用了。默认情况下,用户会被重定向到登录页面,这样他们可以重新登录。但是,如果你想要将他们导航至不同的页面,那么可以调用logoutSuccessUrl()指定退出后的不同页面:

1
2
3
.and()
.logout()
.logoutSuccessUrl("/")

在本例中,用户在退出之后将会回到主页。

4.3.4 防止跨站请求伪造

跨站请求伪造(Cross-Site Request Forgery,CSRF)是一种常见的安全攻击。它会让用户在一个恶意的Web页面上填写信息,然后自动(通常是秘密的)将表单以攻击受害者的身份提交到另外一个应用上。例如,用户看到一个来自攻击者的Web站点的表单,这个站点会自动将数据POST到用户银行Web站点的URL上(这个站点可能设计得很糟糕,无法防御这种类型的攻击),实现转账的操作。用户可能根本不知道发生了攻击,直到他们发现账号上的钱已经不翼而飞。

为了防止这种类型的攻击,应用可以在展现表单的时候生成一个CSRF token,并放到隐藏域中,然后将其临时存储起来,以便后续在服务器上使用。在提交表单的时候,token将和其他的表单数据一起发送至服务器端。请求会被服务器拦截,并与最初生成的token进行对比。如果token匹配,那么请求将会允许处理;否则,表单肯定是由恶意网站渲染的,因为它不知道服务器所生成的token。

比较幸运的是,Spring Security提供了内置的CSRF保护。更幸运的是,默认它就是启用的,我们不需要显式配置它。我们唯一需要做的就是确保应用中的每个表单都要有一个名为“_csrf”的字段,它会持有CSRF token。

Spring Security甚至进一步简化了将token放到请求的“_csrf”属性中这一任务。在Thymeleaf模板中,我们可以按照如下的方式在隐藏域中渲染CSRF token:

1
<input type="hidden" name="_csrf" th:value="${_csrf.token}"/>

如果你使用Spring MVC的JSP标签库或者Spring Security的Thymeleaf方言,那么甚至都不用明确包含这个隐藏域(这个隐藏域会自动生成)。

在Thymeleaf中,我们只需要确保<form>的某个属性带有Thymeleaf属性前缀即可。通常这并不是什么问题,因为我们一般会使用Thymeleaf渲染相对于上下文的路径。例如,为了让Thymeleaf渲染隐藏域,我们只需要使用th:action属性就可以了:

1
<form method="POST" th:action="@{/login}" id="loginForm">

我们还可以禁用Spring Security对CSRF的支持,但是我很犹豫是否要为你们展现这个功能。CSRF的防护非常重要,并且很容易在表单中实现,所以我们没有理由禁用它。但是,如果你坚持要禁用,那么可以通过调用disable()来实现:

1
2
3
.and()
.csrf()
.disable()

再次强调,不要禁用CSRF防护,对于生产环境的应用来说更是如此。

Taco Cloud应用所有Web层的安全性都已经配置好了。除此之外,我们还有了一个自定义的登录页并且能够通过基于JPA的用户repository来认证用户。接下来,我们看一下如何获取已登录用户的信息。

4.2 配置Spring Security

多年以来,出现了多种配置Spring Security的方式,包括冗长的基于XML的配置。幸运的是,最近几个版本的Spring Security都支持基于Java的配置,这种方式更加易于阅读和编写。

在本章结束之前,我们会使用基于Java的Spring Security配置完成Taco Cloud安全性需要的所有设置。但是,首先,我们需要编写程序清单4.1中这个基础的配置类。
程序清单4.1 Spring Security的基础配置类

1
2
3
4
5
6
7
8
9
10
11
package tacos.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web
.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web
.configuration.WebSecurityConfigurerAdapter;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
}

这个基础的安全配置都为我们做了些什么呢?其实并不太多,但是它确实朝着我们所需的安全性需求向前推进了一步。如果此时你再次访问Taco Cloud主页,那么系统依然会提示你进行登录。但是,现在不再是提示HTTP basic认证的对话框,而是会展现如图4.2所示的登录表单。

epub_29101559_31

图4.2 Spring Security为我们免费提供的简单登录页
提示:在进行手动安全测试的时候,你会发现将浏览器设置为私有或隐身模式会非常有用。这能够确保每次打开一个私有/隐身窗口都会有一个新的会话。每次你都需要重新登录应用,但是你尽可以放心,在安全性方面做得所有变更都会生效,旧会话不会有任何残留,妨碍我们看到变更的效果。

这是一个很小的改进,通过Web页面提示登录(尽管看上去非常简陋)要比HTTPbasic对话框更友好一些。在4.3.2小节,我们将会自定义登录页面。不过,我们现在的任务是配置用户存储,使系统能够处理多个用户。

事实上,Spring Security为配置用户存储提供了多个可选方案,包括:

  • 基于内存的用户存储;
  • 基于JDBC的用户存储;
  • 以LDAP作为后端的用户存储;
  • 自定义用户详情服务。

不管使用哪种用户存储,你都可以通过覆盖WebSecurityConfigurerAdapter基础配置类中定义的configure()方法来进行配置。首先,我们可以将如下的方法添加到SecurityConfig类中:

1
2
3
4
5
@Override
protected void configure(AuthenticationManagerBuilder auth)
throws Exception {
...
}

现在,我们需要使用指定的AuthenticationManagerBuilder替换上面的省略号,以此来定义在认证过程中如何查找用户。我们先来看一下基于内存的用户存储。

4.2.1 基于内存的用户存储

用户信息可以存储在内存之中。假设我们只有数量有限的几个用户,而且这些用户几乎不会发生变化,在这种情况下,将这些用户定义成安全配置的一部分是非常简单的。

例如,程序清单4.2展示了如何在内存用户存储中配置两个用户,即“buzz”和“woody”。

程序清单4.2 在内存用户存储中定义用户

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
protected void configure(AuthenticationManagerBuilder auth)
throws Exception {
auth
.inMemoryAuthentication()
.withUser("buzz")
.password("infinity")
.authorities("ROLE_USER")
.and()
.withUser("woody")
.password("bullseye")
.authorities("ROLE_USER");
}

我们可以看到,AuthenticationManagerBuilder使用构造者(builder)风格的接口来构建认证细节。在本例中,我们在安全配置中调用inMemoryAuthentication()方法来指定用户信息。

每次调用withUser()都会配置一个用户,这个方法给定的值是用户名,而密码和授权信息是通过password()和authorities()方法来指定的。如程序清单4.2中所示,两个用户都授予了ROLE_USER权限。用户buzz的密码为“infinity”,而woody的密码为“bullseye”。

对于测试和简单的应用来讲,基于内存的用户存储是很有用的,但是这种方式不能很方便地编辑用户。如果需要新增、移除或变更用户,那么你要对代码做出必要的修改,然后重新构建和部署应用。

对于Taco Cloud应用来说,我们希望顾客能够在应用中进行注册,并且能够管理自己的用户账号。这明显与内存用户存储的限制不符,所以我们接下来看一下另外一种方式,这种方式允许使用数据库后端作为用户存储。

4.2.2 基于JDBC的用户存储

用户信息通常会在关系型数据库中进行维护,基于JDBC的用户存储方案会更加合理一些。程序清单4.3展示了使用JDBC对存储在关系型数据库中的用户信息进行认证所需的Spring Security配置。

程序清单4.3 基于JDBC用户存储进行认证

1
2
3
4
5
6
7
8
9
@Autowired
DataSource dataSource;
@Override
protected void configure(AuthenticationManagerBuilder auth)
throws Exception {
auth
.jdbcAuthentication()
.dataSource(dataSource);
}

在这里的configure()实现中,调用了AuthenticationManagerBuilder的jdbcAuthentication()方法。我们必须还要设置一个DataSource,这样它才能知道如何访问数据库。这里的DataSource是通过自动装配的技巧获取到的。

重写默认的用户查询功能

尽管最少配置能够让一切运转起来,但是它对我们的数据库模式有一些要求,预期某些存储用户数据的表已经存在。更具体来说,下面的代码片段来源于SpringSecurity内部,并展现了当查找用户信息时所执行的SQL查询语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static final String DEF_USERS_BY_USERNAME_QUERY =
"select username,password,enabled " +
"from users " +
"where username = ?";
public static final String DEF_AUTHORITIES_BY_USERNAME_QUERY =
"select username,authority " +
"from authorities " +
"where username = ?";
public static final String DEF_GROUP_AUTHORITIES_BY_USERNAME_QUERY =
"select g.id, g.group_name, ga.authority " +
"from groups g, group_members gm, group_authorities ga " +
"where gm.username = ? " +
"and g.id = ga.group_id " +
"and g.id = gm.group_id";

在第一个查询中,我们获取了用户的用户名、密码以及是否启用的信息,用来进行用户认证。接下来的查询查找了用户所授予的权限,用来进行鉴权。在最后一个查询中,查找了用户作为群组的成员所授予的权限。

如果你能够在数据库中定义和填充满足这些查询的表,那么基本上就不需要再做什么额外的事情了。但是,也有可能你的数据库与上述的不一致,那么你会希望在查询上有更多的控制权。如果是这样,那么我们可以按照程序清单4.4所示的方式配置自己的查询:

程序清单4.4 自定义用户详情查询

1
2
3
4
5
6
7
8
9
10
11
12
13
@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=?");
}

在本例中,我们只重写了认证和基本权限的查询语句,但是通过调用groupAuthorities ByUsername()方法,我们也能够将群组权限重写为自定义的查询语句。

将默认的SQL查询替换为自定义的设计时,很重要的一点就是要遵循查询的基本协议。所有查询都将用户名作为唯一的参数。认证查询会选取用户名、密码以及启用状态信息。权限查询会选取零行或多行包含该用户名及其权限信息的数据。群组权限查询会选取零行或多行数据,每行数据中都会包含群组ID、群组名称以及权限。

使用转码后的密码

看一下上面的认证查询,它预期用户密码存储在了数据库之中。这里唯一的问题在于如果密码明文存储,就很容易受到黑客的窃取。但是,如果数据库中的密码进行了转码,那么认证就会失败,因为它与用户提交的明文密码并不匹配。

为了解决这个问题,我们需要借助passwordEncoder()方法指定一个密码转码器(encoder):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@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 StandardPasswordEncoder("53cr3t");
}

passwordEncoder()方法可以接受Spring Security中PasswordEncoder接口的任意实现。Spring Security的加密模块包括了多个这样的实现。

  • BCryptPasswordEncoder:使用bcrypt强哈希加密。
  • NoOpPasswordEncoder:不进行任何转码。
  • Pbkdf2PasswordEncoder:使用PBKDF2加密。
  • SCryptPasswordEncoder:使用scrypt哈希加密。
  • StandardPasswordEncoder:使用SHA-256哈希加密。

上述的代码中使用了StandardPasswordEncoder,但是你可以使用任意一个实现,如果内置的实现无法满足需求时,你甚至可以提供自定义的实现。PasswordEncoder接口非常简单:

1
2
3
4
public interface PasswordEncoder {
String encode(CharSequence rawPassword);
boolean matches(CharSequence rawPassword, String encodedPassword);
}

不管你使用哪一个密码转码器,都需要理解一点,即数据库中的密码是永远不会解码的。用户在登录时所采取的策略与之相反,输入的密码会按照相同的算法进行转码,然后与数据库中已经转码过的密码进行对比。这个对比是在PasswordEncoder的matches()方法中进行的。

最终,我们实现了在数据库中维护Taco Cloud用户数据。但是,我并没有采用jdbcAuthentication(),因为我想到了另外一种认证方案。在介绍该方案之前,我们先看一下如何配置Spring Security依赖另一种通用的用户数据源:使用LDAP(Lightweight Directory Access Protocol,轻量级目录访问协议)访问的用户存储。

4.2.3 以LDAP作为后端的用户存储

为了配置Spring Security使用基于LDAP认证,我们可以使用ldapAuthentication()方法。这个方法在功能上类似于jdbcAuthentication(),只不过是LDAP版本。如下的configure()方法展现了LDAP认证的简单配置:

1
2
3
4
5
6
7
8
@Override
protected void configure(AuthenticationManagerBuilder auth)
throws Exception {
auth
.ldapAuthentication()
.userSearchFilter("(uid={0})")
.groupSearchFilter("member={0}");
}

方法userSearchFilter()和groupSearchFilter()用来为基础LDAP查询提供过滤条件,它们分别用于搜索用户和组。默认情况下,对于用户和组的基础查询都是空的,也就是表明搜索会在LDAP层级结构的根开始。但是我们可以通过指定查询基础来改变这个默认行为:

1
2
3
4
5
6
7
8
9
10
@Override
protected void configure(AuthenticationManagerBuilder auth)
throws Exception {
auth
.ldapAuthentication()
.userSearchBase("ou=people")
.userSearchFilter("(uid={0})")
.groupSearchBase("ou=groups")
.groupSearchFilter("member={0}");
}

userSearchBase()方法为查找用户提供了基础查询。同样的,groupSearchBase()为查找组指定了基础查询。我们声明用户应该在名为people的组织单元下搜索而不是从根开始,而组应该在名为groups的组织单元下搜索。

配置密码比对

基于LDAP认证的默认策略是进行绑定操作,直接通过LDAP服务器认证用户。另一种可选的方式是进行比对操作。这涉及将输入的密码发送到LDAP目录上,并要求服务器将这个密码和用户的密码进行比对。因为比对是在LDAP服务器内完成的,实际的密码能保持私密。

如果你希望通过密码比对进行认证,可以通过声明passwordCompare()方法来实现:

1
2
3
4
5
6
7
8
9
10
11
@Override
protected void configure(AuthenticationManagerBuilder auth)
throws Exception {
auth
.ldapAuthentication()
.userSearchBase("ou=people")
.userSearchFilter("(uid={0})")
.groupSearchBase("ou=groups")
.groupSearchFilter("member={0}")
.passwordCompare();
}

默认情况下,在登录表单中提供的密码将会与用户的LDAP条目中的userPassword属性进行比对。如果密码被保存在不同的属性中,可以通过passwordAttribute()方法来声明密码属性的名称:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
protected void configure(AuthenticationManagerBuilder auth)
throws Exception {
auth
.ldapAuthentication()
.userSearchBase("ou=people")
.userSearchFilter("(uid={0})")
.groupSearchBase("ou=groups")
.groupSearchFilter("member={0}")
.passwordCompare()
.passwordEncoder(new BCryptPasswordEncoder())
.passwordAttribute("passcode");
}

在本例中,我们指定了要与给定密码进行比对的是“passcode”属性。另外,我们还可以指定密码转码器。在进行服务器端密码比对时,有一点非常好,那就是实际的密码在服务器端是私密的。但是进行尝试的密码还是需要通过线路传输到LDAP服务器上,这可能会被黑客所拦截。为了避免这一点,我们可以通过调用passwordEncoder()方法指定加密策略。

在前面的例子中,密码使用bcrypt密码哈希函数加密。这需要LDAP服务器上的密码也使用bcrypt进行了加密。

引用远程的LDAP服务器

到目前为止,我们忽略的一件事就是LDAP和实际的数据在哪里。我们很开心地配置Spring使用LDAP服务器进行认证,但是服务器在哪里呢?

默认情况下,Spring Security的LDAP认证假设LDAP服务器监听本机的33389端口。但是,如果你的LDAP服务器在另一台机器上,那么可以使用contextSource()方法来配置这个地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
protected void configure(AuthenticationManagerBuilder auth)
throws Exception {
auth
.ldapAuthentication()
.userSearchBase("ou=people")
.userSearchFilter("(uid={0})")
.groupSearchBase("ou=groups")
.groupSearchFilter("member={0}")
.passwordCompare()
.passwordEncoder(new BCryptPasswordEncoder())
.passwordAttribute("passcode")
.contextSource()
.url("ldap://tacocloud.com:389/dc=tacocloud,dc=com");
}

contextSource()方法会返回一个ContextSourceBuilder对象,这个对象除了其他功能以外,还提供了url()方法来指定LDAP服务器的地址。

配置嵌入式的LDAP服务器

如果你没有现成的LDAP服务器供认证使用,Spring Security还为我们提供了嵌入式的LDAP服务器。我们不再需要设置远程LDAP服务器的URL,只需通过root()方法指定嵌入式服务器的根前缀就可以了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
protected void configure(AuthenticationManagerBuilder auth)
throws Exception {
auth
.ldapAuthentication()
.userSearchBase("ou=people")
.userSearchFilter("(uid={0})")
.groupSearchBase("ou=groups")
.groupSearchFilter("member={0}")
.passwordCompare()
.passwordEncoder(new BCryptPasswordEncoder())
.passwordAttribute("passcode")
.contextSource()
.root("dc=tacocloud,dc=com");
}

当LDAP服务器启动时,它会尝试在类路径下寻找LDIF文件来加载数据。LDIF(LDAP Data Interchange Format,LDAP数据交换格式)是以文本文件展现LDAP数据的标准方式。每条记录可以有一行或多行,每项包含一个name:value配对信息。记录之间通过空行进行分割。

如果你不想让Spring从整个根路径下搜索LDIF文件,那么可以通过调用ldif()方法来明确指定加载哪个LDIF文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
protected void configure(AuthenticationManagerBuilder auth)
throws Exception {
auth
.ldapAuthentication()
.userSearchBase("ou=people")
.userSearchFilter("(uid={0})")
.groupSearchBase("ou=groups")
.groupSearchFilter("member={0}")
.passwordCompare()
.passwordEncoder(new BCryptPasswordEncoder())
.passwordAttribute("passcode")
.contextSource()
.root("dc=tacocloud,dc=com")
.ldif("classpath:users.ldif");
}

在这里,我们明确要求LDAP服务器从类路径根目录下的users.ldif文件中加载内容。如果你比较好奇,如下就是一个包含用户数据的LDIF文件,我们可以使用它来加载嵌入式LDAP服务器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
dn: ou=groups,dc=tacocloud,dc=com
objectclass: top
objectclass: organizationalUnit
ou: groups
dn: ou=people,dc=tacocloud,dc=com
objectclass: top
objectclass: organizationalUnit
ou: people
dn: uid=buzz,ou=people,dc=tacocloud,dc=com
objectclass: top
objectclass: person
objectclass: organizationalPerson
objectclass: inetOrgPerson
cn: Buzz Lightyear
sn: Lightyear
uid: buzz
userPassword: password
dn: cn=tacocloud,ou=groups,dc=tacocloud,dc=com
objectclass: top
objectclass: groupOfNames
cn: tacocloud
member: uid=buzz,ou=people,dc=tacocloud,dc=com

Spring Security内置的用户存储非常便利,并且涵盖了最为常用的用户场景。但是,我们的Taco Cloud应用需要一些特殊的功能。当开箱即用的用户存储无法满足需求的时候,我们就需要创建和配置自定义的用户详情服务。

4.2.4 自定义用户认证

在上一章中,我们采用Spring Data JPA作为所有taco、配料和订单数据的持久化方案。所以,采用相同的方式来持久化用户数据是非常有意义的。如果这样做,数据最终应该位于关系型数据库之中。因此,我们可以使用基于JDBC的认证,但更好的办法是使用Spring Data repository来存储用户。

要事优先,在此之前,我们首先要创建领域对象,以及展现和持久化用户信息的repository接口。

定义用户领域对象和持久化

当Taco Cloud的顾客注册应用的时候,它们需要提供除用户名和密码之外的更多信息。他们会提供全名、地址和电话号码。这些信息可以用于各种目的,包括预先填充表单(更不用说潜在的市场销售机会)。

为了捕获这些信息,我们要创建程序清单4.5所示的User类:

程序清单4.5 定义用户实体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
package tacos;
import java.util.Arrays;
import java.util.Collection;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.
SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import lombok.AccessLevel;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
@Entity
@Data
@NoArgsConstructor(access=AccessLevel.PRIVATE, force=true)
@RequiredArgsConstructor
public class User implements UserDetails {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy=GenerationType.AUTO)
private Long id;
private final String username;
private final String password;
private final String fullname;
private final String street;
private final String city;
private final String state;
private final String zip;
private final String phoneNumber;
@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;
}
}

你可能也发现了,User类要比第3章所定义的实体都更加复杂。除了定义了一些属性之外,User类还实现了Spring Security的UserDetails接口。

通过实现UserDetails接口,我们能够提供更多信息给框架,比如用户都被授予了哪些权限以及用户的账号是否可用。

getAuthorities()方法应该返回用户被授予权限的一个集合。各种is…Expired()方法要返回一个boolean值,表明用户的账号是否可用或过期。

对于User实体来说,getAuthorities()方法只是简单地返回一个集合,这个集合表明所有的用户都被授予了ROLE_USER权限。至少就现在来说,Taco Cloud没有必要禁用用户,所以所有的is…Expired()方法均返回true,表明用户是处于活跃状态的。

User实体定义完之后,我们就可以定义repository接口了:

1
2
3
4
5
6
package tacos.data;
import org.springframework.data.repository.CrudRepository;
import tacos.User;
public interface UserRepository extends CrudRepository<User, Long> {
User findByUsername(String username);
}

除了扩展CrudRepository所得到的CRUD操作之外,UserRepository接口还定义了一个findByUsername()方法(将会在用户详情服务中用到,以便于根据用户名查找User)。

就像我们在第3章中所学到的那样,Spring Data JPA会在运行时自动生成这个接口的实现。所以,我们现在就可以编写使用该repository的用户详情接口了。

创建用户详情服务

Spring Security的UserDetailsService是一个相当简单直接的接口:

1
2
3
4
public interface UserDetailsService {
UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException;
}

正如我们所看到的,这个接口的实现会得到一个用户的用户名,并且要么返回查找到的UserDetails对象,要么在根据用户名无法得到任何结果的情况下抛出Username NotFoundException。

因为我们的User类实现了UserDetails,并且UserRepository提供了findByUsername()方法,所以它们非常适合用在UserDetailsService实现中。程序清单4.6展现了Taco Cloud应用中将会用到的用户详情服务。

程序清单4.6 声明自定义的用户详情服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package tacos.security;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.
UserDetailsService;
import org.springframework.security.core.userdetails.
UsernameNotFoundException;
import org.springframework.stereotype.Service;
import tacos.User;
import tacos.data.UserRepository;
@Service
public class UserRepositoryUserDetailsService
implements UserDetailsService {
private UserRepository userRepo;
@Autowired
public UserRepositoryUserDetailsService(UserRepository userRepo) {
this.userRepo = userRepo;
}
@Override
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
User user = userRepo.findByUsername(username);
if (user != null) {
return user;
}
throw new UsernameNotFoundException(
"User '" + username + "' not found");
}
}

UserRepositoryUserDetailsService通过构造器将UserRepository注入进来。然后,在loadByUsername()方法中,它调用了UserRepository的findByUsername()方法来查找User。

loadByUsername()方法有一个简单的规则:它决不能返回null。因此,如果调用findByUsername()返回null,那么loadByUsername()将会抛出UsernameNotFoundException;否则,将会返回查找到的User。

我们注意到UserRepositoryUserDetailsService上添加了@Service。这是Spring的另外一个构造型(stereotype)注解,它表明这个类要包含到Spring的组件扫描中,所以我们不需要再明确将这个类声明为bean了。Spring将会自动发现它并将其初始化为一个bean。

但是,我们依然需要将这个自定义的用户详情服务与Spring Security配置在一起。因此,我们再次回到configure()方法:

1
2
3
4
5
6
7
8
@Autowired
private UserDetailsService userDetailsService;
@Override
protected void configure(AuthenticationManagerBuilder auth)
throws Exception {
auth
.userDetailsService(userDetailsService);
}

在这里,我们只是简单地调用userDetailsService()方法,并将自动装配到SecurityConfig中的UserDetailsService实例传递了进去。

像基于JDBC的认证一样,我们可以(也应该)配置一个密码转码器,这样在数据库中的密码将是转码过的。我们首先需要声明一个PasswordEncoder类型的bean,然后通过调用passwordEncoder()方法将它注入到用户详情服务中:

1
2
3
4
5
6
7
8
9
10
11
@Bean
public PasswordEncoder encoder() {
return new StandardPasswordEncoder("53cr3t");
}
@Override
protected void configure(AuthenticationManagerBuilder auth)
throws Exception {
auth
.userDetailsService(userDetailsService)
.passwordEncoder(encoder());
}

我们讨论一下configure()方法中比较重要的最后一行。看上去,我们调用了encoder()方法,并将返回值传递给passwordEncoder()。实际上,encoder()方法带有@Bean注解,它将用来在Spring应用上下文中声明PasswordEncoderbean。对于encoder()的任何调用都会被拦截,并且会返回应用上下文中的bean实例。

现在,我们已经有了自定义的用户详情服务,它会通过JPA repository读取用户信息,接下来我们需要一种将用户存放到数据库中的办法。为了做到这一点,我们需要为Taco Cloud创建一个注册页面,供用户注册本应用。

注册用户

尽管在安全性方面,Spring Security会为我们处理很多事情,但是它没有直接涉及用户注册的流程,所以我们需要借助Spring MVC的一些技能来完成这个任务。程序清单4.7所示的RegistrationController类会负责展现和处理注册表单。

尽管在安全性方面,Spring Security会为我们处理很多事情,但是它没有直接涉及用户注册的流程,所以我们需要借助Spring MVC的一些技能来完成这个任务。程序清单4.7所示的RegistrationController类会负责展现和处理注册表单。

程序清单4.7 用户注册控制器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package tacos.security;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import tacos.data.UserRepository;
@Controller
@RequestMapping("/register")
public class RegistrationController {
private UserRepository userRepo;
private PasswordEncoder passwordEncoder;
public RegistrationController(
UserRepository userRepo, PasswordEncoder passwordEncoder) {
this.userRepo = userRepo;
this.passwordEncoder = passwordEncoder;
}
@GetMapping
public String registerForm() {
return "registration";
}
@PostMapping
public String processRegistration(RegistrationForm form) {
userRepo.save(form.toUser(passwordEncoder));
return "redirect:/login";
}
}

与很多典型的Spring MVC控制器类似,RegistrationController使用@Controller注解表明它是一个控制器,并且允许组件扫描功能发现它。它还使用了@RequestMapping注解,这样就能处理路径为“/register”的请求了。具体来讲,对“/register”的GET请求会由registerForm()方法来处理,它只是简单地返回一个逻辑视图名registration。程序清单4.8展现了定义registration视图的Thymeleaf模板。

程序清单4.8 注册表单的Thymeleaf视图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org">
<head>
<title>Taco Cloud</title>
</head>
<body>
<h1>Register</h1>
<img th:src="@{/images/TacoCloud.png}"/>
<form method="POST" th:action="@{/register}" id="registerForm">
<label for="username">Username: </label>
<input type="text" name="username"/><br/>
<label for="password">Password: </label>
<input type="password" name="password"/><br/>
<label for="confirm">Confirm password: </label>
<input type="password" name="confirm"/><br/>
<label for="fullname">Full name: </label>
<input type="text" name="fullname"/><br/>
<label for="street">Street: </label>
<input type="text" name="street"/><br/>
<label for="city">City: </label>
<input type="text" name="city"/><br/>
<label for="state">State: </label>
<input type="text" name="state"/><br/>
<label for="zip">Zip: </label>
<input type="text" name="zip"/><br/>
<label for="phone">Phone: </label>
<input type="text" name="phone"/><br/>
<input type="submit" value="Register"/>
</form>
</body>
</html>

当表单提交的时候,processRegistration()方法会处理HTTP POST请求。ProcessRegistration()方法得到的RegistrationForm对象绑定了请求的数据,该类的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package tacos.security;
import org.springframework.security.crypto.password.PasswordEncoder;
import lombok.Data;
import tacos.User;
@Data
public class RegistrationForm {
private String username;
private String password;
private String fullname;
private String street;
private String city;
private String state;
private String zip;
private String phone;
public User toUser(PasswordEncoder passwordEncoder) {
return new User(
username, passwordEncoder.encode(password),
fullname, street, city, state, zip, phone);
}
}

就其大部分内容而言,RegistrationForm就是一个简单的支持Lombok类,具有一些相关的属性。但是,toUser()方法使用这些属性创建了一个新的User对象,processRegistration()使用注入的UserRepository保存了该对象。

你肯定已经发现RegistrationController注入了一个PasswordEncoder,这其实就是我们在前面所声明的PasswordEncoder。在处理表单提交的时候,RegistrationController将其传递给toUser()方法,在将密码保存到数据库之前,会使用它对密码进行转码。通过这种方式,用户的密码可以以转码后的形式写入到数据库中,用户详情服务就能基于转码后的密码对用户进行认证了。

现在,Taco Cloud应用已经有了完整的用户注册和认证功能。但是,如果你现在启动应用,就会发现我们无法进入注册页面,也不会提示进行登录。这是因为在默认情况下,所有的请求都需要认证。接下来,我们看一下Web请求是如何被拦截和保护的,这样我们才能解决这个先有鸡还是先有蛋的诡异问题。

保护Spring应用的第一步就是将Spring Boot security starter依赖添加到构建文件中。在项目的pom.xml文件中,添加如下的<dependency>条目:

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

如果你使用Spring Tool Suite,那么这个过程会更加简单。用鼠标右键点击pom.xml文件并在Spring弹出菜单中选择“Edit Starters”,将会出现“EditSpring Boot Starters”对话框,在“Core”分类中选择“Security”条目,如图4.1所示。

epub_29101559_30

图4.1 使用Spring Tool Suite添加security starter

不管你是否相信,要保护我们的应用,只需添加这项依赖就可以了。当应用启动的时候,自动配置功能会探测到Spring Security出现在了类路径中,因此它会初始化一些基本的安全配置。

如果你想试一下,可以启动应用并尝试访问主页(或者任意页面)。应用将会弹出一个HTTP basic认证对话框并提示你进行认证。要想通过这个认证,你需要一个用户名和密码。用户名为user,而密码则是随机生成的,它会被写入应用的日志文件中。日志条目大致如下所示:

1
Using default security password: 087cfc6a-027d-44bc-95d7-cbb3a798a1ea

假设输入了正确的用户名和密码,你就有权限访问应用了。

看上去保护Spring应用是一项非常简单的任务。现在,Taco Cloud应用已经安全了,我们可以结束本节并进入下一个话题了。但是,在继续下一步之前,我们回顾一下自动配置提供了什么类型的安全性。

通过将security starter添加到项目的构建文件中,我们得到了如下的安全特性:

通过将security starter添加到项目的构建文件中,我们得到了如下的安全特性:

  • 所有的HTTP请求路径都需要认证;
  • 不需要特定的角色和权限;
  • 没有登录页面;
  • 认证过程是通过HTTP basic认证对话框实现的;
  • 系统只有一个用户,用户名为user。

这是一个很好的开端,但是我相信大多数应用(包括Taco Cloud)的安全需求与这些基础的安全特性截然不同。

如果想要确保Taco Cloud应用的安全性,我们还有很多的工作要做。我们至少要配置Spring Security实现如下功能:

  • 通过登录页面来提示用户进行认证,而不是使用HTTP basic对话框;
  • 提供多个用户,并提供一个注册页面,这样Taco Cloud的新用户能够注册进来;
  • 对不同的请求路径,执行不同的安全规则。举例来说,主页和注册页面根本不需要进行认证。

为了满足Taco Cloud的安全需求,我们需要编写一些显式的配置,覆盖掉自动配置为我们提供的功能。我们首先配置一个合适的用户存储,这样就能有多个用户了。

第4章 保护Spring

本章内容:
  • 自动配置Spring Security
  • 设置自定义的用户存储
  • 自定义登录页
  • 防范CSRF攻击
  • 知道用户是谁

有一点不知道你是否在意过,那就是在电视剧中大多数人从不锁门。在Leave it toBeaver热播的时代,人们不锁门这事并不值得大惊小怪,但是在这个隐私和安全被看得极其重要的年代,看到电视剧中的角色允许别人大摇大摆地进入自己的寓所或家中实在让人难以置信。

现在,信息可能是我们最有价值的东西,一些不怀好意的人想尽办法试图偷偷进入不安全的应用程序来窃取我们的数据和身份信息。作为软件开发人员,我们必须采取措施来保护应用程序中的信息。无论你是通过用户名/密码来保护电子邮件账号,还是基于交易PIN来保护经纪账户,安全性都是绝大多数应用系统中的一个重要切面。

3.3 小结

  • Spring的JdbcTemplate能够极大地简化JDBC的使用。
  • 在我们需要知道数据库所生成的ID值时,可以组合使用PreparedStatementCreator和KeyHolder。
  • 为了简化数据的插入,可以使用SimpleJdbcInsert。
  • Spring Data JPA能够极大地简化JPA持久化,我们只需编写repository接口即可。

3.2 使用Spring Data JPA持久化数据

Spring Data是一个非常大的伞形项目,由多个子项目组成,其中大多数子项目都关注对不同的数据库类型进行数据持久化。比较流行的几个Spring Data项目包括:

  • Spring Data JPA:基于关系型数据库进行JPA持久化。
  • Spring Data MongoDB:持久化到Mongo文档数据库。
  • Spring Data Neo4j:持久化到Neo4j图数据库。
  • Spring Data Redis:持久化到Redis key-value存储。
  • Spring Data Cassandra:持久化到Cassandra数据库。

Spring Data为所有项目提供了一项最有趣且最有用的特性,就是基于repository规范接口自动生成repository的功能。

要了解Spring Data是如何运行的,我们需要重新开始,将本章前文基于JDBC的repository替换为使用Spring Data JPA的repository。首先,我们需要将SpringData JPA添加到项目的构建文件中。

3.2.1 添加Spring Data JPA到项目中

Spring Boot应用可以通过JPA starter来添加Spring Data JPA。这个starter依赖不仅会引入Spring Data JPA,还会传递性地将Hibernate作为JPA实现引入进来:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

如果你想要使用不同的JPA实现,那么至少需要将Hibernate依赖排除出去并将你所选择的JPA库包含进来。举例来说,如果想要使用EclipseLink来替代Hibernate,就需要像这样修改构建文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
<exclusions>
<exclusion>
<artifactId>hibernate-entitymanager</artifactId>
<groupId>org.hibernate</groupId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.eclipse.persistence</groupId>
<artifactId>eclipselink</artifactId>
<version>2.5.2</version>
</dependency>

需要注意,根据你所选择的JPA实现,这里可能还需要其他的变更。你可以参考所选择的JPA实现文档以了解更多细节。现在,我们重新看一下领域对象,并为它们添加注解,使其支持JPA持久化。

3.2.2 将领域对象标注为实体

你马上将会看到,在创建repository方面,Spring Data为我们做了很多非常棒的事情。但是,在使用JPA映射注解标注领域对象方面,它却没有提供太多的助益。我们需要打开Ingredient、Taco和Order类,并为其添加一些注解,首先是Ingredient类,如程序清单3.16所示。

程序清单3.16 为Ingredient添加注解使其支持JPA持久化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package tacos;
import javax.persistence.Entity;
import javax.persistence.Id;
import lombok.AccessLevel;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
@Data
@RequiredArgsConstructor
@NoArgsConstructor(access=AccessLevel.PRIVATE, force=true)
@Entity
public class Ingredient {
@Id
private final String id;
private final String name;
private final Type type;
public static enum Type {
WRAP, PROTEIN, VEGGIES, CHEESE, SAUCE
}
}

为了将Ingredient声明为JPA实体,它必须添加@Entity注解。它的id属性需要使用@Id注解,以便于将其指定为数据库中唯一标识该实体的属性。

除了JPA特定的注解,你可能会发现我们在类级别添加了@NoArgsConstructor注解。JPA需要实体有一个无参的构造器,Lombok的@NoArgsConstructor注解能够帮助我们实现这一点。但是,我们不想直接使用它,因此通过将access属性设置为AccessLevel.PRIVATE使其变成私有的。因为这里有必须要设置的final属性,所以我们将force设置为true,这样Lombok生成的构造器就会将它们设置为null。

我们还添加了一个@RequiredArgsConstructor注解。@Data注解会为我们添加一个有参构造器,但是使用@NoArgsConstructor注解之后,这个构造器就会被移除掉。现在,我们显式添加@RequiredArgsConstructor注解,以确保除了private的无参构造器之外,我们还会有一个有参构造器。

接下来,我们看一下程序清单3.17所示的Taco类,看看它是如何标注为JPA实体的。

程序清单3.17 将Taco标注为实体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package tacos;
import java.util.Date;
import java.util.List;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.ManyToMany;
import javax.persistence.OneToMany;
import javax.persistence.PrePersist;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import lombok.Data;
@Data
@Entity
public class Taco {
@Id
@GeneratedValue(strategy=GenerationType.AUTO)
private Long id;
@NotNull
@Size(min=5, message="Name must be at least 5 characters long")
private String name;
private Date createdAt;
@ManyToMany(targetEntity=Ingredient.class)
@Size(min=1, message="You must choose at least 1 ingredient")
private List<Ingredient> ingredients;
@PrePersist
void createdAt() {
this.createdAt = new Date();
}
}

与Ingredient类似,Taco类现在添加了@Entity注解,并为其id属性添加了@Id注解。因为我们要依赖数据库自动生成ID值,所以在这里还为id属性设置了@GeneratedValue,将它的strategy设置为AUTO。

为了声明Taco与其关联的Ingredient列表之间的关系,我们为ingredients添加了@ManyToMany注解。每个Taco可以有多个Ingredient,而每个Ingredient可以是多个Taco的组成部分。

你会看到,在这里有一个新的方法createdAt(),并使用了@PrePersist注解。在Taco持久化之前,我们会使用这个方法将createdAt设置为当前的日期和时间。最后,我们要将Order对象标注为实体。程序清单3.18展示了新的Order类。

程序清单3.18 将Order标注为JPA实体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package tacos;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.ManyToMany;
import javax.persistence.OneToMany;
import javax.persistence.PrePersist;
import javax.persistence.Table;
import javax.validation.constraints.Digits;
import javax.validation.constraints.Pattern;
import org.hibernate.validator.constraints.CreditCardNumber;
import org.hibernate.validator.constraints.NotBlank;
import lombok.Data;
@Data
@Entity
@Table(name="Taco_Order")
public class Order implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy=GenerationType.AUTO)
private Long id;
private Date placedAt;
...
@ManyToMany(targetEntity=Taco.class)
private List<Taco> tacos = new ArrayList<>();
public void addDesign(Taco design) {
this.tacos.add(design);
}
@PrePersist
void placedAt() {
this.placedAt = new Date();
}
}

我们可以看到,Order所需的变更就是Taco的翻版。但是,在类级别这里有了一个新的注解,即@Table。它表明Order实体应该持久化到数据库中名为Taco_Order的表中。

我们可以将这个注解用到所有的实体上,但是只有Order有必要这样做。如果没有它,JPA默认会将实体持久化到名为Order的表中,但是order是SQL的保留字,这样做的话会产生问题。实体都已经标注好了,现在我们该编写repository了。

3.2.3 声明JPA repository

在JDBC版本的repository中,我们显式声明想要repository提供的方法。但是,借助Spring Data,我们可以扩展CrudRepository接口。举例来说,如下是新的IngredientRepository接口。

1
2
3
4
5
6
package tacos.data;
import org.springframework.data.repository.CrudRepository;
import tacos.Ingredient;
public interface IngredientRepository
extends CrudRepository<Ingredient, String> {
}

CrudRepository定义了很多用于CRUD(创建、读取、更新、删除)操作的方法。注意,它是参数化的,第一个参数是repository要持久化的实体类型,第二个参数是实体ID属性的类型。对于IngredientRepository来说,参数应该是Ingredient和String。

我们可以非常简单地定义TacoRepository:

1
2
3
4
5
6
package tacos.data;
import org.springframework.data.repository.CrudRepository;
import tacos.Taco;
public interface TacoRepository
extends CrudRepository<Taco, Long> {
}

IngredientRepository和TacoRepository之间唯一比较明显的区别就是CrudRepository的参数。在这里,我们将其设置为Taco和Long,从而指定Taco实体(及其ID类型)是该repository接口的持久化单元。最后,相同的变更可以用到OrderRepository上:

1
2
3
4
5
6
package tacos.data;
import org.springframework.data.repository.CrudRepository;
import tacos.Order;
public interface OrderRepository
extends CrudRepository<Order, Long> {
}

现在,我们有了3个repository。你可能会想,我们应该需要编写它们的实现类,包括每个实现类所需的十多个方法。但是,Spring Data JPA带来的好消息是,我们根本就不用编写实现类!当应用启动的时候,Spring Data JPA会在运行期自动生成实现类。这意味着,我们现在就可以使用这些repository了。我们只需要像使用基于JDBC的实现那样将它们注入控制器中就可以了。

CrudRepository所提供的方法对于实体的通用持久化是非常有用的。但是,如果我们的需求并不局限于基本持久化,那又该怎么办呢?接下来,我们看一下如何自定义repository来执行特定领域的查询。

3.2.4 自定义JPA repository

假设除了CrudRepository提供的基本CRUD操作之外,我们还需要获取投递到指定邮编(Zip)的订单。实际上,我们只需要添加如下的方法声明到OrderRepository中,这个问题就解决了:

1
List<Order> findByDeliveryZip(String deliveryZip);

当创建repository实现的时候,Spring Data会检查repository接口的所有方法,解析方法的名称,并基于被持久化的对象来试图推测方法的目的。本质上,SpringData定义了一组小型的领域特定语言(Domain-Specific Language,DSL),在这里持久化的细节都是通过repository方法的签名来描述的。

Spring Data能够知道这个方法是要查找Order的,因为我们使用Order对CrudRepository进行了参数化。方法名findByDeliveryZip()确定该方法需要根据deliveryZip属性相匹配来查找Order,而deliveryZip的值是作为参数传递到方法中来的。

findByDeliveryZip ()方法非常简单,但是Spring Data也能处理更加有意思的方法名称。repository方法是由一个动词、一个可选的主题(Subject)、关键词By以及一个断言所组成的。在findByDeliveryZip()这个样例中,动词是find,断言是DeliveryZip,主题并没有指定,暗含的主题是Order。

我们考虑另外一个更复杂的样例。假设我们想要查找投递到指定邮编且在一定时间范围内的订单。在这种情况下,我们可以将如下的方法添加到OrderRepository中,它就能达到我们的目的。

1
2
List<Order> readOrdersByDeliveryZipAndPlacedAtBetween(
String deliveryZip, Date startDate, Date endDate);

图3.2展现了Spring Data在生成repository实现的时候是如何解析和理解readOrdersByDeliveryZipAndPlacedAtBetween()方法的。我们可以看到,在readOrdersByDeliveryZipAndPlacedAtBetween()中,动词是read。SpringData会将get、read和find视为同义词,它们都是用来获取一个或多个实体的。另外,我们还可以使用count作为动词,它会返回一个int值,代表匹配实体的数量。

epub_29101559_29

图3.2 Spring Data解析repository方法签名来确定要执行的查询

尽管方法的主题是可选的,但是这里要查找的就是Order。Spring Data会忽略主题中大部分的单词,所以你尽可以将方法命名为readPuppiesBy…,它依然会去查找Order实体,因为CrudRepository的类型是参数化的。

单词By后面的断言是方法签名中最为有意思的一部分。在本例中,断言指定了Order的两个属性:deliveryZip和placedAt。deliveryZip属性的值必须要等于方法第一个参数传入的值。关键字Between表明placedAt属性的值必须要位于方法最后两个参数的值之间。

除了Equals和Between操作之外,Spring Data方法签名还能包括如下的操作符:

  • IsAfter、After、IsGreaterThan、GreaterThan
  • IsGreaterThanEqual、GreaterThanEqual
  • IsBefore、Before、IsLessThan、LessThan
  • IsLessThanEqual、LessThanEqual
  • IsBetween、Between
  • IsNull、Null
  • IsNotNull、NotNull
  • IsIn、In
  • IsNotIn、NotIn
  • IsStartingWith、StartingWith、StartsWith
  • IsEndingWith、EndingWith、EndsWith
  • IsContaining、Containing、Contains
  • IsLike、Like
  • IsNotLike、NotLike
  • IsTrue、True
  • IsFalse、False
  • Is、Equals
  • IsNot、Not
  • IgnoringCase、IgnoresCase

作为IgnoringCase/IgnoresCase的替代方案,我们还可以在方法上添加AllIgnoringCase或AllIgnoresCase,这样它就会忽略所有String对比的大小写。例如,请看如下方法:

1
2
List<Order> findByDeliveryToAndDeliveryCityAllIgnoresCase(
String deliveryTo, String deliveryCity);

最后,我们还可以在方法名称的结尾处添加OrderBy,实现结果集根据某个列排序。例如,我们可以按照deliveryTo属性排序:

1
List<Order> findByDeliveryCityOrderByDeliveryTo(String city);

尽管方法名称约定对于相对简单的查询非常有用,但是,不难想象,对于更为复杂的查询,方法名可能会面临失控的风险。在这种情况下,可以将方法定义为任何你想要的名称,并为其添加@Query注解,从而明确指明方法调用时要执行的查询,如下面的样例所示:

1
2
@Query("Order o where o.deliveryCity='Seattle'")
List<Order> readOrdersDeliveredInSeattle();

在本例中,通过使用@Query,我们声明只查询所有投递到Seattle的订单。但是,我们可以使用@Query执行任何想要的查询,有些查询是通过方法命名约定很难甚至根本无法实现的。