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)