Google
Enterprise reCaptcha

Mar 08

Introduction

I have been using version two of Google Recaptcha for a while and this month I thought I should update that to version 3. Little did I know that version 3 was now also yesterday's tech and today we have Google Recaptcha Enterprise. Is it any better? That I don't know but I do have the starting of an implementation of it to use in Sitefinity.

I never really get on with Google's stuff. I always find myself jumping around console and admin screens trying to find the 'key' or 'id' I want along with enabling and configuring switches and trying to work out if I am reading the 'relevant' online material. (This is just me, to be honest.) But I will look to guide you right as far as I understand it all.

Though this works there is still alot of things to be tested and improved on I think. There are a few scenerios that I haven't looked at testing around and I have no idea how to test an actual 'bot' and failure. So when looking at this article don't take it as a 'enterprise' ready solution.

Google Stuff

A quick overview of how it works. The Google scripts you add to your site gather and send telemetry back to google and their analysis to determine if it thinks it is a human or a bot. It places a 'token' on your webpage which you submit to your web server and from your web server you push it to Google who returns a 'scoring'. 1.0 definitely a human. 0.0 Definitely a bot. Anything in the middle means a maybe.
Note: The result sent back is always in the middle.

I don't know what this telemetry is or if it works better if you allow it to 'analysis' longer or even if you can do that.

You may want to read about how it works and why it is better. You can find a review of Google Recaptcha Enterprise at ESG and another review by Arkose Labs.

I don't know ESG or Arkose and this is the first time I have come across both.

Getting started starts at getting your Google Recaptcha Enterprise service set up and ready to go. I will assume you already have a Google account and some services set up like maps. For reCaptcha Enterprise we need a few things.
A Project ID
Enable the Recaptcha Enterprise Service.
An API Key
A Site Key

I am purposely avoiding giving lots of guideance and screen shots about doing this as it would take up a two part post just on that. If you are familar then it should be quite quick. If you are like me you'll be grumpy finding it all for about an hour.
A note about the API Key. Google will suggest that you restrict this using an HTTP referrer. This request happens server side in this implementation so the users will never see the key and you can leave it unrestricted. Otherwise you will need to add your own refrerer header to the request.

Go to your Google console and for your project find the Project ID on the dashboard.

Go to 'APIs and services' search, find and enable the reCAPTCHA Enterprise API.

Also under 'APIs and services, go to Credentials and create an API Key.

