测试方法安全

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

  • Java

  • Kotlin

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;
	}
}
class HelloMessageService : MessageService {

	@PreAuthorize("isAuthenticated()")
	override fun getMessage(): String {
		val authentication: Authentication? = SecurityContextHolder.getContext().authentication
		return "Hello $authentication"
	}

	@PreAuthorize("isAuthenticated()")
	override fun getJsrMessage(): String {
		val authentication = SecurityContextHolder.getContext().authentication
		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 测试支持之前,我们必须进行一些设置

  • Java

  • Kotlin

@ExtendWith(SpringExtension.class) (1)
@ContextConfiguration (2)
class WithMockUserTests {
	// ...
}
@ExtendWith(SpringExtension::class) (1)
@ContextConfiguration (2)
class WithMockUserTests {
}
1 @ExtendWith 指示 spring-test 模块它应该创建一个 ApplicationContext。有关更多信息,请参阅 Spring 参考
2 @ContextConfiguration 指示 spring-test 用于创建 ApplicationContext 的配置。由于未指定配置,因此将尝试默认配置位置。这与使用现有 Spring Test 支持没有什么不同。有关更多信息,请参阅 Spring 参考

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

请记住,我们向 HelloMessageService 添加了 @PreAuthorize 注解,因此它需要经过身份验证的用户才能调用它。如果运行测试,我们预计以下测试将通过

  • Java

  • Kotlin

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

@WithMockUser

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

  • Java

  • Kotlin

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

具体来说,以下是真实的

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

  • 填充到 SecurityContext 中的 Authentication 类型为 UsernamePasswordAuthenticationToken

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

  • User 的用户名为 user

  • User 的密码为 password

  • 使用了单个名为 ROLE_USERGrantedAuthority

前面的示例很方便,因为它允许我们使用许多默认值。如果我们想使用不同的用户名运行测试怎么办?以下测试将以用户名 customUser 运行(同样,用户不需要实际存在)

  • Java

  • Kotlin

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

我们还可以轻松自定义角色。例如,以下测试以 admin 用户名和 ROLE_USERROLE_ADMIN 角色调用。

  • Java

  • Kotlin

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

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

  • Java

  • Kotlin

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

将注解放在每个测试方法上可能有点繁琐。相反,我们可以将注解放在类级别。然后每个测试都使用指定的用户。以下示例使用用户名为 admin、密码为 password 且具有 ROLE_USERROLE_ADMIN 角色的用户运行每个测试

  • Java

  • Kotlin

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

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

  • Java

  • Kotlin

@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
	}
}
@ExtendWith(SpringExtension::class)
@ContextConfiguration
@WithMockUser(username = "admin", roles = ["USER", "ADMIN"])
class WithMockUserNestedTests {

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

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

}

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

@WithMockUser(setupBefore = TestExecutionEvent.TEST_EXECUTION)

@WithAnonymousUser

使用 @WithAnonymousUser 允许以匿名用户身份运行。当您希望以特定用户身份运行大部分测试,但又希望以匿名用户身份运行少量测试时,这尤其方便。以下示例通过使用 @WithMockUser 以匿名用户身份运行 withMockUser1withMockUser2

  • Java

  • Kotlin

@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
	}
}
@ExtendWith(SpringExtension::class)
@WithMockUser
class WithUserClassLevelAuthenticationTests {

	@Test
	fun withMockUser1() {
	}

	@Test
	fun withMockUser2() {
	}

	@Test
	@WithAnonymousUser
	fun anonymous() {
		// override default to run as anonymous user
	}

}

默认情况下,SecurityContextTestExecutionListener.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 的主体调用

  • Java

  • Kotlin

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

我们还可以自定义用于从 UserDetailsService 查找用户的用户名。例如,此测试可以与从 UserDetailsService 返回的用户名为 customUsername 的主体一起运行

  • Java

  • Kotlin

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

