Pull to refresh

Совершенствуем контроль сессий в Spring Security

Reading time 10 min
Views 31K
Добрый день, уважаемое Сообщество.

Разрабатывая многопользовательское web-приложение, столкнулся с проблемой многократного входа в систему (новый login при незавершенной старой сессии), решение которой потребовало необычного обходного маневра, чтобы сохранить логичную работу программы и ее понятный дизайн. В этой статье хочу поделиться c Вами опытом, осветив сперва традиционные подходы к управлению сессиями в Spring Security, и завершив обзор рацпредложением в виде 'костыля' собственной разработки.

Проблема контроля сессий актуальна для множества проектов. В моем случае это была игра (бэкенд на Java+Spring), где зарегистрировавшиеся пользователи могут выбирать с кем сразиться из списка присутствующих на сайте свободных игроков. После входа (login) игрока информация о нем добавляется в структуру данных в памяти. Часть этих данных асинхронно отображается в игровом интерфейсе, как список игроков, присутствующих на арене. Когда игрок выходит, то информация о нем должна быть сохранена в БД, удалена из структуры данных, и игрок более не будет отображаться в списке соперников online. Здесь возникали некоторые трудности из-за асинхронности, но не будем затрагивать их, ведь они лежат в стороне от темы статьи.

Остановимся подробнее на стратегии управления самыми различными ситуациями, связанными с login и logout. Прежде всего нужно было учесть то, что выход игрока с арены может произойти в результате таких его действий:

  • он может добросовестно разлогиниться (нажав кнопку logout);
  • может просто закрыть браузер, крышку ноутбука, нажать ресет и т.п., в общем уйти по-английски.


Уходим по-аглийски


Для таких 'английских' сценариев используется следующий подход.

1.  Добавляется SessionEventListener при регистрации DispatcherServlet в ходе стандартной инициализации и настройки Spring MVC приложения:

public class MyApplicationInitializer extends AbstractAnnotationConfigDispatcherServletInitializer  {
    // ... Прочие настройки 
    // Настройка слушателя сессии
    @Override
    protected void registerDispatcherServlet(ServletContext servletContext) {
        super.registerDispatcherServlet(servletContext);
        servletContext.addListener(new SessionEventListener());
    }
}

2. Реализуется слушатель событий сессии:

public class SessionEventListener extends HttpSessionEventPublisher {
    // ... Прочие методы
    @Override
    public void sessionCreated(HttpSessionEvent event) {
        super.sessionCreated(event);
         // ... Прочая логика 
         //Установка таймаута сессии 
        event.getSession().setMaxInactiveInterval(60*10);
    }

    @Override
    public void sessionDestroyed(HttpSessionEvent event) {
        String name=null;
        //----Находим login пользователя с помощью SessionRegistry
        SessionRegistry sessionRegistry = getAnyBean(event, "sessionRegistry");
        SessionInformation sessionInfo = (sessionRegistry != null ? sessionRegistry
                .getSessionInformation(event.getSession().getId()) : null);
        UserDetails ud = null;
        if (sessionInfo != null) ud = (UserDetails) sessionInfo.getPrincipal();
        if (ud != null) {
            name=ud.getUsername();
            //Удаляем запись об игроке и извещаем соперников, что мы ушли
            getAnyBean(event, "allGames").removeByName(name);
        }
        super.sessionDestroyed(event);
    }

    //По другому в слушатель сессии бины не заинжектишь
    public AllGames getAnyBean(HttpSessionEvent event, String name){
        HttpSession session = event.getSession();
        ApplicationContext ctx =
                WebApplicationContextUtils.
                        getWebApplicationContext(session.getServletContext());
        return (AllGames) ctx.getBean(name);
    }
}

3. Добавляется SessionRegistry в конфигурацию Spring Security:

