使用 Spring 安全性进行单元测试

我的公司一直在评估 Spring MVC,以决定我们是否应该在下一个项目中使用它。到目前为止,我喜欢我所看到的,现在我正在研究 Spring Security 模块,以确定它是否是我们可以/应该使用的。

我们的安全要求非常基本; 用户只需要提供一个用户名和密码就可以访问网站的某些部分(比如获取他们账户的信息) ; 网站上有一些页面(FAQ,Support 等)应该给予匿名用户访问权限。

在我创建的原型中,我一直在 Session 中为一个经过身份验证的用户存储一个“ LoginCredenals”对象(它只包含用户名和密码) ; 例如,一些控制器检查这个对象是否在 Session 中以获取对已登录用户名的引用。我希望用 Spring Security 代替这个自己开发的逻辑,这样做的好处是可以消除任何类型的“我们如何跟踪登录的用户?”以及“我们如何对用户进行身份验证?”从我的控制器/业务代码。

似乎 Spring Security 提供了一个(每个线程)“上下文”对象,可以从应用程序的任何地方访问用户名/主体信息..。

Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();

... 这看起来非常不像 Spring,因为这个对象在某种程度上是一个(全局)单例。

我的问题是: 如果这是在 Spring Security 中访问已验证用户信息的标准方法,那么将 Authentication 对象注入到 SecurityContext 中,以便在单元测试需要已验证用户时,我的单元测试可以使用它,这种方法是可接受的吗?

我是否需要在每个测试用例的初始化方法中连接它?

protected void setUp() throws Exception {
...
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(testUser.getLogin(), testUser.getPassword()));
...
}

这似乎太冗长了。有更简单的方法吗?

SecurityContextHolder对象本身看起来非常不像 Spring..。

147796 次浏览

我将看一下 Spring 的抽象测试类和模拟对象,这些都是关于 给你的。它们提供了一种强大的方式来自动连接 Spring 托管对象,使单元和集成测试更加容易。

问题在于 Spring Security 没有将 Authentication 对象作为容器中的 bean 提供,因此无法轻松地将其注入或自动连接到外部。

在我们开始使用 Spring Security 之前,我们将在容器中创建一个会话范围的 bean 来存储主体,将其注入到“ AuthenticationService”(singleton)中,然后将这个 bean 注入到需要了解当前主体的其他服务中。

如果您正在实现自己的身份验证服务,您基本上可以做同样的事情: 创建一个具有“主体”属性的会话范围的 bean,将其注入到您的身份验证服务中,让 auth 服务在成功的 auth 上设置该属性,然后在您需要时将 auth 服务提供给其他 bean。

我不会为使用 SecurityContextHolder 而感到太糟糕。不过。我知道它是一个静态的/Singleton,Spring 不鼓励使用这样的东西,但是它们的实现会根据环境的不同而采取适当的行为: Servlet 容器中的 session 作用域,JUnit 测试中的线程作用域,等等。Singleton 真正的限制因素是它提供的实现对不同的环境不灵活。

您的担心是非常正确的——静态方法调用对于单元测试来说是特别有问题的,因为您不能轻易地模仿您的依赖性。我将向您展示的是如何让 Spring IoC 容器为您完成这些繁琐的工作,为您留下整洁、可测试的代码。SecurityContextHolder 是一个框架类,虽然可以将低级安全代码绑定到它,但您可能希望向 UI 组件(即控制器)公开一个更整洁的接口。

Meyers 提到了一种解决方法——创建您自己的“主体”类型并将一个实例注入到消费者中。在2.x 中引入的 Spring < 作用域代理/> 标记与请求范围 bean 定义相结合,工厂方法支持可能是最具可读性的代码。

它的工作原理如下:

public class MyUserDetails implements UserDetails {
// this is your custom UserDetails implementation to serve as a principal
// implement the Spring methods and add your own methods as appropriate
}


