From e3eaab708c1b3e57f1736b4f1bd5f104b91da61b Mon Sep 17 00:00:00 2001 From: Teriuihi Date: Thu, 17 Apr 2025 20:31:14 +0200 Subject: [PATCH] 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. --- backend/build.gradle.kts | 7 ++ .../altitudeweb/AltitudeWebApplication.java | 4 +- .../history/HistoryApiController.java | 2 + .../limits/InMemoryRateLimiterService.java | 36 ++++++++ .../controllers/limits/RateLimit.java | 16 ++++ .../controllers/limits/RateLimitAspect.java | 89 +++++++++++++++++++ .../controllers/limits/RequestCounter.java | 57 ++++++++++++ .../controllers/team/TeamApiController.java | 3 + 8 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 backend/src/main/java/com/alttd/altitudeweb/controllers/limits/InMemoryRateLimiterService.java create mode 100644 backend/src/main/java/com/alttd/altitudeweb/controllers/limits/RateLimit.java create mode 100644 backend/src/main/java/com/alttd/altitudeweb/controllers/limits/RateLimitAspect.java create mode 100644 backend/src/main/java/com/alttd/altitudeweb/controllers/limits/RequestCounter.java diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index d2625f1..aecd490 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -35,6 +35,13 @@ dependencies { testRuntimeOnly("org.junit.platform:junit-platform-launcher") implementation("org.springframework.boot:spring-boot-configuration-processor") 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 { diff --git a/backend/src/main/java/com/alttd/altitudeweb/AltitudeWebApplication.java b/backend/src/main/java/com/alttd/altitudeweb/AltitudeWebApplication.java index 3a839ce..48c93fa 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/AltitudeWebApplication.java +++ b/backend/src/main/java/com/alttd/altitudeweb/AltitudeWebApplication.java @@ -1,12 +1,12 @@ package com.alttd.altitudeweb; 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.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.EnableAspectJAutoProxy; @SpringBootApplication +@EnableAspectJAutoProxy public class AltitudeWebApplication { public static void main(String[] args) { diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/history/HistoryApiController.java b/backend/src/main/java/com/alttd/altitudeweb/controllers/history/HistoryApiController.java index 951932d..8011e57 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/controllers/history/HistoryApiController.java +++ b/backend/src/main/java/com/alttd/altitudeweb/controllers/history/HistoryApiController.java @@ -1,6 +1,7 @@ package com.alttd.altitudeweb.controllers.history; import com.alttd.altitudeweb.api.HistoryApi; +import com.alttd.altitudeweb.controllers.limits.RateLimit; import com.alttd.altitudeweb.model.HistoryCountDto; import com.alttd.altitudeweb.setup.Connection; import com.alttd.altitudeweb.database.Databases; @@ -17,6 +18,7 @@ import java.util.concurrent.CompletableFuture; @Slf4j @RestController +@RateLimit(limit = 30, timeValue = 1, timeUnit = java.util.concurrent.TimeUnit.MINUTES) public class HistoryApiController implements HistoryApi { @Override diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/limits/InMemoryRateLimiterService.java b/backend/src/main/java/com/alttd/altitudeweb/controllers/limits/InMemoryRateLimiterService.java new file mode 100644 index 0000000..a6c27e6 --- /dev/null +++ b/backend/src/main/java/com/alttd/altitudeweb/controllers/limits/InMemoryRateLimiterService.java @@ -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 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)); + } + + +} diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/limits/RateLimit.java b/backend/src/main/java/com/alttd/altitudeweb/controllers/limits/RateLimit.java new file mode 100644 index 0000000..020a6c5 --- /dev/null +++ b/backend/src/main/java/com/alttd/altitudeweb/controllers/limits/RateLimit.java @@ -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 ""; +} diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/limits/RateLimitAspect.java b/backend/src/main/java/com/alttd/altitudeweb/controllers/limits/RateLimitAspect.java new file mode 100644 index 0000000..0f80248 --- /dev/null +++ b/backend/src/main/java/com/alttd/altitudeweb/controllers/limits/RateLimitAspect.java @@ -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())); + } + } +} diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/limits/RequestCounter.java b/backend/src/main/java/com/alttd/altitudeweb/controllers/limits/RequestCounter.java new file mode 100644 index 0000000..4448367 --- /dev/null +++ b/backend/src/main/java/com/alttd/altitudeweb/controllers/limits/RequestCounter.java @@ -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 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; + } +} diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/team/TeamApiController.java b/backend/src/main/java/com/alttd/altitudeweb/controllers/team/TeamApiController.java index e29d669..3f69878 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/controllers/team/TeamApiController.java +++ b/backend/src/main/java/com/alttd/altitudeweb/controllers/team/TeamApiController.java @@ -1,6 +1,7 @@ package com.alttd.altitudeweb.controllers.team; import com.alttd.altitudeweb.api.TeamApi; +import com.alttd.altitudeweb.controllers.limits.RateLimit; import com.alttd.altitudeweb.setup.Connection; import com.alttd.altitudeweb.database.Databases; 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.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; @Slf4j @RestController +@RateLimit(limit = 30, timeValue = 1, timeUnit = TimeUnit.MINUTES) public class TeamApiController implements TeamApi { @Override