DEV Community

Cover image for Google reCAPTCHA in .NET MAUI
Victor Hugo Garcia
Victor Hugo Garcia

Posted on • Updated on

Google reCAPTCHA in .NET MAUI

Google's reCAPTCHA is a free service that protects your site or app from spam and abuse. It uses advanced risk analysis techniques to tell humans and bots apart. In this post, I’ll show you how to implement Google reCaptcha in your .NET MAUI app for iOS and Android.


Special thanks

I really appreciate the work our friend Enrique Ramos put into getting the Xamarin.Forms article from which I got the inspiration to implement it on .NET MAUI


Setup Google reCaptcha

Go to Google admin portal and configure your sites and API Keys required. Please follow the guide our friend Enrique Ramos shared on the first steps from his article.

Note: This article implements reCaptcha v2. I haven't test it with reCaptcha v3.


Required packages and project conditions

  • For Android, add the NuGet packages required to enable reCaptcha or edit the .csproj file and set them up there:
<ItemGroup Condition="'$(TargetFramework)' == 'net7.0-android'">
      <PackageReference Include="Xamarin.GooglePlayServices.SafetyNet" Version="118.0.1.2" />   
</ItemGroup>
Enter fullscreen mode Exit fullscreen mode
  • For iOS, a package is not required. We will use a hidden WebView that will execute the request and then validate the returned token by the service. More details on the next section.

Implementation using platform-specific API

Well, I found this way easier to maintain and update over time. If you want to support macOS and/or Windows, well the code below is ready for you to add the supported platform API for enabling reCaptha.

Create the interface

I usually create the file under a folder path: /Services/ReCaptcha/

public interface IReCaptchaService
{
    Task<string> Verify(string siteKey, string domainUrl = "https://localhost");
    Task<bool> Validate(string captchaToken);
}
Enter fullscreen mode Exit fullscreen mode

Add the constants

// reCaptcha
    public static readonly string ReCaptchaSiteKey = DeviceInfo.Platform == DevicePlatform.Android ? "XYZ" : "ZYX";
    public static readonly string ReCaptchaSiteSecretKey = DeviceInfo.Platform == DevicePlatform.Android ? "ABC" : "CBA";
    public const string ReCaptchaVerificationUrl = "https://www.google.com/recaptcha/api/siteverify?secret={0}&response={1}";
    public const string ReCaptchaHtml = "<html><head> <meta name=\"viewport\" content=\"width=device-width\"/> <script type=\"text/javascript\">const post=function(value){window.webkit.messageHandlers.recaptcha.postMessage(value);}; console.log=function(message){post(\"ConsoleDebug: \" + message);}; const observers=new Array(); const observeDOM=function(element, completion){const obs=new MutationObserver(completion); obs.observe(element,{attributes: true, childList: true, subtree: true, attributeFilter: [\"style\"]}); observers.push(obs);}; const clearObservers=function(){observers.forEach(function(o){o.disconnect();}); observers=[];}; const execute=function(){console.log(\"executing\"); try{document.getElementsByTagName(\"div\")[4].outerHTML=\"\";}catch (e){}try{observeDOM(document.getElementsByTagName(\"div\")[3], function(){post(\"ShowReCaptchaChallenge\");});}catch (e){post(\"Error27FailedSetup\");}grecaptcha.execute();}; const reset=function(){console.log(\"resetting\"); grecaptcha.reset(); grecaptcha.ready(function(){post(\"DidLoad\");});}; var onloadCallback=function(){grecaptcha.render(\"submit\",{sitekey: \"${siteKey}\", callback: function(token){console.log(token); post(token); clearObservers();}, \"expired-callback\": function(){post(\"Error28Expired\"); clearObservers();}, \"error-callback\": function(){post(\"Error29FailedRender\"); clearObservers();}, size: \"invisible\"}); grecaptcha.ready(function(){observeDOM(document.getElementById(\"body\"), function(mut){const success=!!mut.find(function({addedNodes}){return Array.from( addedNodes.values ? addedNodes.values() : addedNodes ).find(function({nodeName, name}){return nodeName===\"IFRAME\" && !!name;});}); if (success){post(\"DidLoad\");}});});}; </script></head><body id=\"body\"><span id=\"submit\" style=\"visibility: hidden;\"></span><script src=\"https://www.google.com/recaptcha/api.js?onload=onloadCallback&hl=${language}\" async defer></script></body></html>";
