Well, at least if you want to run multiple instances of your application. And, additionally, it’s not necessary, at least in most cases. There’s another mechanism for keeping some user session state in a web application. It’s called Cookie. And, instead of using it only to store a session identifier, why not let it hold the data itself. This blog post shows, that, with some effort, it’s possible to configure Spring Security to store its session information in a cookie instead of a server-side session.
Spring Security architecture
Spring Security integrates into Spring web as a servlet request filter
(see Chapter 9 of the Spring Security Reference).
The FilterChainProxy
is the central filter class and contains a parallel SecurityFilterChain
(see Chapter 9.4 of the Spring Security Reference).
The FilterChainProxy
is also a good starting point for debugging the Spring Security processing.
In our sample project (using Spring Boot 2.3.1 and Spring Security 5.3.3) the SecurityFilterChain
contains the
following filters (identified by debugging into FilterChainProxy.doFilter(...)
and looking into
this.filterChains[0].filters
).
org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter
org.springframework.security.web.context.SecurityContextPersistenceFilter
org.springframework.security.web.header.HeaderWriterFilter
org.springframework.security.web.authentication.logout.LogoutFilter
org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter
org.springframework.security.web.authentication.AnonymousAuthenticationFilter
org.springframework.security.web.session.SessionManagementFilter
org.springframework.security.web.access.ExceptionTranslationFilter
org.springframework.security.web.access.intercept.FilterSecurityInterceptor
Let’s have a closer look at those filters that are relevant for our purpose and how to extend and customize their behaviour.
SecurityContextPersistenceFilter
From the API documentation: “Populates the SecurityContextHolder
with information obtained from the configured
SecurityContextRepository
prior to the request and stores it back in the repository once the request has completed
and clearing the context holder.”
The SecurityContext
mainly represents the persisted session. It contains an Authentication
which in the context of
a web application encapsulates the information of the authenticated user. The default implementation of the
SecurityContextRepository
stores the SecurityContext
in the HttpSession
. To change this behaviour we have
to provide our own SecurityContextRepository
implementation.
The UserInfo
in our sample project is a very simple POJO that implements the UserDetails
interface and contains the
information that we want to hold in our user session.
The SaveToCookieResponseWrapper
gets the UserInfo
from the SecurityContext
and puts it into a SignedUserInfoCookie
.
The SignedUserInfoCookie
is an extension of javax.servlet.http.Cookie
that handles the serialization and deserialization
of the UserInfo
into/from the cookie value.
The cookie value has to follow RFC-6265 which allows only a few non-alphabetical characters (see Stack Overflow answer for a good summary), for example no whitespace, quotes or brackets are allowed. So we can’t use a JSON structure to serialize our payload, which would probably be easier to handle, especially to parse. We could have encoded the payload with Base64 before writing it into the cookie. However, the idea of the sample project was to keep the cookie value unencoded and human-readable, so we decided for the individual format.
As the cookie contains the id and the roles of the authenticated user, we have to make sure that the value is not modified on the client side. To do this our sample application signs the cookie by computing a HMAC (hash-based message authentication code) of the payload and appending it to the cookie value. That’s a quite simple approach and there are probably better and more secure ways of securing the cookie. One option might be JWT which provides a standardized way to securely exchange sensitive data. But, this is a topic of its own and out of the scope of this blog post.
(Thanks to Christian Köberl, @derkoe, for his feedback and ideas to improve the security of the cookie)
When the SecurityContext
is requested via SecurityContextRepository.loadContext(...)
, the javax.servlet.http.Cookie
from the HttpServletRequest
is transformed into a SignedUserInfoCookie
again. The cookie value is verified using the HMAC signature.
A CookieVerificationFailedException
will be thrown if the received cookie is unsigned or the HMAC does not fit to the value.
Finally, the UserInfo
is retrieved from the SignedUserInfoCookie
, wrapped in a UsernamePasswordAuthenticationToken
and set into the SecurityContext
.
UsernamePasswordAuthenticationFilter
From the API documentation: “Processes an authentication form submission.”
See also Chapter 10 of the Spring Security Reference for a detailed description of the Spring Security authentication process.
The UsernamePasswordAuthenticationFilter
triggers the authentication, if necessary and possible. It reads username
and password from a login form request, wraps them into a UsernamePasswordAuthenticationToken
and calls the configured
AuthenticationManager
to perform the authentication.
In the default configuration, the AuthenticationManager
is a ProviderManager
which holds a list of
AuthenticationProviders
to which it delegates the authentication request. In our sample project we use a very basic
InMemoryAuthenticationProvider
which knows only one static user. In a real world project we would instead use a database
or LDAP provider (from the Spring Security LDAP module).
After a successful login the configured AuthenticationSuccessHandler
is called. Usually, this handler decides about
where to forward the user to after the successful login. In the default configuration a
SavedRequestAwareAuthenticationSuccessHandler
is used. It loads and replays the original request (which was cached before
by the ExceptionTranslationFilter
, see next section) to show the page to the user which he/she originally
requested. As this RequestCache
is also stored in the server-side session, we have to find another strategy for this feature as well.
The RedirectToOriginalUrlAuthenticationSuccessHandler
extends the SimpleUrlAuthenticationSuccessHandler
and sets the
targetUrlParameter
in its constructor. The parameter is defined and used by the extended
AbstractAuthenticationTargetUrlRequestHandler
to find the target URL in the request parameters. Using this
feature, we can simply put the originally requested URL into a hidden input field of the login form.
The determineTargetUrl(...)
method of the AbstractAuthenticationTargetUrlRequestHandler
is overridden to prevent
tampering of the target URL parameter (see OWASP Unvalidated Redirects and Forwards Cheat Sheet).
We only expect relative URLs within our own application.
The RedirectToOriginalUrlAuthenticationSuccessHandler
also overrides the onAuthenticationSuccess(...)
method. In this method
we can get additional parameters (in our example a favorite colour) from the login form and add it to the UserInfo
object.
ExceptionTranslationFilter
From the API documentation: “Handles any AccessDeniedException
and AuthenticationException
thrown within the filter chain.”
Especially the very first, unauthorized request of a user triggers an AccessDeniedException
(somewhere out of the
FilterSecurityInterceptor
). This one is catched and handled by the ExceptionTranslationFilter
.
If the user is not yet authenticated, the filter forwards him/her to the configured AuthenticationEntryPoint
.
In the default configuration, the original request is temporarily stored in a RequestCache
to be replayed after a
successful login (see previous section). As noted before, the default HttpSessionRequestCache
also uses the server session
to store the request. We could have introduced a CookieRequestCache
to stored the request in another cookie (like the
SecurityContext
). In our sample project we follow another approach.
We deactivate the RequestCache
and instead extend the default LoginUrlAuthenticationEntryPoint
, which forwards the user
to the login form.
The overridden determineUrlToUseForThisRequest(...)
method appends the URL from the original request as a query
parameter to the redirect URL. This way, the URL can be mapped to a hidden input field and will be included again in the
login request where the AuthenticationSuccessHandler
can read it (see previous section).
WebSecurityConfig
The WebSecurityConfig
configures Spring Security to use all the components described above.
To prevent the creation of the server-side session and the JSESSION
cookie we use the SessionCreationPolicy.STATELESS
.
To really activate this policy, we have to disable CSRF protection as well (see Spring Security issue 5299).
We use the CookieSecurityContextRepository
and our cookie should be deleted after the user logs out.
The RequestCache
is deactivated and instead the LoginWithTargetUrlAuthenticationEntryPoint
is used to add
the originally requested URL to the login form request.
The RedirectToOriginalUrlAuthenticationSuccessHandler
is used to forward the user to the originally requested URL after
a successful login.
Summary
Spring Security might seem to be very complex and hard to configure on the first glance. But, spending some time with the documentation and doing a little bit of debugging in a sample application, it turns out that it is very extensible and customizable by design. The hardest part is to find the right places. This blog post identified the ones that need to be adjusted to store the user session information in a cookie instead of a server-side session. It might also be a starting point for other, related topics.
Many thanks to Michael Vitz for showing me the relevant starting points and reviewing the resulting implementation. Also many thanks to Jochen Christ, Jan Stępień, and Stefan Tilkov for their feedback to this post.
Header Photo by Steve Halama on Unsplash