Multi-Factor Authentication with Spring Security

Posted on Aug 17, 2023 in
Reading time: 15 minutes

Intro

This is the long overdue follow-up to my TOTP tutorial. Using the latest and greatest Spring Boot and Spring Security, I show you how to implement Multi-Factor Authentication (MFA) based on time-based one-time passwords in this tutorial.

Danger Zone

Security is a sensible topic and I am going to disable/override some sensible defaults within Spring Security. Everything I describe below is for educational purposes only and should not be used in production without further research. Treat the code as a proof-of-concept and not as a production-ready solution.

🍿 Watch on YouTube or get the code from GitHub

Architecture

Before we dive right in, let’s take a moment to recap how Spring Security operates so I can then sho you how my approach fits into that architecture.

Spring Security Architecture Overview

Spring Security is a (servlet) filter-based security framework. During runtime, there is a chain of filters that are applied to every request in a pre-defined sequence. Once a filter finishes, it delegates to the next filter in the chain.

What we are interested in in this tutorial are the authentication filters. These filters convert an authentication request into an authentication object. This object represents an authentication attempt, so it is not considered authenticated initially.

For that, the filter delegates to an authentication manager, which acts as a unified interface for all things authentication. The manager itself maintains a collection of authentication providers which implement the actual authentication logic.

Typically, there is a provider for each authentication mechanism, e.g. one for username/password authentication, one for JWT authentication, and so on. The manager delegates to each provider in turn until one of them returns a successful authentication object.

This is where you will also see implementations of the UserDetailsService and a PasswordEncoder - these two usually go together and are used to load a user from a database and to encode/verify passwords.

So let’s look into how this tutorial fits into that architecture:

Spring MFA Architecture Overview

MFA should be optional, either a user has enabled it or not. Either way, we need to authenticate the user first and then check if the user requires MFA. The initial authentication will be done using basic authentication and the respective machinery of filters, managers, and providers.

As you can see in the image, once a user has been authenticated successfully, the authenticated authentication object is stored in the SecurityContext.

At this point we will place a new authentication filter in the chain, directly after the basic authentication filter has finishes its work. Our filter will convert the request into an authentication object - for which we will introduce a dedicated class.

The authentication object is passed to the authentication manager, which in turn will delegate to an authentication provider that we will create. This provider will check the provide one-time password against the MFA device of the user and if it matches, the user is granted access.

With that said..

Let’s Code

When we create a new project and bring in the dependency for Spring Security, it will be configured with some sane defaults already. Upon starting the application we can see that there is a password generated for the default user, named “user”.

We start with some boilerplate code, by adding a controller UserController that is handling GET requests at /users/me and will simply return the current Principal. (In a real-life application you should never expose the naked principal object since it might leak sensitive details!).

@RestController
@RequestMapping("/users")
class UserController {
// PIPO: Principal In, Principal Out
@GetMapping("/me")
fun me(principal: Principal): Principal = principal
}

For this to work, we must require authentication for that endpoint. This is where we create a SecurityConfig to manage everything related to security.

@Configuration
@EnableWebSecurity(debug = true)
class SecurityConfig {
 
@Bean
fun chain(http: HttpSecurity): SecurityFilterChain {
return http
.authorizeHttpRequests { it.anyRequest().authenticated() }
.httpBasic(Customizer.withDefaults())
.build()
}
}

This setup does a few things, it

  • switches to debug mode, which enables us to see incoming request data and the current filter chain,
  • requires authentication for all requests, and
  • enables basic authentication.

We can now send a request to the /users/me endpoint and see that we are getting a 401 Unauthorized response. If we add the basic authentication header, we can see that the request is now successful (replace with the password that is generated for you):

http -a user:ebed822e-3f3c-4e86-818d-3b1405100235 :8080/users/me

With the authentication working, we can now build the MFA functionality. We start by creating a OneTimePasswordDevice interface. There are multiple means of MFA, e.g. using SMS, email, YubiKey, or Google Authenticator. We will implement the latter.

interface OneTimePasswordDevice {
val id: UUID
val name: String
 
fun secret(): String
fun confirm(code: String): Boolean
fun confirmed(): Boolean
fun accepts(code: String): Boolean
}

Each device has a UUID so we can identify it globally (in case we want to store them in a database). It also has a name, that is provided by the user to identify the device (i.e. ‘My Google Authenticator’).

Then there are methods for the secret, which we have to show the user so they can add it to their authenticator app, and a method to confirm a code. This is the method that will be called by the authentication provider to verify the code that the user has entered.

Here is the implementation for the GoogleAuthenticatorDevice, (the GoogleAuthenticator is provided by the TOTP library we are using):

