Stateless authentication with Spring Boot

Introduction

Stateless authentication means that we don't want to store the authentication state of a user on the server, aka session. Why do we need that? One of the reasons can be serving your application through a load balancer and we do not want to use sticky sessions. This will enable us to transparently direct the user to different backend servers without having the need to reauthenticate him. Another use cases may be a REST Api where we do not want to rely on basic auth.

The basic idea behind a stateless authentication is that a user authenticates against your application, when this is successful the server gives the user a token which then should be send on any request to prove that the user has been successfully authenticated.

To send it we can either use a header field while requesting something from the server. This will mainly be used for Rest API clients. For browser we can add it as a cookie after a successful login and the browser will send it on every request.

But what should we store as prove that the user has logged in? And where should we store it? We need a way to detect that the token is still valid, not expired, nobody tampered with it and it should have a reasonable size.

Exactly this can be achieved by using JWT. We will come back to this later, for the time. Either trust me or read about JWT on Wikipedia or jwt.io

For this implementation we will store our JWT Token in a cookie after successful authentication. For security enhancements we should be cautious how we store the cookie. We should set http-only to true and if your site is served over https, which it should, then we will also set the secure flag. This will only send the cookie over https and will hide it from all client side scripts. This, with the combination of JWT provided security measurements, will give us a nice baseline to work with.

let's start

Like in the last article I added a new repo on gitlab.com. You can check out the project here

basics

We build up on our last example and first add some necessary dependencies to the pom.xml

<!-- adding security to our project -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- adding jjwt to our project -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.0</version>
</dependency>

After that we enable Spring Security by adding a Configuration class.

package com.dedicatedcode;

import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;

//enable Security in your application
@EnableWebSecurity
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                //configure all protected paths
                .authorizeRequests()
                    .antMatchers(HttpMethod.POST, "todo/new").authenticated()
                    .antMatchers(HttpMethod.POST, "todo/delete").authenticated()
                    .antMatchers("/css/**").permitAll()
                    .antMatchers("/js/**").permitAll()
                    .antMatchers("/login.html**").permitAll()
                    .anyRequest().authenticated()
            .and()
                // configure the form login to actually login
                .formLogin()
                    .loginPage("/login.html")
                    .permitAll()
                    .and()
                    .logout()
                    .logoutUrl("/logout");
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        // add some test users
        auth.inMemoryAuthentication().withUser("admin").password("admin").roles("ADMIN");
        auth.inMemoryAuthentication().withUser("user").password("user").roles("USER");
    }
}

After that just start your application and try to open localhost:8080, you should be redirected to the login page. Enter admin in both fields and login. If you take a look in the developer tools of your browser you will find a cookie JSESSIONID. That is the one we have to get rid of.

Screenshot of Google Chrome with JSessionID Cookie

going stateless

Now we will adjust our SecurityConfiguration class. First we have to tell Spring Security that it should not store any session information. This will be achieved by first letting Spring Security know that we want to be stateless. Since Spring Security relies on storing its authentication in a session, you wont be able to login anymore without some extra work. First we add our custom JWTSecurityContextRepository which will save the session information in a JWT signed cookie. The next step is enabling cookie based storage also on the CSRF protection. We can use
CookieCsrfTokenRepository for that.

@Bean
public SecurityContextRepository securityContextRepository(UserDetailsManager userDetailsManager,
                                                          @Value("${auth.token}") String tokenName,
                                                          @Value("${auth.secret}") String tokenSecret) {
    //our custom security context repository
    return new JWTSecurityContextRepository(userDetailsManager, tokenName, tokenSecret);
}

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
            //disable using the session to store login information
            .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            //set our custom security context repository
            .securityContext()
                .securityContextRepository(securityContextRepository)
            .and()
            //set csrf token repository to use a cookie and no session
            .csrf()
                .csrfTokenRepository(new CookieCsrfTokenRepository())
            .and()
    ...

The implementation of the JWTSecurityContextRepository is pretty straight forward and we basically build upon HttpSessionSecurityContextRepository. This way we only have to implement one class and benefit from all the rest in Spring Security. Like automatically invalidating the CSRF token on logout and so on.

package com.dedicatedcode;

import io.jsonwebtoken.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.AuthenticationTrustResolver;
import org.springframework.security.authentication.AuthenticationTrustResolverImpl;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.provisioning.UserDetailsManager;
import org.springframework.security.web.context.HttpRequestResponseHolder;
import org.springframework.security.web.context.SaveContextOnUpdateOrErrorResponseWrapper;
import org.springframework.security.web.context.SecurityContextRepository;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Date;

