|
| 1 | +using System.Threading.RateLimiting; |
1 | 2 | using Azure.Monitor.OpenTelemetry.AspNetCore; |
2 | 3 | using EssentialCSharp.Web.Areas.Identity.Data; |
3 | 4 | using EssentialCSharp.Web.Areas.Identity.Services.PasswordValidators; |
|
10 | 11 | using Microsoft.AspNetCore.HttpOverrides; |
11 | 12 | using Microsoft.AspNetCore.Identity; |
12 | 13 | using Microsoft.AspNetCore.Identity.UI.Services; |
| 14 | +using Microsoft.AspNetCore.RateLimiting; |
13 | 15 | using Microsoft.EntityFrameworkCore; |
14 | 16 |
|
15 | 17 | namespace EssentialCSharp.Web; |
@@ -162,6 +164,81 @@ private static void Main(string[] args) |
162 | 164 | logger.LogWarning(ex, "AI Chat services could not be registered. Chat functionality will be unavailable."); |
163 | 165 | } |
164 | 166 |
|
| 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 | + |
165 | 242 | if (!builder.Environment.IsDevelopment()) |
166 | 243 | { |
167 | 244 | builder.Services.AddHttpClient<IMailjetClient, MailjetClient>(client => |
@@ -220,10 +297,14 @@ private static void Main(string[] args) |
220 | 297 |
|
221 | 298 | app.UseAuthentication(); |
222 | 299 | app.UseAuthorization(); |
| 300 | + |
| 301 | + // Enable rate limiting middleware (must be after UseAuthentication) |
| 302 | + app.UseRateLimiter(); |
| 303 | + |
223 | 304 | app.UseMiddleware<ReferralMiddleware>(); |
224 | 305 |
|
225 | 306 | app.MapRazorPages(); |
226 | | - app.MapDefaultControllerRoute(); |
| 307 | + app.MapDefaultControllerRoute().RequireRateLimiting("ChatEndpoint"); // Apply rate limiting to controllers |
227 | 308 |
|
228 | 309 | app.MapFallbackToController("Index", "Home"); |
229 | 310 |
|
|
0 commit comments