5.4 小结

  • Spring bean可以添加@ConfigurationProperties注解,这样就能够从多个属性源中选取一个来注入它的值。
  • 配置属性可以通过命令行参数、环境变量、JVM系统属性、属性文件或YAML文件等方式进行设置。
  • 配置属性可以用来覆盖自动配置相关的设置,包括指定数据源URL和日志级别。
  • Spring profile可以与属性源协同使用,从而能够基于激活的profile条件化地设置配置属性。

5.3 使用profile进行配置

当应用部署到不同的运行时环境中的时候,有些配置细节通常会有些差别。例如,数据库连接的细节在开发环境和质量保证(quality assurance)环境中可能就不相同,而它们与生产环境可能又不一样。配置不同环境之间有差异的属性时,有种办法就是使用环境变量,通过这种方式来指定配置属性,而不是在application.properties和application.yml中进行定义。

例如,在开发阶段,我们可以依赖自动配置的嵌入式H2数据库。但是在生产环境中,我们可以按照如下的方式将数据库配置属性设置为环境变量:

1
2
3
% export SPRING_DATASOURCE_URL=jdbc:mysql://localhost/tacocloud
% export SPRING_DATASOURCE_USERNAME=tacouser
% export SPRING_DATASOURCE_PASSWORD=tacopassword

尽管这种方式可以运行,但是如果配置属性比较多,那么将它们声明为环境变量会非常麻烦。除此之外,我们没有好的方式来跟踪环境变量的变化,也无法在出现错误的时候进行回滚。

相对于这种方式,我更加倾向于采用Spring profile。profile是一种条件化的配置,在运行时,根据哪些profile处于激活状态,可以使用或忽略不同的bean、配置类和配置属性。

例如,为了开发和调试方便,我们希望使用嵌入式的H2数据库,并且Taco Cloud代码的日志级别为DEBUG。但是在生产环境中,我们希望使用外部的MySQL数据库,并将日志级别设置为WARN。在开发场景下,我们可以很容易地设置数据源属性并使用自动配置的H2数据库。对于调试级别的日志需求,我们可以在application.yml文件中通过logging.level.tacos属性将tacos基础包的日志级别设置为DEBUG:

1
2
3
logging:
level:
tacos: DEBUG

这就是我们要针对开发环境做的事情。但是,如果我们不对application.yml做任何修改就将应用部署到生产环境,tacos包依然会写入调试日志并且依然会使用H2数据库。我们需要做的就是定义一个profile,其中包含适用于生产环境的属性。

5.3.1 定义特定profile的属性

定义特定profile相关的属性的一种方式就是创建另外一个YAML或属性文件,其中只包含用于生产环境的属性。文件的名称要遵守如下的约定:application-{profile名}.yml或application-{profile名}.properties。然后,我们就可以在这里声明适用于该profile的配置属性了。例如,我们可以创建一个新的名为application-prod.yml的文件,其中包含如下属性:

1
2
3
4
5
6
7
8
spring:
datasource:
url: jdbc:mysql://localhost/tacocloud
username: tacouser
password: tacopassword
logging:
level:
tacos: WARN

定义特定profile相关的属性的另外一种方式仅适用于YAML配置。它会将特定profile的属性和非profile的属性都放到application.yml中,它们之间使用3个中划线进行分割,并且使用spring.profiles属性来命名profile。如果按照这种方式定义生产环境的属性,等价的application.yml如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
logging:
level:
tacos: DEBUG
---
spring:
profiles: prod
datasource:
url: jdbc:mysql://localhost/tacocloud
username: tacouser
password: tacopassword
logging:
level:
tacos: WARN

定义特定profile相关的属性的另外一种方式仅适用于YAML配置。它会将特定profile的属性和非profile的属性都放到application.yml中,它们之间使用3个中划线进行分割,并且使用spring.profiles属性来命名profile。如果按照这种方式定义生产环境的属性,等价的application.yml如下所示:

1
2
3
4
spring:
profiles:
active:
- prod

我们可以看到,application.yml文件通过一组中划线(—)分成了两部分。第二部分指定了spring.profiles值,代表后面的属性适用于prod profile。而第一部分的属性没有指定spring.profiles,所以它们是所有profile通用的,或者如果当前激活的profile没有设置这些属性,它们就会作为默认值。

