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:
- he can log out in good faith (by clicking the logout button);
- It can simply close the browser, the lid of the laptop, press the reset, etc., in general, leave in English.
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 {
2. A session event listener is implemented:
public class SessionEventListener extends HttpSessionEventPublisher {
3. Add SessionRegistry to the Spring Security configuration:
@Configuration @EnableWebSecurity public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
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.
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:
- make a clean login when he does not have open sessions;
- may forget / do not want to end the old session by pressing the logout button (for example, simply closing the browser window, the lid of the laptop) 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, another computer.
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
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:
- stop, think again and do not log in again, and return to the already open game;
- log in again, killing the last session (and this should happen correctly, with saving data, etc., even if the player simply closed the browser window from 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
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)) {
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 {
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:
- on the frontend in the past browser (the session in which we deliberately refused and expect it to be already destroyed with all the consequences) the player sees the game going on (if there is javascript autonomously 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.
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) {
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:
- in an open browser window with an outdated session, the user (passively watching and not pressing anything) immediately sees the 'beautiful' [ * ] forced transition to the login / home-page;
- the session event is triggered and our entire logic for updating and saving the players arena and their data is triggered. No crutches are needed anymore.
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:
- if you are an administrator and you want to securely beat the session of any user logged in to the system. As a result, the user will instantly see the 'outflow' from the system (transition [ * ] to home / login-page), and the whole logic of saving the update of his data can be implemented in the sessionDestroyed handler;
- for the implementation of the demanded script, when the session is killed a minimum time after the user closes the browser window. In this case, you will need to create a special heartbeat in the client part of the application, transmitting signals to the backend, and a lot more, but this may be the topic of the following publications.
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.