Home | Send Feedback

Stateless Authentication with Spring Security

Published: May 15, 2019  •  java, spring, ionic4

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 that the underlying web server (Tomcat, Jetty or Undertow) provides. To keep track which session belongs to which client the web server 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 is allowed to access a certain endpoint or to call a method.

This approach works fine if you run only one instance of your Spring Boot application. 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 instance one, then Spring Security stores the authentication object in the session store of this instance. As long as the client sends his requests to instance one everything works fine, but if he sends HTTP requests to instance two, they will be rejected because this instance does not know about the existing session in instance one.

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 session. This way you don't have to change anything in your application and can use sessions stored in memory.

Another solution is to store the session objects in a data store or distribute them with a multicast library to all running application instances. This way every application instance has access to all session information, and it does not matter if the client logged in on instance one and subsequent requests went to instance two.
If you are interested in this approach you should check out the Spring Session project.

Stateless

In this blog post, we are looking at a different approach and implement a stateless solution with Spring Security. Stateless in this context means that we don't store any information about the logged in user in memory or a data store on the server. We still need to store information about the logged in user somewhere and associate it with a client. In this configuration, we are going to use a cookie. However, unlike a session cookie that stores a random value into the cookie and then manages a collection in memory to associate the random value to the session object, we store the primary key of the user directly into the cookie value.

This way we don't need to store anything on the server. If a request with the cookie is sent to our back end, the application extracts the cookie value, fetches the user with the primary key from the datastore and then creates a Spring Security authentication object.


As a demo application, I created an Angular / Ionic 4 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 works with any browser-based client-side framework.


Note that using cookies makes your application potentially vulnerable to CSRF attacks. To prevent this attack, the following code utilizes the Same-Site cookie attribute. 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

In this section, we look at the Spring Security configuration in detail. This approach depends on a User collection stored somewhere, and each user is accessible with a primary key. The following demo application uses JOOQ and a H2 database, but this is not a requirement, and you can use any datastore technology and library.

The user object I use for this demo application is stripped down and looks like this.

CREATE TABLE app_user (
    id            BIGINT NOT NULL AUTO_INCREMENT,
    email         VARCHAR(255),
    password_hash VARCHAR(255),
    authority     VARCHAR(255),
    enabled       BOOLEAN,
    PRIMARY KEY(id),
    UNIQUE(email)
);

V0001__initial.sql

We essential only need a username and a password. In this application, the email address serves as the username. The enabled field is optional, but it gives us the ability to lock out a user immediately.


UserDetails

First we need to implement the UserDetails and UserDetailsService interfaces.

The JooqUserDetails.java class implements the UserDetails interface and maps the fields from the database into instance variables and implements the required getter methods.

JooqUserDetailsService.java is the implementation of the UserDetailsService interface.

@Service
public class JooqUserDetailsService implements UserDetailsService {

  private final DSLContext dsl;

  public JooqUserDetailsService(DSLContext dsl) {
    this.dsl = dsl;
  }

  @Override
  public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
    AppUserRecord appUserRecord = this.dsl.selectFrom(APP_USER)
        .where(APP_USER.EMAIL.eq(email)).limit(1).fetchOne();

    if (appUserRecord != null) {
      return new JooqUserDetails(appUserRecord);
    }
    throw new UsernameNotFoundException(email);
  }

}

JooqUserDetailsService.java

This interface only requires us to implement the method loadByUsername(). This method is called each time the client sends a request to /login. The method returns either an instance of our custom JooqUserDetails object or throws a UsernameNotFoundException exception if the user does not exist. We have to make sure that our JooqUserDetailsService is a Spring managed bean. In that case, Spring Boot and Spring Security automatically configure the login handler to use this UserDetailService.


Stateless

Now we start with the main Spring Security configuration.

      http
        .sessionManagement()
        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

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

        .csrf().disable()

SecurityConfig.java

We are disabling CSRF protection here because we are going to use 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().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:
https://docs.spring.io/spring-security/site/docs/current/reference/htmlsingle/#csrf


Login

    .formLogin()
      .successHandler(formLoginSuccessHandler())
      .failureHandler((request, response, exception) ->
                      response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "UNAUTHORIZED"))
      .permitAll()

SecurityConfig.java

Next, we configure the success and failure handlers for the login endpoint. Spring Boot automatically registers a login endpoint listening on the URL /login, but the default handlers send redirect responses (HTTP status code 30x) back to the client. This is not useful for single page applications, and we change it here so that the success handler sends an HTTP status code of 200 and the failure handler 401.


Exception Handler

        .exceptionHandling()
          .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))

SecurityConfig.java

The Spring Security exception handler is called whenever the client is trying to call a secured endpoint without a valid authentication. We use this in the client application when we call the /authentication endpoint to check if the user is logged in or not.

    .authorizeRequests().anyRequest().authenticated()

SecurityConfig.java

Because all requests (except /login and /logout) require a valid authentication a request to /authentication without a valid authentication cookie throws an exception. The exception handler catches this exception and sends back an HTTP status code 401.

The default behavior sends back an HTML login page which is not useful for our Single Page Application. Therefore, we add a custom handler that sends back the 401 status code.


