跳到主要内容
版本:7.0.2

测试方法安全性

DeepSeek V3 中英对照 Method Security Testing Method Security

本节演示如何使用 Spring Security 的测试支持来测试基于方法的安全性。我们首先引入一个 MessageService,它要求用户必须经过身份验证才能访问:

public class HelloMessageService implements MessageService {

@Override
@PreAuthorize("isAuthenticated()")
public String getMessage() {
Authentication authentication = SecurityContextHolder.getContext()
.getAuthentication();
return "Hello " + authentication;
}

@Override
@PreAuthorize("isAuthenticated()")
public String getJsrMessage() {
Authentication authentication = SecurityContextHolder.getContext()
.getAuthentication();
return "Hello JSR " + authentication;
}
}

getMessage 的结果是一个 String,用于向当前的 Spring Security Authentication 说“Hello”。以下清单展示了示例输出:

Hello org.springframework.security.authentication.UsernamePasswordAuthenticationToken@ca25360: Principal: org.springframework.security.core.userdetails.User@36ebcb: Username: user; Password: [PROTECTED]; Enabled: true; AccountNonExpired: true; credentialsNonExpired: true; AccountNonLocked: true; Granted Authorities: ROLE_USER; Credentials: [PROTECTED]; Authenticated: true; Details: null; Granted Authorities: ROLE_USER

安全测试设置

在使用 Spring Security 测试支持之前,我们必须进行一些设置:

@ExtendWith(SpringExtension.class) 1
@ContextConfiguration 2
class WithMockUserTests {
// ...
}
  • @ExtendWith 指示 spring-test 模块创建 ApplicationContext。更多信息,请参阅 Spring 参考文档

  • @ContextConfiguration 指示 spring-test 使用何种配置来创建 ApplicationContext。由于未指定配置,将尝试使用默认的配置位置。这与使用现有的 Spring Test 支持没有区别。更多信息,请参阅 Spring 参考文档

备注

Spring Security 通过 WithSecurityContextTestExecutionListener 与 Spring Test 支持进行集成,确保我们的测试在正确的用户环境下运行。其实现方式是在运行测试之前填充 SecurityContextHolder。如果你使用响应式方法安全,还需要 ReactorContextTestExecutionListener,它会填充 ReactiveSecurityContextHolder。测试完成后,它会清空 SecurityContextHolder。如果仅需要 Spring Security 相关的支持,你可以用 @SecurityTestExecutionListeners 替换 @ContextConfiguration

记住,我们已经在 HelloMessageService 上添加了 @PreAuthorize 注解,因此调用它需要一个已认证的用户。如果我们运行测试,预计以下测试会通过:

@Test
void getMessageUnauthenticated() {
assertThatExceptionOfType(AuthenticationCredentialsNotFoundException.class)
.isThrownBy(() -> messageService.getMessage());
}

@WithMockUser

问题是“我们如何能最轻松地以特定用户身份运行测试?”答案是使用 @WithMockUser。以下测试将以用户名“user”、密码“password”和角色“ROLE_USER”的用户身份运行。

@Test
@WithMockUser
void getMessageWithMockUser() {
String message = messageService.getMessage();
assertThat(message).contains("user");
}

具体而言,以下情况成立:

  • 用户名为 user 的用户不必实际存在,因为我们模拟了用户对象。

  • SecurityContext 中填充的 AuthenticationUsernamePasswordAuthenticationToken 类型。

  • Authentication 上的主体是 Spring Security 的 User 对象。

  • User 的用户名为 user

  • User 的密码为 password

  • 使用了一个名为 ROLE_USERGrantedAuthority

前面的例子很方便,因为它允许我们使用许多默认设置。如果我们想用不同的用户名运行测试呢?下面的测试将使用 customUser 作为用户名运行(再次强调,该用户实际上不需要存在):

@Test
@WithMockUser("customUser")
void getMessageWithMockUserCustomUsername() {
String message = messageService.getMessage();
assertThat(message).contains("customUser");
}

我们也可以轻松地自定义角色。例如,以下测试调用时使用的用户名为 admin,角色为 ROLE_USERROLE_ADMIN

@Test
@WithMockUser(username = "admin", roles = {"USER", "ADMIN"})
void getMessageWithMockUserCustomRoles() {
String message = messageService.getMessage();
assertThat(message)
.contains("admin")
.contains("ROLE_ADMIN")
.contains("ROLE_USER");
}

如果我们不希望值自动添加 ROLE_ 前缀,可以使用 authorities 属性。例如,以下测试将以用户名 admin 以及 USERADMIN 权限被调用。

