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.
auth
jwt
redis
spring-boot
java
software
backend
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'sWebSecurityConfigurerAdapter
, I get to use thehttpSecurity.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 theX-Auth-Token
header instead of theAuthorization
header, I need to set up a customBearerTokenResolver
, which requires me to add a dependency on thespring-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 customJwtAuthenticationConverter
, which requires me to add a dependency on thespring-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:
- Java 14
- Maven
- Docker Compose
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.
- The
/user
endpoint (which requires theUSER
role) is meant for end-users; they will be expected to authenticate with session tokens which are validated through Redis - The
/admin
endpoint (which requires theADMIN
role) is meant for company admins; they will be expected to authenticate with Basic Auth credentials which are compared against in-memory configuration values - The
/system
endpoint (which requires theSYSTEM
role) is meant for other microservices in the system cluster; they will be expected to authenticate using JWTs (JSON Web Tokens) which are validated with the (symmetric) signing key
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.
With this set up, let's start out by setting up the main class for orchestrating our authentication logic and security configuration.
As you can see, I've added some basic configuration to make sure that:
- the
@PreAuthorize("hasRole('*')")
annotation can be used on controller endpoints - a new
javax.servlet.Filter
(implemented as a method reference) is inserted at a certain position in the filter chain - authentication is, by default, required on all endpoints
- the application does not send any
JSESSIONID
cookies to the browser
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.
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
.
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.
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.
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
.
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 asBasic <token>
, wheretoken
=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.
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
.
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.
This means we're now able to access the /admin
endpoint,
assuming we've supplied the correct credentials from our configuration file.
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.
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.
With this setup, you should be prompted with a login modal when trying to visit http://localhost:8080/admin.
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.
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.
Whereas the other endpoints should yield the forbidden status code.