Тестирование Spring Security

ec5c370f3cecdfe94981cb9a2b0807ff

Безопасность играет важную роль в программном обеспечении. В конечном итоге каждому необходимо повысить безопасность своего проекта. В этой статье мы рассмотрим, как протестировать аутентификацию и авторизацию приложений Spring Boot. Мы рассмотрим как приложения-сервлеты MVC, так и реактивные приложения WebFlux.

Spring Security хорошо интегрируется с фреймворками Spring Web MVC и Spring WebFlux. Он также имеет комплексную интеграцию с Spring MVC Test и Spring WebTestClient.

Защитите приложение Spring MVC

Давайте начнем с простого приложения, которое управляет клиентами. Мы хотим создавать, получать и удалять клиентов.

@RestController
@RequiredArgsConstructor
public class CustomerController {
    private final CustomerRepository customerRepository;

    @GetMapping("/customer/{id}")
    Customer getCustomer(@PathVariable Long id) {
        return customerRepository.findById(id).orElseThrow();
    }

    @PostMapping("/customer")
    @ResponseStatus(HttpStatus.CREATED)
    Customer createCustomer(@RequestBody Customer customer) {
        return customerRepository.save(customer);
    }

    @DeleteMapping("/customer/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    void deleteCustomer(@PathVariable Long id) {
        customerRepository.deleteById(id);
    }
}

Мы, вероятно, не хотим, чтобы неавторизованные люди создавали и удаляли клиентов. Таким образом, мы собираемся использовать простую конфигурацию безопасности для добавления аутентификации.

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfiguration {
}

Теперь, чтобы обезопасить конечные точки, мы можем использовать @PreAuthorized аннотацию для включения безопасности метода. Мы собираемся сделать это для POST и DELETE операций.

 @PostMapping("/customer")
    @ResponseStatus(HttpStatus.CREATED)
    @PreAuthorize("hasRole('ADMIN')")
    Customer createCustomer(@RequestBody Customer customer) {
        return customerRepository.save(customer);
    }

    @DeleteMapping("/customer/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    @PreAuthorize("hasRole('ADMIN')")
    void deleteCustomer(@PathVariable Long id) {
        customerRepository.deleteById(id);
    }

С точки зрения тестирования не имеет большого значения, как была настроена конфигурация безопасности. Для простоты мы приведем пример конфигурации вкратце.

Настройте @WebMvcTest С помощью Security

Чтобы протестировать наши контроллеры изолированно, мы можем использовать тест Spring Boot Test @WebMvcTest тестовый фрагмент.

Использование @WebMvcTest загружает компоненты, необходимые для контроллера, но не знает, какие другие компоненты конфигурации загружать. Мы должны сообщить Spring загрузить конфигурацию безопасности с помощью @Import(SecurityConfiguration.class) аннотации.

@WebMvcTest(CustomerController.class)
@Import(SecurityConfiguration.class)
class CustomerControllerTests {
    // ...
}

Если запуск приложения запрещает доступ к конечной точке, но наши тесты не делают того же самого, вероятно, мы забыли импортировать конфигурацию безопасности.

Это может быть трудно обнаружить, если у нас нет тестов, которые проверяют код состояния «запрещено» или «неавторизовано».

Запустите тест от имени пользователя

Чтобы протестировать Spring Security, давайте начнем с конечных точек, для которых не требуются права администратора.

Чтобы запустить тест от имени пользователя, мы можем использовать @WithMockUser аннотацию для обеспечения поддельной аутентификации пользователя.

@Test
    @WithMockUser
    void getCustomer() throws Exception {
        when(customerRepository.findById(1L))
                .thenReturn(Optional.of(new Customer(1L, "John", "Doe")));

        mockMvc.perform(get("/customer/{id}", 1L))
                .andExpect(status().isOk());
    }

Это приведет к аутентификации пользователя, и тест пройдет успешно.

Чтобы убедиться, что наша конфигурация безопасности работает, мы также можем добавить тест для проверки того, что ответ является 401 несанкционированным, если мы не прошли аутентификацию.

Мы можем использовать @WithAnonymousUser аннотацию, которая необязательна, но подчеркивает тот факт, что это пользователь, не прошедший проверку подлинности.

@Test
    @WithAnonymousUser
    void cannotGetCustomerIfNotAuthorized() throws Exception {
        mockMvc.perform(get("/customer/{id}", 1L))
                .andExpect(status().isUnauthorized());
    }

В дополнение к проверке подлинности, мы также можем проверить, что ответ 403 Запрещен, если пользователь не авторизован для доступа к ресурсу.

  @Test
    @WithMockUser
    void cannotCreateCustomerIfNotAnAdmin() throws Exception {
        mockMvc.perform(post("/customer")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"firstName\": \"John\", \"lastName\": \"Doe\"}")
                        .with(csrf())
                )
                .andExpect(status().isForbidden());
    }

