feat: add login rate limiting to prevent brute-force attacks (#2761)

This commit is contained in:
ACX
2026-02-15 08:25:12 -07:00
committed by GitHub
parent 0318d1b3bb
commit f7650d9fd6
7 changed files with 63 additions and 4 deletions

View File

@@ -28,6 +28,7 @@ import java.util.Map;
import java.util.Optional;
import org.booklore.model.enums.AuditAction;
import org.booklore.service.audit.AuditService;
import org.booklore.util.RequestUtils;
@Slf4j
@AllArgsConstructor
@@ -42,6 +43,7 @@ public class AuthenticationService {
private final JwtUtils jwtUtils;
private final DefaultSettingInitializer defaultSettingInitializer;
private final AuditService auditService;
private final LoginRateLimitService loginRateLimitService;
public BookLoreUser getAuthenticatedUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
@@ -90,16 +92,22 @@ public class AuthenticationService {
}
public ResponseEntity<Map<String, String>> loginUser(UserLoginRequest loginRequest) {
String ip = RequestUtils.getCurrentRequest().getRemoteAddr();
loginRateLimitService.checkRateLimit(ip);
BookLoreUserEntity user = userRepository.findByUsername(loginRequest.getUsername()).orElseThrow(() -> {
auditService.log(AuditAction.LOGIN_FAILED, "Login failed for unknown user: " + loginRequest.getUsername());
loginRateLimitService.recordFailedAttempt(ip);
return ApiError.USER_NOT_FOUND.createException(loginRequest.getUsername());
});
if (!passwordEncoder.matches(loginRequest.getPassword(), user.getPasswordHash())) {
auditService.log(AuditAction.LOGIN_FAILED, "Login failed for user: " + loginRequest.getUsername());
loginRateLimitService.recordFailedAttempt(ip);
throw ApiError.INVALID_CREDENTIALS.createException();
}
loginRateLimitService.resetAttempts(ip);
return loginUser(user);
}

View File

@@ -0,0 +1,45 @@
package org.booklore.config.security.service;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import lombok.extern.slf4j.Slf4j;
import org.booklore.exception.ApiError;
import org.booklore.model.enums.AuditAction;
import org.booklore.service.audit.AuditService;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
@Slf4j
@Service
public class LoginRateLimitService {
private static final int MAX_ATTEMPTS = 5;
private final Cache<String, AtomicInteger> attemptCache;
private final AuditService auditService;
public LoginRateLimitService(AuditService auditService) {
this.auditService = auditService;
this.attemptCache = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(15))
.build();
}
public void checkRateLimit(String ip) {
AtomicInteger attempts = attemptCache.getIfPresent(ip);
if (attempts != null && attempts.get() >= MAX_ATTEMPTS) {
auditService.log(AuditAction.LOGIN_RATE_LIMITED, "Login rate limited for IP: " + ip);
throw ApiError.RATE_LIMITED.createException();
}
}
public void recordFailedAttempt(String ip) {
attemptCache.get(ip, k -> new AtomicInteger(0)).incrementAndGet();
}
public void resetAttempts(String ip) {
attemptCache.invalidate(ip);
}
}

View File

@@ -62,7 +62,8 @@ public enum ApiError {
DEMO_USER_PASSWORD_CHANGE_NOT_ALLOWED(HttpStatus.FORBIDDEN, "Demo user password change not allowed."),
PERMISSION_DENIED(HttpStatus.FORBIDDEN, "Permission denied: %s"),
LIBRARY_PATH_NOT_ACCESSIBLE(HttpStatus.SERVICE_UNAVAILABLE, "Library scan aborted: path not accessible or empty: %s"),
FORMAT_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "File format '%s' is not allowed in library '%s'");
FORMAT_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "File format '%s' is not allowed in library '%s'"),
RATE_LIMITED(HttpStatus.TOO_MANY_REQUESTS, "Too many failed login attempts. Please try again later.");
private final HttpStatus status;
private final String message;

View File

@@ -31,5 +31,6 @@ public enum AuditAction {
OPDS_USER_CREATED,
OPDS_USER_DELETED,
OPDS_USER_UPDATED,
NAMING_PATTERN_CHANGED
NAMING_PATTERN_CHANGED,
LOGIN_RATE_LIMITED
}