164

我试图弄清楚如何对我的控制器的 URL 是否正确保护进行单元测试。以防万一有人更改并意外删除了安全设置。

我的控制器方法如下所示:

@RequestMapping("/api/v1/resource/test") 
@Secured("ROLE_USER")
public @ResonseBody String test() {
    return "test";
}

我像这样设置了一个 WebTestEnvironment:

import javax.annotation.Resource;
import javax.naming.NamingException;
import javax.sql.DataSource;

import org.junit.Before;
import org.junit.runner.RunWith;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.support.ClassPathXmlApplicationContext;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.test.context.ActiveProfiles;
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;

@RunWith(SpringJUnit4ClassRunner.class)
@WebAppConfiguration
@ContextConfiguration({ 
        "file:src/main/webapp/WEB-INF/spring/security.xml",
        "file:src/main/webapp/WEB-INF/spring/applicationContext.xml",
        "file:src/main/webapp/WEB-INF/spring/servlet-context.xml" })
public class WebappTestEnvironment2 {

    @Resource
    private FilterChainProxy springSecurityFilterChain;

    @Autowired
    @Qualifier("databaseUserService")
    protected UserDetailsService userDetailsService;

    @Autowired
    private WebApplicationContext wac;

    @Autowired
    protected DataSource dataSource;

    protected MockMvc mockMvc;

    protected final Logger logger = LoggerFactory.getLogger(this.getClass());

    protected UsernamePasswordAuthenticationToken getPrincipal(String username) {

        UserDetails user = this.userDetailsService.loadUserByUsername(username);

        UsernamePasswordAuthenticationToken authentication = 
                new UsernamePasswordAuthenticationToken(
                        user, 
                        user.getPassword(), 
                        user.getAuthorities());

        return authentication;
    }

    @Before
    public void setupMockMvc() throws NamingException {

        // setup mock MVC
        this.mockMvc = MockMvcBuilders
                .webAppContextSetup(this.wac)
                .addFilters(this.springSecurityFilterChain)
                .build();
    }
}

在我的实际测试中,我尝试做这样的事情:

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.Test;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;

import eu.ubicon.webapp.test.WebappTestEnvironment;

public class CopyOfClaimTest extends WebappTestEnvironment {

    @Test
    public void signedIn() throws Exception {

        UsernamePasswordAuthenticationToken principal = 
                this.getPrincipal("test1");

        SecurityContextHolder.getContext().setAuthentication(principal);        

        super.mockMvc
            .perform(
                    get("/api/v1/resource/test")
//                    .principal(principal)
                    .session(session))
            .andExpect(status().isOk());
    }

}

我在这里捡到了这个:

然而,如果仔细观察,这仅在不向 URL 发送实际请求时才有帮助,而仅在功能级别测试服务时才有用。在我的情况下,引发了“拒绝访问”异常:

org.springframework.security.access.AccessDeniedException: Access is denied
    at org.springframework.security.access.vote.AffirmativeBased.decide(AffirmativeBased.java:83) ~[spring-security-core-3.1.3.RELEASE.jar:3.1.3.RELEASE]
    at org.springframework.security.access.intercept.AbstractSecurityInterceptor.beforeInvocation(AbstractSecurityInterceptor.java:206) ~[spring-security-core-3.1.3.RELEASE.jar:3.1.3.RELEASE]
    at org.springframework.security.access.intercept.aopalliance.MethodSecurityInterceptor.invoke(MethodSecurityInterceptor.java:60) ~[spring-security-core-3.1.3.RELEASE.jar:3.1.3.RELEASE]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:172) ~[spring-aop-3.2.1.RELEASE.jar:3.2.1.RELEASE]
        ...

以下两条日志消息值得注意,基本上是说没有用户通过身份验证,表明设置Principal不起作用,或者它被覆盖了。

14:20:34.454 [main] DEBUG o.s.s.a.i.a.MethodSecurityInterceptor - Secure object: ReflectiveMethodInvocation: public java.util.List test.TestController.test(); target is of class [test.TestController]; Attributes: [ROLE_USER]
14:20:34.454 [main] DEBUG o.s.s.a.i.a.MethodSecurityInterceptor - Previously Authenticated: org.springframework.security.authentication.AnonymousAuthenticationToken@9055e4a6: Principal: anonymousUser; Credentials: [PROTECTED]; Authenticated: true; Details: org.springframework.security.web.authentication.WebAuthenticationDetails@957e: RemoteIpAddress: 127.0.0.1; SessionId: null; Granted Authorities: ROLE_ANONYMOUS
4

10 回答 10

156

