Spring Security Brute Force Protection

In this post of Spring security series, we will look at the Spring security brute force protection and how to configure our application against the brute force attacks.

Spring Security Brute Force Protection

Spring security performs a lot of work for us during authentication and authorization process.Brute force is a common attack on the web application where a malicious user will try a password-guessing as brute force attack. Spring security is a flexible framework and provides extension points to extend or use core functionality. Spring security does not provide any ready-to-use feature to for brute force protection but offers certain extension points which we can use.

In this article, we will build spring security brute force protection to handle brute force attacks.Before we get in more details. There are multiple options for handling these attacks.

  1. Locking Account after certain failed attempts.
  2. Device Cookies – Locking from unknown devices.
  3. Use Captcha to prevent automated attacks.

In this article, we will work on the first option. We will keep trek of each failed and successful login attempts and if the consecutive failed login attempts increase a certain threshold, we will lock/ disable the account and let user go through password reset or any additional steps to reactive the account.

This article is part of our Spring Security beginner class, and you can download the source code from our GitHub repository.

1. Spring Security Authentication Events

We will use the Spring security event publishing feature to build our brute force protection service. For each authentication that succeeds or fails, Spring security publish an AuthenticationSuccessEvent or AuthenticationFailureEvent. We will use this feature to build our spring security brute force protection. Here is a high level workflow for our strategy.

  1. We will write a custom authentication failure event listener. This listener will work with the underlying services to keep trek of number of failed attempts and will lock the account if it exceeds.
  2. A success authentication listener to reset any failed count (we will reset the failed counter to zero).

2.Spring AuthenticationFailureEventListner

Let’s build our AuthenticationFailureEventListner which listen to specific events and will notify us in case of any authentication failures.

package com.javadevjournal.core.security.event;

import com.javadevjournal.core.security.bruteforce.BruteForceProtectionService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationListener;
import org.springframework.security.authentication.event.AuthenticationFailureBadCredentialsEvent;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;

@Component
public class AuthenticationFailureListener implements ApplicationListener < AuthenticationFailureBadCredentialsEvent > {

    private static Logger LOG = LoggerFactory.getLogger(AuthenticationFailureListener.class);

    @Resource(name = "bruteForceProtectionService")
    private BruteForceProtectionService bruteForceProtectionService;

    @Override
    public void onApplicationEvent(AuthenticationFailureBadCredentialsEvent event) {
        String username = event.getAuthentication().getName();
        LOG.info("********* login failed for user {} ", username);
        bruteForceProtectionService.registerLoginFailure(username);

    }

}

Our listener is only listening to the AuthenticationFailureBadCredentialsEvent. In this event listener, we are passing the user id information to our BruteForceProtectionService which will check and disable the user account if required.

3.Spring AuthenticationSuccessEventListner

Similar to the failure event handler, Spring security also publish event on the successful authentication and we will create a custom success handler. This handler will handover the control to the BruteForceProtectionService to reset the failed counter.

package com.javadevjournal.core.security.event;

import com.javadevjournal.core.security.bruteforce.BruteForceProtectionService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationListener;
import org.springframework.security.authentication.event.AuthenticationSuccessEvent;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;

@Component
public class AuthenticationSuccessListener implements ApplicationListener < AuthenticationSuccessEvent > {

    private static Logger LOG = LoggerFactory.getLogger(AuthenticationSuccessListener.class);

    @Resource(name = "bruteForceProtectionService")
    private BruteForceProtectionService bruteForceProtectionService;

    @Override
    public void onApplicationEvent(AuthenticationSuccessEvent event) {
        String username = event.getAuthentication().getName();
        LOG.info("********* login successful for user {} ", username);
        bruteForceProtectionService.resetBruteForceCounter(username);
    }
}

4. The BruteForceProtectionService

The BruteForceProtectionService will perform the following tasks as part of the Spring security brute force protection

  1. Increase the failed counter for every failed login attempt.
  2. Check if the failed count exceeds the maximum allowed configuration.
  3. Disable the account is case failed counter exceeds the max limit.
  4. Our BruteForceProtectionService will also reset the counter on the successful login.
