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