Skip to content

Commit 914fb94

Browse files
feat: implement hCaptcha validation on chat endpoints
Replaces the dead CaptchaResponse comment with a working implementation: Server-side (ChatController): - Inject ICaptchaService + IOptions<CaptchaOptions> - Validate hCaptcha token before processing each message - Graceful degradation: skipped when SecretKey is not configured (dev) - Fail-open on hCaptcha outages to avoid blocking legitimate users - Returns 403 + { errorCode: 'captcha_failed' } on invalid tokens Client-side (chat-module.js / ChatWidget.vue): - Invisible hCaptcha widget rendered outside v-if dialog so it persists across open/close cycles and only initializes once - getCaptchaToken() wraps execute() in a Promise; resolves instantly for non-suspicious users (invisible mode) - Token included as captchaResponse in every request body - Widget reset after each send (success or failure) for a fresh token - 403 responses mapped to captcha-error type in the UI - hCaptcha legal disclosure shown in footer when captcha is configured Layout: - Expose window.HCAPTCHA_SITE_KEY from CaptchaOptions for the JS layer
1 parent 653055f commit 914fb94

5 files changed

Lines changed: 146 additions & 2 deletions

File tree

EssentialCSharp.Web/Controllers/ChatController.cs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
using System.Security.Claims;
22
using System.Text.Json;
33
using EssentialCSharp.Chat.Common.Services;
4+
using EssentialCSharp.Web.Models;
45
using EssentialCSharp.Web.Services;
56
using Microsoft.AspNetCore.Authorization;
67
using Microsoft.AspNetCore.Mvc;
78
using Microsoft.AspNetCore.RateLimiting;
9+
using Microsoft.Extensions.Options;
810

911
namespace EssentialCSharp.Web.Controllers;
1012

@@ -17,15 +19,40 @@ public partial class ChatController : ControllerBase
1719
{
1820
private readonly AIChatService _AiChatService;
1921
private readonly ResponseIdValidationService _ResponseIdValidationService;
22+
private readonly ICaptchaService _CaptchaService;
23+
private readonly CaptchaOptions _CaptchaOptions;
2024
private readonly ILogger<ChatController> _Logger;
2125

22-
public ChatController(ILogger<ChatController> logger, AIChatService aiChatService, ResponseIdValidationService responseIdValidationService)
26+
public ChatController(ILogger<ChatController> logger, AIChatService aiChatService,
27+
ResponseIdValidationService responseIdValidationService,
28+
ICaptchaService captchaService, IOptions<CaptchaOptions> captchaOptions)
2329
{
2430
_AiChatService = aiChatService;
2531
_ResponseIdValidationService = responseIdValidationService;
32+
_CaptchaService = captchaService;
33+
_CaptchaOptions = captchaOptions.Value;
2634
_Logger = logger;
2735
}
2836

37+
/// <summary>
38+
/// Validates the hCaptcha token when captcha is configured.
39+
/// Returns <c>true</c> when captcha is not configured (dev mode) or when the token is valid.
40+
/// Fails open on hCaptcha service outages to avoid blocking legitimate users.
41+
/// </summary>
42+
private async Task<bool> IsCaptchaValidAsync(string? token, string? remoteIp, CancellationToken ct)
43+
{
44+
if (string.IsNullOrWhiteSpace(_CaptchaOptions.SecretKey))
45+
return true; // captcha not configured — skip validation
46+
47+
HCaptchaResult? result = await _CaptchaService.VerifyAsync(token, remoteIp, ct);
48+
if (result is null)
49+
{
50+
LogCaptchaServiceUnavailable(_Logger); // hCaptcha unreachable — fail open
51+
return true;
52+
}
53+
return result.Success;
54+
}
55+
2956
[HttpPost("message")]
3057
public async Task<IActionResult> SendMessage([FromBody] ChatMessageRequest request, CancellationToken cancellationToken = default)
3158
{
@@ -37,6 +64,9 @@ public async Task<IActionResult> SendMessage([FromBody] ChatMessageRequest reque
3764
if (string.IsNullOrEmpty(userId))
3865
return Unauthorized();
3966

67+
if (!await IsCaptchaValidAsync(request.CaptchaResponse, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken))
68+
return StatusCode(403, new { error = "Human verification required.", errorCode = "captcha_failed" });
69+
4070
var previousResponseId = string.IsNullOrWhiteSpace(request.PreviousResponseId)
4171
? null
4272
: request.PreviousResponseId.Trim();
@@ -87,6 +117,13 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat
87117
return;
88118
}
89119

120+
if (!await IsCaptchaValidAsync(request.CaptchaResponse, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken))
121+
{
122+
Response.StatusCode = 403;
123+
await Response.WriteAsJsonAsync(new { error = "Human verification required.", errorCode = "captcha_failed" }, CancellationToken.None);
124+
return;
125+
}
126+
90127
var previousResponseId = string.IsNullOrWhiteSpace(request.PreviousResponseId)
91128
? null
92129
: request.PreviousResponseId.Trim();
@@ -184,6 +221,9 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat
184221
}
185222
}
186223

