From 704b600cd780232ba65d7882b3bfa33b86447052 Mon Sep 17 00:00:00 2001
From: Tsvetoslav Tsekov <129774811+tsekovTriesCoding@users.noreply.github.com>
Date: Sat, 14 Mar 2026 20:38:04 +0200
Subject: [PATCH 1/2] feat: add Redis infrastructure for caching, rate
limiting, and idempotency
Add Redis 7 (Alpine) as a distributed cache layer across the platform,
using Spring Boot auto-configured StringRedisTemplate throughout.
Session caching (auth-service):
- TokenBlocklistService: Redis-backed token revocation with TTL matching
token lifetime, fail-open on Redis unavailability
- UserSessionCacheService: cache /auth/me responses (15min TTL),
evicted on logout, status change, and profile update via Kafka events
- Integrate blocklist check into AuthService.logout() and session
caching into AuthController.getCurrentUser()
Rate limiting (api-gateway):
- RateLimitFilter: Redis-backed sliding-window rate limiter using
Lua script (sorted set) for atomic increment + check
- Tiered limits: auth (20/min), admin (200/min), default (100/min)
- Configurable via RateLimitProperties, fail-open when Redis is down
- Client key derived from X-User-Id header or client IP
Idempotency deduplication (finpay-outbox-starter):
- RedisIdempotentConsumerService: Redis-accelerated duplicate check
with automatic DB fallback (sub-ms fast path, DB slow path)
- Write-through pattern: DB first (source of truth), then Redis with TTL
- Smart backfill: cache-warm Redis on DB hit for future lookups
- Auto-configured via @ConditionalOnBean(StringRedisTemplate),
falls back to DB-only IdempotentConsumerService without Redis
Analytics caching (wallet-service):
- WalletCacheService: cache wallet read responses (10s TTL),
evicted on every write operation (deposit, withdraw, freeze, etc.)
- WalletAnalyticsCacheService: cache admin dashboard metrics (30s TTL),
evicted on wallet mutations
Infrastructure:
- Redis 7 Alpine in docker-compose.yml (maxmemory 256mb, allkeys-lru)
- spring-boot-starter-data-redis added to all service POMs
- testcontainers-redis (com.redis:testcontainers-redis:2.2.4) for
integration tests with @ServiceConnection
- Redis testcontainer added to all 5 TestcontainersConfig files
- All Redis operations wrapped in try-catch with graceful degradation
Tests:
- TokenBlocklistServiceTest (6 tests)
- UserSessionCacheServiceTest (4 tests)
- RateLimitFilterTest (6 tests)
- RedisIdempotentConsumerServiceTest (8 tests)
- All use @InjectMocks/@Spy (no new keyword anti-pattern)
- Fixed existing tests: added missing @Mock for new dependencies,
cache eviction in @BeforeEach for test isolation
---
backend/api-gateway/pom.xml | 6 +
.../gateway/config/RateLimitProperties.java | 38 +++++
.../gateway/filter/RateLimitFilter.java | 132 +++++++++++++++
.../src/main/resources/application.yaml | 15 ++
.../gateway/filter/RateLimitFilterTest.java | 154 ++++++++++++++++++
backend/auth-service/pom.xml | 6 +
.../auth/controller/AuthController.java | 6 +-
.../finpay/auth/kafka/UserEventConsumer.java | 5 +
.../com/finpay/auth/service/AuthService.java | 49 +++++-
.../auth/service/TokenBlocklistService.java | 61 +++++++
.../auth/service/UserSessionCacheService.java | 69 ++++++++
.../src/main/resources/application.yaml | 5 +
.../auth/kafka/UserEventConsumerTest.java | 5 +-
.../finpay/auth/service/AuthServiceTest.java | 20 ++-
.../service/TokenBlocklistServiceTest.java | 100 ++++++++++++
.../service/UserSessionCacheServiceTest.java | 110 +++++++++++++
.../auth/testconfig/TestcontainersConfig.java | 9 +
backend/finpay-outbox-starter/pom.xml | 9 +-
.../outbox/OutboxAutoConfiguration.java | 17 ++
.../RedisIdempotentConsumerService.java | 111 +++++++++++++
.../RedisIdempotentConsumerServiceTest.java | 131 +++++++++++++++
backend/notification-service/pom.xml | 7 +
.../src/main/resources/application.yaml | 5 +
.../testconfig/TestcontainersConfig.java | 9 +
backend/payment-service/pom.xml | 6 +
.../src/main/resources/application.yaml | 5 +
.../testconfig/TestcontainersConfig.java | 9 +
backend/pom.xml | 6 +
backend/user-service/pom.xml | 6 +
.../src/main/resources/application.yaml | 5 +
.../user/testconfig/TestcontainersConfig.java | 9 +
backend/wallet-service/pom.xml | 6 +
.../wallet/admin/AdminWalletService.java | 14 +-
.../admin/WalletAnalyticsCacheService.java | 66 ++++++++
.../wallet/wallet/WalletCacheService.java | 58 +++++++
.../finpay/wallet/wallet/WalletService.java | 14 +-
.../src/main/resources/application.yaml | 5 +
.../AdminWalletControllerIntegrationTest.java | 4 +
.../testconfig/TestcontainersConfig.java | 9 +
.../wallet/wallet/WalletServiceTest.java | 1 +
docker-compose.yml | 18 ++
41 files changed, 1309 insertions(+), 11 deletions(-)
create mode 100644 backend/api-gateway/src/main/java/com/finpay/gateway/config/RateLimitProperties.java
create mode 100644 backend/api-gateway/src/main/java/com/finpay/gateway/filter/RateLimitFilter.java
create mode 100644 backend/api-gateway/src/test/java/com/finpay/gateway/filter/RateLimitFilterTest.java
create mode 100644 backend/auth-service/src/main/java/com/finpay/auth/service/TokenBlocklistService.java
create mode 100644 backend/auth-service/src/main/java/com/finpay/auth/service/UserSessionCacheService.java
create mode 100644 backend/auth-service/src/test/java/com/finpay/auth/service/TokenBlocklistServiceTest.java
create mode 100644 backend/auth-service/src/test/java/com/finpay/auth/service/UserSessionCacheServiceTest.java
create mode 100644 backend/finpay-outbox-starter/src/main/java/com/finpay/outbox/idempotency/RedisIdempotentConsumerService.java
create mode 100644 backend/finpay-outbox-starter/src/test/java/com/finpay/outbox/idempotency/RedisIdempotentConsumerServiceTest.java
create mode 100644 backend/wallet-service/src/main/java/com/finpay/wallet/admin/WalletAnalyticsCacheService.java
create mode 100644 backend/wallet-service/src/main/java/com/finpay/wallet/wallet/WalletCacheService.java
diff --git a/backend/api-gateway/pom.xml b/backend/api-gateway/pom.xml
index 2f4c97f..c880bdd 100644
--- a/backend/api-gateway/pom.xml
+++ b/backend/api-gateway/pom.xml
@@ -38,6 +38,12 @@
spring-cloud-starter-config
+
+
+ org.springframework.boot
+ spring-boot-starter-data-redis
+
+
io.jsonwebtoken
diff --git a/backend/api-gateway/src/main/java/com/finpay/gateway/config/RateLimitProperties.java b/backend/api-gateway/src/main/java/com/finpay/gateway/config/RateLimitProperties.java
new file mode 100644
index 0000000..ce9bc0c
--- /dev/null
+++ b/backend/api-gateway/src/main/java/com/finpay/gateway/config/RateLimitProperties.java
@@ -0,0 +1,38 @@
+package com.finpay.gateway.config;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Configurable rate limiting properties.
+ *
+ * rate-limiting:
+ * enabled: true
+ * default-rate: 100 # requests per window
+ * default-window-seconds: 60
+ * auth-rate: 20 # stricter for auth endpoints
+ * auth-window-seconds: 60
+ * admin-rate: 200 # higher for admin endpoints
+ * admin-window-seconds: 60
+ */
+@Configuration
+@ConfigurationProperties(prefix = "rate-limiting")
+@Getter
+@Setter
+public class RateLimitProperties {
+ private boolean enabled = true;
+
+ /** Default requests allowed per window for general API calls. */
+ private int defaultRate = 100;
+ private int defaultWindowSeconds = 60;
+
+ /** Stricter limit for auth endpoints (login/register). */
+ private int authRate = 20;
+ private int authWindowSeconds = 60;
+
+ /** Higher limit for admin endpoints. */
+ private int adminRate = 200;
+ private int adminWindowSeconds = 60;
+}
diff --git a/backend/api-gateway/src/main/java/com/finpay/gateway/filter/RateLimitFilter.java b/backend/api-gateway/src/main/java/com/finpay/gateway/filter/RateLimitFilter.java
new file mode 100644
index 0000000..d2afb54
--- /dev/null
+++ b/backend/api-gateway/src/main/java/com/finpay/gateway/filter/RateLimitFilter.java
@@ -0,0 +1,132 @@
+package com.finpay.gateway.filter;
+
+import com.finpay.gateway.config.RateLimitProperties;
+import jakarta.servlet.*;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.data.redis.core.script.DefaultRedisScript;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.util.List;
+
+/**
+ * Redis-backed sliding-window rate limiter.
+ *
+ * Runs before all other gateway filters. Uses a Lua script to
+ * atomically increment a per-client counter in Redis and check
+ * against the configured limit. Different rate tiers apply to
+ * auth, admin, and general API endpoints.
+ *
+ * The client key is derived from the X-User-Id header (set by
+ * AdminAuthFilter for authenticated users) or the client IP
+ * for anonymous requests.
+ */
+@Component
+@Order(Ordered.HIGHEST_PRECEDENCE)
+@Slf4j
+public class RateLimitFilter implements Filter {
+
+ private final StringRedisTemplate redisTemplate;
+ private final RateLimitProperties properties;
+ private final DefaultRedisScript rateLimitScript;
+
+ public RateLimitFilter(StringRedisTemplate redisTemplate, RateLimitProperties properties) {
+ this.redisTemplate = redisTemplate;
+ this.properties = properties;
+
+ // Lua script: sliding-window counter using a sorted set
+ // Returns 1 if allowed, 0 if rate limit exceeded
+ String lua = """
+ local key = KEYS[1]
+ local window = tonumber(ARGV[1])
+ local limit = tonumber(ARGV[2])
+ local now = tonumber(ARGV[3])
+ local window_start = now - window
+ redis.call('ZREMRANGEBYSCORE', key, '-inf', window_start)
+ local count = redis.call('ZCARD', key)
+ if count < limit then
+ redis.call('ZADD', key, now, now .. '-' .. math.random(1000000))
+ redis.call('EXPIRE', key, window)
+ return 1
+ end
+ return 0
+ """;
+ this.rateLimitScript = new DefaultRedisScript<>(lua, Long.class);
+ }
+
+ @Override
+ public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+ throws IOException, ServletException {
+
+ if (!properties.isEnabled()) {
+ chain.doFilter(request, response);
+ return;
+ }
+
+ HttpServletRequest httpRequest = (HttpServletRequest) request;
+ HttpServletResponse httpResponse = (HttpServletResponse) response;
+
+ String path = httpRequest.getRequestURI();
+ String clientKey = resolveClientKey(httpRequest);
+ RateTier tier = resolveTier(path);
+
+ String redisKey = "gateway:ratelimit:" + tier.name().toLowerCase() + ":" + clientKey;
+
+ try {
+ long nowMillis = System.currentTimeMillis();
+ Long allowed = redisTemplate.execute(
+ rateLimitScript,
+ List.of(redisKey),
+ String.valueOf(tier.windowSeconds()),
+ String.valueOf(tier.maxRequests()),
+ String.valueOf(nowMillis)
+ );
+
+ if (allowed != null && allowed == 0L) {
+ log.warn("Rate limit exceeded for client: {} on path: {} (tier: {})", clientKey, path, tier.name());
+ httpResponse.setStatus(429);
+ httpResponse.setContentType("application/json");
+ httpResponse.setHeader("Retry-After", String.valueOf(tier.windowSeconds()));
+ httpResponse.getWriter().write(
+ "{\"status\":429,\"error\":\"Too Many Requests\",\"message\":\"Rate limit exceeded. Try again later.\"}");
+ return;
+ }
+ } catch (Exception e) {
+ // If Redis is down, allow the request (fail-open) to avoid blocking all traffic
+ log.warn("Rate limiter unavailable, allowing request: {}", e.getMessage());
+ }
+
+ chain.doFilter(request, response);
+ }
+
+ private String resolveClientKey(HttpServletRequest request) {
+ // Prefer authenticated user ID
+ String userId = request.getHeader("X-User-Id");
+ if (userId != null && !userId.isBlank()) {
+ return "user:" + userId;
+ }
+ // Fall back to client IP
+ String forwarded = request.getHeader("X-Forwarded-For");
+ if (forwarded != null && !forwarded.isBlank()) {
+ return "ip:" + forwarded.split(",")[0].trim();
+ }
+ return "ip:" + request.getRemoteAddr();
+ }
+
+ private RateTier resolveTier(String path) {
+ if (path.startsWith("/api/v1/auth/")) {
+ return new RateTier("AUTH", properties.getAuthRate(), properties.getAuthWindowSeconds());
+ }
+ if (path.startsWith("/api/v1/admin/")) {
+ return new RateTier("ADMIN", properties.getAdminRate(), properties.getAdminWindowSeconds());
+ }
+ return new RateTier("DEFAULT", properties.getDefaultRate(), properties.getDefaultWindowSeconds());
+ }
+
+ private record RateTier(String name, int maxRequests, int windowSeconds) {}
+}
diff --git a/backend/api-gateway/src/main/resources/application.yaml b/backend/api-gateway/src/main/resources/application.yaml
index d3d4e7b..0d24aec 100644
--- a/backend/api-gateway/src/main/resources/application.yaml
+++ b/backend/api-gateway/src/main/resources/application.yaml
@@ -22,6 +22,11 @@ spring:
enabled: true
lower-case-service-id: true
+ data:
+ redis:
+ host: ${REDIS_HOST:localhost}
+ port: ${REDIS_PORT:6379}
+
# Routes are defined programmatically in GatewayRoutesConfig.java
# CORS is handled by CorsConfig.java
@@ -89,3 +94,13 @@ springdoc:
- name: Notification Service
url: /api-docs/notification-service
urls-primary-name: Auth Service
+
+# Redis-backed rate limiting
+rate-limiting:
+ enabled: true
+ default-rate: 100
+ default-window-seconds: 60
+ auth-rate: 20
+ auth-window-seconds: 60
+ admin-rate: 200
+ admin-window-seconds: 60
diff --git a/backend/api-gateway/src/test/java/com/finpay/gateway/filter/RateLimitFilterTest.java b/backend/api-gateway/src/test/java/com/finpay/gateway/filter/RateLimitFilterTest.java
new file mode 100644
index 0000000..512512b
--- /dev/null
+++ b/backend/api-gateway/src/test/java/com/finpay/gateway/filter/RateLimitFilterTest.java
@@ -0,0 +1,154 @@
+package com.finpay.gateway.filter;
+
+import com.finpay.gateway.config.RateLimitProperties;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentMatchers;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.data.redis.core.script.RedisScript;
+import org.springframework.mock.web.MockFilterChain;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("RateLimitFilter Unit Tests")
+class RateLimitFilterTest {
+
+ @Mock private StringRedisTemplate redisTemplate;
+ @Spy private RateLimitProperties properties;
+
+ @InjectMocks
+ private RateLimitFilter rateLimitFilter;
+
+ @BeforeEach
+ void setUp() {
+ properties.setEnabled(true);
+ properties.setDefaultRate(100);
+ properties.setDefaultWindowSeconds(60);
+ properties.setAuthRate(20);
+ properties.setAuthWindowSeconds(60);
+ }
+
+ @Nested
+ @DisplayName("Rate Limiting")
+ class RateLimitingTests {
+
+ @Test
+ @DisplayName("should allow request when under rate limit")
+ void shouldAllowRequestUnderLimit() throws Exception {
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/users/me");
+ request.setRemoteAddr("192.168.1.1");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ MockFilterChain filterChain = new MockFilterChain();
+
+ when(redisTemplate.execute(ArgumentMatchers.>any(), anyList(), any(), any(), any()))
+ .thenReturn(1L);
+
+ rateLimitFilter.doFilter(request, response, filterChain);
+
+ assertThat(response.getStatus()).isNotEqualTo(429);
+ }
+
+ @Test
+ @DisplayName("should block request when rate limit exceeded")
+ void shouldBlockWhenRateLimitExceeded() throws Exception {
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/users/me");
+ request.setRemoteAddr("192.168.1.1");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ MockFilterChain filterChain = new MockFilterChain();
+
+ when(redisTemplate.execute(ArgumentMatchers.>any(), anyList(), any(), any(), any()))
+ .thenReturn(0L);
+
+ rateLimitFilter.doFilter(request, response, filterChain);
+
+ assertThat(response.getStatus()).isEqualTo(429);
+ assertThat(response.getContentType()).isEqualTo("application/json");
+ assertThat(response.getHeader("Retry-After")).isEqualTo("60");
+ }
+
+ @Test
+ @DisplayName("should allow request when rate limiting is disabled")
+ void shouldAllowWhenDisabled() throws Exception {
+ properties.setEnabled(false);
+
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/users/me");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ MockFilterChain filterChain = new MockFilterChain();
+
+ rateLimitFilter.doFilter(request, response, filterChain);
+
+ assertThat(response.getStatus()).isNotEqualTo(429);
+ verifyNoInteractions(redisTemplate);
+ }
+
+ @Test
+ @DisplayName("should fail open when Redis is unavailable")
+ void shouldFailOpenWhenRedisDown() throws Exception {
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/users/me");
+ request.setRemoteAddr("192.168.1.1");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ MockFilterChain filterChain = new MockFilterChain();
+
+ when(redisTemplate.execute(ArgumentMatchers.>any(), anyList(), any(), any(), any()))
+ .thenThrow(new RuntimeException("Redis connection refused"));
+
+ rateLimitFilter.doFilter(request, response, filterChain);
+
+ assertThat(response.getStatus()).isNotEqualTo(429);
+ }
+
+ @Test
+ @DisplayName("should use user ID as key when X-User-Id header is present")
+ void shouldUseUserIdForAuthenticatedRequests() throws Exception {
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/wallets/me");
+ request.addHeader("X-User-Id", "user-123");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ MockFilterChain filterChain = new MockFilterChain();
+
+ when(redisTemplate.execute(ArgumentMatchers.>any(), anyList(), any(), any(), any()))
+ .thenReturn(1L);
+
+ rateLimitFilter.doFilter(request, response, filterChain);
+
+ verify(redisTemplate).execute(
+ ArgumentMatchers.>any(),
+ eq(List.of("gateway:ratelimit:default:user:user-123")),
+ any(), any(), any()
+ );
+ }
+
+ @Test
+ @DisplayName("should apply auth tier rate limit for auth endpoints")
+ void shouldApplyAuthTierForAuthEndpoints() throws Exception {
+ MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/auth/login");
+ request.setRemoteAddr("10.0.0.1");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ MockFilterChain filterChain = new MockFilterChain();
+
+ when(redisTemplate.execute(ArgumentMatchers.>any(), anyList(), any(), any(), any()))
+ .thenReturn(1L);
+
+ rateLimitFilter.doFilter(request, response, filterChain);
+
+ verify(redisTemplate).execute(
+ ArgumentMatchers.>any(),
+ eq(List.of("gateway:ratelimit:auth:ip:10.0.0.1")),
+ any(), any(), any()
+ );
+ }
+ }
+}
diff --git a/backend/auth-service/pom.xml b/backend/auth-service/pom.xml
index 4433cc8..b704ee8 100644
--- a/backend/auth-service/pom.xml
+++ b/backend/auth-service/pom.xml
@@ -120,6 +120,12 @@
spring-kafka
+
+
+ org.springframework.boot
+ spring-boot-starter-data-redis
+
+
diff --git a/backend/auth-service/src/main/java/com/finpay/auth/controller/AuthController.java b/backend/auth-service/src/main/java/com/finpay/auth/controller/AuthController.java
index 338869d..8274c1b 100644
--- a/backend/auth-service/src/main/java/com/finpay/auth/controller/AuthController.java
+++ b/backend/auth-service/src/main/java/com/finpay/auth/controller/AuthController.java
@@ -64,8 +64,12 @@ public ResponseEntity