Cookie

The login success handler is also responsible for setting our custom authentication cookie. The javax.servlet.http.Cookie class does not support the SameSite attribute yet. Fortunately, a cookie is just an HTTP header with a special syntax, and we can easily build this manually. To set a cookie in the browser, we need to send the header Set-Cookie in the response and the value of the header contains the cookie name, value and a list of attributes.

  private AuthenticationSuccessHandler formLoginSuccessHandler() {
    return (request, response, authentication) -> {
      JooqUserDetails userDetails = (JooqUserDetails) authentication.getPrincipal();

      List<String> headerValues = new ArrayList<>();

      String cookieValue = userDetails.getUserDbId() + ":";
      if (this.appProperties.getCookieMaxAge() != null) {
        cookieValue += Instant.now().plus(this.appProperties.getCookieMaxAge())
            .getEpochSecond();
      }
      else {
        // default max age of 4h
        cookieValue += Instant.now().plus(Duration.ofHours(4)).getEpochSecond();
      }

      String encryptedCookieValue = SecurityConfig.this.cryptoService
          .encrypt(cookieValue);
      headerValues.add(AuthCookieFilter.COOKIE_NAME + "=" + encryptedCookieValue);

      if (this.appProperties.getCookieMaxAge() != null) {
        long maxAgeInSeconds = this.appProperties.getCookieMaxAge().toSeconds();
        if (maxAgeInSeconds > -1) {
          headerValues.add("Max-Age=" + maxAgeInSeconds);

          if (maxAgeInSeconds == 0) {
            headerValues.add("Expires=" + COOKIE_DATE_FORMATTER.format(
                ZonedDateTime.ofInstant(Instant.ofEpochMilli(10000), ZoneOffset.UTC)));
          }
          else {
            headerValues.add("Expires=" + COOKIE_DATE_FORMATTER
                .format(ZonedDateTime.now(ZoneOffset.UTC).plusSeconds(maxAgeInSeconds)));
          }
        }
      }

      headerValues.add("SameSite=Strict");
      headerValues.add("Path=/");
      headerValues.add("HttpOnly");
      if (this.appProperties.isSecureCookie()) {
        headerValues.add("Secure");
      }

      response.addHeader("Set-Cookie",
          headerValues.stream().collect(Collectors.joining("; ")));

      response.getWriter().print(SecurityContextHolder.getContext().getAuthentication()
          .getAuthorities().iterator().next().getAuthority());
    };
  }

SecurityConfig.java

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

Also, if you want to know more about the SameSite attribute I recommend this blog post:
https://www.netsparker.com/blog/web-security/same-site-cookie-attribute-prevent-cross-site-request-forgery/


One thing you maybe noticed in the code above is that the application encrypts the cookie value. The CryptoService class is responsible for encrypting and decrypting the value with AES-GCM. We do this for two reasons. One reason is to hide information about the internal implementation of our application, and the primary purpose is to prevent simple attacks against our application. Even we set the attribute HttpOnly, which prevents JavaScript to access the cookie, it is not invisible. Every user can open the browser developer console and inspect the cookie.

cookie

If we would send the primary key of the user in plaintext, an attacker could, after analyzing the application in the developer tools, try to find valid values by sending multiple requests with an HTTP client like curl.

   curl -v --cookie "X-authentication=1" http://localhost:8080/message
   curl -v --cookie "X-authentication=2" http://localhost:8080/message
   curl -v --cookie "X-authentication=3" http://localhost:8080/message
   ...

By encrypting the cookie we try to prevent this attack.

The CryptoService stores the key for the AES encryption in the filesystem which is not the best solution especially if you run multiple instances of your application server. All instances need to access the same AES key to encrypt the cookie. In that scenario, you need to store the key in a central place. A possible solution is storing it in a HashiCorp’s Vault instance. Spring provides the Spring Vault library to make the integration into a Spring application very convenient.


Another thing we do in the code above is not only setting the Max-Age cookie attribute but also adding the same expiry timestamp into the cookie value together with the primary key of the user. The idea of this expiry timestamp in the cookie value is to limit the period of validity. This cookie is like a combination of a username and password, and everybody that knows the cookie and sends it with a request to our back end can access the services without any further authentication.

The Max-Age cookie attribute instructs the browser to delete the cookie after a certain amount of time. However, if somebody can gain access to another user's authentication cookie, he can send HTTP requests with the other user's authentication.

curl -v --cookie "X-authentication=nFGbyuFPY+75URsrcenzpdjLzUo7NrjmLqIK61RCsbbSTIQGK/Ccew==" http://localhost:8080/message

If we don't have an expiry date inside the value of this cookie, it would be valid forever. With the embedded expiry date the cookie will become invalid after a certain amount of time. So it does not prevent such an attack, but at least the cookie expires after a while automatically.


One note to the Max-Age and Expires cookie attributes. These attributes are optional, and when you omit them, the cookie becomes a session cookie. With the attributes, the session is a persistent cookie. The difference is that a persistent cookie survives a browser restart. Although some browsers have a session recovery feature that also restores session cookies and if a user never closes the browser the session cookie stays valid for a long time. So, in my opinion, it's always better to specify a Max-Age, so the cookie automatically expires after a while.