public class MyUserHolder {
public static MyUserDetails getUserDetails() {
Authentication a = SecurityContextHolder.getContext().getAuthentication();
if (a == null) {
return null;
} else {
return (MyUserDetails) a.getPrincipal();
}
}
}


public class MyUserAwareController {
MyUserDetails currentUser;


public void setCurrentUser(MyUserDetails currentUser) {
this.currentUser = currentUser;
}


// controller code
}

目前为止没什么复杂的,对吧?事实上,你可能已经做了大部分这样的工作。接下来,在 bean 上下文中定义一个请求范围的 bean 来保存主体:

<bean id="userDetails" class="MyUserHolder" factory-method="getUserDetails" scope="request">
<aop:scoped-proxy/>
</bean>


<bean id="controller" class="MyUserAwareController">
<property name="currentUser" ref="userDetails"/>
<!-- other props -->
</bean>

由于 aop: scoped-agent 标记的神奇功能,每当有新的 HTTP 请求进入时,都将调用静态方法 getUserDetails,并且对 currentUser 属性的任何引用都将得到正确解析。现在,单元测试变得微不足道:

protected void setUp() {
// existing init code


MyUserDetails user = new MyUserDetails();
// set up user as you wish
controller.setCurrentUser(user);
}

希望这个能帮上忙!

我自己也在 给你上问过同样的问题,并且刚刚发布了一个我最近发现的答案。简短的回答是: 注入一个 SecurityContext,并在 Spring 配置中仅引用 SecurityContextHolder以获得 SecurityContext

在这种情况下,使用静态是编写安全代码的最佳方法。

是的,静态通常是不好的-一般来说,但在这种情况下,静态是你想要的。由于安全上下文将主体与当前运行的线程相关联,因此最安全的代码将尽可能直接地从线程访问静态。将访问隐藏在被注入的包装类后面,为攻击者提供了更多可攻击的点。他们不需要访问代码(如果对 jar 进行签名,他们很难改变代码) ,他们只需要一种方法来覆盖配置,这可以在运行时完成,或者将一些 XML 放到类路径中。即使使用注释注入,外部 XML 也可以覆盖它。这样的 XML 可能会给正在运行的系统注入一个流氓主体。

就我个人而言,我会使用 Powermock 和 Mockito 或 Easymock 来模仿单元/集成测试中的静态 SecurityContextHolder.getsecurityContext ()。

@RunWith(PowerMockRunner.class)
@PrepareForTest(SecurityContextHolder.class)
public class YourTestCase {


@Mock SecurityContext mockSecurityContext;


@Test
public void testMethodThatCallsStaticMethod() {
// Set mock behaviour/expectations on the mockSecurityContext
when(mockSecurityContext.getAuthentication()).thenReturn(...)
...
// Tell mockito to use Powermock to mock the SecurityContextHolder
PowerMockito.mockStatic(SecurityContextHolder.class);


// use Mockito to set up your expectation on SecurityContextHolder.getSecurityContext()
Mockito.when(SecurityContextHolder.getSecurityContext()).thenReturn(mockSecurityContext);
...
}
}

不可否认,这里有相当多的代码,比如模拟一个 Authentication 对象,模拟一个 SecurityContext 来返回 Authentication,最后模拟 SecurityContextHolder 来获得 SecurityContext,然而它非常灵活,允许你对诸如 null Authentication 对象等场景进行单元测试,而不必修改你的(非测试)代码

只需按照通常的方法执行,然后使用 SecurityContextHolder.setContext()将其插入到测试类中,例如:

总监:

Authentication a = SecurityContextHolder.getContext().getAuthentication();

测试:

Authentication authentication = Mockito.mock(Authentication.class);
// Mockito.whens() for your authorization object
SecurityContext securityContext = Mockito.mock(SecurityContext.class);
Mockito.when(securityContext.getAuthentication()).thenReturn(authentication);
SecurityContextHolder.setContext(securityContext);

