使用 Spring Security 进行单元测试
-
21-08-2019 - |
题
我的公司一直在评估 Spring MVC 以确定我们是否应该在下一个项目中使用它。到目前为止,我喜欢我所看到的,现在我正在研究 Spring Security 模块,以确定它是否是我们可以/应该使用的东西。
我们的安全要求非常基本;用户只需提供用户名和密码即可访问网站的某些部分(例如获取有关其帐户的信息);网站上有一些页面(常见问题解答、支持等),应向匿名用户授予访问权限。
在我创建的原型中,我一直在会话中为经过身份验证的用户存储一个“LoginCredentials”对象(仅包含用户名和密码);例如,某些控制器会检查该对象是否处于会话中,以获取对登录用户名的引用。我希望用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......
解决方案
问题是Spring Security根本不提供作为容器中的bean的认证对象,所以没有办法轻易注入或自动装配它的开箱。
在我们开始使用Spring Security,我们将在容器中创建一个会话作用域的bean来存储校长,注入一个“的AuthenticationService”这(单),然后注入这个bean成所需要的知识,其他服务当前主要
如果您正在实现自己的身份验证服务,你基本上可以做同样的事情:创建一个“主”属性会话作用域的bean,注入到您的认证服务这一点,有权威性的服务设置该属性上成功的身份验证,然后提供给其他豆类auth服务,你需要它。
我也不会觉得太糟糕了有关使用SecurityContextHolder中。虽然。我知道,这是一个静态/辛格尔顿和春季鼓励使用这样的事情,但其实施需要照顾的行为适当地根据环境:会话范围的Servlet容器,线程范围在JUnit测试,等真正的限制因素的一个Singleton是当它提供了不灵活不同的环境的实施方式。
其他提示
只要做到这一点的常用方法,然后在您的测试类使用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);
您的担心是完全正确的 - 静态方法调用对于单元测试来说尤其成问题,因为您无法轻松模拟您的依赖项。我将向您展示如何让 Spring IoC 容器为您完成繁重的工作,为您留下整洁的、可测试的代码。SecurityContextHolder 是一个框架类,虽然将低级安全代码绑定到它可能没问题,但您可能希望向 UI 组件公开一个更简洁的界面(即控制器)。
cliff.meyers 提到了一种解决方法 - 创建您自己的“主体”类型并将一个实例注入消费者中。春天<aop:作用域代理2.x 中引入的 /> 标记与请求范围 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-proxy 标签的魔力,每次新的 HTTP 请求传入时都会调用静态方法 getUserDetails,并且对 currentUser 属性的任何引用都将被正确解析。现在单元测试变得微不足道:
protected void setUp() {
// existing init code
MyUserDetails user = new MyUserDetails();
// set up user as you wish
controller.setCurrentUser(user);
}
希望这可以帮助!
在没有回答如何创建和注入 Authentication 对象的问题的情况下,Spring Security 4.0 在测试方面提供了一些受欢迎的替代方案。这 @WithMockUser
注释使开发人员能够以简洁的方式指定模拟用户(具有可选的权限、用户名、密码和角色):
@Test
@WithMockUser(username = "admin", authorities = { "ADMIN", "USER" })
public void getMessageWithMockUserCustomAuthorities() {
String message = messageService.getMessage();
...
}
还有一个选项可以使用 @WithUserDetails
模拟一个 UserDetails
从返回的 UserDetailsService
, ,例如
@Test
@WithUserDetails("customUsername")
public void getMessageWithUserDetailsCustomUsername() {
String message = messageService.getMessage();
...
}
更多详细信息可以在 @WithMockUser 和 @WithUserDetails Spring Security 参考文档中的章节(上面的示例是从中复制的)
就我个人而言,我只会使用 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,但是它非常灵活,允许您对空 Authentication 对象等场景进行单元测试。无需更改您的(非测试)代码
在这种情况下,使用静态是编写安全代码的最佳方式。
是的,静态一般都是坏的 - 一般,但在这种情况下,静态就是你想要的。由于安全上下文的首席与当前正在运行的线程关联,最安全的代码将直接访问静态从线程越好。隐藏被注射的包装类背后的访问提供了更多的点来攻击攻击者。他们不需要访问代码(他们将很难随时间变化瓶子是否签署),他们只需要一种方法来覆盖配置,可以在运行时或打滑一些XML到类路径来完成。即使使用注释注入将重写与外部XML。这样XML可以注入的运行的系统与流氓主要
我自己也问过同样的问题 这里, ,并刚刚发布了我最近找到的答案。简短的回答是:注入一个 SecurityContext
, ,并参考 SecurityContextHolder
仅在您的 Spring 配置中获取 SecurityContext
我想看看Spring的抽象测试类和模拟对象被谈论的此处。它们提供的自动装配你的Spring管理对象进行单元测试和集成测试更容易的有效方式。
一般的
同时(自 2013 年 3.2 版起,感谢 SEC-2298)可以使用注释将身份验证注入到 MVC 方法中 @AuthenticationPrincipal:
@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;
}
};
}
验证是在服务器环境中以同样的方式一个线程的性质,因为它是在OS的处理的属性。有一个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
}