package com.javadevjournal.core.security.bruteforce;

import com.javadevjournal.core.user.jpa.data.UserEntity;
import com.javadevjournal.core.user.jpa.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.concurrent.ConcurrentHashMap;

@Service("bruteForceProtectionService")
public class DefaultBruteForceProtectionService implements BruteForceProtectionService {

    @Value("${jdj.security.failedlogin.count}")
    private int maxFailedLogins;

    @Autowired
    UserRepository userRepository;

    @Value("${jdj.brute.force.cache.max}")
    private int cacheMaxLimit;

    private final ConcurrentHashMap < String, FailedLogin > cache;

    public DefaultBruteForceProtectionService() {
        this.cache = new ConcurrentHashMap < > (cacheMaxLimit); //setting max limit for cache
    }

    @Override
    public void registerLoginFailure(String username) {

        UserEntity user = getUser(username);
        if (user != null && !user.isLoginDisabled()) {
            int failedCounter = user.getFailedLoginAttempts();
            if (maxFailedLogins < failedCounter + 1) {
                user.setLoginDisabled(true); //disabling the account
            } else {
                //let's update the counter
                user.setFailedLoginAttempts(failedCounter + 1);
            }
            userRepository.save(user);
        }
    }

    @Override
    public void resetBruteForceCounter(String username) {
        UserEntity user = getUser(username);
        if (user != null) {
            user.setFailedLoginAttempts(0);
            user.setLoginDisabled(false);
            userRepository.save(user);
        }
    }

    @Override
    public boolean isBruteForceAttack(String username) {
        UserEntity user = getUser(username);
        if (user != null) {
            return user.getFailedLoginAttempts() >= maxFailedLogins ? true : false;
        }
        return false;
    }

    protected FailedLogin getFailedLogin(final String username) {
        FailedLogin failedLogin = cache.get(username.toLowerCase());

        if (failedLogin == null) {
            //setup the initial data
            failedLogin = new FailedLogin(0, LocalDateTime.now());
            cache.put(username.toLowerCase(), failedLogin);
            if (cache.size() > cacheMaxLimit) {

                // add the logic to remve the key based by timestamp
            }
        }
        return failedLogin;
    }

    private UserEntity getUser(final String username) {
        return userRepository.findByEmail(username);
    }

    public int getMaxFailedLogins() {
        return maxFailedLogins;
    }

    public void setMaxFailedLogins(int maxFailedLogins) {
        this.maxFailedLogins = maxFailedLogins;
    }

    public class FailedLogin {

        private int count;
        private LocalDateTime date;

        public FailedLogin() {
            this.count = 0;
            this.date = LocalDateTime.now();
        }

        public FailedLogin(int count, LocalDateTime date) {
            this.count = count;
            this.date = date;
        }

        public int getCount() {
            return count;
        }

        public void setCount(int count) {
            this.count = count;
        }

        public LocalDateTime getDate() {
            return date;
        }

        public void setDate(LocalDateTime date) {
            this.date = date;
        }
    }
}

5. UseEntity and UserDetailsService

To Ensure we are saving and updating the counter as well disabling the account, we will make some minor changes to our UserEntity and custom UserDetailsService.

  1. Extend the UserEntity to add failed counter and login disabled flag.
  2. In our custom UserDetailsService, we will pass the disable status based on the login disable flag.

Here is how both the entities look like:

@Entity
@Table(name = "user")
public class UserEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String firstName;
    private String lastName;
    @Column(unique = true)
    private String email;
    private String password;
    private String token;
    private boolean accountVerified;

    //new fields
    private int failedLoginAttempts;
    private boolean loginDisabled;
}

Custom UserDetailsService

@Service("userDetailsService")
public class CustomUserDetailService implements UserDetailsService {

