reCAPTCHA V3: how to deal with expired token after idle?
Asked Answered
L

6

13

For Google reCAPTCHA V2 it was clear what to do when the token gets expired because of idle: the customer has a change to click on the reCaptcha checkbox again. For Google reCAPTCHA V3 it is different as it is unclear when the token gets expired because of idle.

For reCAPTCHA V3 it is suggested by Google:

https://developers.google.com/recaptcha/docs/v3

  1. Load the JavaScript api with your sitekey

  2. Call grecaptcha.execute on an action or when the page loads // we choose when the page loads, OK?

  3. Send the token to your backend with the request to verify // upon button click

OK. If the button was clicked several minutes later than the page was loaded, the V3 token we send to backend is already expired. What's the proper way to deal in this situation? Should we silently auto-update the token by sending calls to Google every minute? What's the best approach for this case? I didn't find any suggestions from Google.

Luna answered 30/1, 2019 at 9:56 Comment(3)
Then I would execute it on button clickChristianize
Did you figure this out? I'm having the same issue with the use case 3. I'm receiving the token at page load but it's expired by the time the form is send to the BE.Partan
@JoãoBelo Yes. The token must be generated later than on page load. It should be generated right before we send it to backend.With another words, we click on the button (from my example), then get the generated token, then send the token to my backend.Luna
N
16

Since reCAPTCHA tokens expire after two minutes, this is how I have put it to work:

Step 1: Load the captcha token on page load (as usual)

Step 2: Use a SetInterval function to reload the token every 90 seconds, so that the reCAPTCHA token is refreshed before it expires after 2 minutes.

// Onload
grecaptcha.ready(function () {
  grecaptcha.execute('YOUR_KEY_HERE', { action: 'request_call_back' }).then(function (e) {
    $('#YOUR_FIELD_NAME_ID').val(e);
  });
});

// Every 90 Seconds
setInterval(function () {
  grecaptcha.ready(function () {
    grecaptcha.execute('YOUR_KEY_HERE', { action: 'request_call_back' }).then(function (e) {
      $('#YOUR_FIELD_NAME_ID').val(e);
    });
  });
}, 90 * 1000);
Niccolite answered 17/9, 2020 at 12:19 Comment(0)
L
11

The token must be generated later than on page load. It should be generated right before we send it to backend. With another words, we click on the button (from my example), then get the generated token, then send the token to backend.

This solution makes sense and fixes my issue.

Luna answered 18/6, 2019 at 11:28 Comment(1)
The solution isn't perfect. Because, when we request the token just before the send. The form needs waiting few seconds to receive token and send with the data.Popularity
T
2

I work in asp.net forms, but this solution can be applicable in any language. The problem of the expired token is annoying.

The token has a validity time of 2 minutes in v3, but the practice of leaving a timer refreshing the token every 2 minutes is not recommended by google. They recommend the token be refreshed only when required.

I opted for a javascript solution, forcing the client to click on a button that refreshes the token.

It should be noted that if "recaptcha.ready" is executed when refreshing the recaptcha, an error is thrown, so I had to separate the "ready" from the "execute" and with this the recaptcha is refreshed without errors.

<script type="text/javascript" >
    grecaptcha.ready(function () {
      captcha_execute();
    });

    function captcha_execute() {
      grecaptcha.execute('<%=System.Configuration.ConfigurationManager.AppSettings("recaptcha-public-key").ToString %>', { action: 'ingreso_usuario_ext' }).then(function (token) {
        document.getElementById("g-recaptcha-response").value = token;
      });
    }

    function los_dos(token_viejo) {
      captcha_execute()
      clase_boton(token_viejo);
    }

    async function clase_boton(token_viejo) {
      btn_act = document.getElementById("Btn_Refrescar");
      btn = document.getElementById("Btn_Ingresar");
      btn.setAttribute("class", "button_gris");
      btn_act.style.display = "none";
      btn.style.display = "initial";
      btn.disabled = true;

      //token_viejo = document.getElementById("g-recaptcha-response").value;
      strToken = token_viejo;
      varCant = 0;

      while (strToken == token_viejo && varCant < 30) {
        strToken = document.getElementById("g-recaptcha-response").value;
        await sleep(100);
        varCant++;
      }

      btn.setAttribute("class", "button_azul");
      btn.disabled = false;

      setTimeout(refrescar_token, 120000);
    }

    function sleep(ms) {
      return new Promise(resolve => setTimeout(resolve, ms));
    }

    function refrescar_token() {
      btn_ing = document.getElementById("Btn_Ingresar");
      btn_act = document.getElementById("Btn_Refrescar");
      btn_act.style.display = "initial";
      btn_ing.style.display = "none";
    }
  </script>

