Skip to content

Commit 0a51883

Browse files
Auth and Rate Limiting
1 parent 9054f6b commit 0a51883

File tree

10 files changed

+857
-78
lines changed

10 files changed

+857
-78
lines changed

EssentialCSharp.Web/Controllers/ChatController.cs

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,27 @@
11
using System.Text.Json;
22
using EssentialCSharp.Chat.Common.Services;
3+
using EssentialCSharp.Web.Services;
4+
using Microsoft.AspNetCore.Authorization;
35
using Microsoft.AspNetCore.Mvc;
6+
using Microsoft.AspNetCore.RateLimiting;
47

58
namespace EssentialCSharp.Web.Controllers;
69

710
[ApiController]
811
[Route("api/[controller]")]
12+
[Authorize] // Require authentication for all chat endpoints
13+
[EnableRateLimiting("ChatEndpoint")]
914
public class ChatController : ControllerBase
1015
{
1116
private readonly AIChatService _AiChatService;
1217
private readonly ILogger<ChatController> _Logger;
18+
private readonly ICaptchaService _CaptchaService;
1319

14-
public ChatController(ILogger<ChatController> logger, AIChatService aiChatService)
20+
public ChatController(ILogger<ChatController> logger, AIChatService aiChatService, ICaptchaService captchaService)
1521
{
1622
_AiChatService = aiChatService;
1723
_Logger = logger;
24+
_CaptchaService = captchaService;
1825
}
1926

2027
[HttpPost("message")]
@@ -32,17 +39,22 @@ public async Task<IActionResult> SendMessage([FromBody] ChatMessageRequest reque
3239
return BadRequest(new { error = "Message cannot be empty." });
3340
}
3441

35-
// TODO: Add user authentication check here when implementing auth
36-
// if (!User.Identity.IsAuthenticated)
37-
// {
38-
// return Unauthorized(new { error = "User must be logged in to use chat." });
39-
// }
40-
41-
// TODO: Add captcha verification here when implementing captcha
42-
// if (!await _captchaService.VerifyAsync(request.CaptchaResponse))
43-
// {
44-
// return BadRequest(new { error = "Captcha verification failed." });
45-
// }
42+
// Require user authentication for chat
43+
if (!User.Identity?.IsAuthenticated ?? true)
44+
{
45+
return Unauthorized(new { error = "User must be logged in to use chat." });
46+
}
47+
// For now, we rely on ASP.NET Core Rate Limiting for protection
48+
// Future enhancement: Add captcha verification after X number of requests
49+
var captchaResult = await _CaptchaService.VerifyAsync(request.CaptchaResponse);
50+
if (captchaResult == null || !captchaResult.Success)
51+
{
52+
return BadRequest(new
53+
{
54+
error = "Captcha verification failed. Please try again.",
55+
requiresCaptcha = true
56+
});
57+
}
4658

4759
var (response, responseId) = await _AiChatService.GetChatCompletion(
4860
prompt: request.Message,
@@ -88,8 +100,26 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat
88100
return;
89101
}
90102

91-
// TODO: Add user authentication check here when implementing auth
92-
// TODO: Add captcha verification here when implementing captcha
103+
// Require user authentication for chat
104+
if (!User.Identity?.IsAuthenticated ?? true)
105+
{
106+
Response.StatusCode = 401;
107+
await Response.WriteAsync(JsonSerializer.Serialize(new { error = "User must be logged in to use chat." }), cancellationToken);
108+
return;
109+
}
110+
// For now, we rely on ASP.NET Core Rate Limiting for protection
111+
// Future enhancement: Add captcha verification after X number of requests
112+
var captchaResult = await _CaptchaService.VerifyAsync(request.CaptchaResponse);
113+
if (captchaResult == null || !captchaResult.Success)
114+
{
115+
Response.StatusCode = 400;
116+
await Response.WriteAsync(JsonSerializer.Serialize(new
117+
{
118+
error = "Captcha verification failed. Please try again.",
119+
requiresCaptcha = true
120+
}), cancellationToken);
121+
return;
122+
}
93123

94124
Response.ContentType = "text/event-stream";
95125
Response.Headers["Cache-Control"] = "no-cache";

EssentialCSharp.Web/Program.cs

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Threading.RateLimiting;
12
using Azure.Monitor.OpenTelemetry.AspNetCore;
23
using EssentialCSharp.Web.Areas.Identity.Data;
34
using EssentialCSharp.Web.Areas.Identity.Services.PasswordValidators;
@@ -10,6 +11,7 @@
1011
using Microsoft.AspNetCore.HttpOverrides;
1112
using Microsoft.AspNetCore.Identity;
1213
using Microsoft.AspNetCore.Identity.UI.Services;
14+
using Microsoft.AspNetCore.RateLimiting;
1315
using Microsoft.EntityFrameworkCore;
1416

1517
namespace EssentialCSharp.Web;
@@ -162,6 +164,81 @@ private static void Main(string[] args)
162164
logger.LogWarning(ex, "AI Chat services could not be registered. Chat functionality will be unavailable.");
163165
}
164166