Мы можем дополнительно настроить пользователя и добавить роли с помощью @WithMockUser аннотации. Например, мы могли бы сделать пользователя администратором с помощью @WithMockUser(roles = "ADMIN").

Включить токен CSRF

Spring Security по умолчанию включает защиту CSRF, что означает, что нам нужно добавить действительный токен CSRF к небезопасным HTTP-методам. Эти методы включают POST,  PUT и DELETE, или все методы, которые не доступны только для чтения.

Мы можем предоставить токен CSRF с постпроцессором запроса.

    @Test
    @WithAnonymousUser
    void cannotGetCustomerIfNotAuthorized() throws Exception {
        mockMvc.perform(get("/customer/{id}", 1L))
                .andExpect(status().isUnauthorized());
    }

Можно добавить недопустимый токен CSRF с помощью csrf().useInvalidToken(), но это бесполезно, если вы не выполняете более сложную конфигурацию.

Запуск от имени пользователя Без аннотаций

@WithMockUser Аннотации удобны, но если нам не нравятся аннотации, мы можем использовать вместо них другие постпроцессоры запросов.

@Test
    void adminCanDeleteCustomer() throws Exception {
        mockMvc.perform(delete("/customer/{id}", 1L)
                        .with(csrf())
                        .with(user("admin").roles("ADMIN"))
                )
                .andExpect(status().isNoContent());
    }

Для проверки статуса несанкционированного доступа мы можем добавить anonymous() постпроцессор. Его использование необязательно, но это еще раз подчеркивает тот факт, что это пользователь, не прошедший проверку подлинности.

  @Test
    void cannotDeleteCustomerIfNotAuthorized() throws Exception {
        mockMvc.perform(delete("/customer/{id}", 1L)
                        .with(csrf())
                        .with(anonymous())
                )
                .andExpect(status().isUnauthorized());
    }

Существует множество других почтовых процессоров, которые можно найти по адресу SecurityMockMvcRequestPostProcessors. Например, мы можем протестировать базовую аутентификацию HTTP, вход в OAuth 2.0 или аутентификацию JWT.

Настройте MockMvc в @SpringBootTest с помощью Security

Если мы хотим протестировать большую часть приложения с помощью @SpringBootTest, мы должны настроить MockMvc для тестов.

@SpringBootTest
class CustomerMockEnvTests {
    @Autowired
    private WebApplicationContext context;