@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    //...Прочие методы
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http    
                .formLogin()
                .loginPage("/login") 
                .failureHandler(new SecurityErrorHandler())
                 //...Прочие настройки опускаем 
                .and()
                .sessionManagement()
                .invalidSessionUrl("/home")
                .maximumSessions(1)
                .maxSessionsPreventsLogin(true)
                .sessionRegistry(sessionRegistry());
    }

    // Стандартная Spring имплементация SessionRegistry
    @Bean(name = "sessionRegistry")
    public SessionRegistry sessionRegistry() {
        return new SessionRegistryImpl();
    }
}

Теперь, благодаря тому, что мы устанавливаем таймаут 'event.getSession().setMaxInactiveInterval(60*10)' для каждой новой сессии (в SessionEventListener ), у нас любой сценарий выхода по-английски будет приводить к тому, что через короткое время (у нас в примере — 10 минут) сессия становится expired. Сразу же будет выброшено событие sessionDestroyed, оно будет обработано слушателем, который вызовет соотвествующий сервис для удаления игрока с арены, сохранения его persistent данных, очистки кэшей и т.п. То, что мы и хотели. Разместив всю эту логику в единственном методе, вызываемом из обработка sessionDestroyed, мы значительно упрощаем дизайн.

image

Логинищимуся — свободу выбора



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

  • сделать чистый login, когда у него нет открытых сессий;
  • может забыть/не захотеть завершить старую сессию нажатием кнопки logout (например, просто закрыв окно браузера, крышку ноутбука) и, пока таймаут в 10 минут не прошел, сессия остается открытой. А игрок нетерпеливо хочет войти с другого более удобного браузера, как вариант с мобильного телефона, планшета, другого компьютера.

Причем последний вариант поведения игрока может быть или намеренным (сменить устройство) или простой ошибкой (отвлекли).

Что предлагает в данном случае стандартный подход Spring Security. Установить при конфигурации следующие свойства:

@Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                //...Типичные настройки опускаем 
                .and()
                .maximumSessions(1)
                .maxSessionsPreventsLogin(false); //Убивает прошлую сессию без предупреждения

При такой конфигурации у игрока не может быть открыто более одной сессии одновременно '.maximumSessions(1)' и при попытке открыть вторую сессию первая будет немедленно убита '.maxSessionsPreventsLogin(false)' и, если окно браузера со старой сессией было открыто, то пользователь увидит в нем, как автоматически происходит переход со страницы[*], где крутилась игра, на заданную страницу благодаря конфигурации '.invalidSessionUrl("/home")'.

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

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

По этой причине предпочтение было отдано следующим настройкам:

@Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                //...Типичные настройки опускаем 
                .and()
                .maximumSessions(1)
                 // .maxSessionsPreventsLogin(false) //Не подходит
                .maxSessionsPreventsLogin(true);


Теперь в результате настройки '.maxSessionsPreventsLogin(true)' повторный логин игрока при незакрытой прошлой сессии приводит к определенней в Spring Security исключительной ситуации SessionAuthenticationException. Нам следует только обработать ее и перенаправить пользователя на html страницу с предупреждением, которая, кроме того, задает выбор: а) не продолжать и вернуться к прошлой открытой сессии (где возможно идет игра); б) все-таки залогиниться и тогда прошлая сессия должна быть убита.

Обработчик такой исключительной ситуации регистрируется при конфигурации Spring Security как '.failureHandler(new SecurityErrorHandler())', а сам класс обработчика реализуется следующим образом:

public class SecurityErrorHandler extends SimpleUrlAuthenticationFailureHandler {

     @Override
     public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) 
        throws IOException, ServletException {
           if (exception.getClass()
                  .isAssignableFrom(SessionAuthenticationException.class)) {
                             //Переход на warning-page, передаем login через URL 
                             //Упрощено для примера (так передавать login не следует)
                             request.getRequestDispatcher("/double_login_warning/"+
                                   request.getParameterValues("username")[0])
                                      .forward(request, response);   
           //...Оставшаяся часть обработчика
     }
}


image

Позволь, я отрублю сессии голову


