Using ReCaptcha v2 Insible in MVC Net Core 2 Applications

Suppose

  • There is only one form in the page
  • The form will be submitted in a way that can fire the the onsubmit event like either of the following:
    • <input type="submit" value="Submit" />
    • <button type="submit">Submit</button>
    You can not submit a form in a way that can bypass the onsubmit event like the following (because the direct invocation of submit() will not fire the onsubmit event):
    <button type='button' onclick='getElementById("myForm").submit();'>Submit</button>

The flow

Clicking on submit -> Model client validation by jquery.validate -> reCaptcha client validation by grecaptcha.execute() -> (reCaptcha puzzle challenges if any) -> submitted by reCaptcha data-callback -> Model server validation -> reCaptcha server validation -> done
Only after the reCaptcha client side validation has been passed, the form has chance to reach the back end. However, the ReCaptcha server side validation can't be omitted as the client side can be easily hijacked by a bot.

/Views/Shared/_reCaptcha.cshtml

 
@using Microsoft.Extensions.Configuration
@inject IConfiguration _configuration

<script>
    function getForm() {
        return $("form").eq(0);
    }

    function recaptchaOnloadCallback() {
        var $form = getForm();
        if ($form.length > 0) {
            $form.append($('<div>').attr({
                'id':               'recaptcha-container',
                'data-sitekey':      '@_configuration.GetSection("Recaptcha_v2_Invisible")["SiteKey"]',
                'data-callback':    'submitWithUserResponseToken',
                'data-size':        'invisible'
            }));
            // render() will add required ReCaptcha markups inside div#recaptcha-container and show the ReCaptcha widget
            var widgetId = grecaptcha.render('recaptcha-container');
            $form.on("submit", function (e) {
                e.preventDefault();
                // Ensure ReCaptcha verification happens only after the client validation is passed!
                // https://jqueryvalidation.org/documentation/
                if ($form.valid()) {
                    // start to request user response token only after submit is clicked to avoid expiration
                    // (token must be validated within 2 minutes)
                    grecaptcha.execute();
                }
            });
        }
    }

    function submitWithUserResponseToken(userResponseToken) {
        var $form = getForm();
        if ($form.length > 0) {

                // for v3: we need manually add the following before submission
                //$form.append($('<input />').attr({
                //    'type': 'hidden',
                //    'name': 'g-recaptcha-response',
                //    'value': userResponseToken
                //}));
                // but for v2: g-recaptcha-response is automatically added to the POST form
                // for v2: don't manually add it like v3 otherwise "invalid-input-response"!
                // Weird: The field value that is automatically added to posted form
                // is different from this passed in userResponseToken.
                // Use DOM's original submit() rather than jquery's submit(), 
                // else the onsubmit event still will be fired by submit() (infinite loop).
                // https://github.com/google/recaptcha/issues/281

                $form[0].submit();
        }
    }

</script>

<script src='https://www.google.com/recaptcha/api.js?onload=recaptchaOnloadCallback&render=explicit' async defer></script>

<environment include="Development,Staging">
    <div class="row mb-2">
        <div class="offset-6 col-3">
            <input type='button' value='Test reCaptcha - (debug only)' onclick='viewReCaptchaAPIResponseToken()' class="enabled" />
        </div>
    </div>
    <script>
        function viewReCaptchaAPIResponseToken() {
            var $form = getForm();
            $form.attr("action", "/MyController/ViewReCaptchaAPIResponseToken");
            // client side valid() is no need
            grecaptcha.execute();
        }
    </script>
</environment>

/Views/My/Create.cshtml, Edit.cshtml and Delete.cshtml

 
    ...
    <partial name="_reCaptcha" />
    ...

/Views/Shared/ViewReCaptchaAPIResponseToken.cshtml

 
        This view wil display the API response token as follows (for debug):

        View ReCaptcha API Response Token
        challenge_ts: 7/29/2019 9:01:53 PM
        error_codes: 
        success: True
        hostname: mydomain.com
        score: 0

<div class="row" id="dev-reCaptcha">
   @{
                ReCaptchaAPIResponseToken apiResponseToken = ViewBag.ApiResponseToken;
                if (apiResponseToken != null)
                {
                    Type type = apiResponseToken.GetType();
                    foreach (System.Reflection.PropertyInfo prop in type.GetProperties())
                    {
                        <div class="col-2">
                             <strong>@prop.Name</strong>:
                        </div>
                        <div class="col-10">
                          @if (prop.PropertyType.IsGenericType && prop.GetValue(apiResponseToken) != null)
                            {
                                // error-codes is type of List<string>
                                foreach (var value in (List<string>) prop.GetValue(apiResponseToken))
                                {
                                    <text>
                                        @value
                                    </text>
                                }
                            }
                            else
                            {
                                <text>
                                    @prop.GetValue(apiResponseToken)
                                </text>
                            }
                       </div>
                    }
                }
   }
</div>