不管应用程序运行的时候哪个profile处于激活状态,根据默认profile,tacos包的日志级别都将会设置为DEBUG。但是,如果名为prod的profile激活,那么logging.level.tacos属性将会被重写为WARN。与之类似,如果prod profile处于激活状态,那么数据源相关的属性将会被设置为使用外部的MySQL数据库。

通过创建模式为application-{profile名}.yml或application-{profile名}.properties的YAML或属性文件,我们可以按需定义任意数量的profile。或者,我们也可以在application.yml中再输入3个中划线,结合spring.profiles属性来指定其他名称的profile,然后添加该profile特定的相关属性。

5.3.2 激活profile

如果我们不激活这些profile,声明profile相关的属性其实没有任何用处。但是,我们该如何激活一个profile呢?要激活某个profile,需要做的就是将profile名称的列表赋值给spring.profiles.active属性。例如,在application.yml中,我们可以这样设置:

1
2
3
4
spring:
profiles:
active:
- prod

但是,这可能是激活profile最糟糕的一种方式。如果我们在application.yml中设置处于激活状态的profile,那么这个profile就会变成默认的profile,我们体验不到使用profile将生产环境相关属性和开发环境相关的属性分开的任何好处。因此,我推荐使用环境变量来设置处于激活状态的profile。在生产环境中,我们可以这样设置SPRING_PROFILES_ACTIVE:

1
% export SPRING_PROFILES_ACTIVE=prod

这样部署到该机器上的任何应用就都会激活prod profile,对应的属性会比默认profile具备更高的优先级。

如果以可执行JAR文件的形式运行应用,那么我们还可以以命令行参数的形式设置激活的profile:

1
% java -jar taco-cloud.jar --spring.profiles.active=prod

你可能已经注意到了,spring.profiles.active属性名是复数形式的profile。这意味着我们可以设置多个激活的profile。如果使用环境变量,通常这可以通过逗号分隔的列表来实现:

1
% export SPRING_PROFILES_ACTIVE=prod,audit,ha

但是,在YAML中,我们要按照如下的方式来声明列表:

1
2
3
4
5
6
spring:
profiles:
active:
- prod
- audit
- ha

另外,值得一提的是,如果我们将Spring应用部署到Cloud Foundry中,将会自动激活一个名为cloud的profile。如果你的生产环境是Cloud Foundry,那么你可以将生产环境相关的属性放到cloud profile下。

在Spring应用中,profile不仅能够用来条件化地设置配置属性,接下来我们看一下如何基于处于激活状态的profile来声明特定的bean。

5.3.3 使用profile条件化地创建bean

有时候,为不同的profile创建一组独特的bean是非常有用的。正常情况下,不管哪个profile处于激活状态,Java配置类中声明的所有bean都会被创建。但是,假设我们希望某些bean仅在特定profile激活的情况下才需要创建。在这种情况下,@Profile注解可以将某些bean设置为仅适用于给定的profile。

例如,在TacoCloudApplication中,我们有一个CommandLineRunner bean,它用来在应用启动的时候加载嵌入式数据库和配料数据。对于开发阶段来讲,这是很不错的;但是对于生产环境的应用来说,就没有必要(也是不符合需求的)了。为了防止在生产部署环境中每次都加载配料数据,我们可以为声明CommandLineRunner bean的方法添加@Profile注解,如下所示:

1
2
3
4
5
6
@Bean
@Profile("dev")
public CommandLineRunner dataLoader(IngredientRepository repo,
UserRepository userRepo, PasswordEncoder encoder) {
...
}

或者,假设我们在dev或qa profile激活的时候都需要创建CommandLineRunner。在这种情况下,我们可以为要创建的bean把所有profile都列出来:

1
2
3
4
5
6
@Bean
@Profile({"dev", "qa"})
public CommandLineRunner dataLoader(IngredientRepository repo,
UserRepository userRepo, PasswordEncoder encoder) {
...
}

现在,配料数据会在dev或qa profile激活的时候才加载。这意味着,在开发环境运行的时候我们需要将dev profile激活。如果除了prod激活时,CommandLineRunner bean都需要创建,那么我们可以采用一种更简便的方式。在这种情况下,我们可以按照如下的方式来使用@Profile

