使用Spring Security进行单元测试


140

我公司一直在评估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对象本身看起来非常不像Spring ...

Answers:


48

问题在于,Spring Security不会使Authentication对象作为容器中的Bean可用,因此无法轻松地将其注入或自动接线。

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

如果要实现自己的身份验证服务,则基本上可以执行以下操作:创建一个具有“ principal”属性的会话范围的Bean,将其注入到身份验证服务中,让auth服务在成功的auth上设置该属性,然后根据需要使auth服务可用于其他bean。

对于使用SecurityContextHolder,我不会感到很糟糕。虽然。我知道这是静态的/ Singleton,Spring不鼓励使用此类东西,但是它们的实现要注意根据环境进行适当的行为:会话范围在Servlet容器中,线程范围在JUnit测试中,等等。真正的限制因素Singleton的含义是它提供对不同环境不灵活的实现。


谢谢,这是有用的建议。到目前为止,我所做的基本上是继续调用SecurityContextHolder.getContext()(通过我自己的一些包装方法,因此至少只能从一个类中调用它)。
马特b

2
尽管只有一个注意事项-我不认为ServletContextHolder具有HttpSession的任何概念或了解它是否在Web服务器环境中运行的方式-但它使用ThreadLocal,除非您将其配置为使用其他方式(唯一的两个内置模式是InheritableThreadLocal和全球)
matt b

在Spring中使用会话/请求范围的bean的唯一缺点是,它们将在JUnit测试中失败。您可以做的是实现一个自定义范围,该范围将使用会话/请求(如果可用),并且有必要退回到线程。我的猜测是Spring Security正在做类似的事情...
悬崖.meyers

我的目标是建立一个没有会话的Rest API。也许带有可刷新令牌。虽然这没有回答我的问题,但有所帮助。谢谢
Pomagranite

166

只需按照通常的方式进行操作,然后使用 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);

2
@Leonardo应该Authentication a在控制器的哪个位置添加它?据我所知,在每种方法中调用?只需添加“春天的方式”,而不是注射,可以吗?
Oleg Kuts

但是,请记住它不是去工作,因为TestNG的持有SecurityContextHolder的线程局部变量,那么你分享测试之间的这个变量...
卢卡斯Woźniczka

@BeforeEach(JUnit5)或@Before(JUnit 4)中进行操作。好简单。
WesternGun

30

在没有回答有关如何创建和注入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();
    ...
}

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


29

您完全可以担心-静态方法调用对于单元测试特别成问题,因为您无法轻松模拟依赖项。我将向您展示的是如何让Spring IoC容器为您完成肮脏的工作,并为您提供简洁,可测试的代码。SecurityContextHolder是一个框架类,虽然可以将低级安全代码与其绑定,但您可能希望向UI组件(即控制器)公开一个更整洁的界面。

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

希望这可以帮助!


9

就我个人而言,我只是将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,但是它非常灵活,允许您对空身份验证对象之类的场景进行单元测试。等,而不必更改您的(非测试)代码


7

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

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


4

我自己在这里问了同样的问题,并发表了我最近发现的答案。简短的答案是:注入a SecurityContext,并且SecurityContextHolder仅在您的Spring配置中引用以获取SecurityContext


3

一般

在此期间(自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;
        }
    };
}

2

我将看一下这里讨论的Spring的抽象测试类和模拟对象。它们提供了自动接线Spring管理的对象的强大方法,从而使单元和集成测试更加容易。


尽管这些测试类很有帮助,但我不确定它们是否适用于此。我的测试没有ApplicationContext的概念-它们不需要一个。我需要做的就是确保在测试方法运行之前已填充SecurityContext-必须先在ThreadLocal中进行设置,这感觉很脏
matt b

1

身份验证是服务器环境中线程的属性,就像它是OS中进程的属性一样。具有用于访问身份验证信息的bean实例将带来不便的配置和布线开销,而没有任何好处。

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


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 
}
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.