"Rate limiting é a primeira linha de defesa contra abuso de API. Redis torna isso rápido e escalável." — #30DiasJava Security Notes

🎯 Objetivo do Day 20

Para proteger a API contra abuso e garantir fair usage, o Day 20 do #30DiasJava implementou Rate Limiting usando Redis, limitando requisições por endereço IP e retornando headers informativos sobre limites e disponibilidade.

🛠️ O que foi implementado

✅ Redis-based Rate Limiting

  • IP-based limiting: Limitação baseada em endereço IP do cliente
  • Sliding window: Janela deslizante de 60 segundos
  • Configurable limits: Limite configurável (padrão: 100 req/min)
  • Redis storage: Armazenamento de contadores em Redis

✅ Request Filtering

  • Spring Filter: Filtro OncePerRequestFilter para interceptar todas as requisições
  • Conditional activation: Ativo apenas quando Redis está disponível
  • Graceful degradation: Sistema funciona normalmente se Redis não estiver disponível

✅ Rate Limit Headers

  • X-RateLimit-Limit: Limite máximo de requisições por período
  • X-RateLimit-Remaining: Requisições restantes no período atual
  • HTTP 429: Status code "Too Many Requests" quando limite excedido

✅ IP Address Detection

  • X-Forwarded-For: Suporte para proxies e load balancers
  • X-Real-IP: Suporte para nginx reverse proxy
  • Remote address fallback: Fallback para IP remoto direto

📊 Arquitetura

┌─────────────────────────────────────────────────────────┐
│                    Client Request                       │
└──────────────────────┬──────────────────────────────────┘
                       │
                       ▼
┌─────────────────────────────────────────────────────────┐
│              RateLimitingFilter                         │
│  ┌──────────────────────────────────────────────────┐  │
│  │  1. Extract IP Address                           │  │
│  │  2. Check Redis Counter                          │  │
│  │  3. Increment Counter                             │  │
│  │  4. Add Rate Limit Headers                        │  │
│  │  5. Block if Limit Exceeded (HTTP 429)            │  │
│  └──────────────────────────────────────────────────┘  │
└──────────────────────┬──────────────────────────────────┘
                       │
                       ▼
┌─────────────────────────────────────────────────────────┐
│              Redis                                      │
│  ┌──────────────────────────────────────────────────┐  │
│  │  Key: "rate_limit:192.168.1.1"                   │  │
│  │  Value: "45" (request count)                      │  │
│  │  TTL: 60 seconds                                  │  │
│  └──────────────────────────────────────────────────┘  │
└──────────────────────┬──────────────────────────────────┘
                       │
                       ▼
┌─────────────────────────────────────────────────────────┐
│              Spring Security Filter Chain               │
│  ┌──────────────────────────────────────────────────┐  │
│  │  Continue to next filter / controller             │  │
│  └──────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────┘

💻 Implementação

Dependências (pom.xml)

<!-- Cache -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

RedisConfig

@Configuration
@ConditionalOnProperty(name = "spring.data.redis.host", matchIfMissing = false)
public class RedisConfig {

    @Value("${spring.data.redis.host:#{null}}")
    private String redisHost;

    @Value("${spring.data.redis.port:6379}")
    private int redisPort;

    @Bean
    public LettuceConnectionFactory redisConnectionFactory() {
        if (redisHost == null || redisHost.trim().isEmpty()) {
            throw new IllegalStateException("Redis host não configurado");
        }
        RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
        config.setHostName(redisHost);
        config.setPort(redisPort);
        return new LettuceConnectionFactory(config);
    }

    @Bean
    public RedisTemplate<String, String> redisTemplate(
            RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, String> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new StringRedisSerializer());
        template.afterPropertiesSet();
        return template;
    }
}

RateLimitingFilter

@Component
@ConditionalOnBean(RedisTemplate.class)
public class RateLimitingFilter extends OncePerRequestFilter {

    private static final Logger log = LoggerFactory.getLogger(
        RateLimitingFilter.class
    );
    
    @Autowired(required = false)
    private RedisTemplate<String, String> redisTemplate;
    
