Home | Send Feedback

Stateless Authentication with Spring Security

Published: 15. May 2019  •  java, spring, ionic

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
);

V0001__initial.sql

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);
  }

}

Application.java

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>

pom.xml


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))

SecurityConfig.java


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();
  }

AuthController.java

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();
  }

AuthController.java

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");
  }

}

UserAuthentication.java

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;

AppUserDetail.java

Later in the application, you can inject this principal object with the @AuthenticationPrincipal into an HTTP endpoint.

  public String message(@AuthenticationPrincipal AppUserDetail user) {

TestController.java

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)));

SecurityConfig.java

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

        })

SecurityConfig.java

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();

SecurityConfig.java

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();
    }

  }

}

SecurityConfig.java

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();
  }

CleanupJob.java


Exception Handler


    private final DSLContext dsl;

SecurityConfig.java

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;
  }
}

AuthCookieFilter.java

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.


SecurityConfig.java


Lastly, we need to tell Spring Security which endpoints are secure and which are publicly available.

  }

  private static class CustomLogoutSuccessHandler implements LogoutSuccessHandler {

SecurityConfig.java

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']);
      }
    ));
  }

}

auth.guard.ts

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))
      );
  }

auth.service.ts

/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))
      );
  }

auth.service.ts

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))
      );
  }

auth.service.ts

The /logout endpoint takes care of deleting the authentication cookie.

Example application

example1 example2

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:

Email 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
  },

proxy.conf.json

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": {

angular.json

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.