Осталось выполнить соотвествующие действия, если пользователь выберет вариант — залогиниться повторно и убить прошлую сессию. В Spring Security есть такая возможность, она реализована в классе SessionInformation его методом expireNow(). Этот метод предлагается использовать, чтобы прекратить любую сессию любого пользователя. Чтобы найти SessionInformation для конкретного пользователя, используя его логин, был создан следующий сервис:

@Service("expireUsereService")
@Scope(value = "session", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class SessionServise {

    //Инжектим sessionRegistry
    private SessionRegistry sessionRegistry;
    @Autowired
    public void setSessionRegistry(SessionRegistry sessionRegistry) {
        this.sessionRegistry = sessionRegistry;
    }

   //Метод для удаления сессии любого пользователя
    public void expireUserSessions(String username) {
            for (Object principal : sessionRegistry.getAllPrincipals()) {
                if (principal instanceof User) {
                    UserDetails userDetails = (UserDetails) principal;
                    if (userDetails.getUsername().equals(username)) {
                        for (SessionInformation information : sessionRegistry
                            .getAllSessions(userDetails, true)) {
                            
                            //Заветное действие
                            information.expireNow();
                        }
                    }
                }
            }
    }

}

Хотя такой подход неоднократно описан в сообществе Spring Security, он имеет существенный недостаток. При его реализации не происходит интуитивно ожидаемого действия. Сессия конечно же объявляется устаревшей (expired), но не закрывается. Другими словами, сессия не будет уничтожена (destroyed), после того, как мы вручную вызвали для нее рекомендованный expireNow(). А значит:

  • на фронтенде в прошлом браузере (от сессии в котором мы намеренно отказалось и ожидаем, что она уже уничтожена со всеми последствиями) игрок видит продолжающуюся игру (если там javascript автономно прокручивает анимацию, то иллюзия вполне реалистична);
  • событие sessionDestroyed не произошло, данные пользователя не сохранены и игровая арена не обновилась. Это существенно нарушает логику работы многопользовательской системы.


Загнанные сессии пристреливают, не правда ли?


Почему так происходит. Вызов метода expireNow() у объекта SessionInformation просто напросто устанавливает значение его поля expired=true. Никаких других действий не выполняется и не должно выполняться. Только когда пользователь из своей устаревшей сессии отправит какой-либо новый HTTP запрос, то тогда эта expired сессия будет убита, а пользователь увидит, как в его браузере произошел редирект на страницу ввода login, обработает событие sessionDestroyed (ожидаемое поведение). Это связано с тем, что: а) уничтожением сессии занимается контейнер сервлетов и делает он это в данном случае после получения нового HTTP запроса; б) функционал Spring Security реализованный за счет цепочек фильтров (Java Servlet Filter) без получения запроса ничего не выполняет; в) добавленный нами к сервлету слушатель SessionEventListener обработает событие sessionDestroyed тоже вследствие нового HTTP запроса.

Рекомендованный многими, включая Spring документацией, метод для контроля сессий 'expireNow()', таким образом, работает вопреки наивным ожиданиям. В нашим случае это нарушало синхронность приложения. Важно, что повторный логин после 'expireNow()' уже возможен, так как контроль сессий Spring Security разрешает это после того, как прошлая сессия была объявлена expired=true (исключения SessionAuthenticationException уже не выбрасываются). Spring документация говорит об этом достаточно поверхностно. При этом прошлая сессия фактически не уничтожена, событие sessionDestroyed не обработано, соответственно, информация об игроке, который ожидает, что он вышел (чтобы возможно заново залогиниться), не сохранена. Игра (как и чат или другое интерактивное приложение) посылают сообщения в старую сессию и т.п. Если игрок теперь залогинится заново произойдет хаос в связи с конкурентным созданием новой сессии и отработкой sessionDestroyed, разбираться с которым можно тяжеловесными threadsafe инструментами. Но можно все сделать проще.

Чтобы исправить эту ситуацию и сделать логику повторного логина и закрытия старой сессии более предсказуемой был использован следующий подход. В наш SessionService (бин назван как 'expireUsereService') мы добавляем следующий метод:

