diff --git a/build.gradle b/build.gradle index f77593f..815ab88 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'com.flexcodelabs' -version = '0.0.78' +version = '0.0.80' description = 'Flextuma App' java { diff --git a/src/main/java/com/flexcodelabs/flextuma/core/security/PatAuthenticationFilter.java b/src/main/java/com/flexcodelabs/flextuma/core/security/PatAuthenticationFilter.java index 4083c87..47956f6 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/security/PatAuthenticationFilter.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/security/PatAuthenticationFilter.java @@ -1,8 +1,11 @@ package com.flexcodelabs.flextuma.core.security; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.flexcodelabs.flextuma.core.dto.ErrorResponse; import com.flexcodelabs.flextuma.core.entities.auth.PersonalAccessToken; import com.flexcodelabs.flextuma.core.entities.auth.User; import com.flexcodelabs.flextuma.core.repositories.PersonalAccessTokenRepository; +import com.flexcodelabs.flextuma.core.services.AuthRateLimitService; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -10,6 +13,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; @@ -28,7 +32,11 @@ @RequiredArgsConstructor public class PatAuthenticationFilter extends OncePerRequestFilter { + private static final String RATE_LIMIT_SCOPE = "PAT"; + private final PersonalAccessTokenRepository patRepository; + private final AuthRateLimitService rateLimitService; + private final ObjectMapper objectMapper; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) @@ -37,9 +45,17 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse String apiKey = request.getHeader("X-API-KEY"); if (apiKey != null && !apiKey.isBlank()) { + if (rateLimitService.isBlocked(request, RATE_LIMIT_SCOPE)) { + writeTooManyRequests(response, + rateLimitService.getBlockTimeRemainingSeconds(request, RATE_LIMIT_SCOPE)); + return; + } + String hashedToken = hashToken(apiKey); Optional patOpt = patRepository.findByToken(hashedToken); + boolean authenticated = false; + if (patOpt.isPresent()) { PersonalAccessToken pat = patOpt.get(); @@ -64,13 +80,30 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse pat.setLastUsedAt(LocalDateTime.now()); patRepository.save(pat); + authenticated = true; } } + + if (authenticated) { + rateLimitService.recordSuccessfulAttempt(request, RATE_LIMIT_SCOPE); + } else { + rateLimitService.recordFailedAttempt(request, RATE_LIMIT_SCOPE); + } } try { filterChain.doFilter(request, response); } finally { ApiTokenContext.clear(); } } + private void writeTooManyRequests(HttpServletResponse response, long retryAfterSeconds) throws IOException { + ErrorResponse errorResponse = ErrorResponse.tooManyRequests( + "Too many invalid API key attempts. Try again in " + retryAfterSeconds + " seconds."); + + response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); + response.setHeader("Retry-After", String.valueOf(retryAfterSeconds)); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } + private String hashToken(String token) { try { java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256"); diff --git a/src/main/java/com/flexcodelabs/flextuma/core/services/AuthRateLimitService.java b/src/main/java/com/flexcodelabs/flextuma/core/services/AuthRateLimitService.java index 888e454..87cc41f 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/services/AuthRateLimitService.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/services/AuthRateLimitService.java @@ -25,8 +25,14 @@ public class AuthRateLimitService { @Value("${flextuma.auth.window-minutes:5}") private int windowMinutes; + private static final String DEFAULT_SCOPE = "AUTH"; + public boolean isBlocked(HttpServletRequest request) { - String clientKey = getClientKey(request); + return isBlocked(request, DEFAULT_SCOPE); + } + + public boolean isBlocked(HttpServletRequest request, String scope) { + String clientKey = getClientKey(request, scope); // Check if client is currently blocked LocalDateTime blockEndTime = blockTimestamps.get(clientKey); @@ -45,7 +51,11 @@ public boolean isBlocked(HttpServletRequest request) { } public void recordFailedAttempt(HttpServletRequest request) { - String clientKey = getClientKey(request); + recordFailedAttempt(request, DEFAULT_SCOPE); + } + + public void recordFailedAttempt(HttpServletRequest request, String scope) { + String clientKey = getClientKey(request, scope); LocalDateTime now = LocalDateTime.now(); // Clean up old attempts outside the window @@ -71,7 +81,11 @@ public void recordFailedAttempt(HttpServletRequest request) { } public void recordSuccessfulAttempt(HttpServletRequest request) { - String clientKey = getClientKey(request); + recordSuccessfulAttempt(request, DEFAULT_SCOPE); + } + + public void recordSuccessfulAttempt(HttpServletRequest request, String scope) { + String clientKey = getClientKey(request, scope); // Clear all tracking on successful attempt attemptCounts.remove(clientKey); @@ -80,7 +94,11 @@ public void recordSuccessfulAttempt(HttpServletRequest request) { } public int getRemainingAttempts(HttpServletRequest request) { - String clientKey = getClientKey(request); + return getRemainingAttempts(request, DEFAULT_SCOPE); + } + + public int getRemainingAttempts(HttpServletRequest request, String scope) { + String clientKey = getClientKey(request, scope); AtomicInteger attempts = attemptCounts.get(clientKey); if (attempts == null) return maxAttempts; @@ -89,7 +107,11 @@ public int getRemainingAttempts(HttpServletRequest request) { } public long getBlockTimeRemainingSeconds(HttpServletRequest request) { - String clientKey = getClientKey(request); + return getBlockTimeRemainingSeconds(request, DEFAULT_SCOPE); + } + + public long getBlockTimeRemainingSeconds(HttpServletRequest request, String scope) { + String clientKey = getClientKey(request, scope); LocalDateTime blockEndTime = blockTimestamps.get(clientKey); if (blockEndTime == null) return 0; @@ -98,7 +120,11 @@ public long getBlockTimeRemainingSeconds(HttpServletRequest request) { return Math.max(0L, secondsRemaining); } - private String getClientKey(HttpServletRequest request) { + private String getClientKey(HttpServletRequest request, String scope) { + return scope + ":" + getClientIp(request); + } + + private String getClientIp(HttpServletRequest request) { String xForwardedFor = request.getHeader("X-Forwarded-For"); if (xForwardedFor != null && !xForwardedFor.isEmpty()) { return xForwardedFor.split(",")[0].trim(); diff --git a/src/test/java/com/flexcodelabs/flextuma/core/security/PatAuthenticationFilterTest.java b/src/test/java/com/flexcodelabs/flextuma/core/security/PatAuthenticationFilterTest.java index 0ec3311..23975ad 100644 --- a/src/test/java/com/flexcodelabs/flextuma/core/security/PatAuthenticationFilterTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/core/security/PatAuthenticationFilterTest.java @@ -3,10 +3,14 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.IOException; +import java.io.PrintWriter; import java.time.LocalDateTime; import java.util.Collections; import java.util.Optional; @@ -19,9 +23,11 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.core.context.SecurityContextHolder; +import com.fasterxml.jackson.databind.ObjectMapper; import com.flexcodelabs.flextuma.core.entities.auth.PersonalAccessToken; import com.flexcodelabs.flextuma.core.entities.auth.User; import com.flexcodelabs.flextuma.core.repositories.PersonalAccessTokenRepository; +import com.flexcodelabs.flextuma.core.services.AuthRateLimitService; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -34,6 +40,12 @@ class PatAuthenticationFilterTest { @Mock private PersonalAccessTokenRepository patRepository; + @Mock + private AuthRateLimitService rateLimitService; + + @Mock + private ObjectMapper objectMapper; + @Mock private HttpServletRequest request; @@ -73,6 +85,7 @@ void doFilterInternal_WithValidToken_AuthenticatesUser() throws ServletException assertNotNull(SecurityContextHolder.getContext().getAuthentication()); assertEquals("testuser", SecurityContextHolder.getContext().getAuthentication().getPrincipal()); verify(patRepository).save(pat); + verify(rateLimitService).recordSuccessfulAttempt(request, "PAT"); verify(filterChain).doFilter(request, response); } @@ -87,9 +100,27 @@ void doFilterInternal_WithInvalidToken_DoesNotAuthenticate() throws ServletExcep filter.doFilterInternal(request, response, filterChain); assertNull(SecurityContextHolder.getContext().getAuthentication()); + verify(rateLimitService).recordFailedAttempt(request, "PAT"); verify(filterChain).doFilter(request, response); } + @Test + void doFilterInternal_WhenRateLimited_RejectsWithoutLookup() throws ServletException, IOException { + when(request.getHeader("X-API-KEY")).thenReturn("some-token"); + when(rateLimitService.isBlocked(request, "PAT")).thenReturn(true); + when(rateLimitService.getBlockTimeRemainingSeconds(request, "PAT")).thenReturn(42L); + when(objectMapper.writeValueAsString(any())).thenReturn("{}"); + when(response.getWriter()).thenReturn(new PrintWriter(java.io.Writer.nullWriter())); + + filter.doFilterInternal(request, response, filterChain); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + verify(response).setStatus(429); + verify(response).setHeader("Retry-After", "42"); + verify(patRepository, never()).findByToken(anyString()); + verify(filterChain, never()).doFilter(request, response); + } + private String hashToken(String token) { try { java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256");