Spring Test & Security: Como simular a autenticação?

124

Eu estava tentando descobrir como testar a unidade se as URLs dos meus controladores estão devidamente protegidas. Para o caso de alguém mudar as coisas e remover acidentalmente as configurações de segurança.

Meu método de controle é assim:

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

Eu configurei um WebTestEnvironment assim:

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();
    }
}

Em meu teste real, tentei fazer algo assim:

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());
    }

}

Eu peguei isso aqui:

No entanto, se olharmos de perto, isso só ajuda quando não enviamos solicitações reais para URLs, mas apenas quando testamos serviços em um nível de função. No meu caso, foi lançada uma exceção de "acesso negado":

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]
        ...

As duas mensagens de log a seguir são dignas de nota basicamente dizendo que nenhum usuário foi autenticado, indicando que a configuração de Principalnão funcionou ou que foi substituída.

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
Martin Becker
fonte
O nome da sua empresa, eu.ubicon, é exibido na sua importação. Isso não é um risco de segurança?
Kyle Bridenstine 01 de
2
Oi, obrigado pelo comentário! Eu não consigo ver por quê. É um software de código aberto de qualquer maneira. Se você estiver interessado, consulte bitbucket.org/ubicon/ubicon (ou bitbucket.org/dmir_wue/everyaware para o fork mais recente). Me avise se eu perder alguma coisa.
Martin Becker
Verifique esta solução (a resposta é para a primavera 4): stackoverflow.com/questions/14308341/…
Nagy Attila

Respostas:

101

Procurando uma resposta, não consegui encontrar nenhuma que fosse fácil e flexível ao mesmo tempo, então encontrei a Referência de Segurança Spring e percebi que existem soluções quase perfeitas. Soluções AOP muitas vezes são os maiores queridos para testes, e Spring fornece-lo com @WithMockUser, @WithUserDetailse @WithSecurityContext, neste artefato:

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

Na maioria dos casos, @WithUserDetailsreúne a flexibilidade e a potência de que preciso.

Como funciona @WithUserDetails?

Basicamente, você só precisa criar um personalizado UserDetailsServicecom todos os perfis de usuários possíveis que deseja testar. Por exemplo

@TestConfiguration
public class SpringSecurityWebAuxTestConfig {

    @Bean
    @Primary
    public UserDetailsService userDetailsService() {
        User basicUser = new UserImpl("Basic User", "[email protected]", "password");
        UserActive basicActiveUser = new UserActive(basicUser, Arrays.asList(
                new SimpleGrantedAuthority("ROLE_USER"),
                new SimpleGrantedAuthority("PERM_FOO_READ")
        ));

        User managerUser = new UserImpl("Manager User", "[email protected]", "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
        ));
    }
}

Agora que temos nossos usuários prontos, imagine que queremos testar o controle de acesso a esta função do controlador:

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

Aqui temos uma função mapeada para a rota / foo / salute e estamos testando uma segurança baseada em função com a @Securedanotação, embora você possa testar @PreAuthorizee @PostAuthorizetambém. Vamos criar dois testes, um para verificar se um usuário válido pode ver essa resposta de saudação e outro para verificar se é realmente proibido.

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

    @Autowired
    private MockMvc mockMvc;

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

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

Como você pode ver, importamos SpringSecurityWebAuxTestConfigpara fornecer nossos usuários para teste. Cada um é usado em seu caso de teste correspondente apenas usando uma anotação direta, reduzindo o código e a complexidade.

Use melhor @WithMockUser para uma segurança baseada em funções mais simples

Como você pode ver, @WithUserDetailstem toda a flexibilidade de que você precisa para a maioria de seus aplicativos. Ele permite que você use usuários personalizados com qualquer GrantedAuthority, como funções ou permissões. Mas se você está apenas trabalhando com funções, o teste pode ser ainda mais fácil e você pode evitar a construção de um personalizado UserDetailsService. Nesses casos, especifique uma combinação simples de usuário, senha e funções com @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";
}

