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