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:
parent
a3570b6833
commit
e3eaab708c
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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 "";
|
||||||
|
}
|
||||||
|
|
@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user