Warning
This article was automatically translated by OpenAI (gpt-4o). It may be edited eventually, but please be aware that it may contain incorrect information at this time.
I will take notes on how to implement two-factor authentication (2FA) with Spring Security.
Since this implementation is limited to two factors, I will explicitly refer to it as 2FA rather than multi-factor authentication (MFA).
When you Google "Spring Security 2FA," you will find the following two examples:
- https://www.baeldung.com/spring-security-two-factor-authentication-with-soft-token
- https://www.javadevjournal.com/spring-security/two-factor-authentication-with-spring-security/
Both implement 2FA using TOTP. However, they require the authentication code to be entered within the login form.
What I want to implement is:
- Only enter the username and password in the login form
- After successfully logging in with the username and password, if 2FA is enabled, display the authentication code (TOTP) input form
This flow cannot be achieved with the above implementation methods.
There is an official sample maintained by the Spring Security team that implements this flow.
https://github.com/spring-projects/spring-security-samples/blob/main/servlet/spring-boot/java/authentication/username-password/mfa
This sample is an example of MFA.
A sample that implements the above flow based on this sample can be found at
https://github.com/making/demo-two-factor-authentication/tree/main
Walkthrough of the Sample App
First, let's walk through the sample app.
Access http://localhost:8080/signup and register an account.
2FA is disabled by default.
Log out.
Log in again.
Since 2FA is disabled, you can log in successfully with just the username and password.
Enable 2FA.
Use Google Authenticator to scan the QR code.
Check the code.
Enter the code and press the verify button.
2FA is now enabled.
Log out.
Log in again.
This time, since 2FA is enabled, you will be prompted to enter the code.
Check the code with Google Authenticator.
Enter the code and press the verify button.
Login was successful.
Explanation of the Implementation
The definition of SecurityFilterChain
is as follows.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http,
AuthenticationSuccessHandler primarySuccessHandler) throws Exception {
return http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/signup", "/error").permitAll()
.requestMatchers("/challenge/totp").access(new TwoFactorAuthorizationManager())
.anyRequest().authenticated())
.formLogin(form -> form
.successHandler(new TwoFactorAuthenticationSuccessHandler("/challenge/totp", primarySuccessHandler)))
.securityContext(securityContext -> securityContext.requireExplicitSave(false))
.build();
}
The key point is the TwoFactorAuthenticationSuccessHandler
set in the successHandler
of formLogin
.
This class is responsible for "displaying the authentication code (TOTP) input form if 2FA is enabled after successfully logging in with the username and password."
The implementation is as follows.
public class TwoFactorAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private final AuthenticationSuccessHandler primarySuccessHandler;
private final AuthenticationSuccessHandler secondarySuccessHandler;
public TwoFactorAuthenticationSuccessHandler(String secondAuthUrl,
AuthenticationSuccessHandler primarySuccessHandler) {
this.primarySuccessHandler = primarySuccessHandler;
this.secondarySuccessHandler = new SimpleUrlAuthenticationSuccessHandler(secondAuthUrl);
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
AccountUserDetails accountUserDetails = (AccountUserDetails) authentication.getPrincipal();
Account account = accountUserDetails.getAccount();
if (account.twoFactorEnabled()) {
SecurityContextHolder.getContext().setAuthentication(new TwoFactorAuthentication(authentication));
this.secondarySuccessHandler.onAuthenticationSuccess(request, response, authentication);
}
else {
this.primarySuccessHandler.onAuthenticationSuccess(request, response, authentication);
}
}
}
The primarySuccessHandler
is the same as the default AuthenticationSuccessHandler
(SavedRequestAwareAuthenticationSuccessHandler
). The secondarySuccessHandler
is an AuthenticationSuccessHandler
that redirects to the specified URL (in this case, /challenge/totp
) upon successful login.
When the username and password are entered in the login form and authentication is successful, the onAuthenticationSuccess
method of TwoFactorAuthenticationSuccessHandler
is called.
As you can see from this method, if 2FA is disabled for the authenticated account, the process is delegated to the primarySuccessHandler
. In other words, it proceeds as if 2FA is not used.
If 2FA is enabled, TwoFactorAuthentication
is set in the SecurityContext
, and the process is delegated to the secondarySuccessHandler
. As a result, it redirects to /challenge/totp
.
TwoFactorAuthentication
is implemented as follows.
public class TwoFactorAuthentication extends AbstractAuthenticationToken {
private final Authentication primary;
public TwoFactorAuthentication(Authentication primary) {
super(List.of());
this.primary = primary;
}
// omitted
@Override
public boolean isAuthenticated() {
return false;
}
public Authentication getPrimary() {
return this.primary;
}
}
It wraps the Authentication
object created through the actual authentication process (implementation is UsernamePasswordAuthenticationToken
), but isAuthenticated
returns false
. In other words, it is in an unauthenticated state.
If 2FA is enabled, even if the login with the username and password is successful, it will not be in an "authenticated" state, so it will not be authorized for anyRequest().authenticated()
.
On the other hand, the next destination /challenge/totp
needs to be authorized, so the setting .requestMatchers("/challenge/totp").access(new TwoFactorAuthorizationManager())
is used.
The implementation of TwoFactorAuthorizationManager
is as follows.
public class TwoFactorAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> {
@Override
public AuthorizationDecision check(Supplier<Authentication> authentication, RequestAuthorizationContext object) {
return new AuthorizationDecision(authentication.get() instanceof TwoFactorAuthentication);
}
}
It only checks whether the target Authentication
object is TwoFactorAuthentication
. Therefore, it is authorized when redirected to /challenge/totp
by TwoFactorAuthenticationSuccessHandler
.
The Controller for /challenge/totp
is as follows.
@Controller
public class TwoFactorAuthController {
private final TwoFactorAuthenticationCodeVerifier codeVerifier;
private final AuthenticationSuccessHandler successHandler;
private final AuthenticationFailureHandler failureHandler;
// omitted
@GetMapping(path = "/challenge/totp")
public String requestTotp() {
return "totp";
}
@PostMapping(path = "/challenge/totp")
public void processTotp(@RequestParam String code, TwoFactorAuthentication authentication,
HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
Authentication primaryAuthentication = authentication.getPrimary();
AccountUserDetails accountUserDetails = (AccountUserDetails) primaryAuthentication.getPrincipal();
Account account = accountUserDetails.getAccount();
if (this.codeVerifier.verify(account, code)) {
SecurityContextHolder.getContext().setAuthentication(primaryAuthentication);
this.successHandler.onAuthenticationSuccess(request, response, primaryAuthentication);
}
else {
this.failureHandler.onAuthenticationFailure(request, response, new BadCredentialsException("Invalid code"));
}
}
}
GET /challenge/totp
only displays the form to enter the code. For POST /challenge/totp
, it verifies the TOTP code with TwoFactorAuthenticationCodeVerifier
.
If the code is valid, it sets the original authenticated Authentication
in the SecurityContext
and processes the success with the default AuthenticationSuccessHandler
.
If the code is not valid, it processes the login failure with the default AuthenticationFailureHandler
.
The generation and verification of the TOTP secret, as well as the generation of the QR code, are omitted in this article. Please check the source code on GitHub.
[Note] From Spring Security 6, simply using
SecurityContextHolder.getContext().setAuthentication(...)
no longer saves the session state by default, and you need to explicitly save the context.
Although there are disadvantages, we setsecurityContext.requireExplicitSave(false)
so that explicit context saving is not necessary in this case.
https://docs.spring.io/spring-security/reference/migration/servlet/session-management.html#_require_explicit_saving_of_securitycontextrepository