Stateless authentication with Spring Boot
Introduction
Stateless authentication means we don’t store a user’s authentication state on the server (no session). Why might we want that? For example, when serving your app behind a load balancer without sticky sessions, users can be routed to different backend servers without re-authenticating. Another use case is a REST API where we don’t want to rely on Basic Auth.
The core idea: a user logs in, the server returns a token, and the client sends that token with each request to prove authentication.
You can send the token in a request header (great for APIs) or as a cookie after a successful login (browsers will send it automatically).
What should we store to prove the user has logged in, and where do we store it? We need a token that’s verifiable (valid, untampered), can expire, and is reasonably small.
JSON Web Tokens (JWT) provide exactly this. Read more at Wikipedia or jwt.io.
In this implementation, we store the JWT in a cookie after login. For security, we set the cookie as httpOnly and, if your site uses HTTPS (it should), also set the secure flag. That ensures it’s sent only over HTTPS and hidden from client-side JavaScript. Combined with JWT’s properties, this gives us a solid baseline.
Let’s start
Like in the last article, there’s a new repo on gitlab.com. You can check out the project here.
Basics
We build on the previous example and add the 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, 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");
}
}
Now start your application and open http://localhost:8080 — you should be redirected to the login page. Enter “admin” in both fields and log in. In your browser’s developer tools you’ll see a cookie named JSESSIONID. That’s what we’re replacing.
Going stateless
Next, adjust SecurityConfiguration. First, tell Spring Security not to store session information by switching to stateless mode. Since Spring Security usually stores authentication in a session, you won’t be able to log in without some extra work.
We’ll add a custom JWTSecurityContextRepository to save the authentication in a signed JWT cookie. Then enable cookie-based storage for CSRF protection using CookieCsrfTokenRepository.
@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 JWTSecurityContextRepository implementation is straightforward and builds on HttpSessionSecurityContextRepository. This lets us implement one class and reuse Spring Security features like invalidating the CSRF token on logout.
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.