使用Spring Security进行单元测试

时间:2008-12-11 19:11:44

标签: java security unit-testing spring spring-security

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

我们的安全要求非常基本;用户只需提供用户名和密码即可访问网站的某些部分(例如获取有关其帐户的信息);并且网站上有一些页面(常见问题解答,支持等),应该授予匿名用户访问权限。

在我创建的原型中,我一直在Session中为经过身份验证的用户存储“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对象本身似乎非常像春天......

11 个答案:

答案 0 :(得分:134)

按照常规方式执行,然后在测试类中使用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);

答案 1 :(得分:41)

问题是Spring Security没有将Authentication对象作为容器中的bean使用,因此无法轻易地将其注入或自动装入它。

在我们开始使用Spring Security之前,我们将在容器中创建一个会话范围的bean来存储Principal,将其注入“AuthenticationService”(单例),然后将此bean注入需要了解其中的其他服务现任校长。

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

使用SecurityContextHolder我不会感觉太糟糕。虽然。我知道它是一个静态/ Singleton,并且Spring不鼓励使用这些东西,但是它们的实现需要根据环境进行适当的操作:在Servlet容器中进行会话作用,在JUnit测试中进行线程作用等。真正的限制因素一个Singleton就是它提供了一种对不同环境不灵活的实现。

答案 2 :(得分:28)

你很关心 - 静态方法调用对于单元测试尤其有问题,因为你不能轻易地模拟你的依赖项。我要向您展示的是如何让Spring IoC容器为您完成脏工作,为您提供整洁,可测试的代码。 SecurityContextHolder是一个框架类,虽然您可以将低级别安全代码绑定到它,但您可能希望向UI组件(即控制器)公开一个更整洁的接口。

cliff.meyers提到了一种解决方法 - 创建自己的“主体”类型并向消费者注入实例。春天< aop:scoped-proxy /> 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);
}

希望这有帮助!

答案 3 :(得分:21)

如果不回答有关如何创建和注入身份验证对象的问题,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();
    ...
}

可以在Spring Security参考文档中的@WithMockUser@WithUserDetails章节中找到更多详细信息(从上面复制了上述示例)

答案 4 :(得分:8)

就个人而言,我只会使用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验证对象等,而无需更改(非测试)代码

答案 5 :(得分:5)

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

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

答案 6 :(得分:3)

我自己在here问了同样的问题,刚刚发布了我最近找到的答案。简短的回答是:注入SecurityContext,并仅在Spring配置中引用SecurityContextHolder以获取SecurityContext

答案 7 :(得分:2)

我将看一下Spring的抽象测试类和模拟对象,这些对象被讨论here。它们为Spring管理对象提供了一种强大的自动连接方式,使单元和集成测试更容易。

答案 8 :(得分:2)

一般

与此同时(自版本3.2起,在2013年,感谢SEC-2298),可以使用注释@AuthenticationPrincipal将身份验证注入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;
        }
    };
}

答案 9 :(得分:1)

身份验证是服务器环境中线程的属性,其方式与操作系统中进程的属性相同。拥有用于访问身份验证信息的bean实例将是不方便的配置和布线开销而没有任何好处。

关于测试认证,有几种方法可以让您的生活更轻松。我最喜欢的是制作一个自定义注释@Authenticated和测试执行监听器,它管理它。检查DirtiesContextTestExecutionListener获取灵感。

答案 10 :(得分:0)

经过大量工作后,我能够重现所需的行为。我曾通过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 
}