预览 Spring Security 测试:方法安全

工程 | Rob Winch | 2014 年 5 月 7 日 | ...

[标注 title=更新于 2015 年 3 月 31 日]此博客已过时,不再维护。请参阅参考文档的测试部分以获取更新的文档。[/标注]

周一,我宣布了 Spring Security 4.0.0.M1 的发布。这是介绍 Spring Security 测试支持的三个部分博客系列中的第一篇。系列大纲如下所示

测试方法安全

测试基于方法的安全性一直相当简单。然而,这并不意味着它不能做得更好。让我们探索一个非常简单的示例,看看如何使用 Spring Security Test 支持来简化基于方法的安全测试。

我们首先介绍一个 MessageService,它要求用户经过身份验证才能访问它。

[标注 title=源代码]你可以在 github 上找到此博客系列的完整源代码。[/标注]

public class HelloMessageService implements MessageService {

	@PreAuthorize("authenticated")
	public String getMessage() {
		Authentication authentication = SecurityContextHolder.getContext()
                                                             .getAuthentication();
		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 Test 支持之前,我们必须进行一些设置。一个示例如下所示

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration
@TestExecutionListeners(listeners={ServletTestExecutionListener.class,
		DependencyInjectionTestExecutionListener.class,
		DirtiesContextTestExecutionListener.class,
		TransactionalTestExecutionListener.class,
		WithSecurityContextTestExcecutionListener.class})
public class WithMockUserTests {

这是设置 Spring Security Test 的一个基本示例。重点如下

  • @RunWith 指示 spring-test 模块应创建一个 ApplicationContext。这与使用现有的 Spring Test 支持没有区别。更多信息请参阅 Spring 参考文档
  • @ContextConfiguration 指示 spring-test 使用哪种配置来创建 ApplicationContext。由于未指定配置,将尝试默认配置位置。这与使用现有的 Spring Test 支持没有区别。更多信息请参阅 Spring 参考文档
  • @TestExecutionListeners 指示 spring-test 模块,除了默认的监听器外,还要使用 WithSecurityContextTestExcecutionListener,它将确保我们的测试以正确的用户身份运行。它通过在运行测试之前填充 SecurityContextHolder 来实现这一点。测试完成后,它将清除 SecurityContextHolder

[标注 title=注意]

我们理解 @TestExecutionListeners 相当冗长,并且目前有一些现有的 JIRA 问题有望在将来对此进行改进。请参阅 SEC-2585 以获取最新信息。

[/标注]

请记住,我们在 HelloMessageService 中添加了 @PreAuthorize 注解,因此它要求经过身份验证的用户才能调用。如果我们运行以下测试,我们期望以下测试会通过

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

@WithMockUser

问题是如何最方便地以特定用户身份运行测试。由于我们使用了 WithSecurityContextTestExcecutionListener,以下测试将以用户名为 "user"、密码为 "password"、角色为 "ROLE_USER" 的用户身份运行。

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

具体来说,以下几点是成立的

  • 用户名为 "user" 的用户无需实际存在,因为我们正在模拟用户
  • 填充到 SecurityContext 中的 Authentication 类型是 UsernamePasswordAuthenticationToken
  • Authentication 中的 principal 是一个 User 对象
  • User 将具有用户名为 "user"、密码为 "password",以及 GrantedAuthoritys 只有一个名为 "ROLE_USER" 的权限。

我们的示例很好,因为它提供了很多默认值。如果想使用不同的用户名运行测试怎么办?以下测试将使用用户名为 "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();
	...
}

当然,在每个测试方法上放置注解可能有点麻烦。我们可以将注解放在类级别,这样每个测试都将使用指定的用户。例如,以下代码将以用户名为 "admin"、密码为 "password"、角色为 "ROLE_USER" 和 "ROLE_ADMIN" 的用户身份运行所有测试。

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration
@TestExecutionListeners(listeners={ServletTestExecutionListener.class,
    DependencyInjectionTestExecutionListener.class,
    DirtiesContextTestExecutionListener.class,
    TransactionalTestExecutionListener.class,
    WithSecurityContextTestExcecutionListener.class})
@WithMockUser(username="admin",roles={"USER","ADMIN"})
public class WithMockUserTests {

@WithUserDetails

虽然 @WithMockUser 是一个非常方便的入门方式,但它可能并非适用于所有情况。例如,应用程序通常期望 Authentication principal 是特定类型的对象。这样做是为了让应用程序可以将 principal 引用为自定义类型,并减少对 Spring Security 的耦合。

自定义 principal 通常由自定义的 UserDetailsService 返回,它返回一个同时实现了 UserDetails 和自定义类型的对象。对于这种情况,使用自定义的 UserDetailsService 来创建测试用户非常有用。这正是 @WithUserDetails 的作用。

假设我们将一个 UserDetailsService 暴露为一个 bean,那么以下测试将使用类型为 UsernamePasswordAuthenticationTokenAuthentication 以及从 UserDetailsService 返回的、用户名为 "user" 的 principal 来调用。

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

我们还可以自定义用于从 UserDetailsService 中查找用户的用户名。例如,此测试将使用从 UserDetailsService 返回的、用户名为 "customUsername" 的 principal 执行。

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

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

@WithSecurityContext

我们已经看到,如果我们没有使用自定义的 Authentication principal,那么 @WithMockUser 是一个很好的选择。接下来我们发现 @WithUserDetails 允许我们使用自定义的 UserDetailsService 来创建我们的 Authentication principal,但它要求用户实际存在。现在我们将看到一个提供最大灵活性的选项。

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

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

	String username() default "rob";

	String name() default "Rob Winch";
}

您可以看到 @WithMockCustomUser 注解使用了 @WithSecurityContext 注解进行标注。这向 Spring Security Test 支持表明我们打算为测试创建一个 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 的 WithSecurityContextTestExcecutionListener 将确保我们的 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;
    }
}

转向 Web ...

在这篇文章中,我们演示了 Spring Security Test 如何让测试基于方法的安全性变得更加容易。然而,最好的部分还在后面。在我们的下一篇文章中,我们将演示 Spring Security Test 如何让使用 Spring MVC Test 测试我们的应用程序变得更容易。

[标注 title=请提供反馈!] 如果您对此博客系列或 Spring Security Test 支持有任何反馈,我鼓励您通过 JIRA、评论区或在 twitter 上 ping 我 @rob_winch。当然,最好的反馈是贡献的形式。[/标注]

订阅 Spring 资讯

保持与 Spring 资讯的连接

订阅

抢占先机

VMware 提供培训和认证,助您加速发展。

了解更多

获取支持

Tanzu Spring 通过一个简单的订阅提供 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件。

了解更多

即将举行的活动

查看 Spring 社区所有即将举行的活动。

查看全部