167+
// Add Rate Limiting for API endpoints
168+
builder.Services.AddRateLimiter(options =>
169+
{
170+
// Global rate limiter for authenticated users by username, anonymous by IP
171+
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
172+
{
173+
var partitionKey = httpContext.User.Identity?.IsAuthenticated == true
174+
? httpContext.User.Identity.Name ?? "unknown-user"
175+
: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip";
176+
177+
return RateLimitPartition.GetFixedWindowLimiter(
178+
partitionKey: partitionKey,
179+
factory: _ => new FixedWindowRateLimiterOptions
180+
{
181+
PermitLimit = 30, // requests per window
182+
Window = TimeSpan.FromMinutes(1), // 1 minute window
183+
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
184+
QueueLimit = 0 // No queuing - immediate rejection for better UX
185+
});
186+
});
187+
188+
options.AddFixedWindowLimiter("ChatEndpoint", rateLimiterOptions =>
189+
{
190+
rateLimiterOptions.PermitLimit = 15; // chat messages per window (reasonable limit)
191+
rateLimiterOptions.Window = TimeSpan.FromMinutes(1); // minute window
192+
rateLimiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
193+
rateLimiterOptions.QueueLimit = 0; // No queuing to make rate limiting immediate
194+
});
195+
196+
options.AddFixedWindowLimiter("Anonymous", rateLimiterOptions =>
197+
{
198+
rateLimiterOptions.PermitLimit = 5; // requests per window for anonymous users
199+
rateLimiterOptions.Window = TimeSpan.FromMinutes(1);
200+
rateLimiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
201+
rateLimiterOptions.QueueLimit = 0; // No queuing for anonymous users
202+
});
203+
204+
// Custom response when rate limit is exceeded
205+
options.OnRejected = async (context, cancellationToken) =>
206+
{
207+
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
208+
context.HttpContext.Response.Headers.RetryAfter = "60";
209+
if (context.HttpContext.Request.Path.StartsWithSegments("/api/chat"))
210+
{
211+
// Custom rejection handling logic
212+
context.HttpContext.Response.ContentType = "application/json";
213+
214+
var errorResponse = new
215+
{
216+
error = "Rate limit exceeded. Please wait before sending another message.",
217+
retryAfter = 60,
218+
requiresCaptcha = true,
219+
statusCode = 429
220+
};
221+
222+
await context.HttpContext.Response.WriteAsync(
223+
System.Text.Json.JsonSerializer.Serialize(errorResponse),
224+
cancellationToken);
225+
226+
// Optional logging
227+
logger.LogWarning("Rate limit exceeded for user: {User}, IP: {IpAddress}",
228+
context.HttpContext.User.Identity?.Name ?? "anonymous",
229+
context.HttpContext.Connection.RemoteIpAddress);
230+
return;
231+
}
232+
233+
await context.HttpContext.Response.WriteAsync("Rate limit exceeded. Please try again later.", cancellationToken);
234+
235+
// Optional logging
236+
logger.LogWarning("Rate limit exceeded for user: {User}, IP: {IpAddress}",
237+
context.HttpContext.User.Identity?.Name ?? "anonymous",
238+
context.HttpContext.Connection.RemoteIpAddress);
239+
};
240+
});
241+
165242
if (!builder.Environment.IsDevelopment())
166243
{
167244
builder.Services.AddHttpClient<IMailjetClient, MailjetClient>(client =>
@@ -220,10 +297,14 @@ private static void Main(string[] args)
220297

221298
app.UseAuthentication();
222299
app.UseAuthorization();
300+
301+
// Enable rate limiting middleware (must be after UseAuthentication)
302+
app.UseRateLimiter();
303+
223304
app.UseMiddleware<ReferralMiddleware>();
224305

225306
app.MapRazorPages();
226-
app.MapDefaultControllerRoute();
307+
app.MapDefaultControllerRoute().RequireRateLimiting("ChatEndpoint"); // Apply rate limiting to controllers
227308

228309
app.MapFallbackToController("Index", "Home");
229310

EssentialCSharp.Web/Services/CaptchaService.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,12 @@ public class CaptchaService(IHttpClientFactory clientFactory, IOptions<CaptchaOp
2424
return await PostVerification(postData);
2525
}
2626

27-
public async Task<HCaptchaResult?> VerifyAsync(string response)
27+
public async Task<HCaptchaResult?> VerifyAsync(string? response)
2828
{
29+
if (string.IsNullOrWhiteSpace(response))
30+
{
31+
return null;
32+
}
2933
string secret = Options.SecretKey ?? throw new InvalidOperationException($"{CaptchaOptions.CaptchaSender} {nameof(Options.SecretKey)} is unexpectedly null");
3034
string sitekey = Options.SiteKey ?? throw new InvalidOperationException($"{CaptchaOptions.CaptchaSender} {nameof(Options.SiteKey)} is unexpectedly null");
3135

EssentialCSharp.Web/Services/ICaptchaService.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@ namespace EssentialCSharp.Web.Services;
55
public interface ICaptchaService
66
{
77
Task<HCaptchaResult?> VerifyAsync(string secret, string response, string sitekey);
8-
Task<HCaptchaResult?> VerifyAsync(string response);
8+
Task<HCaptchaResult?> VerifyAsync(string? response);
99
}

0 commit comments

Comments
 (0)