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.