章节 III. 测试

本节描述 Spring Security 提供的测试支持。

[Tip] Tip

要使用 Spring Security 测试支持,必须将 spring-security-test-5.1.0.BUILD-SNAPSHOT.jar 作为项目的依赖项。

11. 测试方法安全

本节演示如何使用 Spring Security 的测试支持来测试基于方法的安全性。我们首先介绍一个 MessageService,要求用户进行身份验证以便访问它。

public class HelloMessageService implements MessageService {

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

getMessage 的结果是一个字符串,表示当前的 Spring Security Authentication 的 saying "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

11.1 安全测试设置

在使用 Spring Security 测试支持之前,我们必须执行一些设置。下面可以看到一个例子:

@RunWith(SpringJUnit4ClassRunner.class) 1
@ContextConfiguration 2
public class WithMockUserTests {

这是如何设置 Spring Security 测试的一个基本示例。亮点是:

1

@RunWith 指示 Spring 测试模块应该创建一个 ApplicationContext。这与使用现有的 Spring 测试支持没有什么不同。有关附加信息,请参阅 Spring 参考文档。

2

@ContextConfiguration 指示 Spring 测试用于创建 ApplicationContext 的配置。由于未指定配置,因此将尝试默认配置位置。这与使用现有的 Spring 测试支持没有什么不同。有关附加信息,请参阅 Spring 参考文档。

[Note] Note

Spring Security 使用 WithSecurityContextTestExecutionListener 挂钩到 Spring 测试支持中,这将确保我们的测试与正确的用户一起运行。它通过在运行测试之前填充 SecurityContextHolder 来实现这一点。如果你正在使用响应式方法安全性,你还需要 ReactorContextTestExecutionListener,它填充 ReactiveSecurityContextHolder。在测试完成后,它将清除 SecurityContextHolder。如果只需要与 Spring Security 相关的支持,可以用 @SecurityTestExecutionListeners 替换 @ContextConfiguration 配置。

记住,我们在 HelloMessageService 中添加了 @PreAuthorize 注解,所以它需要一个经过验证的用户来调用它。如果我们运行下面的测试,我们预期下面的测试将通过:

@Test(expected = AuthenticationCredentialsNotFoundException.class)
public void getMessageUnauthenticated() {
	messageService.getMessage();
}

11.2 @WithMockUser

问题是我们如何最容易地将测试作为一个特定的用户来运行?答案是使用 @WithMockUser。下面的测试将用用户名 "user"、密码 "password" 和 角色 "ROLE_USER" 的用户运行。

@Test
@WithMockUser
public void getMessageWithMockUser() {
String message = messageService.getMessage();
...
}

具体地说,以下是正确的:

  • 用户名 "user" 的用户不必存在,因为我们在模拟用户。
  • 在 SecurityContext 中填充的 Authentication 类型为 UsernamePasswordAuthenticationToken。
  • Authentication 的主体是 Spring Security 的 User 对象
  • User 将有用户名为 "user"、密码为 "password" 以及命名为 "ROLE_USER" 使用的单个 GrantedAuthority。

我们的例子很好,因为我们能够利用很多默认值。如果我们想用不同的用户名运行测试怎么办?下面的测试将使用用户名 "customUser" 运行。同样,用户不需要实际存在。

@Test
@WithMockUser("customUsername")
public void getMessageWithMockUserCustomUsername() {
	String message = messageService.getMessage();
...
}

我们也可以轻松定制角色。例如,这个测试将用用户名 "admin" 和角色 "ROLE_USER" 和 "ROLE_ADMIN" 来调用。

@Test
@WithMockUser(username="admin",roles={"USER","ADMIN"})
public void getMessageWithMockUserCustomUser() {
	String message = messageService.getMessage();
	...
}

如果我们不希望用 ROLE_ 自动前缀值,我们可以利用权限属性。例如,这个测试将用用户名 "admin" 和权限 "USER" 和 "ADMIN" 来调用。

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

当然,把注解放在每一种测试方法上都有点乏味。相反,我们可以将注解放置在类级别,并且每个测试都将使用指定的用户。例如,下面将使用用户名 "admin"、密码 "password" 以及角色 "ROLE_USER" 和 "ROLE_ADMIN" 运行每个测试。

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration
@WithMockUser(username="admin",roles={"USER","ADMIN"})
public class WithMockUserTests {

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

@WithMockUser(setupBefore = TestExecutionEvent.TEST_EXECUTION)

11.3 @WithAnonymousUser

使用 @WithAnonymousUser 允许作为匿名用户运行。当你希望使用特定用户运行大多数测试,但是希望以匿名用户身份运行一些测试时,这尤其方便。例如,下面将使用 @WithMockUser 和匿名作为匿名用户运行 withMockUser1 和 withMockUser2。

@RunWith(SpringJUnit4ClassRunner.class)
@WithMockUser
public class WithUserClassLevelAuthenticationTests {