我们还可以提供一个显式的 bean 名称来查找 UserDetailsService。以下测试使用 bean 名称为 myUserDetailsServiceUserDetailsService 查找用户名为 customUsername 的用户

  • Java

  • Kotlin

@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);
}
@Test
@WithUserDetails(value = "customUsername", userDetailsServiceBeanName = "myUserDetailsService")
fun getMessageWithUserDetailsServiceBeanName() {
    val message: String = messageService.getMessage()
    assertThat(message).contains("customUsername");
    val principal = SecurityContextHolder.getContext().authentication!!.principal
    assertThat(principal).isInstanceOf(CustomUserDetails::class.java)
}

@WithMockUser 一样,我们也可以将注解放在类级别,以便每个测试都使用相同的用户。但是,与 @WithMockUser 不同,@WithUserDetails 要求用户存在。

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

@WithUserDetails(setupBefore = TestExecutionEvent.TEST_EXECUTION)

@WithSecurityContext

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

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

  • Java

  • Kotlin

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

	String username() default "rob";

	String name() default "Rob Winch";
}
@Retention(AnnotationRetention.RUNTIME)
@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory::class)
annotation class WithMockCustomUser(val username: String = "rob", val name: String = "Rob Winch")

您可以看到 @WithMockCustomUser 注解了 @WithSecurityContext 注解。这向 Spring Security 测试支持发出信号,表明我们打算为测试创建 SecurityContext@WithSecurityContext 注解要求我们指定一个 SecurityContextFactory 来创建新的 SecurityContext,给定我们的 @WithMockCustomUser 注解。以下列表显示了我们的 WithMockCustomUserSecurityContextFactory 实现

  • Java

  • Kotlin

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;
	}

}
class WithMockCustomUserSecurityContextFactory : WithSecurityContextFactory<WithMockCustomUser> {
	override fun createSecurityContext(customUser: WithMockCustomUser): SecurityContext {
		val context = SecurityContextHolder.createEmptyContext()
		val principal = CustomUserDetails(customUser.name, customUser.username)
		val auth: Authentication =
				UsernamePasswordAuthenticationToken(principal, "password", principal.authorities)
		context.authentication = auth
		return context
	}
}

我们现在可以使用我们的新注解和 Spring Security 的 WithSecurityContextTestExecutionListener 注解测试类或测试方法,以确保我们的 SecurityContext 得到适当填充。

在创建自己的 WithSecurityContextFactory 实现时,很高兴知道它们可以用标准的 Spring 注解进行注解。例如,WithUserDetailsSecurityContextFactory 使用 @Autowired 注解来获取 UserDetailsService

  • Java

  • Kotlin

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;
	}
}
class WithUserDetailsSecurityContextFactory @Autowired constructor(private val userDetailsService: UserDetailsService) :
    WithSecurityContextFactory<WithUserDetails> {

    override fun createSecurityContext(withUser: WithUserDetails): SecurityContext {
        val username: String = withUser.value
        Assert.hasLength(username, "value() must be non-empty String")
        val principal = userDetailsService.loadUserByUsername(username)
        val authentication: Authentication =
            UsernamePasswordAuthenticationToken(principal, principal.password, principal.authorities)
        val context = SecurityContextHolder.createEmptyContext()
        context.authentication = authentication
        return context
    }

}

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

@WithSecurityContext(setupBefore = TestExecutionEvent.TEST_EXECUTION)

测试元注解

如果您在测试中经常重复使用同一个用户,那么重复指定属性并不理想。例如,如果您有许多与用户名为 admin 且角色为 ROLE_USERROLE_ADMIN 的管理用户相关的测试,则必须编写

  • Java

  • Kotlin

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

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

  • Java

  • Kotlin

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

现在我们可以像使用更详细的 @WithMockUser 一样使用 @WithMockAdmin

元注解适用于上述任何测试注解。例如,这意味着我们也可以为 @WithUserDetails("admin") 创建一个元注解。

© . This site is unofficial and not affiliated with VMware.