Teste de unidade com Spring Security


140

Minha empresa está avaliando o Spring MVC para determinar se devemos usá-lo em um de nossos próximos projetos. Até agora, adoro o que vi e agora estou analisando o módulo Spring Security para determinar se é algo que podemos / devemos usar.

Nossos requisitos de segurança são bem básicos; um usuário só precisa fornecer um nome de usuário e senha para acessar determinadas partes do site (como obter informações sobre sua conta); e há algumas páginas no site (FAQs, suporte etc.) às quais um usuário anônimo deve ter acesso.

No protótipo que estou criando, tenho armazenado um objeto "LoginCredentials" (que apenas contém nome de usuário e senha) na Sessão para um usuário autenticado; alguns dos controladores verificam se este objeto está em sessão para obter uma referência ao nome de usuário conectado, por exemplo. Estou procurando substituir essa lógica caseira pela Spring Security, o que traria o benefício de remover qualquer tipo de "como rastreamos os usuários conectados?" e "como autenticamos usuários?" do meu controlador / código comercial.

Parece que o Spring Security fornece um objeto de "contexto" (por thread) para poder acessar o nome de usuário / informações principais de qualquer lugar do seu aplicativo ...

Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();

... que parece muito pouco primaveril, pois esse objeto é um singleton (global), de certa forma.

Minha pergunta é a seguinte: se esta é a maneira padrão de acessar informações sobre o usuário autenticado no Spring Security, qual é a maneira aceita de injetar um objeto de autenticação no SecurityContext, para que fique disponível para meus testes de unidade quando os testes de unidade exigirem um usuário autenticado?

Preciso conectar isso no método de inicialização de cada caso de teste?

protected void setUp() throws Exception {
    ...
    SecurityContextHolder.getContext().setAuthentication(
        new UsernamePasswordAuthenticationToken(testUser.getLogin(), testUser.getPassword()));
    ...
}

Isso parece muito detalhado. Existe uma maneira mais fácil?

O SecurityContextHolderobjeto em si parece muito pouco parecido com a Primavera ...

Respostas:


48

O problema é que o Spring Security não disponibiliza o objeto Autenticação como um bean no contêiner, portanto, não há como injetá-lo ou instalá-lo automaticamente fora da caixa.

Antes de começarmos a usar o Spring Security, criaríamos um bean com escopo de sessão no contêiner para armazenar o Principal, injetar isso em um "AuthenticationService" (singleton) e depois injetar esse bean em outros serviços que precisassem de conhecimento do Principal atual.

Se você estiver implementando seu próprio serviço de autenticação, poderá basicamente fazer o mesmo: criar um bean com escopo de sessão com uma propriedade "principal", injetar isso no seu serviço de autenticação, fazer com que o serviço de autenticação defina a propriedade como autenticação bem-sucedida e, em seguida, disponibilize o serviço de autenticação para outros beans conforme necessário.

Eu não me sentiria tão mal com o SecurityContextHolder. Apesar. Eu sei que é um estático / Singleton e que o Spring desencoraja o uso dessas coisas, mas sua implementação cuida para se comportar adequadamente, dependendo do ambiente: com escopo de sessão em um contêiner de Servlet, com escopo de thread em um teste JUnit, etc. O verdadeiro fator limitante de um Singleton é quando ele fornece uma implementação que é inflexível para diferentes ambientes.


Obrigado, este é um conselho útil. O que eu fiz até agora é basicamente prosseguir com a chamada SecurityContextHolder.getContext () (através de alguns métodos de invólucro de minha autoria, portanto, pelo menos, é chamado apenas de uma classe).
matt b

2
Embora apenas uma observação - eu não acho que o ServletContextHolder tenha qualquer conceito de HttpSession ou uma maneira de saber se está operando em um ambiente de servidor da web - ele usa o ThreadLocal, a menos que você o configure para usar outra coisa (os únicos outros dois modos internos são InheritableThreadLocal e Global)
matt b