class GoogleAuthenticatorDevice(
override val id: UUID = UUID.randomUUID(),
override val name: String,
private val secret: String = GoogleAuthenticator.createRandomSecret(),
private var confirmed: Boolean = false) : OneTimePasswordDevice {
 
private val authenticator = GoogleAuthenticator(secret)
 
override fun confirm(code: String): Boolean {
if (accepts(code)) {
confirmed = true
}
 
return confirmed()
}
 
override fun confirmed(): Boolean = confirmed
 
override fun accepts(code: String): Boolean {
return authenticator.generate() == code
}
 
override fun secret(): String = secret
 
}

Next up is our implementation of the UserDetails. This interface describes a user within Spring Security. But since it is not aware of a MFA device, we need to create our own implementation. We can use a neat feature of Kotlin here and delegate to an existing implementation for most of the methods.

class TokenUser(private val details: UserDetails) : UserDetails by details {
 
var device: OneTimePasswordDevice? = null
 
override fun toString(): String = details.username
 
fun requiresMfa(): Boolean {
return device?.confirmed() == true
}
 
}

Our TokenUser expects an existing implementation of UserDetails and uses delegation to forward all calls to that implementation. Since our TokenUser itself implements UserDetails we are required to implement each method of that interface, but not with delegation in place.

All we have to do is to add a device property to the TokenUser and implement the requiresMfa() method, which checks if the device is confirmed. And that’s it!

The next steps are adding our own implementation of the UserDetailsService along with a specific PasswordEncoder

@Component
class UserService(private val encoder: PasswordEncoder) : UserDetailsService {
 
private val users = mutableMapOf<String, TokenUser>()
 
override fun loadUserByUsername(username: String): UserDetails = byUsername(username)
 
fun byUsername(username: String): TokenUser =
users[username] ?: throw IllegalArgumentException("User $username not found")
 
fun register(username: String, password: String) {
val user = User
.withUsername(username)
.password(encoder.encode(password))
.authorities("root")
.accountExpired(false)
.disabled(false)
.build()
 
users[username] = TokenUser(user)
}
 
fun attachDevice(username: String, name: String): OneTimePasswordDevice {
val user = byUsername(username)
val device = GoogleAuthenticatorDevice(name = name)
user.device = device
 
return device
}
 
fun confirmDevice(username: String, code: String): OneTimePasswordDevice {
val user = byUsername(username)
val device = user.device ?: throw IllegalStateException("No device attached")
device.confirm(code)
 
return device
}
 
fun attachConfirmedDevice(username: String, name: String, secret: String): OneTimePasswordDevice {
val user = byUsername(username)
val device = GoogleAuthenticatorDevice(name = name, secret = secret, confirmed = true)
user.device = device
 
return device
}
 
}

A lot is going on here, so let’s go through it step by step. First, we have a map of users, which we use to store the users in memory. In a real-life application, you would probably use a database here.

We need to implement loadUserByUsernamefrom the UserDetailsService and we delegate to our own byUsername method which returns a TokenUser for the given username.

The register function creates a new user, using the builder pattern provided by Spring Security. We are using the PasswordEncoder to encode the password and then store the user in our map.

We also add a bunch of functions to attach and confirm user devices. The way this works is that we create a new device and attach it to the user. But we want to make sure that the user really has access to it, so they usually scan a QR code that includes the secret and then can generate codes. So we require a correct code to confirm the device.

Now we also need to provide a PasswordEncoder ourselves. We are using the BCryptPasswordEncoder and add it to the context as part of our SecurityConfig.

@Configuration
@EnableWebSecurity(debug = true)
class SecurityConfig {
 
@Bean
fun chain(http: HttpSecurity): SecurityFilterChain {
return http
.authorizeHttpRequests { it.anyRequest().authenticated() }
.httpBasic(Customizer.withDefaults())
.build()
}
 
@Bean
fun encoder(): PasswordEncoder = BCryptPasswordEncoder()
 
}

At this point, Spring Security no longer generates a default user for us, so we can use the UserService and generate one when the application starts. Here is how:

@SpringBootApplication
class App {
 
@Bean
fun run(users: UserService) = CommandLineRunner {
val username = "hello@axgr.dev"
users.register(username, "swordfish")
users.attachConfirmedDevice(username, "Google Authenticator", "AKMW3WXXWBHAMAHC")
}
 
}
 
fun main(args: Array<String>) {
runApplication<App>(*args)
}

We create a new user with the username hello@axgr.dev and password swordfish. We also attach and confirm an MFA device with a pre-defined secret (so we can use it in our authenticator app). After another restart of the application, we can log in as follows.

