security: add per-user/IP rate limiting via Bucket4j
RateLimitFilter (OncePerRequestFilter) enforces 60 req/min per authenticated Google ID or client IP, using Bucket4j in-memory token buckets. Filter is registered after BearerTokenAuthenticationFilter in the production security chain. Added 4 unit tests covering allow, block, per-IP isolation, and X-Forwarded-For preference. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
package de.zendric.app.xpensely_server.security;
|
||||
|
||||
import io.github.bucket4j.Bandwidth;
|
||||
import io.github.bucket4j.Bucket;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public class RateLimitFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final int REQUESTS_PER_MINUTE = 60;
|
||||
|
||||
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
String key = resolveKey(request);
|
||||
Bucket bucket = buckets.computeIfAbsent(key, k -> newBucket());
|
||||
|
||||
if (bucket.tryConsume(1)) {
|
||||
filterChain.doFilter(request, response);
|
||||
} else {
|
||||
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
|
||||
response.getWriter().write("Rate limit exceeded");
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveKey(HttpServletRequest request) {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null && auth.getPrincipal() instanceof Jwt jwt) {
|
||||
return "user:" + jwt.getSubject();
|
||||
}
|
||||
String ip = request.getHeader("X-Forwarded-For");
|
||||
if (ip != null && !ip.isBlank()) {
|
||||
return "ip:" + ip.split(",")[0].trim();
|
||||
}
|
||||
return "ip:" + request.getRemoteAddr();
|
||||
}
|
||||
|
||||
private Bucket newBucket() {
|
||||
return Bucket.builder()
|
||||
.addLimit(Bandwidth.builder()
|
||||
.capacity(REQUESTS_PER_MINUTE)
|
||||
.refillGreedy(REQUESTS_PER_MINUTE, Duration.ofMinutes(1))
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationFilter;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
@Configuration
|
||||
@@ -31,6 +32,7 @@ public class SecurityConfig {
|
||||
.oauth2ResourceServer(oauth2 -> oauth2
|
||||
.jwt(Customizer.withDefaults()))
|
||||
.oauth2Login(Customizer.withDefaults())
|
||||
.addFilterAfter(new RateLimitFilter(), BearerTokenAuthenticationFilter.class)
|
||||
.csrf().disable();
|
||||
|
||||
return http.build();
|
||||
|
||||
Reference in New Issue
Block a user