Improving Session Control in Spring Security

Good afternoon, dear Community.

While developing a multi-user web application, I encountered the problem of multiple logins (a new login with an incomplete old session), the solution of which required an unusual workaround to preserve the logical operation of the program and its clear design. In this article I want to share my experience with you, first covering the traditional approaches to session management in Spring Security, and completing the review with a rational offer in the form of a 'crutch' of our own design.

The problem of session control is relevant for many projects. In my case, it was a game (Java + Spring backend), where registered users can choose who to fight from the list of free players present on the site. After the player's login, information about him is added to the data structure in memory. Part of this data is displayed asynchronously in the game interface, as a list of players present in the arena. When a player exits, information about him must be stored in the database, removed from the data structure, and the player will no longer be displayed in the list of opponents online. Some difficulties arose here due to asynchrony, but we will not touch on them, because they lie away from the topic of the article.

Let us dwell in more detail on the management strategy for a wide variety of situations related to login and logout. First of all, it was necessary to take into account the fact that a player can leave the arena as a result of his actions:

  • he can log out in good faith (by clicking the logout button);
  • can just close the browser, laptop cover, press reset, etc., in general, leave in English.


We leave in English


For such 'English' scenarios, the following approach is used.

1. A SessionEventListener is added when registering a DispatcherServlet during the standard initialization and configuration of the Spring MVC application:

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

2. A listener of session events is implemented:

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 is added to the Spring Security configuration:

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

Now, thanks to the fact that we set the timeout of 'event.getSession (). SetMaxInactiveInterval (60 * 10)' for each new session (in SessionEventListener), any exit script in English will cause us to have a short time ( in our example - 10 minutes) the session becomes expired. The sessionDestroyed event will be immediately thrown, it will be processed by the listener, who will call the appropriate service to remove the player from the arena, save his persistent data, clear caches, etc. What we wanted. By placing all this logic in a single method called from sessionDestroyed processing, we greatly simplify the design.

image

Login - freedom of choice



So far, Spring Security has demonstrated the necessary flexibility. But here a desire arose to take into account various options for user behavior during authorization in the same way. So, a player can:

  • make a clean login when it has no open sessions;
  • may forget / do not want to end the old session by pressing the logout button (for example, just closing the browser window, laptop cover) and, until the timeout of 10 minutes has passed, the session remains open. And the player eagerly wants to enter from another more convenient browser, as an option from a mobile phone, tablet, other computer.

Moreover, the last variant of the player’s behavior can be either intentional (change the device) or a simple mistake (distracted).

What does the standard Spring Security approach offer in this case. Set the following properties during configuration:

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

With this configuration, the player cannot have more than one session open at the same time .maximumSessions (1) and if you try to open the second session, the first one will be killed immediately .maxSessionsPreventsLogin (false) and if the browser window with the old session was opened, then the user he will see in it how the transition automatically occurs from the page [ * ] where the game was spinning to the given page thanks to the configuration '.invalidSessionUrl ("/ home")'.

It just did not tire out. Since this behavior, Spring Security was like a preventive nuclear bombardment. The player may mistakenly log in again, and his last game stops without warning. It was necessary to refine this scenario,

  • stop, change your mind and do not log in again, but return to an already open game;
  • log in again, killing the last session (and this should happen correctly, with saving data, etc., even if the player just closed the browser window with the last, but still active session).

For this reason, preference was given to the following settings:

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


Now, as a result of setting '.maxSessionsPreventsLogin (true)', re-login of a player with an unclosed last session leads to a more specific SessionAuthenticationException in Spring Security. We should only process it and redirect the user to the html warning page, which, in addition, sets the choice: a) do not continue and return to the last open session (where the game is possibly going on); b) still log in and then the last session should be killed.

The handler of such an exception is registered during Spring Security configuration as '.failureHandler (new SecurityErrorHandler ())', and the handler class itself is implemented as follows:

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

Let me chop off the session head


It remains to perform the appropriate actions if the user selects the option - log in again and kill the last session. Spring Security has this feature, it is implemented in the SessionInformation class by its expireNow () method. This method is proposed to be used to terminate any session of any user. To find the SessionInformation for a specific user using his username, the following service was created:

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

Although this approach has been repeatedly described in the Spring Security community, it has a significant drawback. When it is implemented, the intuitively expected action does not occur. The session is of course declared expired, but does not close. In other words, the session will not be destroyed after we manually called the recommended expireNow () for it. Which means:

  • on the frontend in the previous browser (the session in which we intentionally refused and expect that it has already been destroyed with all the consequences), the player sees the ongoing game (if there javascript independently scrolls the animation, then the illusion is quite realistic);
  • The sessionDestroyed event did not occur, user data was not saved, and the game arena was not updated. This significantly violates the logic of the multi-user system.