http -a hello@axgr.dev:swordfish :8080/users/me

The entry point to our authentication mechanism is a dedicated filter, the OneTimePasswordAuthFilter. It’s purpose is to convert the incoming request into an authentication object and delegate the actual authentication to a dedicated provider.

Here is the initial version of the filter.

class OneTimePasswordAuthFilter() : OncePerRequestFilter() {
 
 
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) {
chain.doFilter(request, response)
}
}

As you can see, it is not doing much. It simply delegates to the next filter in the chain. We are extending OncePerRequestFilter to make sure that our filter is also invoked once per request. Otherwise it may happen that the filter is invoked multiple times for a single request, which we don’t want.

This filter can now be added to the filter chain, directly after the basic authentication filter has finished it’s work. For this we update the security config as follows.

@Configuration
@EnableWebSecurity(debug = true)
class SecurityConfig {
 
@Bean
fun chain(http: HttpSecurity, users: UserService): SecurityFilterChain {
return http
.authorizeHttpRequests { it.anyRequest().authenticated() }
.httpBasic(Customizer.withDefaults())
 
.addFilterAfter(
OneTimePasswordAuthFilter(),
BasicAuthenticationFilter::class.java)
 
.build()
}
 
@Bean
fun encoder(): PasswordEncoder = BCryptPasswordEncoder()
}

As you can see, I have also used @EnableWebSecurity(debug = true), another setting that you should never use in production. But during development it allows us to peek at the actual filter chain. It should look something like this.

Security filter chain: [
DisableEncodeUrlFilter
WebAsyncManagerIntegrationFilter
SecurityContextHolderFilter
HeaderWriterFilter
LogoutFilter
 
BasicAuthenticationFilter
OneTimePasswordAuthFilter
 
RequestCacheAwareFilter
SecurityContextHolderAwareRequestFilter
AnonymousAuthenticationFilter
SessionManagementFilter
ExceptionTranslationFilter
AuthorizationFilter
]

This tells us, that our OneTimePasswordAuthFilter is indeed invoked after the BasicAuthenticationFilter. With that in place, we will build our own implementation of the Authentication interface. It is called OneTimePassword and it, again, decorates an existing authentication. Similar to how we did it with the UserDetails. This is because we want to preserve the existing (basic) authentication and only add the one-time password to it.

class OneTimePassword(val initial: Authentication, val code: String) : Authentication by initial {
 
override fun getCredentials(): Any {
return this.code
}
}

Not a lot to see here, we simply delegate to the existing authentication and add the code as the credentials. This is important because the authentication provider will use the credentials to verify the code.

Speaking of it, here is the OneTimePasswordAuthProvider that implements the actual authentication logic.

@Component
class OneTimePasswordAuthProvider(val users: UserDetailsService) : AuthenticationProvider {
 
override fun authenticate(authentication: Authentication): Authentication {
val auth = authentication as OneTimePassword
val user = users.loadUserByUsername(auth.name) as TokenUser
val device = user.device ?: throw IllegalStateException("No device attached")
 
if (user.requiresMfa()) {
if (!device.accepts(auth.credentials.toString())) {
throw BadCredentialsException("Incorrect OTP!")
}
}
 
return auth.initial
}
 
 
override fun supports(authentication: Class<*>?): Boolean {
return authentication == OneTimePassword::class.java
}
}

The authentication manager “asks” each available provider whether they can handle a specific type of authentication. Our provider is only able to handle the OneTimePassword authentication that we have just created. We can safely assume that the responsible authentication manager will only delegate to our provider if it is able to handle the authentication.

This is why we cast the Authentication to a OneTimePassword immediately. We look up the user, based on the existing authentication and check whether MFA is required. If so, we check whether their device accepts the provided code and raise an exception otherwise. This exception will be translated to an HTTP 401 response.

If you run the application now you will realize that authentication no longer works..

This is because some of the defaults that Spring Security provides are no longer in place because we added an AuthenticationProvider to the context (using @Component). Doing so, automagically removes the DaoAuthenticationProvider which is the authentication provider that handles username-password authentication. So let’s add it back in:

@Configuration
@EnableWebSecurity(debug = true)
class SecurityConfig {
 
@Bean
fun chain(http: HttpSecurity, users: UserService): SecurityFilterChain {
return http
.authorizeHttpRequests { it.anyRequest().authenticated() }
.httpBasic(Customizer.withDefaults())
.addFilterAfter(
OneTimePasswordAuthFilter(),
BasicAuthenticationFilter::class.java)
 
.authenticationProvider(DaoAuthenticationProvider().apply {
setUserDetailsService(users)
setPasswordEncoder(encoder())
})
 
.build()
}
 
@Bean
fun encoder(): PasswordEncoder = BCryptPasswordEncoder()
}

