The security is very much essential for any system whether it is deployed on public domain or in on-premises.
Let's start to implement the Rate Limiter using Bucket4j.
Step-1:
Add Required Bucket4j Maven Dependency in Spring MVC application
<dependencies>
<dependency>
<groupId>com.github.vladimir-bukhtoyarov</groupId>
<artifactId>bucket4j-core</artifactId>
<version>7.3.0</version>
</dependency>
</dependencies>
All maven dependencies used in this code are as follows:
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.errorConsole</groupId>
<artifactId>RequestRateLimiter</artifactId>
<version>0.0.1-SNAPSHOT</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.7.RELEASE</version>
<relativePath />
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<!-- <version>2.17.2</version> -->
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<!-- <version>2.17.2</version> -->
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.vladimir-bukhtoyarov</groupId>
<artifactId>bucket4j-core</artifactId>
<version>7.3.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<!-- Maven Compiler Plugin -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.0</version>
<configuration>
<source>8</source>
<target>8</target>
</configuration>
</plugin>
</plugins>
</build>
</project>
Step-2:
Create SpringBoot based RequestRateLimiterApplication.java
class to start the application
package com.errorConsole;
import java.io.Serializable;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
@SpringBootApplication
public class RequestRateLimiterApplication extends SpringBootServletInitializer implements Serializable {
/**
*
*/
private static final long serialVersionUID = 912138882851139387L;
private static final Logger LOG = LogManager.getLogger(RequestRateLimiterApplication.class);
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
return application.sources(RequestRateLimiterApplication.class); // Application main class
}
public static void main(String[] args) {
try {
SpringApplication.run(RequestRateLimiterApplication.class, args);
} catch (Exception e) {
LOG.error("Error : {}", ExceptionUtils.getStackTrace(e));
}
}
}
Step-3:
Create LimitTestController.class
as Spring REST Controller
package com.errorConsole.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class LimitTestController {
@GetMapping("/testController/{id}")
Integer getNextValue(@PathVariable Integer id) {
//as demo the below loop is added as computation operation for each request
//the respective services layer call can be added here in actual scenarios
int sum = 0;
for(int i=0; i<1000000; i++) {
sum = sum + i;
}
return id+sum;
}
}
Step-4:
Add RateLimiterService.class
file
package com.errorConsole.service;
import io.github.bucket4j.Bucket;
public interface RateLimiterService {
Bucket resolveBucket(String ipAddress);
}
Step-5:
Add the service implementor class RateLimiterServiceImpl.class
package com.errorConsole.service.impl;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.stereotype.Service;
import com.errorConsole.service.RateLimiterService;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.Refill;
@Service
public class RateLimiterServiceImpl implements RateLimiterService {
private static final Logger LOG = LogManager.getLogger(RateLimiterServiceImpl.class);
//Temporary Cache to store ipAddress in token bucket ( we can use any other caching method as well like ehcache )
Map<String, Bucket> bucketCache = new ConcurrentHashMap<>();
private static Integer maxBucketSize = 100;
@Override
public Bucket resolveBucket(String ipAddress) {
LOG.info("bucketCache size = {}", bucketCache.size());
return bucketCache.computeIfAbsent(ipAddress, this::newBucket);
}
private Bucket newBucket(String s) {
//return Bucket4j.builder().addLimit(Bandwidth.classic(10, Refill.intervally(1000, Duration.ofMinutes(1)))).build();
Refill refill = Refill.intervally(20, Duration.ofSeconds(10));
return Bucket.builder().addLimit(Bandwidth.classic(maxBucketSize, refill)).build();
}
}
Step-6:
Add HttpRequestUtil.class
to retrieve the IP address from the HTTP request
package com.errorConsole.utils;
import java.io.Serializable;
import javax.servlet.http.HttpServletRequest;
public class HttpRequestUtil implements Serializable{
public static String getIpAddress(HttpServletRequest request) {
String ip = request.getHeader("x-forwarded-for");
if (ip == null || ip.length() == 0 || ip.equalsIgnoreCase("unknown")) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || ip.equalsIgnoreCase("unknown")) {
ip = request.getHeader("x-real-ip");
}
if (ip == null || ip.length() == 0 || ip.equalsIgnoreCase("unknown")) {
ip = request.getHeader("x-forwarded-server");
}
if (ip == null || ip.length() == 0 || ip.equalsIgnoreCase("unknown")) {
ip = request.getHeader("x-forwarded-host");
}
if (ip == null || ip.length() == 0 || ip.equalsIgnoreCase("unknown")) {
ip = request.getHeader("cf-connecting-ip");
}
if (ip == null || ip.length() == 0 || ip.equalsIgnoreCase("unknown")) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || ip.equalsIgnoreCase("unknown")) {
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
}
if (ip == null || ip.length() == 0 || ip.equalsIgnoreCase("unknown")) {
ip = request.getHeader("HTTP_X_FORWARDED");
}
if (ip == null || ip.length() == 0 || ip.equalsIgnoreCase("unknown")) {
ip = request.getHeader("HTTP_X_CLUSTER_CLIENT_IP");
}
if (ip == null || ip.length() == 0 || ip.equalsIgnoreCase("unknown")) {
ip = request.getHeader("HTTP_CLIENT_IP");
}
if (ip == null || ip.length() == 0 || ip.equalsIgnoreCase("unknown")) {
ip = request.getHeader("HTTP_FORWARDED_FOR");
}
if (ip == null || ip.length() == 0 || ip.equalsIgnoreCase("unknown")) {
ip = request.getHeader("HTTP_FORWARDED");
}
if (ip == null || ip.length() == 0 || ip.equalsIgnoreCase("unknown")) {
ip = request.getHeader("HTTP_VIA");
}
if (ip == null || ip.length() == 0 || ip.equalsIgnoreCase("unknown")) {
ip = request.getHeader("REMOTE_ADDR");
}
if (ip == null || ip.length() == 0 || ip.equalsIgnoreCase("unknown")) {
ip = request.getRemoteAddr();
}
return ip;
}
}
Step-7:
Add HTTP Security using HttpSecurityConfig.java
class which implements WebMvcConfigurer
interface from Spring-MVC JAR.
We need to override the addInterceptors
method. This method uses the InterceptorRegistry.class
where we need to add a new interceptor HandlerInterceptor
and override the preHandle
method to put our custom logic.
If the incoming request is in between the defined threshold range and time frame then the request is allowed to process otherwise the error response code with status 429
is return alongwith the message Too many requests !!!
package com.errorConsole.interceptor;
import java.io.IOException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import com.errorConsole.service.RateLimiterService;
import com.errorConsole.utils.HttpRequestUtil;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.ConsumptionProbe;
@Configuration
public class HttpSecurityConfig implements WebMvcConfigurer {
@Autowired
private RateLimiterService rateLimiterService;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new HandlerInterceptor() {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
String ipAddress = HttpRequestUtil.getIpAddress(request);
ipAddress = ipAddress + "_" + request.getServletPath();
Bucket bucket = rateLimiterService.resolveBucket(ipAddress);
ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(30);
if (probe.isConsumed()) {
response.addHeader("X-Rate-Limit-Remaining", String.valueOf(probe.getRemainingTokens()));
return true;
} else {
long waitForRefill = probe.getNanosToWaitForRefill() / 1_000_000_000;
response.addHeader("Rate-Limit-Retry-After-Seconds", String.valueOf(waitForRefill));
response.sendError(HttpStatus.TOO_MANY_REQUESTS.value(), "API Request Limit Exhausted");
response.setStatus(429);
response.setContentType("text/plain");
response.getWriter().append("Too many requests !!!");
return false;
}
}
});
}
}
Step-8:
Run the RequestRateLimiterApplication.java
SpringBoot application.
The example is created using Eclipse, based on that below is screenshot of the Eclipse console.
Step-9:
First request submitted on TestController
The first request is submitted and processed successfully by the LimitTestController
Step-10:
Send multiple requests by refeshing the webpage using F5
key or hit the http://localhost:8080/testController/1
URL very quikly. The submitted requests logs are captures at Eclipse console, please refer the yellow highlighted timestamp.
Step-11:
As the defined threshold of number of requests in given time frame is breached, The application returns the custom error message Too Many Requests !!!
and defined HTTP Error Coe 429