using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.Extensions.Options;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
namespace TurnstileTag;
public interface ITurnstile
{
///
/// Generates the placeholder which renders the turnstile challenge.
///
public IHtmlContent GetInputHtml();
///
/// Generates the script tag which loads the turnstile library.
///
public IHtmlContent GetScriptHtml();
///
///
///
IHtmlContent GenerateTurnstileFormHtml(ViewContext viewContext);
///
///
///
Task ValidateRequestAsync(HttpContext httpContext);
///
///
///
Task Verify(string value);
}
public class Turnstile : ITurnstile
{
private readonly TurnstileOptions _options;
public Turnstile(IOptions options)
{
_options = options.Value;
}
///
public async Task ValidateRequestAsync(HttpContext httpContext)
{
ArgumentNullException.ThrowIfNull(httpContext);
string[] allowed = new string[] { HttpMethods.Get, HttpMethods.Head, HttpMethods.Options, HttpMethods.Trace };
if (allowed.Contains(httpContext.Request.Method) || !httpContext.Request.HasFormContentType)
{
return;
}
IFormCollection form;
try
{
form = await httpContext.Request.ReadFormAsync();
}
catch (Exception ex) when (ex is InvalidDataException or IOException)
{
// ReadFormAsync can throw InvalidDataException if the form content is malformed.
// [or] Reading the request body (which happens as part of ReadFromAsync) may throw an exception if a client disconnects.
// Wrap it in an AntiforgeryValidationException and allow the caller to handle it as just another antiforgery failure.
throw new TurnstileException("Unable to read the turnstile data from the posted form.", ex);
}
string? captchaResponse = form[_options.FormFieldName].FirstOrDefault();
if (string.IsNullOrEmpty(captchaResponse) || !await Verify(captchaResponse))
{
throw new TurnstileException("Could not verify captcha.");
}
}
///
public async Task Verify(string value)
{
using HttpClient http = new();
using HttpResponseMessage response = await http.PostAsJsonAsync(
requestUri: new Uri(_options.ApiUrl + "/siteverify", UriKind.Absolute),
value: new Request(_options.SecretKey!, value)
);
Response? model = await response.Content.ReadFromJsonAsync();
return model != null && model.Success;
}
///
public IHtmlContent GetInputHtml()
{
HtmlContentBuilder builder = new();
builder.AppendHtml($"");
return builder;
}
///
public IHtmlContent GetScriptHtml()
{
HtmlContentBuilder builder = new();
builder.AppendHtml($"");
return builder;
}
///
public IHtmlContent GenerateTurnstileFormHtml(ViewContext viewContext)
{
ArgumentNullException.ThrowIfNull(viewContext);
var formContext = viewContext.FormContext;
if (formContext.CanRenderAtEndOfForm)
{
if (formContext.FormData.ContainsKey("turnstile"))
{
return HtmlString.Empty;
}
formContext.FormData.Add("turnstile", true);
}
viewContext.ViewData.TryAdd("turnstile", true);
return GetInputHtml();
}
class Request
{
public string Secret { get; set; }
public string Response { get; set; }
public Request(string secret, string response)
{
Secret = secret;
Response = response;
}
}
class Response
{
public bool Success { get; set; }
[JsonPropertyName("challenge_ts")]
public DateTimeOffset ChallengedDate { get; set; }
public string? Hostname { get; set; }
[JsonPropertyName("error-codes")]
public string[]? ErrorCodes { get; set; }
public string? Action { get; set; }
public string? Cdata { get; set; }
}
}