🗓️ May 2020👀  loading

Combining Spring Boot Auth Methods: Redis Sessions, Basic Auth and JWTs

We'll explore three different Spring Boot authentication methods (Redis Sessions, Basic Auth and JWTs), and see how all of these can be enabled within a single application.

Poster for Combining Spring Boot Auth Methods: Redis Sessions, Basic Auth and JWTs
authjwtredisspring-bootjava

My relationship with Spring Security is ... complicated 😬. I like how it offers an extensive security framework and a multitude of auth solutions, but I dislike the documentation and how difficult it can be figuring out how to tailor a particular auth solutions to suit your own needs.

Typical Example. I've included spring-security into my new Java application to validate incoming JWTs. By extending Spring Security's WebSecurityConfigurerAdapter, I get to use the httpSecurity.oauth2ResourceServer() DSL to set up my JWT configuration - so far, so good. However, since my new application hooks into an existing system which is configured to submit JWTs under the X-Auth-Token header instead of the Authorization header, I need to set up a custom BearerTokenResolver, which requires me to add a dependency on the spring-security-oauth2-resource-server module. Also, because existing JWTs in the system adhere to some non-conventional schema, I'm required to provide my own mapping between the JWT and the Spring authentication principal via a custom JwtAuthenticationConverter, which requires me to add a dependency on the spring-security-oauth2-jose module.

The example above shows that, in my personal experience, there's always a lot of manual effort, mapping logic and additional Maven dependencies involved in tailoring a fairly basic Spring Security auth solution. And this example is only about JWTs - if you're looking to support additional authentication methods in the same Spring Boot instance, like Redis Sessions and Basic Authentication, it becomes even more complicated.

That's why, in this article, we'll have a look at the (low-level) javax.servlet.Filter API for handling our authentication logic, with the benefit of maximum customizability and minimal Maven dependencies.

Table of Contents

The Application


You can find the full source code on GitHub. If you would like to follow along, please make sure to have the following tools installed:


We'll be looking at a very minimal Spring Boot application consisting of a simple REST controller with 3 endpoints, each one protected by a different authorization role.

Attempting to access one of these endpoints without any authentication should result in the 401 authentication failed status code. Attempting to access one of these endpoints with the wrong authorization role should result in the 403 forbidden status code.

Quick Reminder. Authentication failed (401) means that the server can't let you access a certain resource because it couldn't figure out who you are. Authorization failed or forbidden (403) means that the server has successfully figured out who you are, but still can't let you access a certain resource because you lack the necessary privileges.

This means that, apart from the Spring framework itself, we'll need a library to connect to Redis and a library to validate JWTs.

<!--Spring-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!--Redis, for authenticating users-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--JWT, for authenticating systems-->
<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>3.10.3</version>
</dependency>
<!--Lombok, because it's great ;)-->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <scope>provided</scope>
</dependency>

With this set up, let's start out by setting up the main class for orchestrating our authentication logic and security configuration.

@EnableGlobalMethodSecurity(prePostEnabled = true)
class SecurityConfiguration extends WebSecurityConfigurerAdapter {
 
    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                // Auth filter
                .addFilterAt(this::authenticationFilter, UsernamePasswordAuthenticationFilter.class)
                // Auth on all endpoints
                .authorizeRequests(conf -> {
                    conf.anyRequest().authenticated();
                })
                // Disable "JSESSIONID" cookies
                .sessionManagement(conf -> {
                    conf.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
                })
    }
 
    private void authenticationFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        // TODO: authenticate the request
        chain.doFilter(request, response);
    }
 
}

As you can see, I've added some basic configuration to make sure that:

The private authenticationFilter method is where we're going to implement all of our custom auth logic. The ServletRequest parameter will allow us to read tokens and credentials from incoming HTTP requests and, if they're valid, allow us to set the SecurityContext authentication object.

Redis Sessions

