Files
roi-theme/Shared/Application/Services/RecaptchaValidationService.php
FrankZamora d135ec8a41 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>
2026-01-08 17:01:46 -06:00

250 lines
7.4 KiB
PHP

<?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';
}
}