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:
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace ROITheme\Public\ContactForm\Infrastructure\Api\WordPress;
|
||||
|
||||
use ROITheme\Shared\Domain\Contracts\ComponentSettingsRepositoryInterface;
|
||||
use ROITheme\Shared\Application\Services\RecaptchaValidationService;
|
||||
|
||||
/**
|
||||
* ContactFormAjaxHandler - Procesa envios del formulario de contacto
|
||||
@@ -12,6 +13,7 @@ use ROITheme\Shared\Domain\Contracts\ComponentSettingsRepositoryInterface;
|
||||
*
|
||||
* SEGURIDAD:
|
||||
* - Verifica nonce
|
||||
* - reCAPTCHA v3 para proteccion anti-spam
|
||||
* - Webhook URL NUNCA se expone al cliente
|
||||
* - Webhook URL se obtiene de BD server-side
|
||||
* - Rate limiting basico
|
||||
@@ -25,7 +27,8 @@ final class ContactFormAjaxHandler
|
||||
private const COMPONENT_NAME = 'contact-form';
|
||||
|
||||
public function __construct(
|
||||
private ComponentSettingsRepositoryInterface $settingsRepository
|
||||
private ComponentSettingsRepositoryInterface $settingsRepository,
|
||||
private ?RecaptchaValidationService $recaptchaService = null
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -52,7 +55,15 @@ final class ContactFormAjaxHandler
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Rate limiting basico (1 envio por IP cada 30 segundos)
|
||||
// 2. Validar reCAPTCHA (si esta habilitado)
|
||||
if (!$this->validateRecaptcha()) {
|
||||
wp_send_json_error([
|
||||
'message' => __('Verificacion de seguridad fallida. Por favor intenta de nuevo.', 'roi-theme')
|
||||
], 403);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Rate limiting basico (1 envio por IP cada 30 segundos)
|
||||
if (!$this->checkRateLimit()) {
|
||||
wp_send_json_error([
|
||||
'message' => __('Por favor espera un momento antes de enviar otro mensaje.', 'roi-theme')
|
||||
@@ -60,7 +71,7 @@ final class ContactFormAjaxHandler
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Sanitizar y validar inputs
|
||||
// 4. Sanitizar y validar inputs
|
||||
$formData = $this->sanitizeFormData($_POST);
|
||||
$validation = $this->validateFormData($formData);
|
||||
|
||||
@@ -72,7 +83,7 @@ final class ContactFormAjaxHandler
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Obtener configuracion del componente (incluye webhook URL)
|
||||
// 5. Obtener configuracion del componente (incluye webhook URL)
|
||||
$settings = $this->settingsRepository->getComponentSettings(self::COMPONENT_NAME);
|
||||
|
||||
if (empty($settings)) {
|
||||
@@ -98,10 +109,10 @@ final class ContactFormAjaxHandler
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Preparar payload para webhook
|
||||
// 6. Preparar payload para webhook
|
||||
$payload = $this->preparePayload($formData, $includePageUrl, $includeTimestamp);
|
||||
|
||||
// 6. Enviar a webhook
|
||||
// 7. Enviar a webhook
|
||||
$result = $this->sendToWebhook($webhookUrl, $webhookMethod, $payload);
|
||||
|
||||
if ($result['success']) {
|
||||
@@ -300,4 +311,31 @@ final class ContactFormAjaxHandler
|
||||
{
|
||||
return $value === true || $value === '1' || $value === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validar reCAPTCHA token
|
||||
*
|
||||
* @return bool True si pasa la validacion o si reCAPTCHA no esta habilitado
|
||||
*/
|
||||
private function validateRecaptcha(): bool
|
||||
{
|
||||
// Si el servicio no esta inyectado, permitir
|
||||
if ($this->recaptchaService === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Si reCAPTCHA no esta habilitado, permitir
|
||||
if (!$this->recaptchaService->isRecaptchaEnabled()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Obtener token del POST
|
||||
$token = sanitize_text_field($_POST['recaptcha_token'] ?? '');
|
||||
|
||||
// Obtener accion configurada para contacto
|
||||
$action = $this->recaptchaService->getContactAction();
|
||||
|
||||
// Validar con el servicio
|
||||
return $this->recaptchaService->validateSubmission($token, $action);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use ROITheme\Shared\Domain\Contracts\RendererInterface;
|
||||
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
|
||||
use ROITheme\Shared\Domain\Entities\Component;
|
||||
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
|
||||
use ROITheme\Shared\Application\Services\RecaptchaValidationService;
|
||||
|
||||
/**
|
||||
* ContactFormRenderer - Renderiza formulario de contacto con webhook
|
||||
@@ -18,6 +19,7 @@ use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
|
||||
* - Envio a webhook configurable (no expuesto en frontend)
|
||||
* - Info de contacto configurable
|
||||
* - Mensajes de exito/error personalizables
|
||||
* - reCAPTCHA v3 para proteccion anti-spam
|
||||
*
|
||||
* @package ROITheme\Public\ContactForm\Infrastructure\Ui
|
||||
*/
|
||||
@@ -26,7 +28,8 @@ final class ContactFormRenderer implements RendererInterface
|
||||
private const COMPONENT_NAME = 'contact-form';
|
||||
|
||||
public function __construct(
|
||||
private CSSGeneratorInterface $cssGenerator
|
||||
private CSSGeneratorInterface $cssGenerator,
|
||||
private ?RecaptchaValidationService $recaptchaService = null
|
||||
) {}
|
||||
|
||||
public function render(Component $component): string
|
||||
@@ -49,8 +52,9 @@ final class ContactFormRenderer implements RendererInterface
|
||||
$css = $this->generateCSS($data);
|
||||
$html = $this->buildHTML($data, $visibilityClass);
|
||||
$js = $this->buildJS($data);
|
||||
$recaptchaScript = $this->generateRecaptchaScript();
|
||||
|
||||
return sprintf("<style>%s</style>\n%s\n<script>%s</script>", $css, $html, $js);
|
||||
return sprintf("<style>%s</style>\n%s\n<script>%s</script>\n%s", $css, $html, $js, $recaptchaScript);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -276,6 +280,7 @@ final class ContactFormRenderer implements RendererInterface
|
||||
// Right column - Form
|
||||
$html .= '<div class="col-lg-7">';
|
||||
$html .= sprintf('<form id="roiContactForm" data-nonce="%s">', esc_attr($nonce));
|
||||
$html .= '<input type="hidden" name="recaptcha_token" id="contact-recaptcha-token" value="">';
|
||||
$html .= '<div class="row g-3">';
|
||||
|
||||
// Full name field
|
||||
@@ -400,17 +405,27 @@ final class ContactFormRenderer implements RendererInterface
|
||||
// AJAX URL for WordPress
|
||||
$ajaxUrl = admin_url('admin-ajax.php');
|
||||
|
||||
// Obtener configuracion reCAPTCHA
|
||||
$recaptchaEnabled = $this->isRecaptchaEnabled();
|
||||
$recaptchaSiteKey = $recaptchaEnabled ? esc_js($this->recaptchaService->getSiteKey()) : '';
|
||||
$recaptchaAction = $recaptchaEnabled ? esc_js($this->recaptchaService->getContactAction()) : '';
|
||||
|
||||
$js = <<<JS
|
||||
(function() {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('roiContactForm');
|
||||
if (!form) return;
|
||||
|
||||
const recaptchaEnabled = {$this->boolToJs($recaptchaEnabled)};
|
||||
const recaptchaSiteKey = '{$recaptchaSiteKey}';
|
||||
const recaptchaAction = '{$recaptchaAction}';
|
||||
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const submitBtn = form.querySelector('button[type="submit"]');
|
||||
const messageDiv = document.getElementById('roiContactFormMessage');
|
||||
const tokenInput = document.getElementById('contact-recaptcha-token');
|
||||
const originalBtnHtml = submitBtn.innerHTML;
|
||||
const nonce = form.dataset.nonce;
|
||||
|
||||
@@ -419,14 +434,25 @@ final class ContactFormRenderer implements RendererInterface
|
||||
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>' + '{$sendingMessage}';
|
||||
messageDiv.style.display = 'none';
|
||||
|
||||
// Collect form data
|
||||
const formData = new FormData(form);
|
||||
formData.append('action', 'roi_contact_form_submit');
|
||||
formData.append('nonce', nonce);
|
||||
formData.append('pageUrl', window.location.href);
|
||||
formData.append('pageTitle', document.title);
|
||||
|
||||
try {
|
||||
// Ejecutar reCAPTCHA si esta habilitado
|
||||
if (recaptchaEnabled && typeof grecaptcha !== 'undefined' && recaptchaSiteKey) {
|
||||
try {
|
||||
const token = await grecaptcha.execute(recaptchaSiteKey, { action: recaptchaAction });
|
||||
tokenInput.value = token;
|
||||
} catch (recaptchaError) {
|
||||
console.warn('reCAPTCHA error:', recaptchaError);
|
||||
// Continuar sin token (fail-open en backend)
|
||||
}
|
||||
}
|
||||
|
||||
// Collect form data
|
||||
const formData = new FormData(form);
|
||||
formData.append('action', 'roi_contact_form_submit');
|
||||
formData.append('nonce', nonce);
|
||||
formData.append('pageUrl', window.location.href);
|
||||
formData.append('pageTitle', document.title);
|
||||
|
||||
const response = await fetch('{$ajaxUrl}', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
@@ -600,6 +626,7 @@ JS;
|
||||
// Modal Body
|
||||
$html .= '<div class="modal-body">';
|
||||
$html .= sprintf('<form id="roiContactModalForm" data-nonce="%s">', esc_attr($nonce));
|
||||
$html .= '<input type="hidden" name="recaptcha_token" id="modal-contact-recaptcha-token" value="">';
|
||||
$html .= '<div class="row g-3">';
|
||||
|
||||
// Full name field
|
||||
@@ -680,17 +707,27 @@ JS;
|
||||
// AJAX URL for WordPress
|
||||
$ajaxUrl = admin_url('admin-ajax.php');
|
||||
|
||||
// Obtener configuracion reCAPTCHA
|
||||
$recaptchaEnabled = $this->isRecaptchaEnabled();
|
||||
$recaptchaSiteKey = $recaptchaEnabled ? esc_js($this->recaptchaService->getSiteKey()) : '';
|
||||
$recaptchaAction = $recaptchaEnabled ? esc_js($this->recaptchaService->getContactAction()) : '';
|
||||
|
||||
$js = <<<JS
|
||||
(function() {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('roiContactModalForm');
|
||||
if (!form) return;
|
||||
|
||||
const recaptchaEnabled = {$this->boolToJs($recaptchaEnabled)};
|
||||
const recaptchaSiteKey = '{$recaptchaSiteKey}';
|
||||
const recaptchaAction = '{$recaptchaAction}';
|
||||
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const submitBtn = form.querySelector('button[type="submit"]');
|
||||
const messageDiv = document.getElementById('roiContactModalMessage');
|
||||
const tokenInput = document.getElementById('modal-contact-recaptcha-token');
|
||||
const originalBtnHtml = submitBtn.innerHTML;
|
||||
const nonce = form.dataset.nonce;
|
||||
|
||||
@@ -699,14 +736,25 @@ JS;
|
||||
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>' + '{$sendingMessage}';
|
||||
messageDiv.style.display = 'none';
|
||||
|
||||
// Collect form data
|
||||
const formData = new FormData(form);
|
||||
formData.append('action', 'roi_contact_form_submit');
|
||||
formData.append('nonce', nonce);
|
||||
formData.append('pageUrl', window.location.href);
|
||||
formData.append('pageTitle', document.title);
|
||||
|
||||
try {
|
||||
// Ejecutar reCAPTCHA si esta habilitado
|
||||
if (recaptchaEnabled && typeof grecaptcha !== 'undefined' && recaptchaSiteKey) {
|
||||
try {
|
||||
const token = await grecaptcha.execute(recaptchaSiteKey, { action: recaptchaAction });
|
||||
tokenInput.value = token;
|
||||
} catch (recaptchaError) {
|
||||
console.warn('reCAPTCHA error:', recaptchaError);
|
||||
// Continuar sin token (fail-open en backend)
|
||||
}
|
||||
}
|
||||
|
||||
// Collect form data
|
||||
const formData = new FormData(form);
|
||||
formData.append('action', 'roi_contact_form_submit');
|
||||
formData.append('nonce', nonce);
|
||||
formData.append('pageUrl', window.location.href);
|
||||
formData.append('pageTitle', document.title);
|
||||
|
||||
const response = await fetch('{$ajaxUrl}', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
@@ -761,4 +809,38 @@ JS;
|
||||
|
||||
return $js;
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera el script de Google reCAPTCHA v3
|
||||
*/
|
||||
private function generateRecaptchaScript(): string
|
||||
{
|
||||
if (!$this->isRecaptchaEnabled()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$siteKey = $this->recaptchaService->getSiteKey();
|
||||
if (empty($siteKey)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$siteKey = esc_attr($siteKey);
|
||||
return '<script src="https://www.google.com/recaptcha/api.js?render=' . $siteKey . '" async defer></script>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si reCAPTCHA esta habilitado
|
||||
*/
|
||||
private function isRecaptchaEnabled(): bool
|
||||
{
|
||||
return $this->recaptchaService !== null && $this->recaptchaService->isRecaptchaEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convierte bool a string JavaScript
|
||||
*/
|
||||
private function boolToJs(bool $value): string
|
||||
{
|
||||
return $value ? 'true' : 'false';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user