Add rate-limiting mechanism with AOP for API endpoints

Introduced a rate-limiting feature using Spring AOP and a custom `RateLimit` annotation. Includes `InMemoryRateLimiterService`, `RateLimitAspect`, and related classes for controlling request limits. Applied rate limiting to specific API controllers to enhance system stability and prevent abuse.
This commit is contained in:
Teriuihi 2025-04-17 20:31:14 +02:00
parent a3570b6833
commit e3eaab708c
8 changed files with 212 additions and 2 deletions

View File

@ -35,6 +35,13 @@ dependencies {
testRuntimeOnly("org.junit.platform:junit-platform-launcher") testRuntimeOnly("org.junit.platform:junit-platform-launcher")
implementation("org.springframework.boot:spring-boot-configuration-processor") implementation("org.springframework.boot:spring-boot-configuration-processor")
implementation("org.springframework.boot:spring-boot-starter-hateoas") implementation("org.springframework.boot:spring-boot-starter-hateoas")
//AOP
implementation("org.aspectj:aspectjrt:1.9.19")
implementation("org.aspectj:aspectjweaver:1.9.19")
implementation("org.springframework:spring-aop")
implementation("org.springframework:spring-aspects")
} }
tasks.withType<Test> { tasks.withType<Test> {

View File

@ -1,12 +1,12 @@
package com.alttd.altitudeweb; package com.alttd.altitudeweb;
import com.alttd.altitudeweb.setup.Connection; import com.alttd.altitudeweb.setup.Connection;
import com.alttd.altitudeweb.setup.InitializeLiteBans;
import com.alttd.altitudeweb.setup.InitializeWebDb;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
@SpringBootApplication @SpringBootApplication
@EnableAspectJAutoProxy
public class AltitudeWebApplication { public class AltitudeWebApplication {
public static void main(String[] args) { public static void main(String[] args) {

View File

@ -1,6 +1,7 @@
package com.alttd.altitudeweb.controllers.history; package com.alttd.altitudeweb.controllers.history;
import com.alttd.altitudeweb.api.HistoryApi; import com.alttd.altitudeweb.api.HistoryApi;
import com.alttd.altitudeweb.controllers.limits.RateLimit;
import com.alttd.altitudeweb.model.HistoryCountDto; import com.alttd.altitudeweb.model.HistoryCountDto;
import com.alttd.altitudeweb.setup.Connection; import com.alttd.altitudeweb.setup.Connection;
import com.alttd.altitudeweb.database.Databases; import com.alttd.altitudeweb.database.Databases;
@ -17,6 +18,7 @@ import java.util.concurrent.CompletableFuture;
@Slf4j @Slf4j
@RestController @RestController
@RateLimit(limit = 30, timeValue = 1, timeUnit = java.util.concurrent.TimeUnit.MINUTES)
public class HistoryApiController implements HistoryApi { public class HistoryApiController implements HistoryApi {
@Override @Override

View File

@ -0,0 +1,36 @@
package com.alttd.altitudeweb.controllers.limits;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class InMemoryRateLimiterService {
private final Map<String, RequestCounter> limiters = new ConcurrentHashMap<>();
public boolean tryAcquire(String key, int limit, Duration duration) {
RequestCounter counter = limiters.computeIfAbsent(key, k -> new RequestCounter());
return counter.tryAcquire(limit, duration);
}
public Duration getNextResetTime(String key, Duration duration) {
RequestCounter counter = limiters.get(key);
if (counter == null) {
return Duration.ZERO;
}
return counter.getNextResetTime(duration);
}
public int getRemainingTokens(String key, int limit, Duration duration) {
RequestCounter counter = limiters.get(key);
if (counter == null) {
return limit;
}
return Math.max(0, limit - counter.getCurrentCount(duration));
}
}

View File

@ -0,0 +1,16 @@
package com.alttd.altitudeweb.controllers.limits;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
int limit() default 20;
long timeValue() default 60;
TimeUnit timeUnit() default TimeUnit.SECONDS;
String key() default "";
}

View File

@ -0,0 +1,89 @@
package com.alttd.altitudeweb.controllers.limits;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.lang.reflect.Method;
import java.time.Duration;
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class RateLimitAspect {
private final InMemoryRateLimiterService rateLimiterService;
@Around("""
@annotation(com.alttd.altitudeweb.controllers.limits.RateLimit)
|| @within(com.alttd.altitudeweb.controllers.limits.RateLimit)""")
public Object rateLimit(ProceedingJoinPoint joinPoint) throws Throwable {
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (requestAttributes == null) {
return joinPoint.proceed();
}
HttpServletRequest request = requestAttributes.getRequest();
HttpServletResponse response = requestAttributes.getResponse();
String clientIp = request.getRemoteAddr();
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
RateLimit rateLimit = method.getAnnotation(RateLimit.class);
if (rateLimit == null) {
rateLimit = method.getDeclaringClass().getAnnotation(RateLimit.class);
}
if (rateLimit == null) {
return joinPoint.proceed();
}
int limit = rateLimit.limit();
Duration duration = Duration.ofSeconds(rateLimit.timeUnit().toSeconds(rateLimit.timeValue()));
String customKey = rateLimit.key();
String key = clientIp + "-" + (customKey.isEmpty() ? method.getName() : customKey);
boolean allowed = rateLimiterService.tryAcquire(key, limit, duration);
if (allowed) {
if (response != null) {
response.setHeader("X-Rate-Limit-Limit", String.valueOf(limit));
response.setHeader("X-Rate-Limit-Remaining",
String.valueOf(rateLimiterService.getRemainingTokens(key, limit, duration)));
}
return joinPoint.proceed();
} else {
log.warn("Rate limit exceeded for IP: {}, endpoint: {}", clientIp, request.getRequestURI());
Duration nextResetTime = rateLimiterService.getNextResetTime(key, duration);
if (response != null) {
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.setHeader("X-Rate-Limit-Limit", String.valueOf(limit));
response.setHeader("X-Rate-Limit-Remaining", "0");
response.setHeader("Retry-After", String.valueOf(nextResetTime.getSeconds()));
}
return ResponseEntity
.status(HttpStatus.TOO_MANY_REQUESTS)
.header("X-Rate-Limit-Limit", String.valueOf(limit))
.header("X-Rate-Limit-Remaining", "0")
.header("Retry-After", String.valueOf(nextResetTime.getSeconds()))
.body(String.format("Rate limit exceeded. Try again in %d seconds.", nextResetTime.getSeconds()));
}
}
}

View File

@ -0,0 +1,57 @@
package com.alttd.altitudeweb.controllers.limits;
import java.time.Duration;
import java.time.Instant;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
public class RequestCounter {
private final Queue<Instant> requestTimestamps = new ConcurrentLinkedQueue<>();
/**
* Attempts to acquire a permit for a request within the specified rate limit.
* This method checks if the number of requests made within the given duration
* is less than the specified limit. If the limit has not been exceeded, the
* current request is recorded and the method will return true. Otherwise, it
* will return false.
*
* @param limit the maximum number of requests allowed within the specified duration
* @param duration the time window during which requests are counted against the limit
* @return true if the request is successfully acquired without exceeding the rate limit, false otherwise
*/
public synchronized boolean tryAcquire(int limit, Duration duration) {
Instant currentTime = Instant.now();
Instant cutoffTime = currentTime.minus(duration);
while (!requestTimestamps.isEmpty() && requestTimestamps.peek().isBefore(cutoffTime)) {
requestTimestamps.poll();
}
if (requestTimestamps.size() < limit) {
requestTimestamps.add(currentTime);
return true;
}
return false;
}
public int getCurrentCount(Duration duration) {
Instant cutoffTime = Instant.now().minus(duration);
return (int) requestTimestamps.stream()
.filter(timestamp -> !timestamp.isBefore(cutoffTime))
.count();
}
public Duration getNextResetTime(Duration duration) {
if (requestTimestamps.isEmpty()) {
return Duration.ZERO;
}
Instant oldestTimestamp = requestTimestamps.peek();
Instant resetTime = oldestTimestamp.plus(duration);
Duration timeUntilReset = Duration.between(Instant.now(), resetTime);
return timeUntilReset.isNegative() ? Duration.ZERO : timeUntilReset;
}
}

View File

@ -1,6 +1,7 @@
package com.alttd.altitudeweb.controllers.team; package com.alttd.altitudeweb.controllers.team;
import com.alttd.altitudeweb.api.TeamApi; import com.alttd.altitudeweb.api.TeamApi;
import com.alttd.altitudeweb.controllers.limits.RateLimit;
import com.alttd.altitudeweb.setup.Connection; import com.alttd.altitudeweb.setup.Connection;
import com.alttd.altitudeweb.database.Databases; import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.luckperms.Player; import com.alttd.altitudeweb.database.luckperms.Player;
@ -13,9 +14,11 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
@Slf4j @Slf4j
@RestController @RestController
@RateLimit(limit = 30, timeValue = 1, timeUnit = TimeUnit.MINUTES)
public class TeamApiController implements TeamApi { public class TeamApiController implements TeamApi {
@Override @Override