Chased sessions shoot, isn't it?


Why it happens. Calling the expireNow () method on the SessionInformation object simply sets the value of its field expired = true. No other actions are and should not be performed. Only when the user sends any new HTTP request from his outdated session, then this expired session will be killed, and the user will see how the redirect to the login input page occurred in his browser and will handle the sessionDestroyed event (expected behavior). This is due to the fact that: a) the servlet container is engaged in the destruction of the session and it does this in this case after receiving a new HTTP request; b) the Spring Security functionality implemented through filter chains (Java Servlet Filter) does nothing without receiving a request;

Recommended by many, including Spring's documentation , the session control method 'expireNow ()' thus works contrary to naive expectations. In our case, this violated the synchronization of the application. It is important that a re-login after 'expireNow ()' is already possible, since Spring Security session control allows this after the last session has been declared expired = true (SessionAuthenticationException is no longer thrown). Spring documentation talks about this quite superficially. At the same time, the previous session was not actually destroyed, the sessionDestroyed event was not processed, accordingly, information about the player who expects him to log out (in order to possibly log in again) is not saved. A game (like a chat or other interactive application) sends messages to an old session, etc. If the player now logs in again, chaos will occur in connection with the competitive creation of a new session and the development of sessionDestroyed, which can be dealt with with heavyweight threadsafe tools. But you can make everything simpler.

To correct this situation and make the logic of re-login and closing the old session more predictable, the following approach was used. In our SessionService (the bean is named as 'expireUsereService') we add the following method:

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) {} //для простоты не допустим никаких исключений
}

By calling this method, we simulate an http request from a user whose session we have flagged as outdated. It is better to call 'killExpiredSessionForSure (id)' immediately after 'expireNow ()', then the desired behavior will occur:

  • in an open browser window with an outdated session, the user (passively observing and not pressing anything) immediately sees a “beautiful” [ * ] forced transition to login / home-page;
  • the sessionDestroyed event is triggered, and all our logic to update and save the arena of players and their data is triggered. No crutches are needed anymore.

At the beginning, my colleagues and I had ideas to store open sessions in an additional data structure, to monitor open sessions from a separate stream, etc. But in my opinion, the proposed option with a simple http request call on behalf of an outdated session (substituting the desired JSESSIONID) is more elegant.


Summarize


In general, thanks to this, the application began to work more intuitively, and the ideas for its design were realized. The idea, which was to place all the code that updates data about online users and stores the data of users who logged out in any way, in the sessionDestroyed event handler, turned out to be a robust one. For its correct implementation, it was only necessary to create an additional mechanism for the destruction of expired sessions, which is described in the conclusion of this article.

In addition, this approach, that is, using a combination of method calls - the well-known 'expireNow ()' and the proposed 'killExpiredSessionForSure (String id), can be used in such cases:

  • if you are an administrator and want to securely beat the session of any user logged in to the system. As a result, the user will instantly see a 'surge' from the system (switching [ * ] to the home / login-page), and all the logic for saving updates to his data can be implemented in the sessionDestroyed handler;
  • to implement a popular scenario when a session is killed after the minimum time after the user closes the browser window. In this case, it will be necessary to create a special heartbeat in the client part of the application that transmits signals to the backend, and much more, but this may be the topic of the following publications.


Note
* - The transition is due to the code on the front end. In our case, the current messages during the game are transmitted using WebSocket. WebSocket uses the HTTP protocol (modified) only to establish a connection, and then exchanges messages using its WebSocket protocol, which runs on top of TCP. Accordingly, the exchange of these messages is not filtered by Servlet Filter in general and the Spring Security filter chain in particular. Therefore, even in the expired session, before our improvement, there was an exchange of game messages. The transmission of such messages did not result in the destruction of the expired session. So there was an illusion of continuing the game where it should not have been. But if the session is permanently destroyed (by calling killExpiredSessionForSure (id)), the WebSocket connection will be automatically disconnected. The front-end code notices this (when the WebSocket connection is broken, the specified callback is executed) and goes to the home / login-page page. This method allows you to interrupt the WebSocket connection with the backend, since the Stomp implementation in Spring out of the box does not have an API for breaking the WebSocket session from the server side.

Also popular now: