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")
|
||||
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<Test> {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user