diff --git a/AspNetCoreRateLimit.sln b/AspNetCoreRateLimit.sln
index 52015eb5..61624f2f 100644
--- a/AspNetCoreRateLimit.sln
+++ b/AspNetCoreRateLimit.sln
@@ -16,6 +16,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AspNetCoreRateLimit.Tests",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNetCoreRateLimit.Redis", "src\AspNetCoreRateLimit.Redis\AspNetCoreRateLimit.Redis.csproj", "{45DC4701-59A2-445F-8010-EAB850FF6F2E}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNetCoreRateLimit.Redis.BodyParameter", "src\AspNetCoreRateLimit.Redis.BodyParameter\AspNetCoreRateLimit.Redis.BodyParameter.csproj", "{E26A2482-DD6E-45E1-90EE-603641BBE34C}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -38,6 +40,10 @@ Global
{45DC4701-59A2-445F-8010-EAB850FF6F2E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{45DC4701-59A2-445F-8010-EAB850FF6F2E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{45DC4701-59A2-445F-8010-EAB850FF6F2E}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E26A2482-DD6E-45E1-90EE-603641BBE34C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E26A2482-DD6E-45E1-90EE-603641BBE34C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E26A2482-DD6E-45E1-90EE-603641BBE34C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E26A2482-DD6E-45E1-90EE-603641BBE34C}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -47,6 +53,7 @@ Global
{14C9FF9D-E50A-4A66-AEAB-F2C113743B95} = {83B05009-1BC7-4E56-8D4B-0E107CA4D45B}
{0A7C0247-AE46-4068-AE42-F955AE2A1D0E} = {83B05009-1BC7-4E56-8D4B-0E107CA4D45B}
{45DC4701-59A2-445F-8010-EAB850FF6F2E} = {909648ED-E1F4-4DAE-A274-AEC47BE826F3}
+ {E26A2482-DD6E-45E1-90EE-603641BBE34C} = {909648ED-E1F4-4DAE-A274-AEC47BE826F3}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {2FE0A4A1-855C-4770-A84F-F1CAE18E62EF}
diff --git a/src/AspNetCoreRateLimit.Redis.BodyParameter/AspNetCoreRateLimit.Redis.BodyParameter.csproj b/src/AspNetCoreRateLimit.Redis.BodyParameter/AspNetCoreRateLimit.Redis.BodyParameter.csproj
new file mode 100644
index 00000000..e78e7edd
--- /dev/null
+++ b/src/AspNetCoreRateLimit.Redis.BodyParameter/AspNetCoreRateLimit.Redis.BodyParameter.csproj
@@ -0,0 +1,14 @@
+
+
+
+ net6.0
+ enable
+ enable
+
+
+
+
+
+
+
+
diff --git a/src/AspNetCoreRateLimit.Redis.BodyParameter/ClientRateLimitAttribute.cs b/src/AspNetCoreRateLimit.Redis.BodyParameter/ClientRateLimitAttribute.cs
new file mode 100644
index 00000000..d616e474
--- /dev/null
+++ b/src/AspNetCoreRateLimit.Redis.BodyParameter/ClientRateLimitAttribute.cs
@@ -0,0 +1,189 @@
+using System.ComponentModel.DataAnnotations;
+using System.Globalization;
+using System.Security.Cryptography;
+using System.Text;
+using AspNetCoreRateLimit.Redis.BodyParameter.Core.ProcessingStrategies;
+using AspNetCoreRateLimit.Redis.BodyParameter.Models;
+using AspNetCoreRateLimit.Redis.BodyParameter.Store;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+
+namespace AspNetCoreRateLimit.Redis.BodyParameter;
+
+[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)]
+public class ClientRateLimitAttribute : ValidationAttribute
+{
+ protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
+ {
+ var httpContextAccessor = validationContext.GetService();
+
+ if (httpContextAccessor == null)
+ {
+ throw new ArgumentNullException(nameof(IHttpContextAccessor), "Don't forget to add service the IHttpContextAccessor.");
+ }
+
+ var clientBodyParameterOptions = validationContext.GetService>();
+
+ if (clientBodyParameterOptions == null)
+ {
+ throw new ArgumentNullException(nameof(ClientBodyParameterRateLimitOptions));
+ }
+
+ var cacheClientBodyParameterPolicies = validationContext.GetService();
+
+ if (cacheClientBodyParameterPolicies == null)
+ {
+ throw new ArgumentNullException(nameof(IClientBodyParameterPolicyStore));
+ }
+
+ var request = httpContextAccessor.HttpContext?.Request;
+
+ if (request == null)
+ {
+ throw new ArgumentNullException(nameof(HttpContext));
+ }
+
+ request.Headers.TryGetValue(clientBodyParameterOptions.Value.ClientIdHeader, out var clientId);
+
+ if (string.IsNullOrEmpty(clientId)) return ValidationResult.Success;
+
+ var clientBodyParameterRateLimitPolicy = cacheClientBodyParameterPolicies.Get($"{clientBodyParameterOptions.Value.ClientPolicyPrefix}_{clientId}");
+
+ if (clientBodyParameterRateLimitPolicy == null)
+ {
+ throw new Exception($"Cannot found any rules for Client: {clientId}.");
+ }
+
+ var endpoint = $"{request.Method.ToLower()}:{request.Path}";
+
+ var endpointRule = clientBodyParameterRateLimitPolicy.Rules.FirstOrDefault(x => x.Endpoint.Equals(endpoint, StringComparison.CurrentCultureIgnoreCase));
+
+ if (endpointRule is not { EnableBodyParameter: true }) return ValidationResult.Success;
+
+ var bodyParameterRule = endpointRule.BodyParameters.FirstOrDefault(x =>
+ x.ParameterName.Equals(validationContext.MemberName, StringComparison.CurrentCultureIgnoreCase) &&
+ x.ParameterValues.Any(y => y == (string)value!));
+
+ if (bodyParameterRule == null)
+ {
+ throw new Exception($"Cannot found any parameter name or values: [{validationContext.MemberName}: {value}].");
+ }
+
+ var bodyParameterRedisProcessingStrategy = validationContext.GetService();
+
+ if (bodyParameterRedisProcessingStrategy == null)
+ {
+ throw new ArgumentNullException(nameof(BodyParameterRedisProcessingStrategy), "Don't forget to add service the '.AddDistributedBodyParameterRateLimitingStores()'.");
+ }
+
+ var key = BuildCounterKey(clientId, clientBodyParameterOptions.Value, endpointRule, bodyParameterRule);
+
+ BodyParameterRateLimitCounter rateLimitCounter = bodyParameterRedisProcessingStrategy.ProcessRequest(key, bodyParameterRule);
+
+ if (bodyParameterRule.Limit > 0)
+ {
+ if (rateLimitCounter.Timestamp + bodyParameterRule.PeriodTimespan.Value < DateTime.UtcNow)
+ {
+ // continue
+ }
+ else
+ {
+ // check if limit is reached
+ if (rateLimitCounter.Count > bodyParameterRule.Limit)
+ {
+ //compute retry after value
+ var retryAfter = RetryAfterFrom(rateLimitCounter.Timestamp, bodyParameterRule);
+
+ if (!endpointRule.MonitorMode)
+ {
+ // break execution
+ var responseErrorMessage = ReturnQuotaExceededResponse(httpContextAccessor.HttpContext, clientBodyParameterOptions.Value, bodyParameterRule, retryAfter);
+
+ return new ValidationResult(responseErrorMessage);
+ }
+ }
+ }
+ }
+ // if limit is zero or less, block the request.
+ else
+ {
+ if (!endpointRule.MonitorMode)
+ {
+ var responseErrorMessage = ReturnQuotaExceededResponse(httpContextAccessor.HttpContext, clientBodyParameterOptions.Value, bodyParameterRule, int.MaxValue.ToString(CultureInfo.InvariantCulture));
+
+ return new ValidationResult(responseErrorMessage);
+ }
+ }
+
+ return ValidationResult.Success;
+ }
+
+ #region Helpers
+
+ private static string BuildCounterKey(string clientId, BodyParameterRateLimitOptions rateLimitOptions, BodyParameterRateLimitRule bodyParameterRateLimitRule, EndpointBodyParameterRateLimitRule endpointBodyParameterRateLimitRule)
+ {
+ var clientAndEndPointKey = $"{rateLimitOptions.RateLimitCounterPrefix}_{clientId}_{bodyParameterRateLimitRule.Endpoint}";
+ var parameterKey = $"{endpointBodyParameterRateLimitRule.Period}_{endpointBodyParameterRateLimitRule.ParameterName}_{endpointBodyParameterRateLimitRule.ParameterValues}";
+ using var algorithm = SHA1.Create();
+ return $"{Convert.ToBase64String(algorithm.ComputeHash(Encoding.UTF8.GetBytes(clientAndEndPointKey)))}:{Convert.ToBase64String(algorithm.ComputeHash(Encoding.UTF8.GetBytes(parameterKey)))}";
+ }
+
+ private static string RetryAfterFrom(DateTime timestamp, EndpointBodyParameterRateLimitRule rule)
+ {
+ var diff = timestamp + rule.PeriodTimespan.Value - DateTime.UtcNow;
+ var seconds = Math.Max(diff.TotalSeconds, 1);
+ return $"{seconds:F0}";
+ }
+
+ private static string FormatPeriodTimespan(TimeSpan period)
+ {
+ var sb = new StringBuilder();
+
+ if (period.Days > 0)
+ {
+ sb.Append($"{period.Days}d");
+ }
+
+ if (period.Hours > 0)
+ {
+ sb.Append($"{period.Hours}h");
+ }
+
+ if (period.Minutes > 0)
+ {
+ sb.Append($"{period.Minutes}m");
+ }
+
+ if (period.Seconds > 0)
+ {
+ sb.Append($"{period.Seconds}s");
+ }
+
+ if (period.Milliseconds > 0)
+ {
+ sb.Append($"{period.Milliseconds}ms");
+ }
+
+ return sb.ToString();
+ }
+
+ private static string ReturnQuotaExceededResponse(HttpContext httpContext, ClientBodyParameterRateLimitOptions rateLimitOptions, EndpointBodyParameterRateLimitRule rule, string retryAfter)
+ {
+ var message = string.Format(
+ rateLimitOptions.QuotaExceededResponse?.Content ??
+ rateLimitOptions.QuotaExceededMessage ??
+ "API parameter calls quota exceeded! maximum admitted {0} per {1}.",
+ rule.Limit,
+ rule.PeriodTimespan.HasValue ? FormatPeriodTimespan(rule.PeriodTimespan.Value) : rule.Period, retryAfter);
+ if (!rateLimitOptions.DisableRateLimitHeaders)
+ {
+ httpContext.Response.Headers["Retry-After"] = retryAfter;
+ httpContext.Response.Headers["X-Enable-Body-Parameter-Rate-Limit"] = "1";
+ }
+
+ return message;
+ }
+
+ #endregion
+}
\ No newline at end of file
diff --git a/src/AspNetCoreRateLimit.Redis.BodyParameter/Core/ProcessingStrategies/BodyParameterRedisProcessingStrategy.cs b/src/AspNetCoreRateLimit.Redis.BodyParameter/Core/ProcessingStrategies/BodyParameterRedisProcessingStrategy.cs
new file mode 100644
index 00000000..184a7c7b
--- /dev/null
+++ b/src/AspNetCoreRateLimit.Redis.BodyParameter/Core/ProcessingStrategies/BodyParameterRedisProcessingStrategy.cs
@@ -0,0 +1,45 @@
+using AspNetCoreRateLimit.Redis.BodyParameter.Models;
+using Microsoft.Extensions.Logging;
+using StackExchange.Redis;
+
+namespace AspNetCoreRateLimit.Redis.BodyParameter.Core.ProcessingStrategies
+{
+ public class BodyParameterRedisProcessingStrategy
+ {
+ private readonly IConnectionMultiplexer _connectionMultiplexer;
+ private readonly IRateLimitConfiguration _config;
+ private readonly ILogger _logger;
+
+ public BodyParameterRedisProcessingStrategy(IConnectionMultiplexer connectionMultiplexer, IRateLimitConfiguration config, ILogger logger)
+ {
+ _connectionMultiplexer = connectionMultiplexer ?? throw new ArgumentException("IConnectionMultiplexer was null. Ensure StackExchange.Redis was successfully registered");
+ _config = config;
+ _logger = logger;
+ }
+
+ private static readonly LuaScript AtomicIncrement = LuaScript.Prepare("local count = redis.call(\"INCRBYFLOAT\", @key, tonumber(@delta)) local ttl = redis.call(\"TTL\", @key) if ttl == -1 then redis.call(\"EXPIRE\", @key, @timeout) end return count");
+
+ public BodyParameterRateLimitCounter ProcessRequest(string counterId, EndpointBodyParameterRateLimitRule rule)
+ {
+ return Increment(counterId, rule.PeriodTimespan.Value, _config.RateIncrementer);
+ }
+
+ private BodyParameterRateLimitCounter Increment(string counterId, TimeSpan interval, Func? rateIncrementer = null)
+ {
+ var now = DateTime.UtcNow;
+ var numberOfIntervals = now.Ticks / interval.Ticks;
+ var intervalStart = new DateTime(numberOfIntervals * interval.Ticks, DateTimeKind.Utc);
+
+ _logger.LogDebug("Calling Lua script. {counterId}, {timeout}, {delta}", counterId, interval.TotalSeconds, 1D);
+ var count = _connectionMultiplexer.GetDatabase().ScriptEvaluate(AtomicIncrement, new
+ {
+ key = new RedisKey(counterId), timeout = interval.TotalSeconds, delta = rateIncrementer?.Invoke() ?? 1D
+ });
+ return new BodyParameterRateLimitCounter
+ {
+ Count = (double)count,
+ Timestamp = intervalStart
+ };
+ }
+ }
+}
diff --git a/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/BodyParameterRateLimitCounter.cs b/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/BodyParameterRateLimitCounter.cs
new file mode 100644
index 00000000..e23bb182
--- /dev/null
+++ b/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/BodyParameterRateLimitCounter.cs
@@ -0,0 +1,12 @@
+namespace AspNetCoreRateLimit.Redis.BodyParameter.Models
+{
+ ///
+ /// Stores the initial access time and the numbers of calls made from that point
+ ///
+ public class BodyParameterRateLimitCounter
+ {
+ public DateTime Timestamp { get; set; }
+
+ public double Count { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/BodyParameterRateLimitOptions.cs b/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/BodyParameterRateLimitOptions.cs
new file mode 100644
index 00000000..57be55ef
--- /dev/null
+++ b/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/BodyParameterRateLimitOptions.cs
@@ -0,0 +1,72 @@
+using Microsoft.AspNetCore.Http;
+
+namespace AspNetCoreRateLimit.Redis.BodyParameter.Models
+{
+ public class BodyParameterRateLimitOptions
+ {
+ public List GeneralRules { get; set; }
+
+ public List EndpointWhitelist { get; set; }
+
+ ///
+ /// Gets or sets the HTTP header that holds the client identifier, by default is X-ClientId
+ ///
+ public string ClientIdHeader { get; set; } = "X-ClientId";
+
+ public List ClientWhitelist { get; set; }
+
+ ///
+ /// Gets or sets the HTTP header of the real ip header injected by reverse proxy, by default is X-Real-IP
+ ///
+ public string RealIpHeader { get; set; } = "X-Real-IP";
+
+ public List IpWhitelist { get; set; }
+
+ ///
+ /// Gets or sets the HTTP Status code returned when rate limiting occurs, by default value is set to 429 (Too Many Requests)
+ ///
+ public int HttpStatusCode { get; set; } = 429;
+
+ ///
+ /// Gets or sets a value that will be used as a formatter for the QuotaExceeded response message.
+ /// If none specified the default will be:
+ /// API calls quota exceeded! maximum admitted {0} per {1}
+ ///
+ public string QuotaExceededMessage { get; set; }
+
+ ///
+ /// Gets or sets a model that represents the QuotaExceeded response (content-type, content, status code).
+ ///
+ public QuotaExceededResponse QuotaExceededResponse { get; set; }
+
+ ///
+ /// Gets or sets the counter prefix, used to compose the rate limit counter cache key
+ ///
+ public string RateLimitCounterPrefix { get; set; } = "crlc";
+
+ ///
+ /// Gets or sets a value indicating whether all requests, including the rejected ones, should be stacked in this order: day, hour, min, sec
+ ///
+ public bool StackBlockedRequests { get; set; }
+
+ ///
+ /// Enables endpoint rate limiting based URL path and HTTP verb
+ ///
+ public bool EnableEndpointRateLimiting { get; set; }
+
+ ///
+ /// Disables X-Rate-Limit and Retry-After headers
+ ///
+ public bool DisableRateLimitHeaders { get; set; }
+
+ ///
+ /// Enabled the comparison logic to use Regex instead of wildcards.
+ ///
+ public bool EnableRegexRuleMatching { get; set; }
+
+ ///
+ /// Gets or sets behavior after the request is blocked
+ ///
+ public Func RequestBlockedBehaviorAsync { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/BodyParameterRateLimitPolicy.cs b/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/BodyParameterRateLimitPolicy.cs
new file mode 100644
index 00000000..d9c23139
--- /dev/null
+++ b/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/BodyParameterRateLimitPolicy.cs
@@ -0,0 +1,7 @@
+namespace AspNetCoreRateLimit.Redis.BodyParameter.Models
+{
+ public class BodyParameterRateLimitPolicy
+ {
+ public List Rules { get; set; } = new List();
+ }
+}
diff --git a/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/BodyParameterRateLimitRule.cs b/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/BodyParameterRateLimitRule.cs
new file mode 100644
index 00000000..3ee383f5
--- /dev/null
+++ b/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/BodyParameterRateLimitRule.cs
@@ -0,0 +1,60 @@
+namespace AspNetCoreRateLimit.Redis.BodyParameter.Models
+{
+ public class BodyParameterRateLimitRule
+ {
+ ///
+ /// HTTP verb and path
+ ///
+ ///
+ /// get:/api/values
+ /// *:/api/values
+ /// *
+ ///
+ public string Endpoint { get; set; }
+
+ ///
+ /// Rate limit period as in 1s, 1m, 1h
+ ///
+ public string Period { get; set; }
+
+ public TimeSpan? PeriodTimespan => Period.ToTimeSpan();
+
+ ///
+ /// Maximum number of requests that a client can make in a defined period
+ ///
+ public double Limit { get; set; }
+
+ ///
+ /// Gets or sets a model that represents the QuotaExceeded response (content-type, content, status code).
+ ///
+ public QuotaExceededResponse QuotaExceededResponse { get; set; }
+
+ ///
+ /// If MonitorMode is true requests that exceed the limit are only logged, and will execute successfully.
+ ///
+ public bool MonitorMode { get; set; } = false;
+
+ public bool EnableBodyParameter { get; set; }
+
+ public List BodyParameters { get; set; }
+ }
+
+ public class EndpointBodyParameterRateLimitRule
+ {
+ public string ParameterName { get; set; }
+
+ public List ParameterValues { get; set; }
+
+ ///
+ /// Rate limit period as in 1s, 1m, 1h
+ ///
+ public string Period { get; set; }
+
+ public TimeSpan? PeriodTimespan => Period.ToTimeSpan();
+
+ ///
+ /// Maximum number of requests that a client can make in a defined period
+ ///
+ public double Limit { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/ClientBodyParameterRateLimitOptions.cs b/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/ClientBodyParameterRateLimitOptions.cs
new file mode 100644
index 00000000..7b9c05fd
--- /dev/null
+++ b/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/ClientBodyParameterRateLimitOptions.cs
@@ -0,0 +1,15 @@
+namespace AspNetCoreRateLimit.Redis.BodyParameter.Models
+{
+ public class ClientBodyParameterRateLimitOptions : BodyParameterRateLimitOptions
+ {
+ ///
+ /// Gets or sets the policy prefix, used to compose the client policy cache key
+ ///
+ public string ClientPolicyPrefix { get; set; } = "crlp";
+
+ public static string GetConfigurationName()
+ {
+ return nameof(ClientRateLimitOptions);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/ClientBodyParameterRateLimitPolicies.cs b/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/ClientBodyParameterRateLimitPolicies.cs
new file mode 100644
index 00000000..fdc5e115
--- /dev/null
+++ b/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/ClientBodyParameterRateLimitPolicies.cs
@@ -0,0 +1,12 @@
+namespace AspNetCoreRateLimit.Redis.BodyParameter.Models
+{
+ public class ClientBodyParameterRateLimitPolicies
+ {
+ public List ClientRules { get; set; }
+
+ public static string GetConfigurationName()
+ {
+ return nameof(ClientRateLimitPolicies);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/ClientBodyParameterRateLimitPolicy.cs b/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/ClientBodyParameterRateLimitPolicy.cs
new file mode 100644
index 00000000..2754297b
--- /dev/null
+++ b/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/ClientBodyParameterRateLimitPolicy.cs
@@ -0,0 +1,7 @@
+namespace AspNetCoreRateLimit.Redis.BodyParameter.Models
+{
+ public class ClientBodyParameterRateLimitPolicy : BodyParameterRateLimitPolicy
+ {
+ public string ClientId { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/ClientRequestIdentity.cs b/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/ClientRequestIdentity.cs
new file mode 100644
index 00000000..edbea195
--- /dev/null
+++ b/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/ClientRequestIdentity.cs
@@ -0,0 +1,16 @@
+namespace AspNetCoreRateLimit.Redis.BodyParameter.Models
+{
+ ///
+ /// Stores the client IP, ID, endpoint and verb
+ ///
+ public class ClientRequestIdentity
+ {
+ public string ClientIp { get; set; }
+
+ public string ClientId { get; set; }
+
+ public string Path { get; set; }
+
+ public string HttpVerb { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/IpBodyParameterRateLimitOptions.cs b/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/IpBodyParameterRateLimitOptions.cs
new file mode 100644
index 00000000..20de4b6e
--- /dev/null
+++ b/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/IpBodyParameterRateLimitOptions.cs
@@ -0,0 +1,10 @@
+namespace AspNetCoreRateLimit.Redis.BodyParameter.Models
+{
+ public class IpBodyParameterRateLimitOptions : BodyParameterRateLimitOptions
+ {
+ ///
+ /// Gets or sets the policy prefix, used to compose the client policy cache key
+ ///
+ public string IpPolicyPrefix { get; set; } = "ippp";
+ }
+}
\ No newline at end of file
diff --git a/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/IpBodyParameterRateLimitPolicies.cs b/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/IpBodyParameterRateLimitPolicies.cs
new file mode 100644
index 00000000..67568ae7
--- /dev/null
+++ b/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/IpBodyParameterRateLimitPolicies.cs
@@ -0,0 +1,7 @@
+namespace AspNetCoreRateLimit.Redis.BodyParameter.Models
+{
+ public class IpBodyParameterRateLimitPolicies
+ {
+ public List IpRules { get; set; } = new List();
+ }
+}
\ No newline at end of file
diff --git a/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/IpBodyParameterRateLimitPolicy.cs b/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/IpBodyParameterRateLimitPolicy.cs
new file mode 100644
index 00000000..cff46184
--- /dev/null
+++ b/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/IpBodyParameterRateLimitPolicy.cs
@@ -0,0 +1,7 @@
+namespace AspNetCoreRateLimit.Redis.BodyParameter.Models
+{
+ public class IpBodyParameterRateLimitPolicy : BodyParameterRateLimitPolicy
+ {
+ public string Ip { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/QuotaExceededResponse.cs b/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/QuotaExceededResponse.cs
new file mode 100644
index 00000000..23eac33a
--- /dev/null
+++ b/src/AspNetCoreRateLimit.Redis.BodyParameter/Models/QuotaExceededResponse.cs
@@ -0,0 +1,11 @@
+namespace AspNetCoreRateLimit.Redis.BodyParameter.Models
+{
+ public class QuotaExceededResponse
+ {
+ public string ContentType { get; set; }
+
+ public string Content { get; set; }
+
+ public int? StatusCode { get; set; } = 429;
+ }
+}
diff --git a/src/AspNetCoreRateLimit.Redis.BodyParameter/RateLimitActionFilterAttribute.cs b/src/AspNetCoreRateLimit.Redis.BodyParameter/RateLimitActionFilterAttribute.cs
new file mode 100644
index 00000000..5b8ecd25
--- /dev/null
+++ b/src/AspNetCoreRateLimit.Redis.BodyParameter/RateLimitActionFilterAttribute.cs
@@ -0,0 +1,21 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
+
+namespace AspNetCoreRateLimit.Redis.BodyParameter;
+
+public class RateLimitActionFilterAttribute : ActionFilterAttribute
+{
+ public override void OnActionExecuting(ActionExecutingContext context)
+ {
+ if (!context.ModelState.IsValid)
+ {
+ context.Result = new ObjectResult(context.ModelState.Values.FirstOrDefault()?.Errors.FirstOrDefault()?.ErrorMessage)
+ {
+ StatusCode = StatusCodes.Status429TooManyRequests
+ };
+ }
+
+ base.OnActionExecuting(context);
+ }
+}
\ No newline at end of file
diff --git a/src/AspNetCoreRateLimit.Redis.BodyParameter/StartupExtensions.cs b/src/AspNetCoreRateLimit.Redis.BodyParameter/StartupExtensions.cs
new file mode 100644
index 00000000..a87a86bc
--- /dev/null
+++ b/src/AspNetCoreRateLimit.Redis.BodyParameter/StartupExtensions.cs
@@ -0,0 +1,26 @@
+using AspNetCoreRateLimit.Redis.BodyParameter.Core.ProcessingStrategies;
+using AspNetCoreRateLimit.Redis.BodyParameter.Store;
+using AspNetCoreRateLimit.Redis.BodyParameter.Store.DistributedCache;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace AspNetCoreRateLimit.Redis.BodyParameter
+{
+ public static class StartupExtensions
+ {
+ public static IServiceCollection AddDistributedBodyParameterRateLimitingStores(this IServiceCollection services)
+ {
+ services.AddDistributedRateLimiting();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ return services;
+ }
+
+ public static void AddBodyParameterRateLimitFilter(this MvcOptions options)
+ {
+ options.Filters.Add(typeof(RateLimitActionFilterAttribute));
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/AspNetCoreRateLimit.Redis.BodyParameter/Store/DistributedCache/DistributedCacheBodyParameterClientBodyParameterPolicyStore.cs b/src/AspNetCoreRateLimit.Redis.BodyParameter/Store/DistributedCache/DistributedCacheBodyParameterClientBodyParameterPolicyStore.cs
new file mode 100644
index 00000000..3bb0fdbf
--- /dev/null
+++ b/src/AspNetCoreRateLimit.Redis.BodyParameter/Store/DistributedCache/DistributedCacheBodyParameterClientBodyParameterPolicyStore.cs
@@ -0,0 +1,33 @@
+using AspNetCoreRateLimit.Redis.BodyParameter.Models;
+using Microsoft.Extensions.Caching.Distributed;
+using Microsoft.Extensions.Options;
+
+namespace AspNetCoreRateLimit.Redis.BodyParameter.Store.DistributedCache
+{
+ public class DistributedCacheBodyParameterClientBodyParameterPolicyStore : DistributedCacheBodyParameterRateLimitStore, IClientBodyParameterPolicyStore
+ {
+ private readonly ClientBodyParameterRateLimitOptions _options;
+ private readonly ClientBodyParameterRateLimitPolicies _policies;
+
+ public DistributedCacheBodyParameterClientBodyParameterPolicyStore(
+ IDistributedCache cache,
+ IOptions options = null,
+ IOptions policies = null) : base(cache)
+ {
+ _options = options?.Value;
+ _policies = policies?.Value;
+ }
+
+ public async Task SeedAsync()
+ {
+ // on startup, save the IP rules defined in appsettings
+ if (_options != null && _policies?.ClientRules != null)
+ {
+ foreach (var rule in _policies.ClientRules)
+ {
+ await SetAsync($"{_options.ClientPolicyPrefix}_{rule.ClientId}", new ClientBodyParameterRateLimitPolicy { ClientId = rule.ClientId, Rules = rule.Rules }).ConfigureAwait(false);
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/AspNetCoreRateLimit.Redis.BodyParameter/Store/DistributedCache/DistributedCacheBodyParameterIpBodyParameterPolicyStore.cs b/src/AspNetCoreRateLimit.Redis.BodyParameter/Store/DistributedCache/DistributedCacheBodyParameterIpBodyParameterPolicyStore.cs
new file mode 100644
index 00000000..4e9bb4ec
--- /dev/null
+++ b/src/AspNetCoreRateLimit.Redis.BodyParameter/Store/DistributedCache/DistributedCacheBodyParameterIpBodyParameterPolicyStore.cs
@@ -0,0 +1,30 @@
+using AspNetCoreRateLimit.Redis.BodyParameter.Models;
+using Microsoft.Extensions.Caching.Distributed;
+using Microsoft.Extensions.Options;
+
+namespace AspNetCoreRateLimit.Redis.BodyParameter.Store.DistributedCache
+{
+ public class DistributedCacheBodyParameterIpBodyParameterPolicyStore : DistributedCacheBodyParameterRateLimitStore, IIpBodyParameterPolicyStore
+ {
+ private readonly IpBodyParameterRateLimitOptions _options;
+ private readonly IpBodyParameterRateLimitPolicies _policies;
+
+ public DistributedCacheBodyParameterIpBodyParameterPolicyStore(
+ IDistributedCache cache,
+ IOptions options = null,
+ IOptions policies = null) : base(cache)
+ {
+ _options = options?.Value;
+ _policies = policies?.Value;
+ }
+
+ public async Task SeedAsync()
+ {
+ // on startup, save the IP rules defined in appsettings
+ if (_options != null && _policies != null)
+ {
+ await SetAsync($"{_options.IpPolicyPrefix}", _policies).ConfigureAwait(false);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/AspNetCoreRateLimit.Redis.BodyParameter/Store/DistributedCache/DistributedCacheBodyParameterRateLimitCounterStore.cs b/src/AspNetCoreRateLimit.Redis.BodyParameter/Store/DistributedCache/DistributedCacheBodyParameterRateLimitCounterStore.cs
new file mode 100644
index 00000000..b0e452c6
--- /dev/null
+++ b/src/AspNetCoreRateLimit.Redis.BodyParameter/Store/DistributedCache/DistributedCacheBodyParameterRateLimitCounterStore.cs
@@ -0,0 +1,12 @@
+using AspNetCoreRateLimit.Redis.BodyParameter.Models;
+using Microsoft.Extensions.Caching.Distributed;
+
+namespace AspNetCoreRateLimit.Redis.BodyParameter.Store.DistributedCache
+{
+ public class DistributedCacheBodyParameterRateLimitCounterStore : DistributedCacheBodyParameterRateLimitStore, IBodyParameterRateLimitCounterStore
+ {
+ public DistributedCacheBodyParameterRateLimitCounterStore(IDistributedCache cache) : base(cache)
+ {
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/AspNetCoreRateLimit.Redis.BodyParameter/Store/DistributedCache/DistributedCacheBodyParameterRateLimitStore.cs b/src/AspNetCoreRateLimit.Redis.BodyParameter/Store/DistributedCache/DistributedCacheBodyParameterRateLimitStore.cs
new file mode 100644
index 00000000..8f3b5724
--- /dev/null
+++ b/src/AspNetCoreRateLimit.Redis.BodyParameter/Store/DistributedCache/DistributedCacheBodyParameterRateLimitStore.cs
@@ -0,0 +1,87 @@
+using Microsoft.Extensions.Caching.Distributed;
+using Newtonsoft.Json;
+
+namespace AspNetCoreRateLimit.Redis.BodyParameter.Store.DistributedCache
+{
+ public class DistributedCacheBodyParameterRateLimitStore : IBodyParameterRateLimitStore
+ {
+ private readonly IDistributedCache _cache;
+
+ public DistributedCacheBodyParameterRateLimitStore(IDistributedCache cache)
+ {
+ _cache = cache;
+ }
+
+ public void Set(string id, T entry, TimeSpan? expirationTime = null)
+ {
+ var options = new DistributedCacheEntryOptions();
+
+ if (expirationTime.HasValue)
+ {
+ options.SetAbsoluteExpiration(expirationTime.Value);
+ }
+
+ _cache.SetString(id, JsonConvert.SerializeObject(entry), options);
+ }
+
+ public Task SetAsync(string id, T entry, TimeSpan? expirationTime = null, CancellationToken cancellationToken = default)
+ {
+ var options = new DistributedCacheEntryOptions();
+
+ if (expirationTime.HasValue)
+ {
+ options.SetAbsoluteExpiration(expirationTime.Value);
+ }
+
+ return _cache.SetStringAsync(id, JsonConvert.SerializeObject(entry), options, cancellationToken);
+ }
+
+ public bool Exists(string id)
+ {
+ var stored = _cache.GetString(id);
+
+ return !string.IsNullOrEmpty(stored);
+ }
+
+ public async Task ExistsAsync(string id, CancellationToken cancellationToken = default)
+ {
+ var stored = await _cache.GetStringAsync(id, cancellationToken);
+
+ return !string.IsNullOrEmpty(stored);
+ }
+
+ public T Get(string id)
+ {
+ var stored = _cache.GetString(id);
+
+ if (!string.IsNullOrEmpty(stored))
+ {
+ return JsonConvert.DeserializeObject(stored);
+ }
+
+ return default;
+ }
+
+ public async Task GetAsync(string id, CancellationToken cancellationToken = default)
+ {
+ var stored = await _cache.GetStringAsync(id, cancellationToken);
+
+ if (!string.IsNullOrEmpty(stored))
+ {
+ return JsonConvert.DeserializeObject(stored);
+ }
+
+ return default;
+ }
+
+ public void Remove(string id)
+ {
+ _cache.Remove(id);
+ }
+
+ public Task RemoveAsync(string id, CancellationToken cancellationToken = default)
+ {
+ return _cache.RemoveAsync(id, cancellationToken);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/AspNetCoreRateLimit.Redis.BodyParameter/Store/IBodyParameterRateLimitCounterStore.cs b/src/AspNetCoreRateLimit.Redis.BodyParameter/Store/IBodyParameterRateLimitCounterStore.cs
new file mode 100644
index 00000000..0cae5909
--- /dev/null
+++ b/src/AspNetCoreRateLimit.Redis.BodyParameter/Store/IBodyParameterRateLimitCounterStore.cs
@@ -0,0 +1,8 @@
+using AspNetCoreRateLimit.Redis.BodyParameter.Models;
+
+namespace AspNetCoreRateLimit.Redis.BodyParameter.Store
+{
+ public interface IBodyParameterRateLimitCounterStore : IBodyParameterRateLimitStore
+ {
+ }
+}
\ No newline at end of file
diff --git a/src/AspNetCoreRateLimit.Redis.BodyParameter/Store/IBodyParameterRateLimitStore.cs b/src/AspNetCoreRateLimit.Redis.BodyParameter/Store/IBodyParameterRateLimitStore.cs
new file mode 100644
index 00000000..a0e99ea9
--- /dev/null
+++ b/src/AspNetCoreRateLimit.Redis.BodyParameter/Store/IBodyParameterRateLimitStore.cs
@@ -0,0 +1,14 @@
+namespace AspNetCoreRateLimit.Redis.BodyParameter.Store
+{
+ public interface IBodyParameterRateLimitStore
+ {
+ bool Exists(string id);
+ Task ExistsAsync(string id, CancellationToken cancellationToken = default);
+ T Get(string id);
+ Task GetAsync(string id, CancellationToken cancellationToken = default);
+ void Remove(string id);
+ Task RemoveAsync(string id, CancellationToken cancellationToken = default);
+ void Set(string id, T entry, TimeSpan? expirationTime = null);
+ Task SetAsync(string id, T entry, TimeSpan? expirationTime = null, CancellationToken cancellationToken = default);
+ }
+}
\ No newline at end of file
diff --git a/src/AspNetCoreRateLimit.Redis.BodyParameter/Store/IClientBodyParameterPolicyStore.cs b/src/AspNetCoreRateLimit.Redis.BodyParameter/Store/IClientBodyParameterPolicyStore.cs
new file mode 100644
index 00000000..4b557dbe
--- /dev/null
+++ b/src/AspNetCoreRateLimit.Redis.BodyParameter/Store/IClientBodyParameterPolicyStore.cs
@@ -0,0 +1,9 @@
+using AspNetCoreRateLimit.Redis.BodyParameter.Models;
+
+namespace AspNetCoreRateLimit.Redis.BodyParameter.Store
+{
+ public interface IClientBodyParameterPolicyStore : IBodyParameterRateLimitStore
+ {
+ Task SeedAsync();
+ }
+}
\ No newline at end of file
diff --git a/src/AspNetCoreRateLimit.Redis.BodyParameter/Store/IIpBodyParameterPolicyStore.cs b/src/AspNetCoreRateLimit.Redis.BodyParameter/Store/IIpBodyParameterPolicyStore.cs
new file mode 100644
index 00000000..49c686b2
--- /dev/null
+++ b/src/AspNetCoreRateLimit.Redis.BodyParameter/Store/IIpBodyParameterPolicyStore.cs
@@ -0,0 +1,9 @@
+using AspNetCoreRateLimit.Redis.BodyParameter.Models;
+
+namespace AspNetCoreRateLimit.Redis.BodyParameter.Store
+{
+ public interface IIpBodyParameterPolicyStore : IBodyParameterRateLimitStore
+ {
+ Task SeedAsync();
+ }
+}
\ No newline at end of file
diff --git a/test/AspNetCoreRateLimit.Demo/AspNetCoreRateLimit.Demo.csproj b/test/AspNetCoreRateLimit.Demo/AspNetCoreRateLimit.Demo.csproj
index e9c5863d..32f374c5 100644
--- a/test/AspNetCoreRateLimit.Demo/AspNetCoreRateLimit.Demo.csproj
+++ b/test/AspNetCoreRateLimit.Demo/AspNetCoreRateLimit.Demo.csproj
@@ -10,9 +10,11 @@
+
+
diff --git a/test/AspNetCoreRateLimit.Demo/Controllers/ClientsController.cs b/test/AspNetCoreRateLimit.Demo/Controllers/ClientsController.cs
index 7b8c5568..c8f219b3 100644
--- a/test/AspNetCoreRateLimit.Demo/Controllers/ClientsController.cs
+++ b/test/AspNetCoreRateLimit.Demo/Controllers/ClientsController.cs
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;
+using AspNetCoreRateLimit.Redis.BodyParameter;
namespace AspNetCoreRateLimit.Demo.Controllers
{
@@ -22,7 +23,7 @@ public string Get(int id)
// POST api/values
[HttpPost]
- public void Post([FromBody]string value)
+ public void Post([FromBody] [ClientRateLimit] string value)
{
}
diff --git a/test/AspNetCoreRateLimit.Demo/Program.cs b/test/AspNetCoreRateLimit.Demo/Program.cs
index 001fb75c..89ac7e3f 100644
--- a/test/AspNetCoreRateLimit.Demo/Program.cs
+++ b/test/AspNetCoreRateLimit.Demo/Program.cs
@@ -28,6 +28,23 @@ public static async Task Main(string[] args)
// seed IP data from appsettings
await ipPolicyStore.SeedAsync();
+
+ #region Custom: Body Parameter Rate Limit
+
+ // get the ClientPolicyStore instance
+ var clientBodyParameterPolicyStore = scope.ServiceProvider.GetRequiredService();
+
+ // seed Client data from appsettings
+ await clientBodyParameterPolicyStore.SeedAsync();
+
+ // get the IpPolicyStore instance
+ var ipBodyParameterPolicyStore = scope.ServiceProvider.GetRequiredService();
+
+ // seed IP data from appsettings
+ await ipBodyParameterPolicyStore.SeedAsync();
+
+ #endregion
+
}
await webHost.RunAsync();
diff --git a/test/AspNetCoreRateLimit.Demo/Startup.cs b/test/AspNetCoreRateLimit.Demo/Startup.cs
index d91376a2..8ad70c46 100644
--- a/test/AspNetCoreRateLimit.Demo/Startup.cs
+++ b/test/AspNetCoreRateLimit.Demo/Startup.cs
@@ -1,4 +1,3 @@
-using AspNetCoreRateLimit.Redis;
using Ben.Diagnostics;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
@@ -6,6 +5,9 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System.Collections.Generic;
+using AspNetCoreRateLimit.Redis.BodyParameter;
+using AspNetCoreRateLimit.Redis.BodyParameter.Models;
+using StackExchange.Redis;
namespace AspNetCoreRateLimit.Demo
{
@@ -29,19 +31,45 @@ public void ConfigureServices(IServiceCollection services)
services.Configure(Configuration.GetSection("ClientRateLimiting"));
services.Configure(Configuration.GetSection("ClientRateLimitPolicies"));
+ #region Custom: Body Parameter Rate Limit
+
+ // NOTE: The following configurations overwrite the above configurations.
+
+ // configure ip rate limiting middleware
+ services.Configure(Configuration.GetSection("IpBodyParameterRateLimiting"));
+ services.Configure(Configuration.GetSection("IpBodyParameterRateLimitPolicies"));
+
+ // configure client rate limiting middleware
+ services.Configure(Configuration.GetSection(ClientBodyParameterRateLimitOptions.GetConfigurationName()));
+ services.Configure(Configuration.GetSection(ClientBodyParameterRateLimitPolicies.GetConfigurationName()));
+
+ var configurationOptions = ConfigurationOptions.Parse(Configuration["ConnectionStrings:Redis"], true);
+ configurationOptions.ResolveDns = true;
+
+ //services.AddSingleton(_ => ConnectionMultiplexer.Connect(configurationOptions));
+ //services.AddStackExchangeRedisCache(options => { options.ConfigurationOptions = configurationOptions; });
+
+ //services.AddDistributedMemoryCache();
+
+ //services.AddDistributedBodyParameterRateLimitingStores();
+
+ services.AddHttpContextAccessor();
+
+ #endregion
+
// register stores
services.AddInMemoryRateLimiting();
//services.AddDistributedRateLimiting();
//services.AddDistributedRateLimiting();
//services.AddRedisRateLimiting();
-
+
services.AddMvc((options) =>
{
options.EnableEndpointRouting = false;
+ options.AddBodyParameterRateLimitFilter(); // Custom: Body Parameter Rate Limit
}).AddNewtonsoftJson();
-
// configure the resolvers
services.AddSingleton();
}
diff --git a/test/AspNetCoreRateLimit.Demo/appsettings.json b/test/AspNetCoreRateLimit.Demo/appsettings.json
index 8c587347..0370b4d2 100644
--- a/test/AspNetCoreRateLimit.Demo/appsettings.json
+++ b/test/AspNetCoreRateLimit.Demo/appsettings.json
@@ -8,6 +8,10 @@
}
},
+ "ConnectionStrings": {
+ "Redis": "localhost"
+ },
+
"IpRateLimiting": {
"EnableEndpointRateLimiting": true,
"StackBlockedRequests": false,
@@ -165,6 +169,46 @@
"ClientRateLimitPolicies": {
"ClientRules": [
+ {
+ "ClientId": "cl-key-0",
+ "Rules": [
+ {
+ "Endpoint": "*",
+ "Period": "1s",
+ "Limit": 10
+ },
+ {
+ "Endpoint": "get:/api/clients",
+ "Period": "1m",
+ "Limit": 2
+ },
+ {
+ "Endpoint": "put:/api/clients",
+ "Period": "5m",
+ "Limit": 2
+ },
+ {
+ "Endpoint": "post:/api/clients",
+ "Period": "1m",
+ "Limit": 7,
+ "EnableBodyParameter": true,
+ "BodyParameters": [
+ {
+ "ParameterName": "value",
+ "ParameterValues": ["BTC/USD", "BTC_USD"],
+ "Period": "1m",
+ "Limit": 3
+ },
+ {
+ "ParameterName": "value",
+ "ParameterValues": ["BTC/EUR", "BTC_EUR"],
+ "Period": "1m",
+ "Limit": 5
+ }
+ ]
+ }
+ ]
+ },
{
"ClientId": "cl-key-1",
"Rules": [