We also need to add our UserService as a dependency since DaoAuthenticationProvider requires a UserDetailsService to be set. We also need to set the PasswordEncoder that we created earlier. With this change, everything should be back to normal.

All that is left, is writing the glue code that connects our filter (and the request) with the authentication provider. We start by adding a little helper here, that is converting a request to our OneTimePassword authentication.

class OneTimePasswordConverter(private val initial: Authentication) : AuthenticationConverter {
 
companion object {
private const val OTP_HEADER_NAME = "otp"
}
 
override fun convert(request: HttpServletRequest): OneTimePassword? {
val otp = request.getHeader(OTP_HEADER_NAME)
 
if (otp == null || otp.isBlank()) {
return null
}
 
return OneTimePassword(initial, otp)
}
 
}

We expect clients to submit a valid MFA code in the otp header. We are using the AuthenticationConverter interface to convert the request to an authentication object. This is the same interface that the BasicAuthenticationFilter uses to convert the request to a UsernamePasswordAuthenticationToken. The converter also needs access to the existing authentication so we can decorate it with the one-time password.

The OneTimePasswordFilter can now use the converter and we can fill in the missing code:

class OneTimePasswordAuthFilter(private val manager: AuthenticationManager, private val users: UserDetailsService) : OncePerRequestFilter() {
 
companion object {
private val log = LoggerFactory.getLogger(OneTimePasswordAuthFilter::class.java)
}
 
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) {
val initial = SecurityContextHolder.getContext().authentication
 
if (initial.isAuthenticated) {
val user = users.loadUserByUsername(initial.name) as TokenUser
if (user.requiresMfa()) {
val otp = OneTimePasswordConverter(initial).convert(request)
 
if (otp == null || otp.code.isBlank()) {
log.info("No OTP code provided.")
response.status = HttpServletResponse.SC_UNAUTHORIZED
response.setHeader("WWW-Authenticate", "OTP")
return
}
 
try {
val authed = manager.authenticate(otp)
SecurityContextHolder.getContext().authentication = authed
} catch (cause: AuthenticationException) {
response.status = HttpServletResponse.SC_UNAUTHORIZED
return
}
} else {
log.info("User ${initial.name} doesn't require a token.")
}
}
 
chain.doFilter(request, response)
}
}

The filter needs access to the AuthenticationManager so it can pass the authentication to it (which in turn delegates it to our authentication provider). The filter grabs the existing authentication from the security context and double-checks that the request is authenticated.

It then loads the respective user and also checks whether MFA is required. If that is the case, it converts the request to a OneTimePassword authentication and if that fails, it returns a 401 Unauthorized response.

Our backend needs a way to tell clients about the missing one-time password code. Because at this point, the client has already successfully authenticated, so just returning a 401 is not enough. We need to tell the client that they need to provide a one-time password.

We do this by setting the WWW-Authenticate header to the value OTP. This is not a standardized scheme to use, but will do the trick so clients can respond to the header and prompt users to enter a code from their MFA app of choice.

If everything goes well, we (re-)set the authentication in the security context and delegate to the next filter in the chain. Note that we are setting the initial authentication, and not our OneTimePassword since the latter is only used for the authentication provider and not relevant for the rest of the application.

The final step is providing the AuthenticationManager to our filter. If you followed the tutorial, then chances are, Spring complains about not having an authentication manager available.

I am not sure if this is by design, but the only way I’ve made this work is by adjusting the SecurityConfig as follows:

@Configuration
@EnableWebSecurity(debug = true)
class SecurityConfig {
 
@Bean
fun chain(http: HttpSecurity, config: AuthenticationConfiguration, users: UserService): SecurityFilterChain {
 
return http
.authorizeHttpRequests { it.anyRequest().authenticated() }
.httpBasic(Customizer.withDefaults())
.addFilterAfter(
 
OneTimePasswordAuthFilter(config.authenticationManager, users),
 
BasicAuthenticationFilter::class.java)
.authenticationProvider(DaoAuthenticationProvider().apply {
setUserDetailsService(users)
setPasswordEncoder(encoder())
})
.build()
}
 
@Bean
fun encoder(): PasswordEncoder = BCryptPasswordEncoder()
}

We let Spring inject the AuthenticationConfiguration which has the authentication manager so we can manually pass it to our filter. With that in place, we can now run the application and test the MFA functionality 🎉

The Dead Letter Queue

Love what you're seeing? By subscribing to my newsletter, not only will you be the first to know about fresh tutorials and videos, but you'll also unlock:

Subscribe now and become a part of our growing tech tribe!