A única desvantagem de usar beans com escopo de sessão / solicitação no Spring é que eles falharão em um teste JUnit. O que você pode fazer é implementar um escopo personalizado que usará a sessão / solicitação, se disponível, e será necessário voltar ao encadeamento. Meu palpite é que Spring Security está fazendo algo semelhante ...
cliff.meyers

Meu objetivo é construir uma API de descanso sem sessões. Talvez com um token atualizável. Enquanto isso não respondeu à minha pergunta, ajudou. Obrigado
Pomagranite

166

Faça da maneira usual e insira-o usando SecurityContextHolder.setContext()na sua classe de teste, por exemplo:

Controlador:

Authentication a = SecurityContextHolder.getContext().getAuthentication();

Teste:

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, onde isso deve Authentication aser adicionado no controlador? Como posso entender em cada invocação de método? Está tudo bem no "caminho da primavera" apenas para adicioná-lo, em vez de injetar?
precisa

Mas lembre-se a sua não vai trabalhar com TestNG porque SecurityContextHolder segurar variável de segmento local, para então você compartilhar esta variável entre os testes ...
Łukasz Wozniczka

Faça isso em @BeforeEach(JUnit5) ou @Before(JUnit 4). Bom e simples.
WesternGun 26/11/19

30

Sem responder à pergunta sobre como criar e injetar objetos de autenticação, o Spring Security 4.0 fornece algumas alternativas bem-vindas quando se trata de teste. A @WithMockUseranotação permite que o desenvolvedor especifique um usuário simulado (com autoridades, nome de usuário, senha e funções opcionais) de maneira organizada:

@Test
@WithMockUser(username = "admin", authorities = { "ADMIN", "USER" })
public void getMessageWithMockUserCustomAuthorities() {
    String message = messageService.getMessage();
    ...
}

Também existe a opção @WithUserDetailsde emular um UserDetailsretorno de UserDetailsService, por exemplo,

@Test
@WithUserDetails("customUsername")
public void getMessageWithUserDetailsCustomUsername() {
    String message = messageService.getMessage();
    ...
}

Mais detalhes podem ser encontrados nos capítulos @WithMockUser e @WithUserDetails nos documentos de referência do Spring Security (dos quais os exemplos acima foram copiados)


29

Você tem razão em se preocupar - as chamadas de método estático são particularmente problemáticas para o teste de unidade, pois você não pode zombar facilmente de suas dependências. O que vou mostrar a você é como deixar o contêiner Spring IoC fazer o trabalho sujo para você, deixando um código limpo e testável. SecurityContextHolder é uma classe de estrutura e, embora possa estar ok para o seu código de segurança de baixo nível estar vinculado a ele, você provavelmente deseja expor uma interface mais limpa aos seus componentes de interface do usuário (ou seja, controladores).

cliff.meyers mencionou uma maneira de contornar isso - crie seu próprio tipo "principal" e injete uma instância nos consumidores. A tag Spring < aop: scoped-proxy /> introduzida na 2.x combinada com uma definição de bean de escopo de solicitação e o suporte ao método de fábrica podem ser o ticket para o código mais legível.

Pode funcionar da seguinte maneira:

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
}

Nada complicado até agora, certo? Na verdade, você provavelmente já teve que fazer a maior parte disso. Em seguida, no seu contexto de bean, defina um bean com escopo de solicitação para armazenar o principal:

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

Graças à mágica da tag aop: scoped-proxy, o método estático getUserDetails será chamado toda vez que uma nova solicitação HTTP for recebida e qualquer referência à propriedade currentUser será resolvida corretamente. Agora o teste de unidade se torna trivial:

protected void setUp() {
    // existing init code

    MyUserDetails user = new MyUserDetails();
    // set up user as you wish
    controller.setCurrentUser(user);
}

Espero que isto ajude!


9

