diff --git a/EssentialCSharp.Web/Controllers/ChatController.cs b/EssentialCSharp.Web/Controllers/ChatController.cs index 66a2866c..08f0d29a 100644 --- a/EssentialCSharp.Web/Controllers/ChatController.cs +++ b/EssentialCSharp.Web/Controllers/ChatController.cs @@ -2,10 +2,12 @@ using System.Security.Claims; using System.Text.Json; using EssentialCSharp.Chat.Common.Services; +using EssentialCSharp.Web.Models; using EssentialCSharp.Web.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Options; namespace EssentialCSharp.Web.Controllers; @@ -18,15 +20,49 @@ public partial class ChatController : ControllerBase { private readonly AIChatService _AIChatService; private readonly ResponseIdValidationService _ResponseIdValidationService; + private readonly ICaptchaService _CaptchaService; + private readonly CaptchaOptions _CaptchaOptions; private readonly ILogger _Logger; - public ChatController(ILogger logger, AIChatService aiChatService, ResponseIdValidationService responseIdValidationService) + public ChatController(ILogger logger, AIChatService aiChatService, + ResponseIdValidationService responseIdValidationService, + ICaptchaService captchaService, IOptions captchaOptions) { _AIChatService = aiChatService; _ResponseIdValidationService = responseIdValidationService; + _CaptchaService = captchaService; + _CaptchaOptions = captchaOptions.Value; _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) { @@ -38,6 +74,12 @@ 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" }); + var previousResponseId = string.IsNullOrWhiteSpace(request.PreviousResponseId) ? null : request.PreviousResponseId.Trim(); @@ -88,6 +130,20 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat return; } + bool? captchaValid = await IsCaptchaValidAsync(request.CaptchaResponse, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken); + if (captchaValid is null) + { + Response.StatusCode = 503; + await Response.WriteAsJsonAsync(new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" }, cancellationToken); + return; + } + if (!captchaValid.Value) + { + Response.StatusCode = 403; + await Response.WriteAsJsonAsync(new { error = "Human verification required.", errorCode = "captcha_failed" }, cancellationToken); + return; + } + var previousResponseId = string.IsNullOrWhiteSpace(request.PreviousResponseId) ? null : request.PreviousResponseId.Trim(); @@ -234,6 +290,12 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat } } + [LoggerMessage(Level = LogLevel.Warning, Message = "hCaptcha service unavailable during chat request — failing closed (503)")] + private static partial void LogCaptchaServiceUnavailable(ILogger logger); + + [LoggerMessage(Level = LogLevel.Warning, Message = "hCaptcha validation failed for chat request — error codes: {ErrorCodes}")] + private static partial void LogCaptchaValidationFailed(ILogger logger, string errorCodes); + [LoggerMessage(Level = LogLevel.Debug, Message = "Chat stream cancelled for user {User}")] private static partial void LogChatStreamCancelled(ILogger logger, string? user); diff --git a/EssentialCSharp.Web/Controllers/ChatMessageRequest.cs b/EssentialCSharp.Web/Controllers/ChatMessageRequest.cs index c797febd..ca05d5ee 100644 --- a/EssentialCSharp.Web/Controllers/ChatMessageRequest.cs +++ b/EssentialCSharp.Web/Controllers/ChatMessageRequest.cs @@ -10,5 +10,10 @@ public class ChatMessageRequest [StringLength(200)] public string? PreviousResponseId { get; set; } public bool EnableContextualSearch { get; set; } = true; - public string? CaptchaResponse { get; set; } // For future captcha implementation + /// + /// hCaptcha token obtained from the client-side invisible widget. + /// Required when CaptchaOptions.SecretKey is configured; ignored otherwise. + /// + [StringLength(2000)] + public string? CaptchaResponse { get; set; } } diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index be4341b7..a981d084 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -117,8 +117,6 @@ private static void Main(string[] args) c.Timeout = TimeSpan.FromSeconds(3); }); - - builder.Services.AddTrustedForwardedHeaders(builder.Configuration, builder.Environment); ConfigurationManager configuration = builder.Configuration; @@ -312,7 +310,7 @@ private static void Main(string[] args) return RateLimitPartition.GetNoLimiter("mcp-transport"); var partitionKey = httpContext.User.Identity?.IsAuthenticated == true - ? httpContext.User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "unknown-user" + ? httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown-user" : httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"; return RateLimitPartition.GetFixedWindowLimiter( @@ -330,7 +328,7 @@ private static void Main(string[] args) { // Partitioned per-user (when authenticated) or per-IP (anonymous) var partitionKey = httpContext.User.Identity?.IsAuthenticated == true - ? $"chat-user:{httpContext.User.Identity.Name ?? "unknown-user"}" + ? $"chat-user:{httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown-user"}" : $"chat-ip:{httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"}"; return RateLimitPartition.GetFixedWindowLimiter( @@ -367,7 +365,6 @@ private static void Main(string[] args) Dictionary errorResponse = new() { ["error"] = "Rate limit exceeded. Please wait before sending another message.", - ["requiresCaptcha"] = true, ["statusCode"] = 429 }; if (retryAfterSeconds is int retryAfter) @@ -619,16 +616,6 @@ await McpJsonRpcResponseWriter.WriteErrorAsync( LogSitemapValidationFailed(logger, ex); // Continue startup even if sitemap validation fails } - catch (ArgumentException ex) - { - LogSitemapValidationFailed(logger, ex); - // Continue startup even if sitemap validation fails - } - catch (FormatException ex) - { - LogSitemapValidationFailed(logger, ex); - // Continue startup even if sitemap validation fails - } app.Run(); } diff --git a/EssentialCSharp.Web/Services/ContentRateLimiterPolicy.cs b/EssentialCSharp.Web/Services/ContentRateLimiterPolicy.cs index a9b4d21e..0e125c56 100644 --- a/EssentialCSharp.Web/Services/ContentRateLimiterPolicy.cs +++ b/EssentialCSharp.Web/Services/ContentRateLimiterPolicy.cs @@ -26,7 +26,7 @@ public RateLimitPartition GetPartition(HttpContext httpContext) // Use stable user ID (GUID) for authenticated users so the bucket survives // username changes and doesn't conflate login/logout with scraping. string partitionKey = isAuthenticated - ? $"user:{httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? httpContext.User.Identity!.Name ?? "unknown"}" + ? $"user:{httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown-user"}" : $"ip:{httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"}"; int perMinuteLimit = isAuthenticated ? AuthenticatedPerMinute : AnonymousPerMinute; diff --git a/EssentialCSharp.Web/Services/McpRateLimiterPolicy.cs b/EssentialCSharp.Web/Services/McpRateLimiterPolicy.cs index 76438d0b..86eaa4e7 100644 --- a/EssentialCSharp.Web/Services/McpRateLimiterPolicy.cs +++ b/EssentialCSharp.Web/Services/McpRateLimiterPolicy.cs @@ -21,7 +21,6 @@ public RateLimitPartition GetPartition(HttpContext httpContext) if (httpContext.User.Identity?.IsAuthenticated == true) { string userId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) - ?? httpContext.User.Identity?.Name ?? "unknown-user"; return RateLimitPartition.GetTokenBucketLimiter( diff --git a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml index 3f19ac18..816c6a6a 100644 --- a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml +++ b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml @@ -6,9 +6,11 @@ @using Microsoft.AspNetCore.Identity @using EssentialCSharp.Web.Areas.Identity.Data @using Microsoft.Extensions.Configuration +@using Microsoft.Extensions.Options @inject ISiteMappingService _SiteMappings @inject SignInManager SignInManager @inject IConfiguration Configuration +@inject IOptions _CaptchaOptions @@ -182,6 +184,10 @@ var buildLabel = ReleaseDateAttribute.GetReleaseDate() is DateTime date ? TimeZoneInfo.ConvertTimeFromUtc(date, TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time")).ToString("d MMM, yyyy h:mm:ss tt", CultureInfo.InvariantCulture) : null; + var chatCaptchaSiteKey = !string.IsNullOrWhiteSpace(_CaptchaOptions.Value.SecretKey) + && !string.IsNullOrWhiteSpace(_CaptchaOptions.Value.SiteKey) + ? _CaptchaOptions.Value.SiteKey + : null; } window.PERCENT_COMPLETE = @Json.Serialize(percentComplete); window.PREVIOUS_PAGE = @Json.Serialize(ViewBag.PreviousPage); @@ -192,6 +198,7 @@ window.TRYDOTNET_ORIGIN = @Json.Serialize(Configuration["TryDotNet:Origin"]); window.BUILD_LABEL = @Json.Serialize(buildLabel); window.ENABLE_CHAT_WIDGET = @Json.Serialize(!Context.Request.Path.StartsWithSegments("/Identity")); + window.HCAPTCHA_SITE_KEY = @Json.Serialize(chatCaptchaSiteKey); diff --git a/EssentialCSharp.Web/src/components/ChatWidget.vue b/EssentialCSharp.Web/src/components/ChatWidget.vue index b7b0820f..ff74fc7a 100644 --- a/EssentialCSharp.Web/src/components/ChatWidget.vue +++ b/EssentialCSharp.Web/src/components/ChatWidget.vue @@ -11,6 +11,7 @@ const { isTyping, chatMessagesEl, chatInputField, + captchaSiteKey, openChatDialog, closeChatDialog, clearChatHistory, @@ -23,6 +24,18 @@ const {