Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ plugins {
}

group = 'com.flexcodelabs'
version = '0.0.78'
version = '0.0.80'
description = 'Flextuma App'

java {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
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;
import jakarta.servlet.http.HttpServletResponse;
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;
Expand All @@ -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)
Expand All @@ -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<PersonalAccessToken> patOpt = patRepository.findByToken(hashedToken);

boolean authenticated = false;

if (patOpt.isPresent()) {
PersonalAccessToken pat = patOpt.get();

Expand All @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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
Expand All @@ -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);
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -34,6 +40,12 @@ class PatAuthenticationFilterTest {
@Mock
private PersonalAccessTokenRepository patRepository;

@Mock
private AuthRateLimitService rateLimitService;

@Mock
private ObjectMapper objectMapper;

@Mock
private HttpServletRequest request;

Expand Down Expand Up @@ -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);
}

Expand All @@ -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");
Expand Down