Pessoalmente, eu usaria o Powermock junto com o Mockito ou o Easymock para zombar do SecurityContextHolder.getSecurityContext () estático no seu teste de unidade / integração, por exemplo

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

É certo que há um pouco de código da placa da caldeira aqui, ou seja, simula um objeto de autenticação, simula um SecurityContext para retornar a autenticação e, finalmente, simula o SecurityContextHolder para obter o SecurityContext, no entanto, é muito flexível e permite que você teste de unidade para cenários como objetos de autenticação nulos etc. sem ter que alterar seu código (sem teste)


7

Usar uma estática nesse caso é a melhor maneira de escrever código seguro.

Sim, as estáticas geralmente são ruins - geralmente, mas neste caso, a estática é o que você deseja. Como o contexto de segurança associa um Principal ao encadeamento atualmente em execução, o código mais seguro acessa a estática do encadeamento o mais diretamente possível. Ocultar o acesso atrás de uma classe de wrapper que é injetada fornece ao invasor mais pontos para atacar. Eles não precisariam acessar o código (que dificilmente mudariam se o jar fosse assinado), eles apenas precisariam de uma maneira de substituir a configuração, o que pode ser feito em tempo de execução ou colocar algum XML no caminho de classe. Mesmo usando a injeção de anotação seria substituível pelo XML externo. Esse XML pode injetar no sistema em execução um principal não autorizado.


4

Fiz a mesma pergunta aqui e acabei de publicar uma resposta que encontrei recentemente. A resposta curta é: injete ae SecurityContextrefira-se SecurityContextHolderapenas à sua configuração do Spring para obter oSecurityContext


3

Geral

Enquanto isso (desde a versão 3.2, no ano de 2013, graças à SEC-2298 ), a autenticação pode ser injetada nos métodos MVC usando a anotação @AuthenticationPrincipal :

@Controller
class Controller {
  @RequestMapping("/somewhere")
  public void doStuff(@AuthenticationPrincipal UserDetails myUser) {
  }
}

Testes

No seu teste de unidade, você pode obviamente chamar esse método diretamente. Nos testes de integração, org.springframework.test.web.servlet.MockMvcvocê pode usar org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user()para injetar o usuário assim:

mockMvc.perform(get("/somewhere").with(user(myUserDetails)));

No entanto, isso apenas preencherá diretamente o SecurityContext. Se você deseja garantir que o usuário seja carregado de uma sessão em seu teste, você pode usar o seguinte:

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

Eu daria uma olhada nas classes de teste abstratas do Spring e nos objetos simulados mencionados aqui . Eles fornecem uma maneira poderosa de conectar automaticamente seus objetos gerenciados Spring, facilitando o teste de unidade e integração.


Embora essas classes de teste sejam úteis, não tenho certeza se elas se aplicam aqui. Meus testes não têm conceito do ApplicationContext - eles não precisam de um. Todos necessidade I é certificar-se de que o SecurityContext é preenchida antes o método de teste é executado - ele só se sente sujo ter que colocá-lo em um ThreadLocal primeiro
matt b

1

A autenticação é uma propriedade de um encadeamento no ambiente do servidor da mesma maneira que é uma propriedade de um processo no SO. Ter uma instância de bean para acessar informações de autenticação seria configuração inconveniente e sobrecarga da fiação sem nenhum benefício.

Em relação à autenticação de teste, existem várias maneiras de facilitar sua vida. O meu favorito é criar uma anotação personalizada @Authenticatede um ouvinte de execução de teste, que a gerencia. Procure DirtiesContextTestExecutionListenerinspiração.


0

Depois de muito trabalho, consegui reproduzir o comportamento desejado. Eu havia emulado o login pelo MockMvc. É muito pesado para a maioria dos testes de unidade, mas útil para testes de integração.

É claro que estou disposto a ver os novos recursos do Spring Security 4.0 que facilitarão nossos testes.

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 
}
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.