【问题标题】:使用 Spring Security 进行单元测试
【发布时间】:2010-09-26 12:11:00
【问题描述】:

我的公司一直在评估 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……

【问题讨论】:

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


    【解决方案1】:

    我会看一下 here 讨论的 Spring 的抽象测试类和模拟对象。它们提供了一种强大的方式来自动连接 Spring 托管对象,从而使单元和集成测试更容易。

    【讨论】:

    • 虽然这些测试课程很有帮助,但我不确定它们是否适用于此。我的测试没有 ApplicationContext 的概念——他们不需要。我所需要的只是确保在测试方法运行之前填充 SecurityContext - 必须首先在 ThreadLocal 中设置它感觉很脏
    【解决方案2】:

    问题在于 Spring Security 没有将 Authentication 对象作为容器中的 bean 提供,因此无法轻松地注入或自动装配它。

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

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

    我不会对使用 SecurityContextHolder 感到难过。尽管。我知道这是一个静态/单例,Spring 不鼓励使用这样的东西,但它们的实现会根据环境注意适当的行为:Servlet 容器中的会话范围,JUnit 测试中的线程范围等。真正的限制因素单例是指它提供了一种对不同环境不灵活的实现。

    【讨论】:

    • 谢谢,这是有用的建议。到目前为止,我所做的基本上是继续调用 SecurityContextHolder.getContext() (通过我自己的一些包装方法,所以至少它只从一个类中调用)。
    • 虽然只有一个注释——我不认为 ServletContextHolder 有任何 HttpSession 的概念或知道它是否在 Web 服务器环境中运行的方法——它使用 ThreadLocal 除非你将它配置为使用其他东西(仅有的另外两种内置模式是 InheritableThreadLocal 和 Global)
    • 在 Spring 中使用 session/request-scoped beans 的唯一缺点是它们将在 JUnit 测试中失败。您可以做的是实现一个自定义范围,如果可用,它将使用会话/请求并回退到线程是必要的。我的猜测是 Spring Security 正在做类似的事情......
    • 我的目标是构建一个没有会话的 Rest api。也许带有可刷新的令牌。虽然这没有回答我的问题,但它有所帮助。谢谢
    【解决方案3】:

    你的担心是对的——静态方法调用对于单元测试来说尤其成问题,因为你不能轻易地模拟你的依赖关系。我将向您展示的是如何让 Spring IoC 容器为您完成繁琐的工作,从而为您留下整洁、可测试的代码。 SecurityContextHolder 是一个框架类,虽然可以将您的低级安全代码绑定到它,但您可能希望向您的 UI 组件(即控制器)公开一个更简洁的界面。

    cliff.meyers 提到了一种解决方法——创建自己的“主体”类型并将实例注入消费者。 2.x 中引入的 Spring aop:scoped-proxy/> 标签与请求范围 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);
    }
    

    希望这会有所帮助!

    【讨论】:

      【解决方案4】:

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

      【讨论】:

        【解决方案5】:

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

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

        【讨论】:

          【解决方案6】:

          我个人只会在你的单元/集成测试中使用 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 身份验证对象等,而无需更改您的(非测试)代码

          【讨论】:

          • 我知道这已经过时了,但是当你可以调用 SecurityContextHolder.setContext().setStrategyName(className) 来实现同样的事情时,使用 PowerMock 是没有意义的。
          【解决方案7】:

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

          【讨论】:

          • @Leonardo 这个Authentication a 应该添加到控制器的哪个位置?正如我在每个方法调用中可以理解的那样? “spring way”只添加而不是注入可以吗?
          • 但请记住它不适用于 TestNG,因为 SecurityContextHolder 持有本地线程变量,因此您可以在测试之间共享此变量...
          • @BeforeEach(JUnit5) 或@Before(JUnit 4) 中执行。又好又简单。
          • 完全符合我的需要。谢谢!
          【解决方案8】:

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

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

          【讨论】:

            【解决方案9】:

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

            【讨论】:

              【解决方案10】:

              一般

              与此同时(从 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;
                      }
                  };
              }
              

              【讨论】:

                【解决方案11】:

                在没有回答有关如何创建和注入 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 章节中找到(上面的例子是从中复制的)

                【讨论】:

                • 这是当今最直接的方法,而且效果很好。
                猜你喜欢
                • 2017-10-21
                • 1970-01-01
                • 1970-01-01
                • 2016-06-05
                • 2014-04-27
                • 2015-08-12
                • 1970-01-01
                • 2019-04-09
                • 2016-12-07
                相关资源
                最近更新 更多