mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
feat: add login rate limiting to prevent brute-force attacks (#2761)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user