Now go to the reCAPTCHA admin page. (https://console.cloud.google.com/security/recaptcha) Here you need to create a Key and this is known as the Site Key.

The next thing you need from Google is the JavaScript. This is found in their online documentation. There are a number of ways to add reCaptcha to your site. Here I have chosen to use the REST API but you should take the time to read the documentation and see all the options there are.

The part for us is:

<script src="https://www.google.com/recaptcha/enterprise.js?render=site_key"></script>
<script>
    grecaptcha.enterprise.ready(function () {
        grecaptcha.enterprise.execute('site_key', { action: 'homepage' }).then(function (token) {
            // Do something with the token here
        });
    });
</script>

You will see how I use this later on.

Create a New Form Widget

By creating a new form widget means we are able to add this functionality to any Sitefinity form we want. I have done this under the Mvc folder in the Sitefinity WebApp project and started by reading this Sitefinity documentation article first.

I am storing my three key values, Project ID, Site ID and API Key, in a custom configuration setting.

Model Folder

RecaptchaViewModel: I started by copying the Sitefinity TextFieldViewModel and added a new property 'DataSiteKey' that I would need.

public class RecaptchaViewModel
{
    public String DataSiteKey { getset; }
    public Object Value { getset; }
    public String CssClass { getset; }
    public String ValidationAttributes { getinternal set; }
    public ValidatorDefinition ValidatorDefinition { getset; }
    public IMetaField MetaField { getset; }
    public Boolean Hidden { getset; }
}

IRecaptchaModel: Interface class modelled from Sitefinity ITextFieldModel.

public interface IRecaptchaModel : IFormFieldModel 
{
    String DataSiteKey { get; }     Boolean ShouldRenderCaptcha();
}
Shouldn't I be modeling this of the Sitefinity Captcha widget? That widget implements the FormElement classes which is used for non submitting fields like the page break or title widgets you can add. In this implementation I am 'submitting' the Google token to be evaluated at the server. The Sitefinity Captcha does this via JavaScript on the client before submitting. Probebrly a better implementation approach but I decided on this way for my project.

RecaptchaModel: As you would expect now, this is modelled off of the Sitefinity TextFieldModel. Though a lot of the validation is not here as there is no client-side validation requirement for this.

public class RecaptchaModel : FormFieldModel, IRecaptchaModel
{
    public String DataSiteKey
    {
        get
        {
            var configManager = ConfigManager.GetManager();
            var myConfig = configManager.GetSection<MyConfig>();
 
            return myConfig.Keys.RecaptchaSiteId;
        }
    }
 
    /// <inheritDocs/>
    public Boolean DisplayOnlyForUnauthenticatedUsers { getset; }
 
    public Boolean ShouldRenderCaptcha()
    {
        Boolean isVisible = !(SystemManager.CurrentHttpContext.User != null &&
                SystemManager.CurrentHttpContext.User.Identity != null &&
                SystemManager.CurrentHttpContext.User.Identity.IsAuthenticated &&
                this.DisplayOnlyForUnauthenticatedUsers &&
                !SystemManager.IsDesignMode);
 
        return isVisible;
    }
 
    public override Object GetViewModel(Object value, IMetaField metaField)
    {
        this.Value = value;
        var viewModel = new RecaptchaViewModel()
        {
            Value = value as string ?? this.MetaField?.DefaultValue ?? string.Empty,
            MetaField = metaField,
            ValidationAttributes = this.BuildValidationAttributes(),
            CssClass = this.CssClass,
            ValidatorDefinition = this.BuildValidatorDefinition(this.ValidatorDefinition, metaField?.Title),
            DataSiteKey = this.DataSiteKey
        };
 
        return viewModel;
    }
 
    public override Boolean IsValid(object value)
    {
        Boolean isValid = false;
        if (!this.ShouldRenderCaptcha())
        {
            return true;
        }
              
        String token = value as String;
        if (String.IsNullOrEmpty(token))
        {
            return false;
        }
              
        // I'll show this section later
 
        return isValid;
    }
}

AssementRequest: This is my JSON class used to format the data POST'd to Google.

public partial class AssementRequest
{
    public AssementRequest()
    {
        Event = new SubmitEvent();
    }
 
    [JsonProperty("event")]
    public SubmitEvent Event { getset; }
}
 
public partial class SubmitEvent
{
    [JsonProperty("token")]
    public String Token { getset; }
 
    [JsonProperty("siteKey")]
    public String SiteKey { getset; }
 
    [JsonProperty("expectedAction")]
    public String ExpectedAction { getset; }
}
 
public partial class AssementRequest
{
    public static AssementRequest FromJson(String json) => JsonConvert.DeserializeObject<AssementRequest>(json, RequestConverter.Settings);
}
 
public static class SerializeRequest
{
    public static String ToJson(this AssementRequest self) => JsonConvert.SerializeObject(self, RequestConverter.Settings);
}
 
internal static class RequestConverter
{
    public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings
    {
        MetadataPropertyHandling = MetadataPropertyHandling.Ignore,
        DateParseHandling = DateParseHandling.None,
        Converters =
        {
            new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal }
        },
    };
}

AssementResponse: This is my JSON class used to look at the response form Google.

public partial class AssesmentResponse
{
    [JsonProperty("name")]
    public String Name { getset; }
 
    [JsonProperty("event")]
    public Event Event { getset; }
 
