Secure Your Apps: Implementing Two-Factor Authentication (2FA) with Spring Security

Secure Your Apps: Implementing Two-Factor Authentication (2FA) with Spring Security

In an age where data breaches are becoming alarmingly common, relying on a simple username and password is like leaving your front door unlocked. Passwords can be stolen, guessed, or phished. To build truly secure applications, we need an additional layer of defense. This is where Two-Factor Authentication (2FA) comes in, and fortunately, implementing it with Spring Security is more accessible than ever.

Two-Factor Authentication adds a critical second step to the login process, requiring users to verify their identity with something they have (like a phone) in addition to something they know (their password). This dramatically increases the security of user accounts.

This comprehensive guide will walk you through, step-by-step, how to implement Time-based One-Time Password (TOTP) 2FA in a Spring Boot application using the power and flexibility of Spring Security. We will cover everything from initial setup and secret key generation to customizing the authentication flow and verifying user codes.

What is Two-Factor Authentication (2FA)?

Two-Factor Authentication is a security process where a user provides two different authentication factors to verify themselves. This multi-factor approach ensures that even if one factor is compromised (like a stolen password), an unauthorized user will still be blocked by the second factor.

There are several types of 2FA, including:

  • SMS/Email Codes: Sending a code to a user’s phone or email.
  • Hardware Tokens: A physical device that generates codes.
  • Biometrics: Using a fingerprint or facial scan.
  • Authenticator Apps (TOTP): Using an app like Google Authenticator or Authy that generates a time-sensitive code.

In this tutorial, we will focus on Time-based One-Time Password (TOTP). It’s a popular and highly secure method that doesn’t rely on potentially insecure channels like SMS. The user scans a QR code once to link their account with an authenticator app, which then generates a new 6-digit code every 30 seconds.

Prerequisites

Before we begin, ensure you have the following tools and knowledge:

  • Java Development Kit (JDK) 17 or later.
  • An IDE like IntelliJ IDEA or Eclipse.
  • Maven or Gradle for dependency management.
  • A basic understanding of Spring Boot, Spring Security, and JPA/Hibernate.
  • An authenticator app on your smartphone (e.g., Google Authenticator, Microsoft Authenticator, or Authy).

The 2FA Authentication Flow

Understanding the login flow is crucial. A standard password-only login is a single step. With 2FA, we introduce an intermediate step. The process looks like this:

  1. A user submits their username and password.
  2. Spring Security validates these credentials against the database.
  3. If credentials are correct: Instead of granting full access immediately, we check if the user has 2FA enabled.
  4. If 2FA is enabled: We mark the user’s session as “partially authenticated” and redirect them to a special verification page. They are not yet fully logged in.
  5. The user opens their authenticator app and enters the current 6-digit code on the verification page.
  6. The application validates this code against the user’s secret key.
  7. If the code is correct: The user’s session is upgraded to “fully authenticated,” and they are granted access to the secured parts of the application.

Setting Up the Project

Dependencies

First, let’s create a new Spring Boot project using the Spring Initializr. Include the following dependencies in your pom.xml:


<dependencies><br>
    <!-- Spring Boot Starters --><br>
    <dependency><br>
        <groupId>org.springframework.boot</groupId><br>
        <artifactId>spring-boot-starter-web</artifactId><br>
    </dependency><br>
    <dependency><br>
        <groupId>org.springframework.boot</groupId><br>
        <artifactId>spring-boot-starter-security</artifactId><br>
    </dependency><br>
    <dependency><br>
        <groupId>org.springframework.boot</groupId><br>
        <artifactId>spring-boot-starter-thymeleaf</artifactId><br>
    </dependency><br>
    <dependency><br>
        <groupId>org.springframework.boot</groupId><br>
        <artifactId>spring-boot-starter-data-jpa</artifactId><br>
    </dependency><br>
    <dependency><br>
        <groupId>com.h2database</groupId><br>
        <artifactId>h2</artifactId><br>
        <scope>runtime</scope><br>
    </dependency><br>
<br>
    <!-- TOTP Library --><br>
    <dependency><br>
        <groupId>dev.samstevens.totp</groupId><br>
        <artifactId>totp-spring-boot-starter</artifactId><br>
        <version>1.7.1</version><br>
    </dependency><br>
