Using Spring Session for concurrent session control in a clustered environment
For a long time, Spring Security has provided support to limit the number of sessions a single user can have concurrently. This prevents users from being logged in from many different devices at the same time, for example to ensure that they won’t share their credentials to a paid site with their friends and family.
My former colleague Quinten Krijger has blogged about this feature before. Note the last paragraph, which explains how this support is limited to single-node applications.
Although running on a single node may suffice for many applications, there are plenty applications running in a clustered environment that should be able to benefit from concurrent session control as well. As hinted in the aforementioned blog, this requires both implementing a custom SessionRegistry as well as ensuring that expiring a session is propagated to all nodes in the cluster.
This is exactly what I’ve done recently using Spring Session, a framework that allows you to take control over managing sessions using a shared external registry like Redis. In this post I’d like to walk you through the code, which can be found here: https://github.com/jkuipers/spring-session-concurrent-session-control
UPDATE:
Based on the code I wrote for this blog I’ve opened a pull request for Spring Session. That request is scheduled for inclusion in Spring Session 1.3, but the code works just fine with the upcoming 1.2 release and removes the limitation of not providing an expiry notification after exceeding the maximum number of sessions.
Spring Session primer
Spring Session is a relatively new addition to the Spring portfolio. It is a framework that basically allows you to let your application take control over session management, rather than leaving this up to the servlet container, where session information is typically stored in some external repository like a Redis server.
There are several advantages in doing this: sessions are no longer limited to the traditional 1-session-per-browser approach but can be expanded to individual browser tabs, sessions IDs can be exchanged through other means than only cookies, thus adding session support to e.g. web sockets or through custom request headers, sessions can be clustered, it’s easy to get an overview of the sessions that exist for a given user, etc.
To integrate with the Servlet API, Spring Session provides a filter which wraps your HttpServletRequests and overrides the getSession methods. This means that it becomes trivial to transparently add Spring Session support to a Servlet-based web application, like a Spring-MVC app.
The fact that Spring Session (since version 1.1) allows you to query for sessions stored in an external repository by username seems to make it a good fit for solving the problem of customizing Spring Security to support concurrent session control in a distributed environment.
There’s actually already an open issue for this, but I went ahead and implemented a proof of concept already which I discuss in the next section.
The implementation
Custom SessionRegistry
The default SessionRegistry implementation from Spring Security tracks all sessions in a local map, which is updated when “session created” and “session destroyed” events are received. This doesn’t work for a clustered environment, as these events aren’t propagated across nodes in a cluster. One approach would be to try to propagate these events, but that would mean that the SessionRegistry on every node actually duplicates the information that’s already managed by Spring Session and could potentially get out of sync.
Therefore, I chose to implement a SessionRegistry that simply delegates finding all sessions for a user to Spring Session directly: that means that no internal administration is necessary.
My implementation relies on Spring Session’s FindByIndexNameSessionRepository to retrieve sessions for a given principal. Currently the in-memory MapSessionRepository is the only SessionRepository which doesn’t implement that interface, and that implementation isn’t intended for use in clustered environments anyway, so this should work with all relevant supported repositories. The upcoming 1.2 version of Spring Session will support relational databases and MongoDB in addition to Redis that’s already supported in 1.1.
The SessionRegistry interface defines several methods that are used for internal session book keeping: we don’t need a meaningful implementation of those methods, since we’re not going to track session creation, usage and deletion ourselves but leave this completely to Spring Session.
Also, there’s a method to query for all principals that have sessions that cannot be implemented based on the functionality currently provided by Spring Session. Fortunately Spring Security doesn’t actually call this method in any of its code, so we can easily just throw an UnsupportedOperationException here. As I understand it, there are plans to split up the SessionRegistry interface in future version of Spring Security to avoid the need to provide such a method implementation altogether.
The API also expects that you include support for including expired sessions when querying for a principal’s sessions, but again that’s never used. Nevertheless I’ve taken this into account in my implementation, but currently Spring Session will never return expired sessions when you query it. Should that ever change, then my code will at least be prepared 😉
One thing that is a bit tricky is that Spring Security passes in a principal as an Object, while Spring Session expects a String to represent the current user. I’ve added a method to derive this String from a given principal, based on Spring Security’s AbstractAuthenticationToken#getName() method. This should work for the majority of applications, and would be easy to adapt to custom implementations.
Custom SessionInformation
Out of the box, Spring Security expects that the SessionRegistry tracks sessions itself in the form of SessionInformation objects. When a user logs in but has already reached the configured maximum number of sessions, then by default Spring Security expires the oldest session by calling an expireNow method on the corresponding SessionInformation.
That poses a problem for my implementation, in which the SessionRegistry doesn’t track sessions itself but leaves the session management completely to Spring Session: the SessionInformations in that implementation only contain derived state, so updating that state doesn’t actually expire a session at all.
Therefore, I’ve extended the default SessionInformation so that its expireNow method can be overridden to ensure that an invocation of that method is propagated to Spring Session: see https://github.com/jkuipers/spring-session-concurrent-session-control/blob/master/src/main/java/nl/trifork/security/SpringSessionBackedSessionInformation.java
The only limitation here compared to the default provided by Spring Security is that a session which is expired in this way will simply be deleted, while the default implementation only marks it as expired: the latter approach allows showing a warning to the user that his session was expired because too many sessions were created.
My implementation doesn’t support such a notice, since Spring Session doesn’t allow manual expiration of a session nor retrieving expired sessions.
Testing the implementation
The demo app is a Spring Boot app, so you can run it from your IDE or package it as a jar and run it from the command line. It relies on having a Redis server running on the default port: if your setup is not like that, you’ll have to provide the proper configuration in the application.properties file.
The application configures a single user called ‘user’ with a password ‘secret’. After logging in with this user you’ll see an overview of all sessions and the timestamp of their last usage, with the current session highlighted.
By default the application runs on port 8080. You can run a second instance by providing an alternative port:
java -jar spring-session-concurrent-session-control-1.0.jar --server.port=9000
Now you can log in from two different browsers (or use private browser mode from a single browser) to the two running applications and see your two different sessions. When you refresh the page you’ll see the updated timestamps.
If you now log in a third time, the session with the oldest timestamp will be deleted, thus causing you to be logged out in that browser. This should now work across application instances, since a single Spring Session-backed session repository is used.
Alternatively, in App.java, you can update the code to read
.maxSessionsPreventsLogin(true);
This will cause a third login to simply fail with an error, rather than deleting another session.
Conclusion
Adding support for concurrent session control to Spring Security through Spring Session is actually fairly straightforward. Apart from losing the option to warn a user that a session has been expired because the maximum has been reached, all expected functionality is there with only two classes and very little additional configuration.
Apart from concurrent session management, using Spring Session opens up a slew of additional options: users can see what sessions they have running and manually expire them, for example. Reasons enough to start giving Spring Session a try!
In the coming release it will support additional backends next to Redis, like MongoDB or a relational database, so if running Redis is not an option for you then that will no longer be a show stopper.