Implementación de CAPTCHA gráfico con limitación de tasa y caché en Spring Boot

Para proteger sistemas contra ataques de fuerza bruta y solicitudes maliciosas, es común implementar mecanismos de verificación humana en autenticaciones. Este artículo detalla cómo integrar un CAPTCHA gráfico en Spring Boot, combinado con control de frecuencia de acceso y almacenamiento en caché usando Redis.

Generación del CAPTCHA gráfico

Dependencia de Maven

Se utiliza la biblioteca hutool para simplificar la generación de CAPTCHAs:

<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.6</version>
</dependency>

Utilidad para crear imágenes de CAPTCHA

Esta clase genera un CAPTCHA y devuelve su identificador único, código y representación Base64:

package com.example.security;

import cn.hutool.captcha.CaptchaUtil;
import cn.hutool.captcha.LineCaptcha;
import cn.hutool.core.util.IdUtil;
import lombok.extern.slf4j.Slf4j;
import javax.imageio.ImageIO;
import java.io.ByteArrayOutputStream;
import java.util.Base64;

@Slf4j
public class CaptchaGenerator {
    public String[] produceCaptchaData() {
        try {
            LineCaptcha captchaImage = CaptchaUtil.createLineCaptcha(90, 35);
            String uniqueId = IdUtil.fastSimpleUUID();
            ByteArrayOutputStream imageStream = new ByteArrayOutputStream();
            ImageIO.write(captchaImage.getImage(), "png", imageStream);
            String encodedImage = Base64.getEncoder().encodeToString(imageStream.toByteArray());
            return new String[]{uniqueId, captchaImage.getCode(), encodedImage};
        } catch (Exception e) {
            log.error("Error al generar CAPTCHA: {}", e.getMessage());
            return null;
        }
    }
}

Controlaodr para genercaión y verificación

Este controlador maneja la creación y validación del CAPTCHA, almacenando datos en Redis:

package com.example.controller;

import com.example.model.CaptchaRequest;
import com.example.util.CaptchaGenerator;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;

@RestController
@RequestMapping("/captcha")
public class CaptchaController {
    private final RedisTemplate<String, String> redisTemplate;
    private final CaptchaGenerator captchaGenerator;

    public CaptchaController(RedisTemplate<String, String> redisTemplate, CaptchaGenerator captchaGenerator) {
        this.redisTemplate = redisTemplate;
        this.captchaGenerator = captchaGenerator;
    }

    @GetMapping("/generate/{userId}/{userEmail}")
    public Map<String, Object> generateCaptcha(@PathVariable String userId, @PathVariable String userEmail) {
        ValueOperations<String, String> valueOps = redisTemplate.opsForValue();
        String cacheKey = userId + "_" + userEmail;

        if (redisTemplate.hasKey(cacheKey)) {
            redisTemplate.delete(cacheKey);
        }

        String[] captchaData = captchaGenerator.produceCaptchaData();
        if (captchaData == null) {
            throw new RuntimeException("Error en la generación del CAPTCHA");
        }

        valueOps.set(captchaData[0], captchaData[1]);
        Map<String, Object> response = new HashMap<>();
        response.put("imageBase64", "data:image/png;base64," + captchaData[2]);
        response.put("captchaId", captchaData[0]);
        return response;
    }

    @PostMapping("/validate")
    public String validateCaptcha(@RequestBody CaptchaRequest request) {
        ValueOperations<String, String> valueOps = redisTemplate.opsForValue();
        String storedCode = valueOps.get(request.getCaptchaId());

        if (storedCode == null || !storedCode.equals(request.getInputCode())) {
            throw new IllegalArgumentException("Código CAPTCHA incorrecto");
        }
        // La eliminación del caché se realiza después de la autenticación exitosa
        return "Validación exitosa";
    }
}

El DTO para las solicitudes de vaildación:

package com.example.model;

import lombok.Data;

@Data
public class CaptchaRequest {
    private String captchaId;
    private String inputCode;
    private String email;
}

Integración con el frontend

Ejemplo de código en JavaScript para consumir la API:

// Función para obtener CAPTCHA
async function fetchCaptcha(userId, email) {
    const response = await fetch(`/captcha/generate/${userId}/${email}`);
    return await response.json();
}

// Función para validar CAPTCHA
async function verifyCaptcha(captchaData) {
    const response = await fetch('/captcha/validate', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(captchaData)
    });
    return await response.text();
}

Limitación de tasa de acceso

Definición de anotación personalizada

Creamos una anotación para configurar límites de acceso:

package com.example.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimiter {
    int maxRequests() default 10;
    int windowSeconds() default 120;
}

Aspecto para control de frecuencia

Este aspecto intercepta métodos anotados para aplicar control de tasa:

package com.example.aspect;

import com.example.annotation.RateLimiter;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;

@Aspect
@Component
public class RateLimitAspect {
    private final RedisTemplate<String, Integer> redisTemplate;

    public RateLimitAspect(RedisTemplate<String, Integer> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    @Before("@annotation(rateLimiter)")
    public void enforceRateLimit(JoinPoint joinPoint, RateLimiter rateLimiter) {
        ValueOperations<String, Integer> valueOps = redisTemplate.opsForValue();
        Object[] args = joinPoint.getArgs();
        String identifier = (String) args[1]; // Asumiendo el segundo argumento como identificador
        String key = "rate:" + identifier;

        Integer requestCount = valueOps.get(key);
        if (requestCount == null) {
            requestCount = 0;
        }

        if (requestCount >= rateLimiter.maxRequests()) {
            throw new RuntimeException("Límite de solicitudes excedido");
        }

        Long existingTtl = redisTemplate.getExpire(key);
        if (existingTtl > 0) {
            valueOps.set(key, requestCount + 1, existingTtl, TimeUnit.SECONDS);
        } else {
            valueOps.set(key, requestCount + 1, rateLimiter.windowSeconds(), TimeUnit.SECONDS);
        }
    }
}

Este enfoque utiliza Redis para mantener contadores por usuario, con expiración automática basada en ventanas de tiempo configurables.

Etiquetas: Spring Boot CAPTCHA Redis AOP java

Publicado el 6-22 07:55