There are a few ways to go about storing the sessions of end-users, but you should always do it in such a way that they're never coupled to any particular instance of your application.

For this reason, I highly recommend using the blazingly fast Redis database as your external session store, which will allow you to keep your application stateless (and horizontally scale it, if needed).

So let's set up a service for validating whether an incoming authentication token exists in Redis, and, depending on the outcome, construct an Authentication principal with the USER role.

@Slf4j
@Service
@RequiredArgsConstructor
public class AuthServiceRedis {
 
    private static final String BEARER_PREFIX = "Bearer ";
 
    private final RedisTemplate<String, String> redis;
 
    public Optional<Authentication> authenticate(HttpServletRequest request) {
        return extractBearerTokenHeader(request).flatMap(this::lookup);
    }
 
    private Optional<Authentication> lookup(String token) {
        try {
            String userId = this.redis.opsForValue().get(token);
            if (nonNull(userId)) {
                Authentication authentication = createAuthentication(userId, Role.USER);
                return Optional.of(authentication);
            }
            return Optional.empty();
        } catch (Exception e) {
            log.warn("Unknown error while trying to look up Redis token", e);
            return Optional.empty();
        }
    }
 
    private static Optional<String> extractBearerTokenHeader(@NonNull HttpServletRequest request) {
        try {
            String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
            if (nonNull(authorization)) {
                if (authorization.startsWith(BEARER_PREFIX)) {
                    String token = authorization.substring(BEARER_PREFIX.length()).trim();
                    if (!token.isBlank()) {
                        return Optional.of(token);
                    }
                }
            }
            return Optional.empty();
        } catch (Exception e) {
            log.error("An unknown error occurred while trying to extract bearer token", e);
            return Optional.empty();
        }
    }
 
    private static Authentication createAuthentication(String actor, @NonNull Role... roles) {
        // The difference between `hasAuthority` and `hasRole` is that the latter uses the `ROLE_` prefix
        List<GrantedAuthority> authorities = Stream.of(roles)
                .distinct()
                .map(role -> new SimpleGrantedAuthority("ROLE_" + role.name()))
                .collect(toList());
        return new UsernamePasswordAuthenticationToken(nonNull(actor) ? actor : "N/A", "N/A", authorities);
    }
 
    private enum Role {
        USER,
        ADMIN,
        SYSTEM,
    }
 
}

Note that this implementation assumes a rather simple Redis setup where session tokens are stored in the <token> : <userId> key-value format (e.g.: 6b9611669f31f2a9 : jessy). To support multiple session tokens per user (in case of multiple devices, for example), consider using a sorted set instead.

We can now autowire this Redis authentication service into the security configuration class, so it can be utilized by our authenticationFilter.

@RequiredArgsConstructor
@EnableGlobalMethodSecurity(prePostEnabled = true)
class SecurityConfiguration extends WebSecurityConfigurerAdapter {
 
    private final AuthServiceRedis authService;
 
    // ...
 
    private void authenticationFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        Optional<Authentication> authentication = this.authService.authenticate((HttpServletRequest) request);
        authentication.ifPresent(SecurityContextHolder.getContext()::setAuthentication);
        chain.doFilter(request, response);
    }
 
}

This setup should already allow us to access the /user endpoint, provided that we submit a valid bearer token in the Authorization request header. So, the following HTTP request will fail with the authentication failed HTTP response code.

curl -i http://localhost:8080/user
 
HTTP/1.1 401

To make this work, we should store a session token into our local Redis instance, and submit this token in the HTTP request. Simply connect to Redis via the CLI client (see this helper script for Docker) and execute SET 12345 jessy EX 60 to create a new session which invalidates after 60 seconds.

After doing so, the below HTTP request to /user yields the following result.

curl -i http://localhost:8080/user -H 'Authorization: Bearer 12345'
 
HTTP/1.1 200
{"actor":"jessy","endpoint":"USER"}

