- 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>
250 lines
7.4 KiB
PHP
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';
|
|
}
|
|
}
|