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);

View File

@@ -3,30 +3,18 @@ import {BookBrowserComponent} from './features/book/components/book-browser/book
import {AppLayoutComponent} from './shared/layout/component/layout-main/app.layout.component';
import {LoginComponent} from './shared/components/login/login.component';
import {AuthGuard} from './core/security/auth.guard';
import {SettingsComponent} from './features/settings/settings.component';
import {ChangePasswordComponent} from './shared/components/change-password/change-password.component';
import {BookMetadataCenterComponent} from './features/metadata/component/book-metadata-center/book-metadata-center.component';
import {SetupComponent} from './shared/components/setup/setup.component';
import {SetupGuard} from './shared/components/setup/setup.guard';
import {SetupRedirectGuard} from './shared/components/setup/setup-redirect.guard';
import {EmptyComponent} from './shared/components/empty/empty.component';
import {OidcCallbackComponent} from './core/security/oidc-callback/oidc-callback.component';
import {CbxReaderComponent} from './features/readers/cbx-reader/cbx-reader.component';
import {MainDashboardComponent} from './features/dashboard/components/main-dashboard/main-dashboard.component';
import {SeriesPageComponent} from './features/book/components/series-page/series-page.component';
import {MetadataManagerComponent} from './features/metadata/component/metadata-manager/metadata-manager.component';
import {PdfReaderComponent} from './features/readers/pdf-reader/pdf-reader.component';
import {BookdropFileReviewComponent} from './features/bookdrop/component/bookdrop-file-review/bookdrop-file-review.component';
import {LoginGuard} from './shared/components/setup/login.guard';
import {UserStatsComponent} from './features/stats/component/user-stats/user-stats.component';
import {BookdropGuard} from './core/security/guards/bookdrop.guard';
import {LibraryStatsGuard} from './core/security/guards/library-stats.guard';
import {UserStatsGuard} from './core/security/guards/user-stats.guard';
import {EditMetadataGuard} from './core/security/guards/edit-metdata.guard';
import {EbookReaderComponent} from './features/readers/ebook-reader';
import {LibraryStatsComponent} from './features/stats/component/library-stats/library-stats.component';
import {AudiobookPlayerComponent} from './features/readers/audiobook-player';
import {NotebookComponent} from './features/notebook/components/notebook/notebook.component';
export const routes: Routes = [
{
@@ -47,38 +35,38 @@ export const routes: Routes = [
children: [
{path: 'dashboard', component: MainDashboardComponent, canActivate: [AuthGuard]},
{path: 'all-books', component: BookBrowserComponent, canActivate: [AuthGuard]},
{path: 'settings', component: SettingsComponent, canActivate: [AuthGuard]},
{path: 'settings', loadComponent: () => import('./features/settings/settings.component').then(m => m.SettingsComponent), canActivate: [AuthGuard]},
{path: 'library/:libraryId/books', component: BookBrowserComponent, canActivate: [AuthGuard]},
{path: 'shelf/:shelfId/books', component: BookBrowserComponent, canActivate: [AuthGuard]},
{path: 'unshelved-books', component: BookBrowserComponent, canActivate: [AuthGuard]},
{path: 'series/:seriesName', component: SeriesPageComponent, canActivate: [AuthGuard]},
{path: 'series/:seriesName', loadComponent: () => import('./features/book/components/series-page/series-page.component').then(m => m.SeriesPageComponent), canActivate: [AuthGuard]},
{path: 'magic-shelf/:magicShelfId/books', component: BookBrowserComponent, canActivate: [AuthGuard]},
{path: 'book/:bookId', component: BookMetadataCenterComponent, canActivate: [AuthGuard]},
{path: 'bookdrop', component: BookdropFileReviewComponent, canActivate: [BookdropGuard]},
{path: 'metadata-manager', component: MetadataManagerComponent, canActivate: [EditMetadataGuard]},
{path: 'library-stats', component: LibraryStatsComponent, canActivate: [LibraryStatsGuard]},
{path: 'reading-stats', component: UserStatsComponent, canActivate: [UserStatsGuard]},
{path: 'notebook', component: NotebookComponent, canActivate: [AuthGuard]},
{path: 'book/:bookId', loadComponent: () => import('./features/metadata/component/book-metadata-center/book-metadata-center.component').then(m => m.BookMetadataCenterComponent), canActivate: [AuthGuard]},
{path: 'bookdrop', loadComponent: () => import('./features/bookdrop/component/bookdrop-file-review/bookdrop-file-review.component').then(m => m.BookdropFileReviewComponent), canActivate: [BookdropGuard]},
{path: 'metadata-manager', loadComponent: () => import('./features/metadata/component/metadata-manager/metadata-manager.component').then(m => m.MetadataManagerComponent), canActivate: [EditMetadataGuard]},
{path: 'library-stats', loadComponent: () => import('./features/stats/component/library-stats/library-stats.component').then(m => m.LibraryStatsComponent), canActivate: [LibraryStatsGuard]},
{path: 'reading-stats', loadComponent: () => import('./features/stats/component/user-stats/user-stats.component').then(m => m.UserStatsComponent), canActivate: [UserStatsGuard]},
{path: 'notebook', loadComponent: () => import('./features/notebook/components/notebook/notebook.component').then(m => m.NotebookComponent), canActivate: [AuthGuard]},
]
},
{
path: 'pdf-reader/book/:bookId',
component: PdfReaderComponent,
loadComponent: () => import('./features/readers/pdf-reader/pdf-reader.component').then(m => m.PdfReaderComponent),
canActivate: [AuthGuard]
},
{
path: 'ebook-reader/book/:bookId',
component: EbookReaderComponent,
loadComponent: () => import('./features/readers/ebook-reader/ebook-reader.component').then(m => m.EbookReaderComponent),
canActivate: [AuthGuard]
},
{
path: 'cbx-reader/book/:bookId',
component: CbxReaderComponent,
loadComponent: () => import('./features/readers/cbx-reader/cbx-reader.component').then(m => m.CbxReaderComponent),
canActivate: [AuthGuard]
},
{
path: 'audiobook-player/book/:bookId',
component: AudiobookPlayerComponent,
loadComponent: () => import('./features/readers/audiobook-player/audiobook-player.component').then(m => m.AudiobookPlayerComponent),
canActivate: [AuthGuard]
},
{

View File

@@ -1,5 +0,0 @@
@if (loading) {
<div class="loading-overlay">
<p-progressSpinner></p-progressSpinner>
</div>
}

View File

@@ -1,12 +0,0 @@
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
}

View File

@@ -1,33 +0,0 @@
import {Component, OnInit, OnDestroy, inject} from '@angular/core';
import { Subscription } from 'rxjs';
import {LoadingService} from '../../service/loading.service';
import {ProgressSpinner} from 'primeng/progressspinner';
@Component({
selector: 'app-loading-overlay',
templateUrl: './loading-overlay.component.html',
imports: [
ProgressSpinner
],
standalone: true,
styleUrls: ['./loading-overlay.component.scss']
})
export class LoadingOverlayComponent implements OnInit, OnDestroy {
loading: boolean = false;
private loadingSubscription: Subscription | undefined;
private loadingService = inject(LoadingService);
ngOnInit(): void {
this.loadingSubscription = this.loadingService.loading$.subscribe(
(loading) => {
this.loading = loading;
}
);
}
ngOnDestroy(): void {
this.loadingSubscription?.unsubscribe();
}
}

View File

@@ -1,10 +0,0 @@
import {Injectable} from '@angular/core';
import {BehaviorSubject} from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class LoadingService {
private loadingSubject = new BehaviorSubject<boolean>(false);
loading$ = this.loadingSubject.asObservable();
}