Testing Method Security

此部分演示如何使用 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

Security Test Setup

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

  • Java

  • Kotlin

@ExtendWith(SpringExtension.class) (1)
@ContextConfiguration (2)
public class WithMockUserTests {
	// ...
}
@ExtendWith(SpringExtension.class)
@ContextConfiguration
class WithMockUserTests {
    // ...
}
<1>  `@ExtendWith` 指示 spring-test 模块,它应创建 `ApplicationContext`。有关其他信息,请参阅 {spring-framework-reference-url}testing.html#testcontext-junit-jupiter-extension[Spring 参考]。
<1>  `@ContextConfiguration` 指示 spring-test 用于创建 `ApplicationContext` 的配置。由于未指定配置,因此将尝试默认配置位置。这与使用现有的 Spring 测试支持并无差异。有关其他信息,请参阅 {spring-framework-reference-url}testing.html#spring-testing-annotation-contextconfiguration[Spring 参考]。

Spring Security 通过 WithSecurityContextTestExecutionListener 挂接到 Spring 测试支持,这可确保我们的测试使用正确用户运行。它通过在运行我们的测试之前填充 SecurityContextHolder 来执行此操作。如果您使用反应式方法安全性,还需要 ReactorContextTestExecutionListener,它将填充 ReactiveSecurityContextHolder。测试完成后,它会清除 SecurityContextHolder。如果您只需要 Spring Security 相关的支持,则可以使用 @SecurityTestExecutionListeners 替换 @ContextConfiguration

请记住,我们已向 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 用户名的用户不必存在,因为我们模拟了用户对象。

  • 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_USERROLE_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 的用户名和 USERADMIN 权限调用。

  • 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()
    // ...
}

这种注解方法总是在每个测试方法中进行,可能有点儿复杂。而我们可以在类层级里设置这个注解。然后每个测试都可以使用指定的 user。这个例子展示了每个测试都有一个 user,用户名为 admin,密码为 password,并且有 ROLE_USERROLE_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 测试支持,你也可以在嵌套类里设置注解,用在所有嵌套类里。下面的例子展示了每个测试都有一个 user,用户名为 admin,密码为 password,并且在两个测试方法里具有 ROLE_USERROLE_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,test-method-withmockuser>> 以 withMockUser1withMockUser2 运行,并以匿名用户身份运行 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 返回,该 UserDetailsService 返回同时实现了 UserDetails 和自定义类型的一个对象。在这种情况里,使用自定义 UserDetailsService 创建测试用户会很有用。这正是 @WithUserDetails 正在做的事情。

假设我们有一个公开为 bean 的 UserDetailsService,那么以下测试将使用类型为 UsernamePasswordAuthenticationTokenAuthentication 和从 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。以下测试使用 bean 名称 myUserDetailsService 通过 UserDetailsService 来查找 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 主体,@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,以根据我们的 @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)

Test Meta Annotations

如果你常常在测试中重复使用同一个用户,那么反复指定属性并不是好的主意。例如,如果你有很多与一个具有 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="ADMIN")
public @interface WithMockAdmin { }
@Retention(AnnotationRetention.RUNTIME)
@WithMockUser(value = "rob", roles = ["ADMIN"])
annotation class WithMockAdmin

现在我们可以在 @WithMockUser 一样的方式里使用 @WithMockAdmin

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