    [JsonProperty("score")]
    public Decimal Score { getset; }
 
    [JsonProperty("tokenProperties")]
    public TokenProperties TokenProperties { getset; }
 
    [JsonProperty("reasons")]
    public List<Object> Reasons { getset; }
}
 
public partial class Event
{
    [JsonProperty("token")]
    public String Token { getset; }
 
    [JsonProperty("siteKey")]
    public String SiteKey { getset; }
 
    [JsonProperty("userAgent")]
    public String UserAgent { getset; }
 
    [JsonProperty("userIpAddress")]
    public String UserIpAddress { getset; }
 
    [JsonProperty("expectedAction")]
    public String ExpectedAction { getset; }
 
    [JsonProperty("hashedAccountId")]
    public String HashedAccountId { getset; }
}
 
public partial class TokenProperties
{
    [JsonProperty("valid")]
    public Boolean Valid { getset; }
 
    [JsonProperty("invalidReason")]
    public String InvalidReason { getset; }
 
    [JsonProperty("hostname")]
    public String Hostname { getset; }
 
    [JsonProperty("action")]
    public String Action { getset; }
 
    [JsonProperty("createTime")]
    public DateTimeOffset CreateTime { getset; }
}
 
// Removed formting classes (see request class above)

Controller Folder

RecaptchaController: This is modelled off the Sitefinity TextFieldController.

[ControllerToolboxItem(Name = "GoogleReCaptcha",          Title = 
        "Google reCAPTCHA",          Description = 
        "Google reCAPTCHA Enterprise",          Toolbox = FormsConstants.FormControlsToolboxName,          SectionName = FormsConstants.CommonSectionName, 
                CssClass = 
        "sfCaptchaIcn sfMvcIcn")] [DatabaseMapping(UserFriendlyDataType.LongText)]
        public class RecaptchaController : FormFieldControllerBase<IRecaptchaModel> {     
        public RecaptchaController()     {         
        this.DisplayMode = FieldDisplayMode.Write;     }     [TypeConverter(

        typeof(ExpandableObjectConverter))]     
        public override IRecaptchaModel Model     {         
        get         {             
        if (this.model == null)                 
        this.model = ControllerModelFactory.GetModel<IRecaptchaModel>(this.GetType());             

        return this.model;         }     }     

        private IRecaptchaModel model; }
        

You also need to register the 'binding' in your global file in the BootstrapperOnBootstrapped event.

IKernel kernel = Telerik.Sitefinity.Frontend.FrontendModule.Current.DependencyResolver;
kernel.Bind<IRecaptchaModel>().To<RecaptchaModel>();

View Folder

Write.Default: Finally our razor view. Here you can see I have added the Google JavaScript from above. As for the main enterprise.js script, I have added this directly in my layout cshtml file so as to control its download better. But you are of course welcome to add it here.

@model Ncr.Sitefinity.WebApp.Mvc.Models.RecaptchaViewModel
 
@using Telerik.Sitefinity.Frontend.Mvc.Helpers;
@using Telerik.Sitefinity.Modules.Pages;
 
<div data-sf-role="recaptcha-field" data-sitekey="@Model.DataSiteKey"></div>
<input type="hidden" id="hdnToken" name="@Model.MetaField.FieldName" value="true" hidden />
 
@if (SitefinityContext.IsBackend)
{
    <p>Configuration settings are found in the Advanced settings where you need to define:<br />Project ID, Site ID and Api Key.</p>
}
 
<div class="@Model.CssClass sf-fieldWrp grid-x pb-30">
    <div class="cell small-12">
        <img style="width:100%;max-width:300px;aspect-ratio:3/1;" 
             loading="lazy" 
             decoding="async" 
             src="/recaptcha-enterprise.png" />
    </div>
</div>
 
