Introduction

In web applications, it’s crucial to implement measures to prevent brute force attacks, where an attacker repeatedly attempts to guess valid login credentials. Spring Security provides a flexible framework for securing applications, and it can be extended to protect against such attacks.

In this article, we’ll implement a basic solution to prevent brute-force authentication attempts using Spring Security. We’ll keep track of the number of failed attempts originating from a single IP address and block the IP address for a specified duration (e.g., 24 hours) if the number of attempts exceeds a predefined limit.

1. Add the required Dependencies

Before we dive into the implementation details, let’s add the required dependencies to our pom.xml file:

XML
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.3.0</version>
    <relativePath/>
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>com.google.guava</groupId>
        <artifactId>guava</artifactId>
        <version>33.2.0-jre</version>
    </dependency>
    <!-- Other dependencies -->
</dependencies>

We’ve included the spring-boot-starter-web and spring-boot-starter-security dependencies for building web applications and integrating Spring Security. Additionally, we’ve added the guava dependency, which provides utility classes for caching and other features that we’ll use in our implementation.

2. Create AuthenticationFailureListener

Our first step is to create an AuthenticationFailureListener that listens for AuthenticationFailureBadCredentialsEvent events. This listener will notify our LoginAttemptService about failed authentication attempts:

Java
@Component
public class AuthenticationFailureListener implements 
               ApplicationListener<AuthenticationFailureBadCredentialsEvent> {

    private final HttpServletRequest request;
    private final LoginAttemptService loginAttemptService;

    public AuthenticationFailureListener(HttpServletRequest request, LoginAttemptService loginAttemptService) {
        this.request = request;
        this.loginAttemptService = loginAttemptService;
    }

    @Override
    public void onApplicationEvent(AuthenticationFailureBadCredentialsEvent event) {
        final String ipAddress = getClientIP();
        loginAttemptService.loginFailed(ipAddress);
    }

    private String getClientIP() {
        String xfHeader = request.getHeader("X-Forwarded-For");
        if (xfHeader == null || xfHeader.isEmpty() || !xfHeader.contains(request.getRemoteAddr())) {
            return request.getRemoteAddr();
        }
        return xfHeader.split(",")[0];
    }
}

In this class, we listen for AuthenticationFailureBadCredentialsEvent events, which are triggered when an authentication failure occurs due to invalid credentials. When an event is triggered, we inform the LoginAttemptService of the IP address from where the unsuccessful attempt originated.

We also handle the case where the request is forwarded by a proxy server by checking the X-Forwarded-For header. However, we need to be cautious as this header can be spoofed, so we validate that it contains the request’s remote address before using it.

3. Create LoginAttemptService

The LoginAttemptService keeps track of the number of failed login attempts per IP address using a cache implementation from Guava:

Java
@Service
public class LoginAttemptService {

    private static final int MAX_ATTEMPTS = 5;
    private final LoadingCache<String, Integer> attemptsCache;

    public LoginAttemptService() {
        attemptsCache = CacheBuilder.newBuilder()
                .maximumSize(1000)
                .expireAfterWrite(Duration.ofMinutes(15))
                .build(new CacheLoader<String, Integer>() {
                    public Integer load(String key) {
                        return 0;
                    }
                });
    }

    public void loginFailed(String key) {
        int attempts = attemptsCache.getUnchecked(key);
        attempts++;
        attemptsCache.put(key, attempts);
    }

    public boolean isBlocked(String key) {
        try {
            return attemptsCache.get(key) >= MAX_ATTEMPTS;
        } catch (ExecutionException e) {
            return false;
        }
    }
}

In this implementation, we use Guava’s LoadingCache to store the number of failed attempts for each IP address. The cache is configured to expire after 24 hours (1 day) using the expireAfterWrite method.

The loginFailed method increments the number of failed attempts for the given IP address. The isBlocked method checks if the number of failed attempts for the client’s IP address has reached the maximum allowed limit (MAX_ATTEMPT).

The getClientIP method retrieves the client’s IP address, handling the case where the request is forwarded by a proxy server using the X-Forwarded-For header.

4. Create UserDetailsService Implementation

Next, we need to modify our UserDetailsService implementation to check if the IP address is blocked before attempting to load the user details:

