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.
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.
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.