<script>
    (function ($) {
        var initRecaptcha = function (idxelement) {
            ResetToken();
        };
 
        grecaptcha.enterprise.ready(function () {
            $('[data-sf-role="recaptcha-field"]').each(initRecaptcha);
        });
    }(jQuery));
 
    function ResetToken() {
        grecaptcha.enterprise.execute('@Model.DataSiteKey', { action: 'FormSubmit' }).then(function (token) {
            $("#hdnToken").val(token);
        });
    }
</script>

You will notice I have a Reset function here. I have put it here for convenience but keep in mind that if you have multiple forms on a page this code will turn up multiple times and may cause some issues. The reason I have it as its own function is that you can only use a token once. So if your user submits a form and you do not navigate them away from the page you need to regenerate that token. I am using an 'AJAX' form submit process so my users are staying on the page.

To accomplish that I have customised the Sitefinity form.all.js file adding in a custom call to call it. Around line 77 you will find the place to add it in. What I would like to do here is hook into this event outside of editing the Sitefinity file. Something I will look into later but for now, this will give you an idea of what is required to be done.

else if (responseJson.message && responseJson.message !== '') {
    successMessage.text(responseJson.message);
    successMessageContainer.show();
    loadingImg.hide();
    ResetToken();

Google, is this Valid?

Finally, we get to the last section. As you recall we need to contact Google ourselves and Google will give us a score indicating if they think it is a human or robot. I had to muck around a bit to get this working but the below does work. You may or may not want to log some of this but keep in mind that all interactions and results are stored and displayed in Google for you to look at. But it does point out some places where things can fail. An example is if you submit a bad request by using the wrong Site ID then the return JSON is in a different format than expected.

(This code is inserted into the RecaptchaModel in the IsValid method.)

try
{
    var configManager = ConfigManager.GetManager();
    var myConfig = configManager.GetSection<MyConfig>();
 
    using (HttpClient client = new HttpClient())
    {
        client.Timeout = TimeSpan.FromMilliseconds(10000);
        client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
 
        var request = new AssementRequest();
        request.Event.Token = token;
        request.Event.SiteKey = myConfig.Keys.RecaptchaSiteId;
        request.Event.ExpectedAction = "FormSubmit";
 
        using (StringContent content = new StringContent(request.ToJson(), Encoding.UTF8, "application/json"))
        {
            Uri url = new Uri(String.Format(CultureInfo.CurrentCulture, 
                "https://recaptchaenterprise.googleapis.com/v1beta1/projects/{0}/assessments?key={1}", 
                myConfig.Keys.RecaptchaProjectId, 
                myConfig.Keys.RecaptchaApiKey));
 
            HttpResponseMessage response = client.PostAsync(url, content).Result;
            String responseString = response.Content.ReadAsStringAsync().Result;
            try
            {
                AssesmentResponse responseModel = AssesmentResponse.FromJson(responseString);
                if (responseModel.TokenProperties.InvalidReason != "INVALID_REASON_UNSPECIFIED")
                {
                    Log.Write("Invalid Recaptcha result:" + responseModel.TokenProperties.InvalidReason);
                }
 
                isValid = responseModel.TokenProperties.Valid && responseModel.TokenProperties.InvalidReason == "INVALID_REASON_UNSPECIFIED";
            }
            catch
            {
                Log.Write("Exception with the recaptcha response: " + responseString);
            }
        }     
    }
}
catch (Exception ex)
{
    Log.Write("Exception while validating reCaptcha field: " + ex.Message);
}
 
return isValid;

I still have plenty of questions about interrupting the score and the 'Valid' property. As mentioned above this is not a finished or polished implementation but I suspect that each site may implement and handle this in different ways. Hopefully, this will get you started with a working piece of code.


Darrin Robertson - Sitefinity Developer

Thanks for reading and feel free to comment - Darrin Robertson

If I was really helpful and you would buy me a coffee if you could, yay! You can.


Leave a comment
Load more comments

Make a Comment

recapcha code