«1. Обзор

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

Именно для этой цели Spring предоставляет интерфейс LogoutHandler, и в этом руководстве мы рассмотрим, как реализовать собственный обработчик выхода из системы.

2. Обработка запросов на выход из системы

Каждое веб-приложение, которое осуществляет вход пользователей в систему, должно когда-нибудь выйти из системы. Обработчики Spring Security обычно контролируют процесс выхода из системы. По сути, у нас есть два способа обработки выхода из системы. Как мы увидим, один из них реализует интерфейс LogoutHandler.

2.1. Интерфейс LogoutHandler

Интерфейс LogoutHandler имеет следующее определение:

public interface LogoutHandler {
    void logout(HttpServletRequest request, HttpServletResponse response,Authentication authentication);
}

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

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

2.2. Интерфейс LogoutSuccessHandler

С другой стороны, мы можем использовать исключения для управления стратегией выхода пользователя из системы. Для этого у нас есть интерфейс LogoutSuccessHandler и метод onLogoutSuccess. Этот метод может вызвать исключение, чтобы установить перенаправление пользователя в соответствующий пункт назначения.

Более того, невозможно добавить несколько обработчиков при использовании типа LogoutSuccessHandler, поэтому существует только одна возможная реализация приложения. Вообще говоря, получается, что это последний пункт стратегии выхода из системы.

3. Интерфейс LogoutHandler на практике

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

Начнем с файла application.properties, который содержит свойства подключения к базе данных для нашего примера приложения:

spring.datasource.url=jdbc:postgresql://localhost:5432/test
spring.datasource.username=test
spring.datasource.password=test
spring.jpa.hibernate.ddl-auto=create

3.1. Настройка веб-приложения

Далее мы добавим простую сущность пользователя, которую будем использовать для входа в систему и извлечения данных. Как мы видим, класс User сопоставляется с таблицей пользователей в нашей базе данных:

@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    @Column(unique = true)
    private String login;

    private String password;

    private String role;

    private String language;

    // standard setters and getters
}

Для целей кэширования нашего приложения мы реализуем службу кэширования, которая внутренне использует ConcurrentHashMap для хранения пользователей:

@Service
public class UserCache {
    @PersistenceContext
    private EntityManager entityManager;

    private final ConcurrentMap<String, User> store = new ConcurrentHashMap<>(256);
}

Используя этот сервис, мы можем получить пользователя по имени пользователя (логин) из базы данных и сохранить его внутри нашей карты:

public User getByUserName(String userName) {
    return store.computeIfAbsent(userName, k -> 
      entityManager.createQuery("from User where login=:login", User.class)
        .setParameter("login", k)
        .getSingleResult());
}

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

public void evictUser(String userName) {
    store.remove(userName);
}

Для получения пользовательских данных и информации о языке мы будем использовать стандартный контроллер Spring:

@Controller
@RequestMapping(path = "/user")
public class UserController {

    private final UserCache userCache;

    public UserController(UserCache userCache) {
        this.userCache = userCache;
    }

    @GetMapping(path = "/language")
    @ResponseBody
    public String getLanguage() {
        String userName = UserUtils.getAuthenticatedUserName();
        User user = userCache.getByUserName(userName);
        return user.getLanguage();
    }
}

~~ ~ 3.2. Конфигурация веб-безопасности

В приложении мы сосредоточимся на двух простых действиях — входе в систему и выходе из нее. Во-первых, нам нужно настроить наш класс конфигурации MVC, чтобы пользователи могли аутентифицироваться с использованием базовой HTTP-аутентификации:

@Configuration
@EnableWebSecurity
public class MvcConfiguration extends WebSecurityConfigurerAdapter {

    @Autowired
    private CustomLogoutHandler logoutHandler;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.httpBasic()
            .and()
                .authorizeRequests()
                    .antMatchers(HttpMethod.GET, "/user/**")
                    .hasRole("USER")
            .and()
                .logout()
                    .logoutUrl("/user/logout")
                    .addLogoutHandler(logoutHandler)
                    .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler(HttpStatus.OK))
                    .permitAll()
            .and()
                .csrf()
                    .disable()
                .formLogin()
                    .disable();
    }

    // further configuration
}