Enter fullscreen mode Exit fullscreen mode

Create the implementations on each platform

Android
Under a folder path: /Platforms/Android/Services/ReCaptcha/

/// <summary>
/// https://developer.android.com/training/safetynet/recaptcha
/// </summary>
public class ReCaptchaService : IReCaptchaService
{
    readonly HttpClient _httpClient = new HttpClient();
    private static Context CurrentContext => Platform.CurrentActivity;

    private SafetyNetClient _safetyNetClient;
    private SafetyNetClient SafetyNetClient
    {
        get
        {
            return _safetyNetClient ??= SafetyNetClass.GetClient(CurrentContext);
        }
    }

    public async Task<string> Verify(string siteKey, string domainUrl = "https://localhost")
    {
        SafetyNetApiRecaptchaTokenResponse response = await SafetyNetClass.GetClient(Platform.CurrentActivity).VerifyWithRecaptchaAsync(siteKey);
        return response?.TokenResult;
    }

    public async Task<bool> Validate(string captchaToken)
    {
        var validationUrl = string.Format(Constants.ReCaptchaVerificationUrl, Constants.ReCaptchaSiteSecretKey, captchaToken);
        var response = await _httpClient.GetStringAsync(validationUrl);
        var reCaptchaResponse = JsonConvert.DeserializeObject<ReCaptchaResponse>(response);
        return reCaptchaResponse.Success;
    }
}
Enter fullscreen mode Exit fullscreen mode

iOS
As mentioned before, for iOS we will create the service and then request the validation token by using a hidden embedded webview. I know, I also wish there could be a library officially from Google to avoid doing this, however, this is what we have and it works.

Under a folder path: /Platforms/iOS/Services/ReCaptcha/

public class ReCaptchaService : IReCaptchaService
{
    private TaskCompletionSource<string> _tcsWebView;
    private TaskCompletionSource<bool> _tcsValidation;
    private ReCaptchaWebView _reCaptchaWebView;

    public Task<bool> Validate(string captchaToken)
    {
        _tcsValidation = new TaskCompletionSource<bool>();

        var reCaptchaResponse = new ReCaptchaResponse();
        NSUrl url = new NSUrl(string.Format(Constants.ReCaptchaVerificationUrl, Constants.ReCaptchaSiteSecretKey, captchaToken));
        NSUrlRequest request = new NSUrlRequest(url);
        NSUrlSession session = null;
        NSUrlSessionConfiguration myConfig = NSUrlSessionConfiguration.DefaultSessionConfiguration;
        myConfig.MultipathServiceType = NSUrlSessionMultipathServiceType.Handover;
        session = NSUrlSession.FromConfiguration(myConfig);
        NSUrlSessionTask task = session.CreateDataTask(request, (data, response, error) => {
            Console.WriteLine(data);
            reCaptchaResponse = JsonConvert.DeserializeObject<ReCaptchaResponse>(data.ToString());
            _tcsValidation.TrySetResult(reCaptchaResponse.Success);
        });

        task.Resume();

        return _tcsValidation.Task;
    }

    public Task<string> Verify(string siteKey, string domainUrl = "https://localhost")
    {
        _tcsWebView = new TaskCompletionSource<string>();

        UIWindow window = UIApplication.SharedApplication.KeyWindow;
        var webViewConfiguration = new WKWebViewConfiguration();
        _reCaptchaWebView = new ReCaptchaWebView(window.Bounds, webViewConfiguration)
        {
            SiteKey = siteKey,
            DomainUrl = domainUrl
        };
        _reCaptchaWebView.ReCaptchaCompleted += RecaptchaWebViewViewControllerOnReCaptchaCompleted;

#if DEBUG
        // Forces the Captcha Challenge to be explicitly displayed
        _reCaptchaWebView.PerformSelector(new ObjCRuntime.Selector("setCustomUserAgent:"), NSThread.MainThread, new NSString("Googlebot/2.1"), true);
#endif

        _reCaptchaWebView.CustomUserAgent = "Googlebot/2.1";

        window.AddSubview(_reCaptchaWebView);
        _reCaptchaWebView.LoadInvisibleCaptcha();

        return _tcsWebView.Task;
    }