Logout

    .logout()
      .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler())
      .deleteCookies(AuthCookieFilter.COOKIE_NAME)
      .permitAll()

SecurityConfig.java

Like the login handler, the log out success handler sends by default a redirect response after successful logout.
We change it here to send back the status code 200 instead.
Also, we instruct the logout endpoint to delete our custom authentication cookie. There is no additional code needed for the logout functionality. A client application only needs to send a GET request to the /logout endpoint and check the HTTP status code.


AuthCookieFilter

Next, we implement the counterpart to the login success handler. A filter that is called for every HTTP request and extracts the authentication cookie reads the user from the database and creates a proper authentication object for Spring Security.

  @Override
  public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
      FilterChain filterChain) throws IOException, ServletException {
    HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;

    Cookie[] cookies = httpServletRequest.getCookies();
    if (cookies != null) {
      for (Cookie cookie : cookies) {
        if (cookie.getName().equals(COOKIE_NAME)) {
          String decryptedCookieValue = this.cryptoService.decrypt(cookie.getValue());
          if (decryptedCookieValue != null) {
            int colonPos = decryptedCookieValue.indexOf(':');
            String appUserIdString = decryptedCookieValue.substring(0, colonPos);
            long expiresAtEpochSeconds = Long
                .valueOf(decryptedCookieValue.substring(colonPos + 1));

            if (Instant.now().getEpochSecond() < expiresAtEpochSeconds) {
              try {
                AppUserRecord appUserRecord = this.dsl.selectFrom(APP_USER)
                    .where(APP_USER.ID.eq(Long.valueOf(appUserIdString))).fetchOne();
                if (appUserRecord != null) {
                  JooqUserDetails userDetails = new JooqUserDetails(appUserRecord);
                  this.userDetailsChecker.check(userDetails);

                  SecurityContextHolder.getContext().setAuthentication(
                      new UsernamePasswordAuthenticationToken(userDetails, null,
                          userDetails.getAuthorities()));
                }
              }
              catch (UsernameNotFoundException | LockedException | DisabledException
                  | AccountExpiredException | CredentialsExpiredException e) {
                // ignore this
              }
            }
          }
        }
      }
    }

    filterChain.doFilter(servletRequest, servletResponse);
  }

AuthCookieFilter.java

The filter looks for a cookie that contains our custom authentication token and decrypts the value of this token with the help of the CryptoService class.
Next, it checks the validity with the embedded expiry date. If the cookie is still valid, the code creates a database request to fetch the user with the primary key.

If the user exists a new JooqUserDetails instance is created and then checked with the AccountStatusUserDetailsChecker. This checks if the account is not locked, enabled, not expired and credentials are not expired. This demo application only implements the enabled flag.

If the check is okay a UsernamePasswordAuthenticationToken is instantiated and set into the current SecurityContextHolder.

Although we store no login state on the server, by reading the user for each HTTP request and running it through the AccountStatusUserDetailsChecker, we can immediately block a user by setting the enabled flag to false in the database.

Note that this filter poses a bottleneck because every incoming HTTP request runs through this code. If you expect many requests with a high frequency, you should think about caching the database call. If you add caching you should not cache it for too long, because then we lose the ability to disable a user immediately. Depending on the load and frequency of the requests, it might already improve performance if the cache only stores the user object for a few minutes (for example 5).

Lastly, we configure this filter in the main security configuration. Here we need to make sure that this filter runs before the UsernamePasswordAuthenticationFilter.

        .addFilterBefore(this.authCookieFilter, UsernamePasswordAuthenticationFilter.class);

SecurityConfig.java

This concludes the server side setup for the stateless authentication. This setup should work with any client-side JavaScript framework, but it is specifically targeting Single Page Applications. If you write a Multiple-page application, you need to look at the logout, login and exception handlers and maybe send redirects (HTTP Status Code 30x) instead of just 200 and 401 status codes.

Client

The demo client application is written with Angular and Ionic 4.
In this section, I show 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. The isLoggedIn() method checks if the client either already called the /authenticate or /login endpoint.

@Injectable({
  providedIn: 'root'
})
export class AuthGuard implements CanActivate {

  constructor(private readonly authService: AuthService,
              private readonly router: Router) {
  }

  canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot):
    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

This request is handled by the AuthController and sends back either an HTTP status code of 401. 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 authority of the user 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

To log out a user the client only has to send a GET request to /logout. 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 now restart the Spring Boot application. Refresh the page in the browser, and you should still be logged in.


Proxy

The application needed one particular configuration because we use SameSite=Strict cookies. If we load the client from localhost:8100 and from the client application 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, host name and port number). Fortunately, in an Angular CLI project, this is easy to solve with a proxy configuration.

Create a JSON file in the root of the project with an arbitrary name and add all the URLs you want to internally 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

            "proxyConfig": "proxy.conf.json"
          },
          "configurations": {
            "production": {
              "browserTarget": "app:build: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.