In the body

<body style="background-color: #dededc;" onload="clase_boton('');" >

Buttons

 <asp:Button ID="Btn_Ingresar" runat="server" Text="Ingresar" CssClass="button_gris" Enabled="false" />
 <input type="button" id="Btn_Refrescar" name="Btn_Refrescar" class="button_verde" value="Refrescar Token" title="Refrescar Token" onclick="los_dos(document.getElementById('g-recaptcha-response').value);" style="display: none;" />
          

With javascript, I wait for the token to be populated and when it is populated, I enable the login button. If the process takes too long (due to some error), I still enable it. This is a matter of choice.

After 2 minutes ("setTimeout"), the login button becomes invisible and I show the button to refresh the token.

I hope this helps/guides you solve your problem.

Thrombus answered 27/8, 2020 at 15:56 Comment(0)
D
1
  1. Generating a new token every 2 minutes is a quick fix. Keep in mind this adds more requests than necessary. For a highly visited form I cannot recommend this approach.

  2. Generating a token on page load is only valid for 2 minutes. This seems only applicable for temporary content. I can think of a login code or demo material.

  3. The idea of generating the token on user action is a good idea. This seems like the only valid way to handle forms.


For my case I encountered a couple of challenges that make most solutions provided obsolete.

  • How to handle multiple forms on a single page?
    • How to retreive the script only once?
    • How to bind events dynamically for each form?
  • What exactly are the differences between V2 and V3 and can we write this in a single script?
  • How to handle validators and submit events?
    • How to handle ajax unobtrusive validator specifically?
    • How to wait for the token before submitting

Given these challenges I've tried hard to avoid jQuery. Unfortunately using jQuery saved me some time in dealing with other challenges along the road. This dependency, in my case, is already loaded.


HTML (cshtml Razor syntax)

@using System.Web.Mvc.Html
@using Feature.FormsExtensions.Views
@using Sitecore.Mvc
@model Feature.FormsExtensions.Fields.ReCaptcha.ReCaptchaModel
@{
    var fieldId = Html.IdFor(m => Model.CaptchaValue);
}

<div class="form-group @Model.CssClassSettings.CssClass">
    <input id="@fieldId" name="@Html.NameFor(m => Model.CaptchaValue)" type="hidden" class="fxt-captcha" />
    @Html.ValidationMessageFor(m => Model.CaptchaValue)

    @if (Html.Sitecore().IsExperienceFormsEditMode())
    {
        <img width="300" alt="recaptcha" src="data:image/png;base64,..." />
    }
    else
    {
        <div data-module="recaptcha" data-sitekey="@Model.CaptchaPublicKeyV3" data-enterprise-mode="true" data-field-id="@fieldId"></div>
    }
</div>

The HTML is part of a Sitecore integration based on this github repo. The repo shows a clear understanding between the V2 and Enterprise (V3) differences but I wasn't happy with the inline scripting nor does it solve multiple forms. Hence I refactored the logic to a modular approach.

The data-attributes allow for an easy switch between V2 and V3 and it keeps things clean and simple to understand.


JavaScript

