added rate limiter
This commit is contained in:
@@ -180,6 +180,19 @@
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- Redis (Spring Data Redis + Lettuce) -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Bucket4j core for local token-bucket smoothing -->
|
||||
<dependency>
|
||||
<groupId>com.github.vladimir-bukhtoyarov</groupId>
|
||||
<artifactId>bucket4j-core</artifactId>
|
||||
<version>7.6.0</version>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
@@ -208,4 +221,4 @@
|
||||
<finalName>BBPS</finalName>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
</project>
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.billpaymentservice.bbps.config;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.core.Ordered;
|
||||
|
||||
import com.billpaymentservice.bbps.filter.RateLimitingFilter;
|
||||
import com.billpaymentservice.bbps.ratelimit.LocalBucketManager;
|
||||
|
||||
@Configuration
|
||||
public class RateLimiterConfig {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(RateLimiterConfig.class);
|
||||
|
||||
@Value("${spring.redis.host:localhost}")
|
||||
private String redisHost;
|
||||
|
||||
@Value("${spring.redis.port:6379}")
|
||||
private int redisPort;
|
||||
|
||||
// allow comma-separated patterns, default to /api/* and /masterData/* for backward compatibility
|
||||
@Value("${rate.limiter.url-pattern:/api/*,/masterData/*}")
|
||||
private String urlPattern;
|
||||
|
||||
@Bean
|
||||
public LettuceConnectionFactory redisConnectionFactory() {
|
||||
RedisStandaloneConfiguration cfg = new RedisStandaloneConfiguration(redisHost, redisPort);
|
||||
LettuceConnectionFactory factory = new LettuceConnectionFactory(cfg);
|
||||
// optional tuning
|
||||
factory.setValidateConnection(true);
|
||||
return factory;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public StringRedisTemplate stringRedisTemplate(LettuceConnectionFactory connectionFactory) {
|
||||
StringRedisTemplate tpl = new StringRedisTemplate(connectionFactory);
|
||||
tpl.setEnableTransactionSupport(false);
|
||||
return tpl;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<RateLimitingFilter> rateLimiterFilterRegistration(StringRedisTemplate stringRedisTemplate, LocalBucketManager localBucketManager) {
|
||||
RateLimitingFilter filter = new RateLimitingFilter(stringRedisTemplate, localBucketManager);
|
||||
FilterRegistrationBean<RateLimitingFilter> reg = new FilterRegistrationBean<>();
|
||||
reg.setFilter(filter);
|
||||
|
||||
// support comma-separated patterns in property (e.g. "/api/*,/masterData/*")
|
||||
String[] patterns = urlPattern.split("\\s*,\\s*");
|
||||
reg.addUrlPatterns(patterns);
|
||||
|
||||
LOGGER.info("Registering RateLimitingFilter for patterns={}", Arrays.toString(patterns));
|
||||
|
||||
reg.setName("rateLimitingFilter");
|
||||
// set the filter to highest precedence so it runs before other servlet filters (e.g., security chain)
|
||||
reg.setOrder(Ordered.HIGHEST_PRECEDENCE);
|
||||
return reg;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.billpaymentservice.bbps.filter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.script.DefaultRedisScript;
|
||||
import org.springframework.data.redis.RedisConnectionFailureException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.billpaymentservice.bbps.ratelimit.LocalBucketManager;
|
||||
import com.billpaymentservice.bbps.util.Helper;
|
||||
|
||||
import io.github.bucket4j.Bucket;
|
||||
|
||||
|
||||
public class RateLimitingFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(RateLimitingFilter.class);
|
||||
|
||||
private static final long RATE_PER_WINDOW = 1L;
|
||||
private static final long BURST = 0L;
|
||||
private static final long WINDOW_MILLIS = 60_000L;
|
||||
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private final LocalBucketManager localBucketManager;
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
private final DefaultRedisScript<Long> incrWithExpireScript;
|
||||
|
||||
public RateLimitingFilter(StringRedisTemplate redisTemplate, LocalBucketManager localBucketManager) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.localBucketManager = localBucketManager;
|
||||
String script =
|
||||
"local v = redis.call('INCR', KEYS[1]);" +
|
||||
"if v == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end;" +
|
||||
"return v";
|
||||
|
||||
this.incrWithExpireScript = new DefaultRedisScript<>(script, Long.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
|
||||
String mid = validateAndExtractMid(request, response);
|
||||
if (mid == null) return;
|
||||
|
||||
LOGGER.info("RateLimiter activated → path={} mid={}", request.getRequestURI(), mid);
|
||||
response.setHeader("X-RateLimit-Filter", "invoked");
|
||||
|
||||
long limit = RATE_PER_WINDOW + BURST;
|
||||
String redisKey = buildRedisKey(mid);
|
||||
|
||||
// 1️⃣ Local Bucket Check
|
||||
if (!consumeLocalToken(mid, response)) {
|
||||
long resetAt = calculateResetTime();
|
||||
setRateHeaders(response, limit, 0, resetAt);
|
||||
writeErrorResponse(request, response, 429, "Too Many Requests", "Rate limit exceeded (local)", mid);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2️⃣ Redis Counter
|
||||
Long count = incrementRedisCounter(redisKey);
|
||||
long resetAt = calculateResetTime();
|
||||
long remaining = Math.max(0, limit - count);
|
||||
|
||||
setRateHeaders(response, limit, remaining, resetAt);
|
||||
response.setHeader("X-RateLimit-Count", String.valueOf(count));
|
||||
|
||||
if (count > limit) {
|
||||
handleGlobalLimitExceeded(mid, response, request, resetAt);
|
||||
return;
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
|
||||
} // -------------------------HELPERS-------------------------
|
||||
|
||||
|
||||
private String validateAndExtractMid(HttpServletRequest request, HttpServletResponse response) throws IOException
|
||||
{ String mid = request.getHeader("mid");
|
||||
if (mid == null || mid.trim().isEmpty())
|
||||
{ writeErrorResponse(request, response, 400, "Bad Request", "Required header 'mid' is missing or empty", null);
|
||||
return null; }
|
||||
return mid.trim(); }
|
||||
|
||||
private boolean consumeLocalToken(String mid, HttpServletResponse response) {
|
||||
Bucket bucket = localBucketManager.getBucket("local:mid:" + mid);
|
||||
boolean consumed = bucket.tryConsume(1);
|
||||
response.setHeader("X-RateLimit-Local-Remaining", String.valueOf(bucket.getAvailableTokens()));
|
||||
return consumed;
|
||||
}
|
||||
|
||||
private Long incrementRedisCounter(String key) {
|
||||
long ttlSeconds = (WINDOW_MILLIS / 1000L) + 2;
|
||||
|
||||
try {
|
||||
Long result = redisTemplate.execute(
|
||||
incrWithExpireScript,
|
||||
Collections.singletonList(key),
|
||||
String.valueOf(ttlSeconds)
|
||||
);
|
||||
return (result == null ? 1 : result);
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Redis unavailable → fallback mode");
|
||||
return 1L;
|
||||
}
|
||||
}
|
||||
|
||||
private long calculateResetTime() {
|
||||
long nowSec = System.currentTimeMillis() / 1000L;
|
||||
long win = WINDOW_MILLIS / 1000L;
|
||||
return ((nowSec / win) * win) + win;
|
||||
}
|
||||
|
||||
private String buildRedisKey(String mid) {
|
||||
long index = System.currentTimeMillis() / WINDOW_MILLIS;
|
||||
return "ratelimit:mid:" + mid + ":ts:" + index;
|
||||
}
|
||||
|
||||
private void setRateHeaders(HttpServletResponse response, long limit, long remaining, long resetAt) {
|
||||
response.setHeader("X-RateLimit-Limit", String.valueOf(limit));
|
||||
response.setHeader("X-RateLimit-Remaining", String.valueOf(remaining));
|
||||
response.setHeader("X-RateLimit-Reset", String.valueOf(resetAt));
|
||||
}
|
||||
|
||||
private void handleGlobalLimitExceeded(String mid,
|
||||
HttpServletResponse res,
|
||||
HttpServletRequest req,
|
||||
long resetAt) throws IOException {
|
||||
|
||||
long now = System.currentTimeMillis() / 1000L;
|
||||
res.setHeader("Retry-After", String.valueOf(Math.max(1, resetAt - now)));
|
||||
|
||||
writeErrorResponse(req, res, 429,
|
||||
"Too Many Requests", "Rate limit exceeded (global)", mid);
|
||||
}
|
||||
|
||||
private void writeErrorResponse(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
int status, String error,
|
||||
String message, String mid) throws IOException {
|
||||
|
||||
if (response.isCommitted()) return;
|
||||
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("timestamp", Instant.now().toString());
|
||||
body.put("status", status);
|
||||
body.put("error", error);
|
||||
body.put("message", message);
|
||||
body.put("path", request.getRequestURI());
|
||||
|
||||
if (mid != null) body.put("mid", mid);
|
||||
|
||||
response.setStatus(status);
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
mapper.writeValue(response.getWriter(), body);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.billpaymentservice.bbps.ratelimit;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import io.github.bucket4j.Bandwidth;
|
||||
import io.github.bucket4j.Bucket;
|
||||
import io.github.bucket4j.Bucket4j;
|
||||
import io.github.bucket4j.Refill;
|
||||
|
||||
@Component
|
||||
public class LocalBucketManager {
|
||||
|
||||
// match the filter: 1 request per minute, no burst
|
||||
private static final long RATE_PER_WINDOW = 1L;
|
||||
private static final long BURST = 0L;
|
||||
private static final Duration WINDOW_DURATION = Duration.ofMinutes(1);
|
||||
|
||||
private final ConcurrentMap<String, Bucket> cache = new ConcurrentHashMap<>();
|
||||
|
||||
public Bucket getBucket(String key) {
|
||||
return cache.computeIfAbsent(key, k -> createBucket());
|
||||
}
|
||||
|
||||
private Bucket createBucket() {
|
||||
Bandwidth limit = Bandwidth.classic(RATE_PER_WINDOW + BURST, Refill.greedy(RATE_PER_WINDOW, WINDOW_DURATION));
|
||||
return Bucket4j.builder()
|
||||
.addLimit(limit)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -75,9 +75,19 @@ public class AuthServiceImpl implements AuthService {
|
||||
String mobileNo = request.getMobile();
|
||||
try {
|
||||
Optional<UserEntity> byMobileNumber = userRepository.findByMobile(mobileNo);
|
||||
|
||||
|
||||
|
||||
if (byMobileNumber.isPresent()) {
|
||||
UserEntity user = byMobileNumber.get();
|
||||
LOGGER.info("User found for mobileNo: {}", user);
|
||||
|
||||
if(user.getIsDeleted() != true && user.isEnabled()== false) {
|
||||
LOGGER.warn("User with mobileNo: {} is deleted", mobileNo);
|
||||
return ResponseModel.builder().message("user account is disabled.please contact your service provider").status("Failure")
|
||||
.statusCode(BBPSConstant.USER_IS_DELTED_IN_DB).data(null).build();
|
||||
}
|
||||
|
||||
String token = jwtUtil.generateToken(user.getMobile());
|
||||
|
||||
LOGGER.info("Generated JWT token for mobileNo: " + mobileNo);
|
||||
|
||||
@@ -18,6 +18,7 @@ public class BBPSConstant extends BBPS_RESPONSE_COD_CONSTANT {
|
||||
public static final Long PENDING_CODE = 211L;
|
||||
public static final Long FRAUD_CODE = 1001L;
|
||||
public static final int BADREQUEST = 400;
|
||||
public static final int TO_MANY_REQUEST=429;
|
||||
|
||||
public static final String SUCCESS_STATUS = "success";
|
||||
public static final String FAILED_STATUS = "failed";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.billpaymentservice.bbps.util;
|
||||
|
||||
public class BBPS_RESPONSE_COD_CONSTANT {
|
||||
public static final int USER_IS_DELTED_IN_DB =602;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.billpaymentservice.bbps.util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
public class ConfigHelper {
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -30,7 +30,7 @@ import com.billpaymentservice.bbps.repository.UserRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
@Component
|
||||
public class Helper {
|
||||
public class Helper extends ConfigHelper {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(Helper.class);
|
||||
private final InboundTraceLogRepository inboundTraceLogRepository;
|
||||
|
||||
@@ -18,9 +18,9 @@ swagger.server.description=Local Development Server
|
||||
|
||||
|
||||
# DataSource
|
||||
spring.datasource.url=jdbc:postgresql://localhost:5432/bbps_new_updated
|
||||
#spring.datasource.url=jdbc:postgresql://localhost:5432/bbps_new_updated
|
||||
|
||||
#spring.datasource.url=jdbc:postgresql://localhost:5432/bbps_enhanced
|
||||
spring.datasource.url=jdbc:postgresql://localhost:5432/bbps_enhanced
|
||||
>>>>>>> Stashed changes
|
||||
spring.datasource.username=postgres
|
||||
#spring.datasource.password=UAT@DB_2025
|
||||
|
||||
@@ -21,7 +21,7 @@ spring.profiles.active=local
|
||||
|
||||
|
||||
# DataSource
|
||||
spring.datasource.url=jdbc:postgresql://localhost:5432/devdbuat
|
||||
spring.datasource.url=jdbc:postgresql://localhost:5432/bbps_new_updated
|
||||
spring.datasource.username=postgres
|
||||
#spring.datasource.password=UAT@DB_2025
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.billpaymentservice.bbps.filter;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.script.DefaultRedisScript;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
import com.billpaymentservice.bbps.ratelimit.LocalBucketManager;
|
||||
|
||||
import io.github.bucket4j.Bucket;
|
||||
|
||||
class RateLimitingFilterTest {
|
||||
|
||||
private StringRedisTemplate redisTemplate;
|
||||
private LocalBucketManager localBucketManager;
|
||||
private RateLimitingFilter filter;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
redisTemplate = mock(StringRedisTemplate.class);
|
||||
localBucketManager = mock(LocalBucketManager.class);
|
||||
filter = new RateLimitingFilter(redisTemplate, localBucketManager);
|
||||
}
|
||||
|
||||
@Test
|
||||
void whenLocalBucketExhausted_thenReturns429() throws ServletException, IOException {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
req.addHeader("mid", "MID_LOCAL_EXHAUST");
|
||||
MockHttpServletResponse resp = new MockHttpServletResponse();
|
||||
|
||||
Bucket mockBucket = mock(Bucket.class);
|
||||
when(localBucketManager.getBucket("local:mid:MID_LOCAL_EXHAUST")).thenReturn(mockBucket);
|
||||
when(mockBucket.tryConsume(1)).thenReturn(false);
|
||||
|
||||
FilterChain chain = mock(FilterChain.class);
|
||||
|
||||
filter.doFilter(req, resp, chain);
|
||||
|
||||
assertEquals(429, resp.getStatus());
|
||||
String content = resp.getContentAsString();
|
||||
assertTrue(content.contains("Rate limit exceeded (local)"));
|
||||
verify(chain, never()).doFilter(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void whenLocalAllowsAndRedisUnderLimit_thenPassesThrough() throws Exception {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
req.addHeader("mid", "MID_OK");
|
||||
MockHttpServletResponse resp = new MockHttpServletResponse();
|
||||
|
||||
Bucket mockBucket = mock(Bucket.class);
|
||||
when(localBucketManager.getBucket("local:mid:MID_OK")).thenReturn(mockBucket);
|
||||
when(mockBucket.tryConsume(1)).thenReturn(true);
|
||||
when(mockBucket.getAvailableTokens()).thenReturn(1000L);
|
||||
|
||||
// stub redis execute to return 1 (below limit)
|
||||
when(redisTemplate.execute(any(DefaultRedisScript.class), anyList(), eq("2"))).thenReturn(1L);
|
||||
|
||||
// use a FilterChain that sets a flag by setting response status to 200
|
||||
FilterChain chain = (request, response) -> {
|
||||
((MockHttpServletResponse) response).setStatus(200);
|
||||
};
|
||||
|
||||
filter.doFilter(req, resp, chain);
|
||||
|
||||
assertEquals(200, resp.getStatus());
|
||||
// headers should be present
|
||||
assertEquals("1200", resp.getHeader("X-RateLimit-Limit"));
|
||||
assertNotNull(resp.getHeader("X-RateLimit-Remaining"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void whenLocalAllowsButRedisOverLimit_thenReturns429AndReturnsToken() throws Exception {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
req.addHeader("mid", "MID_GLOBAL_EXHAUST");
|
||||
MockHttpServletResponse resp = new MockHttpServletResponse();
|
||||
|
||||
Bucket mockBucket = mock(Bucket.class);
|
||||
when(localBucketManager.getBucket("local:mid:MID_GLOBAL_EXHAUST")).thenReturn(mockBucket);
|
||||
when(mockBucket.tryConsume(1)).thenReturn(true);
|
||||
when(mockBucket.getAvailableTokens()).thenReturn(0L);
|
||||
|
||||
// stub redis execute to indicate global over-limit
|
||||
when(redisTemplate.execute(any(DefaultRedisScript.class), anyList(), eq("2"))).thenReturn(1201L);
|
||||
|
||||
FilterChain chain = mock(FilterChain.class);
|
||||
|
||||
filter.doFilter(req, resp, chain);
|
||||
|
||||
assertEquals(429, resp.getStatus());
|
||||
String content = resp.getContentAsString();
|
||||
assertTrue(content.contains("Rate limit exceeded (global)"));
|
||||
|
||||
// verify token returned to local bucket
|
||||
verify(mockBucket, times(1)).addTokens(1);
|
||||
verify(chain, never()).doFilter(any(), any());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user