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": [