⬆️ ⬇️

Improving session control in Spring Security

Good afternoon, dear Community.



While developing a multi-user web application, I ran into the problem of multiple logins (new login during an unfinished old session), the solution of which required an unusual workaround in order to preserve the logical work of the program and its clear design. In this article I want to share with you your experience, highlighting first the traditional approaches to session management in Spring Security, and completing the review with a rational proposal in the form of a “crutch” of our own design.



The problem of controlling sessions 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 on the site. After the player is logged in (login), information about him is added to the data structure in memory. Some of this data is asynchronously displayed in the game interface, as a list of players present in the arena. When a player quits, information about him should be saved in the database, removed from the data structure, and the player will no longer be displayed in the list of opponents online. There were some difficulties due to asynchrony, but we will not touch them, because they lie away from the topic of the article.

')

Let us dwell on the management strategy for a variety of situations related to login and logout. First of all, it was necessary to take into account the fact that a player’s exit from the arena may occur as a result of his actions:







Leaving in English



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



1. A SessionEventListener is added when registering a DispatcherServlet during 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 session event listener 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. Add SessionRegistry 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, due to the fact that we set the timeout 'event.getSession (). SetMaxInactiveInterval (60 * 10)' for each new session (in SessionEventListener), we have any exit script in English will lead to the fact that after a short time ( in our example - 10 minutes) the session becomes expired. The sessionDestroyed event will immediately be thrown out, 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. Placing all this logic in a single method called from sessionDestroyed processing, we greatly simplify the design.



image



Log in - freedom of choice





Until now, Spring Security has demonstrated the necessary flexibility. But here there was a desire to also take into account the various options for user behavior during authorization. So, a player can:





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



What does the standard approach of Spring Security offer in this case? When configuring, set the following properties:



 @Override protected void configure(HttpSecurity http) throws Exception { http //...   .and() .maximumSessions(1) .maxSessionsPreventsLogin(false); //     


With this configuration, the player cannot open more than one session at the same time '.maximumSessions (1)' and when trying to open a second session, the first will be immediately killed by '.maxSessionsPreventsLogin (false)' and, if the browser window with the old session was opened, the user will see in it how the transition automatically takes place from the [ * ] page where the game was spinning, to the specified page due to the configuration of '.invalidSessionUrl ("/ home")'.



It just did not tire. Since this behavior of Spring Security was similar to a preventive nuclear bombardment. The player may re-log in by mistake, and his last game without warning stops. It was necessary to modify this scenario so that the player would be shown a warning window with a choice:





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 up '.maxSessionsPreventsLogin (true)', re-login a player with an unclosed last session results in a certain SessionAuthenticationException defined in the Spring Security exception. We should only process it and redirect the user to the html warning page, which, in addition, sets the choice: a) not to continue and return to the last open session (where the game may be playing); b) after all log in and then the last session should be killed.



The handler of such an exception is registered during the 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 cut off the head session



It remains to perform the appropriate actions, if the user chooses the option - login again and kill the last session. In Spring Security there is such a possibility, it is implemented in the SessionInformation class by its expireNow () method. This method is proposed to use to terminate any session of any user. To find the SessionInformation for a specific user using his login, 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. With its implementation does not occur intuitively expected action. Session of course declared to be outdated (expired), but not closed. In other words, the session will not be destroyed (destroyed) after we manually called the recommended expireNow () for it. And that means:







Tired sessions shoot, is not it?



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



The method recommended by many, including Spring documentation , for controlling sessions of 'expireNow ()', thus, works contrary to naive expectations. In our case, this violated the synchronization of the application. It is important that re-login after 'expireNow ()' is already possible, as the control of sessions of Spring Security resolves this after the last session was declared expired = true (exceptions SessionAuthenticationException are not thrown away) . Spring documentation says this rather superficially. At the same time, the last session was not actually destroyed, the sessionDestroyed event was not processed, respectively, the information about the player who expects him to go out (in order to login again) is not saved. A game (like a chat or other interactive application) sends messages to an old session, etc. If the player is now logged in again, chaos will occur due to the competitive creation of a new session and working out sessionDestroyed, which can be dealt with with heavy threadsafe tools. But you can make everything easier.



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 (bin 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 marked as obsolete. It is better to call 'killExpiredSessionForSure (id)' immediately after 'expireNow ()', then the desired behavior will occur:





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





We will summarize



In general, thanks to this, the application began to work more intuitively, and ideas for its design came true. The idea, which was to place all the code that updates data about online users and saves user data, in any way out of the system, in the sessionDestroy event handler, was sound. For its correct implementation, it was necessary only 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, the use of a combination of method calls - the well-known 'expireNow ()' and the proposed 'killExpiredSessionForSure (String id), can be used in such cases:







Note

* - The transition occurs due to the code on the frontend. In our case, the current messages in the course of the game are transmitted using WebSocket. WebSocket uses the HTTP protocol (modified) only to establish a connection, and then exchanges messages on its WebSocket protocol running over TCP. Accordingly, the exchange of these messages is not filtered by the Servlet Filter in general, and the chain of Spring Security filters in particular. Therefore, even in an expired session, prior to our improvement, there was an exchange of game messages. The transfer of such messages did not lead to the destruction of the expired session. So there was the illusion of continuing the game where it should not have been. But if the session is finally destroyed (by calling killExpiredSessionForSure (id)), then the WebSocket connection is automatically terminated. 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 by the backend, since the implementation of Stomp in Spring out of the box does not have an API for breaking the WebSocket session from the server side.

Source: https://habr.com/ru/post/346296/



All Articles