跳到主要内容
版本:7.0.2

测试 OAuth 2.0

DeepSeek V3 中英对照 Testing OAuth 2.0

在 OAuth 2.0 的场景下,先前讨论的基本原则依然适用:最终,这取决于您正在测试的方法期望在 SecurityContextHolder 中包含什么内容。

考虑以下控制器示例:

@GetMapping("/endpoint")
public Mono<String> foo(Principal user) {
return Mono.just(user.getName());
}

这与 OAuth2 无关,因此你可以使用 @WithMockUser 并确保一切正常。

然而,考虑这样一种情况:你的控制器与Spring Security的OAuth 2.0支持的某些方面绑定:

@GetMapping("/endpoint")
public Mono<String> foo(@AuthenticationPrincipal OidcUser user) {
return Mono.just(user.getIdToken().getSubject());
}

在这种情况下,Spring Security的测试支持功能就派上用场了。

测试 OIDC 登录

使用 WebTestClient 测试上一节中展示的方法,需要模拟授权服务器的某种授权流程。这是一项艰巨的任务,因此 Spring Security 内置了消除这种样板代码的支持。

例如,我们可以通过使用 SecurityMockServerConfigurers#oidcLogin 方法,来指示 Spring Security 包含一个默认的 OidcUser

client
.mutateWith(mockOidcLogin()).get().uri("/endpoint").exchange();

该行配置了关联的 MockServerRequest,使其包含一个 OidcUser,该用户对象包含一个简单的 OidcIdToken、一个 OidcUserInfo 以及一个已授予权限的 Collection

具体来说,它包含一个 OidcIdToken,其 sub 声明被设置为 user

assertThat(user.getIdToken().getClaim("sub")).isEqualTo("user");

它还包括一个未设置任何声明的 OidcUserInfo

assertThat(user.getUserInfo().getClaims()).isEmpty();

它还包括一个仅包含一个权限 SCOPE_readCollection 权限集合:

assertThat(user.getAuthorities()).hasSize(1);
assertThat(user.getAuthorities()).containsExactly(new SimpleGrantedAuthority("SCOPE_read"));

Spring Security 确保 OidcUser 实例可用于 @AuthenticationPrincipal 注解

此外,它还将 OidcUser 与一个简单的 OAuth2AuthorizedClient 实例关联,并将其存入模拟的 ServerOAuth2AuthorizedClientRepository 中。如果你的测试使用了 @RegisteredOAuth2AuthorizedClient 注解,这会非常方便。

配置权限

在许多情况下,您的方法受到过滤器或方法安全的保护,需要您的 Authentication 拥有特定的授予权限才能允许请求。

在这种情况下,你可以通过使用 authorities() 方法来提供所需的授权权限:

client
.mutateWith(mockOidcLogin()
.authorities(new SimpleGrantedAuthority("SCOPE_message:read"))
)
.get().uri("/endpoint").exchange();

配置声明

尽管授予的权限在Spring Security中普遍存在,但在OAuth 2.0的情况下,我们还有声明。

例如,假设你有一个 user_id 声明,它表示用户在你系统中的 ID。在控制器中,你可以按如下方式访问它:

@GetMapping("/endpoint")
public Mono<String> foo(@AuthenticationPrincipal OidcUser oidcUser) {
String userId = oidcUser.getIdToken().getClaim("user_id");
// ...
}

在这种情况下,您可以使用 idToken() 方法来指定该声明:

client
.mutateWith(mockOidcLogin()
.idToken((token) -> token.claim("user_id", "1234"))
)
.get().uri("/endpoint").exchange();

这是因为 OidcUser 会从 OidcIdToken 中收集其声明。

额外配置

此外,根据控制器所需的数据,还有更多方法可用于进一步配置身份验证:

  • userInfo(OidcUserInfo.Builder): 配置 OidcUserInfo 实例

  • clientRegistration(ClientRegistration): 使用给定的 ClientRegistration 配置关联的 OAuth2AuthorizedClient

  • oidcUser(OidcUser): 配置完整的 OidcUser 实例

如果你遇到以下情况,最后一种方法会很方便:* 拥有自己的 OidcUser 实现,或 * 需要更改名称属性

例如,假设你的授权服务器在 user_name 声明中发送主体名称,而不是在 sub 声明中。在这种情况下,你可以手动配置一个 OidcUser

OidcUser oidcUser = new DefaultOidcUser(
AuthorityUtils.createAuthorityList("SCOPE_message:read"),
OidcIdToken.withTokenValue("id-token").claim("user_name", "foo_user").build(),
"user_name");

client
.mutateWith(mockOidcLogin().oidcUser(oidcUser))
.get().uri("/endpoint").exchange();

测试 OAuth 2.0 登录

测试 OIDC 登录类似,测试 OAuth 2.0 登录也面临类似的挑战:模拟授权流程。因此,Spring Security 也为非 OIDC 用例提供了测试支持。

假设我们有一个控制器,它获取已登录用户作为 OAuth2User

@GetMapping("/endpoint")
public Mono<String> foo(@AuthenticationPrincipal OAuth2User oauth2User) {
return Mono.just(oauth2User.getAttribute("sub"));
}

在这种情况下,我们可以通过使用 SecurityMockServerConfigurers#oauth2User 方法,来让 Spring Security 包含一个默认的 OAuth2User

client
.mutateWith(mockOAuth2Login())
.get().uri("/endpoint").exchange();

前面的示例将关联的 MockServerRequest 配置为包含一个简单的属性 Map 和一个已授权权限 CollectionOAuth2User

具体来说,它包含一个 Map,其中包含一个键值对 sub/user

assertThat((String) user.getAttribute("sub")).isEqualTo("user");

它还包括一个仅包含一个权限 SCOPE_readCollection 权限集合:

assertThat(user.getAuthorities()).hasSize(1);
assertThat(user.getAuthorities()).containsExactly(new SimpleGrantedAuthority("SCOPE_read"));

Spring Security 会完成必要的工作,以确保 OAuth2User 实例可用于 @AuthenticationPrincipal 注解

此外,它还将该 OAuth2User 与一个简单的 OAuth2AuthorizedClient 实例关联,并将其存入模拟的 ServerOAuth2AuthorizedClientRepository 中。如果你的测试使用了 @RegisteredOAuth2AuthorizedClient 注解,这会非常方便。

配置权限

在许多情况下,您的方法受到过滤器或方法安全的保护,需要您的 Authentication 拥有特定的授予权限才能允许请求。

在这种情况下,你可以通过使用 authorities() 方法来提供所需的授权权限:

client
.mutateWith(mockOAuth2Login()
.authorities(new SimpleGrantedAuthority("SCOPE_message:read"))
)
.get().uri("/endpoint").exchange();

配置声明

尽管授予的权限在Spring Security中非常普遍,但在OAuth 2.0的情况下,我们还有声明。

例如,假设你有一个 user_id 属性,它表示用户在系统中的 ID。在控制器中,你可以按如下方式访问它:

@GetMapping("/endpoint")
public Mono<String> foo(@AuthenticationPrincipal OAuth2User oauth2User) {
String userId = oauth2User.getAttribute("user_id");
// ...
}

在这种情况下,你可以使用 attributes() 方法来指定该属性:

client
.mutateWith(mockOAuth2Login()
.attributes((attrs) -> attrs.put("user_id", "1234"))
)
.get().uri("/endpoint").exchange();

附加配置

此外,根据控制器所需的数据,还有更多方法可用于进一步配置身份验证:

  • clientRegistration(ClientRegistration):使用给定的 ClientRegistration 配置关联的 OAuth2AuthorizedClient

  • oauth2User(OAuth2User):配置完整的 OAuth2User 实例

如果你满足以下任一情况,这个功能会非常有用:* 拥有自己的 OAuth2User 实现,或 * 需要更改名称属性

例如,假设您的授权服务器在user_name声明而非sub声明中发送主体名称。在这种情况下,您可以手动配置一个OAuth2User

OAuth2User oauth2User = new DefaultOAuth2User(
AuthorityUtils.createAuthorityList("SCOPE_message:read"),
Collections.singletonMap("user_name", "foo_user"),
"user_name");

client
.mutateWith(mockOAuth2Login().oauth2User(oauth2User))
.get().uri("/endpoint").exchange();

测试 OAuth 2.0 客户端

无论您的用户如何进行身份验证,您可能还有其他令牌和客户端注册信息在您正在测试的请求中起作用。例如,您的控制器可能依赖客户端凭据授权来获取一个与用户完全无关的令牌:

@GetMapping("/endpoint")
public Mono<String> foo(@RegisteredOAuth2AuthorizedClient("my-app") OAuth2AuthorizedClient authorizedClient) {
return this.webClient.get()
.attributes(oauth2AuthorizedClient(authorizedClient))
.retrieve()
.bodyToMono(String.class);
}

模拟与授权服务器的握手过程可能相当繁琐。作为替代方案,你可以使用 SecurityMockServerConfigurers#oauth2Client 来向一个模拟的 ServerOAuth2AuthorizedClientRepository 中添加一个 OAuth2AuthorizedClient

client
.mutateWith(mockOAuth2Client("my-app"))
.get().uri("/endpoint").exchange();

这将创建一个包含简单 ClientRegistrationOAuth2AccessToken 以及资源所有者名称的 OAuth2AuthorizedClient

具体来说,它包含一个 ClientRegistration,其客户端 ID 为 test-client,客户端密钥为 test-secret

assertThat(authorizedClient.getClientRegistration().getClientId()).isEqualTo("test-client");
assertThat(authorizedClient.getClientRegistration().getClientSecret()).isEqualTo("test-secret");

它还包括一个资源所有者名称 user

assertThat(authorizedClient.getPrincipalName()).isEqualTo("user");

它还包含一个带有 read 范围的 OAuth2AccessToken

assertThat(authorizedClient.getAccessToken().getScopes()).hasSize(1);
assertThat(authorizedClient.getAccessToken().getScopes()).containsExactly("read");

然后,你可以像往常一样,通过在控制器方法中使用 @RegisteredOAuth2AuthorizedClient 来获取客户端。

配置作用域

在许多情况下,OAuth 2.0 访问令牌会附带一组权限范围。以下示例展示了控制器如何检查这些权限范围:

@GetMapping("/endpoint")
public Mono<String> foo(@RegisteredOAuth2AuthorizedClient("my-app") OAuth2AuthorizedClient authorizedClient) {
Set<String> scopes = authorizedClient.getAccessToken().getScopes();
if (scopes.contains("message:read")) {
return this.webClient.get()
.attributes(oauth2AuthorizedClient(authorizedClient))
.retrieve()
.bodyToMono(String.class);
}
// ...
}

给定一个用于检查作用域的控制器,你可以通过使用 accessToken() 方法来配置作用域:

client
.mutateWith(mockOAuth2Client("my-app")
.accessToken(new OAuth2AccessToken(BEARER, "token", null, null, Collections.singleton("message:read")))
)
.get().uri("/endpoint").exchange();

附加配置

您还可以根据控制器期望的数据,使用其他方法进一步配置身份验证:

  • principalName(String):配置资源所有者名称

  • clientRegistration(Consumer<ClientRegistration.Builder>):配置关联的 ClientRegistration

  • clientRegistration(ClientRegistration):配置完整的 ClientRegistration

如果你想使用一个真实的 ClientRegistration,最后一种方法会很方便。

例如,假设您希望使用应用程序中的一个 ClientRegistration 定义,该定义已在您的 application.yml 中指定。

在这种情况下,你的测试可以自动装配 ReactiveClientRegistrationRepository 并查找测试所需的那一个:

@Autowired
ReactiveClientRegistrationRepository clientRegistrationRepository;

// ...

client
.mutateWith(mockOAuth2Client()
.clientRegistration(this.clientRegistrationRepository.findByRegistrationId("facebook").block())
)
.get().uri("/exchange").exchange();

测试 JWT 身份验证

要在资源服务器上进行授权请求,你需要一个承载令牌。如果你的资源服务器配置为使用JWT,那么承载令牌需要按照JWT规范进行签名和编码。所有这些操作可能相当令人望而生畏,尤其是在测试的重点不在此处时。

幸运的是,有几种简单的方法可以克服这个困难,让你的测试专注于授权,而不是承载令牌的表示。我们将在接下来的两个小节中介绍其中的两种方法。

mockJwt() WebTestClientConfigurer

第一种方式是使用 WebTestClientConfigurer。其中最简单的方法是使用 SecurityMockServerConfigurers#mockJwt,如下所示:

client
.mutateWith(mockJwt()).get().uri("/endpoint").exchange();

此示例创建了一个模拟的 Jwt,并将其传递给所有身份验证 API,以便您的授权机制能够对其进行验证。

默认情况下,它创建的 JWT 具有以下特征:

{
"headers" : { "alg" : "none" },
"claims" : {
"sub" : "user",
"scope" : "read"
}
}

生成的 Jwt 如果经过测试,将通过以下方式验证:

assertThat(jwt.getTokenValue()).isEqualTo("token");
assertThat(jwt.getHeaders().get("alg")).isEqualTo("none");
assertThat(jwt.getSubject()).isEqualTo("sub");

请注意,这些值需要由您来配置。

您也可以通过相应的方法配置任何头部信息或声明:

client
.mutateWith(mockJwt().jwt((jwt) -> jwt.header("kid", "one")
.claim("iss", "https://idp.example.org")))
.get().uri("/endpoint").exchange();
client
.mutateWith(mockJwt().jwt((jwt) -> jwt.claims((claims) -> claims.remove("scope"))))
.get().uri("/endpoint").exchange();

scopescp 声明在此处的处理方式与普通持有者令牌请求中的处理方式相同。不过,您可以通过为测试提供所需的 GrantedAuthority 实例列表来轻松覆盖此默认行为:

client
.mutateWith(mockJwt().authorities(new SimpleGrantedAuthority("SCOPE_messages")))
.get().uri("/endpoint").exchange();

或者,如果您有一个自定义的 JwtCollection<GrantedAuthority> 转换器,也可以用它来派生权限:

client
.mutateWith(mockJwt().authorities(new MyConverter()))
.get().uri("/endpoint").exchange();

