From c86a4ecf41170f63479c69cda52f446c62d53d94 Mon Sep 17 00:00:00 2001 From: Jacob O'Toole Date: Thu, 25 May 2023 09:48:38 +0100 Subject: [PATCH 1/8] Add support for returning the full v2 response and verifying the hostname and action This also: - Adds Cloudflare Turnstile constatns and the `cdata` response field. - Adds the `apk_package_name` field for verifying responses from android apps. --- BitArmory.ReCaptcha/Constants.cs | 26 ++++++- BitArmory.ReCaptcha/ReCaptchaResponse.cs | 61 ++++++++++++++- BitArmory.ReCaptcha/ReCaptchaService.cs | 96 ++++++++++++++++++++++-- README.md | 44 ++++++++++- 4 files changed, 210 insertions(+), 17 deletions(-) diff --git a/BitArmory.ReCaptcha/Constants.cs b/BitArmory.ReCaptcha/Constants.cs index b12a80c..cb9ecfd 100644 --- a/BitArmory.ReCaptcha/Constants.cs +++ b/BitArmory.ReCaptcha/Constants.cs @@ -11,13 +11,33 @@ public static class Constants public const string VerifyUrl = "https://www.google.com/recaptcha/api/siteverify"; /// - /// Default URL for reCAPTCHA.js + /// Default URL for verifying reCAPTCHA. + /// + public const string TurnstileVerifyUrl = "https://challenges.cloudflare.com/turnstile/v0/siteverify"; + + /// + /// Default JavaScript URL for reCAPTCHA.js /// public const string JavaScriptUrl = "https://www.google.com/recaptcha/api.js"; - /// + /// + /// More available JavaScript URL for reCAPTCHA.js (available where google.com isn't) + /// + public const string NonGoogleJavaScriptUrl = "https://www.recaptcha.net/recaptcha/api.js"; + + /// + /// Default JavaScript URL for Cloudflare Turnstile + /// + public const string TurnstileJavaScriptUrl = "https://challenges.cloudflare.com/turnstile/v0/api.js"; + + /// /// Default HTTP header key for reCAPTCHA response. /// public const string ClientResponseKey = "g-recaptcha-response"; + + /// + /// Default HTTP header key for reCAPTCHA response. + /// + public const string TurnstileClientResponseKey = "cf-turnstile-response"; } -} \ No newline at end of file +} diff --git a/BitArmory.ReCaptcha/ReCaptchaResponse.cs b/BitArmory.ReCaptcha/ReCaptchaResponse.cs index 40980dc..64bd159 100644 --- a/BitArmory.ReCaptcha/ReCaptchaResponse.cs +++ b/BitArmory.ReCaptcha/ReCaptchaResponse.cs @@ -14,7 +14,55 @@ public class JsonResponse } /// - /// Response from reCAPTCHA verify URL. + /// Response from reCAPTCHA v2 or Cloudflare Turnstile verify URL. + /// + public class ReCaptcha2Response : JsonResponse + { + /// + /// Whether this request was a valid reCAPTCHA token for your site. + /// + public bool IsSuccess { get; set; } + + /// + /// The action name for this request, only provided by Cloudflare Turnstile, not reCAPTCHA v2 (important to verify). + /// + public string? Action { get; set; } + + /// + /// Timestamp of the challenge load (ISO format yyyy-MM-dd'T'HH:mm:ssZZ). + /// + public string ChallengeTs { get; set; } + + /// + /// missing-input-secret: The secret parameter is missing. + /// invalid-input-secret: The secret parameter is invalid or malformed. + /// missing-input-response: The response parameter is missing. + /// invalid-input-response: The response parameter is invalid or malformed. + /// bad-request: The request is invalid or malformed. + /// timeout-or-duplicate: The response is no longer valid: either is too old or has been used previously. + /// + public string[] ErrorCodes { get; set; } + + /// + /// The hostname of the site where the reCAPTCHA was solved (if solved on a website). + /// + public string? HostName { get; set; } + + /// + /// ApkPackageName is the package name of the app the captcha was solved on (if solved in an android app). + /// + public string? ApkPackageName { get; set; } + + /// + /// Customer data passed on the client side. + /// + /// Only provided by Cloudflare Turnstile, not reCAPTCHA v2. + /// + public string? CData { get; set; } + } + + /// + /// Response from reCAPTCHA v3 verify URL. /// public class ReCaptcha3Response : JsonResponse { @@ -49,9 +97,14 @@ public class ReCaptcha3Response : JsonResponse public string[] ErrorCodes { get; set; } /// - /// The hostname of the site where the reCAPTCHA was solved + /// The hostname of the site where the reCAPTCHA was solved (if solved on a website). + /// + public string? HostName { get; set; } + + /// + /// ApkPackageName is the package name of the app the captcha was solved on (if solved in an android app). /// - public string HostName { get; set; } + public string? ApkPackageName { get; set; } } -} \ No newline at end of file +} diff --git a/BitArmory.ReCaptcha/ReCaptchaService.cs b/BitArmory.ReCaptcha/ReCaptchaService.cs index e539a15..73f844f 100644 --- a/BitArmory.ReCaptcha/ReCaptchaService.cs +++ b/BitArmory.ReCaptcha/ReCaptchaService.cs @@ -46,14 +46,14 @@ public ReCaptchaService(string verifyUrl = null, HttpClient client = null) } /// - /// Validate reCAPTCHA v2 using your secret. + /// Validate reCAPTCHA v2 and return the json response (for internal use). /// /// Required. The user response token provided by the reCAPTCHA client-side integration on your site. The value pulled from the client with the request headers or hidden form field. - /// Optional. The remote IP of the client + /// Optional. The remote IP of the client. /// Required. The server-side secret: v2 secret, invisible secret, or android secret. The shared key between your site and reCAPTCHA. /// Async cancellation token. - /// Task returning bool whether reCAPTHCA is valid or not. - public virtual async Task Verify2Async(string clientToken, string remoteIp, string siteSecret, CancellationToken cancellationToken = default) + /// Task returning the parsed JSON response, or null if the request wasn't successful. + async Task JsonResponse2Async(string clientToken, string remoteIp, string siteSecret, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(siteSecret)) throw new ArgumentException("The secret must not be null or empty", nameof(siteSecret)); if (string.IsNullOrWhiteSpace(clientToken)) throw new ArgumentException("The client response must not be null or empty", nameof(clientToken)); @@ -63,15 +63,95 @@ public virtual async Task Verify2Async(string clientToken, string remoteIp var response = await this.HttpClient.PostAsync(verifyUrl, form, cancellationToken) .ConfigureAwait(false); - if( !response.IsSuccessStatusCode ) return false; + if( !response.IsSuccessStatusCode ) return null; var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - var model = Json.Parse(json); + return Json.Parse(json); + } + + /// + /// Validate reCAPTCHA v2 using your secret. + /// + /// Required. The user response token provided by the reCAPTCHA client-side integration on your site. The value pulled from the client with the request headers or hidden form field. + /// Optional. The remote IP of the client. + /// Required. The server-side secret: v2 secret, invisible secret, or android secret. The shared key between your site and reCAPTCHA. + /// Async cancellation token. + /// Optional. The expected hostname. If not null, the verification will fail if this does not match. + /// Optional. The expected action (for Cloudflare Turnstile, this should be null if reCAPTCHA v2 is being used). If not null, the verification will fail if this does not match. + /// Task returning bool whether reCAPTHCA is valid or not. + public virtual async Task Verify2Async(string clientToken, string remoteIp, string siteSecret, CancellationToken cancellationToken = default, string hostname = null, string action = null) + { + var model = await JsonResponse2Async(clientToken, remoteIp, siteSecret, cancellationToken).ConfigureAwait(false); + + // Check the request was successful + if( model == null ) return false; + + // Verify the hostname if it's not null + if (hostname != null && hostname != model["hostname"]) return false; + // Verify the action if it's not null + if (action != null && action != model["action"]) return false; + + // Now return the success value return model["success"].AsBool; } + /// + /// Validate reCAPTCHA v2 using your secret and return the full response. + /// + /// Required. The user response token provided by the reCAPTCHA client-side integration on your site. The value pulled from the client with the request headers or hidden form field. + /// Optional. The remote IP of the client + /// Required. The server-side secret: v2 secret, invisible secret, or android secret. The shared key between your site and reCAPTCHA. + /// Async cancellation token. + /// Task returning the full response. + public virtual async Task Response2Async(string clientToken, string remoteIp, string siteSecret, CancellationToken cancellationToken = default) + { + var model = await JsonResponse2Async(clientToken, remoteIp, siteSecret, cancellationToken).ConfigureAwait(false); + + if( model == null ) return new ReCaptcha2Response {IsSuccess = false}; + + var result = new ReCaptcha2Response(); + + foreach( var kv in model ) + { + switch( kv.Key ) + { + case "success": + result.IsSuccess = kv.Value; + break; + case "action": + result.Action = kv.Value; + break; + case "challenge_ts": + result.ChallengeTs = kv.Value; + break; + case "hostname": + result.HostName = kv.Value; + break; + case "apk_package_name": + result.ApkPackageName = kv.Value; + break; + case "cdata": + result.CData = kv.Value; + break; + case "error-codes" when kv.Value is JsonArray errors: + { + result.ErrorCodes = errors.Children + .Select(n => (string)n) + .ToArray(); + + break; + } + default: + result.ExtraJson.Add(kv.Key, kv.Value); + break; + } + } + + return result; + } + /// /// Validate reCAPTCHA v3 using your secret. /// @@ -84,7 +164,6 @@ public virtual async Task Verify3Async(string clientToken, s if( string.IsNullOrWhiteSpace(siteSecret) ) throw new ArgumentException("The secret must not be null or empty", nameof(siteSecret)); if( string.IsNullOrWhiteSpace(clientToken) ) throw new ArgumentException("The client response must not be null or empty", nameof(clientToken)); - var form = PrepareRequestBody(clientToken, siteSecret, remoteIp); var response = await this.HttpClient.PostAsync(verifyUrl, form, cancellationToken) @@ -117,6 +196,9 @@ public virtual async Task Verify3Async(string clientToken, s case "hostname": result.HostName = kv.Value; break; + case "apk_package_name": + result.ApkPackageName = kv.Value; + break; case "error-codes" when kv.Value is JsonArray errors: { result.ErrorCodes = errors.Children diff --git a/README.md b/README.md index 8c9c474..9a4c570 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ BitArmory.ReCaptcha for .NET and C# Project Description ------------------- -:recycle: A minimal, no-drama, friction-less **C#** **HTTP** verification client for **Google**'s [**reCAPTCHA** API](https://www.google.com/recaptcha). +:recycle: A minimal, no-drama, friction-less **C#** **HTTP** verification client for **Google**'s [**reCAPTCHA** API](https://www.google.com/recaptcha), supporing [**Cloudflare Turnstile**](https://developers.cloudflare.com/turnstile/). The problem with current **ReCaptcha** libraries in **.NET** is that all of them take a hard dependency on the underlying web framework like **ASP.NET WebForms**, **ASP.NET MVC 5**, **ASP.NET Core**, or **ASP.NET Razor Pages**. @@ -20,6 +20,7 @@ Furthermore, current **reCAPTCHA** libraries for **.NET** are hard coded against #### Supported reCAPTCHA Versions * [**reCAPTCHA v2 (I'm not a robot)**][2] * [**reCAPTCHA v3 (Invisible)**][3] +* [**Cloudflare Turnstile (Managed or Invisible)**][4] #### Crypto Tip Jar @@ -48,9 +49,10 @@ You'll need to create **reCAPTCHA** account. You can sign up [here](https://www. 1. Your `site` key 2. Your `secret` key -This library supports both: +This library supports: * [**reCAPTCHA v2 (I'm not a robot)**][2] * [**reCAPTCHA v3 (Invisible)**][3]. +* [**Cloudflare Turnstile (Managed or Invisible)**][4] ## reCAPTCHA v3 (Invisible) @@ -251,6 +253,41 @@ public string GetClientIpAddress(){ That's it! **Happy verifying!** :tada: +### Cloudflare Turnstile + +[Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) is an alternative to reCAPTCHA, providing a very similar interface to reCAPTCHA v2, which doesn't require user intereaction unless the visitor is suspected of being a bot. + +After following the [instructions](https://developers.cloudflare.com/turnstile/get-started/) to get the client ready, and to generate your secret key, verifying the resposne on the server side is easy. + +```csharp +// 1. Get the client IP address in your chosen web framework +string clientIp = GetClientIpAddress(); +string captchaResponse = null; +string secret = "your_secret_key"; + +// 2. Extract the `cf-turnstile-response` field from the HTML form in your chosen web framework +if( this.Request.Form.TryGetValue(Constants.TurnstileClientResponseKey, out var formField) ) +{ + capthcaResponse = formField; +} + +// 3. Validate the response +var captchaApi = new ReCaptchaService(Constants.TurnstileVerifyUrl); +var isValid = await captchaApi.Verify2Async(capthcaResponse, clientIp, secret); +if( !isValid ) +{ + this.ModelState.AddModelError("captcha", "The reCAPTCHA is not valid."); + return new BadRequestResult(); +} +else{ + //continue processing, everything is okay! +} +``` + +The full response, including `cdata`, can be fetched using `captchaApi.Response2Async(capthcaResponse, clientIp, secret)`. + +Furthermore, the *hostname* and *action* can be (and **should** be) verified by passing the `hostname` and `action` arguments to `captchaApi.Verify2Async`. + Building -------- @@ -263,4 +300,5 @@ Upon successful build, the results will be in the `\__compile` directory. If you [0]:https://support.cloudflare.com/hc/en-us/articles/200170986-How-does-Cloudflare-handle-HTTP-Request-headers [2]:#recaptcha-v2-im-not-a-robot -[3]:#recaptcha-v3-invisible-1 \ No newline at end of file +[3]:#recaptcha-v3-invisible-1 +[4]:#cloudflare-turnstile From 226a0167aa6897400aae22b5ce0d98089def497a Mon Sep 17 00:00:00 2001 From: Jacob O'Toole Date: Thu, 25 May 2023 13:13:23 +0100 Subject: [PATCH 2/8] Add net6.0 to target frameworks --- BitArmory.ReCaptcha.Tests/BitArmory.ReCaptcha.Tests.csproj | 4 ++-- BitArmory.ReCaptcha/BitArmory.ReCaptcha.csproj | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/BitArmory.ReCaptcha.Tests/BitArmory.ReCaptcha.Tests.csproj b/BitArmory.ReCaptcha.Tests/BitArmory.ReCaptcha.Tests.csproj index 040b22b..66489a2 100644 --- a/BitArmory.ReCaptcha.Tests/BitArmory.ReCaptcha.Tests.csproj +++ b/BitArmory.ReCaptcha.Tests/BitArmory.ReCaptcha.Tests.csproj @@ -1,6 +1,6 @@  - net45;netcoreapp3.1;net6.0 + net6.0;net45;netcoreapp3.1;net6.0 Library @@ -20,4 +20,4 @@ - \ No newline at end of file + diff --git a/BitArmory.ReCaptcha/BitArmory.ReCaptcha.csproj b/BitArmory.ReCaptcha/BitArmory.ReCaptcha.csproj index 5c03bba..9d04687 100644 --- a/BitArmory.ReCaptcha/BitArmory.ReCaptcha.csproj +++ b/BitArmory.ReCaptcha/BitArmory.ReCaptcha.csproj @@ -5,7 +5,7 @@ 0.0.0-localbuild Brian Chavez - net45;netstandard1.3;netstandard2.0 + net6.0;net45;netstandard1.3;netstandard2.0 latest BitArmory.ReCaptcha.ruleset true @@ -14,7 +14,7 @@ false BitArmory.ReCaptcha BitArmory.ReCaptcha - google;recaptcha;captcha;asp.net;aspnet;mvc;core;razor;razorpages;webforms;security;bot;anti-spam;validation;recpatcha + google;recaptcha;captcha;cloudflare;turnstile;asp.net;aspnet;mvc;core;razor;razorpages;webforms;security;bot;anti-spam;validation;recpatcha https://raw.githubusercontent.com/BitArmory/ReCaptcha/master/docs/recaptcha.png https://github.com/BitArmory/ReCaptcha https://raw.githubusercontent.com/BitArmory/ReCaptcha/master/LICENSE @@ -40,4 +40,4 @@ - \ No newline at end of file + From 2b81ee65ff48fe18de7a0785fb9dc92b431983ad Mon Sep 17 00:00:00 2001 From: Jacob O'Toole Date: Thu, 25 May 2023 13:13:55 +0100 Subject: [PATCH 3/8] Remove nullable indicators to retain backwards compatilibity --- BitArmory.ReCaptcha/ReCaptchaResponse.cs | 12 ++++++------ BitArmory.ReCaptcha/ReCaptchaService.cs | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/BitArmory.ReCaptcha/ReCaptchaResponse.cs b/BitArmory.ReCaptcha/ReCaptchaResponse.cs index 64bd159..457d85c 100644 --- a/BitArmory.ReCaptcha/ReCaptchaResponse.cs +++ b/BitArmory.ReCaptcha/ReCaptchaResponse.cs @@ -26,7 +26,7 @@ public class ReCaptcha2Response : JsonResponse /// /// The action name for this request, only provided by Cloudflare Turnstile, not reCAPTCHA v2 (important to verify). /// - public string? Action { get; set; } + public string Action { get; set; } /// /// Timestamp of the challenge load (ISO format yyyy-MM-dd'T'HH:mm:ssZZ). @@ -46,19 +46,19 @@ public class ReCaptcha2Response : JsonResponse /// /// The hostname of the site where the reCAPTCHA was solved (if solved on a website). /// - public string? HostName { get; set; } + public string HostName { get; set; } /// /// ApkPackageName is the package name of the app the captcha was solved on (if solved in an android app). /// - public string? ApkPackageName { get; set; } + public string ApkPackageName { get; set; } /// /// Customer data passed on the client side. /// /// Only provided by Cloudflare Turnstile, not reCAPTCHA v2. /// - public string? CData { get; set; } + public string CData { get; set; } } /// @@ -99,12 +99,12 @@ public class ReCaptcha3Response : JsonResponse /// /// The hostname of the site where the reCAPTCHA was solved (if solved on a website). /// - public string? HostName { get; set; } + public string HostName { get; set; } /// /// ApkPackageName is the package name of the app the captcha was solved on (if solved in an android app). /// - public string? ApkPackageName { get; set; } + public string ApkPackageName { get; set; } } } diff --git a/BitArmory.ReCaptcha/ReCaptchaService.cs b/BitArmory.ReCaptcha/ReCaptchaService.cs index 73f844f..a29a83c 100644 --- a/BitArmory.ReCaptcha/ReCaptchaService.cs +++ b/BitArmory.ReCaptcha/ReCaptchaService.cs @@ -53,7 +53,7 @@ public ReCaptchaService(string verifyUrl = null, HttpClient client = null) /// Required. The server-side secret: v2 secret, invisible secret, or android secret. The shared key between your site and reCAPTCHA. /// Async cancellation token. /// Task returning the parsed JSON response, or null if the request wasn't successful. - async Task JsonResponse2Async(string clientToken, string remoteIp, string siteSecret, CancellationToken cancellationToken = default) + async Task JsonResponse2Async(string clientToken, string remoteIp, string siteSecret, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(siteSecret)) throw new ArgumentException("The secret must not be null or empty", nameof(siteSecret)); if (string.IsNullOrWhiteSpace(clientToken)) throw new ArgumentException("The client response must not be null or empty", nameof(clientToken)); From dd8b4e476f8743ff07a85232e9a845011ae95a2c Mon Sep 17 00:00:00 2001 From: Jacob O'Toole Date: Thu, 25 May 2023 13:14:29 +0100 Subject: [PATCH 4/8] Add unit tests for v2 hostname and action verification --- .../CaptchaVersion2Tests.cs | 93 ++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/BitArmory.ReCaptcha.Tests/CaptchaVersion2Tests.cs b/BitArmory.ReCaptcha.Tests/CaptchaVersion2Tests.cs index 9afb243..cfffbcd 100644 --- a/BitArmory.ReCaptcha.Tests/CaptchaVersion2Tests.cs +++ b/BitArmory.ReCaptcha.Tests/CaptchaVersion2Tests.cs @@ -21,6 +21,21 @@ string ResponseJson(bool isSuccess) return json; } + string ResponseJsonWithHostAndAction(bool isSuccess, string hostname, string action) + { + var json = @"{ + ""success"": {SUCCESS}, + ""challenge_ts"": ""2018-05-15T23:05:22Z"", + ""hostname"": ""{HOSTNAME}"", + ""action"": ""{ACTION}"" +}" + .Replace("{SUCCESS}", isSuccess.ToString().ToLower()) + .Replace("{HOSTNAME}", hostname) + .Replace("{ACTION}", action); + + return json; + } + [Test] public async Task can_verify_a_captcha() { @@ -60,5 +75,81 @@ public async Task can_verify_failed_response() mockHttp.VerifyNoOutstandingExpectation(); } + + [Test] + public async Task can_verify_a_captcha_from_another_url() + { + var responseJson = ResponseJson(true); + + var mockHttp = new MockHttpMessageHandler(); + + mockHttp.Expect(HttpMethod.Post, Constants.TurnstileVerifyUrl) + .Respond("application/json", responseJson) + .WithExactFormData("response=aaaaa&remoteip=bbbb&secret=cccc"); + + var captcha = new ReCaptchaService(Constants.TurnstileVerifyUrl, client: mockHttp.ToHttpClient()); + + var response = await captcha.Verify2Async("aaaaa", "bbbb", "cccc"); + + response.Should().BeTrue(); + + mockHttp.VerifyNoOutstandingExpectation(); + } + + [Test] + public async Task can_verify_a_captcha_with_hostname_and_action() + { + var responseJson = ResponseJson(true); + var mockHttp = new MockHttpMessageHandler(); + mockHttp.When(HttpMethod.Post, Constants.TurnstileVerifyUrl) + .Respond("application/json", responseJson) + .WithExactFormData("response=aaaaa&remoteip=bbbb&secret=cccc"); + var captcha = new ReCaptchaService(Constants.TurnstileVerifyUrl, client: mockHttp.ToHttpClient()); + + var response = await captcha.Verify2Async("aaaaa", "bbbb", "cccc", hostname: "localhost"); + response.Should().BeTrue(); + + response = await captcha.Verify2Async("aaaaa", "bbbb", "cccc", hostname: "not-localhost"); + response.Should().BeFalse(); + + response = await captcha.Verify2Async("aaaaa", "bbbb", "cccc", hostname: "not-localhost", action: "test"); + response.Should().BeFalse(); + + response = await captcha.Verify2Async("aaaaa", "bbbb", "cccc", hostname: "localhost", action: "test"); + response.Should().BeFalse(); + + mockHttp.VerifyNoOutstandingExpectation(); + + // Now generate responses with the action field filled out (and a different hostname) + responseJson = ResponseJsonWithHostAndAction(true, "example.com", "test-action"); + mockHttp = new MockHttpMessageHandler(); + mockHttp.When(HttpMethod.Post, Constants.TurnstileVerifyUrl) + .Respond("application/json", responseJson) + .WithExactFormData("response=aaaaa&remoteip=bbbb&secret=cccc"); + captcha = new ReCaptchaService(Constants.TurnstileVerifyUrl, client: mockHttp.ToHttpClient()); + + response = await captcha.Verify2Async("aaaaa", "bbbb", "cccc", hostname: "localhost", action: "test-action"); + response.Should().BeFalse(); + + response = await captcha.Verify2Async("aaaaa", "bbbb", "cccc", hostname: "example.com", action: "test-action"); + response.Should().BeTrue(); + + response = await captcha.Verify2Async("aaaaa", "bbbb", "cccc", hostname: "example.com", action: "action"); + response.Should().BeFalse(); + + response = await captcha.Verify2Async("aaaaa", "bbbb", "cccc", hostname: "example.com"); + response.Should().BeTrue(); + + response = await captcha.Verify2Async("aaaaa", "bbbb", "cccc", action: "example.com"); + response.Should().BeFalse(); + + response = await captcha.Verify2Async("aaaaa", "bbbb", "cccc", action: "test-action"); + response.Should().BeTrue(); + + response = await captcha.Verify2Async("aaaaa", "bbbb", "cccc"); + response.Should().BeTrue(); + + mockHttp.VerifyNoOutstandingExpectation(); + } } -} \ No newline at end of file +} From 43b08ec20cdb618309dfe240de338a867c09ace2 Mon Sep 17 00:00:00 2001 From: Jacob O'Toole Date: Thu, 25 May 2023 13:24:04 +0100 Subject: [PATCH 5/8] Add unit test for parsing v2 responses --- .../CaptchaVersion2Tests.cs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/BitArmory.ReCaptcha.Tests/CaptchaVersion2Tests.cs b/BitArmory.ReCaptcha.Tests/CaptchaVersion2Tests.cs index cfffbcd..e797053 100644 --- a/BitArmory.ReCaptcha.Tests/CaptchaVersion2Tests.cs +++ b/BitArmory.ReCaptcha.Tests/CaptchaVersion2Tests.cs @@ -151,5 +151,46 @@ public async Task can_verify_a_captcha_with_hostname_and_action() mockHttp.VerifyNoOutstandingExpectation(); } + + [Test] + public async Task can_parse_full_v2_response() + { + var responseJson = @"{ + ""success"": true, + ""challenge_ts"": ""2018-05-15T23:05:22Z"", + ""hostname"": ""example.net"", + ""action"": ""test-action"", + ""apk_package_name"": ""test-package"", + ""cdata"": ""customer data"" +}"; + var mockHttp = new MockHttpMessageHandler(); + mockHttp.Expect(HttpMethod.Post, Constants.TurnstileVerifyUrl) + .Respond("application/json", responseJson) + .WithExactFormData("response=aaaaa&remoteip=bbbb&secret=cccc"); + var captcha = new ReCaptchaService(Constants.TurnstileVerifyUrl, client: mockHttp.ToHttpClient()); + + var response = await captcha.Response2Async("aaaaa", "bbbb", "cccc"); + response.IsSuccess.Should().BeTrue(); + response.ChallengeTs.Should().Be("2018-05-15T23:05:22Z"); + response.HostName.Should().Be("example.net"); + response.Action.Should().Be("test-action"); + response.ApkPackageName.Should().Be("test-package"); + response.CData.Should().Be("customer data"); + + responseJson = ResponseJson(false); + mockHttp = new MockHttpMessageHandler(); + mockHttp.Expect(HttpMethod.Post, Constants.VerifyUrl) + .Respond("application/json", responseJson) + .WithExactFormData("response=aaaaa&remoteip=bbbb&secret=cccc"); + captcha = new ReCaptchaService(client: mockHttp.ToHttpClient()); + + response = await captcha.Response2Async("aaaaa", "bbbb", "cccc"); + response.IsSuccess.Should().BeFalse(); + response.ChallengeTs.Should().Be("2018-05-15T23:05:22Z"); + response.HostName.Should().Be("localhost"); + response.Action.Should().Be(null); + response.ApkPackageName.Should().Be(null); + response.CData.Should().Be(null); + } } } From 126785c68533a21eae8aacdcf492a8d8d17b7a1b Mon Sep 17 00:00:00 2001 From: Jacob O'Toole Date: Thu, 25 May 2023 14:20:45 +0100 Subject: [PATCH 6/8] Update Cloudflaire Turnstile instructions --- README.md | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9a4c570..c2a7d14 100644 --- a/README.md +++ b/README.md @@ -259,6 +259,27 @@ That's it! **Happy verifying!** :tada: After following the [instructions](https://developers.cloudflare.com/turnstile/get-started/) to get the client ready, and to generate your secret key, verifying the resposne on the server side is easy. +#### Client Side + +More detailed instructions, including theming, can be found at +[https://developers.cloudflare.com/turnstile/get-started/](https://developers.cloudflare.com/turnstile/get-started/). + +In short, you want to include the JavaScript in the ``: + +```html + +``` + +And then insert the widget: + +```html +
+``` + +The *data-action* attribute can be verified later, on the server side. + +#### Server Side + ```csharp // 1. Get the client IP address in your chosen web framework string clientIp = GetClientIpAddress(); @@ -284,9 +305,17 @@ else{ } ``` -The full response, including `cdata`, can be fetched using `captchaApi.Response2Async(capthcaResponse, clientIp, secret)`. +The *hostname* and *action* can be (and **should** be) verified by passing the `hostname` and `action` arguments to `captchaApi.Verify2Async`, for example: -Furthermore, the *hostname* and *action* can be (and **should** be) verified by passing the `hostname` and `action` arguments to `captchaApi.Verify2Async`. +```csharp +var isValid = await captchaApi.Verify2Async( + capthcaResponse, clientIp, secret, + hostname: "expected.hostname", + action: "example-action", +); +``` + +The full response, including `cdata`, can be fetched using `captchaApi.Response2Async(capthcaResponse, clientIp, secret)`. Building From 26f9c2b87f5fde7ed81fcb2104a408a1dc1b969e Mon Sep 17 00:00:00 2001 From: Jacob O'Toole Date: Thu, 25 May 2023 14:22:10 +0100 Subject: [PATCH 7/8] Correct Cloudflare Turnstile heading level --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c2a7d14..0ba7b00 100644 --- a/README.md +++ b/README.md @@ -253,13 +253,13 @@ public string GetClientIpAddress(){ That's it! **Happy verifying!** :tada: -### Cloudflare Turnstile +## Cloudflare Turnstile [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) is an alternative to reCAPTCHA, providing a very similar interface to reCAPTCHA v2, which doesn't require user intereaction unless the visitor is suspected of being a bot. After following the [instructions](https://developers.cloudflare.com/turnstile/get-started/) to get the client ready, and to generate your secret key, verifying the resposne on the server side is easy. -#### Client Side +### Client Side More detailed instructions, including theming, can be found at [https://developers.cloudflare.com/turnstile/get-started/](https://developers.cloudflare.com/turnstile/get-started/). @@ -278,7 +278,7 @@ And then insert the widget: The *data-action* attribute can be verified later, on the server side. -#### Server Side +### Server Side ```csharp // 1. Get the client IP address in your chosen web framework From 04eb59418a67514914b41a8423861c2383236014 Mon Sep 17 00:00:00 2001 From: Jacob O'Toole Date: Thu, 25 May 2023 16:56:36 +0100 Subject: [PATCH 8/8] Remove duplicate net6.0 in TargetFrameworks of tests --- BitArmory.ReCaptcha.Tests/BitArmory.ReCaptcha.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BitArmory.ReCaptcha.Tests/BitArmory.ReCaptcha.Tests.csproj b/BitArmory.ReCaptcha.Tests/BitArmory.ReCaptcha.Tests.csproj index 66489a2..948f69b 100644 --- a/BitArmory.ReCaptcha.Tests/BitArmory.ReCaptcha.Tests.csproj +++ b/BitArmory.ReCaptcha.Tests/BitArmory.ReCaptcha.Tests.csproj @@ -1,6 +1,6 @@  - net6.0;net45;netcoreapp3.1;net6.0 + net45;netcoreapp3.1;net6.0 Library