diff --git a/CLAUDE.md b/CLAUDE.md
index a2765372..cea97324 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -49,7 +49,7 @@ Organized by feature module, each with controller/service/repository/payload lay
- **`auth/`** — JWT authentication, OAuth2 (Google), token management (access, refresh, verify-email, reset-password). Tokens stored in Redis. JWT sent via HTTP-only cookies.
- **`user/`** — User/Role JPA entities, repositories, MapStruct mappers for DTO conversion.
- **`admin/`** — Admin user management endpoints.
-- **`shared/`** — Cross-cutting: security config (`SecurityConfig.java`), error handling (standardized error codes in `error-codes.properties` mapped to frontend i18n keys), email service (MJML templates), data initialization (`DataInit.java`), Redis config, i18n messages.
+- **`shared/`** — Cross-cutting: security config (`SecurityConfig.java`), error handling (standardized error codes in `error-codes.properties` mapped to frontend i18n keys), email service (MJML templates), data initialization (`DataInit.java`), Redis config, i18n messages, rate limiting (`shared/ratelimit/`).
Security: Stateless sessions, JWT filter chain, role-based access (ADMIN/USER). Public endpoints are whitelisted in `SecurityConfig.java`; everything else requires authentication.
@@ -58,6 +58,7 @@ Security: Stateless sessions, JWT filter chain, role-based access (ADMIN/USER).
- Device revocation (`DeviceService.revoke()`) intentionally invalidates **all** access tokens via `UserBlacklist`, not just the revoked device's token. Active sessions auto-refresh silently via their refresh tokens. This is by design to avoid an extra Redis lookup on every request.
- Access token validation (`AccessTokenService.check()`) runs on every authenticated request — keep it minimal (JWT verify + blacklist check + user blacklist check).
- Token data (userId, roles, deviceId) must only be extracted from a JWT **after** signature verification.
+- **Rate limiting** uses Bucket4j (token bucket algorithm) with per-IP Caffeine-cached buckets. Apply the `@RateLimit(requests, duration)` annotation to controller methods. Configurable via `rate-limit.enabled` (defaults to `true`, disabled in tests). Rate-limited endpoints return `429 Too Many Requests` with a `Retry-After` header.
### Frontend (`frontend/svelte-kit/src/`)
diff --git a/backend/spring-boot/pom.xml b/backend/spring-boot/pom.xml
index 8c36121d..9d18e989 100644
--- a/backend/spring-boot/pom.xml
+++ b/backend/spring-boot/pom.xml
@@ -22,9 +22,9 @@
33.5.0-jre
4.5.0
1.6.3
- 7.2.1
3.0.1
2.5.4
+ 8.16.1
0.8.14
3.15.0
3.2.1
@@ -97,9 +97,14 @@
${mapstruct.version}
- redis.clients
- jedis
- ${jedis.version}
+ com.bucket4j
+ bucket4j_jdk17-core
+ ${bucket4j.version}
+
+
+ com.bucket4j
+ bucket4j_jdk17-lettuce
+ ${bucket4j.version}
org.springdoc
diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/controller/AuthController.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/controller/AuthController.java
index 2cbbb17a..4ebb1ac6 100644
--- a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/controller/AuthController.java
+++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/controller/AuthController.java
@@ -16,6 +16,7 @@
import org.bugzkit.api.auth.util.AuthUtil;
import org.bugzkit.api.auth.util.JwtUtil;
import org.bugzkit.api.shared.constants.Path;
+import org.bugzkit.api.shared.ratelimit.RateLimit;
import org.bugzkit.api.user.payload.dto.UserDTO;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
@@ -49,12 +50,14 @@ public AuthController(AuthService authService, DeviceService deviceService) {
this.deviceService = deviceService;
}
+ @RateLimit(requests = 5, duration = 60)
@PostMapping("/register")
public ResponseEntity register(
@Valid @RequestBody RegisterUserRequest registerUserRequest) {
return new ResponseEntity<>(authService.register(registerUserRequest), HttpStatus.CREATED);
}
+ @RateLimit(requests = 10, duration = 60)
@PostMapping("/tokens")
public ResponseEntity authenticate(
@Valid @RequestBody AuthTokensRequest authTokensRequest, HttpServletRequest request) {
@@ -90,6 +93,7 @@ public ResponseEntity revokeDevice(@PathVariable String deviceId) {
return ResponseEntity.noContent().build();
}
+ @RateLimit(requests = 10, duration = 60)
@PostMapping("/tokens/refresh")
public ResponseEntity refreshTokens(HttpServletRequest request) {
final var refreshToken = AuthUtil.getValueFromCookie("refreshToken", request);
@@ -98,6 +102,7 @@ public ResponseEntity refreshTokens(HttpServletRequest request) {
return setAuthCookies(authTokens);
}
+ @RateLimit(requests = 3, duration = 60)
@PostMapping("/password/forgot")
public ResponseEntity forgotPassword(
@Valid @RequestBody ForgotPasswordRequest forgotPasswordRequest) {
@@ -105,6 +110,7 @@ public ResponseEntity forgotPassword(
return ResponseEntity.noContent().build();
}
+ @RateLimit(requests = 5, duration = 60)
@PostMapping("/password/reset")
public ResponseEntity resetPassword(
@Valid @RequestBody ResetPasswordRequest resetPasswordRequest) {
@@ -112,6 +118,7 @@ public ResponseEntity resetPassword(
return ResponseEntity.noContent().build();
}
+ @RateLimit(requests = 3, duration = 60)
@PostMapping("/verification-email")
public ResponseEntity sendVerificationMail(
@Valid @RequestBody VerificationEmailRequest verificationEmailRequest) {
@@ -119,6 +126,7 @@ public ResponseEntity sendVerificationMail(
return ResponseEntity.noContent().build();
}
+ @RateLimit(requests = 5, duration = 60)
@PostMapping("/verify-email")
public ResponseEntity verifyEmail(
@Valid @RequestBody VerifyEmailRequest verifyEmailRequest) {
diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/config/RedisConfig.java b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/config/RedisConfig.java
index 8a2ebda8..d09afbca 100644
--- a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/config/RedisConfig.java
+++ b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/config/RedisConfig.java
@@ -1,5 +1,7 @@
package org.bugzkit.api.shared.config;
+import io.lettuce.core.RedisClient;
+import io.lettuce.core.RedisURI;
import java.time.Duration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
@@ -7,14 +9,14 @@
import org.springframework.context.annotation.Profile;
import org.springframework.data.redis.connection.RedisPassword;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
-import org.springframework.data.redis.connection.jedis.JedisClientConfiguration;
-import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
+import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
+import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisKeyValueAdapter.EnableKeyspaceEvents;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
@Configuration
-@Profile({"dev", "prod"})
+@Profile({"dev", "prod", "test"})
@EnableRedisRepositories(enableKeyspaceEvents = EnableKeyspaceEvents.ON_STARTUP)
public class RedisConfig {
@Value("${spring.data.redis.host}")
@@ -33,21 +35,34 @@ public class RedisConfig {
private int timeout;
@Bean
- JedisConnectionFactory jedisConnectionFactory() {
- final var redisStandaloneConfiguration = new RedisStandaloneConfiguration();
- redisStandaloneConfiguration.setHostName(host);
- redisStandaloneConfiguration.setPort(port);
- redisStandaloneConfiguration.setDatabase(database);
- redisStandaloneConfiguration.setPassword(RedisPassword.of(password));
- return new JedisConnectionFactory(
- redisStandaloneConfiguration,
- JedisClientConfiguration.builder().connectTimeout(Duration.ofSeconds(timeout)).build());
+ LettuceConnectionFactory redisConnectionFactory() {
+ final var redisConfig = new RedisStandaloneConfiguration();
+ redisConfig.setHostName(host);
+ redisConfig.setPort(port);
+ redisConfig.setDatabase(database);
+ redisConfig.setPassword(RedisPassword.of(password));
+ final var clientConfig =
+ LettuceClientConfiguration.builder().commandTimeout(Duration.ofSeconds(timeout)).build();
+ return new LettuceConnectionFactory(redisConfig, clientConfig);
}
@Bean
public RedisTemplate redisTemplate() {
final var template = new RedisTemplate();
- template.setConnectionFactory(jedisConnectionFactory());
+ template.setConnectionFactory(redisConnectionFactory());
return template;
}
+
+ @Bean(destroyMethod = "shutdown")
+ public RedisClient lettuceClient() {
+ final var redisURI =
+ RedisURI.builder()
+ .withHost(host)
+ .withPort(port)
+ .withDatabase(database)
+ .withPassword(password.toCharArray())
+ .withTimeout(Duration.ofSeconds(timeout))
+ .build();
+ return RedisClient.create(redisURI);
+ }
}
diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/config/SecurityConfig.java b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/config/SecurityConfig.java
index 06d6a571..e3194e39 100644
--- a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/config/SecurityConfig.java
+++ b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/config/SecurityConfig.java
@@ -90,6 +90,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.permitAll()
.requestMatchers(OAUTH2_WHITELIST)
.permitAll()
+ .requestMatchers("/favicon.ico")
+ .permitAll()
.anyRequest()
.authenticated())
.oauth2Login(
diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/config/WebMvcConfig.java b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/config/WebMvcConfig.java
index 9bd77abf..ad67a199 100644
--- a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/config/WebMvcConfig.java
+++ b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/config/WebMvcConfig.java
@@ -1,6 +1,7 @@
package org.bugzkit.api.shared.config;
import org.bugzkit.api.shared.interceptor.RequestInterceptor;
+import org.bugzkit.api.shared.ratelimit.RateLimitInterceptor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
@@ -12,6 +13,15 @@ public class WebMvcConfig implements WebMvcConfigurer {
@Value("${ui.url}")
private String uiUrl;
+ private final RateLimitInterceptor rateLimitInterceptor;
+ private final RequestInterceptor requestInterceptor;
+
+ public WebMvcConfig(
+ RateLimitInterceptor rateLimitInterceptor, RequestInterceptor requestInterceptor) {
+ this.rateLimitInterceptor = rateLimitInterceptor;
+ this.requestInterceptor = requestInterceptor;
+ }
+
@Override
public void addCorsMappings(CorsRegistry registry) {
registry
@@ -24,6 +34,7 @@ public void addCorsMappings(CorsRegistry registry) {
@Override
public void addInterceptors(InterceptorRegistry registry) {
- registry.addInterceptor(new RequestInterceptor());
+ registry.addInterceptor(rateLimitInterceptor);
+ registry.addInterceptor(requestInterceptor);
}
}
diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/error/exception/TooManyRequestsException.java b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/error/exception/TooManyRequestsException.java
new file mode 100644
index 00000000..4894a045
--- /dev/null
+++ b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/error/exception/TooManyRequestsException.java
@@ -0,0 +1,16 @@
+package org.bugzkit.api.shared.error.exception;
+
+import java.io.Serial;
+import lombok.Getter;
+import org.springframework.http.HttpStatus;
+
+@Getter
+public class TooManyRequestsException extends RuntimeException {
+ @Serial private static final long serialVersionUID = -2937654540916338509L;
+ private final HttpStatus status;
+
+ public TooManyRequestsException(String message) {
+ super(message);
+ this.status = HttpStatus.TOO_MANY_REQUESTS;
+ }
+}
diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/error/handling/CustomExceptionHandler.java b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/error/handling/CustomExceptionHandler.java
index 9b5e0a76..dfd532b3 100644
--- a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/error/handling/CustomExceptionHandler.java
+++ b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/error/handling/CustomExceptionHandler.java
@@ -5,6 +5,7 @@
import org.bugzkit.api.shared.error.exception.BadRequestException;
import org.bugzkit.api.shared.error.exception.ConflictException;
import org.bugzkit.api.shared.error.exception.ResourceNotFoundException;
+import org.bugzkit.api.shared.error.exception.TooManyRequestsException;
import org.bugzkit.api.shared.error.exception.UnauthorizedException;
import org.bugzkit.api.shared.logger.CustomLogger;
import org.bugzkit.api.shared.message.service.MessageService;
@@ -88,6 +89,12 @@ public ResponseEntity