From dab18ce08c2981b6ea7bd05a20e8e71fd8adadc7 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sun, 17 May 2026 00:19:12 -0700 Subject: [PATCH 1/3] Share captcha validation policy --- .../CaptchaValidationServiceTests.cs | 121 ++++++++++ .../Pages/Account/ForgotPassword.cshtml.cs | 9 +- .../Identity/Pages/Account/Login.cshtml.cs | 9 +- .../Identity/Pages/Account/Register.cshtml.cs | 227 +++++++++--------- .../Account/ResendEmailConfirmation.cshtml.cs | 9 +- .../Pages/Account/ResetPassword.cshtml.cs | 9 +- .../Controllers/ChatController.cs | 79 +++--- .../IServiceCollectionExtensions.cs | 1 + .../Services/CaptchaValidationOutcome.cs | 10 + .../Services/CaptchaValidationResult.cs | 8 + .../Services/CaptchaValidationService.cs | 29 +++ .../Services/ICaptchaValidationService.cs | 7 + 12 files changed, 341 insertions(+), 177 deletions(-) create mode 100644 EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs create mode 100644 EssentialCSharp.Web/Services/CaptchaValidationOutcome.cs create mode 100644 EssentialCSharp.Web/Services/CaptchaValidationResult.cs create mode 100644 EssentialCSharp.Web/Services/CaptchaValidationService.cs create mode 100644 EssentialCSharp.Web/Services/ICaptchaValidationService.cs diff --git a/EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs b/EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs new file mode 100644 index 00000000..d31e2f13 --- /dev/null +++ b/EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs @@ -0,0 +1,121 @@ +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_Disabled_SkipsVerification() + { + 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.Disabled); + await Assert.That(result.ShouldProceed).IsTrue(); + 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_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.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 secret, string response, string sitekey, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + 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/Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs index ef3c5ba1..7e566037 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] @@ -36,8 +35,8 @@ public class InputModel public async Task OnPostAsync() { 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()); + 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 998ee7cb..c0c564c2 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 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] @@ -68,8 +67,8 @@ public async Task OnPostAsync(string? returnUrl = null) 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()); + 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 a29d2254..a10be034 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 { @@ -89,135 +89,140 @@ 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()); + if (!captchaResult.ShouldProceed) { - ModelState.AddModelError(string.Empty, HCaptchaErrorDetails.GetValue(HCaptchaErrorDetails.MissingInputResponse).FriendlyDescription); - return Page(); - } - - HCaptchaResult? response = await captchaService.VerifyAsync(hCaptcha_response, HttpContext.Connection.RemoteIpAddress?.ToString()); - if (response is null) - { - ModelState.AddModelError(string.Empty, "Captcha verification is temporarily unavailable. Please try again later."); - return Page(); - } - - // The JSON should also return a field "success" as true - // https://docs.hcaptcha.com/#verify-the-user-response-server-side - if (response.Success) - { - 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) + if (captchaResult.Outcome == CaptchaValidationOutcome.MissingToken) { - LogPasswordNull(logger); - ModelState.AddModelError(string.Empty, "Error: Password null; please enter in a password"); + ModelState.AddModelError(string.Empty, HCaptchaErrorDetails.GetValue(HCaptchaErrorDetails.MissingInputResponse).FriendlyDescription); return Page(); } - IdentityResult result = await userManager.CreateAsync(user, Input.Password); - - if (result.Succeeded) + if (captchaResult.Outcome == CaptchaValidationOutcome.Unavailable) { - 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, "Captcha verification is temporarily unavailable. Please try again later."); + return Page(); + } + if (captchaResult.Outcome == CaptchaValidationOutcome.Invalid) + { + HCaptchaResult? response = captchaResult.Response; + // The JSON should also return a field "success" as true + // https://docs.hcaptcha.com/#verify-the-user-response-server-side + if (response is null) { - ModelState.AddModelError(string.Empty, "Error: Email may not be null."); + LogHCaptchaNullErrorCodes(logger); + ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); 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 + switch (response.ErrorCodes?.Length) { - await signInManager.SignInAsync(user, isPersistent: false); - return LocalRedirect(returnUrl); - } - } - foreach (IdentityError error in result.Errors) - { - ModelState.AddModelError(string.Empty, error.Description); - } - } - else - { - 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)) + case 0: + LogHCaptchaNoErrorCodes(logger); + ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); + return Page(); + case > 1: + LogHCaptchaMultipleErrorCodes(logger, string.Join(", ", response.ErrorCodes)); + ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); + return Page(); + default: { - switch (details.ErrorCode) + if (response.ErrorCodes is null) { - 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; + LogHCaptchaNullErrorCodes(logger); + ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); + return Page(); } - } - else - { + 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()); + return Page(); + case HCaptchaErrorDetails.BadRequest: + ModelState.AddModelError(string.Empty, details.FriendlyDescription); + LogHCaptchaErrorCode(logger, details.ToString()); + return Page(); + 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 Page(); + default: + LogHCaptchaUnknownErrorCode(logger, details?.ErrorCode); + ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); + return Page(); + } + } + LogHCaptchaUnrecognizedErrorCode(logger, response.ErrorCodes.Single()); ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); + return Page(); } + } + } + + return Page(); + } - break; - } + 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) + { + LogPasswordNull(logger); + ModelState.AddModelError(string.Empty, "Error: Password null; please enter in a password"); + return Page(); + } + IdentityResult result = await userManager.CreateAsync(user, Input.Password); + + if (result.Succeeded) + { + 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); + } + } + foreach (IdentityError error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); } // If we got this far, something failed, redisplay form diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml.cs index e7586fd2..12650f49 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] @@ -37,8 +36,8 @@ public class InputModel public async Task OnPostAsync() { 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()); + 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..0149fada 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] @@ -63,8 +62,8 @@ public IActionResult OnGet(string? code = null) public async Task OnPostAsync() { 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()); + 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 08f0d29a..77dede75 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; @@ -20,49 +19,19 @@ public partial class ChatController : ControllerBase { private readonly AIChatService _AIChatService; private readonly ResponseIdValidationService _ResponseIdValidationService; - private readonly ICaptchaService _CaptchaService; - private readonly CaptchaOptions _CaptchaOptions; + private readonly ICaptchaValidationService _CaptchaValidationService; private readonly ILogger _Logger; public ChatController(ILogger logger, AIChatService aiChatService, ResponseIdValidationService responseIdValidationService, - ICaptchaService captchaService, IOptions captchaOptions) + ICaptchaValidationService captchaValidationService) { _AIChatService = aiChatService; _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) { @@ -74,11 +43,22 @@ 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) + CaptchaValidationResult captchaValidation = await _CaptchaValidationService.ValidateAsync(request.CaptchaResponse, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken); + if (!captchaValidation.ShouldProceed) + { + if (captchaValidation.Outcome == CaptchaValidationOutcome.Unavailable) + { + LogCaptchaServiceUnavailable(_Logger); + return StatusCode(503, new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" }); + } + + if (captchaValidation.Outcome == CaptchaValidationOutcome.Invalid) + { + LogCaptchaValidationFailed(_Logger, string.Join(',', captchaValidation.Response?.ErrorCodes ?? [])); + } + return StatusCode(403, new { error = "Human verification required.", errorCode = "captcha_failed" }); + } var previousResponseId = string.IsNullOrWhiteSpace(request.PreviousResponseId) ? null @@ -130,15 +110,22 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat return; } - bool? captchaValid = await IsCaptchaValidAsync(request.CaptchaResponse, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken); - if (captchaValid is null) - { - 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) + CaptchaValidationResult captchaValidation = await _CaptchaValidationService.ValidateAsync(request.CaptchaResponse, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken); + if (!captchaValidation.ShouldProceed) { + if (captchaValidation.Outcome == CaptchaValidationOutcome.Unavailable) + { + LogCaptchaServiceUnavailable(_Logger); + Response.StatusCode = 503; + await Response.WriteAsJsonAsync(new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" }, cancellationToken); + return; + } + + if (captchaValidation.Outcome == CaptchaValidationOutcome.Invalid) + { + LogCaptchaValidationFailed(_Logger, string.Join(',', captchaValidation.Response?.ErrorCodes ?? [])); + } + Response.StatusCode = 403; await Response.WriteAsJsonAsync(new { error = "Human verification required.", errorCode = "captcha_failed" }, cancellationToken); return; diff --git a/EssentialCSharp.Web/Extensions/IServiceCollectionExtensions.cs b/EssentialCSharp.Web/Extensions/IServiceCollectionExtensions.cs index 74967040..00500e96 100644 --- a/EssentialCSharp.Web/Extensions/IServiceCollectionExtensions.cs +++ b/EssentialCSharp.Web/Extensions/IServiceCollectionExtensions.cs @@ -11,6 +11,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/CaptchaValidationOutcome.cs b/EssentialCSharp.Web/Services/CaptchaValidationOutcome.cs new file mode 100644 index 00000000..80bbabff --- /dev/null +++ b/EssentialCSharp.Web/Services/CaptchaValidationOutcome.cs @@ -0,0 +1,10 @@ +namespace EssentialCSharp.Web.Services; + +public enum CaptchaValidationOutcome +{ + Disabled, + MissingToken, + Unavailable, + Invalid, + Valid +} diff --git a/EssentialCSharp.Web/Services/CaptchaValidationResult.cs b/EssentialCSharp.Web/Services/CaptchaValidationResult.cs new file mode 100644 index 00000000..f891a8a9 --- /dev/null +++ b/EssentialCSharp.Web/Services/CaptchaValidationResult.cs @@ -0,0 +1,8 @@ +using EssentialCSharp.Web.Models; + +namespace EssentialCSharp.Web.Services; + +public sealed record CaptchaValidationResult(CaptchaValidationOutcome Outcome, HCaptchaResult? Response) +{ + public bool ShouldProceed => Outcome is CaptchaValidationOutcome.Disabled or CaptchaValidationOutcome.Valid; +} diff --git a/EssentialCSharp.Web/Services/CaptchaValidationService.cs b/EssentialCSharp.Web/Services/CaptchaValidationService.cs new file mode 100644 index 00000000..81f26549 --- /dev/null +++ b/EssentialCSharp.Web/Services/CaptchaValidationService.cs @@ -0,0 +1,29 @@ +using EssentialCSharp.Web.Models; +using Microsoft.Extensions.Options; + +namespace EssentialCSharp.Web.Services; + +public sealed class CaptchaValidationService(ICaptchaService captchaService, IOptions optionsAccessor) : ICaptchaValidationService +{ + private CaptchaOptions Options { get; } = optionsAccessor.Value; + + public Task ValidateAsync(string? response, CancellationToken cancellationToken = default) + => ValidateAsync(response, remoteIp: null, cancellationToken); + + public async Task ValidateAsync(string? response, string? remoteIp, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(Options.SecretKey) || string.IsNullOrWhiteSpace(Options.SiteKey)) + return new CaptchaValidationResult(CaptchaValidationOutcome.Disabled, null); + + if (string.IsNullOrWhiteSpace(response)) + return new CaptchaValidationResult(CaptchaValidationOutcome.MissingToken, null); + + HCaptchaResult? result = await captchaService.VerifyAsync(response, remoteIp, cancellationToken); + if (result is null) + return new CaptchaValidationResult(CaptchaValidationOutcome.Unavailable, null); + + return new CaptchaValidationResult( + result.Success ? CaptchaValidationOutcome.Valid : CaptchaValidationOutcome.Invalid, + result); + } +} 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); +} From 23b2ebac5b01770bddc7a058a20b49b0aecd82f6 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sun, 17 May 2026 00:31:03 -0700 Subject: [PATCH 2/3] Fail closed on missing captcha config --- EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs | 4 ++-- EssentialCSharp.Web/Services/CaptchaValidationResult.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs b/EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs index d31e2f13..3e0d960a 100644 --- a/EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs +++ b/EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs @@ -8,7 +8,7 @@ namespace EssentialCSharp.Web.Tests; public class CaptchaValidationServiceTests { [Test] - public async Task ValidateAsync_Disabled_SkipsVerification() + public async Task ValidateAsync_MissingConfig_RejectsWithoutVerification() { StubCaptchaService captchaService = new((_, _, _) => throw new InvalidOperationException("Verifier should not be called.")); using ServiceProvider serviceProvider = CreateServiceProvider( @@ -20,7 +20,7 @@ public async Task ValidateAsync_Disabled_SkipsVerification() CaptchaValidationResult result = await validationService.ValidateAsync("token", "127.0.0.1"); await Assert.That(result.Outcome).IsEqualTo(CaptchaValidationOutcome.Disabled); - await Assert.That(result.ShouldProceed).IsTrue(); + await Assert.That(result.ShouldProceed).IsFalse(); await Assert.That(captchaService.CallCount).IsEqualTo(0); } diff --git a/EssentialCSharp.Web/Services/CaptchaValidationResult.cs b/EssentialCSharp.Web/Services/CaptchaValidationResult.cs index f891a8a9..c0613c3b 100644 --- a/EssentialCSharp.Web/Services/CaptchaValidationResult.cs +++ b/EssentialCSharp.Web/Services/CaptchaValidationResult.cs @@ -4,5 +4,5 @@ namespace EssentialCSharp.Web.Services; public sealed record CaptchaValidationResult(CaptchaValidationOutcome Outcome, HCaptchaResult? Response) { - public bool ShouldProceed => Outcome is CaptchaValidationOutcome.Disabled or CaptchaValidationOutcome.Valid; + public bool ShouldProceed => Outcome is CaptchaValidationOutcome.Valid; } From d360c484a11c643a7011bd0a1ea5836468da580f Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sun, 17 May 2026 22:22:17 -0700 Subject: [PATCH 3/3] Harden captcha failure handling --- .../Identity/Pages/Account/Register.cshtml.cs | 1 + .../Controllers/ChatController.cs | 17 +++++++++++++++++ EssentialCSharp.Web/Services/CaptchaService.cs | 4 ++++ 3 files changed, 22 insertions(+) diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs index a10be034..b10ae4dd 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs @@ -167,6 +167,7 @@ public async Task OnPostAsync(string? returnUrl = null) } } + ModelState.AddModelError(string.Empty, "Captcha verification is temporarily unavailable. Please try again later."); return Page(); } diff --git a/EssentialCSharp.Web/Controllers/ChatController.cs b/EssentialCSharp.Web/Controllers/ChatController.cs index 77dede75..ff12bc6d 100644 --- a/EssentialCSharp.Web/Controllers/ChatController.cs +++ b/EssentialCSharp.Web/Controllers/ChatController.cs @@ -46,6 +46,12 @@ public async Task SendMessage([FromBody] ChatMessageRequest reque CaptchaValidationResult captchaValidation = await _CaptchaValidationService.ValidateAsync(request.CaptchaResponse, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken); if (!captchaValidation.ShouldProceed) { + if (captchaValidation.Outcome == CaptchaValidationOutcome.Disabled) + { + LogCaptchaConfigurationMissing(_Logger); + return StatusCode(503, new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" }); + } + if (captchaValidation.Outcome == CaptchaValidationOutcome.Unavailable) { LogCaptchaServiceUnavailable(_Logger); @@ -113,6 +119,14 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat CaptchaValidationResult captchaValidation = await _CaptchaValidationService.ValidateAsync(request.CaptchaResponse, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken); if (!captchaValidation.ShouldProceed) { + if (captchaValidation.Outcome == CaptchaValidationOutcome.Disabled) + { + LogCaptchaConfigurationMissing(_Logger); + Response.StatusCode = 503; + await Response.WriteAsJsonAsync(new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" }, cancellationToken); + return; + } + if (captchaValidation.Outcome == CaptchaValidationOutcome.Unavailable) { LogCaptchaServiceUnavailable(_Logger); @@ -280,6 +294,9 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat [LoggerMessage(Level = LogLevel.Warning, Message = "hCaptcha service unavailable during chat request — failing closed (503)")] private static partial void LogCaptchaServiceUnavailable(ILogger logger); + [LoggerMessage(Level = LogLevel.Warning, Message = "hCaptcha configuration missing during chat request — failing closed (503)")] + private static partial void LogCaptchaConfigurationMissing(ILogger logger); + [LoggerMessage(Level = LogLevel.Warning, Message = "hCaptcha validation failed for chat request — error codes: {ErrorCodes}")] private static partial void LogCaptchaValidationFailed(ILogger logger, string errorCodes); diff --git a/EssentialCSharp.Web/Services/CaptchaService.cs b/EssentialCSharp.Web/Services/CaptchaService.cs index 48d53b05..1e76f767 100644 --- a/EssentialCSharp.Web/Services/CaptchaService.cs +++ b/EssentialCSharp.Web/Services/CaptchaService.cs @@ -74,6 +74,10 @@ public partial class CaptchaService(IHttpClientFactory clientFactory, IOptions(await res.Content.ReadAsStringAsync(cancellationToken)); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException) { LogSiteverifyFailed(logger, ex);