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);
|
||||
|
||||
@@ -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]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
@if (loading) {
|
||||
<div class="loading-overlay">
|
||||
<p-progressSpinner></p-progressSpinner>
|
||||
</div>
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user