    private void RecaptchaWebViewViewControllerOnReCaptchaCompleted(object sender, string recaptchaResult)
    {
        if (!(sender is ReCaptchaWebView reCaptchaWebViewViewController))
        {
            return;
        }

        _tcsWebView?.SetResult(recaptchaResult);
        reCaptchaWebViewViewController.ReCaptchaCompleted -= RecaptchaWebViewViewControllerOnReCaptchaCompleted;
        _reCaptchaWebView.Hidden = true;
        _reCaptchaWebView.StopLoading();
        _reCaptchaWebView.RemoveFromSuperview();
        _reCaptchaWebView.Dispose();
        _reCaptchaWebView = null;
    }
}
Enter fullscreen mode Exit fullscreen mode
/// <summary>
/// Hidden WebView that loads Invisible Captcha v2.
/// If the user is required to resolve a Captcha Challenge,
/// then the WebView is displayed over the App with the Challenge as content and transparent background. 
/// Based on this Swift implementation https://github.com/fjcaetano/ReCaptcha
/// </summary>
public sealed class ReCaptchaWebView : WKWebView, IWKScriptMessageHandler
{
    private bool _captchaCompleted;
    public event EventHandler<string> ReCaptchaCompleted;

    public string SiteKey { get; set; }
    public string DomainUrl { get; set; }
    public string LanguageCode { get; set; }

    public ReCaptchaWebView(CGRect frame, WKWebViewConfiguration configuration) : base(frame, configuration)
    {
        BackgroundColor = UIColor.Clear;
        ScrollView.BackgroundColor = UIColor.Clear;
        Opaque = false;
        Hidden = true;

        Configuration.UserContentController.AddScriptMessageHandler(this, "recaptcha");
    }

    public void LoadInvisibleCaptcha()
    {
        var html = new NSString(Constants.ReCaptchaHtml
            .Replace("${siteKey}", SiteKey)); 
        LoadHtmlString(html, new NSUrl(DomainUrl));
    }

    public void DidReceiveScriptMessage(WKUserContentController userContentController, WKScriptMessage message)
    {
        string post = message.Body.ToString();
        switch (post)
        {
            case "DidLoad":
                ExecuteCaptcha();
                break;
            case "ShowReCaptchaChallenge":
                Hidden = false;
                break;
            case "Error27FailedSetup":
            case "Error28Expired":
            case "Error29FailedRender":
                if (_captchaCompleted)
                {
                    OnReCaptchaCompleted(null);
                    Debug.WriteLine(post);
                    return;
                }

                _captchaCompleted = true; // 1 retry
                Reset();
                break;
            default:
                if (post.Contains("ConsoleDebug:"))
                {
                    Debug.WriteLine(post);
                }
                else
                {
                    _captchaCompleted = true;
                    OnReCaptchaCompleted(post); // token
                }
                break;
        }
    }

    private void OnReCaptchaCompleted(string token)
    {
        ReCaptchaCompleted?.Invoke(this, token);
    }

    private async void ExecuteCaptcha()
    {
        await EvaluateJavaScriptAsync(new NSString("execute();"));
    }

    private async void Reset()
    {
        await EvaluateJavaScriptAsync(new NSString("reset();"));
    }
Enter fullscreen mode Exit fullscreen mode

Then apply some Dependency Injection for registering the services in the MauiProgram.cs file and verify and validate from any ViewModel or View:

var captchaToken = await _reCaptcha.Verify(Constants.ReCaptchaSiteKey);

if (captchaToken == null)
    throw new Exception("Unable to retrieve reCaptcha Token");

bool isValidCaptchaToken = await _reCaptcha.Validate(captchaToken);
if (!isValidCaptchaToken)
    throw new Exception("reCaptcha token validation failed.");

Debug.WriteLine($"reCaptcha token: {captchaToken} is valid? {isValidCaptchaToken}");
Enter fullscreen mode Exit fullscreen mode

Thanks for reading!

Top comments (1)

Collapse
 
alfredopizana profile image
alfredopizana

Thanks for sharing!