寻找答案我找不到任何既简单又灵活的方法,然后我找到了Spring Security Reference,我意识到有近乎完美的解决方案。AOP 解决方案通常是最适合测试的解决方案,Spring 为它提供了@WithMockUser,@WithUserDetails@WithSecurityContext, 在这个工件中:

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <version>4.2.2.RELEASE</version>
    <scope>test</scope>
</dependency>

在大多数情况下,@WithUserDetails收集我需要的灵活性和力量。

@WithUserDetails 是如何工作的?

基本上,您只需要创建一个UserDetailsService包含您想要测试的所有可能用户配置文件的自定义。例如

@TestConfiguration
public class SpringSecurityWebAuxTestConfig {

    @Bean
    @Primary
    public UserDetailsService userDetailsService() {
        User basicUser = new UserImpl("Basic User", "user@company.com", "password");
        UserActive basicActiveUser = new UserActive(basicUser, Arrays.asList(
                new SimpleGrantedAuthority("ROLE_USER"),
                new SimpleGrantedAuthority("PERM_FOO_READ")
        ));

        User managerUser = new UserImpl("Manager User", "manager@company.com", "password");
        UserActive managerActiveUser = new UserActive(managerUser, Arrays.asList(
                new SimpleGrantedAuthority("ROLE_MANAGER"),
                new SimpleGrantedAuthority("PERM_FOO_READ"),
                new SimpleGrantedAuthority("PERM_FOO_WRITE"),
                new SimpleGrantedAuthority("PERM_FOO_MANAGE")
        ));

        return new InMemoryUserDetailsManager(Arrays.asList(
                basicActiveUser, managerActiveUser
        ));
    }
}

现在我们的用户已经准备好了,想象一下我们要测试这个控制器函数的访问控制:

@RestController
@RequestMapping("/foo")
public class FooController {

    @Secured("ROLE_MANAGER")
    @GetMapping("/salute")
    public String saluteYourManager(@AuthenticationPrincipal User activeUser)
    {
        return String.format("Hi %s. Foo salutes you!", activeUser.getUsername());
    }
}

在这里,我们有一个映射到路由/foo/salute@Secured的函数,我们正在使用注释测试基于角色的安全性,尽管您@PreAuthorize也可以进行测试@PostAuthorize。让我们创建两个测试,一个检查有效用户是否可以看到这个敬礼响应,另一个检查它是否真的被禁止。

@RunWith(SpringRunner.class)
@SpringBootTest(
        webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
        classes = SpringSecurityWebAuxTestConfig.class
)
@AutoConfigureMockMvc
public class WebApplicationSecurityTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    @WithUserDetails("manager@company.com")
    public void givenManagerUser_whenGetFooSalute_thenOk() throws Exception
    {
        mockMvc.perform(MockMvcRequestBuilders.get("/foo/salute")
                .accept(MediaType.ALL))
                .andExpect(status().isOk())
                .andExpect(content().string(containsString("manager@company.com")));
    }

    @Test
    @WithUserDetails("user@company.com")
    public void givenBasicUser_whenGetFooSalute_thenForbidden() throws Exception
    {
        mockMvc.perform(MockMvcRequestBuilders.get("/foo/salute")
                .accept(MediaType.ALL))
                .andExpect(status().isForbidden());
    }
}

如您所见,我们导入SpringSecurityWebAuxTestConfig以提供我们的用户进行测试。每一个都在其对应的测试用例上使用,只需使用简单的注释,减少代码和复杂性。

更好地使用 @WithMockUser 以获得更简单的基于角色的安全性

如您所见@WithUserDetails,它具有大多数应用程序所需的所有灵活性。它允许您使用具有任何 GrantedAuthority 的自定义用户,例如角色或权限。但是,如果您只是使用角色,测试会更容易,并且您可以避免构建自定义UserDetailsService. 在这种情况下,使用@WithMockUser指定用户、密码和角色的简单组合。

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
@WithSecurityContext(
    factory = WithMockUserSecurityContextFactory.class
)
public @interface WithMockUser {
    String value() default "user";

    String username() default "";

    String[] roles() default {"USER"};

    String password() default "password";
}

注释为非常基本的用户定义了默认值。在我们的例子中,我们正在测试的路由只要求经过身份验证的用户是管理员,我们可以退出使用SpringSecurityWebAuxTestConfig并执行此操作。

@Test
@WithMockUser(roles = "MANAGER")
public void givenManagerUser_whenGetFooSalute_thenOk() throws Exception
{
    mockMvc.perform(MockMvcRequestBuilders.get("/foo/salute")
            .accept(MediaType.ALL))
            .andExpect(status().isOk())
            .andExpect(content().string(containsString("user")));
}

请注意,现在我们得到的不是用户manager@company.com@WithMockUser提供的默认值:user ; 但这并不重要,因为我们真正关心的是他的角色:ROLE_MANAGER

结论