    // Configuration
    private static final int MAX_REQUESTS_PER_MINUTE = 100;
    private static final String RATE_LIMIT_KEY_PREFIX = "rate_limit:";
    
    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {
        
        // Skip rate limiting if Redis is not available
        if (redisTemplate == null) {
            filterChain.doFilter(request, response);
            return;
        }
        
        String clientIp = getClientIpAddress(request);
        String rateLimitKey = RATE_LIMIT_KEY_PREFIX + clientIp;
        
        // Check current request count
        ValueOperations<String, String> ops = redisTemplate.opsForValue();
        String currentCount = ops.get(rateLimitKey);
        
        int count = currentCount != null ? Integer.parseInt(currentCount) : 0;
        
        if (count >= MAX_REQUESTS_PER_MINUTE) {
            log.warn("Rate limit exceeded for IP: {}", clientIp);
            response.setStatus(429); // Too Many Requests
            response.setContentType("application/json");
            response.getWriter().write(
                "{\"error\":\"Rate limit exceeded\"," +
                "\"message\":\"Too many requests. Please try again later.\"}"
            );
            return;
        }
        
        // Increment counter
        if (count == 0) {
            ops.set(rateLimitKey, "1", 60, TimeUnit.SECONDS);
        } else {
            ops.increment(rateLimitKey);
        }
        
        // Add rate limit headers
        response.setHeader("X-RateLimit-Limit", 
            String.valueOf(MAX_REQUESTS_PER_MINUTE));
        response.setHeader("X-RateLimit-Remaining", 
            String.valueOf(MAX_REQUESTS_PER_MINUTE - count - 1));
        
        filterChain.doFilter(request, response);
    }
    
    private String getClientIpAddress(HttpServletRequest request) {
        // Check X-Forwarded-For header (for proxies/load balancers)
        String xForwardedFor = request.getHeader("X-Forwarded-For");
        if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
            return xForwardedFor.split(",")[0].trim();
        }
        
        // Check X-Real-IP header (for nginx reverse proxy)
        String xRealIp = request.getHeader("X-Real-IP");
        if (xRealIp != null && !xRealIp.isEmpty()) {
            return xRealIp;
        }
        
        // Fallback to remote address
        return request.getRemoteAddr();
    }
}

SecurityConfig Integration

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    @Autowired
    private RateLimitingFilter rateLimitingFilter;
    
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) {
        http
            .addFilterBefore(rateLimitingFilter, 
                UsernamePasswordAuthenticationFilter.class)
            // ... outras configurações
        ;
        return http.build();
    }
}

Configuration (application.yml)

spring:
  data:
    redis:
      host: ${REDIS_HOST:localhost}
      port: ${REDIS_PORT:6379}
      username: ${REDIS_USERNAME:}
      password: ${REDIS_PASSWORD:}

📈 Fluxo de Uso

1. Request Normal (dentro do limite)

# Request 1-100 no mesmo minuto
curl http://localhost:8080/api/familias

# Response Headers:
HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 99

# Request 50
curl http://localhost:8080/api/familias

# Response Headers:
HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 50

2. Request Excedendo Limite

# Request 101 (limite excedido)
curl http://localhost:8080/api/familias

# Response:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json

{
  "error": "Rate limit exceeded",
  "message": "Too many requests. Please try again later."
}

3. Redis Key Structure

# Redis keys
redis-cli

# Ver contador de um IP
GET rate_limit:192.168.1.1
# "45"

# Ver TTL restante
TTL rate_limit:192.168.1.1
# 35 (segundos restantes)

# Listar todos os rate limits ativos
KEYS rate_limit:*
# 1) "rate_limit:192.168.1.1"
# 2) "rate_limit:10.0.0.5"
# 3) "rate_limit:172.16.0.10"

📊 Métricas e Observabilidade

Logs

2025-11-20 10:15:23.456  WARN RateLimitingFilter : Rate limit exceeded for IP: 192.168.1.100
2025-11-20 10:15:24.123  INFO RateLimitingFilter : Rate limit check passed for IP: 10.0.0.5

Redis Monitoring

# Monitorar comandos Redis em tempo real
redis-cli MONITOR

# Output:
1658324523.456789 [0 127.0.0.1:54321] "GET" "rate_limit:192.168.1.1"
1658324523.457123 [0 127.0.0.1:54321] "INCR" "rate_limit:192.168.1.1"

🎓 Lições Aprendidas

✅ O que funcionou bem

  • Redis é rápido: Operações de incremento e verificação são muito rápidas
  • TTL automático: Redis expira keys automaticamente, não precisa limpeza manual
  • Graceful degradation: Sistema funciona mesmo se Redis não estiver disponível
  • IP detection: Suporte a proxies e load balancers via headers

⚠️ Desafios e soluções

  • Distributed rate limiting: Com múltiplas instâncias, cada uma tem seu próprio contador. Solução: usar Redis compartilhado (já implementado).
  • Rate limiting por usuário: Atualmente apenas por IP. Para rate limiting por usuário autenticado, usar userId em vez de IP.
  • Burst protection: Sliding window permite bursts. Para proteção contra bursts, considerar token bucket algorithm.

📚 Recursos

🔗 Links

Next episode → Day 21/30 — OpenAPI/Swagger Documentation