Spring Test & Security: 如何模拟身份验证?

我试图找出如何单元测试,如果我的网址的控制器是正确的安全。以防有人修改了周围的设置,意外地删除了安全设置。

我的控制器方法如下:

@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
309858 次浏览

结果发现,作为 Spring Security 筛选器链的一部分的 SecurityContextPersistenceFilter总是重置我的 SecurityContext,我将其设置为调用 SecurityContextHolder.getContext().setAuthentication(principal)(或使用 .principal(principal)方法)。这个过滤器设置 SecurityContextHolder中的 SecurityContextSecurityContext来自 SecurityContextRepositorySecurityContext5,就是我之前设置的那个。默认情况下,存储库是 HttpSessionSecurityContextRepositoryHttpSessionSecurityContextRepository检查给定的 SecurityContext0并尝试访问相应的 SecurityContext1。如果它存在,它将尝试从 SecurityContext1读取 SecurityContext。如果失败,存储库将生成一个空的 SecurityContext

因此,我的解决方案是在传递请求的同时传递一个 HttpSession,该请求保存 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());
}
}

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

  • 选项1 : 使用 mock-我的意思是使用某个 mock 库模拟 SecurityContextHolder-例如 EasyMock
  • 选项2 : 在某些服务的代码中包装调用 SecurityContextHolder.get...——例如在 SecurityServiceImpl中使用实现 SecurityService接口的方法 getCurrentPrincipal,然后在测试中您可以简单地创建该接口的模拟实现,该实现返回所需的主体,而不需要访问 SecurityContextHolder

下面是一个示例,说明如何使用 Base64基本身份验证来测试 SpringMockMvcSecurityConfig。

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

    <dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.3</version>
</dependency>

添加 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。 对于弹簧安全4,弹簧安全测试成为弹簧安全的一部分(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

在寻找答案的过程中,我无法找到既简单又灵活的解决方案,然后我发现了 Spring 安全引用,我意识到有几乎完美的解决方案。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());
}
}

这里我们有一个 获取映射函数获取映射函数到路由 敬礼,我们正在测试一个基于角色的安全与 @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")));
}

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

结论

正如您所看到的,使用 @WithUserDetails@WithMockUser这样的注释,我们可以在不构建与我们的体系结构相异的类的情况下进行 在不同的经过身份验证的用户方案之间切换,只是为了进行简单的测试。它还建议您了解 @ WithSecurityContext如何工作以获得更大的灵活性。

简短的回答:

@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;
}

在执行 formLogin from spring 安全性测试之后,您的每个请求都将作为登录用户自动调用。

长话短说:

检查这个解决方案(答案是春季4) : 如何用 Spring 3.2新的 mvc 测试登录用户

自 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'

虽然回答得很晚,但是这对我很有用,可能会很有用。

在使用 Spring Security 和 mock Mvc 时,您所需要做的就是像前面提到的那样使用@WithMockUser 注释。

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

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

在测试包上创建一个类 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 {
...
}

当使用 MockMvcBuilders.webAppContextSetup(wac).addFilters(...)springSecurityFilterChain(更具体地说是 SecurityContextPersistenceFilter)将接管和删除 @WithMockUser准备的 SecurityContext(相当愚蠢) ; 这种情况发生是因为 SecurityContextPersistenceFilter试图“恢复”SecurityContextHttpSession那里找不到。那么,使用这个简单的 AutoStoreSecurityContextHttpFilter定义如下,这将照顾到把 @WithMockUser的预备 SecurityContextHttpSession,这样以后 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);
}
}