224+
[LoggerMessage(Level = LogLevel.Warning, Message = "hCaptcha service unavailable during chat request — failing open")]
225+
private static partial void LogCaptchaServiceUnavailable(ILogger<ChatController> logger);
226+
187227
[LoggerMessage(Level = LogLevel.Debug, Message = "Chat stream cancelled for user {User}")]
188228
private static partial void LogChatStreamCancelled(ILogger<ChatController> logger, string? user);
189229

EssentialCSharp.Web/Controllers/ChatMessageRequest.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,10 @@ public class ChatMessageRequest
1010
[StringLength(200)]
1111
public string? PreviousResponseId { get; set; }
1212
public bool EnableContextualSearch { get; set; } = true;
13+
/// <summary>
14+
/// hCaptcha token obtained from the client-side invisible widget.
15+
/// Required when <c>CaptchaOptions.SecretKey</c> is configured; ignored otherwise.
16+
/// </summary>
17+
[StringLength(2000)]
18+
public string? CaptchaResponse { get; set; }
1319
}

EssentialCSharp.Web/Views/Shared/_Layout.cshtml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
@using Microsoft.AspNetCore.Identity
77
@using EssentialCSharp.Web.Areas.Identity.Data
88
@using Microsoft.Extensions.Configuration
9+
@using Microsoft.Extensions.Options
910
@inject ISiteMappingService _SiteMappings
1011
@inject SignInManager<EssentialCSharpWebUser> SignInManager
1112
@inject IConfiguration Configuration
13+
@inject IOptions<CaptchaOptions> _CaptchaOptions
1214
<!DOCTYPE html>
1315
<html lang="en">
1416
<head>
@@ -192,6 +194,7 @@
192194
window.TRYDOTNET_ORIGIN = @Json.Serialize(Configuration["TryDotNet:Origin"]);
193195
window.BUILD_LABEL = @Json.Serialize(buildLabel);
194196
window.ENABLE_CHAT_WIDGET = @Json.Serialize(!Context.Request.Path.StartsWithSegments("/Identity"));
197+
window.HCAPTCHA_SITE_KEY = @Json.Serialize(_CaptchaOptions.Value.SiteKey);
195198
</script>
196199
<script src="~/dist/assets/site-shell.js" type="module" asp-append-version="true"></script>
197200
</body>

