Escrevendo Testes de Segurança com @WithMockUser e CSRF

Ao concluir esta lição, você será capaz de:

  • Saber que tipo de massa de dados devemos usar ao escrever testes de segurança para controllers Spring MVC e REST.
  • Compreender como adicionar um token CSRF válido em requisições HTTP mutáveis (POST, PUT, DELETE, PATCH) no MockMvc.
  • Configurar o usuário autenticado (@WithAnonymousUser, @WithMockUser e @WithUserDetails) utilizado durante o processamento da requisição HTTP pelo controller testado.

Esta lição assume que:


1. Introdução ao Sistema Sob Teste (SUT)

Os requisitos de segurança da nossa aplicação de exemplo são:

  • Nossa aplicação possui dois perfis de usuário (roles): ROLE_USER e ROLE_ADMIN.
  • O perfil ROLE_ADMIN possui todas as permissões de acesso concedidas ao perfil ROLE_USER.
  • Apenas usuários autenticados que possuam pelo menos o perfil ROLE_USER têm permissão para acessar os endpoints da nossa aplicação.

Além disso, adicionamos uma nova tabela chamada user_accounts ao esquema do nosso banco de dados. Essa tabela armazena as contas dos usuários cadastrados na aplicação (como id, email_address, name, password, role e is_enabled).

A seguir, criaremos o dataset do DbUnit que inicializa as contas de usuário no nosso banco de dados.


2. Criando o Dataset de Usuários (users.xml)

Inseriremos os dados de usuários necessários no banco de dados criando um novo arquivo de dataset separado, pois a tabela user_accounts pertence a um domínio lógico diferente das tabelas tasks, tags e tasks_tags.

Como nossa aplicação possui dois perfis de usuário (ROLE_USER e ROLE_ADMIN), criamos um dataset que insere duas linhas na tabela user_accounts. O nome desse arquivo é users.xml, e seu conteúdo é o seguinte:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<dataset>
    <user_accounts id="1"
                   creation_time="2017-02-11 11:41:28"
                   email_address="john.doe@gmail.com"
                   is_enabled="true"
                   modification_time="2017-02-11 11:41:28"
                   name="John Doe"
                   password="user"
                   role="ROLE_USER"
                   version="0"/>
    <user_accounts id="2"
                   creation_time="2017-02-11 11:41:28"
                   email_address="anne.admin@gmail.com"
                   is_enabled="true"
                   modification_time="2017-02-11 11:41:28"
                   name="Anne Admin"
                   password="admin"
                   role="ROLE_ADMIN"
                   version="0"/>
</dataset>

Nota: Como esse dataset será reutilizado por múltiplos testes de integração em pacotes distintos, devemos colocá-lo no pacote raiz de recursos /com/testwithspring/intermediate/users.xml, junto com os demais datasets compartilhados.

A seguir, veremos como criar a classe de constantes que espelha os dados inseridos na tabela user_accounts.


3. Utilizando os Dados de Usuário no Código de Teste (Users)

Criamos o objeto de constantes Users seguindo estes três passos:

  1. Declarar um object Users em Kotlin (que garante uma única instância singleton sem permitir instanciação externa).
  2. Declarar as constantes no sub-objeto JohnDoe contendo as informações do usuário com perfil ROLE_USER.
  3. Declarar as constantes no sub-objeto AnneAdmin contendo as informações da usuária com perfil ROLE_ADMIN.

Após criarmos o objeto de constantes, seu código-fonte em Kotlin ficará assim:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package com.testwithspring.intermediate

import com.testwithspring.intermediate.user.UserRole

object Users {

    object JohnDoe {
        const val ID = 1L
        const val EMAIL_ADDRESS = "john.doe@gmail.com"
        const val NAME = "John Doe"
        const val PASSWORD = "user"
        val ROLE = UserRole.ROLE_USER
    }

    object AnneAdmin {
        const val ID = 2L
        const val EMAIL_ADDRESS = "anne.admin@gmail.com"
        const val NAME = "Anne Admin"
    }
}

Duas observações importantes sobre o design da classe Users:

  • Demos nomes de “pessoas reais” (JohnDoe e AnneAdmin) aos nossos objetos internos porque personas reais são muito mais fáceis de ler e lembrar nos testes do que nomes burocráticos como RegisteredUser e Administrator.
  • Os objetos JohnDoe e AnneAdmin declaram constantes ligeiramente diferentes porque incluímos na classe Users apenas as constantes que são efetivamente referenciadas pelos nossos testes de integração.

Agora estamos prontos para escrever testes de segurança para os nossos controllers Spring MVC!


4. Escrevendo Testes de Segurança para Controllers Spring MVC

Durante esta seção, modificaremos um teste de integração existente que verifica se o controller retorna o status HTTP 200 OK quando uma tarefa existente é excluída do banco de dados (DELETE /api/task/{taskId}). Nosso novo teste garantirá que o status 200 OK seja retornado quando John Doe exclui uma tarefa existente.

