feat(api): implement recaptcha v3 anti-spam protection
- Add RecaptchaValidatorInterface and RecaptchaResult entity in Domain - Create RecaptchaValidationService in Application layer - Implement GoogleRecaptchaValidator for API integration - Add recaptcha-settings schema and admin FormBuilder - Integrate reCAPTCHA validation in NewsletterAjaxHandler - Integrate reCAPTCHA validation in ContactFormAjaxHandler - Update FooterRenderer and ContactFormRenderer with reCAPTCHA scripts - Configure DIContainer with RecaptchaValidationService injection Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
249
Shared/Application/Services/RecaptchaValidationService.php
Normal file
249
Shared/Application/Services/RecaptchaValidationService.php
Normal file
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Application\Services;
|
||||
|
||||
use ROITheme\Shared\Domain\Contracts\RecaptchaValidatorInterface;
|
||||
use ROITheme\Shared\Domain\Contracts\ComponentSettingsRepositoryInterface;
|
||||
use ROITheme\Shared\Domain\Entities\RecaptchaResult;
|
||||
|
||||
/**
|
||||
* RecaptchaValidationService - Servicio de aplicacion para validacion reCAPTCHA
|
||||
*
|
||||
* RESPONSABILIDAD: Orquestar la validacion de tokens reCAPTCHA v3,
|
||||
* aplicando la configuracion del componente (threshold, fail-open, logging).
|
||||
*
|
||||
* DEPENDENCIAS (inyectadas via constructor):
|
||||
* - RecaptchaValidatorInterface: Para validar tokens con API de Google
|
||||
* - ComponentSettingsRepositoryInterface: Para obtener configuracion de BD
|
||||
*
|
||||
* USO:
|
||||
* ```php
|
||||
* $service = new RecaptchaValidationService($validator, $settingsRepo);
|
||||
* $isValid = $service->validateSubmission($token, 'newsletter_submit');
|
||||
* ```
|
||||
*
|
||||
* @package ROITheme\Shared\Application\Services
|
||||
*/
|
||||
final class RecaptchaValidationService
|
||||
{
|
||||
private const COMPONENT_NAME = 'recaptcha-settings';
|
||||
|
||||
public function __construct(
|
||||
private RecaptchaValidatorInterface $validator,
|
||||
private ComponentSettingsRepositoryInterface $settingsRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Validar un envio de formulario con reCAPTCHA
|
||||
*
|
||||
* @param string $token Token de reCAPTCHA del frontend
|
||||
* @param string $action Accion a validar (ej: 'newsletter_submit')
|
||||
* @return bool True si la validacion pasa, false si falla
|
||||
*/
|
||||
public function validateSubmission(string $token, string $action): bool
|
||||
{
|
||||
$settings = $this->getSettings();
|
||||
|
||||
// Si reCAPTCHA esta deshabilitado, permitir siempre
|
||||
if (!$this->isEnabled($settings)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Validar que tengamos las credenciales
|
||||
$secretKey = $this->getSecretKey($settings);
|
||||
if (empty($secretKey)) {
|
||||
$this->log('reCAPTCHA: Secret key no configurada, permitiendo envio');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Si no hay token y fail-open esta habilitado, permitir
|
||||
if (empty($token)) {
|
||||
if ($this->isFailOpen($settings)) {
|
||||
$this->log('reCAPTCHA: Token vacio, fail-open habilitado');
|
||||
return true;
|
||||
}
|
||||
$this->logBlocked('Token vacio', 0.0, $action);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validar con API de Google
|
||||
$result = $this->validator->validate($token, $action, $secretKey);
|
||||
|
||||
// Manejar errores de API
|
||||
if (!$result->isSuccess()) {
|
||||
if ($this->isFailOpen($settings)) {
|
||||
$this->log('reCAPTCHA: API error, fail-open habilitado: ' . implode(', ', $result->getErrorCodes()));
|
||||
return true;
|
||||
}
|
||||
$this->logBlocked('API error: ' . implode(', ', $result->getErrorCodes()), 0.0, $action);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verificar threshold
|
||||
$threshold = $this->getThreshold($settings);
|
||||
$isValid = $result->isValid($threshold);
|
||||
|
||||
if (!$isValid && $this->shouldLogBlocked($settings)) {
|
||||
$this->logBlocked('Score bajo', $result->getScore(), $action, $threshold);
|
||||
}
|
||||
|
||||
return $isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar si reCAPTCHA esta habilitado
|
||||
*
|
||||
* @return bool True si esta habilitado
|
||||
*/
|
||||
public function isRecaptchaEnabled(): bool
|
||||
{
|
||||
return $this->isEnabled($this->getSettings());
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener el Site Key para uso en frontend
|
||||
*
|
||||
* @return string Site key o cadena vacia si no configurado
|
||||
*/
|
||||
public function getSiteKey(): string
|
||||
{
|
||||
$settings = $this->getSettings();
|
||||
return $settings['credentials']['site_key'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener la accion configurada para newsletter
|
||||
*
|
||||
* @return string Nombre de la accion
|
||||
*/
|
||||
public function getNewsletterAction(): string
|
||||
{
|
||||
$settings = $this->getSettings();
|
||||
return $settings['behavior']['action_newsletter'] ?? 'newsletter_submit';
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener la accion configurada para contacto
|
||||
*
|
||||
* @return string Nombre de la accion
|
||||
*/
|
||||
public function getContactAction(): string
|
||||
{
|
||||
$settings = $this->getSettings();
|
||||
return $settings['behavior']['action_contact'] ?? 'contact_submit';
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener configuracion del componente
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function getSettings(): array
|
||||
{
|
||||
return $this->settingsRepository->getComponentSettings(self::COMPONENT_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar si esta habilitado
|
||||
*
|
||||
* @param array<string, mixed> $settings
|
||||
* @return bool
|
||||
*/
|
||||
private function isEnabled(array $settings): bool
|
||||
{
|
||||
$isEnabled = $settings['visibility']['is_enabled'] ?? false;
|
||||
return $isEnabled === true || $isEnabled === '1' || $isEnabled === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener secret key
|
||||
*
|
||||
* @param array<string, mixed> $settings
|
||||
* @return string
|
||||
*/
|
||||
private function getSecretKey(array $settings): string
|
||||
{
|
||||
return $settings['credentials']['secret_key'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener threshold configurado
|
||||
*
|
||||
* @param array<string, mixed> $settings
|
||||
* @return float
|
||||
*/
|
||||
private function getThreshold(array $settings): float
|
||||
{
|
||||
$threshold = $settings['behavior']['score_threshold'] ?? '0.5';
|
||||
return (float) $threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar si fail-open esta habilitado
|
||||
*
|
||||
* @param array<string, mixed> $settings
|
||||
* @return bool
|
||||
*/
|
||||
private function isFailOpen(array $settings): bool
|
||||
{
|
||||
$failOpen = $settings['behavior']['fail_open'] ?? true;
|
||||
return $failOpen === true || $failOpen === '1' || $failOpen === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar si se debe loguear intentos bloqueados
|
||||
*
|
||||
* @param array<string, mixed> $settings
|
||||
* @return bool
|
||||
*/
|
||||
private function shouldLogBlocked(array $settings): bool
|
||||
{
|
||||
$logBlocked = $settings['behavior']['log_blocked'] ?? true;
|
||||
return $logBlocked === true || $logBlocked === '1' || $logBlocked === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrar mensaje en log
|
||||
*
|
||||
* @param string $message
|
||||
*/
|
||||
private function log(string $message): void
|
||||
{
|
||||
error_log('ROI Theme ' . $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrar intento bloqueado
|
||||
*
|
||||
* @param string $reason Razon del bloqueo
|
||||
* @param float $score Score obtenido
|
||||
* @param string $action Accion intentada
|
||||
* @param float|null $threshold Threshold configurado
|
||||
*/
|
||||
private function logBlocked(string $reason, float $score, string $action, ?float $threshold = null): void
|
||||
{
|
||||
$ip = $this->getClientIP();
|
||||
$thresholdStr = $threshold !== null ? ", threshold: {$threshold}" : '';
|
||||
$this->log("reCAPTCHA BLOCKED: {$reason} | action: {$action} | score: {$score}{$thresholdStr} | IP: {$ip}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener IP del cliente (para logging)
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function getClientIP(): string
|
||||
{
|
||||
if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
|
||||
return sanitize_text_field($_SERVER['HTTP_CLIENT_IP']);
|
||||
}
|
||||
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||
return sanitize_text_field(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0]);
|
||||
}
|
||||
if (!empty($_SERVER['REMOTE_ADDR'])) {
|
||||
return sanitize_text_field($_SERVER['REMOTE_ADDR']);
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
42
Shared/Domain/Contracts/RecaptchaValidatorInterface.php
Normal file
42
Shared/Domain/Contracts/RecaptchaValidatorInterface.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Domain\Contracts;
|
||||
|
||||
use ROITheme\Shared\Domain\Entities\RecaptchaResult;
|
||||
|
||||
/**
|
||||
* RecaptchaValidatorInterface - Contrato para validacion de reCAPTCHA
|
||||
*
|
||||
* RESPONSABILIDAD: Definir el contrato para validar tokens de reCAPTCHA v3
|
||||
* con la API de Google.
|
||||
*
|
||||
* PRINCIPIOS:
|
||||
* - Interface Segregation: Una sola responsabilidad - validar tokens
|
||||
* - Dependency Inversion: Depender de abstraccion, no de implementacion
|
||||
*
|
||||
* USO:
|
||||
* ```php
|
||||
* final class GoogleRecaptchaValidator implements RecaptchaValidatorInterface
|
||||
* {
|
||||
* public function validate(string $token, string $action, string $secretKey): RecaptchaResult
|
||||
* {
|
||||
* // Llamar a API de Google y retornar resultado
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @package ROITheme\Shared\Domain\Contracts
|
||||
*/
|
||||
interface RecaptchaValidatorInterface
|
||||
{
|
||||
/**
|
||||
* Validar un token de reCAPTCHA v3 con la API de Google
|
||||
*
|
||||
* @param string $token Token generado por reCAPTCHA en frontend
|
||||
* @param string $action Nombre de la accion (ej: 'newsletter_submit', 'contact_submit')
|
||||
* @param string $secretKey Clave secreta de reCAPTCHA
|
||||
* @return RecaptchaResult Resultado de la validacion con score y estado
|
||||
*/
|
||||
public function validate(string $token, string $action, string $secretKey): RecaptchaResult;
|
||||
}
|
||||
134
Shared/Domain/Entities/RecaptchaResult.php
Normal file
134
Shared/Domain/Entities/RecaptchaResult.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Domain\Entities;
|
||||
|
||||
/**
|
||||
* RecaptchaResult - Entidad que representa el resultado de validacion reCAPTCHA
|
||||
*
|
||||
* RESPONSABILIDAD: Encapsular el resultado de la validacion de reCAPTCHA v3
|
||||
* incluyendo el score, estado de exito y posibles codigos de error.
|
||||
*
|
||||
* INMUTABILIDAD: Esta entidad es inmutable - una vez creada no puede modificarse.
|
||||
*
|
||||
* SCORE reCAPTCHA v3:
|
||||
* - 1.0: Muy probablemente humano
|
||||
* - 0.9: Probablemente humano
|
||||
* - 0.5: Indeterminado
|
||||
* - 0.1: Probablemente bot
|
||||
* - 0.0: Muy probablemente bot
|
||||
*
|
||||
* @package ROITheme\Shared\Domain\Entities
|
||||
*/
|
||||
final class RecaptchaResult
|
||||
{
|
||||
/**
|
||||
* @param bool $success Si la validacion fue exitosa (token valido)
|
||||
* @param float $score Score de 0.0 (bot) a 1.0 (humano)
|
||||
* @param string $action Accion verificada
|
||||
* @param array<string> $errorCodes Codigos de error de la API
|
||||
* @param string $hostname Hostname donde se genero el token
|
||||
* @param string $challengeTs Timestamp del challenge
|
||||
*/
|
||||
public function __construct(
|
||||
private bool $success,
|
||||
private float $score,
|
||||
private string $action,
|
||||
private array $errorCodes = [],
|
||||
private string $hostname = '',
|
||||
private string $challengeTs = ''
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Verificar si el resultado es valido segun un threshold
|
||||
*
|
||||
* @param float $threshold Score minimo requerido (ej: 0.5)
|
||||
* @return bool True si success=true Y score >= threshold
|
||||
*/
|
||||
public function isValid(float $threshold): bool
|
||||
{
|
||||
return $this->success && $this->score >= $threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar si la accion coincide con la esperada
|
||||
*
|
||||
* @param string $expectedAction Accion esperada
|
||||
* @return bool True si la accion coincide
|
||||
*/
|
||||
public function hasValidAction(string $expectedAction): bool
|
||||
{
|
||||
return $this->action === $expectedAction;
|
||||
}
|
||||
|
||||
public function isSuccess(): bool
|
||||
{
|
||||
return $this->success;
|
||||
}
|
||||
|
||||
public function getScore(): float
|
||||
{
|
||||
return $this->score;
|
||||
}
|
||||
|
||||
public function getAction(): string
|
||||
{
|
||||
return $this->action;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string>
|
||||
*/
|
||||
public function getErrorCodes(): array
|
||||
{
|
||||
return $this->errorCodes;
|
||||
}
|
||||
|
||||
public function hasErrors(): bool
|
||||
{
|
||||
return !empty($this->errorCodes);
|
||||
}
|
||||
|
||||
public function getHostname(): string
|
||||
{
|
||||
return $this->hostname;
|
||||
}
|
||||
|
||||
public function getChallengeTs(): string
|
||||
{
|
||||
return $this->challengeTs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crear resultado de fallo (para errores de red, timeout, etc.)
|
||||
*
|
||||
* @param array<string> $errorCodes Codigos de error
|
||||
* @return self
|
||||
*/
|
||||
public static function failure(array $errorCodes = ['unknown-error']): self
|
||||
{
|
||||
return new self(
|
||||
success: false,
|
||||
score: 0.0,
|
||||
action: '',
|
||||
errorCodes: $errorCodes
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertir a array para logging
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'success' => $this->success,
|
||||
'score' => $this->score,
|
||||
'action' => $this->action,
|
||||
'error_codes' => $this->errorCodes,
|
||||
'hostname' => $this->hostname,
|
||||
'challenge_ts' => $this->challengeTs,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,10 @@ use ROITheme\Shared\Domain\Contracts\PostGridShortcodeRendererInterface;
|
||||
use ROITheme\Shared\Infrastructure\Query\PostGridQueryBuilder;
|
||||
use ROITheme\Shared\Infrastructure\Ui\PostGridShortcodeRenderer;
|
||||
use ROITheme\Shared\Application\UseCases\RenderPostGrid\RenderPostGridUseCase;
|
||||
// reCAPTCHA Anti-spam System
|
||||
use ROITheme\Shared\Domain\Contracts\RecaptchaValidatorInterface;
|
||||
use ROITheme\Shared\Infrastructure\Services\GoogleRecaptchaValidator;
|
||||
use ROITheme\Shared\Application\Services\RecaptchaValidationService;
|
||||
|
||||
/**
|
||||
* DIContainer - Contenedor de Inyección de Dependencias
|
||||
@@ -542,4 +546,35 @@ final class DIContainer
|
||||
}
|
||||
return $this->instances['renderPostGridUseCase'];
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// reCAPTCHA Anti-spam System
|
||||
// ===============================
|
||||
|
||||
/**
|
||||
* Obtiene el validador de reCAPTCHA (comunicacion con API de Google)
|
||||
*/
|
||||
public function getRecaptchaValidator(): RecaptchaValidatorInterface
|
||||
{
|
||||
if (!isset($this->instances['recaptchaValidator'])) {
|
||||
$this->instances['recaptchaValidator'] = new GoogleRecaptchaValidator();
|
||||
}
|
||||
return $this->instances['recaptchaValidator'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el servicio de validacion reCAPTCHA
|
||||
*
|
||||
* Orquesta la validacion usando configuracion de BD
|
||||
*/
|
||||
public function getRecaptchaValidationService(): RecaptchaValidationService
|
||||
{
|
||||
if (!isset($this->instances['recaptchaValidationService'])) {
|
||||
$this->instances['recaptchaValidationService'] = new RecaptchaValidationService(
|
||||
$this->getRecaptchaValidator(),
|
||||
$this->getComponentSettingsRepository()
|
||||
);
|
||||
}
|
||||
return $this->instances['recaptchaValidationService'];
|
||||
}
|
||||
}
|
||||
|
||||
112
Shared/Infrastructure/Services/GoogleRecaptchaValidator.php
Normal file
112
Shared/Infrastructure/Services/GoogleRecaptchaValidator.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Infrastructure\Services;
|
||||
|
||||
use ROITheme\Shared\Domain\Contracts\RecaptchaValidatorInterface;
|
||||
use ROITheme\Shared\Domain\Entities\RecaptchaResult;
|
||||
|
||||
/**
|
||||
* GoogleRecaptchaValidator - Implementacion de validacion reCAPTCHA con API de Google
|
||||
*
|
||||
* RESPONSABILIDAD: Comunicarse con la API de Google reCAPTCHA v3 para
|
||||
* validar tokens y obtener scores.
|
||||
*
|
||||
* API ENDPOINT: https://www.google.com/recaptcha/api/siteverify
|
||||
*
|
||||
* RESPUESTA DE GOOGLE:
|
||||
* ```json
|
||||
* {
|
||||
* "success": true|false,
|
||||
* "score": 0.0-1.0,
|
||||
* "action": "string",
|
||||
* "challenge_ts": "timestamp",
|
||||
* "hostname": "string",
|
||||
* "error-codes": ["string"]
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @package ROITheme\Shared\Infrastructure\Services
|
||||
*/
|
||||
final class GoogleRecaptchaValidator implements RecaptchaValidatorInterface
|
||||
{
|
||||
private const API_URL = 'https://www.google.com/recaptcha/api/siteverify';
|
||||
private const TIMEOUT_SECONDS = 5;
|
||||
|
||||
/**
|
||||
* Validar token de reCAPTCHA v3 con API de Google
|
||||
*
|
||||
* @param string $token Token generado por reCAPTCHA en frontend
|
||||
* @param string $action Accion esperada
|
||||
* @param string $secretKey Clave secreta de reCAPTCHA
|
||||
* @return RecaptchaResult Resultado de la validacion
|
||||
*/
|
||||
public function validate(string $token, string $action, string $secretKey): RecaptchaResult
|
||||
{
|
||||
// Validar parametros
|
||||
if (empty($token) || empty($secretKey)) {
|
||||
return RecaptchaResult::failure(['missing-input-secret']);
|
||||
}
|
||||
|
||||
// Preparar request
|
||||
$response = wp_remote_post(self::API_URL, [
|
||||
'timeout' => self::TIMEOUT_SECONDS,
|
||||
'body' => [
|
||||
'secret' => $secretKey,
|
||||
'response' => $token,
|
||||
'remoteip' => $this->getClientIP(),
|
||||
],
|
||||
]);
|
||||
|
||||
// Manejar error de conexion
|
||||
if (is_wp_error($response)) {
|
||||
error_log('ROI Theme reCAPTCHA API error: ' . $response->get_error_message());
|
||||
return RecaptchaResult::failure(['connection-error']);
|
||||
}
|
||||
|
||||
// Verificar codigo HTTP
|
||||
$statusCode = wp_remote_retrieve_response_code($response);
|
||||
if ($statusCode !== 200) {
|
||||
error_log('ROI Theme reCAPTCHA API HTTP error: ' . $statusCode);
|
||||
return RecaptchaResult::failure(['http-error-' . $statusCode]);
|
||||
}
|
||||
|
||||
// Parsear respuesta JSON
|
||||
$body = wp_remote_retrieve_body($response);
|
||||
$data = json_decode($body, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) {
|
||||
error_log('ROI Theme reCAPTCHA API invalid JSON: ' . $body);
|
||||
return RecaptchaResult::failure(['invalid-json']);
|
||||
}
|
||||
|
||||
// Construir resultado
|
||||
return new RecaptchaResult(
|
||||
success: (bool) ($data['success'] ?? false),
|
||||
score: (float) ($data['score'] ?? 0.0),
|
||||
action: (string) ($data['action'] ?? ''),
|
||||
errorCodes: (array) ($data['error-codes'] ?? []),
|
||||
hostname: (string) ($data['hostname'] ?? ''),
|
||||
challengeTs: (string) ($data['challenge_ts'] ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener IP del cliente
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function getClientIP(): string
|
||||
{
|
||||
if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
|
||||
return sanitize_text_field($_SERVER['HTTP_CLIENT_IP']);
|
||||
}
|
||||
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||
return sanitize_text_field(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0]);
|
||||
}
|
||||
if (!empty($_SERVER['REMOTE_ADDR'])) {
|
||||
return sanitize_text_field($_SERVER['REMOTE_ADDR']);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user