Java
@Service
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;
    private final LoginAttemptService loginAttemptService;
    private final HttpServletRequest request;

    public CustomUserDetailsService(UserRepository userRepository, LoginAttemptService loginAttemptService, 
                                HttpServletRequest request) {
        this.userRepository = userRepository;
        this.loginAttemptService = loginAttemptService;
        this.request = request;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        String ipAddress = getClientIP();
        if (loginAttemptService.isBlocked(ipAddress)) {
            throw new RuntimeException("Blocked due to too many failed login attempts");
        }

        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("User not found"));

        return new org.springframework.security.core.userdetails.User(
                user.getUsername(),
                user.getPassword(),
                user.isEnabled(),
                true,
                true,
                true,
                getAuthorities(user.getRoles())
        );
    }

    private String getClientIP() {
        // Retrieve client IP address using X-Forwarded-For header or remote address
        // ...
    }

    // Map roles to GrantedAuthority instances
    private Collection<? extends GrantedAuthority> getAuthorities(Set<Role> roles) {
    return roles.stream()
            .map(role -> new SimpleGrantedAuthority(role.getName()))
            .collect(Collectors.toSet());
}
}

In the loadUserByUsername method, we first check if the client’s IP address is blocked due to too many failed login attempts using the LoginAttemptService. If the IP is blocked, we throw a RuntimeException to prevent further authentication processing. Otherwise, we proceed with the regular user authentication flow.

5. Create the CustomAuthenticationFailureHandler

Finally, we’ll create a custom AuthenticationFailureHandler to handle failed authentication attempts and provide appropriate feedback to the user:

Java
@Component
public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    private final MessageSource messageSource;
    private final LoginAttemptService loginAttemptService;
    private final HttpServletRequest request;

    public CustomAuthenticationFailureHandler(MessageSource messageSource, LoginAttemptService loginAttemptService, HttpServletRequest request) {
        this.messageSource = messageSource;
        this.loginAttemptService = loginAttemptService;
        this.request = request;
    }

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, 
                    AuthenticationException exception) throws IOException, ServletException {
        String ipAddress = getClientIP();
        loginAttemptService.loginFailed(ipAddress);

        if (loginAttemptService.isBlocked(ipAddress)) {
            String blockedMessage = messageSource.getMessage("auth.message.blocked", null, request.getLocale());
            addErrorMessage(request, response, blockedMessage);
        } else {
            super.onAuthenticationFailure(request, response, exception);
        }
    }

    private String getClientIP() {
        // Retrieve client IP address using X-Forwarded-For header or remote address
        //

In this configuration, we wire up the CustomUserDetailsService and CustomAuthenticationFailureHandler components we created earlier. The DaoAuthenticationProvider is configured to use our custom UserDetailsService. Additionally, we set the authenticationFailureHandler on the FormLoginConfigurer to handle failed authentication attempts.

Testing:

To test the brute force prevention mechanism, you can create a simple controller and a login form in your Spring Boot application. Attempt to log in with invalid credentials multiple times, and you should see the appropriate feedback messages and blocking behavior after reaching the maximum allowed failed attempts.

Here is an sample example of Brutt force attack Integration Test:

Java
@SpringBootTest
@AutoConfigureMockMvc
@Import(TestSecurityConfig.class)
public class BruteForcePreventionIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private LoginAttemptService loginAttemptService;

    @Test
    public void testBruteForcePreventionMechanism() throws Exception {
        // Create a test user
        User testUser = new User("testuser", "password", true, Set.of(new Role("ROLE_USER")));
        userRepository.save(testUser);

        // Attempt to log in with invalid credentials multiple times
        for (int i = 0; i < LoginAttemptService.MAX_ATTEMPTS + 1; i++) {
            mockMvc.perform(post("/login")
                    .param("username", "testuser")
                    .param("password", "wrongpassword"))
                .andExpect(status().isFound())
                .andExpect(redirectedUrl("/login?error"));
        }

        // Check if the IP address is blocked
        String ipAddress = "127.0.0.1"; // Replace with the IP address you want to test
        assertTrue(loginAttemptService.isBlocked(ipAddress));

        // Attempt to log in with valid credentials, should fail due to IP being blocked
        mockMvc.perform(post("/login")
                .param("username", "testuser")
                .param("password", "correctPassword"))
            .andExpect(status().isFound())
            .andExpect(redirectedUrl("/login?error"))
            .andExpect(model().attribute("errorMessage", "auth.message.blocked"));
    }
}

Conclusion:

Implementing a robust brute force prevention mechanism is crucial for safeguarding your Spring Boot application against unauthorized access attempts. By leveraging Spring Security 6.3 and Spring Boot 3.3.0, you can effectively track failed login attempts, block IP addresses after a configurable threshold, and provide meaningful feedback to users.

This comprehensive solution not only enhances the security of your application but also serves as a powerful deterrent against malicious actors attempting brute force attacks. Remember, security is an ongoing process, and it’s essential to regularly review and update your security measures to stay ahead of emerging threats.

By |Last Updated: May 26th, 2024|Categories: Spring Security|

Table of Contents