Vamos começar inicializando o banco de dados em um estado conhecido.

Passo 1: Inicializando o Banco de Dados com Múltiplos Datasets

Precisamos garantir que o conteúdo de users.xml seja inserido no banco de dados antes da execução dos métodos de teste, juntamente com tasks.xml. Fazemos isso passando ambos os datasets para a anotação @DatabaseSetup:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
package com.testwithspring.intermediate.web

import com.github.springtestdbunit.DbUnitTestExecutionListener
import com.github.springtestdbunit.annotation.DatabaseSetup
import com.github.springtestdbunit.annotation.DbUnitConfiguration
import com.testwithspring.intermediate.IntegrationTest
import com.testwithspring.intermediate.IntegrationTestContext
import com.testwithspring.intermediate.ReplacementDataSetLoader
import com.testwithspring.intermediate.Tasks
import com.testwithspring.intermediate.config.Profiles
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.TestExecutionListeners
import org.springframework.test.context.support.DependencyInjectionTestExecutionListener
import org.springframework.test.context.transaction.TransactionalTestExecutionListener
import org.springframework.test.context.web.ServletTestExecutionListener
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.ResultActions
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import org.springframework.web.context.WebApplicationContext

@SpringBootTest
@AutoConfigureMockMvc
@Import(IntegrationTestContext::class)
@TestExecutionListeners(
    listeners = [
        DependencyInjectionTestExecutionListener::class,
        TransactionalTestExecutionListener::class,
        DbUnitTestExecutionListener::class,
        ServletTestExecutionListener::class,
        WithSecurityContextTestExecutionListener::class
    ]
)
@DatabaseSetup(
    "/com/testwithspring/intermediate/users.xml",
    "/com/testwithspring/intermediate/tasks.xml"
)
@DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader::class)
@IntegrationTest
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class DeleteTaskAsUserWhenTaskIsFoundTest {

    @Autowired
    private lateinit var webAppContext: WebApplicationContext

    private lateinit var mockMvc: MockMvc

    @BeforeEach
    fun configureSystemUnderTest() {
        mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext)
            .apply<org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder>(springSecurity())
            .build()
    }

    @Test
    fun `deve retornar status HTTP 200 OK`() {
        deleteTask()
            .andExpect(status().isOk)
    }

    private fun deleteTask(): ResultActions {
        return mockMvc.perform(
            delete("/api/task/{taskId}", Tasks.WriteLesson.ID)
        )
    }
}

Se executarmos esse teste de integração agora, notaremos que ele falha porque o status HTTP 403 Forbidden é retornado! A razão para isso é que nossa requisição DELETE não contém um token CSRF (Cross-Site Request Forgery) válido.

Vamos descobrir como adicionar esse token à nossa requisição HTTP.


Passo 2: Adicionando um Token CSRF Válido à Requisição HTTP

Quando utilizamos a proteção CSRF habilitada por padrão no Spring Security para aplicações web baseadas em sessão/cookies, precisamos incluir um token CSRF válido sempre que enviarmos uma requisição mutável (DELETE, PATCH, POST ou PUT) ao método do controller testado.

Podemos injetar esse token na nossa requisição do MockMvc utilizando o RequestPostProcessor estático csrf() da classe SecurityMockMvcRequestPostProcessors:

1
2
3
4
5
6
    private fun deleteTask(): ResultActions {
        return mockMvc.perform(
            delete("/api/task/{taskId}", Tasks.WriteLesson.ID)
                .with(csrf())
        )
    }

Dica de Segurança: Você também pode testar se sua aplicação rejeita ataques CSRF enviando um token inválido com .with(csrf().useInvalidToken()) e verificando se a resposta retorna status().isForbidden (403)!

Quando executamos nosso teste de integração após adicionar .with(csrf()), notamos que ele ainda falha, mas agora retorna o código de status HTTP 401 Unauthorized (ou redireciona para a página de login)! Isso acontece porque ainda não configuramos um usuário autenticado, e o Spring Security bloqueia usuários anônimos.

Vamos ver como configurar o usuário autenticado.


Passo 3: Configurando o Usuário Autenticado

O suporte de testes do Spring Security fornece três anotações principais para configurar o usuário autenticado no SecurityContext antes da execução de um método de teste:

  • @WithAnonymousUser: Define um AnonymousAuthenticationToken no contexto de segurança. Raramente precisamos adicioná-la no método porque o comportamento padrão sem anotação já é anônimo, mas ela é muito útil quando anotamos a classe de teste com um usuário autenticado padrão e queremos sobrescrever apenas um método @Test específico para rodar como usuário anônimo.
  • @WithMockUser: Permite configurar rapidamente username, roles (ex: roles = ["USER"]) e password em memória no contexto de segurança, sem consultar o banco de dados nem o UserDetailsService.
  • @WithUserDetails: Permite configurar o username (no nosso caso, o e-mail Users.JohnDoe.EMAIL_ADDRESS) do usuário cujas informações completas serão carregadas invocando o bean real UserDetailsService da aplicação! Após carregar o objeto UserDetails a partir do banco de dados, o Spring Security o registra no SecurityContext antes do método de teste rodar.