<br>
    <!-- QR Code Generation Library --><br>
    <dependency><br>
        <groupId>com.google.zxing</groupId><br>
        <artifactId>javase</artifactId><br>
        <version>3.5.1</version><br>
    </dependency><br>
</dependencies><br>

User Entity

Next, we need to update our User entity to store 2FA-related information. We’ll add a boolean to track if 2FA is enabled and a string to hold the secret key.


@Entity<br>
public class User implements UserDetails {<br>
    // ... existing fields like id, username, password, roles<br>
<br>
    private boolean mfaEnabled;<br>
    private String mfaSecret;<br>
<br>
    // ... getters and setters<br>
}<br>

Core Security Configuration

Let’s start with a basic Spring Security configuration. This will handle standard form-based login. We will modify this later to inject our custom 2FA logic.


@Configuration<br>
@EnableWebSecurity<br>
public class SecurityConfig {<br>
<br>
    @Bean<br>
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {<br>
        http<br>
            .authorizeHttpRequests(auth -> auth<br>
                .requestMatchers("/login", "/register", "/css/**").permitAll()<br>
                .anyRequest().authenticated()<br>
            )<br>
            .formLogin(form -> form<br>
                .loginPage("/login")<br>
                .defaultSuccessUrl("/home", true)<br>
                .permitAll()<br>
            )<br>
            .logout(LogoutConfigurer::permitAll);<br>
        return http.build();<br>
    }<br>
<br>
    // ... PasswordEncoder and UserDetailsService beans<br>
}<br>

Generating the 2FA Secret and QR Code

When a user decides to enable 2FA, we need to generate a unique secret key for them and display it as a QR code for easy scanning.

MFA Service Layer

Create a service to handle the 2FA logic. We’ll inject the SecretGenerator from the `totp-spring-boot-starter` library.


@Service<br>
public class MfaService {<br>
<br>
    @Autowired<br>
    private SecretGenerator secretGenerator;<br>
<br>
    @Autowired<br>
    private QrDataFactory qrDataFactory;<br>
<br>
    @Autowired<br>
    private QrGenerator qrGenerator;<br>
<br>
    public String generateNewSecret() {<br>
        return secretGenerator.generate();<br>
    }<br>
<br>
    public String generateQrCodeDataUri(String secret) {<br>
        QrData data = qrDataFactory.newQrData(secret, "YourAppName");<br>
        try {<br>
            byte[] imageData = qrGenerator.generate(data);<br>
            return "data:image/png;base64," + Base64.getEncoder().encodeToString(imageData);<br>
        } catch (QrGenerationException e) {<br>
            throw new RuntimeException("Error generating QR code", e);<br>
        }<br>
    }<br>
<br>
    // ... method for code verification will be added later<br>
}<br>

The 2FA Setup Page

Create a controller endpoint that a logged-in user can visit to set up 2FA. This endpoint generates a new secret, saves it temporarily, and displays a page with the QR code.


@Controller<br>
public class MfaController {<br>
<br>
    @GetMapping("/setup-2fa")<br>
    public String setup2faPage(Model model, Principal principal) {<br>
        User user = userService.findByUsername(principal.getName());<br>
        String secret = mfaService.generateNewSecret();<br>
        user.setMfaSecret(secret); // Temporarily set, don't enable yet<br>
        userService.save(user);<br>
<br>
        model.addAttribute("secret", secret);<br>
        model.addAttribute("qrCodeUri", mfaService.generateQrCodeDataUri(secret));<br>
        return "setup-2fa";<br>
    }<br>
}<br>

The associated Thymeleaf view (`setup-2fa.html`) would display the QR code and secret, along with a form to enter a verification code to confirm the setup.

Customizing the Authentication Flow for 2FA

This is the most critical part. We need to intercept a successful password login and divert the user to our 2FA verification step if they have it enabled.

The Custom `AuthenticationSuccessHandler`

Spring Security provides the `AuthenticationSuccessHandler` interface, which is perfect for this task. We’ll create a custom implementation.


@Component<br>
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {<br>
<br>
    private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();<br>
<br>
    @Override<br>
    public void onAuthenticationSuccess(HttpServletRequest request,<br>
                                        HttpServletResponse response,<br>
                                        Authentication authentication) throws IOException {<br>
<br>
        User user = (User) authentication.getPrincipal();<br>
<br>
        if (user.isMfaEnabled()) {<br>
            // User has 2FA enabled, enter partial authentication state<br>
            request.getSession().setAttribute("username", user.getUsername());<br>
            redirectStrategy.sendRedirect(request, response, "/verify-2fa");<br>
        } else {<br>
            // User does not have 2FA, proceed to home page<br>
            redirectStrategy.sendRedirect(request, response, "/home");<br>
        }<br>
    }<br>
}<br>

