16

I am using Google invisible recaptcha. Is there a way to detect when the challenge window is closed? By challenge window I mean window where you have to pick some images for verification.

I currently put a spinner on the button that rendered the recaptcha challenge, once the button is clicked. There is no way for the user to be prompted with another challenge window.

I am calling render function programmatically:

grecaptcha.render(htmlElement, { callback: this.verified, expiredCallback: this.resetRecaptcha, sitekey: this.siteKey, theme: "light", size: "invisible" });

I have 2 callback functions wired up the verified and the resetRecaptcha functions which look like:

function resetRecaptcha() {
        grecaptcha.reset();
    }

function verified(recaptchaResponse)
{
/*
which calls the server to validate
*/
}

I would have expected that grecaptcha.render has another callback that is called when the challenge screen is closed without the user verifying himself by selecting the images.

Tarek
  • 161
  • 1
  • 1
  • 5
  • 1
    please show us the code you already have – arc Apr 19 '17 at 07:00
  • 1
    I don't think code is needed here @arcs. I have the same question - how to detect that the challange widgets was just closed, without an other actions? – knaos Jul 07 '17 at 11:10
  • 1
    @knaos you were right. I posted the solution below... 2 months late – arc Jul 08 '17 at 09:37

6 Answers6

17

As you mention, the API doesn't support this feature.

However, you can add this feature yourself. You may use the following code with caution, Google might change its reCaptcha and by doing so break this custom code. The solution relies on two characteristics of reCaptcha, so if the code doesn't work, look there first:

  • the window iframe src: contains "google.com/recaptcha/api2/bframe"
  • the CSS opacity property: changed to 0 when the window is closed

 

// to begin: we listen to the click on our submit button
// where the invisible reCaptcha has been attachtted to
// when clicked the first time, we setup the close listener
recaptchaButton.addEventListener('click', function(){
    if(!window.recaptchaCloseListener) initListener()

})

function initListener() {

    // set a global to tell that we are listening
    window.recaptchaCloseListener = true

    // find the open reCaptcha window
    HTMLCollection.prototype.find = Array.prototype.find
    var recaptchaWindow = document
        .getElementsByTagName('iframe')
        .find(x=>x.src.includes('google.com/recaptcha/api2/bframe'))
        .parentNode.parentNode

    // and now we are listening on CSS changes on it
    // when the opacity has been changed to 0 we know that
    // the window has been closed
    new MutationObserver(x => recaptchaWindow.style.opacity == 0 && onClose())
        .observe(recaptchaWindow, { attributes: true, attributeFilter: ['style'] })

}

// now do something with this information
function onClose() {
    console.log('recaptcha window has been closed')
}
arc
  • 4,553
  • 5
  • 34
  • 43
3

The drawback of detecting when iframe was hidden is that it fires not only when user closes captcha by clicking in background, but also when he submits the answer.

What I needed is detect only the first situation (cancel captcha).

I created a dom observer to detect when captcha is attached to DOM, then I disconnect it (because it is no longer needed) and add click handler to its background element.

Keep in mind that this solution is sensitive to any changes in DOM structure, so if google decides to change it for whatever reason, it may break.

Also remember to cleanup the observers/listeners, in my case (react) I do it in cleanup function of useEffect.

    const captchaBackgroundClickHandler = () => {
        ...do whatever you need on captcha cancel
    };

    const domObserver = new MutationObserver(() => {
        const iframe = document.querySelector("iframe[src^=\"https://www.google.com/recaptcha\"][src*=\"bframe\"]");

        if (iframe) {
            domObserver.disconnect();

            captchaBackground = iframe.parentNode?.parentNode?.firstChild;
            captchaBackground?.addEventListener("click", captchaBackgroundClickHandler);
        }
    });

    domObserver.observe(document.documentElement || document.body, { childList: true, subtree: true });
2

As I mentioned in the comments of the answer submitted by @arcs, it is a good solution which works but it also fires onClose() when the user successfully completes the challenge. My solution is to change the onClose() function like so:

// now do something with this information
function onClose() {
    if(!grecaptcha.getResponse()) {
        console.log('recaptcha window has been closed')
    }
}

This way, it only executes the desired code if the challenge has been closed and it has not been completed by the user, thus the response cannot be returned with grecaptcha.getResponse()

Russell Chisholm
  • 645
  • 9
  • 13
1

To work in IE this solution needs polyfills for .include() and Array.from(), found below:

Array.from on the Internet Explorer

ie does not support 'includes' method

And the updated code:

function initListener() {

              // set a global to tell that we are listening
              window.recaptchaCloseListener = true

              // find the open reCaptcha window

                    var frames = Array.from(document.getElementsByTagName('iframe'));
                    var recaptchaWindow;

                    frames.forEach(function(x){

                        if (x.src.includes('google.com/recaptcha/api2/bframe') ){
                            recaptchaWindow = x.parentNode.parentNode;
                        };

                    });

              // and now we are listening on CSS changes on it
              // when the opacity has been changed to 0 we know that
                // the window has been closed

                new MutationObserver(function(){
                    recaptchaWindow.style.opacity == 0 && onClose();
                })
                  .observe(recaptchaWindow, { attributes: true, attributeFilter: ['style'] })

            }
Sean Doherty
  • 2,273
  • 1
  • 13
  • 20
0

My solution:

