[.NET MAUI] Google reCAPTCHA
.NET MAUI에서 reCAPTCHA 기능을 사용하는 방법입니다.
(Android 만 우선.. iOS는 확인이 마무리되는 대로 업데이트하겠습니다.)
먼저 reCAPTCHA 기능을 사용하기 위해서는 아래 링크로 들어가서 SiteKey, SiteSecretKey를 받아야 합니다.
https://www.google.com/recaptcha/admin/create
아래처럼 선택 하고 라벨과 패키지 명을 입력합니다.
제출하게 되면 아래 처럼 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