C#/Xamarin Maui

[.NET MAUI] Google reCAPTCHA

kjun.kr 2023. 4. 12. 23:34
728x90
728x170

.NET MAUI에서 reCAPTCHA 기능을 사용하는 방법입니다.

(Android 만 우선.. iOS는 확인이 마무리되는 대로 업데이트하겠습니다.)

먼저 reCAPTCHA 기능을 사용하기 위해서는 아래 링크로 들어가서 SiteKey, SiteSecretKey를 받아야 합니다.

https://www.google.com/recaptcha/admin/create

 

로그인 - Google 계정

이메일 또는 휴대전화

accounts.google.com

아래처럼 선택 하고 라벨과 패키지 명을 입력합니다.

제출하게 되면 아래 처럼 SiteKey, SiteSecretKey를

https://www.google.com/recaptcha/admin 여기로 이동하면 등록된 목록을 확인할 수 있습니다.

ReCaptchaService를 각 플랫폼 별로 정의합니다. 먼저 인터페이스를 생성합니다.

IReCaptchaService.cs

namespace Maui.CaptchaTest.Common
{
    public interface IReCaptchaService
    {
        Task<string> Verify(string siteKey, string domainUrl = "https://localhost");
        Task<bool> Validate(string captchaToken);
    }
}

Captcha 유효성 확인에 필요한 응답 구조를 정의합니다.

CaptchaResult.cs

using System.Text.Json.Serialization;

namespace Maui.CaptchaTest.Common
{
    public class CaptchaResult
    {
        [JsonPropertyName("success")]
        public bool Success { get; set; }

        [JsonPropertyName("challenge_ts")]
        public DateTime ChallengeTs { get; set; }

        [JsonPropertyName("apk_package_name")]
        public string ApkPackageName { get; set; }

        [JsonPropertyName("error-codes")]
        public List<object> ErrorCodes { get; set; }
    }
}


이제 각 플랫폼 별로 ReCaptchaService를 정의합니다.
(파일의 위치는 아래와 같이 했습니다.)

ReCaptchaService.cs (Android)

using Android.Content;
using Android.Gms.SafetyNet;

using Maui.CaptchaTest.Common;

using Newtonsoft.Json;

namespace Maui.CaptchaTest.Platforms.Android.ReCaptcha
{
    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")
        {
            var client = SafetyNetClass.GetClient(Platform.CurrentActivity);

            SafetyNetApiRecaptchaTokenResponse response = await client.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;
        }
    }
}

ReCaptchaService.cs (iOS)

using Foundation;

using Maui.CaptchaTest.Common;

using Newtonsoft.Json;

using UIKit;

using WebKit;

namespace Maui.CaptchaTest.Platforms.iOS.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;
        }
    }
}

ReCaptchaWebView.cs (Captcha 웹뷰를 띄워주기 위해서 생성)

using System.Diagnostics;

using CoreGraphics;

using Foundation;

using Maui.CaptchaTest.Common;

using UIKit;

using WebKit;

namespace Maui.CaptchaTest.Platforms.iOS.ReCaptcha
{
    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();"));
        }
    }
}


Constants.cs 에 필요한 상수 값들을 정의합니다.
(여기에서 제일 처음 발급받았던 SiteKey, SiteSecretKey를
  ReCaptchaSiteKey, ReCaptchaSiteSecretKey에  세팅합니다.)

namespace Maui.CaptchaTest.Common
{
    public class Constants
    {
        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>";
    }
}

 

MauiProgram.cs 에서 ReCaptchaService를 각 디바이스 별로 DI 합니다.

using CommunityToolkit.Maui;

using Maui.CaptchaTest.Common;

using Microsoft.Extensions.Logging;

namespace Maui.CaptchaTest;

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .UseMauiCommunityToolkit()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
                fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
            });

#if DEBUG
        builder.Logging.AddDebug();
#endif

#if ANDROID
	    builder.Services.AddScoped<IReCaptchaService, Maui.CaptchaTest.Platforms.Android.ReCaptcha.ReCaptchaService>();
#elif IOS
        builder.Services.AddScoped<IReCaptchaService, Maui.CaptchaTest.Platforms.iOS.ReCaptcha.ReCaptchaService>();
#endif

        return builder.Build();
    }
}

 

화면에서 사용하는 방법은 버튼 클릭 이벤트에서 아래처럼 사용합니다.

using Maui.CaptchaTest.Common;

namespace Maui.CaptchaTest;

public partial class MainPage : ContentPage
{
    IReCaptchaService reCaptchaService;

    public MainPage(IReCaptchaService reCaptchaService)
    {
        this.reCaptchaService = reCaptchaService;

        InitializeComponent();
    }

    private async void OnCounterClicked(object sender, EventArgs e)
    {
        var captchaToken = await reCaptchaService.Verify(Constants.ReCaptchaSiteKey);

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

        bool isValidCaptchaToken = await reCaptchaService.Validate(captchaToken);

        if (!isValidCaptchaToken)
        {
            throw new Exception("reCaptcha token validation failed.");

        }
    }
}


결과

응답내용

{
  "success": true,
  "challenge_ts": "2023-04-13T09:30:14Z",
  "apk_package_name": "com.companyname.maui.captchatest"
}

 

[Source]
https://github.com/kei-soft/Maui.CaptchaTest

 

GitHub - kei-soft/Maui.CaptchaTest

Contribute to kei-soft/Maui.CaptchaTest development by creating an account on GitHub.

github.com

 

728x90
그리드형