此版本仍在开发中,尚未被视为稳定版本。如需最新稳定版本,请使用 Spring Security 7.0.4spring-doc.cadn.net.cn

测试方法安全

此部分展示了如何使用 Spring Security 的测试支持来测试基于方法的安全性。 我们首先介绍一个 MessageService,要求用户必须经过身份验证才能访问它:spring-doc.cadn.net.cn

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 方法的结果是一个表示“Hello”的 String,指向当前的 Spring Security Authentication。 下面的示例显示了输出结果:spring-doc.cadn.net.cn

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测试支持之前,我们必须进行一些设置:spring-doc.cadn.net.cn

@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 测试支持中,这确保了我们的测试是在正确的用户上下文中运行。 它通过在运行测试前填充 SecurityContextHolder 来实现这一点。 如果使用响应式方法安全,则还需要 ReactorContextTestExecutionListener,该监听器会填充 ReactiveSecurityContextHolder。 测试完成后,它会清空 SecurityContextHolder。 如果你只需要 Spring Security 相关的支持,可以将 @ContextConfiguration 替换为 @SecurityTestExecutionListenersspring-doc.cadn.net.cn

请注意,我们在@PreAuthorize中添加了HelloMessageService注解,因此需要经过认证的用户才能调用它。 如果运行测试,我们期望以下测试将通过。spring-doc.cadn.net.cn

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

@WithMockUser

The question is "如何最简单地以特定用户的身份运行测试?" The answer is to use @WithMockUser. The following test will be run as a user with the username "user", the password "password", and the roles "ROLE_USER".spring-doc.cadn.net.cn

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

具体来说,以下内容为真:spring-doc.cadn.net.cn

该示例很方便,因为它允许我们使用很多默认设置。 如果我们想要用不同的用户名运行测试会怎样呢? 以下的测试将会使用用户名customUser(再次强调,这个用户不需要实际存在)。spring-doc.cadn.net.cn

@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_USER以及ROLE_ADMIN进行调用。spring-doc.cadn.net.cn

@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权限的情况下被调用。spring-doc.cadn.net.cn

@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角色的用户下:spring-doc.cadn.net.cn

@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 角色的用户。spring-doc.cadn.net.cn

@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 之后但在测试方法被调用之前。spring-doc.cadn.net.cn

@WithMockUser(setupBefore = TestExecutionEvent.TEST_EXECUTION)

@WithAnonymousUser

使用@WithAnonymousUser可以在匿名用户身份下运行测试。这在你希望大多数测试以特定用户身份运行但又需要某些测试以匿名用户身份运行时特别方便。 以下示例展示了如何通过使用withMockUser1withMockUser2来以匿名用户身份运行#test-method-withmockuseranonymous:spring-doc.cadn.net.cn

@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 之后但在测试方法被调用之前。spring-doc.cadn.net.cn

@WithAnonymousUser(setupBefore = TestExecutionEvent.TEST_EXECUTION)

@WithUserDetails

@WithMockUser 是一个方便的开始方式,但在某些情况下可能不起作用。例如,一些应用程序期望 @WithMockUser 主体为特定类型。 这是为了让应用程序可以将主体引用为其自定义类型,并减少与 Spring Security 的耦合。spring-doc.cadn.net.cn

自定义principal通常由一个自定义的UserDetailsService返回,该服务返回一个同时实现了UserDetails和自定义类型的对象。 在这种情况下,通过使用自定义的UserDetailsService来创建测试用户是有用的。 这就是@WithUserDetails所做的。spring-doc.cadn.net.cn

假设我们已经暴露了一个UserDetailsService bean,以下测试使用类型为AuthenticationUsernamePasswordAuthenticationToken并从UserDetailsService返回主名为user的主体执行:spring-doc.cadn.net.cn

@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返回的principal,并且该principal具有自定义用户名customUsername:
spring-doc.cadn.net.cn

@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名称来查找。 以下测试通过使用名为myUserDetailsService的bean查找用户名customUsernamespring-doc.cadn.net.cn

@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一样,我们也可以将注解放置在类级别,以便每个测试都使用相同的用户。spring-doc.cadn.net.cn

spring-doc.cadn.net.cn

然而,与@WithMockUser不同的是,@WithUserDetails要求该用户必须存在。spring-doc.cadn.net.cn

spring-doc.cadn.net.cn

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

@WithUserDetails(setupBefore = TestExecutionEvent.TEST_EXECUTION)

@WithSecurityContext

我们已经看到,@WithMockUser 是如果不需要自定义 Authentication 主体时的一个极好的选择。 接下来,我们发现 @WithUserDetails 允许使用自定义的 UserDetailsService 来创建我们的 Authentication 主体,但要求用户必须存在。 我们现在看到一个选项,提供了最大的灵活性。spring-doc.cadn.net.cn

我们可以通过使用@WithSecurityContext来自定义注解,来创建任何所需的SecurityContext。 例如,我们可以创建一个名为@WithMockCustomUser的注解:spring-doc.cadn.net.cn

@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的实现:spring-doc.cadn.net.cn

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被适当填充。spring-doc.cadn.net.cn

在创建您自己的WithSecurityContextFactory实现时,知道它们可以使用标准Spring注解进行标注是非常方便的。 例如,WithUserDetailsSecurityContextFactory使用@Autowired注解来获取UserDetailsService:spring-doc.cadn.net.cn

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 之后但在测试方法被调用之前。spring-doc.cadn.net.cn

@WithSecurityContext(setupBefore = TestExecutionEvent.TEST_EXECUTION)

@WithMockUser@WithUserDetails@WithSecurityContext 用于填充测试线程的 SecurityContextHolder。 这不适用于测试对运行中服务器发起的完整 HTTP 请求,因为这些请求由不同的线程处理。 对于端到端 HTTP 测试,请对请求本身进行 身份验证(例如使用 HTTP Basic 或 Bearer Token)。spring-doc.cadn.net.cn

测试元注解

如果在测试中频繁重用同一个用户,每次都必须指定属性并不是最佳做法。例如,如果你有多个与用户名为admin且具有ROLE_USERROLE_ADMIN角色的管理员用户的测试相关联,你需要编写如下内容:spring-doc.cadn.net.cn

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

而不是在 everywhere 处处重复这些内容,我们可以使用一个元注解。 例如,我们可以创建一个名为 WithMockAdmin 的元注解:spring-doc.cadn.net.cn

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

现在我们可以像使用更冗长的@WithMockAdmin一样使用@WithMockUserspring-doc.cadn.net.cn

元注解可以与上述描述的任何测试注解一起使用。 例如,这意味着我们也可以为@WithUserDetails("admin")创建一个元注解。spring-doc.cadn.net.cn