/appsettings.json

  
 "Recaptcha_v2_Checkbox": {
    "Uri": "https://www.google.com/recaptcha/api/siteverify",
    "SiteKey": "......",
    "SecretKey": "......"
  },
  "Recaptcha_v2_Invisible": {
    "Uri": "https://www.google.com/recaptcha/api/siteverify",
    "SiteKey": "......",
    "SecretKey": "......"
  },
  "Recaptcha_v3": {
    "Uri": "https://www.google.com/recaptcha/api/siteverify",
    "SiteKey": "......",
    "SecretKey": "......"
  }

/Controllers/MyController.cs

    public class MyController : Controller
    {
        ...

        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Create(MyModel model)
        {
           ...
           await Validate(model);

           if (ModelState.IsValid)
            {
                _context.Add(model);
                await _context.SaveChangesAsync();
                return RedirectToAction("Index", "Home");
            }
           ...
         }

        public async Task Validate(MyModel model)
        {
           ...
            if (request.Consent!= true)
            {
                ModelState.AddModelError("Consent", "Your Consent is required");
            }
           ...
            if (!ModelState.IsValid)
                _controller.TempData["ErrorMessage"] = "Please check the highlighted fields below for errors";
            else
                // valid reCaptcha after model validation is passed
                await ValidateRecaptcha();
        }


        public async Task ValidateRecaptcha()
        {
            var apiResponseToken = await ReCaptchaHelper.ReCaptchaPassed(HttpContext.Request.Form["g-recaptcha-response"]);

            //for ViewReCaptchaAPIResponseToken.cshtml
            ViewBag.ApiResponseToken = apiResponseToken;
            if (!apiResponseToken.success)
            {
                ModelState.AddModelError(string.Empty, "You failed the CAPTCHA");
            }
        }


        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> ViewReCaptchaAPIResponseToken()
        {
            await ValidateRecaptcha();
            return View();
        }
        ...

    }

/Helers/ReCaptchaHelper.cs

    public class ReCaptchaAPIResponseToken
    {
        public DateTime challenge_ts { get; set; }
        [JsonProperty(PropertyName = "error-codes")]
        public List<string> error_codes { get; set; }
        public bool success { get; set; }
        public string hostname { get; set; }
        public float score { get; set; } // for v3 only
    }

    public static class ReCaptchaHelper
    {
        public static async Task<ReCaptchaAPIResponseToken> ReCaptchaPassed(string userResponseToken)
        {
            HttpClient httpClient = new HttpClient();
            string uri = Startup.Configuration.GetSection("Recaptcha_v2_Invisible")["Uri"];
            string secretKey = Startup.Configuration.GetSection("Recaptcha_v2_Invisible")["SecretKey"];
            var values = new Dictionary<string, string> {
                        {
                            "secret",
                            secretKey
                        },
                        {
                            "response",
                            userResponseToken
                        }
                    };
            var content = new System.Net.Http.FormUrlEncodedContent(values);
            var result = await httpClient.PostAsync(uri, content);

            var apiResponseToken = Newtonsoft.Json.JsonConvert.DeserializeObject<
ReCaptchaAPIResponseToken>(await result.Content.ReadAsStringAsync());
            return apiResponseToken;
        }
    }

The "Security Preference" Settings in Admin Console

https://developers.google.com/recaptcha/docs/versions: By default only the most suspicious traffic will be prompted to solve a captcha. To alter this behavior edit your site security preference under advanced settings.
The Security Preference defaults to the left (Easiest for users) which means you have fewer chances to be required to resolve the the annoying images puzzles.
However if you change this setting to others (Most secure etc), you most likely will be required to resolve the image puzzles.

Some helpful references

    // How can I customize reCAPTCHA v3?
    // https://developers.google.com/recaptcha/docs/display?csw=1
    // https://y-designs.com/ideas/stories/google-recaptcha-v2-on-a-dynamic-page
    // https://wordpress.org/support/topic/recaptcha-badge-on-all-pages-not-just-pages-with-contact-forms/
    // https://developers.google.com/recaptcha/docs/faq
    // https://stackoverflow.com/questions/53590011/how-to-implement-recaptcha-v3-in-asp-net
    // https://tehnoblog.org/google-no-captcha-invisible-recaptcha-first-experience-results-review/
    // https://medium.com/@jsoverson/thoughts-on-recaptcha-v3-e837d4a0a63
    // https://www.google.com/recaptcha/admin/site/346930825
    // is v3 reliable?
    // https://github.com/google/recaptcha/issues/248
    // v2: https://product.hubspot.com/blog/quick-guide-invisible-recaptcha
    // v2: https://stackoverflow.com/questions/41079335/implement-the-new-invisible-recaptcha-from-google
    // https://stackoverflow.com/questions/5127813/call-mvc-3-client-side-validation-manually-for-ajax-posts
    // https://stackoverflow.com/questions/53590011/how-to-implement-recaptcha-v3-in-asp-net
    // https://medium.com/rubrikkgroup/understanding-async-avoiding-deadlocks-e41f8f2c6f5d
    // https://stackoverflow.com/questions/10343632/httpclient-getasync-never-returns-when-using-await-async
    // https://www.c-sharpcorner.com/article/implement-google-recaptcha-in-asp-net-mvc/

Comments

Popular posts from this blog

Use GnuPG Tools or C# Code for PGP Encryption and Signature

Errors in Net Core Add-Migration

Confusing Concepts about SFTP: SSH2 vs OpenSSH