@Test
@WithMockUser(username = "admin", authorities = {"ADMIN", "USER"})
public void getMessageWithMockUserCustomAuthorities() {
String message = messageService.getMessage();
assertThat(message)
.contains("admin")
.contains("ADMIN")
.contains("USER")
.doesNotContain("ROLE_");
}

在每个测试方法上添加注解可能会有些繁琐。我们可以将注解放在类级别上,这样每个测试都会使用指定的用户。以下示例展示了如何让每个测试都以用户名为 admin、密码为 password,且拥有 ROLE_USERROLE_ADMIN 角色的用户身份运行:

@ExtendWith(SpringExtension.class)
@ContextConfiguration
@WithMockUser(username = "admin", roles = {"USER", "ADMIN"})
class WithMockUserClassTests {
// ...
}

如果你使用 JUnit 5 的 @Nested 测试支持,也可以将注解放在外层类上,使其应用于所有嵌套类。以下示例为两个测试方法中的每一个都运行一个用户名为 admin、密码为 password 且拥有 ROLE_USERROLE_ADMIN 角色的用户。

@ExtendWith(SpringExtension.class)
@ContextConfiguration
@WithMockUser(username = "admin", roles = {"USER", "ADMIN"})
class WithMockUserNestedTests {

@Nested
class TestSuite1 {
// ... all test methods use admin user
}

@Nested
class TestSuite2 {
// ... all test methods use admin user
}
}

默认情况下,SecurityContext 会在 TestExecutionListener.beforeTestMethod 事件期间被设置。这相当于在 JUnit 的 @Before 之前发生。你可以将其更改为在 TestExecutionListener.beforeTestExecution 事件期间发生,该事件在 JUnit 的 @Before 之后,但在测试方法被调用之前:

@WithMockUser(setupBefore = TestExecutionEvent.TEST_EXECUTION)

@WithAnonymousUser

使用 @WithAnonymousUser 可以以匿名用户身份运行测试。这在您希望使用特定用户运行大多数测试,但需要以匿名用户身份运行少数测试时尤为方便。以下示例通过 @WithMockUser 运行 withMockUser1withMockUser2,并以匿名用户身份运行 anonymous

@ExtendWith(SpringExtension.class)
@WithMockUser
class WithUserClassLevelAuthenticationTests {

@Test
void withMockUser1() {
}

@Test
void withMockUser2() {
}

@Test
@WithAnonymousUser
void anonymous() throws Exception {
// override default to run as anonymous user
}
}

默认情况下,SecurityContext 会在 TestExecutionListener.beforeTestMethod 事件期间被设置。这相当于在 JUnit 的 @Before 之前发生。你可以将其更改为在 TestExecutionListener.beforeTestExecution 事件期间发生,该事件在 JUnit 的 @Before 之后,但在测试方法被调用之前:

@WithAnonymousUser(setupBefore = TestExecutionEvent.TEST_EXECUTION)

@WithUserDetails

虽然 @WithMockUser 是一种便捷的入门方式,但它可能无法适用于所有场景。例如,某些应用程序期望 Authentication 主体是特定类型。这样做是为了让应用程序能够将主体引用为自定义类型,从而减少与 Spring Security 的耦合。

自定义主体通常由自定义的 UserDetailsService 返回,该服务返回一个同时实现 UserDetails 和自定义类型的对象。对于这种情况,通过使用自定义的 UserDetailsService 来创建测试用户会很有用。这正是 @WithUserDetails 注解的作用。

假设我们有一个暴露为bean的UserDetailsService,以下测试将通过一个类型为UsernamePasswordAuthenticationTokenAuthentication对象以及一个从UserDetailsService返回的、用户名为user的主体来调用:

@Test
@WithUserDetails
void getMessageWithUserDetails() {
String message = messageService.getMessage();
assertThat(message).contains("user");
}

我们也可以自定义用于从 UserDetailsService 中查找用户的用户名。例如,以下测试可以使用从 UserDetailsService 返回的、用户名为 customUsername 的主体来运行:

@Test
@WithUserDetails("customUsername")
void getMessageWithUserDetailsCustomUsername() {
String message = messageService.getMessage();
assertThat(message).contains("customUsername");
}

我们也可以提供一个明确的 bean 名称来查找 UserDetailsService。以下测试通过使用 bean 名称为 myUserDetailsServiceUserDetailsService 来查找用户名为 customUsername 的用户:

