Google reCAPTCHA in .NET MAUI
Published: 2023-02-18 00:16:14

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](https://medium.com/@HeikkiDev/google-recaptcha-v2-in-native-xamarin-forms-apps-ee3ca76ac542) from which I got the inspiration to implement it on .NET MAUI

---
## Setup Google reCaptcha 
Go to [Google admin portal](https://www.google.com/recaptcha/admin) and configure your sites and API Keys required. Please follow [the guide](https://medium.com/@HeikkiDev/google-recaptcha-v2-in-native-xamarin-forms-apps-ee3ca76ac542) 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:

```xml
<ItemGroup Condition="'$(TargetFramework)' == 'net7.0-android'">
	  <PackageReference Include="Xamarin.GooglePlayServices.SafetyNet" Version="118.0.1.2" />	
</ItemGroup>
```
- 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/_
```csharp
public interface IReCaptchaService
{
    Task<string> Verify(string siteKey, string domainUrl = "https://localhost");
    Task<bool> Validate(string captchaToken);
}
```
### Add the constants
```csharp
// 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>";
```

### Create the implementations on each platform
***Android***
Under a folder path: _/Platforms/Android/Services/ReCaptcha/_
```csharp
/// <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;
    }
}
```
***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/_
```csharp
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;
    }
}
```
```csharp
/// <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();"));
    }
```

Then apply some [Dependency Injection](https://learn.microsoft.com/en-us/dotnet/architecture/maui/dependency-injection) for registering the services in the MauiProgram.cs file and verify and validate from any ViewModel or View:
```csharp
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}");
```

Thanks for reading!