1
2
3
4
5
6
@Bean
@Profile("!prod")
public CommandLineRunner dataLoader(IngredientRepository repo,
UserRepository userRepo, PasswordEncoder encoder) {
...
}

在这里,感叹号(!)否定了profile的名称。实际上,它的含义是只要prod profile不激活就要创建CommandLineRunner bean。

我们还可以在带有@Configuration注解的类上使用@Profile。例如,假设我们要将CommandLineRunner抽取到一个名为DevelopmentConfig的配置类中,那么我们可以按照如下的方式为DevelopmentConfig添加@Profile

1
2
3
4
5
6
7
8
9
@Profile({"!prod", "!qa"})
@Configuration
public class DevelopmentConfig {
@Bean
public CommandLineRunner dataLoader(IngredientRepository repo,
UserRepository userRepo, PasswordEncoder encoder) {
...
}
}

在这里,CommandLineRunner bean(包括DevelopmentConfig中定义的其他bean)只有在prod和qa均没有激活的情况下才会创建。

5.2 创建自己的配置属性

正如我在前文所述,配置属性只不过是bean的属性,它们可以从Spring的环境抽象中接受配置。我还没有提及的是这些bean该如何消费这些配置。

为了支持配置属性的注入,Spring Boot提供了@ConfigurationProperties注解。将它放到Spring bean上之后,它就会为该bean中那些能够根据Spring环境注入值的属性赋值。

为了阐述@ConfigurationProperties是如何运行的,假设我们为OrderController添加了如下的方法,该方法会列出当前认证用户过去的订单:

1
2
3
4
5
6
7
@GetMapping
public String ordersForUser(
@AuthenticationPrincipal User user, Model model) {
model.addAttribute("orders",
orderRepo.findByUserOrderByPlacedAtDesc(user));
return "orderList";
}

除此之外,我们还要为OrderRepository添加必要的findByUser()方法:

1
List<Order> findByUserOrderByPlacedAtDesc(User user);

注意,这个repository方法使用了OrderByPlacedAtDesc子句。OrderBy区域指定了结果要按照什么属性来排序,在本例中,也就是placedAt属性。最后的Desc声明要按照降序进行排列。所以,返回的订单将会按照时间由近及远进行排序。

按照这种写法,如果用户只创建了少量订单,那么这个控制器方法可能会非常有用,但是,对于狂热的taco爱好者来说,这种方式就显得有些不方便了。在浏览器中显示一些订单会很有用,但是一长串没完没了的订单列表简直就是“噪声”。假设,我们希望将显示的订单数量限制为最近的20个,那么我们可以按照如下方式来修改ordersForUser():

1
2
3
4
5
6
7
8
@GetMapping
public String ordersForUser(
@AuthenticationPrincipal User user, Model model) {
Pageable pageable = PageRequest.of(0, 20);
model.addAttribute("orders",
orderRepo.findByUserOrderByPlacedAtDesc(user, pageable));
return "orderList";
}

OrderRepository也需要对应修改:

1
2
List<Order> findByUserOrderByPlacedAtDesc(
User user, Pageable pageable);

现在,我们修改了findByUserOrderByPlacedAtDesc()方法的签名,使其能够接受Pageable参数。Pageable是Spring Data根据页号和每页数量选取结果的子集的一种方法。在ordersForUser()控制器方法中,我们构建了一个PageRequest对象,该对象实现了Pageable,我们将其声明为请求第一页(序号为0)的数据,并且每页数量为20,这样我们就能获取当前用户最近的20个订单。

尽管这种方式能够很好地运行,但是我们在这里硬编码了每页的数量,这有点让人担心。如果我们以后发现展示20个订单太多,并决定将其修改为10个,那该怎么办?因为这个值是硬编码的,所以需要重新构建和重新部署应用。

我们可以将每页数量设置成一个自定义的配置属性,而不是硬编码到代码中。首先,我们需要添加一个名为pageSize的新属性到OrderController中,并为OrderController添加@ConfigurationProperties注解,如程序清单5.1所示。