正如您所看到的注释@WithUserDetails@WithMockUser我们可以在不同的经过身份验证的用户场景之间切换,而无需构建与我们的架构疏远的类,只是为了进行简单的测试。它还建议您查看@WithSecurityContext如何工作以获得更大的灵活性。

于 2017-05-11T16:24:11.773 回答
86

从 Spring 4.0+ 开始,最好的解决方案是使用 @WithMockUser 注释测试方法

@Test
@WithMockUser(username = "user1", password = "pwd", roles = "USER")
public void mytest1() throws Exception {
    mockMvc.perform(get("/someApi"))
        .andExpect(status().isOk());
}

请记住将以下依赖项添加到您的项目中

'org.springframework.security:spring-security-test:4.2.3.RELEASE'
于 2017-11-30T13:51:27.853 回答
56

事实证明,SecurityContextPersistenceFilter作为 Spring Security 过滤器链的一部分,总是重置 my SecurityContext,我设置调用SecurityContextHolder.getContext().setAuthentication(principal)(或使用.principal(principal)方法)。这个过滤器设置了SecurityContext一个SecurityContextHolderSecurityContext一个SecurityContextRepository 覆盖我之前设置的一个。默认情况下,存储库是一个HttpSessionSecurityContextRepositoryHttpSessionSecurityContextRepository检查给定的并HttpRequest尝试访问相应的HttpSession. 如果存在,它将尝试SecurityContextHttpSession. 如果失败,存储库会生成一个空的SecurityContext.

因此,我的解决方案是将 aHttpSession与请求一起传递,其中包含SecurityContext

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.Test;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;

import eu.ubicon.webapp.test.WebappTestEnvironment;

public class Test extends WebappTestEnvironment {

    public static class MockSecurityContext implements SecurityContext {

        private static final long serialVersionUID = -1386535243513362694L;

        private Authentication authentication;

        public MockSecurityContext(Authentication authentication) {
            this.authentication = authentication;
        }

        @Override
        public Authentication getAuthentication() {
            return this.authentication;
        }

        @Override
        public void setAuthentication(Authentication authentication) {
            this.authentication = authentication;
        }
    }

    @Test
    public void signedIn() throws Exception {

        UsernamePasswordAuthenticationToken principal = 
                this.getPrincipal("test1");

        MockHttpSession session = new MockHttpSession();
        session.setAttribute(
                HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, 
                new MockSecurityContext(principal));


        super.mockMvc
            .perform(
                    get("/api/v1/resource/test")
                    .session(session))
            .andExpect(status().isOk());
    }
}
于 2013-03-04T14:11:35.410 回答
32

在 pom.xml 中添加:

    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-test</artifactId>
        <version>4.0.0.RC2</version>
    </dependency>

org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors用于授权请求。请参阅https://github.com/rwinch/spring-security-test-blog ( https://jira.spring.io/browse/SEC-2592 )上的示例用法。

更新:

