diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Configurations/RateLimiterConfiguration.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Configurations/RateLimiterConfiguration.cs new file mode 100644 index 0000000..a4443db --- /dev/null +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Configurations/RateLimiterConfiguration.cs @@ -0,0 +1,25 @@ +namespace Dotnet.Samples.AspNetCore.WebApi.Configurations; + +/// +/// Configuration options for the Fixed Window Rate Limiter. +/// +public class RateLimiterConfiguration +{ + /// + /// Gets or sets the maximum number of permits that can be leased per window. + /// Default value is 60 requests. + /// + public int PermitLimit { get; set; } = 60; + + /// + /// Gets or sets the time window in seconds for rate limiting. + /// Default value is 60 seconds (1 minute). + /// + public int WindowSeconds { get; set; } = 60; + + /// + /// Gets or sets the maximum number of requests that can be queued when the permit limit is exceeded. + /// A value of 0 means no queuing (default). + /// + public int QueueLimit { get; set; } = 0; +} diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Dotnet.Samples.AspNetCore.WebApi.csproj b/src/Dotnet.Samples.AspNetCore.WebApi/Dotnet.Samples.AspNetCore.WebApi.csproj index 522de4b..4cfe51f 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Dotnet.Samples.AspNetCore.WebApi.csproj +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Dotnet.Samples.AspNetCore.WebApi.csproj @@ -8,13 +8,8 @@ - - - all - - - all - + + @@ -23,6 +18,7 @@ + diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Extensions/ServiceCollectionExtensions.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Extensions/ServiceCollectionExtensions.cs index 19c05bf..59924c9 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Extensions/ServiceCollectionExtensions.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Extensions/ServiceCollectionExtensions.cs @@ -14,9 +14,9 @@ namespace Dotnet.Samples.AspNetCore.WebApi.Extensions; /// -/// Extension methods for WebApplicationBuilder to encapsulate service configuration. +/// Extension methods for IServiceCollection to encapsulate service configuration. /// -public static class ServiceCollectionExtensions +public static partial class ServiceCollectionExtensions { /// /// Adds DbContextPool with SQLite configuration for PlayerDbContext. @@ -54,16 +54,25 @@ IWebHostEnvironment environment /// /// /// The IServiceCollection instance. + /// The web host environment. /// The IServiceCollection for method chaining. - public static IServiceCollection AddCorsDefaultPolicy(this IServiceCollection services) + public static IServiceCollection AddCorsDefaultPolicy( + this IServiceCollection services, + IWebHostEnvironment environment + ) { - services.AddCors(options => + if (environment.IsDevelopment()) { - options.AddDefaultPolicy(corsBuilder => + services.AddCors(options => { - corsBuilder.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader(); + options.AddDefaultPolicy(corsBuilder => + { + corsBuilder.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader(); + }); }); - }); + } + + // No CORS configured in Production or other environments return services; } @@ -96,7 +105,9 @@ IConfiguration configuration { services.AddSwaggerGen(options => { - options.SwaggerDoc("v1", configuration.GetSection("OpenApiInfo").Get()); + var openApiInfo = configuration.GetSection("OpenApiInfo").Get(); + + options.SwaggerDoc("v1", openApiInfo); options.IncludeXmlComments(SwaggerUtilities.ConfigureXmlCommentsFilePath()); options.AddSecurityDefinition("Bearer", SwaggerUtilities.ConfigureSecurityDefinition()); options.OperationFilter(); @@ -143,4 +154,47 @@ public static IServiceCollection RegisterPlayerRepository(this IServiceCollectio services.AddScoped(); return services; } + + /// + /// Adds rate limiting configuration with IP-based partitioning. + ///
+ /// + ///
+ /// The IServiceCollection instance. + /// The application configuration instance. + /// The IServiceCollection for method chaining. + public static IServiceCollection AddFixedWindowRateLimiter( + this IServiceCollection services, + IConfiguration configuration + ) + { + var settings = + configuration.GetSection("RateLimiter").Get() + ?? new RateLimiterConfiguration(); + + services.AddRateLimiter(options => + { + options.GlobalLimiter = PartitionedRateLimiter.Create( + httpContext => + { + var partitionKey = HttpContextUtilities.ExtractIpAddress(httpContext); + + return RateLimitPartition.GetFixedWindowLimiter( + partitionKey: partitionKey, + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = settings.PermitLimit, + Window = TimeSpan.FromSeconds(settings.WindowSeconds), + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = settings.QueueLimit + } + ); + } + ); + + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + }); + + return services; + } } diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs index cd3bdbc..4b43640 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs @@ -22,10 +22,11 @@ /* Controllers -------------------------------------------------------------- */ -builder.Services.AddControllers(); -builder.Services.AddCorsDefaultPolicy(); builder.Services.AddHealthChecks(); +builder.Services.AddControllers(); builder.Services.AddValidators(); +builder.Services.AddCorsDefaultPolicy(builder.Environment); +builder.Services.AddFixedWindowRateLimiter(builder.Configuration); if (builder.Environment.IsDevelopment()) { @@ -54,17 +55,16 @@ * -------------------------------------------------------------------------- */ app.UseSerilogRequestLogging(); +app.UseHttpsRedirection(); +app.MapHealthChecks("/health"); +app.UseRateLimiter(); +app.MapControllers(); if (app.Environment.IsDevelopment()) { + app.UseCors(); app.UseSwagger(); app.UseSwaggerUI(); } -app.UseHttpsRedirection(); -app.UseCors(); -app.UseRateLimiter(); -app.MapHealthChecks("/health"); -app.MapControllers(); - await app.RunAsync(); diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Utilities/HttpContextUtilities.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Utilities/HttpContextUtilities.cs new file mode 100644 index 0000000..e76cfce --- /dev/null +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Utilities/HttpContextUtilities.cs @@ -0,0 +1,47 @@ +using System.Net; + +namespace Dotnet.Samples.AspNetCore.WebApi.Utilities; + +/// +/// Utility class for HTTP context operations. +/// +public static class HttpContextUtilities +{ + /// + /// This method checks for the "X-Forwarded-For" and "X-Real-IP" headers, + /// which are commonly used by proxies to forward the original client IP address. + /// If these headers are not present or the IP address cannot be parsed, + /// it falls back to the remote IP address from the connection. + /// If no valid IP address can be determined, it returns "unknown". + /// + /// The HTTP context. + /// The client IP address or "unknown" if not available. + public static string ExtractIpAddress(HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext); + + var headers = httpContext.Request.Headers; + IPAddress? ipAddress; + + if (headers.TryGetValue("X-Forwarded-For", out var xForwardedFor)) + { + var clientIp = xForwardedFor + .ToString() + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .FirstOrDefault(); + + if (!string.IsNullOrWhiteSpace(clientIp) && IPAddress.TryParse(clientIp, out ipAddress)) + return ipAddress.ToString(); + } + + if ( + headers.TryGetValue("X-Real-IP", out var xRealIp) + && IPAddress.TryParse(xRealIp.ToString(), out ipAddress) + ) + { + return ipAddress.ToString(); + } + + return httpContext.Connection.RemoteIpAddress?.ToString() ?? $"unknown-{Guid.NewGuid()}"; + } +} diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/appsettings.Development.json b/src/Dotnet.Samples.AspNetCore.WebApi/appsettings.Development.json index 2e8c6f6..b749dbc 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/appsettings.Development.json +++ b/src/Dotnet.Samples.AspNetCore.WebApi/appsettings.Development.json @@ -33,5 +33,10 @@ "Name": "MIT License", "Url": "https://opensource.org/license/mit" } + }, + "RateLimiter": { + "PermitLimit": 60, + "WindowSeconds": 60, + "QueueLimit": 0 } } diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/appsettings.json b/src/Dotnet.Samples.AspNetCore.WebApi/appsettings.json index 2e8c6f6..b749dbc 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/appsettings.json +++ b/src/Dotnet.Samples.AspNetCore.WebApi/appsettings.json @@ -33,5 +33,10 @@ "Name": "MIT License", "Url": "https://opensource.org/license/mit" } + }, + "RateLimiter": { + "PermitLimit": 60, + "WindowSeconds": 60, + "QueueLimit": 0 } } diff --git a/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Dotnet.Samples.AspNetCore.WebApi.Tests.csproj b/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Dotnet.Samples.AspNetCore.WebApi.Tests.csproj index d6c085f..be829ae 100644 --- a/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Dotnet.Samples.AspNetCore.WebApi.Tests.csproj +++ b/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Dotnet.Samples.AspNetCore.WebApi.Tests.csproj @@ -9,24 +9,12 @@ - - all - - - all - - - all - - - all - - - all - - - all - + + + + + +