Quando usar @WithUserDetails vs @WithMockUser?

Nos testes de integração (onde o banco de dados e o UserDetailsService reais estão ativos), prefira sempre usar @WithUserDetails porque ele exercita toda a cadeia real de autenticação e é indispensável em duas situações comuns:

  1. Quando salvamos informações de auditoria no banco de dados (ex: @CreatedBy / @LastModifiedBy do Spring Data Envers/Auditing baseados no ID do usuário logado) e queremos fazer asserções sobre essas colunas.
  2. Quando criamos uma classe customizada que implementa UserDetails (ex: LoggedInUser contendo o id numérico da tabela user_accounts) e o controller ou service faz cast do @AuthenticationPrincipal para essa classe customizada.

Portanto, para autenticar como John Doe, anotamos nosso método de teste com @WithUserDetails(Users.JohnDoe.EMAIL_ADDRESS).

Após configurarmos o usuário autenticado e o token CSRF, o código completo da nossa classe DeleteTaskAsUserWhenTaskIsFoundTest ficará assim:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
package com.testwithspring.intermediate.web

import com.github.springtestdbunit.DbUnitTestExecutionListener
import com.github.springtestdbunit.annotation.DatabaseSetup
import com.github.springtestdbunit.annotation.DbUnitConfiguration
import com.testwithspring.intermediate.IntegrationTest
import com.testwithspring.intermediate.IntegrationTestContext
import com.testwithspring.intermediate.ReplacementDataSetLoader
import com.testwithspring.intermediate.Tasks
import com.testwithspring.intermediate.Users
import com.testwithspring.intermediate.config.Profiles
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener
import org.springframework.security.test.context.support.WithUserDetails
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.TestExecutionListeners
import org.springframework.test.context.support.DependencyInjectionTestExecutionListener
import org.springframework.test.context.transaction.TransactionalTestExecutionListener
import org.springframework.test.context.web.ServletTestExecutionListener
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.ResultActions
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import org.springframework.web.context.WebApplicationContext

@SpringBootTest
@AutoConfigureMockMvc
@Import(IntegrationTestContext::class)
@TestExecutionListeners(
    listeners = [
        DependencyInjectionTestExecutionListener::class,
        TransactionalTestExecutionListener::class,
        DbUnitTestExecutionListener::class,
        ServletTestExecutionListener::class,
        WithSecurityContextTestExecutionListener::class
    ]
)
@DatabaseSetup(
    "/com/testwithspring/intermediate/users.xml",
    "/com/testwithspring/intermediate/tasks.xml"
)
@DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader::class)
@IntegrationTest
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class DeleteTaskAsUserWhenTaskIsFoundTest {

    @Autowired
    private lateinit var webAppContext: WebApplicationContext

    private lateinit var mockMvc: MockMvc

    @BeforeEach
    fun configureSystemUnderTest() {
        mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext)
            .apply<org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder>(springSecurity())
            .build()
    }

    @Test
    @WithUserDetails(Users.JohnDoe.EMAIL_ADDRESS)
    fun `deve retornar status HTTP 200 OK quando usuario autenticado excluir tarefa`() {
        deleteTask()
            .andExpect(status().isOk)
    }

    private fun deleteTask(): ResultActions {
        return mockMvc.perform(
            delete("/api/task/{taskId}", Tasks.WriteLesson.ID)
                .with(csrf())
        )
    }
}

Nota: Você pode adicionar qualquer uma dessas três anotações (@WithAnonymousUser, @WithMockUser, @WithUserDetails) tanto no nível da classe de teste quanto no nível do método de teste. Quando executamos nosso teste de integração agora, ele passa com sucesso!

Vamos resumir o que aprendemos nesta lição.


Resumo

Esta lição nos ensinou quatro pontos essenciais:

  • Devemos criar um dataset dedicado (users.xml) que insira os diferentes perfis de usuários da aplicação no banco de dados antes da execução dos testes de integração.
  • Podemos adicionar um token CSRF válido em uma requisição HTTP utilizando o pós-processador SecurityMockMvcRequestPostProcessors.csrf().
  • Em testes de integração com banco de dados real e UserDetailsService customizado, devemos configurar o usuário autenticado usando a anotação @WithUserDetails.
  • Podemos declarar @WithUserDetails (ou @WithMockUser) tanto no nível da classe de teste quanto em métodos de teste individuais.