diff --git a/recaptcha_v3.services.yml b/recaptcha_v3.services.yml new file mode 100644 index 0000000..6232832 --- /dev/null +++ b/recaptcha_v3.services.yml @@ -0,0 +1,4 @@ +services: + recaptcha_v3.enterprise: + class: Drupal\recaptcha_v3\ReCaptcha\Enterprise + arguments: ['@http_client', '@request_stack', '@config.factory'] diff --git a/src/ReCaptcha/Enterprise.php b/src/ReCaptcha/Enterprise.php new file mode 100644 index 0000000..cac74fb --- /dev/null +++ b/src/ReCaptcha/Enterprise.php @@ -0,0 +1,124 @@ +httpClient = $http_client; + $this->requestStack = $request_stack; + $this->config = $config_factory->get('recaptcha_v3.settings'); + } + + /** + * Uses reCAPTCHA enterprise API to verify if the user passes CAPTCHA test. + * + * @param string $recaptcha_response + * The user response token provided by reCAPTCHA. + * + * @return \ReCaptcha\Response + * Response from the service. + */ + public function verify($recaptcha_response) { + // Discard empty solution submissions. + if (empty($recaptcha_response)) { + return new Response(FALSE, [ReCaptcha::E_MISSING_INPUT_RESPONSE]); + } + + $project_id = $this->config->get('enterprise_project_id'); + $site_key = $this->config->get('site_key'); + $api_key = $this->config->get('secret_key'); + $url = 'https://recaptchaenterprise.googleapis.com/v1/projects/' . $project_id . '/assessments?key=' . $api_key; + $ip_address = $this->requestStack->getCurrentRequest()->getClientIp(); + $options = [ + 'headers' => [ + 'Content-type' => 'application/json', + ], + 'body' => json_encode([ + 'event' => [ + 'token' => $recaptcha_response, + 'siteKey' => $site_key, + 'userIpAddress' => $ip_address, + ], + ]), + 'http_errors' => FALSE, + ]; + + $enterprise_response = $this->httpClient->post($url, $options); + + if ($enterprise_response->getStatusCode() == 200) { + $enterprise_response_body = Json::decode($enterprise_response->getBody()); + $hostname = $enterprise_response_body['tokenProperties']['hostname'] ?? ''; + $enterprise_response_valid = $enterprise_response_body['tokenProperties']['valid'] ?? FALSE; + \Drupal::logger('recaptcha_v3')->debug('ReCaptcha enterprise response valid: @valid', ['@valid' => $enterprise_response_valid ? 'true' : 'false']); + $score = $enterprise_response_body['riskAnalysis']['score'] ?? NULL; + if ($enterprise_response_valid && $this->isHumanScore($score)) { + return new Response(TRUE, [], $hostname); + } + else { + $error = $enterprise_response_body['tokenProperties']['invalidReason'] ?? 'unknown'; + return new Response(FALSE, [$error], $hostname); + } + } + elseif ($enterprise_response->getStatusCode() < 0) { + // Negative status codes typically point to network or socket issues. + return new Response(FALSE, [ReCaptcha::E_CONNECTION_FAILED]); + } + else { + // Positive none 200 status code typically means the request has failed. + return new Response(FALSE, [ReCaptcha::E_BAD_RESPONSE]); + } + } + + /** + * reCAPTCHA v3 returns a score (1.0 is very likely a good interaction, 0.0 is very likely a bot). + * Based on the score, you can take variable action in the context of your site. + * reCAPTCHA learns by seeing real traffic on your site. + * For this reason, scores in a staging environment or soon after implementing may differ from production. + * By default, you can use a threshold of 0.5. + * https://developers.google.com/recaptcha/docs/v3#interpreting_the_score + */ + private function isHumanScore($score) { + return $score >= ((float) $this->config->get('recaptcha_is_human_score') ?? 0.5); + } +} diff --git a/js/recaptcha_v3.js b/js/recaptcha_v3.js index 5283a67..23ca885 100644 --- a/js/recaptcha_v3.js +++ b/js/recaptcha_v3.js @@ -10,12 +10,12 @@ function doUpdateTokenElement(element) { const $element = $(element); - grecaptcha.ready(() => { + grecaptcha.enterprise.ready(() => { if (!element) { return; } - grecaptcha + grecaptcha.enterprise .execute($element.data('recaptchaV3SiteKey'), { action: $element.data('recaptchaV3Action'), }) @@ -29,9 +29,9 @@ function updateTokenElement(element) { let timer; // Wait for grecaptcha to be loaded. - if (typeof grecaptcha === 'undefined') { + if (typeof grecaptcha === 'undefined' || typeof grecaptcha.enterprise === 'undefined') { timer = setInterval(() => { - if (typeof grecaptcha !== 'undefined' || !element) { + if ((typeof grecaptcha !== 'undefined' && typeof grecaptcha.enterprise !== 'undefined') || !element) { clearInterval(timer); if (element) { @@ -51,7 +51,7 @@ */ Drupal.behaviors.reCaptchaV3 = { attach: (context) => { - once('recaptcha-v3-token', '.recaptcha-v3-token', context).forEach( + once('recaptcha-enterprise-token', '.recaptcha-v3-token', context).forEach( (element) => { updateTokenElement(element); // Update the recaptcha tokens every 90 seconds. diff --git a/recaptcha_v3.info.yml b/recaptcha_v3.info.yml index 37d9f69..77e2182 100644 --- a/recaptcha_v3.info.yml +++ b/recaptcha_v3.info.yml @@ -6,3 +6,8 @@ package: Spam control configure: recaptcha_v3.settings dependencies: - captcha:captcha + +# Information added by Drupal.org packaging script on 2025-02-13 +version: '2.0.4' +project: 'recaptcha_v3' +datestamp: 1739448402 diff --git a/recaptcha_v3.libraries.yml b/recaptcha_v3.libraries.yml index 744aa4c..c3e0043 100644 --- a/recaptcha_v3.libraries.yml +++ b/recaptcha_v3.libraries.yml @@ -6,7 +6,7 @@ google.recaptcha: url: https://github.com/google/recaptcha/blob/master/LICENSE gpl-compatible: true js: - https://www.google.com/recaptcha/api.js: { + https://www.google.com/recaptcha/enterprise.js: { type: external, minified: true, weight: -200, diff --git a/src/Form/ReCaptchaV3SettingsForm.php b/src/Form/ReCaptchaV3SettingsForm.php index 7f52550..1d3eb41 100644 --- a/src/Form/ReCaptchaV3SettingsForm.php +++ b/src/Form/ReCaptchaV3SettingsForm.php @@ -110,6 +110,37 @@ class ReCaptchaV3SettingsForm extends ConfigFormBase { '#description' => $this->t('The secret key given to you when you register for reCAPTCHA.', [':url' => 'https://www.google.com/recaptcha/admin']), '#required' => TRUE, ]; + $form['use_enterprise'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Use reCAPTCHA Enterprise'), + '#default_value' => $config->get('use_enterprise'), + '#description' => $this->t('Enable to use the Enterprise API.'), + ]; + $form['enterprise_project_id'] = [ + '#type' => 'textfield', + '#title' => $this->t('Enterprise project ID'), + '#default_value' => $config->get('enterprise_project_id'), + '#description' => $this->t('Google Cloud project ID for reCAPTCHA Enterprise.'), + '#states' => [ + 'visible' => [ + ':input[name="use_enterprise"]' => ['checked' => TRUE], + ], + ], + ]; + $form['recaptcha_is_human_score'] = [ + '#type' => 'number', + '#title' => $this->t('Bot likelihood score'), + '#default_value' => $config->get('recaptcha_is_human_score'), + '#description' => $this->t('Threshold score for human/bot detection (0.0 - 1.0).'), + '#step' => 0.1, + '#min' => 0, + '#max' => 1, + '#states' => [ + 'visible' => [ + ':input[name="use_enterprise"]' => ['checked' => TRUE], + ], + ], + ]; $form['verify_hostname'] = [ '#type' => 'checkbox', '#title' => $this->t('Local domain name validation'), @@ -187,6 +218,9 @@ class ReCaptchaV3SettingsForm extends ConfigFormBase { $this->config('recaptcha_v3.settings') ->set('site_key', $values['site_key']) ->set('secret_key', $values['secret_key']) + ->set('use_enterprise', $values['use_enterprise']) + ->set('enterprise_project_id', $values['enterprise_project_id']) + ->set('recaptcha_is_human_score', $values['recaptcha_is_human_score']) ->set('hide_badge', $values['hide_badge']) ->set('verify_hostname', $values['verify_hostname']) ->set('default_challenge', $values['default_challenge'])