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 handleConflictException(ConflictException e) { return createError(e.getStatus(), messageService.getMessage(e.getMessage())); } + @ExceptionHandler({TooManyRequestsException.class}) + public ResponseEntity handleTooManyRequestsException(TooManyRequestsException e) { + customLogger.error("Too many requests", e); + return createError(e.getStatus(), messageService.getMessage(e.getMessage())); + } + @ExceptionHandler({AuthenticationException.class}) public ResponseEntity handleAuthenticationException(AuthenticationException e) { if (e instanceof DisabledException) { diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimit.java b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimit.java new file mode 100644 index 00000000..04159b03 --- /dev/null +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimit.java @@ -0,0 +1,14 @@ +package org.bugzkit.api.shared.ratelimit; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface RateLimit { + int requests(); + + int duration(); +} diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimitInterceptor.java b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimitInterceptor.java new file mode 100644 index 00000000..376cbf8d --- /dev/null +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimitInterceptor.java @@ -0,0 +1,129 @@ +package org.bugzkit.api.shared.ratelimit; + +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.BucketConfiguration; +import io.github.bucket4j.ConsumptionProbe; +import io.github.bucket4j.distributed.ExpirationAfterWriteStrategy; +import io.github.bucket4j.redis.lettuce.Bucket4jLettuce; +import io.github.bucket4j.redis.lettuce.cas.LettuceBasedProxyManager; +import io.lettuce.core.RedisClient; +import io.lettuce.core.codec.ByteArrayCodec; +import io.lettuce.core.codec.RedisCodec; +import io.lettuce.core.codec.StringCodec; +import jakarta.annotation.Nonnull; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.time.Duration; +import java.util.Arrays; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import org.bugzkit.api.shared.error.ErrorMessage; +import org.bugzkit.api.shared.message.service.MessageService; +import org.bugzkit.api.shared.util.Utils; +import org.springframework.aop.support.AopUtils; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Controller; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +public class RateLimitInterceptor implements HandlerInterceptor, SmartInitializingSingleton { + private final MessageService messageService; + private final RedisClient lettuceClient; + private final ApplicationContext applicationContext; + private final Map configCache = new ConcurrentHashMap<>(); + private LettuceBasedProxyManager proxyManager; + + @Value("${rate-limit.enabled}") + private boolean enabled; + + @Value("${server.client-ip-header}") + private String clientIpHeader; + + public RateLimitInterceptor( + MessageService messageService, + RedisClient lettuceClient, + ApplicationContext applicationContext) { + this.messageService = messageService; + this.lettuceClient = lettuceClient; + this.applicationContext = applicationContext; + } + + @Override + public void afterSingletonsInstantiated() { + final var connection = + lettuceClient.connect(RedisCodec.of(StringCodec.UTF8, ByteArrayCodec.INSTANCE)); + final var maxDuration = resolveMaxRateLimitDuration(); + this.proxyManager = + Bucket4jLettuce.casBasedBuilder(connection) + .expirationAfterWrite( + ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax( + Duration.ofSeconds(maxDuration))) + .build(); + } + + private long resolveMaxRateLimitDuration() { + return applicationContext.getBeansWithAnnotation(Controller.class).values().stream() + .flatMap(bean -> Arrays.stream(AopUtils.getTargetClass(bean).getMethods())) + .map(method -> method.getAnnotation(RateLimit.class)) + .filter(Objects::nonNull) + .mapToLong(RateLimit::duration) + .max() + .orElse(60L); + } + + @Override + public boolean preHandle( + @Nonnull HttpServletRequest request, + @Nonnull HttpServletResponse response, + @Nonnull Object handler) + throws Exception { + if (!enabled) return true; + if (!(handler instanceof HandlerMethod handlerMethod)) return true; + final var rateLimit = handlerMethod.getMethodAnnotation(RateLimit.class); + if (rateLimit == null) return true; + + final var ip = Utils.resolveClientIp(request, clientIpHeader); + final var endpointKey = + handlerMethod.getBeanType().getSimpleName() + ":" + handlerMethod.getMethod().getName(); + final var bucketKey = "rate-limit:" + endpointKey + ":" + ip; + final var config = + configCache.computeIfAbsent(endpointKey, k -> buildBucketConfiguration(rateLimit)); + final var bucket = proxyManager.getProxy(bucketKey, () -> config); + final var probe = bucket.tryConsumeAndReturnRemaining(1); + + if (probe.isConsumed()) return true; + + writeRateLimitResponse(response, probe); + return false; + } + + private BucketConfiguration buildBucketConfiguration(RateLimit rateLimit) { + final var bandwidth = + Bandwidth.builder() + .capacity(rateLimit.requests()) + .refillGreedy(rateLimit.requests(), Duration.ofSeconds(rateLimit.duration())) + .build(); + return BucketConfiguration.builder().addLimit(bandwidth).build(); + } + + private void writeRateLimitResponse(HttpServletResponse response, ConsumptionProbe probe) + throws Exception { + final var retryAfter = TimeUnit.NANOSECONDS.toSeconds(probe.getNanosToWaitForRefill()); + final var status = HttpStatus.TOO_MANY_REQUESTS; + final var errorMessage = new ErrorMessage(status); + errorMessage.addCode(messageService.getMessage("request.tooManyRequests")); + + response.setStatus(status.value()); + response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE); + response.setHeader("Retry-After", String.valueOf(retryAfter)); + response.getWriter().write(errorMessage.toString()); + } +} diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/util/Utils.java b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/util/Utils.java new file mode 100644 index 00000000..e099f4a5 --- /dev/null +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/util/Utils.java @@ -0,0 +1,17 @@ +package org.bugzkit.api.shared.util; + +import jakarta.servlet.http.HttpServletRequest; + +public final class Utils { + private Utils() {} + + public static String resolveClientIp(HttpServletRequest request, String clientIpHeader) { + if (clientIpHeader != null && !clientIpHeader.isBlank()) { + final var ip = request.getHeader(clientIpHeader); + if (ip != null && !ip.isBlank()) { + return ip; + } + } + return request.getRemoteAddr(); + } +} diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/user/controller/ProfileController.java b/backend/spring-boot/src/main/java/org/bugzkit/api/user/controller/ProfileController.java index dcb48d88..e4902fd7 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/user/controller/ProfileController.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/user/controller/ProfileController.java @@ -2,6 +2,7 @@ import jakarta.validation.Valid; import org.bugzkit.api.shared.constants.Path; +import org.bugzkit.api.shared.ratelimit.RateLimit; import org.bugzkit.api.user.payload.dto.UserDTO; import org.bugzkit.api.user.payload.request.ChangePasswordRequest; import org.bugzkit.api.user.payload.request.PatchProfileRequest; @@ -40,6 +41,7 @@ public ResponseEntity delete() { return ResponseEntity.noContent().build(); } + @RateLimit(requests = 5, duration = 60) @PatchMapping("/password") public ResponseEntity changePassword( @Valid @RequestBody ChangePasswordRequest changePasswordRequest) { diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/user/controller/UserController.java b/backend/spring-boot/src/main/java/org/bugzkit/api/user/controller/UserController.java index 2676cb3c..a6106f1e 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/user/controller/UserController.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/user/controller/UserController.java @@ -4,6 +4,7 @@ import org.bugzkit.api.shared.constants.Path; import org.bugzkit.api.shared.payload.dto.AvailabilityDTO; import org.bugzkit.api.shared.payload.dto.PageableDTO; +import org.bugzkit.api.shared.ratelimit.RateLimit; import org.bugzkit.api.user.payload.dto.UserDTO; import org.bugzkit.api.user.payload.request.EmailAvailabilityRequest; import org.bugzkit.api.user.payload.request.UsernameAvailabilityRequest; @@ -41,12 +42,14 @@ public ResponseEntity findByUsername(@PathVariable("username") String u return ResponseEntity.ok(userService.findByUsername(username)); } + @RateLimit(requests = 10, duration = 60) @PostMapping("/username/availability") public ResponseEntity usernameAvailability( @Valid @RequestBody UsernameAvailabilityRequest usernameAvailabilityRequest) { return ResponseEntity.ok(userService.usernameAvailability(usernameAvailabilityRequest)); } + @RateLimit(requests = 10, duration = 60) @PostMapping("/email/availability") public ResponseEntity emailAvailability( @Valid @RequestBody EmailAvailabilityRequest emailAvailabilityRequest) { diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/user/payload/dto/UserDTO.java b/backend/spring-boot/src/main/java/org/bugzkit/api/user/payload/dto/UserDTO.java index 03b27c93..e1990896 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/user/payload/dto/UserDTO.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/user/payload/dto/UserDTO.java @@ -4,7 +4,6 @@ import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.annotation.JsonProperty; import com.google.common.base.Objects; -import com.google.gson.annotations.SerializedName; import java.time.LocalDateTime; import java.util.Set; import lombok.Builder; @@ -17,8 +16,7 @@ public record UserDTO( @JsonInclude(Include.NON_NULL) Boolean active, @JsonInclude(Include.NON_NULL) Boolean lock, LocalDateTime createdAt, - @JsonInclude(Include.NON_NULL) @JsonProperty("roles") @SerializedName("roles") - Set roleDTOs) { + @JsonInclude(Include.NON_NULL) @JsonProperty("roles") Set roleDTOs) { @Override public boolean equals(Object o) { diff --git a/backend/spring-boot/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/backend/spring-boot/src/main/resources/META-INF/additional-spring-configuration-metadata.json index d5e773d9..b00c40b4 100644 --- a/backend/spring-boot/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/backend/spring-boot/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -39,6 +39,11 @@ "name": "jwt.reset-password-token.duration", "type": "java.lang.String", "description": "Reset password token duration time." + }, + { + "name": "rate-limit.enabled", + "type": "java.lang.Boolean", + "description": "Enable or disable rate limiter." } ] } diff --git a/backend/spring-boot/src/main/resources/application-dev.yml b/backend/spring-boot/src/main/resources/application-dev.yml index 24458e75..b11c1093 100644 --- a/backend/spring-boot/src/main/resources/application-dev.yml +++ b/backend/spring-boot/src/main/resources/application-dev.yml @@ -4,6 +4,9 @@ ui: domain: name: ${DOMAIN_NAME:localhost} +server: + client-ip-header: ${CLIENT_IP_HEADER:} + spring: datasource: url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DATABASE:bugzkit} diff --git a/backend/spring-boot/src/main/resources/application-prod.yml b/backend/spring-boot/src/main/resources/application-prod.yml index 59ee801f..042e9735 100644 --- a/backend/spring-boot/src/main/resources/application-prod.yml +++ b/backend/spring-boot/src/main/resources/application-prod.yml @@ -6,6 +6,7 @@ domain: server: forward-headers-strategy: native + client-ip-header: ${CLIENT_IP_HEADER:CF-Connecting-IP} spring: config: diff --git a/backend/spring-boot/src/main/resources/application.yml b/backend/spring-boot/src/main/resources/application.yml index 1b469c53..7389001b 100644 --- a/backend/spring-boot/src/main/resources/application.yml +++ b/backend/spring-boot/src/main/resources/application.yml @@ -50,6 +50,9 @@ jwt: reset-password-token: duration: ${RESET_PASSWORD_TOKEN_DURATION:900} +rate-limit: + enabled: true + springdoc: swagger-ui: url: /openapi.yml diff --git a/backend/spring-boot/src/main/resources/error-codes.properties b/backend/spring-boot/src/main/resources/error-codes.properties index 24b359c0..f188c74d 100644 --- a/backend/spring-boot/src/main/resources/error-codes.properties +++ b/backend/spring-boot/src/main/resources/error-codes.properties @@ -24,6 +24,7 @@ user.lock=API_ERROR_USER_LOCK user.rolesEmpty=API_ERROR_USER_ROLES_EMPTY user.roleNotFound=API_ERROR_USER_ROLE_NOT_FOUND +request.tooManyRequests=API_ERROR_REQUEST_TOO_MANY_REQUESTS request.parameterMissing=API_ERROR_REQUEST_PARAMETER_MISSING request.methodNotSupported=API_ERROR_REQUEST_METHOD_NOT_SUPPORTED request.messageNotReadable=API_ERROR_REQUEST_MESSAGE_NOT_READABLE diff --git a/backend/spring-boot/src/main/resources/static/openapi.yml b/backend/spring-boot/src/main/resources/static/openapi.yml index 930576e2..e42d40e3 100644 --- a/backend/spring-boot/src/main/resources/static/openapi.yml +++ b/backend/spring-boot/src/main/resources/static/openapi.yml @@ -47,6 +47,8 @@ paths: $ref: "#/components/responses/BadRequest" 409: $ref: "#/components/responses/Conflict" + 429: + $ref: "#/components/responses/TooManyRequests" /auth/tokens: post: tags: @@ -72,6 +74,8 @@ paths: $ref: "#/components/responses/Unauthorized" 403: $ref: "#/components/responses/Forbidden" + 429: + $ref: "#/components/responses/TooManyRequests" delete: tags: - auth @@ -178,6 +182,8 @@ paths: - "refreshToken=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJyb2xlcyI6WyJVU0VSIl0sImlzc3VlZEF0IjoiMjAyMS0xMC0xOVQxMzowMDowMy45MDYzNDQyMDJaIiwiZXhwIjoxNjM1MjUzMjAzLCJ1c2VySWQiOjJ9.RHzh6qyGJEKYdvCuCF7wPoUGBSrDGeoY8dSTBhuv21Fzw_CPEa5KeI3MOYgSN3zA1o_ZlKwjHgpSsPM3xAO_DQ; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800" 400: $ref: "#/components/responses/BadRequest" + 429: + $ref: "#/components/responses/TooManyRequests" /auth/password/forgot: post: tags: @@ -188,6 +194,8 @@ paths: responses: 204: description: Forgot password email sent successfully + 429: + $ref: "#/components/responses/TooManyRequests" /auth/password/reset: post: tags: @@ -200,6 +208,8 @@ paths: description: Password reset successfully 400: $ref: "#/components/responses/BadRequest" + 429: + $ref: "#/components/responses/TooManyRequests" /auth/verification-email: post: tags: @@ -210,6 +220,8 @@ paths: responses: 204: description: Verification email sent successfully + 429: + $ref: "#/components/responses/TooManyRequests" /auth/verify-email: post: tags: @@ -222,6 +234,8 @@ paths: description: Email verified successfully 400: $ref: "#/components/responses/BadRequest" + 429: + $ref: "#/components/responses/TooManyRequests" /profile: get: tags: @@ -296,6 +310,8 @@ paths: $ref: "#/components/responses/BadRequest" 401: $ref: "#/components/responses/Unauthorized" + 429: + $ref: "#/components/responses/TooManyRequests" /users: get: tags: @@ -397,6 +413,8 @@ paths: available: true 400: $ref: "#/components/responses/BadRequest" + 429: + $ref: "#/components/responses/TooManyRequests" /users/email/availability: post: tags: @@ -415,6 +433,8 @@ paths: available: true 400: $ref: "#/components/responses/BadRequest" + 429: + $ref: "#/components/responses/TooManyRequests" /roles: get: tags: @@ -711,6 +731,23 @@ components: error: Conflict codes: - API_ERROR_1 + TooManyRequests: + description: Too Many Requests + headers: + Retry-After: + schema: + type: integer + description: Seconds until the rate limit resets + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorMessage" + example: + timestamp: 2023-05-18T00:51:50.758738 + status: 429 + error: Too Many Requests + codes: + - API_ERROR_REQUEST_TOO_MANY_REQUESTS parameters: id: name: id diff --git a/backend/spring-boot/src/test/java/org/bugzkit/api/shared/config/DatabaseContainers.java b/backend/spring-boot/src/test/java/org/bugzkit/api/shared/config/DatabaseContainers.java index 480a46dd..97ab1a9c 100644 --- a/backend/spring-boot/src/test/java/org/bugzkit/api/shared/config/DatabaseContainers.java +++ b/backend/spring-boot/src/test/java/org/bugzkit/api/shared/config/DatabaseContainers.java @@ -1,21 +1,29 @@ package org.bugzkit.api.shared.config; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.postgresql.PostgreSQLContainer; import org.testcontainers.utility.DockerImageName; @Testcontainers public abstract class DatabaseContainers { @Container @ServiceConnection - static PostgreSQLContainer postgres = - new PostgreSQLContainer<>(DockerImageName.parse("postgres").withTag("latest")); + static PostgreSQLContainer postgres = + new PostgreSQLContainer(DockerImageName.parse("postgres").withTag("latest")); @Container - @ServiceConnection(name = "redis") static GenericContainer redis = new GenericContainer<>(DockerImageName.parse("redis").withTag("latest")) - .withExposedPorts(6379); + .withExposedPorts(6379) + .withCommand("redis-server", "--requirepass", "root"); + + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", redis::getHost); + registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379)); + } } diff --git a/backend/spring-boot/src/test/java/org/bugzkit/api/shared/integration/RateLimitIT.java b/backend/spring-boot/src/test/java/org/bugzkit/api/shared/integration/RateLimitIT.java new file mode 100644 index 00000000..a3a10451 --- /dev/null +++ b/backend/spring-boot/src/test/java/org/bugzkit/api/shared/integration/RateLimitIT.java @@ -0,0 +1,151 @@ +package org.bugzkit.api.shared.integration; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.bugzkit.api.auth.payload.request.ForgotPasswordRequest; +import org.bugzkit.api.auth.payload.request.VerificationEmailRequest; +import org.bugzkit.api.shared.config.DatabaseContainers; +import org.bugzkit.api.shared.constants.Path; +import org.bugzkit.api.shared.email.service.EmailService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import tools.jackson.databind.ObjectMapper; + +@DirtiesContext +@AutoConfigureMockMvc +@ActiveProfiles("test") +@SpringBootTest +@TestPropertySource(properties = "rate-limit.enabled=true") +class RateLimitIT extends DatabaseContainers { + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @MockitoBean private EmailService emailService; + + @Value("${server.client-ip-header}") + private String clientIpHeader; + + @Test + void forgotPassword_withinLimitSucceeds_exceedLimitReturns429() throws Exception { + final var body = objectMapper.writeValueAsString(new ForgotPasswordRequest("test@example.com")); + + // 3 requests within limit should succeed + for (int i = 0; i < 3; i++) { + mockMvc + .perform( + post(Path.AUTH + "/password/forgot") + .header(clientIpHeader, "10.0.0.1") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isNoContent()); + } + + // 4th request should be rate limited + mockMvc + .perform( + post(Path.AUTH + "/password/forgot") + .header(clientIpHeader, "10.0.0.1") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isTooManyRequests()) + .andExpect(content().string(containsString("API_ERROR_REQUEST_TOO_MANY_REQUESTS"))) + .andExpect(header().exists("Retry-After")); + } + + @Test + void rateLimitedEndpoints_haveIndependentBuckets() throws Exception { + final var forgotBody = + objectMapper.writeValueAsString(new ForgotPasswordRequest("test@example.com")); + final var verificationBody = + objectMapper.writeValueAsString(new VerificationEmailRequest("test@example.com")); + + // Exhaust forgot-password limit (3 requests) + for (int i = 0; i < 3; i++) { + mockMvc + .perform( + post(Path.AUTH + "/password/forgot") + .header(clientIpHeader, "10.0.0.2") + .contentType(MediaType.APPLICATION_JSON) + .content(forgotBody)) + .andExpect(status().isNoContent()); + } + + // Forgot password is exhausted + mockMvc + .perform( + post(Path.AUTH + "/password/forgot") + .header(clientIpHeader, "10.0.0.2") + .contentType(MediaType.APPLICATION_JSON) + .content(forgotBody)) + .andExpect(status().isTooManyRequests()); + + // Verification email should still work (separate bucket) + mockMvc + .perform( + post(Path.AUTH + "/verification-email") + .header(clientIpHeader, "10.0.0.2") + .contentType(MediaType.APPLICATION_JSON) + .content(verificationBody)) + .andExpect(status().isNoContent()); + } + + @Test + void rateLimitIsPerIp_differentIpsHaveIndependentBuckets() throws Exception { + final var body = objectMapper.writeValueAsString(new ForgotPasswordRequest("test@example.com")); + + // Exhaust limit for IP 10.0.0.3 + for (int i = 0; i < 3; i++) { + mockMvc + .perform( + post(Path.AUTH + "/password/forgot") + .header(clientIpHeader, "10.0.0.3") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isNoContent()); + } + + // 10.0.0.3 is rate limited + mockMvc + .perform( + post(Path.AUTH + "/password/forgot") + .header(clientIpHeader, "10.0.0.3") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isTooManyRequests()); + + // 10.0.0.4 should still work (different IP, different bucket) + mockMvc + .perform( + post(Path.AUTH + "/password/forgot") + .header(clientIpHeader, "10.0.0.4") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isNoContent()); + } + + @Test + void endpointWithoutRateLimit_neverThrottled() throws Exception { + // DELETE /auth/tokens has no @RateLimit annotation + for (int i = 0; i < 20; i++) { + mockMvc + .perform( + delete(Path.AUTH + "/tokens") + .header(clientIpHeader, "10.0.0.5") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNoContent()); + } + } +} diff --git a/backend/spring-boot/src/test/resources/application.yml b/backend/spring-boot/src/test/resources/application.yml index 639a44d8..251dced9 100644 --- a/backend/spring-boot/src/test/resources/application.yml +++ b/backend/spring-boot/src/test/resources/application.yml @@ -2,6 +2,7 @@ app: name: ${APP_NAME:bugzkit} server: port: ${API_PORT:8080} + client-ip-header: ${CLIENT_IP_HEADER:CF-Connecting-IP} ui: url: ${UI_URL:http://localhost:5173} domain: @@ -72,6 +73,9 @@ jwt: reset-password-token: duration: ${RESET_PASSWORD_TOKEN_DURATION:900} +rate-limit: + enabled: false + springdoc: swagger-ui: url: /openapi.yml diff --git a/docs/src/content/deploy.mdx b/docs/src/content/deploy.mdx index 2ff68337..9e5ec348 100644 --- a/docs/src/content/deploy.mdx +++ b/docs/src/content/deploy.mdx @@ -54,3 +54,22 @@ To deploy manually on a Docker Swarm node: ```bash docker stack deploy -c docker-stack.prod.yml bugzkit ``` + +### Client IP Resolution + +The production profile is configured for a Cloudflare + Traefik setup out of the box: + +- `server.forward-headers-strategy: native` — Tomcat resolves the real client IP from `X-Forwarded-For` set by Traefik. +- `server.client-ip-header: CF-Connecting-IP` — the API reads Cloudflare's header for the real visitor IP, which cannot be spoofed by clients. + +If you use a different CDN or no CDN at all, override the `CLIENT_IP_HEADER` environment variable: + +| Setup | `CLIENT_IP_HEADER` | +| ------------------------ | ------------------ | +| Cloudflare (default) | `CF-Connecting-IP` | +| Fastly | `Fastly-Client-IP` | +| Akamai | `True-Client-IP` | +| nginx (`real_ip` module) | `X-Real-IP` | +| Traefik only (no CDN) | _(unset)_ | + +When unset, the server falls back to `getRemoteAddr()` resolved by Tomcat from `X-Forwarded-For`, which is correct for a Traefik-only deployment. diff --git a/docs/src/content/environment-variables.mdx b/docs/src/content/environment-variables.mdx index 6dc079b3..a6d04275 100644 --- a/docs/src/content/environment-variables.mdx +++ b/docs/src/content/environment-variables.mdx @@ -29,3 +29,4 @@ | VERIFY_EMAIL_TOKEN_DURATION | Duration of the verify email token in seconds | 900 (15 minutes) | | RESET_PASSWORD_TOKEN_DURATION | Duration of the reset password token in seconds | 900 (15 minutes) | | USER_PASSWORD | Password for auto-generated Spring Security users | qwerty123 | +| CLIENT_IP_HEADER | Header used to resolve real client IP (e.g. behind a proxy) | CF-Connecting-IP | diff --git a/docs/src/content/how-it-works/_meta.js b/docs/src/content/how-it-works/_meta.js index 0f73865f..852231e5 100644 --- a/docs/src/content/how-it-works/_meta.js +++ b/docs/src/content/how-it-works/_meta.js @@ -4,6 +4,7 @@ const config = { auth: 'Auth', 'error-handling': 'Error Handling', i18n: 'Internationalization', + 'rate-limiting': 'Rate Limiting', email: 'Email', testing: 'Testing', }; diff --git a/docs/src/content/how-it-works/rate-limiting.mdx b/docs/src/content/how-it-works/rate-limiting.mdx new file mode 100644 index 00000000..26e1373a --- /dev/null +++ b/docs/src/content/how-it-works/rate-limiting.mdx @@ -0,0 +1,52 @@ +## Rate Limiting + +The backend uses IP-based rate limiting to protect sensitive endpoints from brute force attacks and abuse. It is built with [Bucket4j](https://github.com/bucket4j/bucket4j) (token bucket algorithm) and Redis for distributed bucket storage. + +### How It Works + +A custom `@RateLimit` annotation is applied to controller methods that need throttling: + +```java +@RateLimit(requests = 10, duration = 60) +@PostMapping("/authenticate") +public ResponseEntity authenticate(...) { ... } +``` + +- `requests` — maximum number of requests allowed in the window. +- `duration` — window size in seconds. + +When a request comes in, the `RateLimitInterceptor` checks if the handler method has a `@RateLimit` annotation. If it does, a token bucket is resolved for the client's IP and endpoint combination. If the bucket is exhausted, a `429 Too Many Requests` response is returned with a `Retry-After` header. + +### Client IP Resolution + +The client IP is resolved via `HttpRequestUtil.resolveClientIp()`. It checks the configured `server.client-ip-header` first (a CDN-specific header such as `CF-Connecting-IP` for Cloudflare), falling back to `request.getRemoteAddr()`. In production, Tomcat's `RemoteIpValve` (`server.forward-headers-strategy: native`) ensures `getRemoteAddr()` reflects the real client IP from `X-Forwarded-For` set by Traefik. See the [Deploy](/deploy) page for configuration details. + +### Rate-Limited Endpoints + +| Endpoint | Limit | +| ----------------------------------- | ------------ | +| `POST /auth/authenticate` | 10 req / 60s | +| `POST /auth/register` | 5 req / 60s | +| `POST /auth/tokens/refresh` | 10 req / 60s | +| `POST /auth/password/forgot` | 3 req / 60s | +| `POST /auth/password/reset` | 5 req / 60s | +| `POST /auth/verification-email` | 3 req / 60s | +| `POST /auth/verify-email` | 5 req / 60s | +| `POST /users/username/availability` | 10 req / 60s | +| `POST /users/email/availability` | 10 req / 60s | +| `PATCH /profile/password` | 5 req / 60s | + +### Bucket Isolation + +Each rate limit bucket is scoped to a **specific endpoint + client IP** combination. This means: + +- Different endpoints have independent limits — exhausting the login limit does not affect forgot-password. +- Different IPs have independent limits — one client being throttled does not affect others. + +### Configuration + +Rate limiting is enabled by default. It can be disabled by setting `rate-limit.enabled` to `false` in `application.yml`. This is used in the test profile to prevent interference with integration tests. + +### Storage + +Buckets are stored in Redis via Bucket4j's Lettuce integration. Redis keys are namespaced as `rate-limit:::` and expire automatically using a `basedOnTimeForRefillingBucketUpToMax` strategy — keys live only as long as the longest configured rate limit window. Since buckets are stored in the same Redis instance used for token storage, no additional infrastructure is required. This also makes rate limiting work correctly across multiple API instances.