mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
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:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user