程序清单5.1 在OrderController中启用配置属性功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Controller
@RequestMapping("/orders")
@SessionAttributes("order")
@ConfigurationProperties(prefix="taco.orders")
public class OrderController {
private int pageSize = 20;
public void setPageSize(int pageSize) {
this.pageSize = pageSize;
}
...
@GetMapping
public String ordersForUser(
@AuthenticationPrincipal User user, Model model) {
Pageable pageable = PageRequest.of(0, pageSize);
model.addAttribute("orders",
orderRepo.findByUserOrderByPlacedAtDesc(user, pageable));
return "orderList";
}
}

程序清单5.1最重要的变更是添加了@ConfigurationProperties注解。它的prefix属性设置成了taco.orders,这意味着当设置pageSize的时候,我们需要使用名为taco.orders.pageSize的配置属性。

新的pageSize值默认为20,但是通过设置taco.orders.pageSize属性,我们可以很容易地将其修改为任意的值。例如,我们可以在application.yml中按照如下的方式设置该属性:

1
2
3
taco:
orders:
pageSize: 10

如果在生产环境中需要快速更改,我们可以将taco.orders.pageSize设置为环境变量,这样就不用重新构建和重新部署应用了:

1
$ export TACO_ORDERS_PAGESIZE=10

设置配置属性的任何方式都可以用来调整最近订单页面中每页的数量。接下来,我们看一下如何在属性持有者(property holder)中设置配置数据。

5.2.1 定义配置属性的持有者

这里并没有说@ConfigurationProperties只能用到控制器或特定类型的bean中。@ConfigurationProperties实际上通常会放到一种特定类型的bean中,这种bean的目的就是持有配置数据。这样的话,特定的配置细节就能从控制器和其他应用程序类中抽离出来,多个bean也能更容易地共享一些通用的配置。

针对OrderController中的pageSize属性,我们可以将其抽取到一个单独的类中。程序清单5.2就以这样的方式来使用OrderProps类。

程序清单5.2 将pageSize抽取到持有者类中

1
2
3
4
5
6
7
8
9
10
11
package tacos.web;
import org.springframework.boot.context.properties.
ConfigurationProperties;
import org.springframework.stereotype.Component;
import lombok.Data;
@Component
@ConfigurationProperties(prefix="taco.orders")
@Data
public class OrderProps {
private int pageSize = 20;
}

就像我们在OrderController中所做的那样,pageSize的默认值为20,OrderProps使用了@ConfigurationProperties注解并且将前缀设置成了taco.orders。这个类还用到了@Component注解,这样Spring的组件扫描功能会自动发现它并将其创建为Spring应用上下文中的bean。这是非常重要的,因为我们下一步要将OrderProps作为bean注入到OrderController中。

配置属性持有者并没有什么特别之处。它们只是将Spring环境注入到其属性中的bean。它们可以注入到任意需要这些属性的其他bean中。对于OrderController来说,我们就可以从OrderController中移除pageSize,并注入和使用OrderProps bean:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Controller
@RequestMapping("/orders")
@SessionAttributes("order")
public class OrderController {
private OrderRepository orderRepo;
private OrderProps props;
public OrderController(OrderRepository orderRepo,
OrderProps props) {
this.orderRepo = orderRepo;
this.props = props;
}
...
@GetMapping
public String ordersForUser(
@AuthenticationPrincipal User user, Model model) {
Pageable pageable = PageRequest.of(0, props.getPageSize());
model.addAttribute("orders",
orderRepo.findByUserOrderByPlacedAtDesc(user, pageable));
return "orderList";
}
...
}

现在,OrderController不需要负责处理自己的配置属性了。这样能够让OrderController中的代码更加整洁一些,并且能够让其他的bean重用OrderProps中的属性。除此之外,我们可以将订单相关的属性全部放到一个地方,也就是OrderProps类中。如果我们需要添加、删除、重命名或者以其他方式更改其中的属性,我们只需要在OrderProps中进行变更就可以了。