As expected, attempting to connect to the other endpoints in our application (/admin and /system) with a valid Redis session token results in the forbidden status code, because jessy has only been granted the USER role, and not ADMIN or SYSTEM.

curl -i http://localhost:8080/admin -H 'Authorization: Bearer 12345'
 
HTTP/1.1 403

Basic Auth

I like the Basic Auth scheme because it's one of the easiest ways to protect an application without having to build any login screens. If the browser notices that a particular resource requires Basic Auth, it automatically prompts the user with a login modal for authentication (similar to the window.alert dialog). The UX isn't the best, but it's usually good enough for internal admin pages.

Quick reminder: with Basic Auth, you submit your login credentials under the Authorization HTTP request header as Basic <token>, where token = base64_encode(username + ':' + password)

So let's see how we can grant access to the /admin endpoint with Basic Auth. For this, I will introduce an AuthServiceBasic, conceptually similar to the AuthServiceRedis we already have.

@Slf4j
@Service
class AuthServiceBasic extends AuthService {
 
    private static final PasswordEncoder BCRYPT = new BCryptPasswordEncoder();
 
    private final String username;
    private final String password;
 
    AuthServiceBasic(@Value("${auth.basic.username}") String username, @Value("${auth.basic.password}") String password) {
        this.username = username;
        this.password = BCRYPT.encode(password);
    }
 
    @Override
    public Optional<Authentication> authenticate(HttpServletRequest request) {
        return extractBasicAuthHeader(request).flatMap(this::check);
    }
 
    private Optional<Authentication> check(Credentials credentials) {
        try {
            if (credentials.getUsername().equals(this.username)) {
                if (BCRYPT.matches(credentials.getPassword(), this.password)) {
                    Authentication authentication = createAuthentication(credentials.getUsername(), Role.ADMIN);
                    return Optional.of(authentication);
                }
            }
            return Optional.empty();
        } catch (Exception e) {
            log.warn("Unknown error while trying to check Basic Auth credentials", e);
            return Optional.empty();
        }
    }
 
}

As you can see, we just have a simple method which compares the incoming Basic Auth credentials against the credentials from our application.yaml configuration file. In production, these values should be configured through environment variables.

I've also introduced an abstract AuthService superclass with re-usable methods like extractBearerTokenHeader, extractBasicAuthHeader and createAuthentication.

Hierarchy diagram of the AuthService superclass and its implementations (Redis, Basic and JWT)

To incorporate this new AuthService (and all future Auth Services) into our authenticationFilter, I will update the SecurityConfiguration class to autowire a list of all registered AuthService instances, and iterate through them until the HTTP request has been authenticated.

@RequiredArgsConstructor
@EnableGlobalMethodSecurity(prePostEnabled = true)
class SecurityConfiguration extends WebSecurityConfigurerAdapter {
 
    private final List<AuthService> authServices;
 
    // ...
 
    private void authenticationFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        Optional<Authentication> authentication = this.authenticate((HttpServletRequest) request);
        authentication.ifPresent(SecurityContextHolder.getContext()::setAuthentication);
        chain.doFilter(request, response);
    }
 
    private Optional<Authentication> authenticate(HttpServletRequest request) {
        for (AuthService authService : this.authServices) {
            Optional<Authentication> authentication = authService.authenticate(request);
            if (authentication.isPresent()) {
                return authentication;
            }
        }
        return Optional.empty();
    }
 
}

This means we're now able to access the /admin endpoint, assuming we've supplied the correct credentials from our configuration file.

curl -i http://localhost:8080/admin -u webmaster:pass123
 
HTTP/1.1 200
{"actor":"webmaster","endpoint":"ADMIN"}

As expected, we are greeted with the forbidden status code if we try to access one of the other endpoints with our Basic Auth credentials.

curl -i http://localhost:8080/system -u webmaster:pass123
 
HTTP/1.1 403