@Test
@WithUserDetails(value="customUsername", userDetailsServiceBeanName="myUserDetailsService")
void getMessageWithUserDetailsServiceBeanName() {
String message = messageService.getMessage();
assertThat(message).contains("customUsername");
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
assertThat(principal).isInstanceOf(CustomUserDetails.class);
}

正如我们对 @WithMockUser 所做的那样,我们也可以将注解放在类级别,这样每个测试都会使用相同的用户。然而,与 @WithMockUser 不同,@WithUserDetails 要求用户必须存在。

默认情况下,SecurityContext 会在 TestExecutionListener.beforeTestMethod 事件期间设置。这相当于在 JUnit 的 @Before 之前发生。你可以将其更改为在 TestExecutionListener.beforeTestExecution 事件期间发生,该事件在 JUnit 的 @Before 之后,但在测试方法被调用之前:

@WithUserDetails(setupBefore = TestExecutionEvent.TEST_EXECUTION)

@WithSecurityContext

我们已经看到,如果不使用自定义的 Authentication 主体,@WithMockUser 是一个极佳的选择。接着,我们发现 @WithUserDetails 允许我们使用自定义的 UserDetailsService 来创建 Authentication 主体,但要求用户必须存在。现在,我们将看到一个提供最大灵活性的选项。

我们可以创建自己的注解,利用 @WithSecurityContext 来创建任何我们想要的 SecurityContext。例如,我们可以创建一个名为 @WithMockCustomUser 的注解:

@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class)
public @interface WithMockCustomUser {

String username() default "rob";

String name() default "Rob Winch";
}

可以看到,@WithMockCustomUser 注解被标注了 @WithSecurityContext 注解。这向 Spring Security 测试支持表明我们打算为测试创建一个 SecurityContext@WithSecurityContext 注解要求我们指定一个 SecurityContextFactory,以便根据我们的 @WithMockCustomUser 注解来创建新的 SecurityContext。以下清单展示了我们的 WithMockCustomUserSecurityContextFactory 实现:

public class WithMockCustomUserSecurityContextFactory
implements WithSecurityContextFactory<WithMockCustomUser> {

@Override
public SecurityContext createSecurityContext(WithMockCustomUser customUser) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
CustomUserDetails principal = new CustomUserDetails(customUser.name(), customUser.username());
Authentication auth = UsernamePasswordAuthenticationToken.authenticated(principal, "password",
principal.getAuthorities());
context.setAuthentication(auth);
return context;
}

}

现在,我们可以使用新的注解以及Spring Security的WithSecurityContextTestExecutionListener来标注测试类或测试方法,以确保我们的SecurityContext能够被正确地填充。

在创建自定义的 WithSecurityContextFactory 实现时,了解它们可以使用标准 Spring 注解进行标注会很有帮助。例如,WithUserDetailsSecurityContextFactory 就使用了 @Autowired 注解来获取 UserDetailsService

final class WithUserDetailsSecurityContextFactory
implements WithSecurityContextFactory<WithUserDetails> {

private final UserDetailsService userDetailsService;

@Autowired
public WithUserDetailsSecurityContextFactory(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}

public SecurityContext createSecurityContext(WithUserDetails withUser) {
String username = withUser.value();
Assert.hasLength(username, "value() must be non-empty String");
UserDetails principal = userDetailsService.loadUserByUsername(username);
Authentication authentication = UsernamePasswordAuthenticationToken.authenticated(principal, principal.getPassword(), principal.getAuthorities());
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
return context;
}
}

默认情况下,SecurityContext 会在 TestExecutionListener.beforeTestMethod 事件期间设置。这相当于在 JUnit 的 @Before 之前发生。你可以将其更改为在 TestExecutionListener.beforeTestExecution 事件期间发生,该事件在 JUnit 的 @Before 之后,但在测试方法被调用之前:

@WithSecurityContext(setupBefore = TestExecutionEvent.TEST_EXECUTION)

测试元注解

如果在测试中频繁复用同一个用户,反复指定其属性并不理想。例如,若存在大量与管理员用户相关的测试,其用户名为 admin,角色为 ROLE_USERROLE_ADMIN,则每次都需要编写:

@WithMockUser(username = "admin", roles = {"USER", "ADMIN"})

与其在每个地方重复这些代码,我们可以使用元注解。例如,我们可以创建一个名为 WithMockAdmin 的元注解:

@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(value="rob",roles={"USER","ADMIN"})
public @interface WithMockAdmin { }

现在我们可以像使用更冗长的 @WithMockUser 一样来使用 @WithMockAdmin

元注解可以与上述任何测试注解配合使用。这意味着我们同样可以为 @WithUserDetails("admin") 创建元注解。