window.Namespace = (function ($, ns) {

    'use strict';

    const cfg = {
        cache: {
            container: '[data-module="recaptcha"]',
            form: 'form',
            typeSubmit: '[type="submit"]'
        },
        events: {
            click: 'click',
            load: 'DOMContentLoaded',
            submit: 'submit'
        },
        options: {
            callbackPrefix: 'handleRecaptcha_',
            ajax: {
                standard: {
                    url: 'https://www.google.com/recaptcha/api.js'
                },
                enterprise: {
                    url: 'https://www.google.com/recaptcha/enterprise.js'
                }
            }
        }
    };

    ns.ReCaptcha = {
        init: function () {
            this.cacheItems();

            if (this.containers.length) {
                if (window.grecaptcha) {
                    Array.from(this.containers).map((container) => {
                        this.activate(container);
                    });
                } else {
                    this.getScript(this.containers[0], this.init.bind(this));
                }
            }
        },

        cacheItems: function () {
            this.containers = document.querySelectorAll(cfg.cache.container);
        },

        activate: function (container) {
            this.globalSetup(container);
            this.globalFunctions(container);
            this.execute();
        },

        getScript: function (container, callback) {
            const ajaxOptions = this.getAjaxOptions(container);

            $.getScript(ajaxOptions).done((data, textStatus, xhr) => {
                console.log(textStatus);
                callback();
            }).fail((xhr, textStatus, errorThrown) => {
                console.warn(textStatus, errorThrown);
            });
        },

        getAjaxOptions: function (container) {
            const { options } = cfg;
            const { sitekey, enterpriseMode } = container.dataset;
            const isEnterpriseMode = this.convertToBoolean(enterpriseMode);
            const ajaxType = isEnterpriseMode ? 'enterprise' : 'standard';
            const ajaxData = isEnterpriseMode ? {
                data: {
                    render: sitekey,
                    hl: document.documentElement.lang
                }
            } : {
                data: {
                    sitekey: sitekey,
                    hl: document.documentElement.lang
                }
            };

            return {...options.ajax[ajaxType], ...ajaxData};
        },

        convertToBoolean: function (str) {
            return str.toLowerCase() === 'true';
        },

        globalSetup: function (container) {
            const { fieldId, sitekey } = container.dataset;

            window.NsReCaptcha = window.NsReCaptcha || {};
            window.NsReCaptcha.fields = window.NsReCaptcha.fields || [];
            window.NsReCaptcha.fields.push(document.getElementById(fieldId));
        },

        globalFunctions: function (container) {
            const { cache, events, options } = cfg;
            const { fieldId, sitekey, enterpriseMode } = container.dataset;
            const shortId = fieldId.substr(0, 12);

            window.NsReCaptcha[options.callbackPrefix + shortId] = ((event) => {
                const isClick = event.type === events.click && !event.isTrigger;
                const field = document.getElementById(fieldId);
                const form = field.closest(cache.form);
                const grecaptcha = this.convertToBoolean(enterpriseMode) ? window.grecaptcha.enterprise : window.grecaptcha;

                // Check wether we have an orignal click
                if (isClick) {
                    // Blocks ajax handler
                    this.blockUnobtrusiveValidation(event);

                    // Exec recaptcha
                    grecaptcha.ready(function () {
                        grecaptcha.execute(sitekey, { action: event.type }).then(function (token) {
                            field.value = token;
                            // updates the error validation message
                            $(field).valid();
                            console.log('ReCaptcha updated');

                            // Re-enables ajax handler
                            this.enableUnobtrusiveValidation(event);
                        });
                    });
                }
            })

            window.NsReCaptcha.bindEvents = window.NsReCaptcha.bindEvents || (() => {
                window.NsReCaptcha.InstanceCount = window.NsReCaptcha.InstanceCount || 1;

                // make sure we have all iterations before mapping
                if (window.NsReCaptcha.InstanceCount === this.containers.length) {
                    // map all recaptcha fields
                    window.NsReCaptcha.fields.map((fld) => {
                        const form = fld.closest(cache.form);
                        const formWrapper = $(form).parent();
                        const callback = options.callbackPrefix + shortId;

                        //window.NsReCaptcha[callback]({ type: events.load });
                        formWrapper.on(events.click, cache.typeSubmit, window.NsReCaptcha[callback].bind(this));
                    });
                } else {
                    window.NsReCaptcha.InstanceCount++;
                }
            });
        },

        /**
         * Blocks the submit event by canceling the unobtrusive validation
         * @param {Event} ev - click event
         */
        blockUnobtrusiveValidation: function (ev) {
            const { cache, classes } = cfg;
            const form = ev.currentTarget.closest(cache.form);

            ev.currentTarget.type = 'button';
            ev.currentTarget.classList.add(classes.cancel);
            form.dataset.unobtrusiveAjaxClickTarget = $(ev.currentTarget);
        },

        /**
         * Re-enables the submit event by sending a new click event
         * @param {Event} ev - click event
         */
        enableUnobtrusiveValidation: function (ev) {
            const { cache, classes, events } = cfg;
            const form = ev.currentTarget.closest(cache.form);

            ev.currentTarget.type = 'submit';
            form.dataset.unobtrusiveAjaxClickTarget = $();
            ev.currentTarget.classList.remove(classes.cancel);
            $(ev.currentTarget).trigger(events.click);
        },

        execute: function () {
            window.NsReCaptcha.bindEvents();
        }

    };

    return ns;

}(window.jQuery, window.Namespace || {}));

