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); +}