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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user