chore: add caching, validation, lazy routes, refresh rate limiting, JaCoCo, and cleanup (#2762)

* feat: add login rate limiting to prevent brute-force attacks

* chore: add caching, validation, lazy routes, refresh rate limiting, JaCoCo, and remove dead code
This commit is contained in:
ACX
2026-02-15 08:51:25 -07:00
committed by GitHub
parent f7650d9fd6
commit 03272f7c35
12 changed files with 150 additions and 93 deletions

View File

@@ -4,6 +4,7 @@ plugins {
id 'io.spring.dependency-management' version '1.1.7'
id 'org.hibernate.orm' version '7.2.4.Final'
id 'com.github.ben-manes.versions' version '0.53.0'
id 'jacoco'
}
group = 'org.booklore'
@@ -108,6 +109,7 @@ dependencies {
implementation 'com.fasterxml.jackson.core:jackson-annotations'
// --- Caching ---
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'com.github.ben-manes.caffeine:caffeine:3.2.3'
// --- Test Dependencies ---
@@ -127,6 +129,15 @@ hibernate {
test {
useJUnitPlatform()
jvmArgs("-XX:+EnableDynamicAgentLoading")
finalizedBy jacocoTestReport
}
jacocoTestReport {
dependsOn test
reports {
xml.required = true
html.required = true
}
}
bootRun {

View File

@@ -0,0 +1,24 @@
package org.booklore.config;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Duration;
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager("publicSettings");
cacheManager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(5))
.maximumSize(10));
return cacheManager;
}
}

View File

@@ -0,0 +1,75 @@
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 AuthRateLimitService {
private static final int MAX_ATTEMPTS = 5;
private final Cache<String, AtomicInteger> attemptCache;
private final AuditService auditService;
public AuthRateLimitService(AuditService auditService) {
this.auditService = auditService;
this.attemptCache = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(15))
.build();
}
// --- Login rate limiting ---
public void checkLoginRateLimit(String ip) {
checkRateLimit("login:" + ip, AuditAction.LOGIN_RATE_LIMITED, "Login rate limited for IP: " + ip);
}
public void recordFailedLoginAttempt(String ip) {
recordFailedAttempt("login:" + ip);
}
public void resetLoginAttempts(String ip) {
resetAttempts("login:" + ip);
}
// --- Refresh token rate limiting ---
public void checkRefreshRateLimit(String ip) {
checkRateLimit("refresh:" + ip, AuditAction.REFRESH_RATE_LIMITED, "Refresh rate limited for IP: " + ip);
}
public void recordFailedRefreshAttempt(String ip) {
recordFailedAttempt("refresh:" + ip);
}
public void resetRefreshAttempts(String ip) {
resetAttempts("refresh:" + ip);
}
// --- Shared internals ---
private void checkRateLimit(String key, AuditAction action, String message) {
AtomicInteger attempts = attemptCache.getIfPresent(key);
if (attempts != null && attempts.get() >= MAX_ATTEMPTS) {
auditService.log(action, message);
throw ApiError.RATE_LIMITED.createException();
}
}
private void recordFailedAttempt(String key) {
attemptCache.get(key, k -> new AtomicInteger(0)).incrementAndGet();
}
private void resetAttempts(String key) {
attemptCache.invalidate(key);
}
}

View File