    private MockMvc mockMvc;

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

Важной частью здесь является добавление MockMvcConfigurer.springSecurity() в конфигурацию.

Мы могли бы использовать @AutoconfigureMockMvc аннотацию здесь вместо этого, но полезно знать, как инициализировать MockMvc вручную. Проблема с @AutoconfigureMockMvc аннотацией заключается в том, что она может нарушить кэширование тестирования контекста приложения Spring Boot.

После MockMvc настройки его использование ничем не отличается от тестирования с помощью @WebMvcTest. Мы также можем использовать @WithMockUser аннотации.

Настройте MockMvc WebTestClient в @SpringBootTest с помощью Security

Что, если мы захотим написать сквозные тесты для приложения?  Поскольку тест запускает приложение в другом процессе, одним из вариантов является использование WebTestClient для отправки запросов к приложению.

Однако есть одна загвоздка: мы не можем автоматически подключать WebTestClient компонент непосредственно в тесте. Нам приходится настраивать клиент вручную и использовать обходной путь.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class CustomerServerEnvTests {
    @Autowired
    private WebApplicationContext context;

    private WebTestClient webClient;

    @BeforeEach
    void setup() {
        webClient = MockMvcWebTestClient.bindToApplicationContext(context)
                .apply(springSecurity())
                .defaultRequest(get("/").with(csrf()))
                .configureClient()
                .build();
    }

    @Test
    void createCustomer() {
        webClient.mutateWith(csrf()).post().uri("/customer")
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue("{\"firstName\": \"John\", \"lastName\": \"Doe\"}")
                .exchange()
                .expectStatus().isCreated();
    }
}

Это потому, что они изначально были разработаны WebTestClient для тестирования приложений reactive Spring и пока не поддерживают это.

Если мы попытаемся выполнить автоматическую проводку WebTestClient и попытаемся вызвать mutateWith(csrf()), тесты завершатся ошибкой с загадочным сообщением.

Cannot invoke "org.springframework.web.server.adapter.WebHttpHandlerBuilder.filters(java.util.function.Consumer)" because "httpHandlerBuilder" is null

Поскольку это сквозной тест, вместо того, чтобы имитировать аутентификацию, мы могли бы вместо этого предоставить заголовки аутентификации.

 @Test
    void createCustomer() {
        webClient.post().uri("/customer")
                .headers(http -> http.setBasicAuth("username", "password"))
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue("{\"firstName\": \"John\", \"lastName\": \"Doe\"}")
                .exchange()
                .expectStatus().isCreated();
    }

Мы можем даже предоставить токен на предъявителя с тем же механизмом. Однако создание токенов является громоздким процессом, и лучше сосредоточиться на тестировании авторизации, а не на представлении токенов на предъявителя.

Защитите приложение Spring WebFlux

Давайте начнем с предыдущего примера приложения и перенесем его в реактивное приложение.

@RestController
@RequiredArgsConstructor
public class CustomerController {
    private final CustomerRepository customerRepository;

    @GetMapping("/customer/{id}")
    Mono getCustomer(@PathVariable Long id) {
        return customerRepository.findById(id);
    }

    @PostMapping("/customer")
    @ResponseStatus(HttpStatus.CREATED)
    @PreAuthorize("hasRole('ADMIN')")
    Mono createCustomer(@RequestBody Customer customer) {
        return customerRepository.save(customer);
    }

    @DeleteMapping("/customer/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    @PreAuthorize("hasRole('ADMIN')")
    Mono deleteCustomer(@PathVariable Long id) {
        return customerRepository.deleteById(id);
    }
}

Нам потребуется настроить безопасность. Аннотации несколько отличаются от конфигурации безопасности MVC.

@Configuration
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
public class SecurityConfiguration {
}

Как только конфигурация безопасности установлена, конечные точки теперь защищены.

Настройте @WebFluxTest с помощью Security

Чтобы протестировать наши реактивные контроллеры изолированно, мы можем использовать тест Spring Boot Test @WebFluxText тестовый фрагмент. Мы также можем использовать WebTestClient непосредственно в этих тестах, поскольку он был разработан для работы с реактивными приложениями.

@WebFluxTest(CustomerController.class)
@Import(SecurityConfiguration.class)
class CustomerControllerTests {
    @MockBean
    private CustomerRepository customerRepository;

    @Autowired
    private WebTestClient webClient;