First we need to determine how many instances (containers) we're dealing with. We also require the recaptcha script by lazy loading only once.

We continue by setting up window objects (memory), dynamic events and an initialiser to kick things off.

For each instance we generate a unique window.NsReCaptcha.handleRecaptcha_{uniqueId} callback function. For all instances we create a single window.NsReCaptcha.bindEvents function that iterates over the hidden field of each form and binds the submit button to the proper callback function.

When a user clicks the submit button, we make sure the submit button is of a normal type button. This avoids an issue "Form submission canceled because the form is not connected". We disable the ajax unobtrusive validation by adding a CSS class cancel to the button. We then set a data-attribute unobtrusiveAjaxClickTarget on the form with that button (jQuery object). When the submit event is triggered by unobtrusive validation we tricked it into canceling the submit event. This gives us time to execute grecaptcha and request a token. Simply undo our trickery and trigger another click event. This time the click event has different attributes event.isTrigger = true and we can simply enter the area of unobtrusive validation.

Dibbell answered 2/7 at 21:49 Comment(0)
D
0

I had similar issue also. We just need to execute recaptcha before submitting form. For that, some tricky needed. Please refer my code.

let recaptcha_token=null;
$('form').submit(function (e){
        var that=this;
        if(!recaptcha_token){
            e.preventDefault();
            letr recaptcha_site_key='<%=recaptcha_site_key%>'
            grecaptcha.execute(recaptcha_site_key, {action:'verify'})
                .then(function(token) {
                    console.log(token);
                    $('.recaptcha_token').val(token);
                    recaptcha_token=token;
                    that.submit();
                });
        }else
            return true;
    });

So, when page loaded, recaptcha_token is null. When we submit form, because recaptcha_token is null, form submit event will be prevented. And after executing recaptcha, we get recaptcha_token and submit form again, then it will be submitted.

Doug answered 8/9, 2022 at 2:38 Comment(0)
D
0

$(function(){
  grecaptcha.ready(function() {
    $('form input[type="submit"],form   button[type="submit"]').click(function(e){
         var btn=this;
     if(!$(btn).data('isCapchaValidated')){

      e.preventDefault();
     // {action: 'recaptchaV3'} not required
grecaptcha.execute('YOUR_KEY', {action: 'recaptchaV3'}).then(function(token) {
                $('#g-recaptcha-response').value(token);
                $(btn).data('isCapchaValidated',true)
                
                $(btn).click()
            });
     }
    })
  })
})
<form name="recaptchaV3">
<input name="email" value="">
<input type="submit" value="submit">
<input type='hidden' id="g-recaptcha-response" name='g-recaptcha-response' value="">
</form>

<script src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
<script src="https://www.google.com/recaptcha/api.js?render=YOUR_KEY"></script>
Despoil answered 22/5 at 13:18 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.