身份验证是服务器环境中线程的属性,就像它是操作系统中进程的属性一样。拥有一个用于访问身份验证信息的 bean 实例将是不方便的配置和连接开销,而且没有任何好处。

关于测试身份验证,有几种方法可以使您的生活更轻松。我最喜欢的是创建一个自定义注释 @Authenticated和测试执行侦听器,由它来管理它。检查 DirtiesContextTestExecutionListener的灵感。

经过相当多的工作,我能够复制所需的行为。我通过 MockMvc 模拟了登录。它对于大多数单元测试来说太重了,但对于集成测试却很有帮助。

当然,我愿意在 Spring Security 4.0中看到这些新特性,它们将使我们的测试更加容易。

package [myPackage]


import static org.junit.Assert.*;


import javax.inject.Inject;
import javax.servlet.http.HttpSession;


import org.junit.Before;
import org.junit.Test;
import org.junit.experimental.runners.Enclosed;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;


import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;


@ContextConfiguration(locations={[my config file locations]})
@WebAppConfiguration
@RunWith(SpringJUnit4ClassRunner.class)
public static class getUserConfigurationTester{


private MockMvc mockMvc;


@Autowired
private FilterChainProxy springSecurityFilterChain;


@Autowired
private MockHttpServletRequest request;


@Autowired
private WebApplicationContext webappContext;


@Before
public void init() {
mockMvc = MockMvcBuilders.webAppContextSetup(webappContext)
.addFilters(springSecurityFilterChain)
.build();
}




@Test
public void testTwoReads() throws Exception{


HttpSession session  = mockMvc.perform(post("/j_spring_security_check")
.param("j_username", "admin_001")
.param("j_password", "secret007"))
.andDo(print())
.andExpect(status().isMovedTemporarily())
.andExpect(redirectedUrl("/index"))
.andReturn()
.getRequest()
.getSession();


request.setSession(session);


SecurityContext securityContext = (SecurityContext)   session.getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY);


SecurityContextHolder.setContext(securityContext);


// Your test goes here. User is logged with
}

将军

同时(自从版本3.2,在2013年,感谢 SEC-2298)认证可以注入到 MVC 方法使用注释 @ 身份验证校长:

@Controller
class Controller {
@RequestMapping("/somewhere")
public void doStuff(@AuthenticationPrincipal UserDetails myUser) {
}
}

测试

在您的单元测试中,您显然可以直接调用此方法。在使用 org.springframework.test.web.servlet.MockMvc的集成测试中,您可以使用 org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user()像下面这样注入用户:

mockMvc.perform(get("/somewhere").with(user(myUserDetails)));

然而,这将直接填充 SecurityContext。如果要确保用户是从测试中的会话加载的,可以使用以下方法:

mockMvc.perform(get("/somewhere").with(sessionUser(myUserDetails)));
/* ... */
private static RequestPostProcessor sessionUser(final UserDetails userDetails) {
return new RequestPostProcessor() {
@Override
public MockHttpServletRequest postProcessRequest(final MockHttpServletRequest request) {
final SecurityContext securityContext = new SecurityContextImpl();
securityContext.setAuthentication(
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities())
);
request.getSession().setAttribute(
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, securityContext
);
return request;
}
};
}

在不回答如何创建和注入 Authentication 对象的问题的情况下,Spring Security 4.0在测试方面提供了一些受欢迎的替代方案。@WithMockUser注释允许开发人员以一种简洁的方式指定一个模拟用户(带有可选的权限、用户名、密码和角色) :

@Test
@WithMockUser(username = "admin", authorities = { "ADMIN", "USER" })
public void getMessageWithMockUserCustomAuthorities() {
String message = messageService.getMessage();
...
}

还可以选择使用 @WithUserDetails来模拟从 UserDetailsService返回的 UserDetails,例如。

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

更多的细节可以在 Spring Security 参考文档中的 @ WithMockUser@ WithUserDetails章节中找到(上面的例子是从中复制的)