    @Test
    @WithMockUser
    void getCustomer() {
        when(customerRepository.findById(1L))
                .thenReturn(Mono.just(new Customer(1L, "John", "Doe")));

        webClient.get().uri("/customer/{id}", 1)
                .exchange()
                .expectStatus().isOk();
    }

Для любых конечных точек, которым требуется токен CSRF, нам нужно добавить его. Мы делаем это, изменяя WebTestClient с помощью мутаторов, поступающих от SecurityMockServerConfigurers.

    @Test
    @WithMockUser(roles = "ADMIN")
    void adminCanCreateCustomers() {
        when(customerRepository.save(any()))
                .thenReturn(Mono.just(new Customer(1L, "John", "Doe")));

        webClient.mutateWith(csrf())
                .post().uri("/customer")
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue("{\"firstName\": \"John\", \"lastName\": \"Doe\"}")
                .exchange()
                .expectStatus().isCreated();
    }

Опять же, если мы не хотим использовать @WithMockUser аннотацию, мы можем дополнительно настроить клиент с помощью мутаторов, которые обеспечивают аутентификацию.

    @Test
    void adminCanDeleteCustomer() {
        when(customerRepository.deleteById(1L)).thenReturn(Mono.empty());

        webClient.mutateWith(csrf())
                .mutateWith(mockUser().roles("ADMIN"))
                .delete().uri("/customer/{id}", 1)
                .exchange()
                .expectStatus().isNoContent();
    }

В дополнение к предоставлению макета пользователя мы можем использовать, например,  mockJwt() или mockOAuth2Login().

Настройте WebFlux WebTestClient в @SpringBootTest с помощью Security

Переходим к сквозным тестам, и@SpringBootTest,  WebTestClient компонент недоступен по умолчанию. Клиент необходимо настроить вручную.

@SpringBootTest
public class CustomerControllerEndToEndTests {
    @Autowired
    private ApplicationContext context;

    private WebTestClient webClient;

    @BeforeEach
    void setup() {
        webClient = WebTestClient.bindToApplicationContext(context)
                .apply(springSecurity())
                .configureClient()
                .build();
    }
}

Мы могли бы снова сделать то же самое, добавив @AutoconfigureWebTestClient аннотацию, но она страдает от тех же проблем с контекстным кэшированием, что упоминались ранее.

Здесь не имеет значения, запускаем ли мы тест в макетной среде или в серверной среде. WebTestClient настраивается одинаково в обоих реактивных приложениях.

После WebTestClient настройки его использование ничем не отличается от тестирования с помощью @WebFluxTest. Мы можем либо использовать @WithMockUser, либо видоизменить клиент с помощью mock security от SecurityMockServerConfigurers.

Включить журналы отладки Для устранения неполадок

Иногда, когда наши тесты безопасности завершаются неудачей, бывает сложно выяснить, что не так. Для устранения проблем с Spring Security мы можем включить ведение журнала отладки безопасности, чтобы увидеть, что происходит.

logging:
  level:
    org:
      springframework:
        security: DEBUG

Например, если мы забудем токен CSRF, это не будет очевидно, пока мы не используем ведение журнала отладки. После включения ведения журнала безопасности отладки мы можем выяснить причину сбоя.

o.s.security.web.csrf.CsrfFilter : Invalid CSRF token found for http://localhost/customer

Краткие сведения

Spring Security хорошо интегрируется с фреймворками Spring Web MVC и Spring WebFlux. Он также имеет всестороннюю интеграцию с MockMvc и WebTestClient.

Мы можем подделать аутентификацию, используя аннотацию или методический подход. Также возможно предоставить разные роли для тестирования авторизации.

Охватить различные случаи несложно. Желаемый уровень проверки зависит от того, насколько сложна наша конфигурация безопасности. Поскольку это довольно важная часть приложения, полезно протестировать ее полностью.

© Habrahabr.ru