A anotação define valores padrão para um usuário muito básico. Como em nosso caso a rota que estamos testando requer apenas que o usuário autenticado seja um gerente, podemos parar de usar SpringSecurityWebAuxTestConfige fazer isso.

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

Observe que agora, em vez do usuário [email protected] , estamos obtendo o padrão fornecido por @WithMockUser: usuário ; no entanto, não importará porque o que realmente importa é o seu papel: ROLE_MANAGER.

Conclusões

Como você pode ver com anotações como @WithUserDetailse @WithMockUserpodemos alternar entre diferentes cenários de usuários autenticados sem construir classes alienadas de nossa arquitetura apenas para fazer testes simples. Também é recomendado que você veja como @WithSecurityContext funciona para ter ainda mais flexibilidade.

EliuX
fonte
Como simular vários usuários ? Por exemplo, a primeira solicitação é enviada por tom, enquanto a segunda é enviada por jerry?
ch271828n
Você pode criar uma função onde seu teste está com tom e criar outro teste com a mesma lógica e testá-lo com Jerry. Haverá um resultado específico para cada teste, portanto, haverá diferentes afirmações e, se um teste falhar, ele dirá pelo nome qual usuário / função não funcionou. Lembre-se de que em uma solicitação, o usuário pode ser apenas um, portanto, especificar vários usuários em uma solicitação não faz sentido.
EliuX
Desculpe, quero dizer esse cenário de exemplo: Nós testamos isso, Tom cria um artigo secreto, então Jerry tenta ler isso e Jerry não deve vê-lo (já que é secreto). Então, neste caso, é um teste de unidade ...
ch271828n
É muito parecido com o cenário BasicUsere Manager Userfornecido na resposta. O conceito principal é que, em vez de nos preocuparmos com os usuários, realmente nos preocupamos com suas funções, mas cada um desses testes, localizados no mesmo teste de unidade, na verdade representam consultas diferentes. feito por usuários diferentes (com funções diferentes) para o mesmo terminal.
EliuX de
61

Desde o Spring 4.0+, a melhor solução é anotar o método de teste com @WithMockUser

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

Lembre-se de adicionar a seguinte dependência ao seu projeto

'org.springframework.security:spring-security-test:4.2.3.RELEASE'
GummyBear21
fonte
1
A primavera é incrível. Obrigado
TuGordoBello
Boa resposta. Além disso - você não precisa usar o mockMvc, mas caso esteja usando, por exemplo, PagingAndSortingRepository de springframework.data - você pode apenas chamar métodos do repositório diretamente (que são anotados com EL @PreAuthorize (......))
supertramp
50

Acontece que o SecurityContextPersistenceFilter, que faz parte da cadeia de filtros do Spring Security, sempre redefine meu SecurityContext, que eu defini chamando SecurityContextHolder.getContext().setAuthentication(principal)(ou usando o .principal(principal)método). Este filtro define o SecurityContextem SecurityContextHoldercom um SecurityContextde um SecurityContextRepository SOBRESCREVER aquele que eu defini anteriormente. O repositório é um HttpSessionSecurityContextRepositorypor padrão. O HttpSessionSecurityContextRepositoryinspeciona o dado HttpRequeste tenta acessar o correspondente HttpSession. Se existir, ele tentará ler o SecurityContextdo HttpSession. Se isso falhar, o repositório gerará um vazio SecurityContext.

Assim, minha solução é repassar um HttpSessionjunto com a solicitação, que contém 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());
    }
}
Martin Becker
fonte
2
Ainda não adicionamos suporte oficial para Spring Security. Consulte jira.springsource.org/browse/SEC-2015. Um esboço de como será a aparência está especificado em github.com/SpringSource/spring-test-mvc/blob/master/src/test/…
Rob Winch
Não acho que criar um objeto de autenticação e adicionar uma sessão com o atributo correspondente seja tão ruim. Você acha que esta é uma "solução alternativa" válida? O suporte direto, por outro lado, seria ótimo, é claro. Parece muito legal. Obrigado pelo link!
Martin Becker
ótima solução. funcionou para mim! apenas um pequeno problema com a nomenclatura do método protegido getPrincipal()que, na minha opinião, é um pouco enganador. idealmente, deveria ter sido nomeado getAuthentication(). da mesma forma, em seu signedIn()teste, a variável local deve ser nomeada authou em authenticationvez deprincipal
Tanvir
O que é "getPrincipal (" test1 ") ¿?? Você poderia explicar onde é isso? Agradecemos antecipadamente
user2992476
@ user2992476 Provavelmente retorna um objeto do tipo UsernamePasswordAuthenticationToken. Como alternativa, você cria GrantedAuthority e constrói este objeto.
bluelurker
31

