diff --git a/.gitignore b/.gitignore
index 57015074..dea519cd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,6 +33,7 @@ TestResults/
# Old or generated files to not commit
build_output.txt
+test_output.txt
wwwroot/sitemap.xml
wwwroot/Chapters
EssentialCSharp.Web/wwwroot/Chapters
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 301a14e3..46d68feb 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -61,7 +61,7 @@
-
+
diff --git a/EssentialCSharp.Chat.Shared/EssentialCSharp.Chat.Common.csproj b/EssentialCSharp.Chat.Shared/EssentialCSharp.Chat.Common.csproj
index e367599d..83273b81 100644
--- a/EssentialCSharp.Chat.Shared/EssentialCSharp.Chat.Common.csproj
+++ b/EssentialCSharp.Chat.Shared/EssentialCSharp.Chat.Common.csproj
@@ -12,6 +12,7 @@
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs b/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs
index 42c5fa71..60dc38f8 100644
--- a/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs
+++ b/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs
@@ -6,7 +6,6 @@
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Http.Resilience;
using Microsoft.Extensions.Options;
using Microsoft.SemanticKernel;
using Npgsql;
diff --git a/EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs b/EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs
new file mode 100644
index 00000000..1e9ac023
--- /dev/null
+++ b/EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs
@@ -0,0 +1,174 @@
+using EssentialCSharp.Web.Models;
+using EssentialCSharp.Web.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+
+namespace EssentialCSharp.Web.Tests;
+
+public class CaptchaValidationServiceTests
+{
+ [Test]
+ public async Task ValidateAsync_MissingConfig_RejectsWithoutVerification()
+ {
+ StubCaptchaService captchaService = new((_, _, _) => throw new InvalidOperationException("Verifier should not be called."));
+ using ServiceProvider serviceProvider = CreateServiceProvider(
+ new CaptchaOptions { SecretKey = string.Empty, SiteKey = string.Empty },
+ captchaService);
+
+ ICaptchaValidationService validationService = serviceProvider.GetRequiredService();
+
+ CaptchaValidationResult result = await validationService.ValidateAsync("token", "127.0.0.1");
+
+ await Assert.That(result.Outcome).IsEqualTo(CaptchaValidationOutcome.Misconfigured);
+ await Assert.That(result.ShouldProceed).IsFalse();
+ await Assert.That(captchaService.CallCount).IsEqualTo(0);
+ }
+
+ [Test]
+ public async Task ValidateAsync_MissingSiteKey_RejectsWithoutVerification()
+ {
+ StubCaptchaService captchaService = new((_, _, _) => throw new InvalidOperationException("Verifier should not be called."));
+ using ServiceProvider serviceProvider = CreateServiceProvider(
+ new CaptchaOptions { SecretKey = "secret", SiteKey = string.Empty },
+ captchaService);
+
+ ICaptchaValidationService validationService = serviceProvider.GetRequiredService();
+
+ CaptchaValidationResult result = await validationService.ValidateAsync("token", "127.0.0.1");
+
+ await Assert.That(result.Outcome).IsEqualTo(CaptchaValidationOutcome.Misconfigured);
+ await Assert.That(result.ShouldProceed).IsFalse();
+ await Assert.That(captchaService.CallCount).IsEqualTo(0);
+ }
+
+ [Test]
+ public async Task ValidateAsync_MissingSecretKey_RejectsWithoutVerification()
+ {
+ StubCaptchaService captchaService = new((_, _, _) => throw new InvalidOperationException("Verifier should not be called."));
+ using ServiceProvider serviceProvider = CreateServiceProvider(
+ new CaptchaOptions { SecretKey = string.Empty, SiteKey = "sitekey" },
+ captchaService);
+
+ ICaptchaValidationService validationService = serviceProvider.GetRequiredService();
+
+ CaptchaValidationResult result = await validationService.ValidateAsync("token", "127.0.0.1");
+
+ await Assert.That(result.Outcome).IsEqualTo(CaptchaValidationOutcome.Misconfigured);
+ await Assert.That(result.ShouldProceed).IsFalse();
+ await Assert.That(captchaService.CallCount).IsEqualTo(0);
+ }
+
+ [Test]
+ public async Task ValidateAsync_MissingToken_ReturnsMissingToken()
+ {
+ StubCaptchaService captchaService = new((_, _, _) => throw new InvalidOperationException("Verifier should not be called."));
+ using ServiceProvider serviceProvider = CreateServiceProvider(
+ new CaptchaOptions { SecretKey = "secret", SiteKey = "sitekey" },
+ captchaService);
+
+ ICaptchaValidationService validationService = serviceProvider.GetRequiredService();
+
+ CaptchaValidationResult result = await validationService.ValidateAsync(string.Empty, "127.0.0.1");
+
+ await Assert.That(result.Outcome).IsEqualTo(CaptchaValidationOutcome.MissingToken);
+ await Assert.That(result.ShouldProceed).IsFalse();
+ await Assert.That(captchaService.CallCount).IsEqualTo(0);
+ }
+
+ [Test]
+ public async Task ValidateAsync_Unavailable_ReturnsUnavailable()
+ {
+ StubCaptchaService captchaService = new((_, _, _) => Task.FromResult(null));
+ using ServiceProvider serviceProvider = CreateServiceProvider(
+ new CaptchaOptions { SecretKey = "secret", SiteKey = "sitekey" },
+ captchaService);
+
+ ICaptchaValidationService validationService = serviceProvider.GetRequiredService();
+
+ CaptchaValidationResult result = await validationService.ValidateAsync("token", "127.0.0.1");
+
+ await Assert.That(result.Outcome).IsEqualTo(CaptchaValidationOutcome.Unavailable);
+ await Assert.That(result.ShouldProceed).IsFalse();
+ await Assert.That(captchaService.CallCount).IsEqualTo(1);
+ }
+
+ [Test]
+ public async Task ValidateAsync_ForwardsCancellationToken()
+ {
+ using CancellationTokenSource cancellationTokenSource = new();
+ CancellationToken receivedCancellationToken = default;
+ StubCaptchaService captchaService = new((_, _, cancellationToken) =>
+ {
+ receivedCancellationToken = cancellationToken;
+ return Task.FromResult(new HCaptchaResult { Success = true });
+ });
+ using ServiceProvider serviceProvider = CreateServiceProvider(
+ new CaptchaOptions { SecretKey = "secret", SiteKey = "sitekey" },
+ captchaService);
+
+ ICaptchaValidationService validationService = serviceProvider.GetRequiredService();
+
+ await validationService.ValidateAsync("token", "127.0.0.1", cancellationTokenSource.Token);
+
+ await Assert.That(receivedCancellationToken).IsEqualTo(cancellationTokenSource.Token);
+ }
+
+ [Test]
+ public async Task ValidateAsync_InvalidAndValid_ReturnExpectedOutcome()
+ {
+ StubCaptchaService invalidCaptchaService = new((_, _, _) => Task.FromResult(new HCaptchaResult
+ {
+ Success = false,
+ ErrorCodes = ["invalid-input-response"]
+ }));
+ using ServiceProvider invalidProvider = CreateServiceProvider(
+ new CaptchaOptions { SecretKey = "secret", SiteKey = "sitekey" },
+ invalidCaptchaService);
+
+ ICaptchaValidationService invalidValidationService = invalidProvider.GetRequiredService();
+ CaptchaValidationResult invalidResult = await invalidValidationService.ValidateAsync("token", "127.0.0.1");
+
+ await Assert.That(invalidResult.Outcome).IsEqualTo(CaptchaValidationOutcome.Invalid);
+ await Assert.That(invalidResult.Response).IsNotNull();
+ await Assert.That(invalidResult.ShouldProceed).IsFalse();
+
+ StubCaptchaService validCaptchaService = new((_, _, _) => Task.FromResult(new HCaptchaResult
+ {
+ Success = true
+ }));
+ using ServiceProvider validProvider = CreateServiceProvider(
+ new CaptchaOptions { SecretKey = "secret", SiteKey = "sitekey" },
+ validCaptchaService);
+
+ ICaptchaValidationService validValidationService = validProvider.GetRequiredService();
+ CaptchaValidationResult validResult = await validValidationService.ValidateAsync("token", "127.0.0.1");
+
+ await Assert.That(validResult.Outcome).IsEqualTo(CaptchaValidationOutcome.Valid);
+ await Assert.That(validResult.ShouldProceed).IsTrue();
+ await Assert.That(validCaptchaService.CallCount).IsEqualTo(1);
+ }
+
+ private static ServiceProvider CreateServiceProvider(CaptchaOptions options, ICaptchaService captchaService)
+ {
+ ServiceCollection services = new();
+ services.AddLogging();
+ services.AddSingleton(Options.Create(options));
+ services.AddSingleton(captchaService);
+ services.AddSingleton();
+ return services.BuildServiceProvider();
+ }
+
+ private sealed class StubCaptchaService(Func> verifyAsync) : ICaptchaService
+ {
+ public int CallCount { get; private set; }
+
+ public Task VerifyAsync(string? response, CancellationToken cancellationToken = default)
+ => VerifyAsync(response, remoteIp: null, cancellationToken);
+
+ public async Task VerifyAsync(string? response, string? remoteIp, CancellationToken cancellationToken = default)
+ {
+ CallCount++;
+ return await verifyAsync(response, remoteIp, cancellationToken);
+ }
+ }
+}
diff --git a/EssentialCSharp.Web.Tests/ChatAvailabilityTests.cs b/EssentialCSharp.Web.Tests/ChatAvailabilityTests.cs
index 74c6d919..e3a026d2 100644
--- a/EssentialCSharp.Web.Tests/ChatAvailabilityTests.cs
+++ b/EssentialCSharp.Web.Tests/ChatAvailabilityTests.cs
@@ -7,6 +7,8 @@ namespace EssentialCSharp.Web.Tests;
public class ChatAvailabilityTests : IntegrationTestBase
{
private const string HCaptchaTestToken = "10000000-aaaa-bbbb-cccc-000000000001";
+ private const string InvalidHCaptchaTestToken = "invalid-captcha-test-token";
+ private const string UnavailableHCaptchaTestToken = "unavailable-captcha-test-token";
[Test]
[Arguments("/api/chat/message", "chat-unavailable-message")]
@@ -30,4 +32,34 @@ public async Task ChatEndpoint_WhenBackendUnavailable_Returns503WithContract(str
using JsonDocument payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
await Assert.That(payload.RootElement.GetProperty("errorCode").GetString()).IsEqualTo("chat_unavailable");
}
+
+ [Test]
+ [Arguments("/api/chat/message", "chat-invalid-message", InvalidHCaptchaTestToken, HttpStatusCode.Forbidden, "captcha_failed")]
+ [Arguments("/api/chat/stream", "chat-invalid-stream", InvalidHCaptchaTestToken, HttpStatusCode.Forbidden, "captcha_failed")]
+ [Arguments("/api/chat/message", "chat-captcha-unavailable-message", UnavailableHCaptchaTestToken, HttpStatusCode.ServiceUnavailable, "captcha_unavailable")]
+ [Arguments("/api/chat/stream", "chat-captcha-unavailable-stream", UnavailableHCaptchaTestToken, HttpStatusCode.ServiceUnavailable, "captcha_unavailable")]
+ public async Task ChatEndpoint_WhenCaptchaValidationFails_ReturnsExpectedStatusAndCode(
+ string endpoint,
+ string userNameSeed,
+ string captchaResponse,
+ HttpStatusCode expectedStatus,
+ string expectedErrorCode)
+ {
+ HttpClient client = McpTestHelper.CreateClient(Factory);
+
+ string userId = await McpTestHelper.CreateUserAsync(Factory, userNameSeed);
+ (string cookieName, string cookieValue) = await McpTestHelper.CreateIdentityApplicationCookieAsync(Factory, userId);
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
+ {
+ Content = JsonContent.Create(new { message = "Hello", enableContextualSearch = false, captchaResponse })
+ };
+ McpTestHelper.AddCookie(request, cookieName, cookieValue);
+
+ using HttpResponseMessage response = await client.SendAsync(request);
+ await Assert.That(response.StatusCode).IsEqualTo(expectedStatus);
+
+ using JsonDocument payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+ await Assert.That(payload.RootElement.GetProperty("errorCode").GetString()).IsEqualTo(expectedErrorCode);
+ }
}
diff --git a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs
index 05129e0e..d29750a5 100644
--- a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs
+++ b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs
@@ -179,9 +179,17 @@ private sealed class PassingCaptchaService : ICaptchaService
};
public Task VerifyAsync(string? response, CancellationToken cancellationToken = default) =>
- Task.FromResult(_Success);
+ VerifyAsync(response, remoteIp: null, cancellationToken: cancellationToken);
- public Task VerifyAsync(string? response, string? remoteIp, CancellationToken cancellationToken = default) =>
- Task.FromResult(_Success);
+ public Task VerifyAsync(string? response, string? remoteIp, CancellationToken cancellationToken = default)
+ {
+ if (response == "unavailable-captcha-test-token")
+ return Task.FromResult(null);
+
+ if (response == "invalid-captcha-test-token")
+ return Task.FromResult(new HCaptchaResult { Success = false });
+
+ return Task.FromResult(_Success);
+ }
}
}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs
index ef3c5ba1..5c5af8d2 100644
--- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs
@@ -1,8 +1,7 @@
-using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations;
using System.Text;
using System.Text.Encodings.Web;
using EssentialCSharp.Web.Areas.Identity.Data;
-using EssentialCSharp.Web.Models;
using EssentialCSharp.Web.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI.Services;
@@ -13,7 +12,7 @@
namespace EssentialCSharp.Web.Areas.Identity.Pages.Account;
-public class ForgotPasswordModel(UserManager userManager, IEmailSender emailSender, ICaptchaService captchaService, IOptions optionsAccessor) : PageModel
+public class ForgotPasswordModel(UserManager userManager, IEmailSender emailSender, ICaptchaValidationService captchaValidationService, IOptions optionsAccessor) : PageModel
{
private InputModel? _Input;
[BindProperty]
@@ -33,11 +32,14 @@ public class InputModel
public string? Email { get; set; }
}
- public async Task OnPostAsync()
+ public async Task OnPostAsync(CancellationToken cancellationToken = default)
{
string? captchaToken = Request.Form[CaptchaOptions.HttpPostResponseKeyName];
- HCaptchaResult? captchaResult = await captchaService.VerifyAsync(captchaToken, HttpContext.Connection.RemoteIpAddress?.ToString());
- if (captchaResult?.Success != true)
+ CaptchaValidationResult captchaResult = await captchaValidationService.ValidateAsync(
+ captchaToken,
+ HttpContext.Connection.RemoteIpAddress?.ToString(),
+ cancellationToken);
+ if (!captchaResult.ShouldProceed)
{
ModelState.AddModelError(string.Empty, "Human verification failed. Please try again.");
return Page();
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs
index 16c16154..d537de31 100644
--- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs
@@ -1,6 +1,5 @@
using System.ComponentModel.DataAnnotations;
using EssentialCSharp.Web.Areas.Identity.Data;
-using EssentialCSharp.Web.Models;
using EssentialCSharp.Web.Services;
using EssentialCSharp.Web.Services.Referrals;
using Microsoft.AspNetCore.Authentication;
@@ -11,7 +10,7 @@
namespace EssentialCSharp.Web.Areas.Identity.Pages.Account;
-public partial class LoginModel(SignInManager signInManager, UserManager userManager, ILogger logger, IReferralService referralService, ICaptchaService captchaService, IOptions optionsAccessor) : PageModel
+public partial class LoginModel(SignInManager signInManager, UserManager userManager, ILogger logger, IReferralService referralService, ICaptchaValidationService captchaValidationService, IOptions optionsAccessor) : PageModel
{
private InputModel? _Input;
[BindProperty]
@@ -63,15 +62,16 @@ public async Task OnGetAsync(string? returnUrl = null)
ReturnUrl = returnUrl;
}
- public async Task OnPostAsync(string? returnUrl = null)
+ public async Task OnPostAsync(string? returnUrl = null, CancellationToken cancellationToken = default)
{
returnUrl ??= Url.Content("~/");
string? captchaToken = Request.Form[CaptchaOptions.HttpPostResponseKeyName];
-
- HCaptchaResult? captchaResult = await captchaService.VerifyAsync(captchaToken, HttpContext.Connection.RemoteIpAddress?.ToString());
-
- if (captchaResult?.Success != true)
+ CaptchaValidationResult captchaResult = await captchaValidationService.ValidateAsync(
+ captchaToken,
+ HttpContext.Connection.RemoteIpAddress?.ToString(),
+ cancellationToken);
+ if (!captchaResult.ShouldProceed)
{
ModelState.AddModelError(string.Empty, "Human verification failed. Please try again.");
ExternalLogins = (await signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs
index 5527cd0c..059b34ff 100644
--- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs
@@ -20,7 +20,7 @@ public partial class RegisterModel(
SignInManager signInManager,
ILogger logger,
IEmailSender emailSender,
- ICaptchaService captchaService,
+ ICaptchaValidationService captchaValidationService,
IOptions optionsAccessor,
IUserEmailStore emailStore) : PageModel
{
@@ -78,7 +78,7 @@ public async Task OnGetAsync(string? returnUrl = null)
ExternalLogins = (await signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
}
- public async Task OnPostAsync(string? returnUrl = null)
+ public async Task OnPostAsync(string? returnUrl = null, CancellationToken cancellationToken = default)
{
returnUrl ??= Url.Content("~/");
ExternalLogins = (await signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
@@ -89,142 +89,156 @@ public async Task OnPostAsync(string? returnUrl = null)
return Page();
}
- if (string.IsNullOrEmpty(hCaptcha_response))
+ CaptchaValidationResult captchaResult = await captchaValidationService.ValidateAsync(
+ hCaptcha_response,
+ HttpContext.Connection.RemoteIpAddress?.ToString(),
+ cancellationToken);
+ if (!captchaResult.ShouldProceed)
{
- ModelState.AddModelError(string.Empty, HCaptchaErrorDetails.GetValue(HCaptchaErrorDetails.MissingInputResponse).FriendlyDescription);
+ AddCaptchaValidationError(captchaResult);
return Page();
}
- HCaptchaResult? response = await captchaService.VerifyAsync(hCaptcha_response, HttpContext.Connection.RemoteIpAddress?.ToString());
+ EssentialCSharpWebUser user = CreateUser();
+ user.FirstName = Input.FirstName;
+ user.LastName = Input.LastName;
- if (response is null)
+ await userStore.SetUserNameAsync(user, Input.UserName, cancellationToken);
+ await emailStore.SetEmailAsync(user, Input.Email, cancellationToken);
+ if (Input.Password is null)
{
- ModelState.AddModelError(string.Empty, "Captcha verification is temporarily unavailable. Please try again later.");
+ LogPasswordNull(logger);
+ ModelState.AddModelError(string.Empty, "Error: Password null; please enter in a password");
return Page();
}
+ IdentityResult result = await userManager.CreateAsync(user, Input.Password);
- // The JSON should also return a field "success" as true
- // https://docs.hcaptcha.com/#verify-the-user-response-server-side
- if (response.Success)
+ if (result.Succeeded)
{
- EssentialCSharpWebUser user = CreateUser();
- user.FirstName = Input.FirstName;
- user.LastName = Input.LastName;
-
- await userStore.SetUserNameAsync(user, Input.UserName, CancellationToken.None);
- await emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);
- if (Input.Password is null)
+ LogUserCreatedWithPassword(logger);
+
+ string userId = await userManager.GetUserIdAsync(user);
+ string code = await userManager.GenerateEmailConfirmationTokenAsync(user);
+ code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
+ string? callbackUrl = Url.Page(
+ "/Account/ConfirmEmail",
+ pageHandler: null,
+ values: new { area = "Identity", userId = userId, code = code, returnUrl = returnUrl },
+ protocol: Request.Scheme);
+
+ if (callbackUrl is null)
+ {
+ ModelState.AddModelError(string.Empty, "Error: callback url unexpectedly null.");
+ return Page();
+ }
+ if (Input.Email is null)
{
- LogPasswordNull(logger);
- ModelState.AddModelError(string.Empty, "Error: Password null; please enter in a password");
+ ModelState.AddModelError(string.Empty, "Error: Email may not be null.");
return Page();
}
- IdentityResult result = await userManager.CreateAsync(user, Input.Password);
+ await emailSender.SendEmailAsync(Input.Email, "Confirm your email",
+ $"Please confirm your account by clicking here.");
- if (result.Succeeded)
+ if (userManager.Options.SignIn.RequireConfirmedAccount)
{
- LogUserCreatedWithPassword(logger);
-
- string userId = await userManager.GetUserIdAsync(user);
- string code = await userManager.GenerateEmailConfirmationTokenAsync(user);
- code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
- string? callbackUrl = Url.Page(
- "/Account/ConfirmEmail",
- pageHandler: null,
- values: new { area = "Identity", userId = userId, code = code, returnUrl = returnUrl },
- protocol: Request.Scheme);
-
- if (callbackUrl is null)
- {
- ModelState.AddModelError(string.Empty, "Error: callback url unexpectedly null.");
- return Page();
- }
- if (Input.Email is null)
- {
- ModelState.AddModelError(string.Empty, "Error: Email may not be null.");
- return Page();
- }
- await emailSender.SendEmailAsync(Input.Email, "Confirm your email",
- $"Please confirm your account by clicking here.");
-
- if (userManager.Options.SignIn.RequireConfirmedAccount)
- {
- return RedirectToPage("RegisterConfirmation", new { email = Input.Email, returnUrl = returnUrl });
- }
- else
- {
- await signInManager.SignInAsync(user, isPersistent: false);
- return LocalRedirect(returnUrl);
- }
+ return RedirectToPage("RegisterConfirmation", new { email = Input.Email, returnUrl = returnUrl });
}
- foreach (IdentityError error in result.Errors)
+ else
{
- ModelState.AddModelError(string.Empty, error.Description);
+ await signInManager.SignInAsync(user, isPersistent: false);
+ return LocalRedirect(returnUrl);
}
}
- else
+ foreach (IdentityError error in result.Errors)
{
- switch (response.ErrorCodes?.Length)
- {
- case 0:
- LogHCaptchaNoErrorCodes(logger);
- ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again.");
- break;
- case > 1:
- LogHCaptchaMultipleErrorCodes(logger, string.Join(", ", response.ErrorCodes));
- ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again.");
- break;
- default:
- {
- if (response.ErrorCodes is null)
- {
- LogHCaptchaNullErrorCodes(logger);
- ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again.");
- break;
- }
- if (HCaptchaErrorDetails.TryGetValue(response.ErrorCodes.Single(), out HCaptchaErrorDetails? details))
- {
- switch (details.ErrorCode)
- {
- case HCaptchaErrorDetails.MissingInputResponse:
- case HCaptchaErrorDetails.InvalidInputResponse:
- case HCaptchaErrorDetails.InvalidOrAlreadySeenResponse:
- ModelState.AddModelError(string.Empty, details.FriendlyDescription);
- LogHCaptchaErrorCode(logger, details.ToString());
- break;
- case HCaptchaErrorDetails.BadRequest:
- ModelState.AddModelError(string.Empty, details.FriendlyDescription);
- LogHCaptchaErrorCode(logger, details.ToString());
- break;
- case HCaptchaErrorDetails.MissingInputSecret:
- case HCaptchaErrorDetails.InvalidInputSecret:
- case HCaptchaErrorDetails.NotUsingDummyPasscode:
- case HCaptchaErrorDetails.SitekeySecretMismatch:
- LogHCaptchaCriticalErrorCode(logger, details.ToString());
- ModelState.AddModelError(string.Empty, "Captcha verification is temporarily unavailable. Please try again later.");
- break;
- default:
- LogHCaptchaUnknownErrorCode(logger, details?.ErrorCode);
- ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again.");
- break;
- }
- }
- else
- {
- LogHCaptchaUnrecognizedErrorCode(logger, response.ErrorCodes.Single());
- ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again.");
- }
-
- break;
- }
-
- }
+ ModelState.AddModelError(string.Empty, error.Description);
}
// If we got this far, something failed, redisplay form
return Page();
}
+ private void AddCaptchaValidationError(CaptchaValidationResult captchaResult)
+ {
+ switch (captchaResult.Outcome)
+ {
+ case CaptchaValidationOutcome.Misconfigured:
+ ModelState.AddModelError(string.Empty, "Captcha verification is not configured. Please contact support.");
+ return;
+ case CaptchaValidationOutcome.MissingToken:
+ ModelState.AddModelError(string.Empty, HCaptchaErrorDetails.GetValue(HCaptchaErrorDetails.MissingInputResponse).FriendlyDescription);
+ return;
+ case CaptchaValidationOutcome.Unavailable:
+ ModelState.AddModelError(string.Empty, "Captcha verification is temporarily unavailable. Please try again later.");
+ return;
+ case CaptchaValidationOutcome.Invalid when captchaResult.Response is not null:
+ AddInvalidCaptchaError(captchaResult.Response);
+ return;
+ case CaptchaValidationOutcome.Invalid:
+ LogHCaptchaInvalidOutcomeMissingResponse(logger);
+ ModelState.AddModelError(string.Empty, "Captcha verification is temporarily unavailable. Please try again later.");
+ return;
+ default:
+ LogHCaptchaUnhandledOutcome(logger, captchaResult.Outcome.ToString());
+ ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again.");
+ return;
+ }
+ }
+
+ private void AddInvalidCaptchaError(HCaptchaResult response)
+ {
+ if (response.ErrorCodes is null)
+ {
+ LogHCaptchaNullErrorCodes(logger);
+ ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again.");
+ return;
+ }
+
+ if (response.ErrorCodes.Length == 0)
+ {
+ LogHCaptchaNoErrorCodes(logger);
+ ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again.");
+ return;
+ }
+
+ if (response.ErrorCodes.Length > 1)
+ {
+ LogHCaptchaMultipleErrorCodes(logger, string.Join(", ", response.ErrorCodes));
+ ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again.");
+ return;
+ }
+
+ string errorCode = response.ErrorCodes[0];
+ if (!HCaptchaErrorDetails.TryGetValue(errorCode, out HCaptchaErrorDetails? details) || details is null)
+ {
+ LogHCaptchaUnrecognizedErrorCode(logger, errorCode);
+ ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again.");
+ return;
+ }
+
+ switch (details.ErrorCode)
+ {
+ case HCaptchaErrorDetails.MissingInputResponse:
+ case HCaptchaErrorDetails.InvalidInputResponse:
+ case HCaptchaErrorDetails.InvalidOrAlreadySeenResponse:
+ case HCaptchaErrorDetails.BadRequest:
+ ModelState.AddModelError(string.Empty, details.FriendlyDescription);
+ LogHCaptchaErrorCode(logger, details.ToString());
+ return;
+ case HCaptchaErrorDetails.MissingInputSecret:
+ case HCaptchaErrorDetails.InvalidInputSecret:
+ case HCaptchaErrorDetails.NotUsingDummyPasscode:
+ case HCaptchaErrorDetails.SitekeySecretMismatch:
+ LogHCaptchaCriticalErrorCode(logger, details.ToString());
+ ModelState.AddModelError(string.Empty, "Captcha verification is temporarily unavailable. Please try again later.");
+ return;
+ default:
+ LogHCaptchaUnknownErrorCode(logger, details.ErrorCode);
+ ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again.");
+ return;
+ }
+ }
+
private EssentialCSharpWebUser CreateUser()
{
try
@@ -265,4 +279,10 @@ private EssentialCSharpWebUser CreateUser()
[LoggerMessage(Level = LogLevel.Error, Message = "HCaptcha returned unrecognized error code: {ErrorCode}")]
private static partial void LogHCaptchaUnrecognizedErrorCode(ILogger logger, string errorCode);
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "HCaptcha invalid outcome did not include response payload")]
+ private static partial void LogHCaptchaInvalidOutcomeMissingResponse(ILogger logger);
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "HCaptcha returned unhandled non-proceeding outcome: {Outcome}")]
+ private static partial void LogHCaptchaUnhandledOutcome(ILogger logger, string outcome);
}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml.cs
index e7586fd2..9dba0c1c 100644
--- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml.cs
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml.cs
@@ -1,8 +1,7 @@
-using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations;
using System.Text;
using System.Text.Encodings.Web;
using EssentialCSharp.Web.Areas.Identity.Data;
-using EssentialCSharp.Web.Models;
using EssentialCSharp.Web.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
@@ -15,7 +14,7 @@
namespace EssentialCSharp.Web.Areas.Identity.Pages.Account;
[AllowAnonymous]
-public class ResendEmailConfirmationModel(UserManager userManager, IEmailSender emailSender, ICaptchaService captchaService, IOptions optionsAccessor) : PageModel
+public class ResendEmailConfirmationModel(UserManager userManager, IEmailSender emailSender, ICaptchaValidationService captchaValidationService, IOptions optionsAccessor) : PageModel
{
private InputModel? _Input;
[BindProperty]
@@ -34,11 +33,14 @@ public class InputModel
public string? Email { get; set; }
}
- public async Task OnPostAsync()
+ public async Task OnPostAsync(CancellationToken cancellationToken = default)
{
string? captchaToken = Request.Form[CaptchaOptions.HttpPostResponseKeyName];
- HCaptchaResult? captchaResult = await captchaService.VerifyAsync(captchaToken, HttpContext.Connection.RemoteIpAddress?.ToString());
- if (captchaResult?.Success != true)
+ CaptchaValidationResult captchaResult = await captchaValidationService.ValidateAsync(
+ captchaToken,
+ HttpContext.Connection.RemoteIpAddress?.ToString(),
+ cancellationToken);
+ if (!captchaResult.ShouldProceed)
{
ModelState.AddModelError(string.Empty, "Human verification failed. Please try again.");
return Page();
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs
index a1c85a83..19ebf68e 100644
--- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs
@@ -1,7 +1,6 @@
-using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations;
using System.Text;
using EssentialCSharp.Web.Areas.Identity.Data;
-using EssentialCSharp.Web.Models;
using EssentialCSharp.Web.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
@@ -11,7 +10,7 @@
namespace EssentialCSharp.Web.Areas.Identity.Pages.Account;
-public class ResetPasswordModel(UserManager userManager, ICaptchaService captchaService, IOptions optionsAccessor) : PageModel
+public class ResetPasswordModel(UserManager userManager, ICaptchaValidationService captchaValidationService, IOptions optionsAccessor) : PageModel
{
private InputModel? _Input;
[BindProperty]
@@ -60,11 +59,14 @@ public IActionResult OnGet(string? code = null)
}
}
- public async Task OnPostAsync()
+ public async Task OnPostAsync(CancellationToken cancellationToken = default)
{
string? captchaToken = Request.Form[CaptchaOptions.HttpPostResponseKeyName];
- HCaptchaResult? captchaResult = await captchaService.VerifyAsync(captchaToken, HttpContext.Connection.RemoteIpAddress?.ToString());
- if (captchaResult?.Success != true)
+ CaptchaValidationResult captchaResult = await captchaValidationService.ValidateAsync(
+ captchaToken,
+ HttpContext.Connection.RemoteIpAddress?.ToString(),
+ cancellationToken);
+ if (!captchaResult.ShouldProceed)
{
ModelState.AddModelError(string.Empty, "Human verification failed. Please try again.");
return Page();
diff --git a/EssentialCSharp.Web/Controllers/ChatController.cs b/EssentialCSharp.Web/Controllers/ChatController.cs
index 5a00b93a..79322b08 100644
--- a/EssentialCSharp.Web/Controllers/ChatController.cs
+++ b/EssentialCSharp.Web/Controllers/ChatController.cs
@@ -7,7 +7,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
-using Microsoft.Extensions.Options;
namespace EssentialCSharp.Web.Controllers;
@@ -24,49 +23,19 @@ public partial class ChatController : ControllerBase
private readonly IChatCompletionService _ChatService;
private readonly ResponseIdValidationService _ResponseIdValidationService;
- private readonly ICaptchaService _CaptchaService;
- private readonly CaptchaOptions _CaptchaOptions;
+ private readonly ICaptchaValidationService _CaptchaValidationService;
private readonly ILogger _Logger;
public ChatController(ILogger logger, IChatCompletionService chatService,
ResponseIdValidationService responseIdValidationService,
- ICaptchaService captchaService, IOptions captchaOptions)
+ ICaptchaValidationService captchaValidationService)
{
_ChatService = chatService;
_ResponseIdValidationService = responseIdValidationService;
- _CaptchaService = captchaService;
- _CaptchaOptions = captchaOptions.Value;
+ _CaptchaValidationService = captchaValidationService;
_Logger = logger;
}
- ///
- /// Validates the hCaptcha token when captcha is configured.
- /// Returns true when captcha is not configured (dev mode) or when the token is valid.
- /// Returns false for missing or invalid tokens.
- /// Returns null when hCaptcha cannot be reached, so the caller can fail closed.
- ///
- private async Task IsCaptchaValidAsync(string? token, string? remoteIp, CancellationToken ct)
- {
- if (string.IsNullOrWhiteSpace(_CaptchaOptions.SecretKey))
- return true; // captcha not configured — skip validation
-
- if (string.IsNullOrWhiteSpace(token))
- return false; // token required when captcha is configured — reject without an outbound call
-
- HCaptchaResult? result = await _CaptchaService.VerifyAsync(token, remoteIp, ct);
- if (result is null)
- {
- LogCaptchaServiceUnavailable(_Logger); // hCaptcha unreachable — fail closed
- return null;
- }
-
- if (!result.Success)
- {
- LogCaptchaValidationFailed(_Logger, string.Join(',', result.ErrorCodes ?? []));
- }
- return result.Success;
- }
-
[HttpPost("message")]
public async Task SendMessage([FromBody] ChatMessageRequest request, CancellationToken cancellationToken = default)
{
@@ -78,11 +47,11 @@ public async Task SendMessage([FromBody] ChatMessageRequest reque
if (string.IsNullOrEmpty(userId))
return Unauthorized();
- bool? captchaValid = await IsCaptchaValidAsync(request.CaptchaResponse, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken);
- if (captchaValid is null)
- return StatusCode(503, new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" });
- if (!captchaValid.Value)
- return StatusCode(403, new { error = "Human verification required.", errorCode = "captcha_failed" });
+ CaptchaValidationResult captchaValidation = await _CaptchaValidationService.ValidateAsync(request.CaptchaResponse, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken);
+ if (!captchaValidation.ShouldProceed)
+ {
+ return HandleCaptchaValidationFailure(captchaValidation);
+ }
var previousResponseId = string.IsNullOrWhiteSpace(request.PreviousResponseId)
? null
@@ -152,17 +121,10 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat
return;
}
- bool? captchaValid = await IsCaptchaValidAsync(request.CaptchaResponse, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken);
- if (captchaValid is null)
+ CaptchaValidationResult captchaValidation = await _CaptchaValidationService.ValidateAsync(request.CaptchaResponse, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken);
+ if (!captchaValidation.ShouldProceed)
{
- Response.StatusCode = 503;
- await Response.WriteAsJsonAsync(new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" }, cancellationToken);
- return;
- }
- if (!captchaValid.Value)
- {
- Response.StatusCode = 403;
- await Response.WriteAsJsonAsync(new { error = "Human verification required.", errorCode = "captcha_failed" }, cancellationToken);
+ await HandleCaptchaValidationFailureAsync(captchaValidation, cancellationToken);
return;
}
@@ -382,6 +344,64 @@ await Response.WriteAsJsonAsync(new
}
}
+ private IActionResult HandleCaptchaValidationFailure(CaptchaValidationResult captchaValidation)
+ {
+ return captchaValidation.Outcome switch
+ {
+ CaptchaValidationOutcome.Misconfigured or CaptchaValidationOutcome.Unavailable => LogAndReturn503(),
+ CaptchaValidationOutcome.Invalid => LogInvalidAndReturn403(),
+ _ => StatusCode(403, new { error = "Human verification required.", errorCode = "captcha_failed" })
+ };
+
+ IActionResult LogAndReturn503()
+ {
+ LogCaptchaUnavailable(captchaValidation.Outcome);
+ return StatusCode(503, CaptchaUnavailableResponseBody);
+ }
+
+ IActionResult LogInvalidAndReturn403()
+ {
+ LogCaptchaValidationFailed(_Logger, string.Join(',', captchaValidation.Response?.ErrorCodes ?? []));
+ return StatusCode(403, new { error = "Human verification required.", errorCode = "captcha_failed" });
+ }
+ }
+
+ private async Task HandleCaptchaValidationFailureAsync(CaptchaValidationResult captchaValidation, CancellationToken cancellationToken)
+ {
+ switch (captchaValidation.Outcome)
+ {
+ case CaptchaValidationOutcome.Misconfigured:
+ case CaptchaValidationOutcome.Unavailable:
+ LogCaptchaUnavailable(captchaValidation.Outcome);
+ Response.StatusCode = 503;
+ await Response.WriteAsJsonAsync(CaptchaUnavailableResponseBody, cancellationToken);
+ break;
+
+ case CaptchaValidationOutcome.Invalid:
+ LogCaptchaValidationFailed(_Logger, string.Join(',', captchaValidation.Response?.ErrorCodes ?? []));
+ Response.StatusCode = 403;
+ await Response.WriteAsJsonAsync(new { error = "Human verification required.", errorCode = "captcha_failed" }, cancellationToken);
+ break;
+
+ default:
+ Response.StatusCode = 403;
+ await Response.WriteAsJsonAsync(new { error = "Human verification required.", errorCode = "captcha_failed" }, cancellationToken);
+ break;
+ }
+ }
+
+ private static readonly object CaptchaUnavailableResponseBody = new
+ {
+ error = "Human verification is temporarily unavailable. Please try again later.",
+ errorCode = "captcha_unavailable"
+ };
+
+ private void LogCaptchaUnavailable(CaptchaValidationOutcome outcome)
+ {
+ if (outcome == CaptchaValidationOutcome.Unavailable)
+ LogCaptchaServiceUnavailable(_Logger);
+ }
+
[LoggerMessage(Level = LogLevel.Warning, Message = "hCaptcha service unavailable during chat request — failing closed (503)")]
private static partial void LogCaptchaServiceUnavailable(ILogger logger);
diff --git a/EssentialCSharp.Web/Extensions/IServiceCollectionExtensions.cs b/EssentialCSharp.Web/Extensions/IServiceCollectionExtensions.cs
index 1c3806ca..3bdf24ec 100644
--- a/EssentialCSharp.Web/Extensions/IServiceCollectionExtensions.cs
+++ b/EssentialCSharp.Web/Extensions/IServiceCollectionExtensions.cs
@@ -10,6 +10,7 @@ public static void AddCaptchaService(this IServiceCollection services, IConfigur
{
services.Configure(CaptchaOptions);
services.AddSingleton();
+ services.AddSingleton();
services.AddHttpClient("hCaptcha", c =>
{
c.BaseAddress = new Uri("https://api.hcaptcha.com");
diff --git a/EssentialCSharp.Web/Services/CaptchaService.cs b/EssentialCSharp.Web/Services/CaptchaService.cs
index a4a1fbee..4b2012d3 100644
--- a/EssentialCSharp.Web/Services/CaptchaService.cs
+++ b/EssentialCSharp.Web/Services/CaptchaService.cs
@@ -66,12 +66,16 @@ private static List> BuildPostData(string secret, s
// hCaptcha siteverify requires URL-encoded POST; base URL is set in IHttpClientFactory registration
using FormUrlEncodedContent content = new(postData);
- HttpResponseMessage res = await client.PostAsync("/siteverify", content, cancellationToken);
+ using HttpResponseMessage res = await client.PostAsync("/siteverify", content, cancellationToken);
res.EnsureSuccessStatusCode();
return JsonSerializer.Deserialize(await res.Content.ReadAsStringAsync(cancellationToken));
}
- catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException)
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException or JsonException)
{
LogSiteverifyFailed(logger, ex);
return null;
diff --git a/EssentialCSharp.Web/Services/CaptchaValidationOutcome.cs b/EssentialCSharp.Web/Services/CaptchaValidationOutcome.cs
new file mode 100644
index 00000000..92b0bc3e
--- /dev/null
+++ b/EssentialCSharp.Web/Services/CaptchaValidationOutcome.cs
@@ -0,0 +1,10 @@
+namespace EssentialCSharp.Web.Services;
+
+public enum CaptchaValidationOutcome
+{
+ Misconfigured,
+ MissingToken,
+ Unavailable,
+ Invalid,
+ Valid
+}
diff --git a/EssentialCSharp.Web/Services/CaptchaValidationResult.cs b/EssentialCSharp.Web/Services/CaptchaValidationResult.cs
new file mode 100644
index 00000000..915fb698
--- /dev/null
+++ b/EssentialCSharp.Web/Services/CaptchaValidationResult.cs
@@ -0,0 +1,32 @@
+using EssentialCSharp.Web.Models;
+
+namespace EssentialCSharp.Web.Services;
+
+public sealed class CaptchaValidationResult
+{
+ private CaptchaValidationResult(CaptchaValidationOutcome outcome, HCaptchaResult? response)
+ {
+ Outcome = outcome;
+ Response = response;
+ }
+
+ public CaptchaValidationOutcome Outcome { get; }
+
+ public HCaptchaResult? Response { get; }
+
+ public bool ShouldProceed => Outcome is CaptchaValidationOutcome.Valid;
+
+ public static CaptchaValidationResult Misconfigured() => new(CaptchaValidationOutcome.Misconfigured, null);
+
+ public static CaptchaValidationResult MissingToken() => new(CaptchaValidationOutcome.MissingToken, null);
+
+ public static CaptchaValidationResult Unavailable() => new(CaptchaValidationOutcome.Unavailable, null);
+
+ public static CaptchaValidationResult FromResponse(HCaptchaResult response)
+ {
+ ArgumentNullException.ThrowIfNull(response);
+ return new(
+ response.Success ? CaptchaValidationOutcome.Valid : CaptchaValidationOutcome.Invalid,
+ response);
+ }
+}
diff --git a/EssentialCSharp.Web/Services/CaptchaValidationService.cs b/EssentialCSharp.Web/Services/CaptchaValidationService.cs
new file mode 100644
index 00000000..473761fe
--- /dev/null
+++ b/EssentialCSharp.Web/Services/CaptchaValidationService.cs
@@ -0,0 +1,52 @@
+using EssentialCSharp.Web.Models;
+using Microsoft.Extensions.Options;
+
+namespace EssentialCSharp.Web.Services;
+
+public sealed partial class CaptchaValidationService(
+ ICaptchaService captchaService,
+ IOptions optionsAccessor,
+ ILogger logger) : ICaptchaValidationService
+{
+ private CaptchaOptions Options { get; } = optionsAccessor.Value;
+ private bool IsCaptchaConfigurationComplete =>
+ !string.IsNullOrWhiteSpace(Options.SecretKey) &&
+ !string.IsNullOrWhiteSpace(Options.SiteKey);
+ private int _configurationWarningLogged;
+
+ public Task ValidateAsync(string? response, CancellationToken cancellationToken = default)
+ => ValidateAsync(response, remoteIp: null, cancellationToken);
+
+ ///
+ /// Validates a captcha response.
+ ///
+ ///
+ /// IMPORTANT: Both and must be configured
+ /// (non-empty) for captcha validation to be enabled. If either key is missing, the validation is marked as
+ /// and the captcha service is not invoked. This is intentional:
+ /// HCaptcha requires both keys to function properly, and partial configuration (one key set, one missing)
+ /// indicates a deployment configuration error that should be detected early.
+ ///
+ public async Task ValidateAsync(string? response, string? remoteIp, CancellationToken cancellationToken = default)
+ {
+ if (!IsCaptchaConfigurationComplete)
+ {
+ if (Interlocked.Exchange(ref _configurationWarningLogged, 1) == 0)
+ LogConfigurationMissing(logger);
+
+ return CaptchaValidationResult.Misconfigured();
+ }
+
+ if (string.IsNullOrWhiteSpace(response))
+ return CaptchaValidationResult.MissingToken();
+
+ HCaptchaResult? result = await captchaService.VerifyAsync(response, remoteIp, cancellationToken);
+ if (result is null)
+ return CaptchaValidationResult.Unavailable();
+
+ return CaptchaValidationResult.FromResponse(result);
+ }
+
+ [LoggerMessage(Level = LogLevel.Warning, Message = "hCaptcha configuration is incomplete; validation is failing closed")]
+ private static partial void LogConfigurationMissing(ILogger logger);
+}
diff --git a/EssentialCSharp.Web/Services/ICaptchaValidationService.cs b/EssentialCSharp.Web/Services/ICaptchaValidationService.cs
new file mode 100644
index 00000000..4b391a16
--- /dev/null
+++ b/EssentialCSharp.Web/Services/ICaptchaValidationService.cs
@@ -0,0 +1,7 @@
+namespace EssentialCSharp.Web.Services;
+
+public interface ICaptchaValidationService
+{
+ Task ValidateAsync(string? response, CancellationToken cancellationToken = default);
+ Task ValidateAsync(string? response, string? remoteIp, CancellationToken cancellationToken = default);
+}