4.0.0.RC2 适用于 spring-security 3.x。对于 spring-security 4 spring-security-test 成为 spring-security 的一部分(http://docs.spring.io/spring-security/site/docs/4.0.x/reference/htmlsingle/#test,版本相同)。

设置已更改:http ://docs.spring.io/spring-security/site/docs/4.0.x/reference/htmlsingle/#test-mockmvc

public void setup() {
    mvc = MockMvcBuilders
            .webAppContextSetup(context)
            .apply(springSecurity())  
            .build();
}

基本身份验证示例: http: //docs.spring.io/spring-security/site/docs/4.0.x/reference/htmlsingle/#testing-http-basic-authentication

于 2014-05-14T14:57:04.637 回答
7

这是一个示例,供那些想要使用 Base64 基本身份验证测试 Spring MockMvc 安全配置的人使用。

String basicDigestHeaderValue = "Basic " + new String(Base64.encodeBase64(("<username>:<password>").getBytes()));
this.mockMvc.perform(get("</get/url>").header("Authorization", basicDigestHeaderValue).accept(MediaType.APPLICATION_JSON)).andExpect(status().isOk());

Maven 依赖

    <dependency>
        <groupId>commons-codec</groupId>
        <artifactId>commons-codec</artifactId>
        <version>1.3</version>
    </dependency>
于 2014-02-25T02:09:37.303 回答
4

简短的回答:

@Autowired
private WebApplicationContext webApplicationContext;

@Autowired
private Filter springSecurityFilterChain;

@Before
public void setUp() throws Exception {
    final MockHttpServletRequestBuilder defaultRequestBuilder = get("/dummy-path");
    this.mockMvc = MockMvcBuilders.webAppContextSetup(this.webApplicationContext)
            .defaultRequest(defaultRequestBuilder)
            .alwaysDo(result -> setSessionBackOnRequestBuilder(defaultRequestBuilder, result.getRequest()))
            .apply(springSecurity(springSecurityFilterChain))
            .build();
}

private MockHttpServletRequest setSessionBackOnRequestBuilder(final MockHttpServletRequestBuilder requestBuilder,
                                                             final MockHttpServletRequest request) {
    requestBuilder.session((MockHttpSession) request.getSession());
    return request;
}

从 spring 安全测试执行后formLogin,您的每个请求都将被自动调用为登录用户。

长答案:

检查这个解决方案(答案是spring 4):How to login a user with spring 3.2 new mvc testing

于 2017-11-02T07:32:06.287 回答
2

避免在测试中使用 SecurityContextHolder 的选项:

  • 选项 1:使用模拟 - 我的意思是SecurityContextHolder使用一些模拟库进行模拟 -例如EasyMock
  • 选项 2:在您的代码中包装调用SecurityContextHolder.get...某些服务 - 例如在实现接口SecurityServiceImpl的方法中,然后在您的测试中,您可以简单地创建此接口的模拟实现,该接口返回所需的主体而无需访问.getCurrentPrincipalSecurityServiceSecurityContextHolder
于 2013-03-04T17:38:25.650 回答
1

虽然很晚的答案。但这对我有用,并且可能有用。

在使用 Spring Security 和 mockMvc 时,您只需使用 @WithMockUser 注释,就像提到的其他注释一样。

Spring security 还提供了另一个注释,称为@WithAnonymousUser测试未经身份验证的请求。但是,您应该在这里小心。你会期待 401,但默认情况下我得到 403 Forbidden Error。在实际场景中,当您运行实际服务时,它会被重定向,最终您会得到正确的 401 响应代码。匿名请求使用此注解。

您也可以考虑省略注释并简单地保持未经授权。但这通常会引发正确的异常(如 AuthenticationException),但如果处理正确(如果您使用自定义处理程序),您将获得正确的状态代码。我曾经为此得到500。所以寻找调试器中引发的异常,检查是否处理正确并返回正确的状态码。

于 2020-12-04T16:50:14.863 回答
0

TestUserDetailsImpl在您的测试包上创建一个类:

@Service
@Primary
@Profile("test")
public class TestUserDetailsImpl implements UserDetailsService {
    public static final String API_USER = "apiuser@example.com";

    private User getAdminUser() {
        User user = new User();
        user.setUsername(API_USER);

        SimpleGrantedAuthority role = new SimpleGrantedAuthority("ROLE_API_USER");
        user.setAuthorities(Collections.singletonList(role));

        return user;
    }

    @Override
    public UserDetails loadUserByUsername(String username) 
                                         throws UsernameNotFoundException {
        if (Objects.equals(username, ADMIN_USERNAME))
            return getAdminUser();
        throw new UsernameNotFoundException(username);
    }
}

休息端点:

@GetMapping("/invoice")
@Secured("ROLE_API_USER")
public Page<InvoiceDTO> getInvoices(){
   ...
}

测试端点:

@Test
@WithUserDetails("apiuser@example.com")
public void testApi() throws Exception {
     ...
}
于 2021-01-05T12:58:39.143 回答
0

使用时MockMvcBuilders.webAppContextSetup(wac).addFilters(...)springSecurityFilterChain(更具体地说SecurityContextPersistenceFilter)将接管并删除SecurityContext@WithMockUser(相当愚蠢)准备的;发生这种情况是因为SecurityContextPersistenceFilter试图SecurityContextHttpSession找不到的地方“恢复”。好吧,使用AutoStoreSecurityContextHttpFilter下面定义的这个简单的定义,它将负责将@WithMockUser' 准备好SecurityContext放入HttpSession这样以后SecurityContextPersistenceFilter才能找到它的地方。

@ContextConfiguration(...) // the issue doesn't occur when using @SpringBootTest
public class SomeTest {
    @Autowired
    private Filter springSecurityFilterChain;
    private MockMvc mockMvc;

    @BeforeEach
    void setup(WebApplicationContext wac) {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(wac)
                .addFilters(new AutoStoreSecurityContextHttpFilter(), springSecurityFilterChain).build();
    }

    @WithMockUser
    @Test
    void allowAccessToAuthenticated() {
        ...
    }
}

// don't use this Filter in production because it's only intended for tests, to solve the 
// @WithMockUser & springSecurityFilterChain (more specifically SecurityContextPersistenceFilter) "misunderstandings"
public class AutoStoreSecurityContextHttpFilter extends HttpFilter {
    protected void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws IOException, ServletException {
        req.getSession().setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, SecurityContextHolder.getContext());
        super.doFilter(req, res, chain);
    }
}
于 2021-08-12T22:38:57.197 回答