let removeRecaptchaOverlayEventListener = null
const reassignGRecatchaExecute = () => {
  if (!window.grecaptcha || !window.grecaptcha.execute) {
    return
  }
  /* save original grecaptcha.execute */
  const originalExecute = window.grecaptcha.execute
  window.grecaptcha.execute = (...params) => {
    try {
      /* find challenge iframe */
      const recaptchaIframe = [...document.body.getElementsByTagName('iframe')].find(el => el.src.match('https://www.google.com/recaptcha/api2/bframe'))
      const recaptchaOverlay = recaptchaIframe.parentElement.parentElement.firstElementChild
      /* detect when the recaptcha challenge window is closed and reset captcha */
      !removeRecaptchaOverlayEventListener && recaptchaOverlay.addEventListener('click', window.grecaptcha.reset)
      /* save remove event listener for click event */
      removeRecaptchaOverlayEventListener = () => recaptchaOverlay.removeEventListener('click', window.grecaptcha.reset)
    } catch (error) {
      console.error(error)
    } finally {
      originalExecute(...params)
    }
  }
}

Call this function after you run window.grecaptcha.render() and before window.grecaptcha.execute()

And don't forget to remove event listener: removeRecaptchaOverlayEventListener()

mh77
  • 1
  • 1
0

For everybody that didn't quite get how it all works, here is another example with explanations that you might find useful:

So we have 2 challenges here.

1) Detect when the challenge is shown and get the overlay div of the challenge

function detectWhenReCaptchaChallengeIsShown() {
    return new Promise(function(resolve) {
        const targetElement = document.body;

        const observerConfig = {
            childList: true,
            attributes: false,
            attributeOldValue: false,
            characterData: false,
            characterDataOldValue: false,
            subtree: false
        };

        function DOMChangeCallbackFunction(mutationRecords) {
            mutationRecords.forEach((mutationRecord) => {
                if (mutationRecord.addedNodes.length) {
                    var reCaptchaParentContainer = mutationRecord.addedNodes[0];
                    var reCaptchaIframe = reCaptchaParentContainer.querySelectorAll('iframe[title*="recaptcha"]');

                    if (reCaptchaIframe.length) {
                        var reCaptchaChallengeOverlayDiv = reCaptchaParentContainer.firstChild;
                        if (reCaptchaChallengeOverlayDiv.length) {
                            reCaptchaObserver.disconnect();
                            resolve(reCaptchaChallengeOverlayDiv);
                        }
                    }
                }
            });
        }

        const reCaptchaObserver = new MutationObserver(DOMChangeCallbackFunction);
        reCaptchaObserver.observe(targetElement, observerConfig);
    });
}

First, we created a target element that we would observe for Google iframe appearance. We targeted document.body as an iframe will be appended to it:

const targetElement = document.body;

Then we created a config object for MutationObserver. Here we might specify what exactly we track in DOM changes. Please note that all values are 'false' by default so we could only leave 'childList' - which means that we would observe only the child node changes for the target element - document.body in our case:

const observerConfig = {
    childList: true,
    attributes: false,
    attributeOldValue: false,
    characterData: false,
    characterDataOldValue: false,
    subtree: false
};

Then we created a function that would be invoked when an observer detects a specific type of DOM change that we specified in config object. The first argument represents an array of Mutation Observer objects. We grabbed the overlay div and returned in with Promise.

function DOMChangeCallbackFunction(mutationRecords) {
    mutationRecords.forEach((mutationRecord) => {
        if (mutationRecord.addedNodes.length) { //check only when notes were added to DOM
            var reCaptchaParentContainer = mutationRecord.addedNodes[0];
            var reCaptchaIframe = reCaptchaParentContainer.querySelectorAll('iframe[title*="recaptcha"]');

            if (reCaptchaIframe.length) { // Google reCaptcha iframe was loaded
                var reCaptchaChallengeOverlayDiv = reCaptchaParentContainer.firstChild;
                if (reCaptchaChallengeOverlayDiv.length) {
                    reCaptchaObserver.disconnect(); // We don't want to observe more DOM changes for better performance
                    resolve(reCaptchaChallengeOverlayDiv); // Returning the overlay div to detect close events
                }
            }
        }
    });
}

Lastly we instantiated an observer itself and started observing DOM changes:

const reCaptchaObserver = new MutationObserver(DOMChangeCallbackFunction);
reCaptchaObserver.observe(targetElement, observerConfig);

2) Second challenge is the main question of that post - how do we detect that the challenge is closed? Well, we need help of MutationObserver again.

detectReCaptchaChallengeAppearance().then(function (reCaptchaChallengeOverlayDiv) {
    var reCaptchaChallengeClosureObserver = new MutationObserver(function () {
        if ((reCaptchaChallengeOverlayDiv.style.visibility === 'hidden') && !grecaptcha.getResponse()) {
            // TADA!! Do something here as the challenge was either closed by hitting outside of an overlay div OR by pressing ESC key
            reCaptchaChallengeClosureObserver.disconnect();
        }
    });
    reCaptchaChallengeClosureObserver.observe(reCaptchaChallengeOverlayDiv, {
        attributes: true,
        attributeFilter: ['style']
    });
});

So what we did is we get the Google reCaptcha challenge overlay div with the Promise we created in Step1 and then we subscribed for "style" changes on overlay div. This is because when the challenge is closed - Google fade it out. It's important to note that the visibility will be also hidden when a person solves the captcha successfully. That is why we added !grecaptcha.getResponse() check. It will return nothing unless the challenge is resolved. This is pretty much it - I hope that helps :)

kosmeln
  • 247
  • 2
  • 10
  • The following query selector is prone to fail if reCAPTCHA has been loaded in a language other than English: `reCaptchaParentContainer.querySelectorAll('iframe[title="recaptcha challenge"]');`. For instance, in pt-BR the title is "desafio reCAPTCHA". – caiosm1005 Feb 24 '20 at 15:00
  • Thank you! I've changed it to more bulletproof 'iframe[title*="recaptcha"]' – kosmeln Mar 17 '20 at 13:28