12.4 使用 Spring Security 和 Spring Data 保护和审计数据
到目前为止,我们已经研究了保护 Spring Boot 应用程序暴露的 API 以及处理身份验证和授权等问题。那么数据呢?一旦您有了 Spring Security,您还可以保护业务和数据层。
关于业务逻辑,您可以启用方法安全性功能直接在业务方法上检查用户身份验证或授权,利用 @PreAuthorize 等注解。在 Polar Bookshop 系统中,业务层不够复杂,不需要额外的安全策略,因此我不会描述它。
注意 要了解有关如何使用方法身份验证和授权的更多信息,请参阅 Laurentiu Spilca 的《Spring Security in Action》(Manning,2020)的第 8 章,其中对此进行了详细说明。
另一方面,数据层需要一些额外的工作来解决两个主要问题:
- 我们如何知道哪个用户创建了什么数据?谁最后更改了它?
- 我们如何确保每个用户只能访问自己的书籍订单?
本节将解决这两个问题。首先,我将解释如何在 Catalog Service 和 Order Service 中启用对用户数据操作的审计。然后,我将引导您完成 Order Service 保持数据私有所需的更改。
12.4.1 使用 Spring Security 和 Spring Data JDBC 审计数据
让我们首先考虑 Catalog Service,其数据层是使用 Spring Data JDBC 实现的。在第 5 章中,您学习了如何启用 JDBC 数据审计,并将其配置为保存每个数据实体的创建日期和最后修改日期。在此基础上,我们现在可以扩展审计范围以包含创建实体的用户的用户名和最后修改它的用户。
首先,我们需要告诉 Spring Data 在哪里获取有关当前经过身份验证的用户的信息。在上一章中,您了解到 Spring Security 将有关经过身份验证的用户的信息存储在 Authentication 对象中,该对象存储在可通过 SecurityContextHolder 访问的 SecurityContext 对象中。我们可以使用该对象层次结构来指定如何为 Spring Data 提取委托人。
定义审计员以捕获谁创建或更新了 JDBC 数据实体
在 Catalog Service 项目(catalog-service)中,打开 DataConfig 类。这就是我们使用 @EnableJdbcAuditing 注解启用数据审计的地方。现在,我们还将定义一个 AuditorAware Bean,它应该返回委托人——当前经过身份验证的用户。
清单 12.21 在 Spring Data JDBC 中配置用户审计
@Configuration
@EnableJdbcAuditing
// 在 Spring Data JDBC 中启用实体审计
public class DataConfig {
@Bean
AuditorAware<String> auditorAware() {
// 返回当前经过身份验证的用户用于审计目的
return () -> Optional
.ofNullable(SecurityContextHolder.getContext())
// 从 SecurityContextHolder 中提取当前经过身份验证用户的 SecurityContext 对象
.map(SecurityContext::getAuthentication)
// 从 SecurityContext 中提取当前经过身份验证用户的 Authentication 对象
.filter(Authentication::isAuthenticated)
// 处理用户未通过身份验证但正在操作数据的情况。由于我们保护了所有端点,因此这种情况不应发生,但我们将包括它以求完整
.map(Authentication::getName);
// 从 Authentication 对象中提取当前经过身份验证用户的用户名
}
}
为创建或更新 JDBC 数据实体的用户添加审计元数据
定义 AuditorAware Bean 并启用审计后,Spring Data 将使用它来提取委托人。在我们的例子中,它是当前经过身份验证的用户的用户名,表示为 String。然后我们可以使用 @CreatedBy 和 @LastModifiedBy 注解 Book record 中的两个新字段。每当对实体执行创建或更新操作时,Spring Data 将自动填充它们。
清单 12.22 在 JDBC 实体中捕获用户审计元数据的字段
public record Book (
// ...
@CreatedBy
// 谁创建了实体
String createdBy,
@LastModifiedBy
// 谁最后修改了实体
String lastModifiedBy,
) {
public static Book of(String isbn, String title, String author,
Double price, String publisher) {
return new Book(null, isbn, title, author, price, publisher,
null, null, null, null, 0);
}
}
添加新字段后,我们需要更新一些使用 Book 全参构造函数的类,该构造函数现在需要传递 createdBy 和 lastModifiedBy 的值。
BookService 类包含更新书籍的逻辑。打开它并更改 editBookDetails() 方法,以确保在调用数据层时正确传递审计元数据。
清单 12.23 更新书籍时包含现有审计元数据
@Service
public class BookService {
// ...
public Book editBookDetails(String isbn, Book book) {
return bookRepository.findByIsbn(isbn)
.map(existingBook -> {
var bookToUpdate = new Book(
existingBook.id(),
existingBook.isbn(),
book.title(),
book.author(),
book.price(),
book.publisher(),
existingBook.createdDate(),
existingBook.lastModifiedDate(),
existingBook.createdBy(),
// 谁创建了实体
existingBook.lastModifiedBy(),
// 谁最后更新了实体
existingBook.version());
return bookRepository.save(bookToUpdate);
})
.orElseGet(() -> addBookToCatalog(book));
}
}
我将让您以类似的方式更新自动测试。您还可以扩展 BookJsonTests 中的测试以验证新字段的序列化和反序列化。作为参考,您可以查看本书附带源代码仓库中的 Chapter12/12-end/catalog-service。确保更新使用 Book() 构造函数的测试,否则应用程序构建将失败。
编写 Flyway 迁移以将新审计元数据添加到架构中
由于我们更改了实体模型,因此我们需要相应地更新数据库架构。假设 Catalog Service 已经在生产环境中,因此我们需要一个 Flyway 迁移来在下一个版本中更新架构。在第 5 章中,我们介绍了 Flyway 为数据库添加版本控制。对架构的每次更改都必须注册为迁移,以确保稳健的架构演进和可重复性。
对数据库架构的任何更改也应该是向后兼容的,以支持云原生应用程序的常见部署策略,如滚动升级、蓝/绿部署或金丝雀发布(我们将在第 15 章中介绍的主题)。在本例中,我们需要向 book 表添加新列。只要我们不将它们设为必填,更改将是向后兼容的。更改架构后,Catalog Service 上一版本的任何正在运行的实例将继续正常工作,只是忽略新列。
在 Catalog Service 项目的 src/main/resources/db/migration 文件夹中,创建一个新的 V3__Add_user_audit.sql 迁移脚本,向 book 表添加两个新列。确保在版本号后键入两个下划线。
清单 12.24 向 book 表添加新的审计元数据
ALTER TABLE book ADD COLUMN created_by varchar(255);
-- 添加一个列来保存创建该行的用户的用户名
ALTER TABLE book ADD COLUMN last_modified_by varchar(255);
-- 添加一个列来保存最后更新该行的用户的用户名
在应用程序启动期间,Flyway 将自动遍历所有迁移脚本并应用尚未应用的脚本。
强制执行向后兼容更改的权衡是,我们现在必须将两个字段视为可选的,而我们需要始终填写这两个字段,如果未填写,可能会导致验证失败。这是一个常见问题,可以通过应用程序的两个后续版本来解决:
- 在第一个版本中,您添加新列作为可选列,并实现数据迁移以填写所有现有数据的新列。对于 Catalog Service,您可以使用常规值来表示我们不知道谁创建或更新了实体,例如
unknown或anonymous。 - 在第二个版本中,您可以创建一个新的迁移来安全地更新架构并使新列成为必需列。
如果您愿意这样做,我将把它留给您。如果您对实现数据迁移感兴趣,我建议您查看 Flyway 的官方文档(https://flywaydb.org)。
在下一节中,您将看到如何测试 Spring Data JDBC 中与用户相关的审计。
12.4.2 使用 Spring Data 和 @WithMockUser 测试数据审计
当我们在数据层测试安全性时,我们不关心采用了哪种身份验证策略。我们唯一需要知道的是操作是否在经过身份验证的请求上下文中执行。
Spring Security Test 项目为我们提供了一个方便的 @WithMockUser 注解,我们可以在测试用例上使用它,使它们在经过身份验证的上下文中运行。您还可以添加有关模拟用户的信息。由于我们正在测试审计,我们希望定义至少一个可用作委托人的用户名。
让我们扩展 BookRepositoryJdbcTests 类,添加涵盖用户数据审计的新测试用例。
清单 12.25 测试用户经过身份验证或未经过身份验证时的数据审计
@DataJdbcTest
@Import(DataConfig.class)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ActiveProfiles("integration")
class BookRepositoryJdbcTests {
// ...
@Test
void whenCreateBookNotAuthenticatedThenNoAuditMetadata() {
var bookToCreate = Book.of("1232343456", "Title", "Author", 12.90, "Polarsophia");
var createdBook = bookRepository.save(bookToCreate);
// 此测试用例在未经过身份验证的上下文中执行
assertThat(createdBook.createdBy()).isNull();
assertThat(createdBook.lastModifiedBy()).isNull();
// 没有经过身份验证的用户时没有审计数据
}
@Test
@WithMockUser("john")
void whenCreateBookAuthenticatedThenAuditMetadata() {
var bookToCreate = Book.of("1232343457", "Title", "Author", 12.90, "Polarsophia");
var createdBook = bookRepository.save(bookToCreate);
// 此测试用例在用户 "john" 的经过身份验证的上下文中执行
assertThat(createdBook.createdBy()).isEqualTo("john");
assertThat(createdBook.lastModifiedBy()).isEqualTo("john");
// 有经过身份验证的用户时有审计数据
}
}
打开终端窗口,导航到 Catalog Service 根文件夹,然后按如下方式运行新添加的测试:
$ ./gradlew test --tests BookRepositoryJdbcTests
如果您遇到任何失败,可能是因为您尚未更新使用 Book() 构造函数的测试用例。我们向领域模型添加了新字段,因此请记住也要更新这些测试用例。
12.4.3 使用 Spring Security 和 Spring Data R2DBC 保护用户数据
类似于我们在 Catalog Service 中所做的,本节将向您展示如何在 Order Service 中为用户添加数据审计。得益于 Spring Data 和 Spring Security 提供的抽象,即使我们使用 Spring Data R2DBC 和响应式 Spring,实现也不会有太大差异。
除了数据审计之外,Order Service 还有一个额外的关键要求。用户应该只能访问自己的订单。我们需要确保所有这些数据的私密性。本节还将引导您完成实现该结果所需的更改。
定义审计员以捕获谁创建或更新了 R2DBC 数据实体
即使在这种情况下,我们也需要告诉 Spring Data 在哪里获取有关当前经过身份验证的用户的信息。由于它是一个响应式应用程序,这次我们将从 ReactiveSecurityContextHolder 获取委托人的 SecurityContext 对象。
在 Order Service 项目(order-service)中,打开 DataConfig 类并添加一个 ReactiveAuditorAware Bean 来返回当前经过身份验证的用户的用户名。
清单 12.26 在 Spring Data R2DBC 中配置用户审计
@Configuration
@EnableR2dbcAuditing
// 在 Spring Data R2DBC 中启用实体审计
public class DataConfig {
@Bean
ReactiveAuditorAware<String> auditorAware() {
// 返回当前经过身份验证的用户用于审计目的
return () -> ReactiveSecurityContextHolder.getContext()
// 从 ReactiveSecurityContextHolder 中提取当前经过身份验证用户的 SecurityContext 对象
.map(SecurityContext::getAuthentication)
// 从 SecurityContext 中提取当前经过身份验证用户的 Authentication 对象
.filter(Authentication::isAuthenticated)
// 处理用户未通过身份验证但正在操作数据的情况。由于我们保护了所有端点,因此这种情况不应发生,但我们将包括它以求完整
.map(Authentication::getName);
// 从 Authentication 对象中提取当前经过身份验证用户的用户名
}
}
为创建或更新 R2DBC 数据实体的用户添加审计元数据
定义 ReactiveAuditorAware Bean 并启用审计后,Spring Data 将使用它来提取当前经过身份验证的用户的用户名,表示为 String。即使在这种情况下,我们也可以使用 @CreatedBy 和 @LastModifiedBy 注解 Order record 中的两个新字段。每当对实体执行创建或更新操作时,Spring Data 将自动填充它们。
清单 12.27 在 R2DBC 实体中捕获用户审计元数据的字段
@Table("orders")
public record Order (
// ...
@CreatedBy
// 谁创建了实体
String createdBy,
@LastModifiedBy
// 谁最后修改了实体
String lastModifiedBy,
) {
public static Order of(String bookIsbn, String bookName, Double bookPrice,
Integer quantity, OrderStatus status) {
return new Order(null, bookIsbn, bookName, bookPrice, quantity,
status, null, null, null, null, 0);
}
}
添加新字段后,我们需要更新一些使用 Order 全参构造函数的类,该构造函数现在需要您传递 createdBy 和 lastModifiedBy 的值。
OrderService 类包含更新已调度订单的逻辑。打开它并更改 buildDispatchedOrder() 方法,以确保在调用数据层时正确传递审计元数据。
清单 12.28 更新订单时包含现有审计元数据
@Service
public class OrderService {
// ...
private Order buildDispatchedOrder(Order existingOrder) {
return new Order(
existingOrder.id(),
existingOrder.bookIsbn(),
existingOrder.bookName(),
existingOrder.bookPrice(),
existingOrder.quantity(),
OrderStatus.DISPATCHED,
existingOrder.createdDate(),
existingOrder.lastModifiedDate(),
existingOrder.createdBy(),
// 谁创建了实体
existingOrder.lastModifiedBy(),
// 谁最后更新了实体
existingOrder.version());
}
}
我将让您以类似的方式更新自动测试。您还可以扩展 OrderJsonTests 中的测试以验证新字段的序列化。作为参考,您可以查看本书附带源代码仓库中的 Chapter12/12-end/order-service。确保更新使用 Order() 构造函数的测试,否则应用程序构建将失败。
编写 Flyway 迁移以将新审计元数据添加到架构中
类似于我们为 Catalog Service 所做的,我们需要编写一个迁移来用两个新字段更新数据库架构,这两个字段保存创建实体的用户的用户名和最后修改它的用户。
在 Order Service 项目的 src/main/resources/db/migration 文件夹中,创建一个新的 V2__Add_user_audit.sql 迁移脚本,向 orders 表添加两个新列。确保在版本号后键入两个下划线。
清单 12.29 向 orders 表添加新的审计元数据
ALTER TABLE orders ADD COLUMN created_by varchar(255);
-- 添加一个列用于保存创建该行的用户的用户名
ALTER TABLE orders ADD COLUMN last_modified_by varchar(255);
-- 添加一个列用于保存最后更新该行的用户的用户名
确保用户数据隐私
我们还有一个尚未涵盖的要求:确保订单数据只能由创建订单的用户访问。任何用户都不应能够看到其他人的订单。
在 Spring 中实现此要求有几种不同的解决方案。我们将遵循以下步骤:
- 向
OrderRepository添加一个自定义查询,以根据创建订单的用户过滤订单。 - 更新
OrderService以使用新查询而不是默认的findAll()。 - 更新
OrderController以从安全上下文中提取当前经过身份验证的用户的用户名,并在请求订单时将其传递给OrderService。
警告 我们将依赖一个特定的解决方案,确保每个用户只能通过
/orders端点访问自己的订单。但是,这不会阻止开发人员将来使用OrderRepository暴露的其他方法并泄露私有数据。如果您想了解如何改进此解决方案,请参阅 Laurentiu Spilca 的《Spring Security in Action》(Manning,2020)的第 17 章。
让我们从 OrderRepository 开始。使用您在第 5 章中学到的约定,定义一个方法来查找由指定用户创建的所有订单。Spring Data 将在运行时为其生成实现。
清单 12.30 定义返回用户创建的订单的方法
public interface OrderRepository extends ReactiveCrudRepository<Order, Long> {
Flux<Order> findAllByCreatedBy(String userId);
// 查询仅由给定用户创建的订单的自定义方法
}
接下来,我们需要更新 OrderService 中的 getAllOrders() 方法以接受用户名作为输入,并使用 OrderRepository 提供的新查询方法。
清单 12.31 仅返回指定用户的订单
@Service
public class OrderService {
private final OrderRepository orderRepository;
public Flux<Order> getAllOrders(String userId) {
return orderRepository.findAllByCreatedBy(userId);
// 请求所有订单时,响应仅包含属于给定用户的订单
}
// ...
}
最后,让我们更新 OrderController 中的 getAllOrders() 方法。正如您在上一章中所学到的,您可以通过 @AuthenticationPrincipal 注解自动装配代表当前经过身份验证的用户的对象。在 Edge Service 中,该对象的类型为 OidcUser,因为它基于 OpenID Connect 身份验证。由于 Order Service 配置了 JWT 身份验证,因此委托人的类型将为 Jwt。我们可以使用 JWT(Access Token)来读取包含生成 Access Token 的用户名的 sub 声明(主题)。
清单 12.32 获取用户名并仅返回他们创建的订单
@RestController
@RequestMapping("orders")
public class OrderController {
private final OrderService orderService;
@GetMapping
public Flux<Order> getAllOrders(
@AuthenticationPrincipal Jwt jwt
// 自动装配代表当前经过身份验证的用户的 JWT
) {
return orderService.getAllOrders(jwt.getSubject());
// 提取 JWT 的主题并将其用作用户标识符
}
// ...
}
Order Service 到此为止。在下一节中,您将编写一些自动测试来验证数据审计和保护要求。
12.4.4 使用 @WithMockUser 和 Spring Data R2DBC 测试数据审计和保护
在上一节中,我们为用户配置了数据审计,并强制执行策略以仅返回当前经过身份验证的用户的订单。本节将向您展示如何将数据审计作为切片测试进行测试。要验证数据保护要求,您可以参考本书附带的仓库,并查看它如何在 OrderServiceApplicationTests 类的集成测试中被涵盖(Chapter12/12-end/order-service/src/test/java)。
数据审计在仓库级别应用。我们可以扩展 OrderRepositoryR2dbcTests 类,添加涵盖用户经过身份验证和未经过身份验证场景的额外测试用例。
类似于我们在 Catalog Service 中所做的,我们可以使用 Spring Security 的 @WithMockUser 注解在经过身份验证的上下文中执行测试方法,依赖模拟用户表示。
清单 12.33 测试用户经过身份验证或未经过身份验证时的数据审计
@DataR2dbcTest
@Import(DataConfig.class)
@Testcontainers
class OrderRepositoryR2dbcTests {
// ...
@Test
void whenCreateOrderNotAuthenticatedThenNoAuditMetadata() {
var rejectedOrder = OrderService.buildRejectedOrder("1234567890", 3);
StepVerifier.create(orderRepository.save(rejectedOrder))
.expectNextMatches(order -> Objects.isNull(order.createdBy()) &&
Objects.isNull(order.lastModifiedBy()))
.verifyComplete();
// 用户未通过身份验证时,不保存审计元数据
}
@Test
@WithMockUser("marlena")
void whenCreateOrderAuthenticatedThenAuditMetadata() {
var rejectedOrder = OrderService.buildRejectedOrder("1234567890", 3);
StepVerifier.create(orderRepository.save(rejectedOrder))
.expectNextMatches(order -> order.createdBy().equals("marlena") &&
order.lastModifiedBy().equals("marlena"))
.verifyComplete();
// 用户经过身份验证时,有关谁创建或更新实体的信息正确包含在数据中
}
}
打开终端窗口,导航到 Catalog Service 根文件夹,然后按如下方式运行新添加的测试:
$ ./gradlew test --tests OrderRepositoryR2dbcTests
如果您遇到任何失败,可能是因为您尚未更新使用 Order() 构造函数的测试用例。我们向领域模型添加了新字段,因此请记住也要更新这些测试用例。
这就结束了我们关于使用 Spring Boot、Spring Security、Spring Data 和 Keycloak 对命令式和响应式云原生应用程序进行身份验证、授权和审计的讨论。
Polar Labs
请随时应用您在前面章节中学到的知识,并更新 Catalog Service 和 Order Service 以进行部署。
- 更新两个应用程序的 Docker Compose 定义以配置 Keycloak URL。您可以使用容器名称(
polar-keycloak:8080),它由内置的 Docker DNS 解析。 - 更新两个应用程序的 Kubernetes 清单以配置 Keycloak URL。您可以使用 Keycloak 服务名称(
polar-keycloak)作为 URL,因为所有交互都在集群内发生。
您可以参考本书附带源代码仓库中的 Chapter12/12-end 文件夹以查看最终结果(https://github.com/ThomasVitale/cloud-native-spring-in-action)。您可以使用 kubectl apply -f services 从 Chapter12/12-end/polar-deployment/kubernetes/platform/development 文件夹中可用的清单部署备份服务,或使用 ./create-cluster.sh 部署整个集群。
本章小结
- 在 OIDC/OAuth2 设置中,客户端(Edge Service)通过 Access Token 被授予代表用户访问资源服务器(Catalog Service 和 Order Service)的权限。
- Spring Cloud Gateway 提供了一个
TokenRelay过滤器,用于自动将 Access Token 添加到路由到下游的任何请求中。 - 遵循 JWT 格式,ID Token 和 Access Token 可以将有关经过身份验证的用户的相关信息作为声明传播。例如,您可以添加
roles声明并配置 Spring Security,根据用户角色设置授权策略。 - Spring Boot 应用程序可以使用 Spring Security 配置为 OAuth2 资源服务器。
- 在 OAuth2 资源服务器中,对用户进行身份验证的策略完全基于每个请求的 Authorization 头中提供的有效 Access Token。我们称之为 JWT 身份验证。
- 在 OAuth2 资源服务器中,安全策略仍然通过
SecurityFilterChain(命令式)或SecurityWebFilterChain(响应式)Bean 强制执行。 - Spring Security 将权限、角色和范围表示为
GrantedAuthority对象。 - 您可以提供自定义
JwtAuthenticationConverterBean 来定义如何从 JWT 中提取授予权限,例如使用roles声明。 - 授予权限可用于采用 RBAC 策略并根据用户角色保护端点。
- Spring Data 库支持审计以跟踪谁创建了实体以及谁最后更新了它。您可以通过配置
AuditorAware(或ReactiveAuditorAware)Bean 来返回当前经过身份验证的用户的用户名,从而在 Spring Data JDBC 和 Spring Data R2DBC 中启用此功能。 - 启用数据审计后,您可以在发生创建或更新操作时使用
@CreatedBy和@LastModifiedBy注解自动注入正确的值。 - 测试安全性具有挑战性,但 Spring Security 提供了方便的实用程序来使这变得更容易,包括修改 HTTP 请求以包含 JWT Access Token(
.with(jwt())或.mutateWith(mockJwt()))或在给定用户的特定安全上下文中运行测试用例(@WithMockUser)的表达式。 - Testcontainers 可以通过使用实际的 Keycloak 容器来验证与 Spring Security 的交互,帮助编写完整的集成测试。