diff --git a/src/Indice.AspNetCore/Configuration/RateLimiterOptions.cs b/src/Indice.AspNetCore/Configuration/RateLimiterOptions.cs index 07cba57e5..e2a49c616 100644 --- a/src/Indice.AspNetCore/Configuration/RateLimiterOptions.cs +++ b/src/Indice.AspNetCore/Configuration/RateLimiterOptions.cs @@ -18,13 +18,22 @@ public class RateLimiterOptions /// List of all rate limiter policies. This is used to ensure that all policies are registered in the rate limiter middleware. public IReadOnlyList AllRateLimiterPolicies { get; set; } = Array.Empty(); - /// Custom factory function for creating policy-specific rate limiter rules. Set this to provide custom configurations based on policy names. - public Func? CustomPolicyFactory { get; set; } + /// + /// Custom factory function for creating policy-specific collections of rate limiter rules. + /// Set this to provide custom configurations based on policy names. + /// Return an empty list to indicate that the policy has no active rate limiting rules. + /// Returning null will cause to fall back to a single default rule. + /// + public Func>? CustomPolicyFactory { get; set; } - /// Default configuration for . Returns custom rule if is set, otherwise returns a default rule. + /// + /// Gets the configured list for the specified policy. + /// If is set, its result is returned (including an empty list, which means no rules are applied). + /// If the factory is not set or returns null, a list containing a single default rule is returned. + /// /// The policy name to get the configuration for. - public RateLimiterEndpointRule GetPolicySettings(string policyName) => - CustomPolicyFactory?.Invoke(policyName) ?? new(); + public List GetPolicySettings(string policyName) => + CustomPolicyFactory?.Invoke(policyName) ?? [new()]; } /// Rate limiter fixed window options for Server API. @@ -44,6 +53,18 @@ public class RateLimiterEndpointRule /// The Http method of the endpoint to apply the rate limiter. Optional. public string? HttpMethod { get; set; } + /// + /// The property path to extract from the request body for partitioning (e.g., input name for form "Input.Email" , or property name for json "email"). + /// When specified, rate limiting will be applied per unique value of this property instead of per IP or user. + /// Optional. + /// + public string? PartitionByProperty { get; set; } + + /// + /// The partitioning strategy to use for rate limiting. Determines how requests are grouped. + /// Defaults to (User subject if authenticated, then request property (if specified), otherwise IP address). + /// + public RateLimiterPartitionStrategy PartitionStrategy { get; set; } = RateLimiterPartitionStrategy.Auto; /// Determines whether has a value. public bool HasHttpMehtod => !string.IsNullOrWhiteSpace(HttpMethod); @@ -52,3 +73,16 @@ public class RateLimiterEndpointRule public bool CanLimitHttpMethod(string? httpMethod) => !HasHttpMehtod || string.Equals(HttpMethod, httpMethod, StringComparison.OrdinalIgnoreCase); } + +/// Defines the strategy for partitioning rate limit requests. +public enum RateLimiterPartitionStrategy +{ + /// Automatically determine: User subject if authenticated, then request property (if specified), otherwise IP address. + Auto = 0, + /// Partition by IP address. + IpAddress = 1, + /// Partition by authenticated user subject claim. + User = 2, + /// Partition by a property extracted from the request body (requires ). + RequestProperty = 3 +} diff --git a/src/Indice.AspNetCore/Extensions/RateLimiterExtensions.cs b/src/Indice.AspNetCore/Extensions/RateLimiterExtensions.cs index 787b579b3..e9bffd68a 100644 --- a/src/Indice.AspNetCore/Extensions/RateLimiterExtensions.cs +++ b/src/Indice.AspNetCore/Extensions/RateLimiterExtensions.cs @@ -1,8 +1,13 @@ using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; using System.Threading.RateLimiting; +using System.Globalization; using Indice.AspNetCore.Configuration; using Indice.Security; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -26,13 +31,14 @@ public static IServiceCollection AddRateLimiting(this IServiceCollection service services.AddRateLimiter(options => { foreach (var endpoint in rateLimiterOptions.AllRateLimiterPolicies) { - var endpointOptions = rateLimiterOptions.Rules.FirstOrDefault(rule => rule.Endpoint == endpoint) ?? rateLimiterOptions.GetPolicySettings(endpoint); + var defaultPolicies = rateLimiterOptions.GetPolicySettings(endpoint); + var endpointOptions = rateLimiterOptions.Rules.FirstOrDefault(rule => rule.Endpoint == endpoint) ?? defaultPolicies.First(); options.AddPolicy(endpoint, context => { if (!endpointOptions.CanLimitHttpMethod(context.Request.Method)) { return RateLimitPartition.GetNoLimiter("NoRateLimiting"); } return RateLimitPartition.GetFixedWindowLimiter( - partitionKey: context.User.FindFirstValue(rateLimiterOptions.UserIdentifierClaimType) ?? context.Connection.RemoteIpAddress?.ToString() ?? context.Request.Headers.Host.ToString(), + partitionKey: GetPartitionKey(context, rateLimiterOptions.UserIdentifierClaimType, endpointOptions), factory: _ => new FixedWindowRateLimiterOptions { PermitLimit = endpointOptions.PermitLimit.GetValueOrDefault(), QueueLimit = endpointOptions.QueueLimit.GetValueOrDefault(), @@ -51,4 +57,174 @@ public static IServiceCollection AddRateLimiting(this IServiceCollection service }); return services; } + /// + /// Helper method to determine the partition key based on strategy, user claims, request body, or fallback to IP/Host + /// + /// The HTTP context. + /// The claim type to use for identifying the user. + /// The rate limiter endpoint rule. + /// The partition key. + public static string GetPartitionKey(HttpContext httpContext, string userIdentifierClaimType, RateLimiterEndpointRule rule) { + var strategy = rule.PartitionStrategy; + + switch (strategy) { + case RateLimiterPartitionStrategy.IpAddress: + return httpContext.Connection.RemoteIpAddress?.ToString() ?? httpContext.Request.Headers.Host.ToString(); + case RateLimiterPartitionStrategy.RequestProperty: + if (string.IsNullOrEmpty(rule.PartitionByProperty)) { + return httpContext.Connection.RemoteIpAddress?.ToString() ?? httpContext.Request.Headers.Host.ToString(); + } + return ExtractPropertyFromRequest(httpContext, rule.PartitionByProperty) + ?? httpContext.Connection.RemoteIpAddress?.ToString() + ?? httpContext.Request.Headers.Host.ToString(); + + case RateLimiterPartitionStrategy.User: + case RateLimiterPartitionStrategy.Auto: + default: + return httpContext.User.FindFirstValue(userIdentifierClaimType) + ?? httpContext.Connection.RemoteIpAddress?.ToString() + ?? httpContext.Request.Headers.Host.ToString(); + } + } + + private static string? ExtractPropertyFromRequest(HttpContext httpContext, string partitionByProperty) { + httpContext.Request.EnableBuffering(); + try { + // Handle form data + if (httpContext.Request.HasFormContentType) { + if (httpContext.Request.Form.TryGetValue(partitionByProperty, out var propertyValue)) { + return NormalizePartitionKey(propertyValue.ToString()); + } + } + // Handle JSON content + else if (httpContext.Request.ContentType != null && httpContext.Request.ContentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase)) { + var requestBody = httpContext.Request.Body; + requestBody.Position = 0; + try { + var property = FindPropertyValue(requestBody, partitionByProperty).GetAwaiter().GetResult(); + return NormalizePartitionKey(property); + } finally { + requestBody.Position = 0; + } + } + } catch (JsonException) { + // fall back + } catch (InvalidDataException) { + // fall back + } catch (IOException) { + // fall back + } catch (UnauthorizedAccessException) { + // fall back + } catch (NotSupportedException) { + // fall back + } catch (InvalidOperationException) { + // fall back + } + return null; + } + + /// + /// Efficiently reads a JSON stream and finds the value of the specified (dot-separated) property path + /// without deserializing the entire payload. + /// + private static async Task FindPropertyValue(Stream jsonStream, string property) { + string[] path = property.Split('.'); + const int chunkSize = 4096; + byte[] buffer = System.Buffers.ArrayPool.Shared.Rent(chunkSize); + + try { + int bytesInBuffer = 0; + int bytesRead; + var state = new JsonReaderState(); + int matchIndex = 0; + int currentPathDepth = 0; + + while ((bytesRead = await jsonStream.ReadAsync(buffer.AsMemory(bytesInBuffer, buffer.Length - bytesInBuffer))) > 0) { + bytesInBuffer += bytesRead; + bool isFinalBlock = bytesRead == 0 || jsonStream.Position == jsonStream.Length; + + var reader = new Utf8JsonReader(buffer.AsSpan(0, bytesInBuffer), isFinalBlock, state); + + while (reader.Read()) { + if (reader.TokenType == JsonTokenType.PropertyName) { + if (matchIndex < path.Length && reader.ValueTextEquals(path[matchIndex])) { + matchIndex++; + currentPathDepth = reader.CurrentDepth; + + if (matchIndex == path.Length) { + if (reader.Read()) { + string? result = reader.TokenType switch { + JsonTokenType.String => reader.GetString(), + JsonTokenType.Number => reader.GetDouble().ToString(CultureInfo.InvariantCulture), + JsonTokenType.True => "true", + JsonTokenType.False => "false", + JsonTokenType.Null => null, + _ => reader.GetString() + }; + return result; + } + } else { + reader.Read(); + if (reader.TokenType == JsonTokenType.StartArray) { + continue; + } + } + } else if (reader.CurrentDepth <= currentPathDepth && matchIndex > 0) { + matchIndex = 0; + currentPathDepth = 0; + } + } + } + + state = reader.CurrentState; + int bytesConsumed = (int)reader.BytesConsumed; + + buffer.AsSpan(bytesConsumed, bytesInBuffer - bytesConsumed).CopyTo(buffer); + bytesInBuffer -= bytesConsumed; + + if (bytesInBuffer == buffer.Length) { + byte[] newBuffer = System.Buffers.ArrayPool.Shared.Rent(buffer.Length * 2); + buffer.AsSpan(0, bytesInBuffer).CopyTo(newBuffer); + System.Buffers.ArrayPool.Shared.Return(buffer); + buffer = newBuffer; + } + + if (isFinalBlock) break; + } + + return null; + } finally { + System.Buffers.ArrayPool.Shared.Return(buffer); + } + } + + /// + /// Normalizes a raw partition key value by trimming, checking for emptiness, and bounding its length. + /// Long values are replaced with a fixed-length SHA-256 hash to keep the key size bounded. + /// + /// The raw key value extracted from the request or user claims. + /// A normalized, bounded key suitable for use as a partition key, or null if not usable. + private static string? NormalizePartitionKey(string? rawKey) { + if (string.IsNullOrWhiteSpace(rawKey)) { + return null; + } + var trimmed = rawKey.Trim(); + if (trimmed.Length == 0) { + return null; + } + // If the key is already reasonably small, use it as-is. + const int MaxKeyLength = 128; + if (trimmed.Length <= MaxKeyLength) { + return trimmed; + } + // For very long keys, use a fixed-length hash to bound the size. + using var sha256 = SHA256.Create(); + var bytes = Encoding.UTF8.GetBytes(trimmed); + var hash = sha256.ComputeHash(bytes); + var builder = new StringBuilder(hash.Length * 2); + foreach (var b in hash) { + builder.Append(b.ToString("x2")); + } + return builder.ToString(); + } } \ No newline at end of file diff --git a/src/Indice.AspNetCore/Features/MultiRateLimiter/MultiRateLimiterService.cs b/src/Indice.AspNetCore/Features/MultiRateLimiter/MultiRateLimiterService.cs new file mode 100644 index 000000000..85b1dcc7e --- /dev/null +++ b/src/Indice.AspNetCore/Features/MultiRateLimiter/MultiRateLimiterService.cs @@ -0,0 +1,116 @@ +using Indice.AspNetCore.Configuration; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using System.Collections.Concurrent; +using System.Security.Claims; +using System.Threading.RateLimiting; + +namespace Indice.AspNetCore.Features.MultiRateLimiter; + +/// +/// Service that manages rate limiting policies and stores Rate Limiters in memory. +/// +public interface IMultiRateLimiterService +{ + /// + /// Adds a rate limiting policy. + /// + /// The name of the policy. + /// Function that configures the rate limit partition for the policy. + void AddPolicy(string policyName, Func> configurePolicy); + + /// + /// Gets a rate limiting policy by name. + /// + /// The name of the policy. + /// The policy function if found, otherwise null. + Func>? GetPolicy(string policyName); + + /// + /// Gets all registered policy names. + /// + /// A read-only collection of policy names. + IReadOnlyCollection GetPolicyNames(); + + /// + /// Returns the Rate Limiter associated with a key. Creates it first if it doesn't exist. + /// + /// The identifier used to store the Rate Limiter. + /// Function that creates the Rate Limiter. + public System.Threading.RateLimiting.RateLimiter GetOrCreateLimiter(string key, Func factory); +} + + +/// +/// Service that manages rate limiting policies and stores Rate Limiters in memory. +/// +public class MultiRateLimiterService : IMultiRateLimiterService +{ + private static readonly ConcurrentDictionary _limiters = new(); + private readonly ConcurrentDictionary>> _policies = new(); + + + /// + /// Helper extension to add policies to the service with a rule. + /// + public void AddPolicy(string policyName, RateLimiterEndpointRule rule, string userIdentifierClaimType = ClaimTypes.NameIdentifier) + { + AddPolicy(policyName, context => + { + if (!rule.CanLimitHttpMethod(context.Request.Method)) + { + return RateLimitPartition.GetNoLimiter("NoRateLimiting"); + } + + return RateLimitPartition.GetFixedWindowLimiter( + partitionKey: RateLimiterExtensions.GetPartitionKey(context, userIdentifierClaimType, rule), + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = rule.PermitLimit ?? 10, + QueueLimit = rule.QueueLimit ?? 0, + QueueProcessingOrder = rule.QueueProcessingOrder ?? QueueProcessingOrder.OldestFirst, + Window = rule.Window ?? TimeSpan.FromMinutes(1), + AutoReplenishment = true + }); + }); + } + + /// + /// Adds a rate limiting policy. + /// + /// The name of the policy. + /// Function that configures the rate limit partition for the policy. + public void AddPolicy(string policyName, Func> configurePolicy) + { + _policies[policyName] = configurePolicy; + } + + /// + /// Gets a rate limiting policy by name. + /// + /// The name of the policy. + /// The policy function if found, otherwise null. + public Func>? GetPolicy(string policyName) + { + return _policies.TryGetValue(policyName, out var policy) ? policy : null; + } + + /// + /// Gets all registered policy names. + /// + /// A read-only collection of policy names. + public IReadOnlyCollection GetPolicyNames() + { + return _policies.Keys.ToList().AsReadOnly(); + } + + /// + /// Returns the Rate Limiter associated with a key. Creates it first if it doesn't exist. + /// + /// The identifier used to store the Rate Limiter. + /// Function that creates the Rate Limiter. + public System.Threading.RateLimiting.RateLimiter GetOrCreateLimiter(string key, Func factory) + { + return _limiters.GetOrAdd(key, _ => factory()); + } +} diff --git a/src/Indice.AspNetCore/Features/MultiRateLimiter/MultiRateLimiterServiceExtensions.cs b/src/Indice.AspNetCore/Features/MultiRateLimiter/MultiRateLimiterServiceExtensions.cs new file mode 100644 index 000000000..bcea7b76c --- /dev/null +++ b/src/Indice.AspNetCore/Features/MultiRateLimiter/MultiRateLimiterServiceExtensions.cs @@ -0,0 +1,65 @@ +using System.Threading.RateLimiting; +using Indice.AspNetCore.Configuration; +using Indice.AspNetCore.Features.MultiRateLimiter; +using Microsoft.Extensions.Configuration; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for registering the MultiRateLimiter service. +/// +public static class MultiRateLimiterServiceExtensions +{ + /// + /// Registers the multi-rate limiter services with configuration binding. + /// + /// The service collection. + /// The configuration. + /// An action to configure the base rate limiter options. + /// An action to configure policies via the service. + /// The configuration section name. Optional. + public static IServiceCollection AddMultipleRateLimiter(this IServiceCollection services, IConfiguration configuration, Action? configureOptions = null, Action? configurePolicies = null, string? configurationSectionName = null) { + var sectionName = configurationSectionName ?? "MultiRateLimiter"; + + // Configure base RateLimiterOptions + services.Configure(configuration.GetSection(sectionName)); + if (configureOptions != null) { + services.Configure(configureOptions); + } + + // Register the service + services.AddSingleton(provider => { + var service = new MultiRateLimiterService(); + + // Load configuration and auto-register policies from config + var rateLimiterOptions = new RateLimiterOptions(); + configuration.GetSection(sectionName).Bind(rateLimiterOptions); + configureOptions?.Invoke(rateLimiterOptions); + + // Register policies from configuration Rules + foreach (var rule in rateLimiterOptions.Rules.Where(x => !string.IsNullOrEmpty(x.Endpoint))) { + service.AddPolicy(rule.Endpoint!, context => { + if (!rule.CanLimitHttpMethod(context.Request.Method)) { + return RateLimitPartition.GetNoLimiter("NoRateLimiting"); + } + + return RateLimitPartition.GetFixedWindowLimiter( + partitionKey: RateLimiterExtensions.GetPartitionKey(context, rateLimiterOptions.UserIdentifierClaimType, rule), + factory: _ => new FixedWindowRateLimiterOptions { + PermitLimit = rule.PermitLimit ?? 10, + QueueLimit = rule.QueueLimit ?? 0, + QueueProcessingOrder = rule.QueueProcessingOrder ?? QueueProcessingOrder.OldestFirst, + Window = rule.Window ?? TimeSpan.FromMinutes(1), + AutoReplenishment = true + }); + }); + } + + // Allow additional configuration via callback + configurePolicies?.Invoke(service); + return service; + }); + + return services; + } +} \ No newline at end of file diff --git a/src/Indice.AspNetCore/Features/MultiRateLimiter/MultiRateLimitingFilter.cs b/src/Indice.AspNetCore/Features/MultiRateLimiter/MultiRateLimitingFilter.cs new file mode 100644 index 000000000..6dd30e85e --- /dev/null +++ b/src/Indice.AspNetCore/Features/MultiRateLimiter/MultiRateLimitingFilter.cs @@ -0,0 +1,205 @@ +using Indice.AspNetCore.Configuration; +using Indice.AspNetCore.Features.MultiRateLimiter; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Threading.RateLimiting; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// An endpoint filter that enforces multiple rate limiting policies. +/// Can be used with minimal APIs via . +/// +public class MultiRateLimitingEndpointFilter : IEndpointFilter +{ + private readonly string[] _policyNames; + + /// + /// Creates a new instance of the filter with the specified policy names. + /// + /// The names of the rate limiting policies to apply. + public MultiRateLimitingEndpointFilter(params string[] policyNames) { + if (policyNames == null || policyNames.Length == 0) { + throw new ArgumentException("At least one policy name must be specified.", nameof(policyNames)); + } + _policyNames = policyNames; + } + + /// + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) { + var httpContext = context.HttpContext; + var rateLimiterService = httpContext.RequestServices.GetRequiredService(); + var options = httpContext.RequestServices.GetRequiredService>().Value; + var logger = httpContext.RequestServices.GetRequiredService>(); + var endpoint = httpContext.GetEndpoint(); + + foreach (var policyName in _policyNames) { + var configurePolicy = rateLimiterService.GetPolicy(policyName); + if (configurePolicy == null) { + logger.LogWarning("Policy '{PolicyName}' was not found for endpoint: '{EndpointName}'.", policyName, endpoint?.DisplayName); + continue; + } + + var partition = configurePolicy(httpContext); + var key = $"{policyName}_{httpContext.Request.Path}_{partition.PartitionKey}"; + var limiter = rateLimiterService.GetOrCreateLimiter(key, () => partition.Factory(partition.PartitionKey)); + + using var lease = await limiter.AcquireAsync(); + if (!lease.IsAcquired) { + var rejectionStatusCode = options.RejectionStatusCode ?? StatusCodes.Status429TooManyRequests; + httpContext.Response.StatusCode = rejectionStatusCode; + + if (lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) { + httpContext.Items.Add("retry-after", retryAfter.TotalSeconds); + httpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString(); + } + + logger.LogInformation("Rate limit reached for policy: {PolicyName} on endpoint: {EndpointName}", policyName, endpoint?.DisplayName); + return Results.Text("Too many requests. Please try again later.", statusCode: rejectionStatusCode); + } + } + + return await next(context); + } +} + +/// +/// An action filter attribute that enforces multiple rate limiting policies. +/// Can be used with MVC controllers and Razor Pages. +/// +/// +/// Usage for Controllers: [MultiRateLimiting("policy1", "policy2")] +/// Usage for Razor Pages: @attribute [MultiRateLimiting("policy1", "policy2")] +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] +public class MultiRateLimitingAttribute : Attribute, IAsyncActionFilter, IAsyncPageFilter, IOrderedFilter +{ + private readonly string[] _policyNames; + + /// + /// Creates a new instance of the attribute with the specified policy names. + /// + /// The names of the rate limiting policies to apply. + public MultiRateLimitingAttribute(params string[] policyNames) { + if (policyNames == null || policyNames.Length == 0) { + throw new ArgumentException("At least one policy name must be specified.", nameof(policyNames)); + } + _policyNames = policyNames; + } + + /// + /// Gets or sets the order in which the filter executes. Lower values execute first. + /// Default is 0 (executes early in the pipeline). + /// + public int Order { get; set; } = 0; + + /// + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { + var result = await TryAcquireRateLimitAsync(context.HttpContext); + if (result == null) { + await next(); + } else { + context.Result = result; + } + } + + /// + public Task OnPageHandlerSelectionAsync(PageHandlerSelectedContext context) { + return Task.CompletedTask; + } + + /// + public async Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext context, PageHandlerExecutionDelegate next) { + var result = await TryAcquireRateLimitAsync(context.HttpContext); + if (result == null) { + await next(); + } else { + context.Result = result; + } + } + + private async Task TryAcquireRateLimitAsync(HttpContext httpContext) { + var rateLimiterService = httpContext.RequestServices.GetRequiredService(); + var options = httpContext.RequestServices.GetRequiredService>().Value; + var logger = httpContext.RequestServices.GetRequiredService>(); + var endpoint = httpContext.GetEndpoint(); + + foreach (var policyName in _policyNames) { + var configurePolicy = rateLimiterService.GetPolicy(policyName); + if (configurePolicy == null) { + logger.LogWarning("Policy '{PolicyName}' was not found for endpoint: '{EndpointName}'.", policyName, endpoint?.DisplayName); + continue; + } + + var partition = configurePolicy(httpContext); + var key = $"{policyName}_{httpContext.Request.Path}_{partition.PartitionKey}"; + var limiter = rateLimiterService.GetOrCreateLimiter(key, () => partition.Factory(partition.PartitionKey)); + + using var lease = await limiter.AcquireAsync(); + if (!lease.IsAcquired) { + var rejectionStatusCode = options.RejectionStatusCode ?? StatusCodes.Status429TooManyRequests; + + if (lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) { + httpContext.Items.Add("retry-after", retryAfter.TotalSeconds); + httpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString(); + } + + logger.LogInformation("Rate limit reached for policy: {PolicyName} on endpoint: {EndpointName}", policyName, endpoint?.DisplayName); + + return new ContentResult { + Content = "Too many requests. Please try again later.", + StatusCode = rejectionStatusCode, + ContentType = "text/plain" + }; + } + } + + return null; // No rate limit exceeded, continue with the request + } +} + +/// +/// Extension methods for adding multi-rate limiting to endpoints. +/// +public static class MultiRateLimitingFilterExtensions +{ + /// + /// Adds multiple rate limiting policies to a minimal API endpoint. + /// + /// The route handler builder. + /// The names of the rate limiting policies to apply. + /// The route handler builder for chaining. + /// + /// + /// app.MapGet("/api/resource", () => "Hello") + /// .WithMultiRateLimiting("per-user", "per-ip"); + /// + /// + public static RouteHandlerBuilder WithMultiRateLimiting(this RouteHandlerBuilder builder, params string[] policyNames) { + builder.AddEndpointFilter(new MultiRateLimitingEndpointFilter(policyNames)); + return builder; + } + + /// + /// Adds multiple rate limiting policies to a route group. + /// + /// The route group builder. + /// The names of the rate limiting policies to apply. + /// The route group builder for chaining. + /// + /// + /// app.MapGroup("/api") + /// .WithMultiRateLimiting("per-user", "per-ip") + /// .MapGet("/resource", () => "Hello"); + /// + /// + public static RouteGroupBuilder WithMultiRateLimiting(this RouteGroupBuilder builder, params string[] policyNames) { + builder.AddEndpointFilter(new MultiRateLimitingEndpointFilter(policyNames)); + return builder; + } +} diff --git a/src/Indice.Features.Identity.Server/Extensions/ServiceCollectionExtensions.cs b/src/Indice.Features.Identity.Server/Extensions/ServiceCollectionExtensions.cs index 36ced1c0c..77c20b31d 100644 --- a/src/Indice.Features.Identity.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/Indice.Features.Identity.Server/Extensions/ServiceCollectionExtensions.cs @@ -428,18 +428,18 @@ private static IServiceCollection AddIdentityRateLimiter(this IServiceCollection services.AddRateLimiting(configuration, options => { options.AllRateLimiterPolicies = RateLimiterPolicies.All; options.CustomPolicyFactory = (policyName) => policyName switch { - "secure-page" => new() { PermitLimit = 5, Window = TimeSpan.FromSeconds(1), HttpMethod = "POST" }, - "forgot-password" => new() { PermitLimit = 5, Window = TimeSpan.FromSeconds(1), HttpMethod = "POST" }, - "login" => new() { PermitLimit = 5, Window = TimeSpan.FromSeconds(1), HttpMethod = "POST" }, - "register" => new() { PermitLimit = 5, Window = TimeSpan.FromSeconds(1), HttpMethod = "POST" }, - "login/add-email" => new() { PermitLimit = 5, Window = TimeSpan.FromMinutes(1), HttpMethod = "POST" }, - "login/add-phone" => new() { PermitLimit = 5, Window = TimeSpan.FromMinutes(1), HttpMethod = "POST" }, - "login/verify-phone" => new() { PermitLimit = 3, Window = TimeSpan.FromMinutes(1), HttpMethod = "POST" }, - "login/mfa/onboarding/add-email" => new() { PermitLimit = 1, Window = TimeSpan.FromMinutes(1), HttpMethod = "POST" }, - "login/mfa/onboarding/add-phone" => new() { PermitLimit = 1, Window = TimeSpan.FromMinutes(1), HttpMethod = "POST" }, - "login/mfa/onboarding/setup-authenticator" => new() { PermitLimit = 5, Window = TimeSpan.FromMinutes(1), HttpMethod = "POST" }, - "manage/profile" => new() { PermitLimit = 2, Window = TimeSpan.FromSeconds(3), HttpMethod = "POST" }, - _ => new RateLimiterEndpointRule() + "secure-page" => [new() { PermitLimit = 5, Window = TimeSpan.FromSeconds(1), HttpMethod = "POST" }], + "forgot-password" => [new() { PermitLimit = 5, Window = TimeSpan.FromSeconds(1), HttpMethod = "POST" }], + "login" => [new() { PermitLimit = 5, Window = TimeSpan.FromSeconds(1), HttpMethod = "POST" }], + "register" => [new() { PermitLimit = 5, Window = TimeSpan.FromSeconds(1), HttpMethod = "POST" }], + "login/add-email" => [new() { PermitLimit = 5, Window = TimeSpan.FromMinutes(1), HttpMethod = "POST" }], + "login/add-phone" => [new() { PermitLimit = 5, Window = TimeSpan.FromMinutes(1), HttpMethod = "POST" }], + "login/verify-phone" => [new() { PermitLimit = 3, Window = TimeSpan.FromMinutes(1), HttpMethod = "POST" }], + "login/mfa/onboarding/add-email" => [new() { PermitLimit = 1, Window = TimeSpan.FromMinutes(1), HttpMethod = "POST" }], + "login/mfa/onboarding/add-phone" => [new() { PermitLimit = 1, Window = TimeSpan.FromMinutes(1), HttpMethod = "POST" }], + "login/mfa/onboarding/setup-authenticator" => [new() { PermitLimit = 5, Window = TimeSpan.FromMinutes(1), HttpMethod = "POST" }], + "manage/profile" => [new() { PermitLimit = 2, Window = TimeSpan.FromSeconds(3), HttpMethod = "POST" }], + _ => [new()] }; }, "IdentityServer:RateLimiter"); return services;