IK.AM


Programming > Java > org > springframework > security > web > authentication

How to Implement Two-Factor Authentication (2FA) with Spring Security

Created on Fri Sep 08 2023 • Last Updated on Fri Sep 08 2023N/A Views

🏷️ Java | Spring Boot | Spring Security | 2FA | MFA

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:

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.

image

2FA is disabled by default.

image

Log out.

image

Log in again.

image

Since 2FA is disabled, you can log in successfully with just the username and password.

image

Enable 2FA.

image

Use Google Authenticator to scan the QR code.

Check the code.

Enter the code and press the verify button.

image

2FA is now enabled.

image

Log out.

image

Log in again.

image

This time, since 2FA is enabled, you will be prompted to enter the code.

image

Check the code with Google Authenticator.

Enter the code and press the verify button.

image

Login was successful.

image

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 set securityContext.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

Found a mistake? Update the entry.