From 53641d97eaa338ca1461b095d8a66aaf9ca47c9a Mon Sep 17 00:00:00 2001 From: Lincpay Tech Date: Fri, 12 Dec 2025 18:52:53 +0530 Subject: [PATCH] added rate limiter --- pom.xml | 15 +- .../bbps/config/RateLimiterConfig.java | 68 +++++++ .../bbps/filter/RateLimitingFilter.java | 174 ++++++++++++++++++ .../bbps/ratelimit/LocalBucketManager.java | 34 ++++ .../bbps/serviceImpl/AuthServiceImpl.java | 10 + .../bbps/util/BBPSConstant.java | 1 + .../bbps/util/BBPS_RESPONSE_COD_CONSTANT.java | 1 + .../bbps/util/ConfigHelper.java | 20 ++ .../billpaymentservice/bbps/util/Helper.java | 2 +- .../resources/application-local.properties | 4 +- src/main/resources/application.properties | 2 +- .../bbps/filter/RateLimitingFilterTest.java | 109 +++++++++++ 12 files changed, 435 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/billpaymentservice/bbps/config/RateLimiterConfig.java create mode 100644 src/main/java/com/billpaymentservice/bbps/filter/RateLimitingFilter.java create mode 100644 src/main/java/com/billpaymentservice/bbps/ratelimit/LocalBucketManager.java create mode 100644 src/main/java/com/billpaymentservice/bbps/util/ConfigHelper.java create mode 100644 src/test/java/com/billpaymentservice/bbps/filter/RateLimitingFilterTest.java diff --git a/pom.xml b/pom.xml index a50ad5a..1f4fb9f 100644 --- a/pom.xml +++ b/pom.xml @@ -180,6 +180,19 @@ true + + + org.springframework.boot + spring-boot-starter-data-redis + + + + + com.github.vladimir-bukhtoyarov + bucket4j-core + 7.6.0 + + @@ -208,4 +221,4 @@ BBPS - + \ No newline at end of file diff --git a/src/main/java/com/billpaymentservice/bbps/config/RateLimiterConfig.java b/src/main/java/com/billpaymentservice/bbps/config/RateLimiterConfig.java new file mode 100644 index 0000000..7ced321 --- /dev/null +++ b/src/main/java/com/billpaymentservice/bbps/config/RateLimiterConfig.java @@ -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 rateLimiterFilterRegistration(StringRedisTemplate stringRedisTemplate, LocalBucketManager localBucketManager) { + RateLimitingFilter filter = new RateLimitingFilter(stringRedisTemplate, localBucketManager); + FilterRegistrationBean 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; + } +} \ No newline at end of file diff --git a/src/main/java/com/billpaymentservice/bbps/filter/RateLimitingFilter.java b/src/main/java/com/billpaymentservice/bbps/filter/RateLimitingFilter.java new file mode 100644 index 0000000..868f674 --- /dev/null +++ b/src/main/java/com/billpaymentservice/bbps/filter/RateLimitingFilter.java @@ -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 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 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); + } +} + + + diff --git a/src/main/java/com/billpaymentservice/bbps/ratelimit/LocalBucketManager.java b/src/main/java/com/billpaymentservice/bbps/ratelimit/LocalBucketManager.java new file mode 100644 index 0000000..50c1d8b --- /dev/null +++ b/src/main/java/com/billpaymentservice/bbps/ratelimit/LocalBucketManager.java @@ -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 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(); + } +} \ No newline at end of file diff --git a/src/main/java/com/billpaymentservice/bbps/serviceImpl/AuthServiceImpl.java b/src/main/java/com/billpaymentservice/bbps/serviceImpl/AuthServiceImpl.java index 65be8d6..cf85184 100644 --- a/src/main/java/com/billpaymentservice/bbps/serviceImpl/AuthServiceImpl.java +++ b/src/main/java/com/billpaymentservice/bbps/serviceImpl/AuthServiceImpl.java @@ -75,9 +75,19 @@ public class AuthServiceImpl implements AuthService { String mobileNo = request.getMobile(); try { Optional 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); diff --git a/src/main/java/com/billpaymentservice/bbps/util/BBPSConstant.java b/src/main/java/com/billpaymentservice/bbps/util/BBPSConstant.java index 553d2ba..e4b06ed 100644 --- a/src/main/java/com/billpaymentservice/bbps/util/BBPSConstant.java +++ b/src/main/java/com/billpaymentservice/bbps/util/BBPSConstant.java @@ -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"; diff --git a/src/main/java/com/billpaymentservice/bbps/util/BBPS_RESPONSE_COD_CONSTANT.java b/src/main/java/com/billpaymentservice/bbps/util/BBPS_RESPONSE_COD_CONSTANT.java index 8bd51a9..73e2165 100644 --- a/src/main/java/com/billpaymentservice/bbps/util/BBPS_RESPONSE_COD_CONSTANT.java +++ b/src/main/java/com/billpaymentservice/bbps/util/BBPS_RESPONSE_COD_CONSTANT.java @@ -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; } diff --git a/src/main/java/com/billpaymentservice/bbps/util/ConfigHelper.java b/src/main/java/com/billpaymentservice/bbps/util/ConfigHelper.java new file mode 100644 index 0000000..1f6c41a --- /dev/null +++ b/src/main/java/com/billpaymentservice/bbps/util/ConfigHelper.java @@ -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 { + + + + +} diff --git a/src/main/java/com/billpaymentservice/bbps/util/Helper.java b/src/main/java/com/billpaymentservice/bbps/util/Helper.java index 6c62baf..bf4a955 100644 --- a/src/main/java/com/billpaymentservice/bbps/util/Helper.java +++ b/src/main/java/com/billpaymentservice/bbps/util/Helper.java @@ -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; diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties index 6722a87..16caf94 100644 --- a/src/main/resources/application-local.properties +++ b/src/main/resources/application-local.properties @@ -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 diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index b64b5a8..888fe3a 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -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 diff --git a/src/test/java/com/billpaymentservice/bbps/filter/RateLimitingFilterTest.java b/src/test/java/com/billpaymentservice/bbps/filter/RateLimitingFilterTest.java new file mode 100644 index 0000000..4cd7371 --- /dev/null +++ b/src/test/java/com/billpaymentservice/bbps/filter/RateLimitingFilterTest.java @@ -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()); + } +}