EssentialCSharp.Web/src/components/ChatWidget.vue

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const {
1111
isTyping,
1212
chatMessagesEl,
1313
chatInputField,
14+
captchaSiteKey,
1415
openChatDialog,
1516
closeChatDialog,
1617
clearChatHistory,
@@ -23,6 +24,18 @@ const {
2324

2425
<template>
2526
<div class="chat-widget">
27+
<!--
28+
Invisible hCaptcha container: lives outside the v-if dialog so the widget
29+
persists across open/close cycles and only needs to be initialized once.
30+
Renders only when captcha is configured (HCAPTCHA_SITE_KEY is non-null).
31+
-->
32+
<div
33+
v-if="captchaSiteKey"
34+
id="chat-captcha-container"
35+
class="visually-hidden"
36+
aria-hidden="true"
37+
/>
38+
2639
<button
2740
class="chat-button elevation-6"
2841
:class="{ 'chat-button--active': showChatDialog }"
@@ -189,6 +202,13 @@ const {
189202
Type your question and press Enter or click send. Maximum 500 characters.
190203
</div>
191204
</form>
205+
<!-- hCaptcha legal disclosure required for invisible mode -->
206+
<p v-if="captchaSiteKey" class="captcha-notice small text-muted mt-1">
207+
Protected by hCaptcha —
208+
<a href="https://www.hcaptcha.com/privacy" target="_blank" rel="noopener noreferrer">Privacy</a>
209+
&amp;
210+
<a href="https://www.hcaptcha.com/terms" target="_blank" rel="noopener noreferrer">Terms</a>
211+
</p>
192212
</div>
193213
</div>
194214
</div>

EssentialCSharp.Web/wwwroot/js/chat-module.js

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,72 @@ import { ref, nextTick, watch, onMounted, onUnmounted } from "vue";
66
const errorIconClassByType = {
77
'rate-limit': 'fas fa-clock',
88
'auth-error': 'fas fa-lock',
9+
'captcha-error': 'fas fa-shield-alt',
910
'validation-error': 'fas fa-exclamation-circle',
1011
'network-error': 'fas fa-wifi',
1112
'connection-error': 'fas fa-plug'
1213
};
1314

15+
// hCaptcha integration — invisible widget renders once and is reused across messages.
16+
// When HCAPTCHA_SITE_KEY is null (dev / captcha not configured), all captcha calls are no-ops.
17+
const captchaSiteKey = window.HCAPTCHA_SITE_KEY || null;
18+
let captchaWidgetId = null;
19+
let captchaTokenResolve = null;
20+
let captchaTokenReject = null;
21+
22+
function initCaptchaWidget() {
23+
if (!captchaSiteKey) return;
24+
// Guard: only render once (widget lives outside the v-if dialog overlay)
25+
if (captchaWidgetId !== null) return;
26+
const container = document.getElementById('chat-captcha-container');
27+
if (!container) return;
28+
29+
window.EssentialCSharp.HCaptcha.whenHcaptchaReady(() => {
30+
if (captchaWidgetId !== null) return; // double-check after async wait
31+
captchaWidgetId = window.hcaptcha.render(container, {
32+
sitekey: captchaSiteKey,
33+
size: 'invisible',
34+
callback: (token) => {
35+
const resolve = captchaTokenResolve;
36+
captchaTokenResolve = null;
37+
captchaTokenReject = null;
38+
resolve?.(token);
39+
},
40+
'expired-callback': () => {
41+
const reject = captchaTokenReject;
42+
captchaTokenResolve = null;
43+
captchaTokenReject = null;
44+
reject?.(new Error('captcha-expired'));
45+
},
46+
'error-callback': () => {
47+
const reject = captchaTokenReject;
48+
captchaTokenResolve = null;
49+
captchaTokenReject = null;
50+
reject?.(new Error('captcha-error'));
51+
}
52+
});
53+
});
54+
}
55+
56+
/**
57+
* Returns a fresh hCaptcha token, or null if captcha is not configured.
58+
* Resolves after the invisible challenge completes (typically instant for non-suspicious users).
59+
*/
60+
function getCaptchaToken() {
61+
if (!captchaSiteKey || captchaWidgetId === null) return Promise.resolve(null);
62+
return new Promise((resolve, reject) => {
63+
captchaTokenResolve = resolve;
64+
captchaTokenReject = reject;
65+
window.hcaptcha.execute(captchaWidgetId);
66+
});
67+
}
68+
69+
function resetCaptchaWidget() {
70+
if (captchaWidgetId !== null && typeof window.hcaptcha?.reset === 'function') {
71+
window.hcaptcha.reset(captchaWidgetId);
72+
}
73+
}
74+
1475
export function useChatWidget() {
1576
// Authentication state
1677
const isAuthenticated = ref(window.IS_AUTHENTICATED || false);
@@ -109,6 +170,7 @@ export function useChatWidget() {
109170
chatInputField.value.focus();
110171
}
111172
scrollToBottom();
173+
initCaptchaWidget();
112174
});
113175
}
114176

@@ -212,10 +274,14 @@ export function useChatWidget() {
212274

213275
let reader = null;
214276
try {
277+
// Obtain invisible hCaptcha token (null when captcha is not configured)
278+
const captchaResponse = await getCaptchaToken();
279+
215280
const requestBody = {
216281
message: userMessage,
217282
enableContextualSearch: true,
218-
previousResponseId: lastResponseId.value
283+
previousResponseId: lastResponseId.value,
284+
captchaResponse: captchaResponse
219285
};
220286

221287
const response = await fetch('/api/chat/stream', {
@@ -229,6 +295,8 @@ export function useChatWidget() {
229295
if (!response.ok) {
230296
if (response.status === 401) {
231297
throw new Error('Authentication required');
298+
} else if (response.status === 403) {
299+
throw new Error('captcha-failed: Human verification failed. Please try again.');
232300
} else if (response.status === 429) {
233301
// Handle rate limiting - simple error message without captcha
234302
let errorData;
@@ -330,6 +398,9 @@ export function useChatWidget() {
330398
errorMessage = 'You must be logged in to use the chat feature. Please log in and try again.';
331399
errorType = 'auth-error';
332400
isAuthenticated.value = false; // Update auth state
401+
} else if (error.message?.startsWith('captcha-')) {
402+
errorMessage = 'Human verification failed. Please try again.';
403+
errorType = 'captcha-error';
333404
} else if (error.message?.includes('Rate limit exceeded')) {
334405
errorMessage = error.message; // Use the specific rate limit message with timing
335406
errorType = 'rate-limit';
@@ -358,6 +429,9 @@ export function useChatWidget() {
358429
}
359430
}
360431

432+
// Reset the captcha widget so a fresh token is obtained for the next message
433+
resetCaptchaWidget();
434+
361435
// Ensure typing indicator is hidden
362436
isTyping.value = false;
363437

@@ -379,6 +453,7 @@ export function useChatWidget() {
379453
isTyping,
380454
chatMessagesEl,
381455
chatInputField,
456+
captchaSiteKey,
382457

383458
// Methods
384459
openChatDialog,

0 commit comments

Comments
 (0)