From d52dd09d43777283b1db19c71130a8ca02da46c9 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Fri, 7 Aug 2026 18:07:26 -0400 Subject: [PATCH] Fix reCAPTCHA v3 on AJAX forms + add it to signup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v3 helper's submit hook ran in the capture phase and only preventDefault()'d — it didn't stop the form's own AJAX handler, which fired instantly with an empty token, and it never re-armed, so retries reused a spent token. That broke login/forgot/reset/otp the moment reCAPTCHA was enabled ("your security token expired"). Add stopImmediatePropagation (hold the AJAX handler until the async token exists) + re-arm per submit. One fix repairs every form-submit AJAX form at once. Signup (a public, account-creating form) had NO reCAPTCHA. It's a button-click AJAX submit, so it mints the v3 token before the /api call (like the shop subscribe form) and validates server-side with Tiger_Validate_Recaptcha. Renders the badge via $this->formRecaptcha. Coverage after this: login/forgot/reset/otp (helper) + signup here + shop subscribe. No other guest-facing submit forms exist. No-op when reCAPTCHA is disabled. Co-Authored-By: Claude Opus 4.8 (1M context) --- library/Tiger/View/Helper/FormRecaptcha.php | 7 ++++++- modules/signup/languages/en/signup.php | 1 + modules/signup/services/Signup.php | 6 ++++++ .../signup/views/scripts/index/index.phtml | 20 +++++++++++++++++-- 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/library/Tiger/View/Helper/FormRecaptcha.php b/library/Tiger/View/Helper/FormRecaptcha.php index 840f2bb..6fc1ea2 100644 --- a/library/Tiger/View/Helper/FormRecaptcha.php +++ b/library/Tiger/View/Helper/FormRecaptcha.php @@ -72,7 +72,12 @@ protected function _v3($site, $siteEsc, array $attribs) . 'document.querySelectorAll("input.g-recaptcha-response").forEach(function(inp){' . 'var form=inp.form; if(!form||form.__grcBound)return; form.__grcBound=true;' . 'form.addEventListener("submit",function(e){' - . 'if(form.__grcOk)return; e.preventDefault();' + // Token ready (this is the re-submit): re-arm for the NEXT submit so retries mint a fresh, + // unused token — a v3 token is single-use — then let the form's own handler proceed. + . 'if(form.__grcOk){form.__grcOk=false;return;}' + // First submit: HOLD the form's own submit handler (Tiger forms AJAX on submit and would + // otherwise fire with an empty token) until the async token exists, then re-dispatch. + . 'e.preventDefault(); e.stopImmediatePropagation();' . 'grecaptcha.execute(' . json_encode($site) . ',{action:' . json_encode($action) . '}).then(function(t){' . 'inp.value=t; form.__grcOk=true;' . 'if(typeof form.requestSubmit==="function")form.requestSubmit();else form.submit();});' diff --git a/modules/signup/languages/en/signup.php b/modules/signup/languages/en/signup.php index a66558b..fe05709 100644 --- a/modules/signup/languages/en/signup.php +++ b/modules/signup/languages/en/signup.php @@ -7,6 +7,7 @@ */ return [ 'signup.disabled' => 'Public signup is currently turned off.', + 'signup.error.recaptcha' => "Couldn't verify you're human — please try again.", 'signup.check_email' => 'Account created — check your email to verify it, then sign in.', 'signup.verified' => 'Your email is verified and your account is active.', 'signup.invalid_link' => 'This verification link is invalid or has expired.', diff --git a/modules/signup/services/Signup.php b/modules/signup/services/Signup.php index c35cf99..623307d 100644 --- a/modules/signup/services/Signup.php +++ b/modules/signup/services/Signup.php @@ -41,6 +41,12 @@ public function create(array $params): void if (self::isPublicDisabled()) { $this->_error('signup.disabled'); return; } $form = new Signup_Form_Signup(); if (!$form->isValid($params)) { $this->_formErrors($form); return; } + // Bot gate on this PUBLIC, account-creating endpoint — the same core reCAPTCHA validator the auth + // flow uses (Tiger_Validate_Recaptcha). A no-op when reCAPTCHA is disabled (the validator passes), + // so nothing changes on installs without keys; fail_open covers a Google outage. + if (!(new Tiger_Validate_Recaptcha(['action' => 'signup']))->isValid(null, $params)) { + $this->_error('signup.error.recaptcha'); return; + } $v = $form->getValues(); try { diff --git a/modules/signup/views/scripts/index/index.phtml b/modules/signup/views/scripts/index/index.phtml index ed30b76..96c6651 100644 --- a/modules/signup/views/scripts/index/index.phtml +++ b/modules/signup/views/scripts/index/index.phtml @@ -9,6 +9,9 @@ */ $form = $this->form; $el = function ($name) use ($form) { return $form->getElement($name); }; +// v3 site key for the JS to mint a token before this button-click AJAX submit (empty when off / v2). +$rcSite = (class_exists('Tiger_Recaptcha') && Tiger_Recaptcha::isEnabled() && Tiger_Recaptcha::version() === 'v3') + ? (string) Tiger_Recaptcha::siteKey() : ''; ?>
@@ -19,8 +22,10 @@ $el = function ($name) use ($form) { return $form->getElement($name); };
-
+ + + formRecaptcha('g-recaptcha-response', null, ['action' => 'signup']) ?>
@@ -116,11 +121,21 @@ $el = function ($name) use ($form) { return $form->getElement($name); }; document.addEventListener('DOMContentLoaded', function () { var form = document.getElementById('signup-form'); var fb = document.getElementById('signup-feedback'); + var rcSite = form.getAttribute('data-recaptcha-site') || ''; // v3 site key (empty = off/v2 → skip) + function rcToken(action) { + if (!rcSite || !window.grecaptcha) { return Promise.resolve(''); } + return new Promise(function (resolve) { + try { grecaptcha.ready(function () { grecaptcha.execute(rcSite, { action: action }).then(resolve, function () { resolve(''); }); }); } + catch (e) { resolve(''); } + }); + } document.getElementById('signup-submit').addEventListener('click', function () { var btn = this; form.querySelectorAll('.is-invalid').forEach(function (e) { e.classList.remove('is-invalid'); }); - + rcToken('signup').then(function (token) { + var hid = form.querySelector('input.g-recaptcha-response'); + if (hid && token) { hid.value = token; } var fd = new URLSearchParams(new FormData(form)); fd.set('module', 'signup'); fd.set('service', 'signup'); fd.set('method', 'create'); @@ -156,6 +171,7 @@ document.addEventListener('DOMContentLoaded', function () { }).catch(function () { TigerDOM.notify(fb, 'Network error — please try again.', { type: 'error' }); }); + }); }); });