Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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/`)

Expand Down
13 changes: 9 additions & 4 deletions backend/spring-boot/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@
<guava.version>33.5.0-jre</guava.version>
<jwt.version>4.5.0</jwt.version>
<mapstruct.version>1.6.3</mapstruct.version>
<jedis.version>7.2.1</jedis.version>
<springdoc.version>3.0.1</springdoc.version>
<datafaker.version>2.5.4</datafaker.version>
<bucket4j.version>8.16.1</bucket4j.version>
<jacoco.version>0.8.14</jacoco.version>
<maven-compiler-plugin.version>3.15.0</maven-compiler-plugin.version>
<spotless.version>3.2.1</spotless.version>
Expand Down Expand Up @@ -97,9 +97,14 @@
<version>${mapstruct.version}</version>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>${jedis.version}</version>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-core</artifactId>
<version>${bucket4j.version}</version>
</dependency>
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-lettuce</artifactId>
<version>${bucket4j.version}</version>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -49,12 +50,14 @@ public AuthController(AuthService authService, DeviceService deviceService) {
this.deviceService = deviceService;
}

@RateLimit(requests = 5, duration = 60)
@PostMapping("/register")
public ResponseEntity<UserDTO> register(
@Valid @RequestBody RegisterUserRequest registerUserRequest) {
return new ResponseEntity<>(authService.register(registerUserRequest), HttpStatus.CREATED);
}

@RateLimit(requests = 10, duration = 60)
@PostMapping("/tokens")
public ResponseEntity<Void> authenticate(
@Valid @RequestBody AuthTokensRequest authTokensRequest, HttpServletRequest request) {
Expand Down Expand Up @@ -90,6 +93,7 @@ public ResponseEntity<Void> revokeDevice(@PathVariable String deviceId) {
return ResponseEntity.noContent().build();
}

@RateLimit(requests = 10, duration = 60)
@PostMapping("/tokens/refresh")
public ResponseEntity<Void> refreshTokens(HttpServletRequest request) {
final var refreshToken = AuthUtil.getValueFromCookie("refreshToken", request);
Expand All @@ -98,27 +102,31 @@ public ResponseEntity<Void> refreshTokens(HttpServletRequest request) {
return setAuthCookies(authTokens);
}

@RateLimit(requests = 3, duration = 60)
@PostMapping("/password/forgot")
public ResponseEntity<Void> forgotPassword(
@Valid @RequestBody ForgotPasswordRequest forgotPasswordRequest) {
authService.forgotPassword(forgotPasswordRequest);
return ResponseEntity.noContent().build();
}

@RateLimit(requests = 5, duration = 60)
@PostMapping("/password/reset")
public ResponseEntity<Void> resetPassword(
@Valid @RequestBody ResetPasswordRequest resetPasswordRequest) {
authService.resetPassword(resetPasswordRequest);
return ResponseEntity.noContent().build();
}

@RateLimit(requests = 3, duration = 60)
@PostMapping("/verification-email")
public ResponseEntity<Void> sendVerificationMail(
@Valid @RequestBody VerificationEmailRequest verificationEmailRequest) {
authService.sendVerificationMail(verificationEmailRequest);
return ResponseEntity.noContent().build();
}

@RateLimit(requests = 5, duration = 60)
@PostMapping("/verify-email")
public ResponseEntity<Void> verifyEmail(
@Valid @RequestBody VerifyEmailRequest verifyEmailRequest) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
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;
import org.springframework.context.annotation.Configuration;
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}")
Expand All @@ -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<String, Object> redisTemplate() {
final var template = new RedisTemplate<String, Object>();
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.permitAll()
.requestMatchers(OAUTH2_WHITELIST)
.permitAll()
.requestMatchers("/favicon.ico")
.permitAll()
.anyRequest()
.authenticated())
.oauth2Login(
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -88,6 +89,12 @@ public ResponseEntity<Object> handleConflictException(ConflictException e) {
return createError(e.getStatus(), messageService.getMessage(e.getMessage()));
}

@ExceptionHandler({TooManyRequestsException.class})
public ResponseEntity<Object> handleTooManyRequestsException(TooManyRequestsException e) {
customLogger.error("Too many requests", e);
return createError(e.getStatus(), messageService.getMessage(e.getMessage()));
}

@ExceptionHandler({AuthenticationException.class})
public ResponseEntity<Object> handleAuthenticationException(AuthenticationException e) {
if (e instanceof DisabledException) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
Loading