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> logout( HttpServletRequest request, HttpServletResponse response) { String refreshToken = extractRefreshTokenFromCookie(request); + String accessToken = extractAccessTokenFromCookie(request); + if (accessToken == null) { + accessToken = extractTokenFromHeader(request); + } if (refreshToken != null) { - authService.logout(refreshToken); + authService.logout(refreshToken, accessToken); } cookieService.clearAuthCookies(response); return ResponseEntity.ok(Map.of("message", "Successfully logged out")); diff --git a/backend/auth-service/src/main/java/com/finpay/auth/kafka/UserEventConsumer.java b/backend/auth-service/src/main/java/com/finpay/auth/kafka/UserEventConsumer.java index a3f9ab7..3534b89 100644 --- a/backend/auth-service/src/main/java/com/finpay/auth/kafka/UserEventConsumer.java +++ b/backend/auth-service/src/main/java/com/finpay/auth/kafka/UserEventConsumer.java @@ -6,6 +6,7 @@ import com.finpay.auth.entity.UserCredential; import com.finpay.auth.repository.RefreshTokenRepository; import com.finpay.auth.repository.UserCredentialRepository; +import com.finpay.auth.service.UserSessionCacheService; import com.finpay.outbox.idempotency.IdempotentConsumerService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -44,6 +45,7 @@ public class UserEventConsumer { private final RefreshTokenRepository refreshTokenRepository; private final ObjectMapper objectMapper; private final IdempotentConsumerService idempotentConsumer; + private final UserSessionCacheService sessionCacheService; @RetryableTopic( attempts = "4", @@ -114,6 +116,9 @@ private void handleStatusChanged(String message) throws Exception { refreshTokenRepository.revokeAllByUserId(userId); log.info("Revoked all refresh tokens for suspended user {}", userId); } + + // Evict session cache so stale profile data isn't served + sessionCacheService.evictSession(userId); } else { log.debug("Credential enabled flag already correct for user {} (status={})", userId, newStatus); } diff --git a/backend/auth-service/src/main/java/com/finpay/auth/service/AuthService.java b/backend/auth-service/src/main/java/com/finpay/auth/service/AuthService.java index 02ae345..af61982 100644 --- a/backend/auth-service/src/main/java/com/finpay/auth/service/AuthService.java +++ b/backend/auth-service/src/main/java/com/finpay/auth/service/AuthService.java @@ -22,7 +22,10 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Duration; +import java.time.Instant; import java.time.LocalDateTime; +import java.util.Date; import java.util.UUID; /** @@ -41,6 +44,8 @@ public class AuthService { private final JwtService jwtService; private final AuthEventProducer authEventProducer; private final UserServiceClient userServiceClient; + private final TokenBlocklistService tokenBlocklistService; + private final UserSessionCacheService sessionCacheService; @Observed(name = "auth.register", contextualName = "register-user") public AuthResponse register(RegisterRequest request) { @@ -152,27 +157,47 @@ public AuthResponse refreshToken(RefreshTokenRequest request) { } @Observed(name = "auth.logout", contextualName = "logout-user") - public void logout(String refreshTokenValue) { + public void logout(String refreshTokenValue, String accessToken) { log.debug("Logging out user"); + // Block the access token in Redis so it can't be reused + if (accessToken != null && !accessToken.isBlank()) { + blockAccessToken(accessToken); + } + refreshTokenRepository.findByToken(refreshTokenValue) .ifPresent(token -> { token.setRevoked(true); refreshTokenRepository.save(token); + sessionCacheService.evictSession(token.getUserId()); }); } public void logoutAll(UUID userId) { log.debug("Logging out all sessions for user: {}", userId); refreshTokenRepository.revokeAllByUserId(userId); + sessionCacheService.evictSession(userId); } public UserDto getCurrentUser(String token) { if (!jwtService.isTokenValid(token)) { throw new InvalidTokenException("Invalid or expired token"); } - + + // Check if token has been revoked via Redis blocklist + String tokenId = jwtService.extractUserId(token) + ":" + jwtService.extractExpiration(token).getTime(); + if (tokenBlocklistService.isBlocked(tokenId)) { + throw new InvalidTokenException("Token has been revoked"); + } + UUID userId = jwtService.extractUserIdAsUUID(token); + + // Try Redis session cache first + UserDto cached = sessionCacheService.getCachedSession(userId); + if (cached != null) { + return cached; + } + UserCredential credential = credentialRepository.findById(userId) .orElseThrow(() -> new InvalidTokenException("User not found")); @@ -184,12 +209,16 @@ public UserDto getCurrentUser(String token) { // Try to fetch the full profile from user-service (has address, fresh profileImageUrl, etc.) UserDto fullProfile = userServiceClient.getUserProfile(userId); if (fullProfile != null) { - return fullProfile.withoutPassword(); + UserDto result = fullProfile.withoutPassword(); + sessionCacheService.cacheUserSession(userId, result); + return result; } // Fall back to local credential data if user-service is unavailable log.debug("Using local credential data for user: {} (user-service unavailable)", userId); - return toUserDto(credential); + UserDto result = toUserDto(credential); + sessionCacheService.cacheUserSession(userId, result); + return result; } private AuthResponse createAuthResponse(UserCredential credential) { @@ -287,4 +316,16 @@ public UpgradePlanResponse upgradePlan(UUID userId, UpgradePlanRequest request) return UpgradePlanResponse.success(userId.toString(), previousPlanName, newPlan.name()); } + + private void blockAccessToken(String accessToken) { + try { + String userId = jwtService.extractUserId(accessToken); + Date expiration = jwtService.extractExpiration(accessToken); + String tokenId = userId + ":" + expiration.getTime(); + Duration ttl = Duration.between(Instant.now(), expiration.toInstant()); + tokenBlocklistService.blockToken(tokenId, ttl); + } catch (Exception e) { + log.debug("Could not block access token (may already be expired): {}", e.getMessage()); + } + } } diff --git a/backend/auth-service/src/main/java/com/finpay/auth/service/TokenBlocklistService.java b/backend/auth-service/src/main/java/com/finpay/auth/service/TokenBlocklistService.java new file mode 100644 index 0000000..39aa688 --- /dev/null +++ b/backend/auth-service/src/main/java/com/finpay/auth/service/TokenBlocklistService.java @@ -0,0 +1,61 @@ +package com.finpay.auth.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; + +/** + * Redis-backed token blocklist for instant token revocation checks. + * When a user logs out or their tokens are revoked, the access token's JTI + * is added here with a TTL matching the token's remaining lifetime. + * This avoids DB lookups on every authenticated request. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class TokenBlocklistService { + + private static final String BLOCKLIST_PREFIX = "auth:blocklist:"; + + private final StringRedisTemplate redisTemplate; + + /** + * Block a token by storing its identifier in Redis with a TTL. + * + * @param tokenId unique token identifier (JWT subject + issued-at, or token hash) + * @param ttl time until the token would naturally expire + */ + public void blockToken(String tokenId, Duration ttl) { + if (tokenId == null || tokenId.isBlank()) { + return; + } + if (ttl.isPositive()) { + try { + redisTemplate.opsForValue().set(BLOCKLIST_PREFIX + tokenId, "revoked", ttl); + log.debug("Blocked token: {} with TTL: {}", tokenId, ttl); + } catch (Exception e) { + log.warn("Failed to block token in Redis: {}", e.getMessage()); + } + } + } + + /** + * Check if a token has been revoked. + * Returns false (fail-open) if Redis is unavailable - tokens have short TTL, + * so the window of exposure is bounded by the access token lifetime. + */ + public boolean isBlocked(String tokenId) { + if (tokenId == null || tokenId.isBlank()) { + return false; + } + try { + return Boolean.TRUE.equals(redisTemplate.hasKey(BLOCKLIST_PREFIX + tokenId)); + } catch (Exception e) { + log.warn("Redis unavailable for blocklist check, failing open: {}", e.getMessage()); + return false; + } + } +} diff --git a/backend/auth-service/src/main/java/com/finpay/auth/service/UserSessionCacheService.java b/backend/auth-service/src/main/java/com/finpay/auth/service/UserSessionCacheService.java new file mode 100644 index 0000000..53fe7b0 --- /dev/null +++ b/backend/auth-service/src/main/java/com/finpay/auth/service/UserSessionCacheService.java @@ -0,0 +1,69 @@ +package com.finpay.auth.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.finpay.auth.dto.UserDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.UUID; + +/** + * Caches user session data in Redis to avoid DB lookups on every + * /auth/me call. The cached profile is invalidated on logout, status + * changes, and profile updates (via Kafka events). + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class UserSessionCacheService { + + private static final String SESSION_PREFIX = "auth:session:"; + private static final Duration SESSION_TTL = Duration.ofMinutes(15); + + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + /** + * Cache a user profile for the given user ID. + */ + public void cacheUserSession(UUID userId, UserDto userDto) { + try { + String json = objectMapper.writeValueAsString(userDto); + redisTemplate.opsForValue().set(SESSION_PREFIX + userId, json, SESSION_TTL); + log.debug("Cached session for user: {}", userId); + } catch (Exception e) { + log.debug("Failed to cache user session for {}: {}", userId, e.getMessage()); + } + } + + /** + * Retrieve a cached user profile, or null if not cached. + */ + public UserDto getCachedSession(UUID userId) { + try { + String json = redisTemplate.opsForValue().get(SESSION_PREFIX + userId); + if (json == null) { + return null; + } + return objectMapper.readValue(json, UserDto.class); + } catch (Exception e) { + log.debug("Failed to read cached session for {}: {}", userId, e.getMessage()); + return null; + } + } + + /** + * Remove the cached session (on logout, status change, profile update). + */ + public void evictSession(UUID userId) { + try { + redisTemplate.delete(SESSION_PREFIX + userId); + log.debug("Evicted session cache for user: {}", userId); + } catch (Exception e) { + log.debug("Failed to evict session cache for {}: {}", userId, e.getMessage()); + } + } +} diff --git a/backend/auth-service/src/main/resources/application.yaml b/backend/auth-service/src/main/resources/application.yaml index a3c18d8..7eac60e 100644 --- a/backend/auth-service/src/main/resources/application.yaml +++ b/backend/auth-service/src/main/resources/application.yaml @@ -22,6 +22,11 @@ spring: password: ${MYSQL_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + jpa: hibernate: ddl-auto: update diff --git a/backend/auth-service/src/test/java/com/finpay/auth/kafka/UserEventConsumerTest.java b/backend/auth-service/src/test/java/com/finpay/auth/kafka/UserEventConsumerTest.java index 5b630fd..0c2ef8e 100644 --- a/backend/auth-service/src/test/java/com/finpay/auth/kafka/UserEventConsumerTest.java +++ b/backend/auth-service/src/test/java/com/finpay/auth/kafka/UserEventConsumerTest.java @@ -4,6 +4,7 @@ import com.finpay.auth.entity.UserCredential; import com.finpay.auth.repository.RefreshTokenRepository; import com.finpay.auth.repository.UserCredentialRepository; +import com.finpay.auth.service.UserSessionCacheService; import com.finpay.outbox.idempotency.IdempotentConsumerService; import org.apache.kafka.clients.consumer.ConsumerRecord; import org.junit.jupiter.api.BeforeEach; @@ -23,7 +24,6 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -32,8 +32,9 @@ class UserEventConsumerTest { @Mock private UserCredentialRepository credentialRepository; @Mock private RefreshTokenRepository refreshTokenRepository; - @Spy private ObjectMapper objectMapper = new ObjectMapper(); + @Spy private ObjectMapper objectMapper; @Mock private IdempotentConsumerService idempotentConsumer; + @Mock private UserSessionCacheService sessionCacheService; @InjectMocks private UserEventConsumer consumer; diff --git a/backend/auth-service/src/test/java/com/finpay/auth/service/AuthServiceTest.java b/backend/auth-service/src/test/java/com/finpay/auth/service/AuthServiceTest.java index f31cb66..377675f 100644 --- a/backend/auth-service/src/test/java/com/finpay/auth/service/AuthServiceTest.java +++ b/backend/auth-service/src/test/java/com/finpay/auth/service/AuthServiceTest.java @@ -44,6 +44,8 @@ class AuthServiceTest { @Mock private JwtService jwtService; @Mock private AuthEventProducer authEventProducer; @Mock private UserServiceClient userServiceClient; + @Mock private TokenBlocklistService tokenBlocklistService; + @Mock private UserSessionCacheService sessionCacheService; @InjectMocks private AuthService authService; @@ -347,7 +349,7 @@ void shouldLogoutSuccessfully() { when(refreshTokenRepository.findByToken("refresh-token")).thenReturn(Optional.of(token)); when(refreshTokenRepository.save(any(RefreshToken.class))).thenReturn(token); - authService.logout("refresh-token"); + authService.logout("refresh-token", null); assertThat(token.isRevoked()).isTrue(); verify(refreshTokenRepository).save(token); @@ -434,7 +436,11 @@ void shouldRejectSuspendedUser() { savedCredential.setEnabled(false); when(jwtService.isTokenValid("valid-token")).thenReturn(true); + when(jwtService.extractUserId("valid-token")).thenReturn(savedCredential.getId().toString()); + when(jwtService.extractExpiration("valid-token")).thenReturn(new java.util.Date(System.currentTimeMillis() + 60000)); + when(tokenBlocklistService.isBlocked(anyString())).thenReturn(false); when(jwtService.extractUserIdAsUUID("valid-token")).thenReturn(savedCredential.getId()); + when(sessionCacheService.getCachedSession(savedCredential.getId())).thenReturn(null); when(credentialRepository.findById(savedCredential.getId())).thenReturn(Optional.of(savedCredential)); assertThatThrownBy(() -> authService.getCurrentUser("valid-token")) @@ -448,7 +454,11 @@ void shouldRejectSuspendedUser() { @DisplayName("should return user profile for active user") void shouldReturnProfileForActiveUser() { when(jwtService.isTokenValid("valid-token")).thenReturn(true); + when(jwtService.extractUserId("valid-token")).thenReturn(savedCredential.getId().toString()); + when(jwtService.extractExpiration("valid-token")).thenReturn(new java.util.Date(System.currentTimeMillis() + 60000)); + when(tokenBlocklistService.isBlocked(anyString())).thenReturn(false); when(jwtService.extractUserIdAsUUID("valid-token")).thenReturn(savedCredential.getId()); + when(sessionCacheService.getCachedSession(savedCredential.getId())).thenReturn(null); when(credentialRepository.findById(savedCredential.getId())).thenReturn(Optional.of(savedCredential)); UserDto fullProfile = new UserDto( @@ -469,7 +479,11 @@ void shouldReturnProfileForActiveUser() { @DisplayName("should fall back to local credential when user-service is unavailable") void shouldFallBackToLocalCredential() { when(jwtService.isTokenValid("valid-token")).thenReturn(true); + when(jwtService.extractUserId("valid-token")).thenReturn(savedCredential.getId().toString()); + when(jwtService.extractExpiration("valid-token")).thenReturn(new java.util.Date(System.currentTimeMillis() + 60000)); + when(tokenBlocklistService.isBlocked(anyString())).thenReturn(false); when(jwtService.extractUserIdAsUUID("valid-token")).thenReturn(savedCredential.getId()); + when(sessionCacheService.getCachedSession(savedCredential.getId())).thenReturn(null); when(credentialRepository.findById(savedCredential.getId())).thenReturn(Optional.of(savedCredential)); when(userServiceClient.getUserProfile(savedCredential.getId())).thenReturn(null); @@ -495,7 +509,11 @@ void shouldThrowForInvalidToken() { void shouldThrowWhenUserNotFound() { UUID missingId = UUID.randomUUID(); when(jwtService.isTokenValid("valid-token")).thenReturn(true); + when(jwtService.extractUserId("valid-token")).thenReturn(missingId.toString()); + when(jwtService.extractExpiration("valid-token")).thenReturn(new java.util.Date(System.currentTimeMillis() + 60000)); + when(tokenBlocklistService.isBlocked(anyString())).thenReturn(false); when(jwtService.extractUserIdAsUUID("valid-token")).thenReturn(missingId); + when(sessionCacheService.getCachedSession(missingId)).thenReturn(null); when(credentialRepository.findById(missingId)).thenReturn(Optional.empty()); assertThatThrownBy(() -> authService.getCurrentUser("valid-token")) diff --git a/backend/auth-service/src/test/java/com/finpay/auth/service/TokenBlocklistServiceTest.java b/backend/auth-service/src/test/java/com/finpay/auth/service/TokenBlocklistServiceTest.java new file mode 100644 index 0000000..216e1f7 --- /dev/null +++ b/backend/auth-service/src/test/java/com/finpay/auth/service/TokenBlocklistServiceTest.java @@ -0,0 +1,100 @@ +package com.finpay.auth.service; + +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("TokenBlocklistService Unit Tests") +class TokenBlocklistServiceTest { + + @Mock private StringRedisTemplate redisTemplate; + @Mock private ValueOperations valueOperations; + + @InjectMocks + private TokenBlocklistService blocklistService; + + @Nested + @DisplayName("blockToken") + class BlockTokenTests { + + @Test + @DisplayName("should store token in Redis with TTL") + void shouldStoreTokenWithTtl() { + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + + Duration ttl = Duration.ofMinutes(30); + blocklistService.blockToken("user123:1234567890", ttl); + + verify(valueOperations).set("auth:blocklist:user123:1234567890", "revoked", ttl); + } + + @Test + @DisplayName("should not store if TTL is zero or negative") + void shouldNotStoreIfTtlNonPositive() { + blocklistService.blockToken("user123:1234567890", Duration.ZERO); + + verifyNoInteractions(redisTemplate); + } + + @Test + @DisplayName("should not store if tokenId is null") + void shouldNotStoreIfTokenIdNull() { + blocklistService.blockToken(null, Duration.ofMinutes(5)); + + verifyNoInteractions(redisTemplate); + } + + @Test + @DisplayName("should not store if tokenId is blank") + void shouldNotStoreIfTokenIdBlank() { + blocklistService.blockToken(" ", Duration.ofMinutes(5)); + + verifyNoInteractions(redisTemplate); + } + } + + @Nested + @DisplayName("isBlocked") + class IsBlockedTests { + + @Test + @DisplayName("should return true if token exists in Redis") + void shouldReturnTrueIfTokenExists() { + when(redisTemplate.hasKey("auth:blocklist:user123:1234567890")).thenReturn(true); + + assertThat(blocklistService.isBlocked("user123:1234567890")).isTrue(); + } + + @Test + @DisplayName("should return false if token does not exist in Redis") + void shouldReturnFalseIfTokenMissing() { + when(redisTemplate.hasKey("auth:blocklist:user123:1234567890")).thenReturn(false); + + assertThat(blocklistService.isBlocked("user123:1234567890")).isFalse(); + } + + @Test + @DisplayName("should return false if tokenId is null") + void shouldReturnFalseIfTokenIdNull() { + assertThat(blocklistService.isBlocked(null)).isFalse(); + } + + @Test + @DisplayName("should return false if tokenId is blank") + void shouldReturnFalseIfTokenIdBlank() { + assertThat(blocklistService.isBlocked(" ")).isFalse(); + } + } +} diff --git a/backend/auth-service/src/test/java/com/finpay/auth/service/UserSessionCacheServiceTest.java b/backend/auth-service/src/test/java/com/finpay/auth/service/UserSessionCacheServiceTest.java new file mode 100644 index 0000000..39f3952 --- /dev/null +++ b/backend/auth-service/src/test/java/com/finpay/auth/service/UserSessionCacheServiceTest.java @@ -0,0 +1,110 @@ +package com.finpay.auth.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.finpay.auth.dto.UserDto; +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.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.ValueOperations; + +import java.time.Duration; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("UserSessionCacheService Unit Tests") +class UserSessionCacheServiceTest { + + @Mock private StringRedisTemplate redisTemplate; + @Mock private ValueOperations valueOperations; + @Spy private ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + + @InjectMocks + private UserSessionCacheService cacheService; + + private UUID userId; + private UserDto userDto; + + @BeforeEach + void setUp() { + userId = UUID.randomUUID(); + userDto = new UserDto( + userId, "john@example.com", null, + "John", "Doe", "+1234567890", "ACTIVE", "USER", + null, null, null, null, null, null, null, + true, false, "STARTER", null, null, null + ); + } + + @Nested + @DisplayName("cacheUserSession") + class CacheUserSessionTests { + + @Test + @DisplayName("should store user session in Redis with TTL") + void shouldStoreSessionWithTtl() throws Exception { + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + String expectedJson = objectMapper.writeValueAsString(userDto); + + cacheService.cacheUserSession(userId, userDto); + + verify(valueOperations).set( + eq("auth:session:" + userId), + eq(expectedJson), + eq(Duration.ofMinutes(15)) + ); + } + } + + @Nested + @DisplayName("getCachedSession") + class GetCachedSessionTests { + + @Test + @DisplayName("should return cached user when present") + void shouldReturnCachedUser() throws Exception { + String json = objectMapper.writeValueAsString(userDto); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get("auth:session:" + userId)).thenReturn(json); + + UserDto result = cacheService.getCachedSession(userId); + + assertThat(result).isNotNull(); + assertThat(result.email()).isEqualTo("john@example.com"); + assertThat(result.firstName()).isEqualTo("John"); + } + + @Test + @DisplayName("should return null when cache miss") + void shouldReturnNullOnCacheMiss() { + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get("auth:session:" + userId)).thenReturn(null); + + assertThat(cacheService.getCachedSession(userId)).isNull(); + } + } + + @Nested + @DisplayName("evictSession") + class EvictSessionTests { + + @Test + @DisplayName("should delete session from Redis") + void shouldDeleteSession() { + cacheService.evictSession(userId); + + verify(redisTemplate).delete("auth:session:" + userId); + } + } +} diff --git a/backend/auth-service/src/test/java/com/finpay/auth/testconfig/TestcontainersConfig.java b/backend/auth-service/src/test/java/com/finpay/auth/testconfig/TestcontainersConfig.java index f87fc47..6a9ef55 100644 --- a/backend/auth-service/src/test/java/com/finpay/auth/testconfig/TestcontainersConfig.java +++ b/backend/auth-service/src/test/java/com/finpay/auth/testconfig/TestcontainersConfig.java @@ -7,6 +7,8 @@ import org.testcontainers.mysql.MySQLContainer; import org.testcontainers.utility.DockerImageName; +import com.redis.testcontainers.RedisContainer; + /** * Shared Testcontainers configuration for auth-service integration tests. * Provides MySQL and Kafka containers wired via @ServiceConnection. @@ -30,4 +32,11 @@ public ConfluentKafkaContainer kafkaContainer() { return new ConfluentKafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0")) .withReuse(true); } + + @Bean + @ServiceConnection(name = "redis") + public RedisContainer redisContainer() { + return new RedisContainer(DockerImageName.parse("redis:7-alpine")) + .withReuse(true); + } } diff --git a/backend/finpay-outbox-starter/pom.xml b/backend/finpay-outbox-starter/pom.xml index ce7bed5..0b3b566 100644 --- a/backend/finpay-outbox-starter/pom.xml +++ b/backend/finpay-outbox-starter/pom.xml @@ -18,7 +18,7 @@ configuration auto-configured. - + jar @@ -38,6 +38,13 @@ spring-boot-autoconfigure + + + org.springframework.boot + spring-boot-starter-data-redis + true + + org.springframework.boot spring-boot-configuration-processor diff --git a/backend/finpay-outbox-starter/src/main/java/com/finpay/outbox/OutboxAutoConfiguration.java b/backend/finpay-outbox-starter/src/main/java/com/finpay/outbox/OutboxAutoConfiguration.java index 22c329c..24913b3 100644 --- a/backend/finpay-outbox-starter/src/main/java/com/finpay/outbox/OutboxAutoConfiguration.java +++ b/backend/finpay-outbox-starter/src/main/java/com/finpay/outbox/OutboxAutoConfiguration.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.finpay.outbox.config.OutboxKafkaMessageConfig; import com.finpay.outbox.idempotency.IdempotentConsumerService; +import com.finpay.outbox.idempotency.RedisIdempotentConsumerService; import com.finpay.outbox.publisher.OutboxPublisher; import com.finpay.outbox.repository.OutboxEventRepository; import com.finpay.outbox.repository.ProcessedEventRepository; @@ -10,10 +11,13 @@ import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.AutoConfigurationPackage; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.scheduling.annotation.EnableScheduling; @@ -58,6 +62,19 @@ public OutboxPublisher outboxPublisher(OutboxEventRepository outboxEventReposito } // Idempotency (consumer side) + // When Redis is available, use the Redis-accelerated implementation. + // Otherwise, fall back to the database-only implementation. + + @Bean + @ConditionalOnClass(StringRedisTemplate.class) + @ConditionalOnBean(StringRedisTemplate.class) + @Primary + public IdempotentConsumerService redisIdempotentConsumerService( + ProcessedEventRepository processedEventRepository, + OutboxProperties properties, + StringRedisTemplate redisTemplate) { + return new RedisIdempotentConsumerService(processedEventRepository, properties, redisTemplate); + } @Bean @ConditionalOnMissingBean(IdempotentConsumerService.class) diff --git a/backend/finpay-outbox-starter/src/main/java/com/finpay/outbox/idempotency/RedisIdempotentConsumerService.java b/backend/finpay-outbox-starter/src/main/java/com/finpay/outbox/idempotency/RedisIdempotentConsumerService.java new file mode 100644 index 0000000..9388c6c --- /dev/null +++ b/backend/finpay-outbox-starter/src/main/java/com/finpay/outbox/idempotency/RedisIdempotentConsumerService.java @@ -0,0 +1,111 @@ +package com.finpay.outbox.idempotency; + +import com.finpay.outbox.OutboxProperties; +import com.finpay.outbox.entity.ProcessedEvent; +import com.finpay.outbox.repository.ProcessedEventRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.LocalDateTime; + +/** + * Redis-accelerated idempotent consumer. + * + * Duplicate checks hit Redis first (sub-millisecond) and only fall + * through to the database when the key is missing from cache. + * Writes go to both Redis (with TTL) and the database (durable + * source of truth). + * + * This is a drop-in replacement for {@link IdempotentConsumerService} + * and is auto-configured when Redis is on the classpath. + */ +@Slf4j +public class RedisIdempotentConsumerService extends IdempotentConsumerService { + + private static final String IDEMPOTENCY_PREFIX = "outbox:processed:"; + + private final StringRedisTemplate redisTemplate; + private final ProcessedEventRepository repository; + private final OutboxProperties properties; + + public RedisIdempotentConsumerService(ProcessedEventRepository repository, + OutboxProperties properties, + StringRedisTemplate redisTemplate) { + super(repository, properties); + this.repository = repository; + this.properties = properties; + this.redisTemplate = redisTemplate; + } + + @Override + public boolean isDuplicate(String eventId) { + if (eventId == null || eventId.isBlank()) { + return false; + } + // Fast path: check Redis + try { + if (Boolean.TRUE.equals(redisTemplate.hasKey(IDEMPOTENCY_PREFIX + eventId))) { + return true; + } + } catch (Exception e) { + log.debug("Redis unavailable for idempotency check, falling back to DB: {}", e.getMessage()); + } + // Slow path: check DB (and backfill Redis if found) + boolean exists = repository.existsById(eventId); + if (exists) { + backfillRedis(eventId); + } + return exists; + } + + @Override + @Transactional(propagation = Propagation.REQUIRED) + public void markProcessed(String eventId, String consumerGroup) { + if (eventId == null || eventId.isBlank()) { + return; + } + // Write to DB first (source of truth) + if (repository.existsById(eventId)) { + return; + } + try { + repository.saveAndFlush(ProcessedEvent.builder() + .eventId(eventId) + .consumerGroup(consumerGroup) + .processedAt(LocalDateTime.now()) + .build()); + log.debug("Marked event as processed: eventId={}, consumer={}", eventId, consumerGroup); + } catch (DataIntegrityViolationException e) { + log.debug("Concurrent processed event insert (safe to ignore): eventId={}", eventId); + } + + // Write to Redis with TTL matching retention + try { + Duration ttl = Duration.ofDays(properties.getIdempotency().getRetentionDays()); + redisTemplate.opsForValue().set(IDEMPOTENCY_PREFIX + eventId, consumerGroup, ttl); + } catch (Exception e) { + log.debug("Failed to cache processed event in Redis: {}", e.getMessage()); + } + } + + private void backfillRedis(String eventId) { + try { + Duration ttl = Duration.ofDays(properties.getIdempotency().getRetentionDays()); + redisTemplate.opsForValue().set(IDEMPOTENCY_PREFIX + eventId, "backfill", ttl); + } catch (Exception e) { + log.debug("Failed to backfill Redis for eventId={}: {}", eventId, e.getMessage()); + } + } + + @Override + @Scheduled(fixedDelayString = "${finpay.idempotency.cleanup-interval-ms:3600000}") + @Transactional + public void cleanup() { + super.cleanup(); + } +} diff --git a/backend/finpay-outbox-starter/src/test/java/com/finpay/outbox/idempotency/RedisIdempotentConsumerServiceTest.java b/backend/finpay-outbox-starter/src/test/java/com/finpay/outbox/idempotency/RedisIdempotentConsumerServiceTest.java new file mode 100644 index 0000000..ba2c11c --- /dev/null +++ b/backend/finpay-outbox-starter/src/test/java/com/finpay/outbox/idempotency/RedisIdempotentConsumerServiceTest.java @@ -0,0 +1,131 @@ +package com.finpay.outbox.idempotency; + +import com.finpay.outbox.OutboxProperties; +import com.finpay.outbox.entity.ProcessedEvent; +import com.finpay.outbox.repository.ProcessedEventRepository; +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.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.ValueOperations; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RedisIdempotentConsumerService Unit Tests") +class RedisIdempotentConsumerServiceTest { + + @Mock private ProcessedEventRepository repository; + @Mock private StringRedisTemplate redisTemplate; + @Mock private ValueOperations valueOperations; + @Spy private OutboxProperties properties; + + @InjectMocks + private RedisIdempotentConsumerService service; + + @Nested + @DisplayName("isDuplicate") + class IsDuplicateTests { + + @Test + @DisplayName("should return true if event exists in Redis") + void shouldReturnTrueIfInRedis() { + when(redisTemplate.hasKey("outbox:processed:event-123")).thenReturn(true); + + assertThat(service.isDuplicate("event-123")).isTrue(); + verify(repository, never()).existsById(any()); + } + + @Test + @DisplayName("should fall back to DB if Redis miss, and backfill Redis") + void shouldFallBackToDbAndBackfill() { + when(redisTemplate.hasKey("outbox:processed:event-456")).thenReturn(false); + when(repository.existsById("event-456")).thenReturn(true); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + + assertThat(service.isDuplicate("event-456")).isTrue(); + verify(valueOperations).set(eq("outbox:processed:event-456"), eq("backfill"), any(Duration.class)); + } + + @Test + @DisplayName("should return false if not in Redis or DB") + void shouldReturnFalseIfNotFound() { + when(redisTemplate.hasKey("outbox:processed:event-789")).thenReturn(false); + when(repository.existsById("event-789")).thenReturn(false); + + assertThat(service.isDuplicate("event-789")).isFalse(); + } + + @Test + @DisplayName("should return false for null eventId") + void shouldReturnFalseForNull() { + assertThat(service.isDuplicate(null)).isFalse(); + } + + @Test + @DisplayName("should fall back to DB if Redis throws exception") + void shouldFallBackIfRedisError() { + when(redisTemplate.hasKey("outbox:processed:event-err")).thenThrow(new RuntimeException("Redis down")); + when(repository.existsById("event-err")).thenReturn(false); + + assertThat(service.isDuplicate("event-err")).isFalse(); + } + } + + @Nested + @DisplayName("markProcessed") + class MarkProcessedTests { + + @Test + @DisplayName("should write to both DB and Redis") + void shouldWriteToBothDbAndRedis() { + when(repository.existsById("event-new")).thenReturn(false); + when(repository.saveAndFlush(any(ProcessedEvent.class))).thenReturn(null); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + + service.markProcessed("event-new", "test-consumer"); + + verify(repository).saveAndFlush(any(ProcessedEvent.class)); + verify(valueOperations).set( + eq("outbox:processed:event-new"), + eq("test-consumer"), + eq(Duration.ofDays(14)) + ); + } + + @Test + @DisplayName("should skip if already exists in DB") + void shouldSkipIfAlreadyExists() { + when(repository.existsById("event-existing")).thenReturn(true); + + service.markProcessed("event-existing", "test-consumer"); + + verify(repository, never()).saveAndFlush(any()); + } + + @Test + @DisplayName("should not fail if Redis write fails") + void shouldNotFailIfRedisWriteFails() { + when(repository.existsById("event-redis-fail")).thenReturn(false); + when(repository.saveAndFlush(any(ProcessedEvent.class))).thenReturn(null); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + doThrow(new RuntimeException("Redis down")).when(valueOperations) + .set(any(), any(), any(Duration.class)); + + // Should not throw + service.markProcessed("event-redis-fail", "test-consumer"); + + verify(repository).saveAndFlush(any(ProcessedEvent.class)); + } + } +} diff --git a/backend/notification-service/pom.xml b/backend/notification-service/pom.xml index 8554ff1..880a645 100644 --- a/backend/notification-service/pom.xml +++ b/backend/notification-service/pom.xml @@ -62,6 +62,13 @@ org.springframework.kafka spring-kafka + + + + org.springframework.boot + spring-boot-starter-data-redis + + org.springframework.boot spring-boot-starter-mail diff --git a/backend/notification-service/src/main/resources/application.yaml b/backend/notification-service/src/main/resources/application.yaml index 4f74a3c..4fbfb04 100644 --- a/backend/notification-service/src/main/resources/application.yaml +++ b/backend/notification-service/src/main/resources/application.yaml @@ -22,6 +22,11 @@ spring: password: ${MYSQL_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + jpa: hibernate: ddl-auto: update diff --git a/backend/notification-service/src/test/java/com/finpay/notification/testconfig/TestcontainersConfig.java b/backend/notification-service/src/test/java/com/finpay/notification/testconfig/TestcontainersConfig.java index 2ed7285..2491bc6 100644 --- a/backend/notification-service/src/test/java/com/finpay/notification/testconfig/TestcontainersConfig.java +++ b/backend/notification-service/src/test/java/com/finpay/notification/testconfig/TestcontainersConfig.java @@ -7,6 +7,8 @@ import org.testcontainers.mysql.MySQLContainer; import org.testcontainers.utility.DockerImageName; +import com.redis.testcontainers.RedisContainer; + @TestConfiguration(proxyBeanMethods = false) public class TestcontainersConfig { @@ -26,4 +28,11 @@ public ConfluentKafkaContainer kafkaContainer() { return new ConfluentKafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0")) .withReuse(true); } + + @Bean + @ServiceConnection(name = "redis") + public RedisContainer redisContainer() { + return new RedisContainer(DockerImageName.parse("redis:7-alpine")) + .withReuse(true); + } } diff --git a/backend/payment-service/pom.xml b/backend/payment-service/pom.xml index 39fae07..6f32aed 100644 --- a/backend/payment-service/pom.xml +++ b/backend/payment-service/pom.xml @@ -63,6 +63,12 @@ spring-kafka + + + org.springframework.boot + spring-boot-starter-data-redis + + org.springframework.kafka diff --git a/backend/payment-service/src/main/resources/application.yaml b/backend/payment-service/src/main/resources/application.yaml index 0eec3f3..4e1f3e7 100644 --- a/backend/payment-service/src/main/resources/application.yaml +++ b/backend/payment-service/src/main/resources/application.yaml @@ -22,6 +22,11 @@ spring: password: ${MYSQL_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + jpa: hibernate: ddl-auto: update diff --git a/backend/payment-service/src/test/java/com/finpay/payment/testconfig/TestcontainersConfig.java b/backend/payment-service/src/test/java/com/finpay/payment/testconfig/TestcontainersConfig.java index 1cd911c..efc4659 100644 --- a/backend/payment-service/src/test/java/com/finpay/payment/testconfig/TestcontainersConfig.java +++ b/backend/payment-service/src/test/java/com/finpay/payment/testconfig/TestcontainersConfig.java @@ -7,6 +7,8 @@ import org.testcontainers.mysql.MySQLContainer; import org.testcontainers.utility.DockerImageName; +import com.redis.testcontainers.RedisContainer; + @TestConfiguration(proxyBeanMethods = false) public class TestcontainersConfig { @@ -26,4 +28,11 @@ public ConfluentKafkaContainer kafkaContainer() { return new ConfluentKafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0")) .withReuse(true); } + + @Bean + @ServiceConnection(name = "redis") + public RedisContainer redisContainer() { + return new RedisContainer(DockerImageName.parse("redis:7-alpine")) + .withReuse(true); + } } diff --git a/backend/pom.xml b/backend/pom.xml index 4267a3a..0b12869 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -188,6 +188,12 @@ testcontainers-kafka test + + com.redis + testcontainers-redis + 2.2.4 + test + org.springframework.boot spring-boot-testcontainers diff --git a/backend/user-service/pom.xml b/backend/user-service/pom.xml index 18e4716..e6b497b 100644 --- a/backend/user-service/pom.xml +++ b/backend/user-service/pom.xml @@ -63,6 +63,12 @@ spring-kafka + + + org.springframework.boot + spring-boot-starter-data-redis + + com.cloudinary diff --git a/backend/user-service/src/main/resources/application.yaml b/backend/user-service/src/main/resources/application.yaml index 6af0ae6..a2ed023 100644 --- a/backend/user-service/src/main/resources/application.yaml +++ b/backend/user-service/src/main/resources/application.yaml @@ -22,6 +22,11 @@ spring: password: ${MYSQL_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + jpa: hibernate: ddl-auto: update diff --git a/backend/user-service/src/test/java/com/finpay/user/testconfig/TestcontainersConfig.java b/backend/user-service/src/test/java/com/finpay/user/testconfig/TestcontainersConfig.java index 66368d5..d114888 100644 --- a/backend/user-service/src/test/java/com/finpay/user/testconfig/TestcontainersConfig.java +++ b/backend/user-service/src/test/java/com/finpay/user/testconfig/TestcontainersConfig.java @@ -7,6 +7,8 @@ import org.testcontainers.mysql.MySQLContainer; import org.testcontainers.utility.DockerImageName; +import com.redis.testcontainers.RedisContainer; + /** * Shared Testcontainers configuration for user-service integration tests. */ @@ -29,4 +31,11 @@ public ConfluentKafkaContainer kafkaContainer() { return new ConfluentKafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0")) .withReuse(true); } + + @Bean + @ServiceConnection(name = "redis") + public RedisContainer redisContainer() { + return new RedisContainer(DockerImageName.parse("redis:7-alpine")) + .withReuse(true); + } } diff --git a/backend/wallet-service/pom.xml b/backend/wallet-service/pom.xml index 1aa3e70..5539cc0 100644 --- a/backend/wallet-service/pom.xml +++ b/backend/wallet-service/pom.xml @@ -63,6 +63,12 @@ spring-kafka + + + org.springframework.boot + spring-boot-starter-data-redis + + org.springframework.kafka diff --git a/backend/wallet-service/src/main/java/com/finpay/wallet/admin/AdminWalletService.java b/backend/wallet-service/src/main/java/com/finpay/wallet/admin/AdminWalletService.java index 07baf89..9b7e5e3 100644 --- a/backend/wallet-service/src/main/java/com/finpay/wallet/admin/AdminWalletService.java +++ b/backend/wallet-service/src/main/java/com/finpay/wallet/admin/AdminWalletService.java @@ -24,6 +24,7 @@ public class AdminWalletService { private final WalletRepository walletRepository; private final WalletService walletService; + private final WalletAnalyticsCacheService analyticsCacheService; /** * List all wallets with server-side pagination, sorting, and filtering. @@ -50,6 +51,7 @@ public Page listAllWallets(Wallet.WalletStatus status, @Transactional public WalletResponse freezeWallet(UUID userId) { walletService.freezeWallet(userId); + analyticsCacheService.evictMetrics(); return walletService.getWalletByUserId(userId); } @@ -59,13 +61,20 @@ public WalletResponse freezeWallet(UUID userId) { @Transactional public WalletResponse unfreezeWallet(UUID userId) { walletService.unfreezeWallet(userId); + analyticsCacheService.evictMetrics(); return walletService.getWalletByUserId(userId); } /** * Get wallet metrics for admin dashboard. + * Uses Redis cache to avoid expensive aggregate queries. */ public AdminWalletMetrics getWalletMetrics() { + AdminWalletMetrics cached = analyticsCacheService.getCachedMetrics(); + if (cached != null) { + return cached; + } + long totalWallets = walletRepository.count(); long activeWallets = walletRepository.countByStatus(Wallet.WalletStatus.ACTIVE); long frozenWallets = walletRepository.countByStatus(Wallet.WalletStatus.FROZEN); @@ -75,7 +84,10 @@ public AdminWalletMetrics getWalletMetrics() { .map(Wallet::getBalance) .reduce(BigDecimal.ZERO, BigDecimal::add); - return new AdminWalletMetrics(totalWallets, activeWallets, frozenWallets, closedWallets, totalBalance); + AdminWalletMetrics metrics = new AdminWalletMetrics( + totalWallets, activeWallets, frozenWallets, closedWallets, totalBalance); + analyticsCacheService.cacheMetrics(metrics); + return metrics; } private WalletResponse toWalletResponse(Wallet w) { diff --git a/backend/wallet-service/src/main/java/com/finpay/wallet/admin/WalletAnalyticsCacheService.java b/backend/wallet-service/src/main/java/com/finpay/wallet/admin/WalletAnalyticsCacheService.java new file mode 100644 index 0000000..361a982 --- /dev/null +++ b/backend/wallet-service/src/main/java/com/finpay/wallet/admin/WalletAnalyticsCacheService.java @@ -0,0 +1,66 @@ +package com.finpay.wallet.admin; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; + +/** + * Caches wallet analytics/metrics in Redis to avoid expensive + * aggregate queries on every admin dashboard load. + * Cache is invalidated on wallet mutations and expires after 30 seconds. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class WalletAnalyticsCacheService { + + private static final String METRICS_KEY = "wallet:analytics:metrics"; + private static final Duration METRICS_TTL = Duration.ofSeconds(30); + + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + /** + * Retrieve cached metrics, or null if not cached / expired. + */ + public AdminWalletMetrics getCachedMetrics() { + try { + String json = redisTemplate.opsForValue().get(METRICS_KEY); + if (json == null) { + return null; + } + return objectMapper.readValue(json, AdminWalletMetrics.class); + } catch (Exception e) { + log.debug("Failed to read cached metrics: {}", e.getMessage()); + return null; + } + } + + /** + * Cache the computed metrics with a short TTL. + */ + public void cacheMetrics(AdminWalletMetrics metrics) { + try { + String json = objectMapper.writeValueAsString(metrics); + redisTemplate.opsForValue().set(METRICS_KEY, json, METRICS_TTL); + log.debug("Cached wallet analytics metrics"); + } catch (Exception e) { + log.debug("Failed to cache metrics: {}", e.getMessage()); + } + } + + /** + * Evict the cached metrics (called after wallet mutations). + */ + public void evictMetrics() { + try { + redisTemplate.delete(METRICS_KEY); + } catch (Exception e) { + log.debug("Failed to evict metrics cache: {}", e.getMessage()); + } + } +} diff --git a/backend/wallet-service/src/main/java/com/finpay/wallet/wallet/WalletCacheService.java b/backend/wallet-service/src/main/java/com/finpay/wallet/wallet/WalletCacheService.java new file mode 100644 index 0000000..07a2e2b --- /dev/null +++ b/backend/wallet-service/src/main/java/com/finpay/wallet/wallet/WalletCacheService.java @@ -0,0 +1,58 @@ +package com.finpay.wallet.wallet; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.finpay.wallet.wallet.dto.WalletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.UUID; + +/** + * Caches wallet read responses in Redis to reduce DB load for + * frequent balance checks. Cache entries have a short TTL and + * are evicted on every write operation (deposit, withdraw, transfer). + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class WalletCacheService { + + private static final String WALLET_PREFIX = "wallet:user:"; + private static final Duration WALLET_TTL = Duration.ofSeconds(10); + + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + public WalletResponse getCachedWallet(UUID userId) { + try { + String json = redisTemplate.opsForValue().get(WALLET_PREFIX + userId); + if (json == null) { + return null; + } + return objectMapper.readValue(json, WalletResponse.class); + } catch (Exception e) { + log.debug("Failed to read cached wallet for {}: {}", userId, e.getMessage()); + return null; + } + } + + public void cacheWallet(UUID userId, WalletResponse wallet) { + try { + String json = objectMapper.writeValueAsString(wallet); + redisTemplate.opsForValue().set(WALLET_PREFIX + userId, json, WALLET_TTL); + } catch (Exception e) { + log.debug("Failed to cache wallet for {}: {}", userId, e.getMessage()); + } + } + + public void evictWallet(UUID userId) { + try { + redisTemplate.delete(WALLET_PREFIX + userId); + } catch (Exception e) { + log.debug("Failed to evict wallet cache for {}: {}", userId, e.getMessage()); + } + } +} diff --git a/backend/wallet-service/src/main/java/com/finpay/wallet/wallet/WalletService.java b/backend/wallet-service/src/main/java/com/finpay/wallet/wallet/WalletService.java index 6406c4c..a203049 100644 --- a/backend/wallet-service/src/main/java/com/finpay/wallet/wallet/WalletService.java +++ b/backend/wallet-service/src/main/java/com/finpay/wallet/wallet/WalletService.java @@ -27,6 +27,7 @@ public class WalletService { private final WalletRepository walletRepository; private final WalletTransactionService transactionService; private final WalletMapper walletMapper; + private final WalletCacheService walletCacheService; private static final String DEFAULT_CURRENCY = "USD"; @@ -51,9 +52,16 @@ public WalletResponse getOrCreateWalletWithPlan(UUID userId, String plan) { @Transactional(readOnly = true) public WalletResponse getWalletByUserId(UUID userId) { + // Try Redis cache first + WalletResponse cached = walletCacheService.getCachedWallet(userId); + if (cached != null) { + return cached; + } Wallet wallet = walletRepository.findByUserId(userId) .orElseThrow(() -> new ResourceNotFoundException("Wallet not found for user: " + userId)); - return walletMapper.toResponse(wallet); + WalletResponse response = walletMapper.toResponse(wallet); + walletCacheService.cacheWallet(userId, response); + return response; } public Wallet getWalletForUpdate(UUID userId) { @@ -211,6 +219,7 @@ public WalletResponse freezeWallet(UUID userId) { Wallet wallet = getWalletForUpdate(userId); wallet.setStatus(Wallet.WalletStatus.FROZEN); walletRepository.save(wallet); + walletCacheService.evictWallet(userId); return walletMapper.toResponse(wallet); } @@ -220,6 +229,7 @@ public WalletResponse unfreezeWallet(UUID userId) { throw new WalletException("Wallet is not frozen"); wallet.setStatus(Wallet.WalletStatus.ACTIVE); walletRepository.save(wallet); + walletCacheService.evictWallet(userId); return walletMapper.toResponse(wallet); } @@ -351,5 +361,7 @@ private void recordTransaction(Wallet wallet, WalletTransaction.TransactionType String referenceId, String description) { transactionService.recordTransaction(wallet.getId(), wallet.getUserId(), type, amount, balanceBefore, balanceAfter, wallet.getCurrency(), referenceId, description); + // Evict cached wallet so reads reflect the new balance + walletCacheService.evictWallet(wallet.getUserId()); } } diff --git a/backend/wallet-service/src/main/resources/application.yaml b/backend/wallet-service/src/main/resources/application.yaml index 10da132..3cc8e95 100644 --- a/backend/wallet-service/src/main/resources/application.yaml +++ b/backend/wallet-service/src/main/resources/application.yaml @@ -22,6 +22,11 @@ spring: password: ${MYSQL_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + jpa: hibernate: ddl-auto: update diff --git a/backend/wallet-service/src/test/java/com/finpay/wallet/admin/AdminWalletControllerIntegrationTest.java b/backend/wallet-service/src/test/java/com/finpay/wallet/admin/AdminWalletControllerIntegrationTest.java index 2d2c28c..322b8df 100644 --- a/backend/wallet-service/src/test/java/com/finpay/wallet/admin/AdminWalletControllerIntegrationTest.java +++ b/backend/wallet-service/src/test/java/com/finpay/wallet/admin/AdminWalletControllerIntegrationTest.java @@ -42,12 +42,16 @@ class AdminWalletControllerIntegrationTest { @Autowired private WalletTransactionRepository walletTransactionRepository; + @Autowired + private WalletAnalyticsCacheService analyticsCacheService; + private static final UUID ADMIN_ID = UUID.randomUUID(); @BeforeEach void setUp() { walletTransactionRepository.deleteAll(); walletRepository.deleteAll(); + analyticsCacheService.evictMetrics(); } private Wallet createWalletInDb(UUID userId, BigDecimal balance, Wallet.WalletStatus status) { diff --git a/backend/wallet-service/src/test/java/com/finpay/wallet/testconfig/TestcontainersConfig.java b/backend/wallet-service/src/test/java/com/finpay/wallet/testconfig/TestcontainersConfig.java index db784de..4a4f3a8 100644 --- a/backend/wallet-service/src/test/java/com/finpay/wallet/testconfig/TestcontainersConfig.java +++ b/backend/wallet-service/src/test/java/com/finpay/wallet/testconfig/TestcontainersConfig.java @@ -7,6 +7,8 @@ import org.testcontainers.mysql.MySQLContainer; import org.testcontainers.utility.DockerImageName; +import com.redis.testcontainers.RedisContainer; + /** * Shared Testcontainers configuration for wallet-service integration tests. */ @@ -29,4 +31,11 @@ public ConfluentKafkaContainer kafkaContainer() { return new ConfluentKafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0")) .withReuse(true); } + + @Bean + @ServiceConnection(name = "redis") + public RedisContainer redisContainer() { + return new RedisContainer(DockerImageName.parse("redis:7-alpine")) + .withReuse(true); + } } diff --git a/backend/wallet-service/src/test/java/com/finpay/wallet/wallet/WalletServiceTest.java b/backend/wallet-service/src/test/java/com/finpay/wallet/wallet/WalletServiceTest.java index fbd5fe1..d950654 100644 --- a/backend/wallet-service/src/test/java/com/finpay/wallet/wallet/WalletServiceTest.java +++ b/backend/wallet-service/src/test/java/com/finpay/wallet/wallet/WalletServiceTest.java @@ -34,6 +34,7 @@ class WalletServiceTest { @Mock private WalletRepository walletRepository; @Mock private WalletTransactionService transactionService; @Mock private WalletMapper walletMapper; + @Mock private WalletCacheService walletCacheService; @InjectMocks private WalletService walletService; diff --git a/docker-compose.yml b/docker-compose.yml index 1307132..4c50aac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -64,6 +64,23 @@ services: networks: - finpay-network + # Redis - session caching, rate limiting, idempotency dedup, analytics cache + redis: + image: redis:7-alpine + container_name: finpay-redis + ports: + - "6379:6379" + command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru + volumes: + - redis_data:/data + networks: + - finpay-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + # ========================================== # Observability Stack # ========================================== @@ -135,5 +152,6 @@ networks: volumes: mysql_data: + redis_data: prometheus_data: grafana_data: From 84282dfdf232bcc9e4d99e190ed1c1742915c786 Mon Sep 17 00:00:00 2001 From: Tsvetoslav Tsekov <129774811+tsekovTriesCoding@users.noreply.github.com> Date: Sat, 14 Mar 2026 21:18:08 +0200 Subject: [PATCH 2/2] refactor: store only SHA-256 hash of refresh tokens - Remove raw token column from RefreshToken entity (security best practice) - Add tokenHash column (64-char SHA-256 hex, unique indexed) - Extract sha256() from entity to TokenHashUtil utility class - Update AuthService and OAuth2SuccessHandler to hash before persisting - Update RefreshTokenRepository to findByTokenHash - Update all tests (unit, integration, repository) --- .../com/finpay/auth/entity/RefreshToken.java | 4 +-- .../repository/RefreshTokenRepository.java | 2 +- .../OAuth2AuthenticationSuccessHandler.java | 5 +-- .../com/finpay/auth/service/AuthService.java | 9 +++--- .../com/finpay/auth/util/TokenHashUtil.java | 21 +++++++++++++ .../AuthControllerIntegrationTest.java | 5 +-- .../RefreshTokenRepositoryTest.java | 31 ++++++++++--------- .../finpay/auth/service/AuthServiceTest.java | 19 ++++++------ 8 files changed, 61 insertions(+), 35 deletions(-) create mode 100644 backend/auth-service/src/main/java/com/finpay/auth/util/TokenHashUtil.java diff --git a/backend/auth-service/src/main/java/com/finpay/auth/entity/RefreshToken.java b/backend/auth-service/src/main/java/com/finpay/auth/entity/RefreshToken.java index 660f822..c3cbde7 100644 --- a/backend/auth-service/src/main/java/com/finpay/auth/entity/RefreshToken.java +++ b/backend/auth-service/src/main/java/com/finpay/auth/entity/RefreshToken.java @@ -20,8 +20,8 @@ public class RefreshToken { @GeneratedValue(strategy = GenerationType.UUID) private UUID id; - @Column(nullable = false, unique = true, length = 1024) - private String token; + @Column(nullable = false, unique = true, length = 64) + private String tokenHash; @Column(nullable = false) private UUID userId; diff --git a/backend/auth-service/src/main/java/com/finpay/auth/repository/RefreshTokenRepository.java b/backend/auth-service/src/main/java/com/finpay/auth/repository/RefreshTokenRepository.java index 55b339b..178cff5 100644 --- a/backend/auth-service/src/main/java/com/finpay/auth/repository/RefreshTokenRepository.java +++ b/backend/auth-service/src/main/java/com/finpay/auth/repository/RefreshTokenRepository.java @@ -13,7 +13,7 @@ @Repository public interface RefreshTokenRepository extends JpaRepository { - Optional findByToken(String token); + Optional findByTokenHash(String tokenHash); @Modifying @Query("UPDATE RefreshToken rt SET rt.revoked = true WHERE rt.userId = :userId") diff --git a/backend/auth-service/src/main/java/com/finpay/auth/security/OAuth2AuthenticationSuccessHandler.java b/backend/auth-service/src/main/java/com/finpay/auth/security/OAuth2AuthenticationSuccessHandler.java index de9573a..2469e74 100644 --- a/backend/auth-service/src/main/java/com/finpay/auth/security/OAuth2AuthenticationSuccessHandler.java +++ b/backend/auth-service/src/main/java/com/finpay/auth/security/OAuth2AuthenticationSuccessHandler.java @@ -8,6 +8,7 @@ import com.finpay.auth.repository.RefreshTokenRepository; import com.finpay.auth.repository.UserCredentialRepository; import com.finpay.auth.service.CookieService; +import com.finpay.auth.util.TokenHashUtil; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -55,9 +56,9 @@ public void onAuthenticationSuccess( String accessToken = jwtService.generateAccessToken(user); String refreshTokenValue = jwtService.generateRefreshToken(user); - // Save refresh token + // Save only the hash - never persist the raw token RefreshToken refreshToken = RefreshToken.builder() - .token(refreshTokenValue) + .tokenHash(TokenHashUtil.sha256(refreshTokenValue)) .userId(user.id()) .userEmail(user.email()) .expiryDate(LocalDateTime.now().plusSeconds(jwtService.getRefreshTokenExpiration() / 1000)) diff --git a/backend/auth-service/src/main/java/com/finpay/auth/service/AuthService.java b/backend/auth-service/src/main/java/com/finpay/auth/service/AuthService.java index af61982..840ba51 100644 --- a/backend/auth-service/src/main/java/com/finpay/auth/service/AuthService.java +++ b/backend/auth-service/src/main/java/com/finpay/auth/service/AuthService.java @@ -14,6 +14,7 @@ import com.finpay.auth.repository.RefreshTokenRepository; import com.finpay.auth.repository.UserCredentialRepository; import com.finpay.auth.security.JwtService; +import com.finpay.auth.util.TokenHashUtil; import io.micrometer.observation.annotation.Observed; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -130,7 +131,7 @@ public AuthResponse login(LoginRequest request) { public AuthResponse refreshToken(RefreshTokenRequest request) { log.debug("Refreshing token"); - RefreshToken refreshToken = refreshTokenRepository.findByToken(request.refreshToken()) + RefreshToken refreshToken = refreshTokenRepository.findByTokenHash(TokenHashUtil.sha256(request.refreshToken())) .orElseThrow(() -> new InvalidTokenException("Invalid refresh token")); if (!refreshToken.isValid()) { @@ -165,7 +166,7 @@ public void logout(String refreshTokenValue, String accessToken) { blockAccessToken(accessToken); } - refreshTokenRepository.findByToken(refreshTokenValue) + refreshTokenRepository.findByTokenHash(TokenHashUtil.sha256(refreshTokenValue)) .ifPresent(token -> { token.setRevoked(true); refreshTokenRepository.save(token); @@ -239,9 +240,9 @@ private AuthResponse createAuthResponse(UserCredential credential) { String accessToken = jwtService.generateAccessToken(userDto); String refreshTokenValue = jwtService.generateRefreshToken(userDto); - // Save refresh token + // Save only the hash - never persist the raw token RefreshToken refreshToken = RefreshToken.builder() - .token(refreshTokenValue) + .tokenHash(TokenHashUtil.sha256(refreshTokenValue)) .userId(credential.getId()) .userEmail(credential.getEmail()) .expiryDate(LocalDateTime.now().plusSeconds(jwtService.getRefreshTokenExpiration() / 1000)) diff --git a/backend/auth-service/src/main/java/com/finpay/auth/util/TokenHashUtil.java b/backend/auth-service/src/main/java/com/finpay/auth/util/TokenHashUtil.java new file mode 100644 index 0000000..f18fe2a --- /dev/null +++ b/backend/auth-service/src/main/java/com/finpay/auth/util/TokenHashUtil.java @@ -0,0 +1,21 @@ +package com.finpay.auth.util; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +public final class TokenHashUtil { + + private TokenHashUtil() {} + + public static String sha256(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } +} diff --git a/backend/auth-service/src/test/java/com/finpay/auth/controller/AuthControllerIntegrationTest.java b/backend/auth-service/src/test/java/com/finpay/auth/controller/AuthControllerIntegrationTest.java index b1c4fec..33285e1 100644 --- a/backend/auth-service/src/test/java/com/finpay/auth/controller/AuthControllerIntegrationTest.java +++ b/backend/auth-service/src/test/java/com/finpay/auth/controller/AuthControllerIntegrationTest.java @@ -8,6 +8,7 @@ import com.finpay.auth.entity.RefreshToken; import com.finpay.auth.entity.UserCredential; import com.finpay.auth.repository.RefreshTokenRepository; +import com.finpay.auth.util.TokenHashUtil; import com.finpay.auth.repository.UserCredentialRepository; import com.finpay.auth.service.UserServiceClient; import com.finpay.auth.testconfig.TestcontainersConfig; @@ -219,7 +220,7 @@ void shouldRefreshWithValidCookie() throws Exception { UserCredential user = insertTestUser("refresh@test.com"); String tokenValue = "test-refresh-token-" + UUID.randomUUID(); RefreshToken token = RefreshToken.builder() - .token(tokenValue) + .tokenHash(TokenHashUtil.sha256(tokenValue)) .userId(user.getId()) .userEmail(user.getEmail()) .expiryDate(LocalDateTime.now().plusHours(1)) @@ -254,7 +255,7 @@ void shouldLogout() throws Exception { UserCredential user = insertTestUser("logout@test.com"); String tokenValue = "test-refresh-token-" + UUID.randomUUID(); RefreshToken token = RefreshToken.builder() - .token(tokenValue) + .tokenHash(TokenHashUtil.sha256(tokenValue)) .userId(user.getId()) .userEmail(user.getEmail()) .expiryDate(LocalDateTime.now().plusHours(1)) diff --git a/backend/auth-service/src/test/java/com/finpay/auth/repository/RefreshTokenRepositoryTest.java b/backend/auth-service/src/test/java/com/finpay/auth/repository/RefreshTokenRepositoryTest.java index d9de5f5..5e7ae51 100644 --- a/backend/auth-service/src/test/java/com/finpay/auth/repository/RefreshTokenRepositoryTest.java +++ b/backend/auth-service/src/test/java/com/finpay/auth/repository/RefreshTokenRepositoryTest.java @@ -2,6 +2,7 @@ import com.finpay.auth.entity.RefreshToken; import com.finpay.auth.testconfig.TestMySQLContainerConfig; +import com.finpay.auth.util.TokenHashUtil; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -40,7 +41,7 @@ void setUp() { repository.deleteAll(); userId = UUID.randomUUID(); testToken = RefreshToken.builder() - .token("test-refresh-token-" + UUID.randomUUID()) + .tokenHash(TokenHashUtil.sha256("test-refresh-token-" + UUID.randomUUID())) .userId(userId) .userEmail("john@example.com") .expiryDate(LocalDateTime.now().plusDays(7)) @@ -57,7 +58,7 @@ class FindByTokenTests { void shouldFindByToken() { RefreshToken saved = repository.save(testToken); - Optional found = repository.findByToken(testToken.getToken()); + Optional found = repository.findByTokenHash(testToken.getTokenHash()); assertThat(found).isPresent(); assertThat(found.get().getUserId()).isEqualTo(userId); @@ -67,7 +68,7 @@ void shouldFindByToken() { @Test @DisplayName("should return empty for non-existent token") void shouldReturnEmptyForNonExistentToken() { - Optional found = repository.findByToken("non-existent-token"); + Optional found = repository.findByTokenHash(TokenHashUtil.sha256("non-existent-token")); assertThat(found).isEmpty(); } @@ -82,14 +83,14 @@ class RevokeTests { void shouldRevokeAllTokensForUser() { // Create multiple tokens for the same user RefreshToken token1 = RefreshToken.builder() - .token("token-1-" + UUID.randomUUID()) + .tokenHash(TokenHashUtil.sha256("token-1-" + UUID.randomUUID())) .userId(userId) .userEmail("john@example.com") .expiryDate(LocalDateTime.now().plusDays(7)) .revoked(false) .build(); RefreshToken token2 = RefreshToken.builder() - .token("token-2-" + UUID.randomUUID()) + .tokenHash(TokenHashUtil.sha256("token-2-" + UUID.randomUUID())) .userId(userId) .userEmail("john@example.com") .expiryDate(LocalDateTime.now().plusDays(7)) @@ -104,8 +105,8 @@ void shouldRevokeAllTokensForUser() { entityManager.getEntityManager().clear(); // Verify all tokens are revoked - Optional found1 = repository.findByToken(token1.getToken()); - Optional found2 = repository.findByToken(token2.getToken()); + Optional found1 = repository.findByTokenHash(token1.getTokenHash()); + Optional found2 = repository.findByTokenHash(token2.getTokenHash()); assertThat(found1).isPresent(); assertThat(found1.get().isRevoked()).isTrue(); @@ -118,7 +119,7 @@ void shouldRevokeAllTokensForUser() { void shouldNotRevokeOtherUsersTokens() { UUID otherUserId = UUID.randomUUID(); RefreshToken otherToken = RefreshToken.builder() - .token("other-token-" + UUID.randomUUID()) + .tokenHash(TokenHashUtil.sha256("other-token-" + UUID.randomUUID())) .userId(otherUserId) .userEmail("other@example.com") .expiryDate(LocalDateTime.now().plusDays(7)) @@ -131,7 +132,7 @@ void shouldNotRevokeOtherUsersTokens() { repository.revokeAllByUserId(userId); // Other user's token should still be valid - Optional found = repository.findByToken(otherToken.getToken()); + Optional found = repository.findByTokenHash(otherToken.getTokenHash()); assertThat(found).isPresent(); assertThat(found.get().isRevoked()).isFalse(); } @@ -145,7 +146,7 @@ class DeleteExpiredTests { @DisplayName("should delete expired tokens") void shouldDeleteExpiredTokens() { RefreshToken expiredToken = RefreshToken.builder() - .token("expired-token-" + UUID.randomUUID()) + .tokenHash(TokenHashUtil.sha256("expired-token-" + UUID.randomUUID())) .userId(userId) .userEmail("john@example.com") .expiryDate(LocalDateTime.now().minusDays(1)) @@ -157,8 +158,8 @@ void shouldDeleteExpiredTokens() { repository.deleteExpiredTokens(LocalDateTime.now()); - assertThat(repository.findByToken(testToken.getToken())).isPresent(); - assertThat(repository.findByToken(expiredToken.getToken())).isEmpty(); + assertThat(repository.findByTokenHash(testToken.getTokenHash())).isPresent(); + assertThat(repository.findByTokenHash(expiredToken.getTokenHash())).isEmpty(); } } @@ -170,7 +171,7 @@ class TokenValidityTests { @DisplayName("should report valid token") void shouldReportValidToken() { RefreshToken saved = repository.save(testToken); - Optional found = repository.findByToken(testToken.getToken()); + Optional found = repository.findByTokenHash(testToken.getTokenHash()); assertThat(found).isPresent(); assertThat(found.get().isValid()).isTrue(); @@ -183,7 +184,7 @@ void shouldReportRevokedTokenAsInvalid() { testToken.setRevoked(true); repository.save(testToken); - Optional found = repository.findByToken(testToken.getToken()); + Optional found = repository.findByTokenHash(testToken.getTokenHash()); assertThat(found).isPresent(); assertThat(found.get().isValid()).isFalse(); @@ -195,7 +196,7 @@ void shouldReportExpiredTokenAsInvalid() { testToken.setExpiryDate(LocalDateTime.now().minusDays(1)); repository.save(testToken); - Optional found = repository.findByToken(testToken.getToken()); + Optional found = repository.findByTokenHash(testToken.getTokenHash()); assertThat(found).isPresent(); assertThat(found.get().isValid()).isFalse(); diff --git a/backend/auth-service/src/test/java/com/finpay/auth/service/AuthServiceTest.java b/backend/auth-service/src/test/java/com/finpay/auth/service/AuthServiceTest.java index 377675f..4869ad6 100644 --- a/backend/auth-service/src/test/java/com/finpay/auth/service/AuthServiceTest.java +++ b/backend/auth-service/src/test/java/com/finpay/auth/service/AuthServiceTest.java @@ -12,6 +12,7 @@ import com.finpay.auth.repository.RefreshTokenRepository; import com.finpay.auth.repository.UserCredentialRepository; import com.finpay.auth.security.JwtService; +import com.finpay.auth.util.TokenHashUtil; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -252,14 +253,14 @@ void shouldRejectRefreshWhenSuspended() { RefreshToken validToken = RefreshToken.builder() .id(UUID.randomUUID()) - .token("suspended-user-token") + .tokenHash(TokenHashUtil.sha256("suspended-user-token")) .userId(savedCredential.getId()) .userEmail("john@example.com") .expiryDate(LocalDateTime.now().plusDays(7)) .revoked(false) .build(); - when(refreshTokenRepository.findByToken("suspended-user-token")).thenReturn(Optional.of(validToken)); + when(refreshTokenRepository.findByTokenHash(TokenHashUtil.sha256("suspended-user-token"))).thenReturn(Optional.of(validToken)); when(credentialRepository.findById(savedCredential.getId())).thenReturn(Optional.of(savedCredential)); when(refreshTokenRepository.save(any(RefreshToken.class))).thenReturn(validToken); @@ -278,14 +279,14 @@ void shouldRejectRefreshWhenSuspended() { void shouldRefreshTokenSuccessfully() { RefreshToken validToken = RefreshToken.builder() .id(UUID.randomUUID()) - .token("valid-refresh-token") + .tokenHash(TokenHashUtil.sha256("valid-refresh-token")) .userId(savedCredential.getId()) .userEmail("john@example.com") .expiryDate(LocalDateTime.now().plusDays(7)) .revoked(false) .build(); - when(refreshTokenRepository.findByToken("valid-refresh-token")).thenReturn(Optional.of(validToken)); + when(refreshTokenRepository.findByTokenHash(TokenHashUtil.sha256("valid-refresh-token"))).thenReturn(Optional.of(validToken)); when(credentialRepository.findById(savedCredential.getId())).thenReturn(Optional.of(savedCredential)); when(refreshTokenRepository.save(any(RefreshToken.class))).thenReturn(validToken); when(jwtService.generateAccessToken(any())).thenReturn("new-access-token"); @@ -304,7 +305,7 @@ void shouldRefreshTokenSuccessfully() { @Test @DisplayName("should throw exception for invalid refresh token") void shouldThrowForInvalidRefreshToken() { - when(refreshTokenRepository.findByToken("invalid-token")).thenReturn(Optional.empty()); + when(refreshTokenRepository.findByTokenHash(TokenHashUtil.sha256("invalid-token"))).thenReturn(Optional.empty()); RefreshTokenRequest request = new RefreshTokenRequest("invalid-token"); assertThatThrownBy(() -> authService.refreshToken(request)) @@ -317,14 +318,14 @@ void shouldThrowForInvalidRefreshToken() { void shouldThrowForExpiredRefreshToken() { RefreshToken expiredToken = RefreshToken.builder() .id(UUID.randomUUID()) - .token("expired-token") + .tokenHash(TokenHashUtil.sha256("expired-token")) .userId(savedCredential.getId()) .userEmail("john@example.com") .expiryDate(LocalDateTime.now().minusDays(1)) .revoked(false) .build(); - when(refreshTokenRepository.findByToken("expired-token")).thenReturn(Optional.of(expiredToken)); + when(refreshTokenRepository.findByTokenHash(TokenHashUtil.sha256("expired-token"))).thenReturn(Optional.of(expiredToken)); RefreshTokenRequest request = new RefreshTokenRequest("expired-token"); assertThatThrownBy(() -> authService.refreshToken(request)) @@ -342,11 +343,11 @@ class LogoutTests { void shouldLogoutSuccessfully() { RefreshToken token = RefreshToken.builder() .id(UUID.randomUUID()) - .token("refresh-token") + .tokenHash(TokenHashUtil.sha256("refresh-token")) .revoked(false) .build(); - when(refreshTokenRepository.findByToken("refresh-token")).thenReturn(Optional.of(token)); + when(refreshTokenRepository.findByTokenHash(TokenHashUtil.sha256("refresh-token"))).thenReturn(Optional.of(token)); when(refreshTokenRepository.save(any(RefreshToken.class))).thenReturn(token); authService.logout("refresh-token", null);