Updating the Security Configuration

Now, we must tell Spring Security to use our new handler. We inject it into our `SecurityConfig` and add it to the `formLogin` configuration.


@Configuration<br>
@EnableWebSecurity<br>
public class SecurityConfig {<br>
<br>
    @Autowired<br>
    private CustomAuthenticationSuccessHandler successHandler;<br>
<br>
    @Bean<br>
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {<br>
        http<br>
            .authorizeHttpRequests(auth -> auth<br>
                .requestMatchers("/login", "/verify-2fa").permitAll() // Permit access to verify page<br>
                .anyRequest().authenticated()<br>
            )<br>
            .formLogin(form -> form<br>
                .loginPage("/login")<br>
                .successHandler(successHandler) // Use our custom handler<br>
                .permitAll()<br>
            )<br>
            // ... rest of config<br>
        return http.build();<br>
    }<br>
}<br>

Verifying the TOTP Code

The Verification Controller and View

We need a page for the user to enter their code. Create a controller with GET and POST mappings for `/verify-2fa`.


@Controller<br>
public class MfaController {<br>
<br>
    @GetMapping("/verify-2fa")<br>
    public String verify2faPage() {<br>
        // Check if the user is in the partial auth state<br>
        if (SecurityContextHolder.getContext().getAuthentication() == null || <br>
            !request.getSession().getAttribute("username").equals(...)) { // Simplified check<br>
            return "redirect:/login";<br>
        }<br>
        return "verify-2fa";<br>
    }<br>
<br>
    @PostMapping("/verify-2fa")<br>
    public String verify2faCode(@RequestParam("code") String code, HttpSession session) {<br>
        String username = (String) session.getAttribute("username");<br>
        User user = userService.findByUsername(username);<br>
<br>
        if (mfaService.isCodeValid(user.getMfaSecret(), code)) {<br>
            // Code is valid, complete the authentication<br>
            Authentication auth = new UsernamePasswordAuthenticationToken(<br>
                user, null, user.getAuthorities());<br>
            SecurityContextHolder.getContext().setAuthentication(auth);<br>
<br>
            session.removeAttribute("username");<br>
            return "redirect:/home";<br>
        } else {<br>
            // Code is invalid, show error<br>
            return "redirect:/verify-2fa?error";<br>
        }<br>
    }<br>
}<br>

The key part is in the POST method. If the code is valid, we manually create a new, fully-populated `Authentication` object and set it in the `SecurityContextHolder`. This action upgrades the user’s session from partially to fully authenticated.

The Verification Logic in the Service

Finally, let’s add the verification method to our `MfaService`.


@Service<br>
public class MfaService {<br>
    // ... existing methods<br>
<br>
    @Autowired<br>
    private CodeVerifier codeVerifier;<br>
<br>
    public boolean isCodeValid(String secret, String code) {<br>
        return codeVerifier.isValid(secret, code);<br>
    }<br>
}<br>

Best Practices and Considerations

While our implementation is functional, here are a few critical improvements for a production-ready system:

  • Recovery Codes: What if a user loses their phone? During the 2FA setup process, you should generate a set of one-time-use recovery codes that the user can store in a safe place.
  • Rate Limiting: Protect the `/verify-2fa` endpoint from brute-force attacks. Implement rate limiting to lock an account after a certain number of failed verification attempts.
  • Secure Storage: The MFA secret key is highly sensitive. It should always be encrypted at rest in your database. Do not store it as plain text.
  • Clear State Management: Ensure that the “partially authenticated” state is handled robustly. Prevent users in this state from accessing any secure resources other than the verification page.

Conclusion

You have successfully integrated a robust Two-Factor Authentication system into a Spring Boot application. By leveraging the hooks provided by Spring Security, such as the `AuthenticationSuccessHandler`, we were able to create a custom, two-step login flow that significantly enhances user account security.

Adding 2FA is no longer a luxury; it’s a fundamental requirement for modern applications that handle sensitive user data. With the power of the Spring ecosystem, implementing this crucial security feature is a manageable and highly rewarding task. Now you can provide your users with the peace of mind they deserve.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top