252 lines
9.1 KiB
PHP
252 lines
9.1 KiB
PHP
<?php
|
|
namespace Grav\Plugin\Form\Captcha;
|
|
|
|
use Grav\Common\Grav;
|
|
use Grav\Common\Uri;
|
|
|
|
/**
|
|
* Google reCAPTCHA provider implementation
|
|
*/
|
|
class ReCaptchaProvider implements CaptchaProviderInterface
|
|
{
|
|
/** @var array */
|
|
protected $config;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->config = Grav::instance()['config']->get('plugins.form.recaptcha', []);
|
|
}
|
|
|
|
/**
|
|
* {@inheritdoc}
|
|
*/
|
|
public function validate(array $form, array $params = []): array
|
|
{
|
|
$grav = Grav::instance();
|
|
$uri = $grav['uri'];
|
|
$ip = Uri::ip();
|
|
$hostname = $uri->host();
|
|
|
|
try {
|
|
$secretKey = $params['recaptcha_secret'] ?? $params['recatpcha_secret'] ??
|
|
$this->config['secret_key'] ?? null;
|
|
|
|
$defaultVersion = $this->normalizeVersion($this->config['version'] ?? '2-checkbox');
|
|
$version = $this->normalizeVersion($params['recaptcha_version'] ?? $defaultVersion);
|
|
|
|
$payloadVersion = $this->detectVersionFromPayload($form);
|
|
if ($payloadVersion !== null) {
|
|
$version = $payloadVersion;
|
|
}
|
|
|
|
if (!$secretKey) {
|
|
throw new \RuntimeException("reCAPTCHA secret key not configured.");
|
|
}
|
|
|
|
$requestMethod = extension_loaded('curl') ? new \ReCaptcha\RequestMethod\CurlPost() : null;
|
|
$recaptcha = new \ReCaptcha\ReCaptcha($secretKey, $requestMethod);
|
|
|
|
// Handle V3
|
|
if ($version === '3') {
|
|
// For V3, look for token in both top level and data[] structure
|
|
$token = $form['token'] ?? ($form['data']['token'] ?? null);
|
|
$action = $form['action'] ?? ($form['data']['action'] ?? null);
|
|
|
|
if (!$token) {
|
|
$grav['log']->debug('reCAPTCHA validation failed: token missing for v3');
|
|
return [
|
|
'success' => false,
|
|
'error' => 'missing-input-response',
|
|
'details' => ['error' => 'missing-input-response', 'version' => 'v3']
|
|
];
|
|
}
|
|
|
|
$recaptcha->setExpectedHostname($hostname);
|
|
|
|
// Set action if provided
|
|
if ($action) {
|
|
$recaptcha->setExpectedAction($action);
|
|
}
|
|
|
|
// Set score threshold
|
|
$recaptcha->setScoreThreshold($this->config['score_threshold'] ?? 0.5);
|
|
}
|
|
// Handle V2 (both checkbox and invisible)
|
|
else {
|
|
// For V2, look for standard response parameter
|
|
$token = $form['g-recaptcha-response'] ?? ($form['data']['g-recaptcha-response'] ?? null);
|
|
if (!$token) {
|
|
$post = $grav['uri']->post();
|
|
if (is_array($post)) {
|
|
if (isset($post['g-recaptcha-response'])) {
|
|
$token = $post['g-recaptcha-response'];
|
|
} elseif (isset($post['g_recaptcha_response'])) {
|
|
$token = $post['g_recaptcha_response'];
|
|
} elseif (isset($post['data']) && is_array($post['data'])) {
|
|
if (isset($post['data']['g-recaptcha-response'])) {
|
|
$token = $post['data']['g-recaptcha-response'];
|
|
} elseif (isset($post['data']['g_recaptcha_response'])) {
|
|
$token = $post['data']['g_recaptcha_response'];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!$token) {
|
|
$grav['log']->debug('reCAPTCHA validation failed: g-recaptcha-response missing for v2');
|
|
return [
|
|
'success' => false,
|
|
'error' => 'missing-input-response',
|
|
'details' => ['error' => 'missing-input-response', 'version' => 'v2']
|
|
];
|
|
}
|
|
|
|
$recaptcha->setExpectedHostname($hostname);
|
|
}
|
|
|
|
// Log validation attempt
|
|
$grav['log']->debug('reCAPTCHA validation attempt for version ' . $version);
|
|
|
|
$validationResponseObject = $recaptcha->verify($token, $ip);
|
|
$isValid = $validationResponseObject->isSuccess();
|
|
|
|
if (!$isValid) {
|
|
$errorCodes = $validationResponseObject->getErrorCodes();
|
|
$grav['log']->debug('reCAPTCHA validation failed: ' . json_encode($errorCodes));
|
|
|
|
return [
|
|
'success' => false,
|
|
'error' => 'validation-failed',
|
|
'details' => ['error-codes' => $errorCodes, 'version' => $version]
|
|
];
|
|
}
|
|
|
|
// For V3, check if score is available and log it (helpful for debugging/tuning)
|
|
if ($version === '3' && method_exists($validationResponseObject, 'getScore')) {
|
|
$score = $validationResponseObject->getScore();
|
|
$grav['log']->debug('reCAPTCHA v3 validation successful with score: ' . $score);
|
|
} else {
|
|
$grav['log']->debug('reCAPTCHA validation successful');
|
|
}
|
|
|
|
return [
|
|
'success' => true
|
|
];
|
|
} catch (\Exception $e) {
|
|
$grav['log']->error('reCAPTCHA validation error: ' . $e->getMessage());
|
|
|
|
return [
|
|
'success' => false,
|
|
'error' => $e->getMessage(),
|
|
'details' => ['exception' => get_class($e)]
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Normalize version values to the internal format we use elsewhere.
|
|
*/
|
|
protected function normalizeVersion($version): string
|
|
{
|
|
if ($version === null || $version === '') {
|
|
return '2-checkbox';
|
|
}
|
|
|
|
if ($version === 3 || $version === '3') {
|
|
return '3';
|
|
}
|
|
|
|
if ($version === 2 || $version === '2') {
|
|
return '2-checkbox';
|
|
}
|
|
|
|
return (string) $version;
|
|
}
|
|
|
|
/**
|
|
* Infer the recaptcha version from the submitted payload when possible.
|
|
*/
|
|
protected function detectVersionFromPayload(array $form): ?string
|
|
{
|
|
$formData = isset($form['data']) && is_array($form['data']) ? $form['data'] : [];
|
|
|
|
$grav = Grav::instance();
|
|
$config = $grav['config'];
|
|
if ($config->get('plugins.form.debug')) {
|
|
try {
|
|
$grav['log']->debug('reCAPTCHA payload inspection', [
|
|
'top_keys' => array_keys($form),
|
|
'data_keys' => array_keys($formData),
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
// Ignore logging issues, detection should continue.
|
|
}
|
|
}
|
|
|
|
if (array_key_exists('token', $form) || array_key_exists('token', $formData)) {
|
|
return '3';
|
|
}
|
|
|
|
if (array_key_exists('g-recaptcha-response', $form) || array_key_exists('g-recaptcha-response', $formData)) {
|
|
return '2-checkbox';
|
|
}
|
|
|
|
if (array_key_exists('g_recaptcha_response', $form) || array_key_exists('g_recaptcha_response', $formData)) {
|
|
// Support alternative key naming just in case
|
|
return '2-checkbox';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* {@inheritdoc}
|
|
*/
|
|
public function getClientProperties(string $formId, array $field): array
|
|
{
|
|
$siteKey = $field['recaptcha_site_key'] ?? $this->config['site_key'] ?? null;
|
|
$theme = $field['recaptcha_theme'] ?? $this->config['theme'] ?? 'light';
|
|
$version = $this->normalizeVersion($field['recaptcha_version'] ?? $this->config['version'] ?? '2-checkbox');
|
|
|
|
// Determine which version we're using
|
|
$isV3 = $version === '3';
|
|
$isInvisible = $version === '2-invisible';
|
|
|
|
// Log the configuration to help with debugging
|
|
$grav = Grav::instance();
|
|
$grav['log']->debug("reCAPTCHA config for form {$formId}: version={$version}, siteKey=" .
|
|
(empty($siteKey) ? 'MISSING' : 'configured'));
|
|
|
|
return [
|
|
'provider' => 'recaptcha',
|
|
'siteKey' => $siteKey,
|
|
'theme' => $theme,
|
|
'version' => $version,
|
|
'isV3' => $isV3,
|
|
'isInvisible' => $isInvisible,
|
|
'containerId' => "g-recaptcha-{$formId}",
|
|
'scriptUrl' => "https://www.google.com/recaptcha/api.js" . ($isV3 ? '?render=' . $siteKey : ''),
|
|
'initFunctionName' => "initRecaptcha_{$formId}"
|
|
];
|
|
}
|
|
|
|
/**
|
|
* {@inheritdoc}
|
|
*/
|
|
public function getTemplateName(): string
|
|
{
|
|
// Different templates based on version
|
|
$version = $this->normalizeVersion($this->config['version'] ?? '2-checkbox');
|
|
|
|
$isV3 = $version === '3';
|
|
$isInvisible = $version === '2-invisible';
|
|
|
|
if ($isV3) {
|
|
return 'forms/fields/recaptcha/recaptchav3.html.twig';
|
|
} elseif ($isInvisible) {
|
|
return 'forms/fields/recaptcha/recaptcha-invisible.html.twig';
|
|
}
|
|
|
|
return 'forms/fields/recaptcha/recaptcha.html.twig';
|
|
}
|
|
}
|