测试方法安全性
本节演示如何使用 Spring Security 的测试支持来测试基于方法的安全性。我们首先介绍一个需要用户进行身份验证才能访问的 MessageService
:
- Java
- Kotlin
public class HelloMessageService implements MessageService {
@PreAuthorize("authenticated")
public String getMessage() {
Authentication authentication = SecurityContextHolder.getContext()
.getAuthentication();
return "Hello " + authentication;
}
}
class HelloMessageService : MessageService {
@PreAuthorize("authenticated")
fun getMessage(): String {
val authentication: Authentication = SecurityContextHolder.getContext().authentication
return "Hello $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
public class WithMockUserTests {
// ...
}
@ExtendWith(SpringExtension.class)
@ContextConfiguration
class WithMockUserTests {
// ...
}
@ExtendWith
指示 spring-test 模块应该创建一个ApplicationContext
。有关更多信息,请参阅 Spring 参考文档。@ContextConfiguration
指示 spring-test 使用的配置来创建ApplicationContext
。由于没有指定配置,将尝试使用默认的配置位置。这与使用现有的 Spring 测试支持没有区别。有关更多信息,请参阅 Spring 参考文档。
Spring Security 通过 WithSecurityContextTestExecutionListener
钩入 Spring 测试支持,它确保我们的测试以正确的用户身份运行。它通过在运行测试之前填充 SecurityContextHolder
来实现这一点。如果你使用响应式方法安全,还需要 ReactorContextTestExecutionListener
,它会填充 ReactiveSecurityContextHolder
。测试完成后,它会清除 SecurityContextHolder
。如果你只需要与 Spring Security 相关的支持,可以将 @ContextConfiguration
替换为 @SecurityTestExecutionListeners
。
记住,我们在 HelloMessageService
中添加了 @PreAuthorize
注解,因此它需要一个已认证的用户来调用。如果运行测试,我们期望以下测试将通过:
- Java
- Kotlin
@Test(expected = AuthenticationCredentialsNotFoundException.class)
public void getMessageUnauthenticated() {
messageService.getMessage();
}
@Test(expected = AuthenticationCredentialsNotFoundException::class)
fun getMessageUnauthenticated() {
messageService.getMessage()
}
@WithMockUser
问题是“我们如何最轻松地以特定用户身份运行测试?”答案是使用 @WithMockUser
。以下测试将以用户名为 “user”、密码为 “password” 且角色为 “ROLE_USER” 的用户身份运行。
- Java
- Kotlin
@Test
@WithMockUser
public void getMessageWithMockUser() {
String message = messageService.getMessage();
...
}
@Test
@WithMockUser
fun getMessageWithMockUser() {
val message: String = messageService.getMessage()
// ...
}
具体来说,以下情况是真实的:
-
用户名
user
的用户不必存在,因为我们模拟了用户对象。 -
填充到
SecurityContext
中的Authentication
类型是UsernamePasswordAuthenticationToken
。 -
Authentication
的主体是 Spring Security 的User
对象。 -
User
的用户名为user
。 -
User
的密码为password
。 -
使用了一个名为
ROLE_USER
的GrantedAuthority
。
前面的示例很方便,因为它让我们可以使用很多默认设置。如果我们想用不同的用户名运行测试该怎么办?下面的测试将使用 customUser
作为用户名(同样,用户实际上不需要存在):
- Java
- Kotlin
@Test
@WithMockUser("customUsername")
public void getMessageWithMockUserCustomUsername() {
String message = messageService.getMessage();
...
}
@Test
@WithMockUser("customUsername")
fun getMessageWithMockUserCustomUsername() {
val message: String = messageService.getMessage()
// ...
}
我们也可以轻松地自定义角色。例如,以下测试使用用户名 admin
和角色 ROLE_USER
和 ROLE_ADMIN
来调用。
- Java
- Kotlin
@Test
@WithMockUser(username="admin",roles={"USER","ADMIN"})
public void getMessageWithMockUserCustomUser() {
String message = messageService.getMessage();
...
}
@Test
@WithMockUser(username="admin",roles=["USER","ADMIN"])
fun getMessageWithMockUserCustomUser() {
val message: String = messageService.getMessage()
// ...
}
如果我们不想让值自动以前缀 ROLE_
开头,我们可以使用 authorities
属性。例如,以下测试使用用户名 admin
和 USER
与 ADMIN
权限被调用。
- Java
- Kotlin
@Test
@WithMockUser(username = "admin", authorities = { "ADMIN", "USER" })
public void getMessageWithMockUserCustomAuthorities() {
String message = messageService.getMessage();
...
}
@Test
@WithMockUser(username = "admin", authorities = ["ADMIN", "USER"])
fun getMessageWithMockUserCustomUsername() {
val message: String = messageService.getMessage()
// ...
}
在每个测试方法上放置注解可能会有些繁琐。相反,我们可以在类级别放置注解。然后每个测试都使用指定的用户。下面的示例将使用用户名为 admin
、密码为 password
且具有 ROLE_USER
和 ROLE_ADMIN
角色的用户运行每个测试:
- Java
- Kotlin
@ExtendWith(SpringExtension.class)
@ContextConfiguration
@WithMockUser(username="admin",roles={"USER","ADMIN"})
public class WithMockUserTests {
// ...
}
@ExtendWith(SpringExtension.class)
@ContextConfiguration
@WithMockUser(username="admin",roles=["USER","ADMIN"])
class WithMockUserTests {
// ...
}
如果你使用 JUnit 5 的 @Nested
测试支持,你还可以将该注解放在外部类上,以应用于所有嵌套类。以下示例为每个测试运行一个用户名为 admin
、密码为 password
且具有 ROLE_USER
和 ROLE_ADMIN
角色的用户,适用于两个测试方法。
- Java
- Kotlin
@ExtendWith(SpringExtension.class)
@ContextConfiguration
@WithMockUser(username="admin",roles={"USER","ADMIN"})
public class WithMockUserTests {
@Nested
public class TestSuite1 {
// ... all test methods use admin user
}
@Nested
public class TestSuite2 {
// ... all test methods use admin user
}
}
@ExtendWith(SpringExtension::class)
@ContextConfiguration
@WithMockUser(username = "admin", roles = ["USER", "ADMIN"])
class WithMockUserTests {
@Nested
inner class TestSuite1 { // ... all test methods use admin user
}
@Nested
inner 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 运行 withMockUser1
和 withMockUser2
,并以匿名用户身份运行 anonymous
:
- Java
- Kotlin
@ExtendWith(SpringExtension.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
}
}
@ExtendWith(SpringExtension.class)
@WithMockUser
class WithUserClassLevelAuthenticationTests {
@Test
fun withMockUser1() {
}
@Test
fun withMockUser2() {
}
@Test
@WithAnonymousUser
fun anonymous() {
// 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
,以下测试将使用类型为 UsernamePasswordAuthenticationToken
的 Authentication
调用,并且主体是从 UserDetailsService
返回的用户名为 user
的用户:
- Java
- Kotlin
@Test
@WithUserDetails
public void getMessageWithUserDetails() {
String message = messageService.getMessage();
...
}
@Test
@WithUserDetails
fun getMessageWithUserDetails() {
val message: String = messageService.getMessage()
// ...
}
我们还可以自定义用于从 UserDetailsService
中查找用户的用户名。例如,可以使用从 UserDetailsService
返回的具有 customUsername
用户名的主体来运行此测试:
- Java
- Kotlin
@Test
@WithUserDetails("customUsername")
public void getMessageWithUserDetailsCustomUsername() {
String message = messageService.getMessage();
...
}
@Test
@WithUserDetails("customUsername")
fun getMessageWithUserDetailsCustomUsername() {
val message: String = messageService.getMessage()
// ...
}
我们也可以提供一个显式的bean名称来查找UserDetailsService
。下面的测试通过使用名为myUserDetailsService
的bean来查找用户名为customUsername
的用户:
- Java
- Kotlin
@Test
@WithUserDetails(value="customUsername", userDetailsServiceBeanName="myUserDetailsService")
public void getMessageWithUserDetailsServiceBeanName() {
String message = messageService.getMessage();
...
}
@Test
@WithUserDetails(value="customUsername", userDetailsServiceBeanName="myUserDetailsService")
fun getMessageWithUserDetailsServiceBeanName() {
val message: String = messageService.getMessage()
// ...
}
正如我们对 @WithMockUser
所做的那样,我们也可以将注解放在类级别,以便每个测试都使用相同的用户。然而,与 @WithMockUser
不同的是,@WithUserDetails
要求用户必须存在。
默认情况下,SecurityContext
在 TestExecutionListener.beforeTestMethod
事件期间设置。这相当于在 JUnit 的 @Before
之前发生。你可以将其更改为在 TestExecutionListener.beforeTestExecution
事件期间发生,这在 JUnit 的 @Before
之后但在调用测试方法之前:
@WithUserDetails(setupBefore = TestExecutionEvent.TEST_EXECUTION)
@WithSecurityContext
我们已经了解到,如果我们不使用自定义的 Authentication
principal,那么 @WithMockUser
是一个极佳的选择。接下来,我们发现 @WithUserDetails
允许我们使用自定义的 UserDetailsService
来创建我们的 Authentication
principal,但要求用户必须存在。现在我们看到一个选项,它提供了最大的灵活性。
我们可以创建自己的注解,使用 @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
,以便根据我们的 @WithMockCustomUser
注解来创建一个新的 SecurityContext
。以下列出了我们的 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 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
}
}
默认情况下,SecurityContext
在 TestExecutionListener.beforeTestMethod
事件期间设置。这相当于在 JUnit 的 @Before
之前发生。你可以将其更改为在 TestExecutionListener.beforeTestExecution
事件期间发生,这在 JUnit 的 @Before
之后但在调用测试方法之前:
@WithSecurityContext(setupBefore = TestExecutionEvent.TEST_EXECUTION)
测试方法元注解
如果你在测试中经常重复使用同一个用户,那么每次都指定属性就不太理想了。例如,如果你有许多与具有 admin
用户名和 ROLE_USER
与 ROLE_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="ADMIN")
public @interface WithMockAdmin { }
@Retention(AnnotationRetention.RUNTIME)
@WithMockUser(value = "rob", roles = ["ADMIN"])
annotation class WithMockAdmin
现在我们可以用 @WithMockAdmin
,就像使用更冗长的 @WithMockUser
一样。
元注解可以与上述任何测试注解一起使用。例如,这意味着我们也可以为 @WithUserDetails("admin")
创建一个元注解。