Careeroza
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).