    @Autowired
    UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        final UserEntity customer = userRepository.findByEmail(email);
        if (customer == null) {
            throw new UsernameNotFoundException(email);
        }
        boolean enabled = !customer.isAccountVerified();
        UserDetails user = User.withUsername(customer.getEmail())
            .password(customer.getPassword())
            .disabled(customer.isLoginDisabled())
            .authorities("USER").build();
        return user;
    }
}

Keep in mind that once account is disabled, even the login attempt with successful credentials will not unlock the account. Ask user to go with reset password feature to unlock the account.

6. Showing Error on Login Page

Above configurations will make sure that account is locked after 2 unsuccessful login attempts as per of spring security brute force protection.We may also want to show the custom error message on our login page, Let’s make some changes to our custom login page and it’s controller.Add the following entry to the messages.properties file

user.account.locked = Your account has been locked due to multiple failed login attempts.

Once user exceeds the failed login attempts, we will show above message on our login page.The next is making some changes to our login page controller.

@Controller
@RequestMapping("/login")
public class LoginPageController {

    public static final String LAST_USERNAME_KEY = "LAST_USERNAME";

    @Resource(name = "customerAccountService")
    private CustomerAccountService customerAccountService;

    @GetMapping
    public String login(@RequestParam(value = "error", defaultValue = "false") boolean loginError,
        @RequestParam(value = "invalid-session", defaultValue = "false") boolean invalidSession,
        final Model model, HttpSession session) {

        String userName = getUserName(session);
        if (loginError) {
            if (StringUtils.isNotEmpty(userName) && customerAccountService.loginDisabled(userName)) {
                model.addAttribute("accountLocked", Boolean.TRUE);
                model.addAttribute("forgotPassword", new ResetPasswordData());
                return "account/login";
            }
        }
    }


    final String getUserName(HttpSession session) {
        final String username = (String) session.getAttribute(LAST_USERNAME_KEY);
        if (StringUtils.isNotEmpty(username)) {
            session.removeAttribute(LAST_USERNAME_KEY); // we don't need it and removing it.
        }
        return username;
    }
}

We are doing few important things in our Login controller.

  1. If are handling login error request parameter differently. On getting this parameter, we check if we lock the user account.
  2. If it locks the user account, we show a different error message to the customer.
  3. If you look at the getUserName() method, we are getting the username from the session.

By default, the username will not be available in the request, once we receive the control in our login controller. To show the custom message, we need the username. We are using the Spring security failure handler to store the username in the session.

6.1. Spring Security Custom AuthenticationFailureHandler

We are only storing the username in the session and letting the default authentication handler perform its work on the authentication failure.This is how our custom AuthenticationFailureHandler look like:

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class LoginAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    public static final String LAST_USERNAME_KEY = "LAST_USERNAME";

    public void onAuthenticationFailure(HttpServletRequest request,
        HttpServletResponse response, AuthenticationException exception)
    throws IOException, ServletException {
        request.getSession().setAttribute(LAST_USERNAME_KEY, request.getParameter("username"));
        super.onAuthenticationFailure(request, response, exception);
    }
}

Add the condition in your login HTML to show the custom error message on the frontend. We added the following condition to our login page.

<div th:if="${param.error!=null and accountLocked ==true}">
   <div class="alert alert-danger">
      <span th:text="#{user.account.locked}"/>
   </div>
</div>

7. Testing Spring Security Brute Force Protection

Our setup and configurations are complete. Let’s test the brute force protection workflow for our spring security application. Run the application, once the application started, go to the login page and try with an invalid password. For the first 2 cases, you will see the following output.

Spring Security Brute Force Protection
Generic error message on initial requests

Once we cross the threshold (2 in our case), we will see the following message on the login page.

Spring Security Brute Force Protection

Summary

In this article, we learned about the Spring security brute force protection. We saw how to use the spring security generated events to build out functionality to protect the account against the brute force attack.At the end of the article; we learned how to show a customized error message to the customer with the help of the custom AuthenticationFailureHandler.

As Always, the source code for this article is available on our GitHub Repository.