	@Test
	public void withMockUser1() {
	}

	@Test
	public void withMockUser2() {
	}

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

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

@WithAnonymousUser(setupBefore = TestExecutionEvent.TEST_EXECUTION)

11.4 @WithUserDetails

虽然 @WithMockUser 是一种非常方便的启动方法,但无法在所有情况下都能工作。例如,应用程序通常希望 Authentication 主体是特定类型的。这样做可以使应用程序可以将主体引用为自定义类型,并减少对 Spring Security 的耦合。

自定义主体通常由返回实现 UserDetails 和自定义类型的对象的自定义 UserDetailsService 返回。对于这样的情况,使用自定义 UserDetailsService 创建测试用户是有用的。这正是 @WithUserDetails 所做的细节。

假设我们有一个 UserDetailsService 作为 bean 公开,那么以下测试将用 UsernamePasswordAuthenticationToken 类型的 Authentication 和从 UserDetailsService 返回的具有 "user" 用户名的主体来调用。

@Test
@WithUserDetails
public void getMessageWithUserDetails() {
	String message = messageService.getMessage();
	...
}

我们还可以自定义用于从我们的 UserDetailsService 查找用户的用户名。例如,这个测试将使用从 UserDetailsService 返回的用户名为 "customUsername" 的主体来执行。

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

我们还可以提供一个显式 bean 名称来查找 UserDetailsService。例如,该测试将使用具有 bean 名称 "myUserDetailsService" 的 UserDetailsService 查找 "customUsername" 的用户名。

@Test
@WithUserDetails(value="customUsername", userDetailsServiceBeanName="myUserDetailsService")
public void getMessageWithUserDetailsServiceBeanName() {
	String message = messageService.getMessage();
	...
}

像 @WithMockUser 一样,我们也可以把注解放在类级别,这样每个测试都使用同一个用户。然而,与 @WithMockUser 不同,@WithUserDetails 需要用户存在。

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

@WithUserDetails(setupBefore = TestExecutionEvent.TEST_EXECUTION)

11.5 @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 =
			new UsernamePasswordAuthenticationToken(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 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 = new UsernamePasswordAuthenticationToken(principal, principal.getPassword(), principal.getAuthorities());
		SecurityContext context = SecurityContextHolder.createEmptyContext();
		context.setAuthentication(authentication);
		return context;
	}
}

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

@WithSecurityContext(setupBefore = TestExecutionEvent.TEST_EXECUTION)

11.6 测试元注解

如果经常在测试中重用同一用户,则需要重复指定属性是不理想的。例如,如果有许多与管理用户相关的测试,用户名 "admin" 和角色 ROLE_USER 和 ROLE_ADMIN 必须编写:

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

我们可以使用元注解,而不是到处重复。例如,我们可以创建一个名为 WithMockAdmin 的元注解:

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

现在,我们可以和更冗长的 @WithMockUser 方法一样使用 @WithMockAdmin。

元注解与上面描述的任何测试注解一起工作。例如,这意味着我们也可以为 @WithUserDetails("admin") 创建元注解。