diff --git a/backend/UniVerse.Api.Tests/RateLimiting/RateLimitingTests.cs b/backend/UniVerse.Api.Tests/RateLimiting/RateLimitingTests.cs new file mode 100644 index 0000000..2d82143 --- /dev/null +++ b/backend/UniVerse.Api.Tests/RateLimiting/RateLimitingTests.cs @@ -0,0 +1,37 @@ +using System.Net; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using UniVerse.Api.Tests.Helpers; +using Xunit; + +namespace UniVerse.Api.Tests.RateLimiting; + +public class RateLimitingTests +{ + [Fact] + public async Task GlobalRateLimiter_Returns429_WhenPartitionExceedsLimit() + { + await using var factory = new ApiWebApplicationFactory() + .WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["RateLimiting:PermitLimit"] = "1", + ["RateLimiting:WindowSeconds"] = "60", + ["RateLimiting:QueueLimit"] = "0" + }); + }); + }); + + using var client = factory.CreateClient(); + client.DefaultRequestHeaders.Add("Authorization", TestJwtFactory.BearerHeader("Student")); + + using var firstResponse = await client.GetAsync("api/v1/tags"); + using var secondResponse = await client.GetAsync("api/v1/tags"); + + Assert.NotEqual(HttpStatusCode.TooManyRequests, firstResponse.StatusCode); + Assert.Equal(HttpStatusCode.TooManyRequests, secondResponse.StatusCode); + } +} diff --git a/backend/UniVerse.Api/Options/RateLimitingOptions.cs b/backend/UniVerse.Api/Options/RateLimitingOptions.cs new file mode 100644 index 0000000..851154b --- /dev/null +++ b/backend/UniVerse.Api/Options/RateLimitingOptions.cs @@ -0,0 +1,12 @@ +namespace UniVerse.Api.Options; + +public class RateLimitingOptions +{ + public const string SectionName = "RateLimiting"; + + public int PermitLimit { get; set; } = 600; + + public int WindowSeconds { get; set; } = 60; + + public int QueueLimit { get; set; } = 100; +} diff --git a/backend/UniVerse.Api/Program.cs b/backend/UniVerse.Api/Program.cs index 400b497..9e98a79 100644 --- a/backend/UniVerse.Api/Program.cs +++ b/backend/UniVerse.Api/Program.cs @@ -1,12 +1,16 @@ +using System.Security.Claims; using System.Text; using System.Text.Json.Serialization; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi; using Prometheus; using Quartz; using Serilog; +using System.Threading.RateLimiting; using UniVerse.Api.BackgroundServices; using UniVerse.Api.Filters; using UniVerse.Api.Middleware; @@ -69,6 +73,50 @@ builder.Services.AddAuthentication(options => }); builder.Services.AddAuthorization(); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(RateLimitingOptions.SectionName)) + .Validate(options => options.PermitLimit >= 1, + "RateLimiting:PermitLimit must be greater than or equal to 1.") + .Validate(options => options.WindowSeconds >= 1, + "RateLimiting:WindowSeconds must be greater than or equal to 1.") + .Validate(options => options.QueueLimit >= 0, + "RateLimiting:QueueLimit must be greater than or equal to 0.") + .ValidateOnStart(); + +builder.Services.AddRateLimiter(options => +{ + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.GlobalLimiter = PartitionedRateLimiter.Create(context => + { + var rateLimitingOptions = context.RequestServices.GetRequiredService>().Value; + return RateLimitPartition.GetFixedWindowLimiter( + GetRateLimitPartitionKey(context), + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = rateLimitingOptions.PermitLimit, + Window = TimeSpan.FromSeconds(rateLimitingOptions.WindowSeconds), + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = rateLimitingOptions.QueueLimit, + AutoReplenishment = true + }); + }); + options.OnRejected = async (context, cancellationToken) => + { + if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) + context.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString(); + + context.HttpContext.Response.ContentType = "application/problem+json"; + await context.HttpContext.Response.WriteAsJsonAsync(new + { + type = "https://httpstatuses.com/429", + title = "Too Many Requests", + status = StatusCodes.Status429TooManyRequests, + detail = "Rate limit exceeded. Please try again later.", + traceId = context.HttpContext.TraceIdentifier + }, cancellationToken); + }; +}); + // --- CORS --- builder.Services.AddCors(options => { @@ -221,6 +269,7 @@ if (app.Environment.IsDevelopment()) app.UseCors(); app.UseAuthentication(); +app.UseRateLimiter(); app.UseAuthorization(); app.UseHttpMetrics(); if (app.Environment.IsDevelopment()) @@ -237,3 +286,15 @@ app.UseWhen( app.MapMetrics(); app.Run(); + +static string GetRateLimitPartitionKey(HttpContext context) +{ + var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? context.User.FindFirstValue("sub"); + + if (!string.IsNullOrWhiteSpace(userId)) + return $"user:{userId}"; + + var ipAddress = context.Connection.RemoteIpAddress?.ToString(); + return string.IsNullOrWhiteSpace(ipAddress) ? "anonymous:unknown" : $"ip:{ipAddress}"; +} diff --git a/backend/UniVerse.Api/appsettings.json b/backend/UniVerse.Api/appsettings.json index 6864a91..a40dc9a 100644 --- a/backend/UniVerse.Api/appsettings.json +++ b/backend/UniVerse.Api/appsettings.json @@ -13,6 +13,11 @@ "http://localhost:3000" ] }, + "RateLimiting": { + "PermitLimit": 600, + "WindowSeconds": 60, + "QueueLimit": 100 + }, "Llm": { "BaseUrl": "https://api.openai.com/v1/", "ApiKey": "", diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml index b6ec100..9c943d7 100644 --- a/docker-compose-prod.yml +++ b/docker-compose-prod.yml @@ -26,6 +26,10 @@ services: - Cors:Origins=${CORS_ALLOWED_ORIGINS:-http://localhost:3000} + - RateLimiting:PermitLimit=${RATE_LIMITING_PERMIT_LIMIT:-600} + - RateLimiting:WindowSeconds=${RATE_LIMITING_WINDOW_SECONDS:-60} + - RateLimiting:QueueLimit=${RATE_LIMITING_QUEUE_LIMIT:-100} + - Llm:BaseUrl=${LLM_BASE_URL} - Llm:ApiKey=${LLM_API_KEY} - Llm:Model=${LLM_MODEL}