例如,假设我们在多个其他的bean中也用到了pageSize属性,现在我们决定要对这个属性的值进行一些校验,限制它的值必须要不小于5且不大于25。如果没有持有者bean,我们必须要将校验注解用到OrderController的pageSize属性上以及其他所有使用该属性的类上。但是,因为我们现在将pageSize抽取到了OrderProps中,所以只需要修改OrderProps就可以了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package tacos.web;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import org.springframework.boot.context.properties.
ConfigurationProperties;
import org.springframework.stereotype.Component;
import org.springframework.validation.annotation.Validated;
import lombok.Data;
@Component
@ConfigurationProperties(prefix="taco.orders")
@Data
@Validated
public class OrderProps {
@Min(value=5, message="must be between 5 and 25")
@Max(value=25, message="must be between 5 and 25")
private int pageSize = 20;
}
//end::validated[]

尽管我们很容易就可以将@Validated@Min@Max注解用到OrderController(和其他可以注入OrderProps的地方),但是这样会使OrderController更加混乱。通过配置属性的持有者bean,我们将所有的配置属性收集到了一个地方,这样就能让使用这些属性的bean尽可能保持整洁。

5.2.2 声明配置属性元数据

在IDE中,你可能会发现application.yml(或application.properties)文件的taco.orders. pageSize条目上会有一条警告信息,根据IDE不同显示会有所差异,这个警告提示的内容可能是“Unknown property ‘taco’”。这个警告产生的原因在于我们刚刚创建的配置属性缺少元数据。图5.2展示了在Spring Tool Suite中,当我将鼠标悬停到taco属性时的样式。

image-20211014102516936

图5.2 缺少配置属性元数据所产生的警告

配置属性的元数据完全是可选的,它并不会妨碍配置属性的运行。但是,元数据对于为配置属性提供一个最小化的文档非常有用,在IDE中尤为如此。

举例来说,将鼠标指针悬停到security.user.password属性上时,就会看到图5.3那样的效果。尽管悬停对我们的帮助很有限,但是它足以让我们知道这个属性是做什么的以及如何使用它。

image-20211014102550678

图5.3 Spring Tool Suite中配置属性的悬停文档

为了帮助那些使用你所定义的配置属性的人(有可能就是你本人),为这些属性创建一些元数据是非常好的办法,至少它能消除IDE上那些烦人的黄色警告。

为了创建自定义配置属性的元数据,我们需要在META-INF下创建一个名为additional-spring-configuration-metadata.json的文件(比如,在项目的“src/main/resources/ META-INF”目录下)。

快速添加缺失的元数据

如果使用Spring Tool Suite,就会有一个创建缺失属性元数据的快速修正选项。将鼠标放到缺失元数据警告的那行代码上,在Mac下按CMD+1组合键或者在Windows和Linux下按Ctrl+1组合键就能打开快速修正的弹出框(见图5.4)。

image-20211014102606086

图5.4 在Spring Tool Suite中通过快速修正弹出框创建配置属性

然后,选择“Create Metadata for …”选项来为属性添加元数据(会在additional-spring- configuration-metadata.json文件中进行添加),如果文件还不存在,将会自动创建该文件。

对于taco.orders.pageSize属性来说,我们可以通过如下的JSON为其添加元数据:

1
2
3
4
5
6
7
8
9
10
{
"properties": [
{
"name": "taco.orders.page-size",
"type": "java.lang.String",
"description":
"Sets the maximum number of orders to display in a list."
}
]
}

需要注意,在元数据中引用的属性名为taco.orders.page-size。Spring Boot灵活的属性命名功能允许属性名出现不同的变种,比如taco.orders.page-size等价于taco.orders.pageSize。

元数据准备就绪之后,警告信息就会消失了。除此之外,如果将鼠标指针悬停到taco.orders. pageSize属性上,就会看到如图5.5所示的描述信息。

image-20211014102647143

图5.5 自定义配置属性的悬停帮助信息

另外,在IDE中,就像Spring本身提供的配置属性一样,我们还能具备自动补全功能,如图5.6所示。

image-20211014102704934

图5.6 配置属性的元数据能够帮助实现属性的自动补全功能

我们可以看到,配置属性对于调整自动配置的组件以及应用程序自身的bean都非常有用。但是,如果我们想要为不同的部署环境配置不同的属性又该怎么办呢?接下来,我们看一下该如何使用Spring profile搭建特定环境的配置。

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的安全需求,我们需要编写一些显式的配置,覆盖掉自动配置为我们提供的功能。我们首先配置一个合适的用户存储,这样就能有多个用户了。