/**
 * Created by Daniel Wasilew on 30.11.17
 * (c) 2017 Daniel Wasilew <daniel@dedicatedcode.com>
 */
public class JWTSecurityContextRepository implements SecurityContextRepository {
    private static final Logger log = LoggerFactory.getLogger(JWTSecurityContextRepository.class);

    private final UserDetailsManager userDetailsManager;
    private final String tokenName;
    private final String secret;

    JWTSecurityContextRepository(UserDetailsManager userDetailsManager, String tokenName, String secret) {
        this.userDetailsManager = userDetailsManager;
        this.tokenName = tokenName;
        this.secret = secret;
    }

    @Override
    public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
        HttpServletRequest request = requestResponseHolder.getRequest();
        HttpServletResponse response = requestResponseHolder.getResponse();
        requestResponseHolder.setResponse(new SaveToCookieResponseWrapper(request, response, tokenName, secret));
        SecurityContext context = readSecurityContextFromCookie(request);
        if (context == null) {
            return SecurityContextHolder.createEmptyContext();
        }
        return context;
    }

    private SecurityContext readSecurityContextFromCookie(HttpServletRequest request) {
        Cookie[] cookies = request.getCookies();
        if (cookies == null) {
            return null;
        } else {
            for (Cookie cookie : cookies) {
                if (cookie.getName().equals(tokenName)) {
                    try {
                        String username = Jwts.parser().setSigningKey(secret).parse(cookie.getValue(), new JwtHandlerAdapter<String>() {
                            @Override
                            public String onClaimsJws(Jws<Claims> jws) {
                                return jws.getBody().getSubject();
                            }
                        });
                        SecurityContext context = SecurityContextHolder.createEmptyContext();
                        UserDetails userDetails = this.userDetailsManager.loadUserByUsername(username);
                        context.setAuthentication(new UsernamePasswordAuthenticationToken(userDetails.getUsername(), userDetails.getPassword(), userDetails.getAuthorities()));
                        return context;
                    } catch (ExpiredJwtException ex) {
                        log.debug("authentication cookie is expired");
                    } catch (UnsupportedJwtException | MalformedJwtException | SignatureException | IllegalArgumentException e) {
                        log.warn("tampered jwt authentication cookie detected");
                        return null;
                    }
                    System.out.println();

                }
            }
        }
        log.debug("no [{}] found in request.", tokenName);
        return null;
    }

    @Override
    public void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response) {
        SaveToCookieResponseWrapper responseWrapper = (SaveToCookieResponseWrapper) response;
        if (!responseWrapper.isContextSaved()) {
            responseWrapper.saveContext(context);
        }

    }

    @Override
    public boolean containsContext(HttpServletRequest request) {
        return readSecurityContextFromCookie(request) != null;
    }

    private static class SaveToCookieResponseWrapper extends SaveContextOnUpdateOrErrorResponseWrapper {

        private AuthenticationTrustResolver trustResolver = new AuthenticationTrustResolverImpl();

        private final HttpServletRequest request;
        private final String token;
        private final String secret;

        SaveToCookieResponseWrapper(HttpServletRequest request, HttpServletResponse response, String token, String secret) {
            super(response, true);
            this.request = request;
            this.token = token;
            this.secret = secret;
        }

        @Override
        protected void saveContext(SecurityContext securityContext) {
            HttpServletResponse response = (HttpServletResponse) getResponse();
            Authentication authentication = securityContext.getAuthentication();
            if (authentication == null || trustResolver.isAnonymous(authentication)) {
                response.addCookie(createExpireAuthenticationCookie(request));
                return;
            }
            Date expiresAt = new Date(System.currentTimeMillis() + 3600000);
            String jwt = Jwts.builder()
                    .signWith(SignatureAlgorithm.HS512, secret)
                    .setSubject(authentication.getName())
                    .setExpiration(expiresAt).compact();
            response.addCookie(createAuthenticationCookie(jwt));
        }

        private Cookie createAuthenticationCookie(String cookieValue) {
            Cookie authenticationCookie = new Cookie(token, cookieValue);
            authenticationCookie.setPath("/");
            authenticationCookie.setHttpOnly(true);
            authenticationCookie.setSecure(request.isSecure());
            authenticationCookie.setMaxAge(3600000);
            return authenticationCookie;
        }

        private Cookie createExpireAuthenticationCookie(HttpServletRequest request) {
            Cookie removeSessionCookie = new Cookie(token, "");
            removeSessionCookie.setPath("/");
            removeSessionCookie.setMaxAge(0);
            removeSessionCookie.setHttpOnly(true);
            removeSessionCookie.setSecure(request.isSecure());
            return removeSessionCookie;
        }

    }
}

As always, if you have any questions feel free to contact me.