diff --git a/BitArmory.ReCaptcha.Tests/BitArmory.ReCaptcha.Tests.csproj b/BitArmory.ReCaptcha.Tests/BitArmory.ReCaptcha.Tests.csproj index 040b22b..948f69b 100644 --- a/BitArmory.ReCaptcha.Tests/BitArmory.ReCaptcha.Tests.csproj +++ b/BitArmory.ReCaptcha.Tests/BitArmory.ReCaptcha.Tests.csproj @@ -20,4 +20,4 @@ - \ No newline at end of file + diff --git a/BitArmory.ReCaptcha.Tests/CaptchaVersion2Tests.cs b/BitArmory.ReCaptcha.Tests/CaptchaVersion2Tests.cs index 9afb243..e797053 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,122 @@ 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(); + } + + [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); + } } -} \ 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 + 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..457d85c 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 ApkPackageName { get; set; } } -} \ No newline at end of file +} diff --git a/BitArmory.ReCaptcha/ReCaptchaService.cs b/BitArmory.ReCaptcha/ReCaptchaService.cs index e539a15..a29a83c 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..0ba7b00 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,70 @@ 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. + +### 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(); +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 *hostname* and *action* can be (and **should** be) verified by passing the `hostname` and `action` arguments to `captchaApi.Verify2Async`, for example: + +```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 -------- @@ -263,4 +329,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