Adicionar em pom.xml:

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

e uso org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessorspara solicitação de autorização. Veja o exemplo de uso em https://github.com/rwinch/spring-security-test-blog ( https://jira.spring.io/browse/SEC-2592 ).

Atualizar:

4.0.0.RC2 funciona para spring-security 3.x. Para spring-security 4 spring-security-test torna-se parte de spring-security ( http://docs.spring.io/spring-security/site/docs/4.0.x/reference/htmlsingle/#test , a versão é a mesma )

A configuração foi alterada: 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();
}

Amostra para autenticação básica: http://docs.spring.io/spring-security/site/docs/4.0.x/reference/htmlsingle/#testing-http-basic-authentication .

Grigory Kislin
fonte
Isso também corrigiu meu problema de obter um 404 ao tentar fazer login por meio de um filtro de segurança de login. Obrigado!
Ian Newland
Olá, durante o teste, conforme mencionado por GKislin. Estou recebendo o seguinte erro "Falha na autenticação UserDetailsService retornou nulo, que é uma violação do contrato de interface". Qualquer sugestão, por favor. AuthenticationRequest final auth = new AuthenticationRequest (); auth.setUsername (userId); auth.setPassword (senha); mockMvc.perform (post ("/ api / auth /"). content (json (auth)). contentType (MediaType.APPLICATION_JSON));
Sanjeev
7

Aqui está um exemplo para aqueles que desejam testar a configuração de segurança do Spring MockMvc usando autenticação básica Base64.

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());

Dependência Maven

    <dependency>
        <groupId>commons-codec</groupId>
        <artifactId>commons-codec</artifactId>
        <version>1.3</version>
    </dependency>
Jay
fonte
3

Resposta curta:

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

Após realizar o formLoginteste de segurança da primavera, cada uma das suas solicitações será automaticamente chamada de usuário conectado.

Resposta longa:

Verifique esta solução (a resposta é para a primavera 4): Como fazer o login de um usuário com o novo teste mvc da primavera 3.2

Nagy Attila
fonte
2

Opções para evitar o uso de SecurityContextHolder em testes:

  • Opção 1 : use mocks - quero dizer, simule SecurityContextHolderusando alguma biblioteca simulada - EasyMock por exemplo
  • Opção 2 : envolver a chamada SecurityContextHolder.get...em seu código em algum serviço - por exemplo, SecurityServiceImplcom o método getCurrentPrincipalque implementa a SecurityServiceinterface e, em seguida, em seus testes, você pode simplesmente criar uma implementação simulada dessa interface que retorna o principal desejado sem acesso a SecurityContextHolder.
Pavla Nováková
fonte
Mh, talvez eu não entenda o quadro completo. Meu problema era que o SecurityContextPersistenceFilter substitui o SecurityContext usando um SecurityContext de um HttpSessionSecurityContextRepository, que por sua vez lê o SecurityContext do HttpSession correspondente. Assim, a solução usando a sessão. Em relação à chamada para o SecurityContextHolder: Editei minha resposta para não usar mais uma chamada para o SecurityContextHolder. Mas também sem a introdução de qualquer pacote ou bibliotecas de simulação extras. Você acha que esta é uma solução melhor?
Martin Becker
Não entendi exatamente o que você estava procurando e não posso fornecer uma resposta melhor do que a solução que você encontrou e - parece ser uma boa opção.
Pavla Nováková
Certo, obrigado. Vou aceitar minha proposta como uma solução por enquanto.
Martin Becker