public void killExpiredSessionForSure(String id) {
//Упрошен для примера
//id - это SessionID, которую можно получить через  
//вызов метода  getSessionId() объекта SessionInformation
        try {
                HttpHeaders requestHeaders = new HttpHeaders();
                requestHeaders.add("Cookie", "JSESSIONID=" + id);
                HttpEntity requestEntity = new HttpEntity(null, requestHeaders);
                RestTemplate rt = new RestTemplate();
                rt.exchange("http://localhost:8080", HttpMethod.GET, 
                      requestEntity, String.class);          
        } catch (Exception ex) {} //для простоты не допустим никаких исключений
}

Благодаря вызову этого метода мы симулируем http запрос от пользователя, сессия которого была нами же помечена как устаревшая. Лучше вызвать 'killExpiredSessionForSure(id)' сразу после 'expireNow()', тогда будет происходить желаемое поведение:

  • в открытом окне браузера с устаревшей сессией пользователь (пассивно наблюдая и не нажимая ничего) сразу же видит 'красивый'[*] принудительный переход на login/home-page;
  • срабатывает событие sessionDestroyed и вся наша логика по обновлению и сохранению арены игроков и их данных срабатывает. Никаких костылей более не нужно.

Вначале у меня и моих коллег были идеи хранить открытые сессии в дополнительной структуре данных, следить за открытыми сессиями из отдельного потока и т.п. Но по-моему, предложенный вариант с простым вызовом http запроса от имени устаревшей сессии (подставив нужный JSESSIONID) более изящен.


Итоги подведем


В общем, благодаря этому приложение стало работать интуитивно понятнее, и задумки по его дизайну реализовались. Идея, которая заключалась в том, чтобы разместить весь код, обновляющий данные про online пользователей и сохраняющий данные пользователей, любым способом вышедших из системы, в обработчике события sessionDestroyed, оказалась здравой. Для ее корректной реализации потребовалось только создать дополнительный механизм разрушения expired сессий, который и описан в заключении данной статьи.

Кроме того, данный подход, то есть использование комбинации вызова методов — общеизвестного 'expireNow()' и предложенного 'killExpiredSessionForSure(String id), можно использовать и в таких случаях:

  • если Вы администратор и хотите надежно прибить сессию какого-либо пользователя, залогиненного в системе. В результате пользователь мгновенно увидит 'выброс' из системы (переход[*] на home/login-page), и вся логика сохранения обновления его данных может быть реализована в обработчике sessionDestroyed;
  • для реализации востребованного сценария, когда сессия убивается через минимальное время после закрытия пользователем окна браузера. В этом случае необходимо будет создать в клиентской части приложения специальный heartbeat, передающий сигналы на бэкенд, и еще немало чего, но это может быть темой следующих публикаций.


Примечание
* — Переход происходит благодаря коду на фронтенде. В нашем случае текущие сообщения в ходе игры передаются с помощью WebSocket. WebSocket использует HTTP протокол (модифицированный) только для установления соединения, а затем обменивается сообщениями по своему WebSocket протоколу, работающему поверх TCP. Соответственно обмен этими сообщениями не фильтруется Servlet Filter вообще и цепочкой фильтров Spring Security в частности. Поэтому даже в просроченной (expired) сессии до нашего совершенствования шел обмен игровыми сообщениями. Передача таких сообщений не приводила к уничтожению expired сессии. Так возникала иллюзия продолжения игры там, где этого не должно было быть. Но если сессия окончательно уничтожена (с помощью вызова killExpiredSessionForSure(id)), то автоматически разрывается и WebSocket соединение. Фронтендовый код замечает это (при разрыве WebSocket соединения выполняется заданный callback) и переходит на home/login-page страницу. Это способ позволяет прервать WebSocket соединение бэкендом, так как реализация Stomp в Spring из коробки не имеет API для разрыва WebSocket сессии со стороны сервера.
Tags:
Hubs:
+15
Comments 4
Comments Comments 4

Articles