@@ -43,7 +43,7 @@ public class AuthenticationService {
private final JwtUtils jwtUtils;
private final DefaultSettingInitializer defaultSettingInitializer;
private final AuditService auditService;
private final LoginRateLimitService loginRateLimitService;
private final AuthRateLimitService authRateLimitService;
public BookLoreUser getAuthenticatedUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
@@ -93,21 +93,21 @@ public class AuthenticationService {
public ResponseEntity<Map<String, String>> loginUser(UserLoginRequest loginRequest) {
String ip = RequestUtils.getCurrentRequest().getRemoteAddr();
loginRateLimitService.checkRateLimit(ip);
authRateLimitService.checkLoginRateLimit(ip);
BookLoreUserEntity user = userRepository.findByUsername(loginRequest.getUsername()).orElseThrow(() -> {
auditService.log(AuditAction.LOGIN_FAILED, "Login failed for unknown user: " + loginRequest.getUsername());
loginRateLimitService.recordFailedAttempt(ip);
authRateLimitService.recordFailedLoginAttempt(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);
authRateLimitService.recordFailedLoginAttempt(ip);
throw ApiError.INVALID_CREDENTIALS.createException();
}
loginRateLimitService.resetAttempts(ip);
authRateLimitService.resetLoginAttempts(ip);
return loginUser(user);
}
@@ -150,9 +150,16 @@ public class AuthenticationService {
}
public ResponseEntity<Map<String, String>> refreshToken(String token) {
RefreshTokenEntity storedToken = refreshTokenRepository.findByToken(token).orElseThrow(() -> ApiError.INVALID_CREDENTIALS.createException("Refresh token not found"));
String ip = RequestUtils.getCurrentRequest().getRemoteAddr();
authRateLimitService.checkRefreshRateLimit(ip);
RefreshTokenEntity storedToken = refreshTokenRepository.findByToken(token).orElseThrow(() -> {
authRateLimitService.recordFailedRefreshAttempt(ip);
return ApiError.INVALID_CREDENTIALS.createException("Refresh token not found");
});
if (storedToken.isRevoked() || storedToken.getExpiryDate().isBefore(Instant.now()) || !jwtUtils.validateToken(token)) {
authRateLimitService.recordFailedRefreshAttempt(ip);
throw ApiError.INVALID_CREDENTIALS.createException("Invalid or expired refresh token");
}
@@ -172,6 +179,8 @@ public class AuthenticationService {
refreshTokenRepository.save(newRefreshTokenEntity);
authRateLimitService.resetRefreshAttempts(ip);
return ResponseEntity.ok(Map.of(
"accessToken", jwtUtils.generateAccessToken(user),
"refreshToken", newRefreshToken

View File

@@ -1,9 +1,13 @@
package org.booklore.model.dto.request;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class UserLoginRequest {
@NotBlank(message = "Username must not be blank")
private String username;
@NotBlank(message = "Password must not be blank")
private String password;
}

View File

@@ -32,5 +32,6 @@ public enum AuditAction {
OPDS_USER_DELETED,
OPDS_USER_UPDATED,
NAMING_PATTERN_CHANGED,
LOGIN_RATE_LIMITED
LOGIN_RATE_LIMITED,
REFRESH_RATE_LIMITED
}

View File

@@ -7,9 +7,13 @@ import org.booklore.model.dto.BookLoreUser;
import org.booklore.model.dto.request.MetadataRefreshOptions;
import org.booklore.model.dto.settings.*;
import org.booklore.model.entity.AppSettingEntity;
import org.booklore.model.enums.AuditAction;
import org.booklore.model.enums.PermissionType;
import org.booklore.service.audit.AuditService;
import org.booklore.util.UserPermissionUtils;
import org.springframework.boot.sql.init.dependency.DependsOnDatabaseInitialization;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.context.annotation.Lazy;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
@@ -20,8 +24,6 @@ import java.util.List;
import java.util.Map;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
import org.booklore.model.enums.AuditAction;
import org.booklore.service.audit.AuditService;
@Service
@DependsOnDatabaseInitialization
@@ -56,6 +58,7 @@ public class AppSettingService {
return appSettings;
}
@CacheEvict(value = "publicSettings", allEntries = true)
@Transactional
public void updateSetting(AppSettingKey key, Object val) throws JacksonException {
BookLoreUser user = authenticationService.getAuthenticatedUser();
@@ -89,6 +92,7 @@ public class AppSettingService {
}
}
@Cacheable("publicSettings")
public PublicAppSetting getPublicSettings() {
return buildPublicSetting();
}
@@ -171,6 +175,7 @@ public class AppSettingService {
return setting != null ? setting.getVal() : null;
}
@CacheEvict(value = "publicSettings", allEntries = true)
@Transactional
public void saveSetting(String key, String value) {
var setting = settingPersistenceHelper.appSettingsRepository.findByName(key);