If you try to visit http://localhost:8080/admin directly from your browser, you're probably greeted by a 401 authentication failed status code. What's interesting, though, is that browsers are actually configured to prompt the user with a dialog for logging in, and to retry the HTTP request with the submitted Basic Auth credentials, if the application were to include the WWW-Authenticate: Basic header in its 401 response.

So let's set up an authenticationFailedHandler in our security configuration class where we can include the aforementioned header in the HTTP response.

@RequiredArgsConstructor
@EnableGlobalMethodSecurity(prePostEnabled = true)
class SecurityConfiguration extends WebSecurityConfigurerAdapter {
 
    // ...
 
    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                // Auth filter
                .addFilterAt(this::authenticationFilter, UsernamePasswordAuthenticationFilter.class)
                // Auth on all endpoints
                .authorizeRequests(conf -> {
                    conf.anyRequest().authenticated();
                })
                // Disable "JSESSIONID" cookies
                .sessionManagement(conf -> {
                    conf.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
                })
                // Exception handling
                .exceptionHandling(conf -> {
                    conf.authenticationEntryPoint(this::authenticationFailedHandler);
                });
    }
 
    // ...
 
    private void authenticationFailedHandler(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) {
        // Trigger the browser to prompt for Basic Auth
        response.setHeader(HttpHeaders.WWW_AUTHENTICATE, "Basic");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    }
 
}

With this setup, you should be prompted with a login modal when trying to visit http://localhost:8080/admin.

Screenshot of the browser's native Basic Authentication prompt

JWTs

The final authentication scheme we'll have a look at, is JWTs or JSON Web Tokens. I don't like using JWTs for user sessions because, due to their stateless nature, they can't really be revoked in case someone's account is hacked. But I do think they're excellent for authenticating system-to-system communication (in a microservices setup, for example).

Because of the little authentication framework we've set up so far, all we need to do to support JWT authentication, is register a new AuthService instance.

@Slf4j
@Service
class AuthServiceJwt extends AuthService {
 
    private final JWTVerifier jwtVerifier;
 
    AuthServiceJwt(@Value("${auth.jwt.hmacKey}") String hmacKey) {
        Algorithm algo = Algorithm.HMAC256(hmacKey.getBytes(UTF_8));
        this.jwtVerifier = JWT.require(algo).build();
    }
 
    @Override
    public Optional<Authentication> authenticate(HttpServletRequest request) {
        return extractBearerTokenHeader(request).flatMap(this::verify);
    }
 
    private Optional<Authentication> verify(String token) {
        try {
            DecodedJWT jwt = this.jwtVerifier.verify(token);
            String issuer = jwt.getIssuer();
            Authentication authentication = createAuthentication(issuer, Role.SYSTEM);
            return Optional.of(authentication);
        } catch (JWTDecodeException e) {
            return Optional.empty();
        } catch (Exception e) {
            log.warn("Unknown error while trying to verify JWT token", e);
            return Optional.empty();
        }
    }
 
}

As you can see, the implementation is rather straightforward. It checks whether the incoming HTTP requests presents a bearer token, and whether this bearer token represents a valid JWT, i.e.: is signed by the right key.

Use the jwt.io website to easily construct JWTs from your browser.

Attempting to access the /system endpoint with a valid JWT should yield a success response.

token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzb21lLWV4dGVybmFsLXN5c3RlbSJ9.CUaovpMiKURewcqqrc_NjQYGVB6o2DkPglMdihklqhg
curl -i http://localhost:8080/system -H "Authorization: Bearer $token"
 
HTTP/1.1 200
{"actor":"some-external-system","endpoint":"SYSTEM"}

Whereas the other endpoints should yield the forbidden status code.

token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzb21lLWV4dGVybmFsLXN5c3RlbSJ9.CUaovpMiKURewcqqrc_NjQYGVB6o2DkPglMdihklqhg
curl -i http://localhost:8080/user -H "Authorization: Bearer $token"
 
HTTP/1.1 403