Важной частью приведенной выше конфигурации является метод addLogoutHandler. Мы передаем и запускаем наш CustomLogoutHandler в конце обработки выхода. Остальные параметры тонко настраивают HTTP Basic Auth.

3.3. Пользовательский обработчик выхода из системы

Наконец, и самое главное, мы напишем наш собственный обработчик выхода из системы, который выполняет необходимую очистку кэша пользователя:

@Service
public class CustomLogoutHandler implements LogoutHandler {

    private final UserCache userCache;

    public CustomLogoutHandler(UserCache userCache) {
        this.userCache = userCache;
    }

    @Override
    public void logout(HttpServletRequest request, HttpServletResponse response, 
      Authentication authentication) {
        String userName = UserUtils.getAuthenticatedUserName();
        userCache.evictUser(userName);
    }
}

Как мы видим, мы переопределяем метод выхода из системы и просто удаляем данного пользователя. из пользовательского кеша.

4. Интеграционное тестирование

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

@Test
public void whenLogin_thenUseUserCache() {
    assertThat(userCache.size()).isEqualTo(0);

    ResponseEntity<String> response = restTemplate.withBasicAuth("user", "pass")
        .getForEntity(getLanguageUrl(), String.class);

    assertThat(response.getBody()).contains("english");

    assertThat(userCache.size()).isEqualTo(1);

    HttpHeaders requestHeaders = new HttpHeaders();
    requestHeaders.add("Cookie", response.getHeaders()
        .getFirst(HttpHeaders.SET_COOKIE));

    response = restTemplate.exchange(getLanguageUrl(), HttpMethod.GET, 
      new HttpEntity<String>(requestHeaders), String.class);
    assertThat(response.getBody()).contains("english");

    response = restTemplate.exchange(getLogoutUrl(), HttpMethod.GET, 
      new HttpEntity<String>(requestHeaders), String.class);
    assertThat(response.getStatusCode()
        .value()).isEqualTo(200);
}

Давайте разберем шаги, чтобы понять, что мы сделали: :

    «Во-первых, мы проверяем, что кеш пуст. Затем мы аутентифицируем пользователя с помощью метода withBasicAuth. Теперь мы можем проверить полученные данные пользователя и значение языка. Следовательно, мы можем убедиться, что пользователь теперь должен быть в кеше. Снова мы проверяем пользователя. данные, нажав конечную точку языка и используя файл cookie сеанса. Наконец, мы проверяем выход пользователя из системы

Во втором тесте мы проверим, очищается ли кеш пользователя при выходе из системы. Это момент, когда будет вызван наш обработчик выхода из системы:

@Test
public void whenLogout_thenCacheIsEmpty() {
    assertThat(userCache.size()).isEqualTo(0);

    ResponseEntity<String> response = restTemplate.withBasicAuth("user", "pass")
        .getForEntity(getLanguageUrl(), String.class);

    assertThat(response.getBody()).contains("english");

    assertThat(userCache.size()).isEqualTo(1);

    HttpHeaders requestHeaders = new HttpHeaders();
    requestHeaders.add("Cookie", response.getHeaders()
        .getFirst(HttpHeaders.SET_COOKIE));

    response = restTemplate.exchange(getLogoutUrl(), HttpMethod.GET, 
      new HttpEntity<String>(requestHeaders), String.class);
    assertThat(response.getStatusCode()
        .value()).isEqualTo(200);

    assertThat(userCache.size()).isEqualTo(0);

    response = restTemplate.exchange(getLanguageUrl(), HttpMethod.GET, 
      new HttpEntity<String>(requestHeaders), String.class);
    assertThat(response.getStatusCode()
        .value()).isEqualTo(401);
}

Снова шаг за шагом:

    Как и раньше, мы начинаем с проверки того, что кэш пуст. Затем мы аутентифицируем пользователя и проверяем, что пользователь находится в системе. кеш Затем мы выполняем выход и проверяем, что пользователь был удален из кеша Наконец, попытка попасть в конечную точку языка приводит к 401 HTTP-коду неавторизованного ответа

5. Заключение

В этом руководстве мы узнали как реализовать собственный обработчик выхода из системы для исключения пользователей из пользовательского кеша с помощью интерфейса Spring LogoutHandler.

Как всегда, полный исходный код статьи доступен на GitHub.