11.6 测试 Spring Security 和 OpenID Connect
编写自动测试的重要性通常对开发人员来说是显而易见的。然而,当涉及到安全性时,事情可能会变得具有挑战性,有时由于其复杂性而最终未被自动化测试覆盖。幸运的是,Spring Security 提供了多种实用程序,以简单的方式帮助您将安全包含在切片和集成测试中。
在本节中,您将学习如何使用 WebTestClient 对 Spring Security 的支持来测试 OIDC 身份验证和 CSRF 保护。让我们开始吧。
11.6.1 测试 OIDC 身份验证
在第 8 章中,我们依赖 @SpringWebFlux 注解和 WebTestClient 测试了 Spring WebFlux 暴露的 REST 控制器。在本章中,我们添加了一个新的控制器(UserController),因此让我们为其编写一些具有不同安全设置的自动测试。
首先,打开您的 Edge Service 项目,在 src/test/java 中创建一个用 @WebFluxTest(UserController.class) 注解的 UserControllerTests 类,并自动装配一个 WebTestClient Bean。到目前为止,设置与我们在第 8 章中使用的类似:Web 层的切片测试。但我们需要一些额外的设置来覆盖安全场景,如下所示。
清单 11.17 定义一个类来测试 UserController 的安全策略
@WebFluxTest(UserController.class)
@Import(SecurityConfig.class)
// 导入应用程序的安全配置
class UserControllerTests {
@Autowired
WebTestClient webClient;
@MockBean
// 一个模拟 Bean,用于在检索有关客户端注册的信息时跳过与 Keycloak 的交互
ReactiveClientRegistrationRepository clientRegistrationRepository;
}
由于我们将 Edge Service 配置为在请求未经身份验证时返回 HTTP 401 响应,因此让我们验证在未先进行身份验证的情况下调用 /user 端点时会发生这种情况:
@Test
void whenNotAuthenticatedThen401() {
webClient
.get()
.uri("/user")
.exchange()
.expectStatus().isUnauthorized();
}
要测试用户经过身份验证的场景,我们可以使用 mockOidcLogin(),这是由 SecurityMockServerConfigurers 提供的配置对象,用于模拟 OIDC 登录、合成 ID Token,并相应地修改 WebTestClient 中的请求上下文。
/user 端点通过 OidcUser 对象从 ID Token 中读取声明,因此我们需要构建一个包含用户名、名字和姓氏的 ID Token(角色在控制器中是硬编码的)。以下代码展示了如何操作:
@Test
void whenAuthenticatedThenReturnUser() {
var expectedUser = new User("jon.snow", "Jon", "Snow",
List.of("employee", "customer"));
// 预期经过身份验证的用户
webClient
.mutateWith(configureMockOidcLogin(expectedUser))
// 基于 OIDC 定义身份验证上下文并使用预期用户
.get()
.uri("/user")
.exchange()
.expectStatus().is2xxSuccessful()
.expectBody(User.class)
.value(user -> assertThat(user).isEqualTo(expectedUser));
// 期望一个包含与当前经过身份验证的用户相同信息的 User 对象
}
private SecurityMockServerConfigurers.OidcLoginMutator
configureMockOidcLogin(User expectedUser) {
return SecurityMockServerConfigurers.mockOidcLogin().idToken(
builder -> {
// 构建模拟 ID Token
builder.claim(StandardClaimNames.PREFERRED_USERNAME,
expectedUser.username());
builder.claim(StandardClaimNames.GIVEN_NAME,
expectedUser.firstName());
builder.claim(StandardClaimNames.FAMILY_NAME,
expectedUser.lastName());
});
}
最后,按如下方式运行测试:
$ ./gradlew test --tests UserControllerTests
Spring Security 提供的测试实用程序涵盖广泛的场景,并与 WebTestClient 良好集成。在下一节中,您将看到如何使用类似的方法测试 CSRF。
11.6.2 测试 CSRF
在 Spring Security 中,CSRF 保护默认应用于所有修改 HTTP 请求(如 POST、PUT 和 DELETE)。正如您在前面的部分中所看到的,Edge Service 接受对 /logout 端点的 POST 请求以启动注销流程,此类请求需要有效的 CSRF 令牌才能执行。此外,我们配置了来自 OIDC 的 RP 发起的注销功能,因此对 /logout 的 POST 请求实际上将导致 HTTP 302 响应,将浏览器重定向到 Keycloak 以也将用户从那里注销。
创建一个新的 SecurityConfigTests 类,并使用您在上一节中学到的相同策略来设置具有安全支持的 Spring WebFlux 测试,如下所示。
清单 11.18 定义一个用于测试身份验证流程的类
@WebFluxTest
@Import(SecurityConfig.class)
// 导入应用程序安全配置
class SecurityConfigTests {
@Autowired
WebTestClient webClient;
@MockBean
// 一个模拟 Bean,用于在检索有关客户端注册的信息时跳过与 Keycloak 的交互
ReactiveClientRegistrationRepository clientRegistrationRepository;
}
然后添加一个测试用例,检查在发送带有正确 OIDC 登录和 CSRF 上下文的 HTTP POST 请求到 /logout 后,应用程序是否返回 HTTP 302 响应。
@Test
void whenLogoutAuthenticatedAndWithCsrfTokenThen302() {
when(clientRegistrationRepository.findByRegistrationId("test"))
.thenReturn(Mono.just(testClientRegistration()));
webClient
.mutateWith(SecurityMockServerConfigurers.mockOidcLogin())
// 使用模拟 ID Token 对用户进行身份验证
.mutateWith(SecurityMockServerConfigurers.csrf())
// 增强请求以提供所需的 CSRF 令牌
.post()
.uri("/logout")
.exchange()
.expectStatus().isFound();
// 响应是重定向到 Keycloak 以传播注销操作
}
private ClientRegistration testClientRegistration() {
return ClientRegistration.withRegistrationId("test")
// Spring Security 使用的模拟 ClientRegistration 来获取联系 Keycloak 的 URL
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.clientId("test")
.authorizationUri("https://sso.polarbookshop.com/auth")
.tokenUri("https://sso.polarbookshop.com/token")
.redirectUri("https://polarbookshop.com")
.build();
}
最后,按如下方式运行测试:
$ ./gradlew test --tests SecurityConfigTests
与往常一样,您可以在本书附带的源代码仓库中找到更多测试示例。当涉及到安全性时,单元测试和集成测试对于确保应用程序的正确性至关重要,但它们还不够。这些测试涵盖了默认安全配置,在生产环境中可能有所不同。这就是为什么我们还需要在部署管道的验收阶段(如第 3 章所述)进行面向安全的自动测试,以测试部署在类生产环境中的应用程序。
Polar Labs
到目前为止,唯一应该由用户直接访问的应用程序是 Edge Service。所有其他 Spring Boot 应用程序都在其部署的环境中相互交互。
同一 Docker 网络或 Kubernetes 集群内的服务到服务交互可以分别使用容器名称或服务名称进行配置。例如,Edge Service 通过 Docker 上的 http://polar-ui:9004 URL(<container-name>:<container-port>)和 Kubernetes 上的 http://polar-ui URL(服务名称)将请求转发到 Polar UI。
Keycloak 有所不同,因为它涉及服务到服务的交互(目前,这些只是与 Edge Service 的交互),以及通过 Web 浏览器与最终用户的交互。在生产环境中,Keycloak 将通过应用程序和用户都将使用的公共 URL 访问,因此不会有问题。本地环境呢?
由于我们在本地工作时不处理公共 URL,因此我们需要以不同的方式配置事物。在 Docker 上,我们可以通过使用在安装软件时自动配置的 http://host.docker.internal 特殊 URL 来解决此问题。它解析为您的 localhost IP 地址,可以在 Docker 网络内外使用。
在 Kubernetes 上,我们没有通用的 URL 让集群内的 Pod 访问您的本地主机。这意味着 Edge Service 将通过其服务名称(http://polar-keycloak)与 Keycloak 交互。当 Spring Security 将用户重定向到 Keycloak 以登录时,浏览器将返回错误,因为 http://polar-keycloak URL 无法在集群外解析。为了使之成为可能,我们可以更新本地 DNS 配置以将 polar-keycloak 主机名解析为集群 IP 地址。然后专用的 Ingress 将使在请求定向到 polar-keycloak 主机名时访问 Keycloak 成为可能。
如果您使用的是 Linux 或 macOS,您可以在 /etc/hosts 文件中将 polar-keycloak 主机名映射到 minikube 本地 IP 地址。在 Linux 上,IP 地址是 minikube ip --profile polar 命令返回的地址(如第 9 章所述)。在 macOS 上,它将是 127.0.0.1。打开终端窗口并运行以下命令(确保根据您的操作系统将 <ip-address> 占位符替换为集群 IP 地址):
$ echo "<ip-address> polar-keycloak" | sudo tee -a /etc/hosts
在 Windows 上,您必须在 hosts 文件中将 polar-keycloak 主机名映射到 127.0.0.1。以管理员身份打开 PowerShell 窗口并运行以下命令:
$ Add-Content C:\Windows\System32\drivers\etc\hosts "127.0.0.1 polar-keycloak"
我已经更新了为 Polar Bookshop 部署所有备份服务的脚本,包括 Keycloak 和 Polar UI。您可以从本书附带源代码仓库中的 /Chapter11/11-end/polar-deployment/kubernetes/platform/development 文件夹获取它们,并将它们复制到 polar-deployment 仓库中的相同路径。部署还包括为 Keycloak 配置专用 Ingress,接受定向到 polar-keycloak 主机名的请求。
此时,您可以运行 ./create-cluster.sh 脚本(polar-deployment/kubernetes/platform/development)来启动 minikube 集群并部署 Polar Bookshop 的所有备份服务。如果您使用的是 Linux,您将能够直接访问 Keycloak。如果您使用的是 macOS 或 Windows,请记住首先运行 minikube tunnel --profile polar 命令。无论哪种方式,您都可以打开浏览器窗口并通过 polar-keycloak/(包含尾部斜杠)访问 Keycloak。
最后,在更新 Edge Service 的部署脚本以配置 Polar UI 和 Keycloak 的 URL 后,尝试在 Kubernetes 上运行整个系统。您可以参考本书附带源代码仓库中的 Chapter11/11-end 文件夹以查看最终结果(https://github.com/ThomasVitale/cloud-native-spring-in-action)。
下一章将扩展安全主题。它将介绍如何将身份验证上下文从 Edge Service 传播到下游应用程序,以及如何配置授权。
本章小结
- 访问控制系统需要识别(您是谁?)、身份验证(您能证明确实是您吗?)和授权(您被允许做什么?)。
- 在云原生应用程序中实现身份验证和授权的常见策略基于 JWT 作为数据格式,OAuth2 作为授权框架,OpenID Connect 作为身份验证协议。
- 使用 OIDC 身份验证时,客户端应用程序启动流程并将实际身份验证委托给授权服务器。然后授权服务器向客户端颁发 ID Token。
- ID Token 包含有关用户身份验证的信息。
- Keycloak 是一个支持 OAuth2 和 OpenID Connect 的身份和访问管理解决方案,可用作授权服务器。
- Spring Security 为 OAuth2 和 OpenID Connect 提供原生支持,您可以使用它将 Spring Boot 应用程序转变为 OAuth2 客户端。
- 在 Spring Security 中,您可以在
SecurityWebFilterChainBean 中配置身份验证和授权。要启用 OIDC 身份验证流程,可以使用oauth2Login()DSL。 - 默认情况下,Spring Security 暴露一个
/logout端点用于注销用户。 - 在 OIDC/OAuth2 上下文中,我们还需要将注销请求传播到授权服务器(如 Keycloak)以也将用户从那里注销。我们可以通过 Spring Security 通过
OidcClientInitiatedServerLogoutSuccessHandler类支持的 RP 发起的注销流程来做到这一点。 - 当安全的 Spring Boot 应用程序是 SPA 的后端时,我们需要通过 cookie 配置 CSRF 保护,并实现一个身份验证入口点,在请求未经身份验证时返回 HTTP 401 响应(而不是自动重定向到授权服务器的默认 HTTP 302 响应)。
- Spring Security Test 依赖提供了几个用于测试安全的便捷实用程序。
- 可以通过使用特定的 OIDC 登录和 CSRF 保护配置修改其请求上下文来增强
WebTestClientBean。