Lockout in Spring Security

Store Attempts on the User

@Entity
@Table(name = "users")
public class AppUser {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String email;
    private String passwordHash;
    private int failedLoginAttempts;
    private Instant lockedUntil;

    public boolean isLocked(Instant now) {
        return lockedUntil != null && now.isBefore(lockedUntil);
    }
    // getters and setters
}
@Service
public class DbUserDetailsService implements UserDetailsService {
    private final AppUserRepository users;
    private final Clock clock;
    // constructor omitted

    @Override
    public UserDetails loadUserByUsername(String email) {
        AppUser u = users.findByEmail(email).orElseThrow(() -> new UsernameNotFoundException(email));
        return User.withUsername(u.getEmail())
                .password(u.getPasswordHash())
                .roles(u.getRoles().toArray(String[]::new))
                .accountLocked(u.isLocked(clock.instant()))      // DaoAuthenticationProvider throws LockedException
                .build();
    }
}

Count Failures with Events

Spring Security publishes events for every login result. Listen to them instead of changing the login code:

@Component
public class LoginAttemptListener {

    private static final int MAX_FAILURES = 5;
    private final AppUserRepository users;
    private final Clock clock;

    public LoginAttemptListener(AppUserRepository users, Clock clock) {
        this.users = users;
        this.clock = clock;
    }

    @EventListener
    @Transactional
    public void onFailure(AuthenticationFailureBadCredentialsEvent event) {
        users.findByEmail(event.getAuthentication().getName()).ifPresent(u -> {
            u.setFailedLoginAttempts(u.getFailedLoginAttempts() + 1);
            if (u.getFailedLoginAttempts() >= MAX_FAILURES) {
                u.setLockedUntil(clock.instant().plus(Duration.ofMinutes(15)));
                u.setFailedLoginAttempts(0);
            }
        });
    }

    @EventListener
    @Transactional
    public void onSuccess(AuthenticationSuccessEvent event) {
        if (event.getAuthentication() instanceof UsernamePasswordAuthenticationToken) {   // password logins only
            users.findByEmail(event.getAuthentication().getName()).ifPresent(u -> {
                u.setFailedLoginAttempts(0);
                u.setLockedUntil(null);
            });
        }
    }
}

Locked account

The 6th attempt, even with the right password:

curl -i -X POST localhost:8080/api/auth/login -H "Content-Type: application/json" -d '{"email":"[email protected]","password":"S3cret!pass"}'

Example output

HTTP/1.1 401
Content-Type: application/json

{"status":401,"error":"Unauthorized","message":"Invalid email or password, or the account is temporarily locked. Try again later or reset your password."}

Tip: Use one generic message for wrong password, unknown user and locked account. Different messages ("user not found" vs "wrong password") let attackers discover which emails have accounts (user enumeration).