When you add Spring Security to a Spring Boot application, by default, you get a session-based authentication system. Spring Security handles login and logout requests and stores information about the logged-in user in the HTTP session of the underlying webserver (Tomcat, Jetty, or Undertow). To track which session belongs to which client, the webserver sets a cookie with a random session id and stores the session object in memory. Each time the browser sends a request to the server, it sends the session cookie along, and the server retrieves the session object related to the session id. Spring Security then picks up the authentication object from the session and checks if the user can access a specific endpoint or call a method.
This approach works fine if you run only one instance of your Spring Boot application. However, as soon as you need to run multiple instances of the same application to handle all the incoming traffic, you face a problem. If a user logs in on one instance, then Spring Security stores the authentication object in the session store of this instance. As long as the client sends subsequent requests to the same instance, everything works fine, but if he sends HTTP requests to another instance, they will be rejected because this instance does not know about the existing session in the other instance.
Fortunately, there are solutions to this problem. When you have a load balancer running in front of these instances, you can configure him so that HTTP requests with a session cookie are always sent to the instance that created the cookie. This way, you don't have to change anything in your application and use sessions stored in memory.
Another solution is to store the session objects in a central data store or distribute them to all running application instances with a multicast library. This way, every application instance has access to all session information, and it does not matter if the client logs in on one instance and subsequent requests go to another instance.
This blog post will implement the solution with the central data store approach. There is also a Spring project that covers this case. Check out the Spring Session project. But for this example, we are going to implement the solution from scratch.
Stateless ¶
Stateless, in this context, means that we don't store any information about the logged-in user in memory. However, we still need to store information about the logged-in user somewhere and associate it with a client. In this example, we will store session information in a database table and store the primary key to this information in a cookie.
As a demo application, I created an Angular / Ionic application with a login page where users log in with their email and password. The client architecture does not matter, and the focus of this blog post is the configuration of Spring Security. This configuration should work with any client-side framework.
Note that using cookies makes your application potentially vulnerable to CSRF attacks.
The following example utilizes the Same-Site
cookie attribute to prevent this attack. This attribute prevents CSRF attacks on modern browsers, but when you still have users that use older browsers (like IE11 on Windows 7), you need to think about adding some additional CSRF protection.
To check out what browsers currently support the Same-Site
attribute visit:
https://caniuse.com/#search=same-site
Spring Security Configuration ¶
Database ¶
The example application uses two database tables: app_user
and app_session
CREATE TABLE app_user (
id BIGINT NOT NULL AUTO_INCREMENT,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255),
authority VARCHAR(255),
enabled BOOLEAN NOT NULL,
PRIMARY KEY(id),
UNIQUE(email)
);
CREATE TABLE app_session (
id CHAR(35) NOT NULL,
app_user_id BIGINT NOT NULL,
valid_until TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(id),
FOREIGN KEY (app_user_id) REFERENCES app_user(id) ON DELETE CASCADE
);
The app_user
table stores the login information (email and password) and the user's role (authority). With the enabled
field, we can disable a user and prevent him from accessing the application.
The application stores the session information in the app_session
table. A user can have multiple sessions. The primary key will be stored in the cookie, so we use a string as the key. The field valid_until
contains the date when the session expires.
The following demo application uses JOOQ and a H2 database.
Password Encoder ¶
To encrypt passwords, we configure an Argon2PasswordEncoder bean.
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put(defaultEncodingId, new Argon2PasswordEncoder(16, 32, 8, 1 << 16, 4));
return new DelegatingPasswordEncoder(defaultEncodingId, encoders);
}
}
We also need to add the Bouncycastle library, which the Argon2 password encoder depends on.
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15on</artifactId>
<version>1.70</version>
</dependency>
Login ¶
We disable the authentication part of Spring Security. Our login system is straightforward, and we will implement it without the help of Spring Security. However, we will still use Spring Security for authorization and securing our backend services. To disable the authentication system, we have to prevent the Spring Boot auto configurer from running by implementing a custom AuthenticationManager
bean that does nothing.
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.sessionManagement(
cust -> cust.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
Next, we implement our login handler. This regular POST endpoint receives the login information and verifies them.
@PostMapping("/login")
public ResponseEntity<String> login(String username, String password) {
AppUserRecord appUserRecord = this.dsl.selectFrom(APP_USER)
.where(APP_USER.EMAIL.eq(username)).fetchOne();
if (appUserRecord != null) {
boolean pwMatches = this.passwordEncoder.matches(password,
appUserRecord.getPasswordHash());
if (pwMatches && appUserRecord.getEnabled().booleanValue()) {
String sessionId = this.tokenService.createToken();
AppSessionRecord record = this.dsl.newRecord(APP_SESSION);
record.setId(sessionId);
record.setAppUserId(appUserRecord.getId());
record.setValidUntil(
LocalDateTime.now().plus(this.appProperties.getCookieMaxAge()));
record.store();
ResponseCookie cookie = ResponseCookie
.from(AuthCookieFilter.COOKIE_NAME, sessionId)
.maxAge(this.appProperties.getCookieMaxAge()).sameSite("Strict").path("/")
.httpOnly(true).secure(this.appProperties.isSecureCookie()).build();
return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, cookie.toString())
.body(appUserRecord.getAuthority());
}
}
else {
this.passwordEncoder.matches(password, this.userNotFoundEncodedPassword);
}
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
The method fetches the user from the database and compares the passwords. If the login information is valid, it inserts a new record into the app_session
table and creates the authentication cookie with the primary key as a value. Note that we set the Same-Site attribute of the cookie to "Strict" to prevent CSRF attacks. As mentioned before, this only works in browsers that support this attribute.
If you want to learn more about the SameSite
attribute, I recommend this blog post:
https://www.invicti.com/blog/web-security/same-site-cookie-attribute-prevent-cross-site-request-forgery/
We set the HttpOnly
attribute to prevent JavaScript code from accessing the cookie. If your site in production is accessible over TLS (and it should be), you should also set the Secure
attribute. This instructs the browser to send this cookie only over HTTPS and never over an unsecured HTTP connection.
If you want to learn more about Cookies and their attributes, visit this MDN page:
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie
The login method finally sends the user's role (authority) back to the client in the response's body.
Note that the password encoder should be configured so that the check runs for about 1 second. If the endpoint receives a request for a user that is not stored in the database, we need to make sure that the method's runtime stays the same by doing an artificial password check.
If the login fails, the method returns a 401 HTTP response status.
Authentication check ¶
Besides the login endpoint, we also add a simple endpoint that clients can call to check if a user is already logged in.
@GetMapping("/authenticate")
@PreAuthorize("isFullyAuthenticated()")
public String authenticate(@AuthenticationPrincipal AppUserDetail user) {
return user.getAuthorities().iterator().next().getAuthority();
}
Unauthenticated requests to this endpoint result in a 403 HTTP response code; otherwise, it sends back the user's role in the response's body.
Our client app uses this endpoint to check if it's necessary to present the login page or not.
Authentication and Principal objects ¶
We disabled the authentication part of Spring Security but will still rely on Spring Security's authorization system. For that, we need an Authentication
and a principal object.
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
public class UserAuthentication implements Authentication {
private static final long serialVersionUID = 1L;
private final AppUserDetail userDetail;
public UserAuthentication(AppUserDetail userDetail) {
this.userDetail = userDetail;
}
@Override
public String getName() {
return this.userDetail.getEmail();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.userDetail.getAuthorities();
}
@Override
public Object getCredentials() {
return null;
}
@Override
public Object getDetails() {
return null;
}
@Override
public Object getPrincipal() {
return this.userDetail;
}
@Override
public boolean isAuthenticated() {
return true;
}
@Override
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
throw new UnsupportedOperationException(
"this authentication object is always authenticated");
}
}
Our custom implementation holds a reference to the principal object (AppUserDetail) and implements the required Authentication
interface methods.
As principal, you can use any object you want. We are going to use the following class. It's just a holder of the user information.
public class AppUserDetail {
private final Long appUserId;
private final String email;
private final boolean enabled;
private final Set<GrantedAuthority> authorities;
Later in the application, you can inject this principal object with the @AuthenticationPrincipal
into an HTTP endpoint.
public String message(@AuthenticationPrincipal AppUserDetail user) {
We will see these two classes in action further below when implementing the authentication filter.
Stateless ¶
Now we start with the main Spring Security configuration.
.policyDirectives("script-src 'self'; object-src 'none'; base-uri 'self'")))
.csrf(CsrfConfigurer::disable).logout(cust -> {
cust.addLogoutHandler(new HeaderWriterLogoutHandler(
new ClearSiteDataHeaderWriter(Directive.ALL)));
First, we set the session creation policy to STATELESS
. This does not disable session management in the underlying web server; instead,
it instructs Spring Security to no longer create or use an HTTP session for storing the authentication object.
CSRF ¶
})
We are disabling CSRF protection here because we are using a Same-Site cookie. To reiterate, this only protects users on modern browsers. If you also want to target users with older browsers, you should add additional CSRF protection. If you remove
.csrf(cust -> cust.disable())
, you get by default a session-based CSRF protection. For a stateless architecture, a cookie-based solution might be a better fit:
.csrf().csrfTokenRepository(new CookieCsrfTokenRepository())
Visit the official Spring Security documentation to read more about CSRF protection.
Logout ¶
})
.exceptionHandling(cust -> cust
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
.addFilterAfter(this.authCookieFilter, SecurityContextHolderFilter.class);
return http.build();
We use the default Spring Security logout endpoint, accessible with the URL//logout`. We configure the logout endpoint to delete the authentication cookie and implement a custom logout success handler.
package ch.rasc.stateless.config.security;
import static ch.rasc.stateless.db.tables.AppSession.APP_SESSION;
import java.io.IOException;
import org.jooq.DSLContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.authentication.logout.HeaderWriterLogoutHandler;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.security.web.context.SecurityContextHolderFilter;
import org.springframework.security.web.header.writers.ClearSiteDataHeaderWriter;
import org.springframework.security.web.header.writers.ClearSiteDataHeaderWriter.Directive;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@Configuration
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
private final AuthCookieFilter authCookieFilter;
private final CustomLogoutSuccessHandler logoutSuccessHandler;
public SecurityConfig(DSLContext dsl) {
this.authCookieFilter = new AuthCookieFilter(dsl);
this.logoutSuccessHandler = new CustomLogoutSuccessHandler(dsl);
}
@Bean
AuthenticationManager authenticationManager() {
return authentication -> {
throw new AuthenticationServiceException("Cannot authenticate " + authentication);
};
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.sessionManagement(
cust -> cust.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.headers(cust -> cust.contentSecurityPolicy(customizer -> customizer
.policyDirectives("script-src 'self'; object-src 'none'; base-uri 'self'")))
.csrf(CsrfConfigurer::disable).logout(cust -> {
cust.addLogoutHandler(new HeaderWriterLogoutHandler(
new ClearSiteDataHeaderWriter(Directive.ALL)));
cust.logoutSuccessHandler(this.logoutSuccessHandler);
cust.deleteCookies(AuthCookieFilter.COOKIE_NAME);
}).authorizeHttpRequests(cust -> {
cust.requestMatchers("/login").permitAll().anyRequest().authenticated();
})
.exceptionHandling(cust -> cust
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
.addFilterAfter(this.authCookieFilter, SecurityContextHolderFilter.class);
return http.build();
}
private static class CustomLogoutSuccessHandler implements LogoutSuccessHandler {
private final DSLContext dsl;
public CustomLogoutSuccessHandler(DSLContext dsl) {
this.dsl = dsl;
}
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
String sessionId = AuthCookieFilter.extractAuthenticationCookie(request);
if (sessionId != null) {
this.dsl.delete(APP_SESSION).where(APP_SESSION.ID.eq(sessionId)).execute();
}
response.setStatus(HttpServletResponse.SC_OK);
response.getWriter().flush();
}
}
}
This handler is responsible for deleting the session information from the app_session
table.
The problem here is that we can't rely on the users to log out correctly. As a result, the session information might stay forever in the app_session
table. To prevent that, we install a scheduled method that periodically deletes the records based on the expiry date.
@Scheduled(cron = "0 0 5 * * *")
public void doCleanup() {
this.dsl.delete(APP_SESSION).where(APP_SESSION.VALID_UNTIL.le(LocalDateTime.now()))
.execute();
}
Exception Handler ¶
private final DSLContext dsl;
The Spring Security exception handler is called whenever the client tries to reach a secure endpoint without a valid authentication.
The default behavior sends back an HTML login page, which is not helpful for Single Page Applications. Therefore, we configure a custom handler to return the 401 status code.
AuthCookieFilter ¶
This filter is crucial for the authorization system to work. Every request to a secured HTTP endpoint flows through this filter. In addition, he is responsible for extracting the session information from the cookie and checking if there is a record stored in the app_session
table.
package ch.rasc.stateless.config.security;
import static ch.rasc.stateless.db.tables.AppSession.APP_SESSION;
import static ch.rasc.stateless.db.tables.AppUser.APP_USER;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
import org.jooq.DSLContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.GenericFilterBean;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import ch.rasc.stateless.db.tables.records.AppUserRecord;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
public class AuthCookieFilter extends GenericFilterBean {
public final static String COOKIE_NAME = "authentication";
private final DSLContext dsl;
private final Cache<String, AppUserDetail> userDetailsCache;
public AuthCookieFilter(DSLContext dsl) {
this.dsl = dsl;
this.userDetailsCache = Caffeine.newBuilder().expireAfterAccess(1, TimeUnit.MINUTES)
.maximumSize(1_000).build();
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
String sessionId = extractAuthenticationCookie(httpServletRequest);
if (sessionId != null) {
final String sId = sessionId;
AppUserDetail userDetails = this.userDetailsCache.get(sessionId, key -> {
var record = this.dsl.select(APP_USER.asterisk()).from(APP_USER)
.innerJoin(APP_SESSION).onKey().where(APP_SESSION.ID.eq(sId)).fetchOne()
.into(AppUserRecord.class);
if (record != null) {
return new AppUserDetail(record);
}
return null;
});
if (userDetails != null && userDetails.isEnabled()) {
SecurityContextHolder.getContext()
.setAuthentication(new UserAuthentication(userDetails));
}
}
filterChain.doFilter(servletRequest, servletResponse);
}
public static String extractAuthenticationCookie(
HttpServletRequest httpServletRequest) {
String sessionId = null;
Cookie[] cookies = httpServletRequest.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (AuthCookieFilter.COOKIE_NAME.equals(cookie.getName())) {
sessionId = cookie.getValue();
break;
}
}
}
return sessionId;
}
}
When the filter finds a record in the app_session
table, he creates the principal object (AppUserDetail
) and the authentication object (UserAuthentication
) and puts them into the security context. Spring Security's authorization system will then pick up the objects from there.
Note that this filter poses a bottleneck because every incoming HTTP request to a secured endpoint runs through this code. The filter caches the database lookup call for a short time (1 minute) to mitigate this problem. However, don't cache for too long because you lose the ability to change a user's role or disable the user immediately.
We configure this filter in the central security configuration. Here we need to ensure that this filter runs very early in the filter chain. So we insert it after the SecurityContextHolderFilter
, one of the first filters in the chain.
Lastly, we need to tell Spring Security which endpoints are secure and which are publicly available.
}
private static class CustomLogoutSuccessHandler implements LogoutSuccessHandler {
In our example, only /login
is accessible without authentication.
Client ¶
The demo client application is written with Angular and Ionic.
This section shows you a few key parts of the client application.
Guard ¶
When the user navigates to http://localhost:8100, the app redirects to http://localhost:8100/home and this path is protected by a CanActivate guard
The guard first checks if the user is already logged in. The isLoggedIn()
method checks if the client either already called the /authenticate
or /login
endpoint.
@Injectable({
providedIn: 'root'
})
export class AuthGuard {
constructor(private readonly authService: AuthService,
private readonly router: Router) {
}
canActivate(): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
if (this.authService.isLoggedIn()) {
return true;
}
return this.authService.isAuthenticated().pipe(map(authenticated => {
if (authenticated) {
return true;
}
return this.router.createUrlTree(['/login']);
}
));
}
}
Because the client has no access via JavaScript to the authentication cookie (HttpOnly), he has to send
a request to the /authenticated
endpoint to check the presence and validity of the cookie.
isAuthenticated(): Observable<boolean> {
return this.httpClient.get(`/authenticate`, {responseType: 'text'})
.pipe(
map(response => this.handleAuthResponse(response)),
catchError(() => of(false))
);
}
/authenticated
sends back the HTTP status code of 401 when the user is not logged in. In that case, the AuthGuard
redirects to /login
and presents the login page to the user.
If /authenticate
returns an HTTP status code of 200, the response body contains the user's authority, which is then stored in the client app. Subsequent calls to this.authService.isLoggedIn()
will then return true.
Login ¶
The login page presents a form to the user, and when he clicks on the login button, the application sends a POST request to the server with the email and password in the body of the request.
login(username: string, password: string): Observable<boolean> {
const body = new HttpParams().set('username', username).set('password', password);
return this.httpClient.post('/login', body, {responseType: 'text'})
.pipe(
map(response => this.handleAuthResponse(response)),
catchError(() => of(false))
);
}
Like /authenticate
, the /login
endpoint returns either an HTTP status code of 401 for an unsuccessful login or 200, and the user authority
in the response body.
Logout ¶
The client has to send a GET request to /logout
to log out a user.
The application displays the login page if this call is successful.
logout(): Observable<void> {
return this.httpClient.get<void>('/logout')
.pipe(
tap(() => this.authoritySubject.next(null))
);
}
The /logout
endpoint takes care of deleting the authentication cookie.
Example application ¶
You find the complete source for the server and client on GitHub in this repository:
https://github.com/ralscha/blog2019/tree/master/stateless
To run it locally you need to install Node.js, Ionic CLI and OpenJDK.
To start the client, you first need to install the dependencies and start it with the Ionic CLI
cd client
npm install
ionic serve
You can start the server from either inside an IDE or the command line with the following command
cd server
./mvnw spring-boot:run
The server sets up two demo users:
Password | Authority | |
---|---|---|
admin@test.com | admin | ADMIN |
user@test.com | user | USER |
You can verify that the server does not store any state in memory. Open the application in the browser, log in with a user, and restart the Spring Boot application. Refresh the page in the browser, and you should still be logged in.
Proxy ¶
The development environment needs an additional configuration step because we use SameSite=Strict cookies. If we load the client from localhost:8100, and from there, we send requests to localhost:8080 (Spring Boot) SameSite=Strict cookies would not be sent along with the request.
To solve that, we have to access the endpoints from Spring Boot and the Angular Dev Server from the same origin (same URI scheme, hostname, and port number). Fortunately, this is easy to solve with a proxy configuration in an Angular CLI project.
Create a JSON file in the project's root with an arbitrary name and add all the URLs you want to forward to another server.
{
"/login": {
"target": "http://localhost:8080",
"secure": false
},
"/logout": {
"target": "http://localhost:8080",
"secure": false
},
Then open angular.json
and add a proxyConfig
configuration inside the serve.options
object that points to the proxy configuration file.
"options": {
"proxyConfig": "proxy.conf.json",
"buildTarget": "app:build"
},
"configurations": {
"production": {
With this configuration in place, we can now send all requests to localhost:8100 and any request that matches the entry in the proxy configuration file will be internally redirected to localhost:8080.