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:
<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:
@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:
@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:
@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:
@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:
@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.