diff --git a/Directory.Packages.props b/Directory.Packages.props index cf6081db..40137857 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -62,5 +62,6 @@ + diff --git a/EssentialCSharp.Chat.Shared/EssentialCSharp.Chat.Common.csproj b/EssentialCSharp.Chat.Shared/EssentialCSharp.Chat.Common.csproj index 1272ea00..08724ed3 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 9692e002..0113f6ad 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..244d819b --- /dev/null +++ b/EssentialCSharp.Web.Tests/CaptchaValidationServiceTests.cs @@ -0,0 +1,118 @@ +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.Disabled); + 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_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? 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 16c16154..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 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,10 +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 5527cd0c..56c279be 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,137 +89,145 @@ 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.Disabled) { - LogPasswordNull(logger); - ModelState.AddModelError(string.Empty, "Error: Password null; please enter in a password"); + LogHCaptchaDisabledWarning(logger); + ModelState.AddModelError(string.Empty, "Captcha verification is not configured. Please contact support."); return Page(); } - IdentityResult result = await userManager.CreateAsync(user, Input.Password); - - if (result.Succeeded) + if (captchaResult.Outcome == CaptchaValidationOutcome.MissingToken) { - 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); - } + ModelState.AddModelError(string.Empty, HCaptchaErrorDetails.GetValue(HCaptchaErrorDetails.MissingInputResponse).FriendlyDescription); + return Page(); } - foreach (IdentityError error in result.Errors) + if (captchaResult.Outcome == CaptchaValidationOutcome.Unavailable) { - ModelState.AddModelError(string.Empty, error.Description); + ModelState.AddModelError(string.Empty, "Captcha verification is temporarily unavailable. Please try again later."); + return Page(); } - } - else - { - switch (response.ErrorCodes?.Length) + if (captchaResult.Outcome == CaptchaValidationOutcome.Invalid) { - 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)) + HCaptchaResult? response = captchaResult.Response; + ArgumentNullException.ThrowIfNull(response); + + switch (response.ErrorCodes?.Length) + { + 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) + { + LogHCaptchaNullErrorCodes(logger); + ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); + return Page(); + } + if (HCaptchaErrorDetails.TryGetValue(response.ErrorCodes.Single(), out HCaptchaErrorDetails? details)) { - 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); + if (details is null) + { + LogHCaptchaNullErrorCodes(logger); ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); - break; + return Page(); + } + + 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(); + } } - } - else - { + LogHCaptchaUnrecognizedErrorCode(logger, response.ErrorCodes.Single()); ModelState.AddModelError(string.Empty, "Captcha verification failed. Please try again."); + 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 return Page(); @@ -245,6 +253,9 @@ private EssentialCSharpWebUser CreateUser() [LoggerMessage(Level = LogLevel.Information, Message = "User created a new account with password.")] private static partial void LogUserCreatedWithPassword(ILogger logger); + [LoggerMessage(Level = LogLevel.Warning, Message = "Captcha is disabled in configuration")] + private static partial void LogHCaptchaDisabledWarning(ILogger logger); + [LoggerMessage(Level = LogLevel.Error, Message = "HCaptcha determined the passcode is not valid with zero error codes")] private static partial void LogHCaptchaNoErrorCodes(ILogger logger); 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 7cfcb636..e420a4dd 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 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) { @@ -74,11 +43,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 @@ -140,17 +109,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; } @@ -336,9 +298,67 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat } } + private IActionResult HandleCaptchaValidationFailure(CaptchaValidationResult captchaValidation) + { + return captchaValidation.Outcome switch + { + CaptchaValidationOutcome.Disabled => LogAndReturn503(), + CaptchaValidationOutcome.Unavailable => LogAndReturn503(), + CaptchaValidationOutcome.Invalid => LogInvalidAndReturn403(), + _ => StatusCode(403, new { error = "Human verification required.", errorCode = "captcha_failed" }) + }; + + IActionResult LogAndReturn503() + { + if (captchaValidation.Outcome == CaptchaValidationOutcome.Disabled) + LogCaptchaConfigurationMissing(_Logger); + else + LogCaptchaServiceUnavailable(_Logger); + + return StatusCode(503, new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" }); + } + + 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.Disabled: + case CaptchaValidationOutcome.Unavailable: + if (captchaValidation.Outcome == CaptchaValidationOutcome.Disabled) + LogCaptchaConfigurationMissing(_Logger); + else + LogCaptchaServiceUnavailable(_Logger); + + Response.StatusCode = 503; + await Response.WriteAsJsonAsync(new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" }, 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; + } + } + [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/Extensions/IServiceCollectionExtensions.cs b/EssentialCSharp.Web/Extensions/IServiceCollectionExtensions.cs index fecb7c4e..6be96988 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 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); 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..c0613c3b --- /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.Valid; +} diff --git a/EssentialCSharp.Web/Services/CaptchaValidationService.cs b/EssentialCSharp.Web/Services/CaptchaValidationService.cs new file mode 100644 index 00000000..acd68171 --- /dev/null +++ b/EssentialCSharp.Web/Services/CaptchaValidationService.cs @@ -0,0 +1,39 @@ +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); + + /// + /// 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 (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); +} diff --git a/test_output.txt b/test_output.txt new file mode 100644 index 00000000..e06b42e7 --- /dev/null +++ b/test_output.txt @@ -0,0 +1 @@ +Running tests from D:\copilot-worktrees\EssentialCSharp.Web\benjaminmichaelis-super-giggle\EssentialCSharp.Web.Tests\bin\Release\net10.0\EssentialCSharp.Web.Tests.dll (net10.0|x64)