您也可以指定一个完整的 Jwt,此时 Jwt.Builder 会非常方便:

Jwt jwt = Jwt.withTokenValue("token")
.header("alg", "none")
.claim("sub", "user")
.claim("scope", "read")
.build();

client
.mutateWith(mockJwt().jwt(jwt))
.get().uri("/endpoint").exchange();

authentication()WebTestClientConfigurer

第二种方式是通过使用 authentication() Mutator。你可以实例化你自己的 JwtAuthenticationToken 并在测试中提供它:

Jwt jwt = Jwt.withTokenValue("token")
.header("alg", "none")
.claim("sub", "user")
.build();
Collection<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList("SCOPE_read");
JwtAuthenticationToken token = new JwtAuthenticationToken(jwt, authorities);

client
.mutateWith(mockAuthentication(token))
.get().uri("/endpoint").exchange();

请注意,作为替代方案,你也可以使用 @MockBean 注解来模拟 ReactiveJwtDecoder bean 本身。

测试不透明令牌认证

JWT令牌类似,不透明令牌需要授权服务器来验证其有效性,这可能使测试变得更加困难。为了帮助解决这个问题,Spring Security 提供了对不透明令牌的测试支持。

假设你有一个控制器,它获取的身份验证信息是 BearerTokenAuthentication 类型:

@GetMapping("/endpoint")
public Mono<String> foo(BearerTokenAuthentication authentication) {
return Mono.just((String) authentication.getTokenAttributes().get("sub"));
}

在这种情况下,你可以通过使用 SecurityMockServerConfigurers#opaqueToken 方法,来告诉 Spring Security 包含一个默认的 BearerTokenAuthentication

client
.mutateWith(mockOpaqueToken())
.get().uri("/endpoint").exchange();

此示例配置了关联的 MockHttpServletRequest,其中包含一个 BearerTokenAuthentication,该认证包含一个简单的 OAuth2AuthenticatedPrincipal、一个属性 Map 以及一个授予权限的 Collection

具体来说,它包含一个Map,其中包含一个键值对sub/user

assertThat((String) token.getTokenAttributes().get("sub")).isEqualTo("user");

它还包括一个仅包含一个权限 SCOPE_readCollection 权限集合:

assertThat(token.getAuthorities()).hasSize(1);
assertThat(token.getAuthorities()).containsExactly(new SimpleGrantedAuthority("SCOPE_read"));

Spring Security 会完成必要的工作,以确保 BearerTokenAuthentication 实例在您的控制器方法中可用。

配置权限

在许多情况下,您的方法受到过滤器或方法安全的保护,需要您的 Authentication 拥有特定的授予权限才能允许请求。

在这种情况下,你可以通过 authorities() 方法指定所需的授权权限:

client
.mutateWith(mockOpaqueToken()
.authorities(new SimpleGrantedAuthority("SCOPE_message:read"))
)
.get().uri("/endpoint").exchange();

配置声明

尽管授予的权限在Spring Security中相当常见,但在OAuth 2.0的情况下,我们还有属性。

例如,假设你有一个 user_id 属性,它表示用户在系统中的 ID。在控制器中,你可以按如下方式访问它:

@GetMapping("/endpoint")
public Mono<String> foo(BearerTokenAuthentication authentication) {
String userId = (String) authentication.getTokenAttributes().get("user_id");
// ...
}

在这种情况下,你可以使用 attributes() 方法来指定该属性:

client
.mutateWith(mockOpaqueToken()
.attributes((attrs) -> attrs.put("user_id", "1234"))
)
.get().uri("/endpoint").exchange();

额外配置

您还可以根据控制器期望的数据,使用其他方法来进一步配置身份验证。

其中一种方法是 principal(OAuth2AuthenticatedPrincipal),你可以用它来配置构成 BearerTokenAuthentication 基础的完整 OAuth2AuthenticatedPrincipal 实例。

如果您满足以下情况,这将非常方便:* 拥有自己的 OAuth2AuthenticatedPrincipal 实现,或者 * 希望指定不同的主体名称

例如,假设您的授权服务器将主体名称放在 user_name 属性中,而不是 sub 属性中。在这种情况下,您可以手动配置一个 OAuth2AuthenticatedPrincipal

Map<String, Object> attributes = Collections.singletonMap("user_name", "foo_user");
OAuth2AuthenticatedPrincipal principal = new DefaultOAuth2AuthenticatedPrincipal(
(String) attributes.get("user_name"),
attributes,
AuthorityUtils.createAuthorityList("SCOPE_message:read"));

client
.mutateWith(mockOpaqueToken().principal(principal))
.get().uri("/endpoint").exchange();

请注意,除了使用 mockOpaqueToken() 测试支持外,你也可以通过 @MockBean 注解来模拟 OpaqueTokenIntrospector bean 本身。