diff --git a/src/Ocelot/Configuration/Builder/ReRouteBuilder.cs b/src/Ocelot/Configuration/Builder/ReRouteBuilder.cs index 382cf374..2e442701 100644 --- a/src/Ocelot/Configuration/Builder/ReRouteBuilder.cs +++ b/src/Ocelot/Configuration/Builder/ReRouteBuilder.cs @@ -29,10 +29,12 @@ namespace Ocelot.Configuration.Builder private ServiceProviderConfiguraion _serviceProviderConfiguraion; private bool _useQos; private QoSOptions _qosOptions; + public bool _enableRateLimiting; + public RateLimitOptions _rateLimitOptions; public ReRouteBuilder WithLoadBalancer(string loadBalancer) { - _loadBalancer = loadBalancer; + _loadBalancer = loadBalancer; return this; } @@ -161,6 +163,19 @@ namespace Ocelot.Configuration.Builder return this; } + public ReRouteBuilder WithEnableRateLimiting(bool input) + { + _enableRateLimiting = input; + return this; + } + + public ReRouteBuilder WithRateLimitOptions(RateLimitOptions input) + { + _rateLimitOptions = input; + return this; + } + + public ReRoute Build() { return new ReRoute( @@ -185,7 +200,9 @@ namespace Ocelot.Configuration.Builder _loadBalancerKey, _serviceProviderConfiguraion, _useQos, - _qosOptions); + _qosOptions, + _enableRateLimiting, + _rateLimitOptions); } } } diff --git a/src/Ocelot/Configuration/Creator/FileOcelotConfigurationCreator.cs b/src/Ocelot/Configuration/Creator/FileOcelotConfigurationCreator.cs index 380e193f..55f31bfa 100644 --- a/src/Ocelot/Configuration/Creator/FileOcelotConfigurationCreator.cs +++ b/src/Ocelot/Configuration/Creator/FileOcelotConfigurationCreator.cs @@ -109,13 +109,17 @@ namespace Ocelot.Configuration.Creator var authOptionsForRoute = BuildAuthenticationOptions(fileReRoute); var claimsToHeaders = BuildAddThingsToRequest(fileReRoute.AddHeadersToRequest); - + var claimsToClaims = BuildAddThingsToRequest(fileReRoute.AddClaimsToRequest); var claimsToQueries = BuildAddThingsToRequest(fileReRoute.AddQueriesToRequest); var qosOptions = BuildQoSOptions(fileReRoute); + var enableRateLimiting = IsEnableRateLimiting(fileReRoute); + + var rateLimitOption = BuildRateLimitOptions(fileReRoute, globalConfiguration, enableRateLimiting); + var reRoute = new ReRouteBuilder() .WithDownstreamPathTemplate(fileReRoute.DownstreamPathTemplate) .WithUpstreamPathTemplate(fileReRoute.UpstreamPathTemplate) @@ -139,13 +143,34 @@ namespace Ocelot.Configuration.Creator .WithServiceProviderConfiguraion(serviceProviderConfiguration) .WithIsQos(isQos) .WithQosOptions(qosOptions) - .Build(); - + .WithEnableRateLimiting(enableRateLimiting) + .WithRateLimitOptions(rateLimitOption) + .Build(); await SetupLoadBalancer(reRoute); SetupQosProvider(reRoute); return reRoute; } + private static RateLimitOptions BuildRateLimitOptions(FileReRoute fileReRoute, FileGlobalConfiguration globalConfiguration, bool enableRateLimiting) + { + RateLimitOptions rateLimitOption = null; + if (enableRateLimiting) + { + rateLimitOption = new RateLimitOptions(enableRateLimiting, globalConfiguration.RateLimitOptions.ClientIdHeader, + fileReRoute.RateLimitOptions.ClientWhitelist, globalConfiguration.RateLimitOptions.DisableRateLimitHeaders, + globalConfiguration.RateLimitOptions.QuotaExceededMessage, globalConfiguration.RateLimitOptions.RateLimitCounterPrefix, + new RateLimitRule(fileReRoute.RateLimitOptions.Period, TimeSpan.FromSeconds(fileReRoute.RateLimitOptions.PeriodTimespan), fileReRoute.RateLimitOptions.Limit) + , globalConfiguration.RateLimitOptions.HttpStatusCode); + } + + return rateLimitOption; + } + + private static bool IsEnableRateLimiting(FileReRoute fileReRoute) + { + return (fileReRoute.RateLimitOptions != null && fileReRoute.RateLimitOptions.EnableRateLimiting) ? true : false; + } + private QoSOptions BuildQoSOptions(FileReRoute fileReRoute) { return new QoSOptionsBuilder() diff --git a/src/Ocelot/Configuration/File/FileGlobalConfiguration.cs b/src/Ocelot/Configuration/File/FileGlobalConfiguration.cs index f414bc83..2264bee9 100644 --- a/src/Ocelot/Configuration/File/FileGlobalConfiguration.cs +++ b/src/Ocelot/Configuration/File/FileGlobalConfiguration.cs @@ -5,8 +5,12 @@ public FileGlobalConfiguration() { ServiceDiscoveryProvider = new FileServiceDiscoveryProvider(); + RateLimitOptions = new FileRateLimitOptions(); } public string RequestIdKey { get; set; } + public FileServiceDiscoveryProvider ServiceDiscoveryProvider {get;set;} + + public FileRateLimitOptions RateLimitOptions { get; set; } } } diff --git a/src/Ocelot/Configuration/File/FileRateLimitOptions.cs b/src/Ocelot/Configuration/File/FileRateLimitOptions.cs new file mode 100644 index 00000000..afb3fab5 --- /dev/null +++ b/src/Ocelot/Configuration/File/FileRateLimitOptions.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Ocelot.Configuration.File +{ + public class FileRateLimitOptions + { + /// + /// Gets or sets the HTTP header that holds the client identifier, by default is X-ClientId + /// + public string ClientIdHeader { get; set; } = "ClientId"; + + /// + /// 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 the counter prefix, used to compose the rate limit counter cache key + /// + public string RateLimitCounterPrefix { get; set; } = "ocelot"; + + /// + /// Disables X-Rate-Limit and Rety-After headers + /// + public bool DisableRateLimitHeaders { 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; + } + + +} diff --git a/src/Ocelot/Configuration/File/FileRateLimitRule.cs b/src/Ocelot/Configuration/File/FileRateLimitRule.cs new file mode 100644 index 00000000..727a9e82 --- /dev/null +++ b/src/Ocelot/Configuration/File/FileRateLimitRule.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Ocelot.Configuration.File +{ + + public class FileRateLimitRule + { + public FileRateLimitRule() + { + ClientWhitelist = new List(); + } + + public List ClientWhitelist { get; set; } + + /// + /// Enables endpoint rate limiting based URL path and HTTP verb + /// + public bool EnableRateLimiting { get; set; } + + /// + /// Rate limit period as in 1s, 1m, 1h + /// + public string Period { get; set; } + + public double PeriodTimespan { get; set; } + /// + /// Maximum number of requests that a client can make in a defined period + /// + public long Limit { get; set; } + } +} diff --git a/src/Ocelot/Configuration/File/FileReRoute.cs b/src/Ocelot/Configuration/File/FileReRoute.cs index bdb8f87f..07562653 100644 --- a/src/Ocelot/Configuration/File/FileReRoute.cs +++ b/src/Ocelot/Configuration/File/FileReRoute.cs @@ -13,6 +13,7 @@ namespace Ocelot.Configuration.File AuthenticationOptions = new FileAuthenticationOptions(); FileCacheOptions = new FileCacheOptions(); QoSOptions = new FileQoSOptions(); + RateLimitOptions = new FileRateLimitRule(); } public string DownstreamPathTemplate { get; set; } @@ -32,5 +33,6 @@ namespace Ocelot.Configuration.File public int DownstreamPort { get; set; } public FileQoSOptions QoSOptions { get; set; } public string LoadBalancer {get;set;} + public FileRateLimitRule RateLimitOptions { get; set; } } } \ No newline at end of file diff --git a/src/Ocelot/Configuration/RateLimitOptions.cs b/src/Ocelot/Configuration/RateLimitOptions.cs new file mode 100644 index 00000000..85a3bf78 --- /dev/null +++ b/src/Ocelot/Configuration/RateLimitOptions.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Ocelot.Configuration +{ + /// + /// RateLimit Options + /// + public class RateLimitOptions + { + public RateLimitOptions(bool enbleRateLimiting, string clientIdHeader, List clientWhitelist,bool disableRateLimitHeaders, + string quotaExceededMessage, string rateLimitCounterPrefix, RateLimitRule rateLimitRule, int httpStatusCode) + { + EnableRateLimiting = enbleRateLimiting; + ClientIdHeader = clientIdHeader; + ClientWhitelist = clientWhitelist?? new List(); + DisableRateLimitHeaders = disableRateLimitHeaders; + QuotaExceededMessage = quotaExceededMessage; + RateLimitCounterPrefix = rateLimitCounterPrefix; + RateLimitRule = rateLimitRule; + HttpStatusCode = httpStatusCode; + } + + public RateLimitRule RateLimitRule { get; private set; } + + public List ClientWhitelist { get; private set; } + + /// + /// Gets or sets the HTTP header that holds the client identifier, by default is X-ClientId + /// + public string ClientIdHeader { get; private 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; private set; } + + /// + /// 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; private set; } + + /// + /// Gets or sets the counter prefix, used to compose the rate limit counter cache key + /// + public string RateLimitCounterPrefix { get; private set; } + + /// + /// Enables endpoint rate limiting based URL path and HTTP verb + /// + public bool EnableRateLimiting { get; private set; } + + /// + /// Disables X-Rate-Limit and Rety-After headers + /// + public bool DisableRateLimitHeaders { get; private set; } + } + + public class RateLimitRule + { + public RateLimitRule(string period, TimeSpan periodTimespan, long limit) + { + Period = period; + PeriodTimespan = periodTimespan; + Limit = limit; + } + + /// + /// Rate limit period as in 1s, 1m, 1h,1d + /// + public string Period { get; private set; } + + public TimeSpan PeriodTimespan { get; private set; } + /// + /// Maximum number of requests that a client can make in a defined period + /// + public long Limit { get; private set; } + } +} diff --git a/src/Ocelot/Configuration/ReRoute.cs b/src/Ocelot/Configuration/ReRoute.cs index ab9405d0..bfce7c76 100644 --- a/src/Ocelot/Configuration/ReRoute.cs +++ b/src/Ocelot/Configuration/ReRoute.cs @@ -28,7 +28,9 @@ namespace Ocelot.Configuration string reRouteKey, ServiceProviderConfiguraion serviceProviderConfiguraion, bool isQos, - QoSOptions qos) + QoSOptions qos, + bool enableRateLimit, + RateLimitOptions ratelimitOptions) { ReRouteKey = reRouteKey; ServiceProviderConfiguraion = serviceProviderConfiguraion; @@ -55,6 +57,8 @@ namespace Ocelot.Configuration DownstreamScheme = downstreamScheme; IsQos = isQos; QosOptions = qos; + EnableEndpointRateLimiting = enableRateLimit; + RateLimitOptions = ratelimitOptions; } public string ReRouteKey {get;private set;} @@ -79,5 +83,7 @@ namespace Ocelot.Configuration public string DownstreamHost { get; private set; } public int DownstreamPort { get; private set; } public ServiceProviderConfiguraion ServiceProviderConfiguraion { get; private set; } + public bool EnableEndpointRateLimiting { get; private set; } + public RateLimitOptions RateLimitOptions { get; private set; } } } \ No newline at end of file diff --git a/src/Ocelot/DependencyInjection/ServiceCollectionExtensions.cs b/src/Ocelot/DependencyInjection/ServiceCollectionExtensions.cs index e9be9841..9fb8c1c9 100644 --- a/src/Ocelot/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Ocelot/DependencyInjection/ServiceCollectionExtensions.cs @@ -31,6 +31,7 @@ using Ocelot.Requester; using Ocelot.Requester.QoS; using Ocelot.Responder; using Ocelot.ServiceDiscovery; +using Ocelot.RateLimit; namespace Ocelot.DependencyInjection { @@ -87,12 +88,13 @@ namespace Ocelot.DependencyInjection services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // see this for why we register this as singleton http://stackoverflow.com/questions/37371264/invalidoperationexception-unable-to-resolve-service-for-type-microsoft-aspnetc // could maybe use a scoped data repository services.AddSingleton(); services.AddScoped(); - + services.AddMemoryCache(); return services; } } diff --git a/src/Ocelot/Middleware/OcelotMiddlewareExtensions.cs b/src/Ocelot/Middleware/OcelotMiddlewareExtensions.cs index 352aa501..4d9f643c 100644 --- a/src/Ocelot/Middleware/OcelotMiddlewareExtensions.cs +++ b/src/Ocelot/Middleware/OcelotMiddlewareExtensions.cs @@ -11,6 +11,7 @@ using Ocelot.Request.Middleware; using Ocelot.Requester.Middleware; using Ocelot.RequestId.Middleware; using Ocelot.Responder.Middleware; +using Ocelot.RateLimit.Middleware; namespace Ocelot.Middleware { @@ -57,6 +58,9 @@ namespace Ocelot.Middleware // Then we get the downstream route information builder.UseDownstreamRouteFinderMiddleware(); + // We check whether the request is ratelimit, and if there is no continue processing + builder.UseRateLimiting(); + // Now we can look for the requestId builder.UseRequestIdMiddleware(); diff --git a/src/Ocelot/RateLimit/ClientRateLimitProcessor.cs b/src/Ocelot/RateLimit/ClientRateLimitProcessor.cs new file mode 100644 index 00000000..a2ee1202 --- /dev/null +++ b/src/Ocelot/RateLimit/ClientRateLimitProcessor.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Http; +using Ocelot.Configuration; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Ocelot.RateLimit +{ + public class ClientRateLimitProcessor + { + private readonly IRateLimitCounterHandler _counterHandler; + private readonly RateLimitCore _core; + + public ClientRateLimitProcessor(IRateLimitCounterHandler counterHandler) + { + _counterHandler = counterHandler; + _core = new RateLimitCore(_counterHandler); + } + + public RateLimitCounter ProcessRequest(ClientRequestIdentity requestIdentity, RateLimitOptions option) + { + return _core.ProcessRequest(requestIdentity, option); + } + + public string RetryAfterFrom(DateTime timestamp, RateLimitRule rule) + { + return _core.RetryAfterFrom(timestamp, rule); + } + + public RateLimitHeaders GetRateLimitHeaders(HttpContext context, ClientRequestIdentity requestIdentity, RateLimitOptions option) + { + return _core.GetRateLimitHeaders(context, requestIdentity, option); + } + + } +} diff --git a/src/Ocelot/RateLimit/ClientRequestIdentity.cs b/src/Ocelot/RateLimit/ClientRequestIdentity.cs new file mode 100644 index 00000000..a27bc994 --- /dev/null +++ b/src/Ocelot/RateLimit/ClientRequestIdentity.cs @@ -0,0 +1,18 @@ +namespace Ocelot.RateLimit +{ + public class ClientRequestIdentity + { + public ClientRequestIdentity(string clientId, string path, string httpverb) + { + ClientId = clientId; + Path = path; + HttpVerb = httpverb; + } + + public string ClientId { get; private set; } + + public string Path { get; private set; } + + public string HttpVerb { get; private set; } + } +} \ No newline at end of file diff --git a/src/Ocelot/RateLimit/DistributedCacheRateLimitCounterHanlder.cs b/src/Ocelot/RateLimit/DistributedCacheRateLimitCounterHanlder.cs new file mode 100644 index 00000000..1db8f334 --- /dev/null +++ b/src/Ocelot/RateLimit/DistributedCacheRateLimitCounterHanlder.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.Caching.Distributed; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Ocelot.RateLimit +{ + public class DistributedCacheRateLimitCounterHanlder : IRateLimitCounterHandler + { + private readonly IDistributedCache _memoryCache; + + public DistributedCacheRateLimitCounterHanlder(IDistributedCache memoryCache) + { + _memoryCache = memoryCache; + } + + public void Set(string id, RateLimitCounter counter, TimeSpan expirationTime) + { + _memoryCache.SetString(id, JsonConvert.SerializeObject(counter), new DistributedCacheEntryOptions().SetAbsoluteExpiration(expirationTime)); + } + + public bool Exists(string id) + { + var stored = _memoryCache.GetString(id); + return !string.IsNullOrEmpty(stored); + } + + public RateLimitCounter? Get(string id) + { + var stored = _memoryCache.GetString(id); + if (!string.IsNullOrEmpty(stored)) + { + return JsonConvert.DeserializeObject(stored); + } + return null; + } + + public void Remove(string id) + { + _memoryCache.Remove(id); + } + } +} diff --git a/src/Ocelot/RateLimit/IRateLimitCounterHandler.cs b/src/Ocelot/RateLimit/IRateLimitCounterHandler.cs new file mode 100644 index 00000000..cb745a44 --- /dev/null +++ b/src/Ocelot/RateLimit/IRateLimitCounterHandler.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Ocelot.RateLimit +{ + public interface IRateLimitCounterHandler + { + bool Exists(string id); + RateLimitCounter? Get(string id); + void Remove(string id); + void Set(string id, RateLimitCounter counter, TimeSpan expirationTime); + } +} diff --git a/src/Ocelot/RateLimit/MemoryCacheRateLimitCounterHandler.cs b/src/Ocelot/RateLimit/MemoryCacheRateLimitCounterHandler.cs new file mode 100644 index 00000000..9756f2ae --- /dev/null +++ b/src/Ocelot/RateLimit/MemoryCacheRateLimitCounterHandler.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.Caching.Memory; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Ocelot.RateLimit +{ + public class MemoryCacheRateLimitCounterHandler : IRateLimitCounterHandler + { + private readonly IMemoryCache _memoryCache; + + public MemoryCacheRateLimitCounterHandler(IMemoryCache memoryCache) + { + _memoryCache = memoryCache; + } + + public void Set(string id, RateLimitCounter counter, TimeSpan expirationTime) + { + _memoryCache.Set(id, counter, new MemoryCacheEntryOptions().SetAbsoluteExpiration(expirationTime)); + } + + public bool Exists(string id) + { + RateLimitCounter counter; + return _memoryCache.TryGetValue(id, out counter); + } + + public RateLimitCounter? Get(string id) + { + RateLimitCounter counter; + if (_memoryCache.TryGetValue(id, out counter)) + { + return counter; + } + + return null; + } + + public void Remove(string id) + { + _memoryCache.Remove(id); + } + } +} diff --git a/src/Ocelot/RateLimit/Middleware/ClientRateLimitMiddleware.cs b/src/Ocelot/RateLimit/Middleware/ClientRateLimitMiddleware.cs new file mode 100644 index 00000000..1b3efee1 --- /dev/null +++ b/src/Ocelot/RateLimit/Middleware/ClientRateLimitMiddleware.cs @@ -0,0 +1,138 @@ +using Ocelot.Middleware; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Ocelot.Infrastructure.RequestData; +using Microsoft.AspNetCore.Http; +using Ocelot.Logging; +using Ocelot.Configuration; + +namespace Ocelot.RateLimit.Middleware +{ + public class ClientRateLimitMiddleware : OcelotMiddleware + { + private readonly RequestDelegate _next; + private readonly IOcelotLogger _logger; + private readonly IRateLimitCounterHandler _counterHandler; + private readonly ClientRateLimitProcessor _processor; + + public ClientRateLimitMiddleware(RequestDelegate next, + IOcelotLoggerFactory loggerFactory, + IRequestScopedDataRepository requestScopedDataRepository, + IRateLimitCounterHandler counterHandler) + : base(requestScopedDataRepository) + { + _next = next; + _logger = loggerFactory.CreateLogger(); + _counterHandler = counterHandler; + _processor = new ClientRateLimitProcessor(counterHandler); + } + + public async Task Invoke(HttpContext context) + { + _logger.LogDebug("started calling RateLimit middleware"); + var options = DownstreamRoute.ReRoute.RateLimitOptions; + // check if rate limiting is enabled + if (!DownstreamRoute.ReRoute.EnableEndpointRateLimiting) + { + await _next.Invoke(context); + return; + } + // compute identity from request + var identity = SetIdentity(context, options); + + // check white list + if (IsWhitelisted(identity, options)) + { + await _next.Invoke(context); + return; + } + + var rule = options.RateLimitRule; + if (rule.Limit > 0) + { + // increment counter + var counter = _processor.ProcessRequest(identity, options); + + // check if limit is reached + if (counter.TotalRequests > rule.Limit) + { + //compute retry after value + var retryAfter = _processor.RetryAfterFrom(counter.Timestamp, rule); + + // log blocked request + LogBlockedRequest(context, identity, counter, rule); + + // break execution + await ReturnQuotaExceededResponse(context, options, retryAfter); + return; + } + } + //set X-Rate-Limit headers for the longest period + if (!options.DisableRateLimitHeaders) + { + var headers = _processor.GetRateLimitHeaders( context,identity, options); + context.Response.OnStarting(SetRateLimitHeaders, state: headers); + } + + await _next.Invoke(context); + } + + public virtual ClientRequestIdentity SetIdentity(HttpContext httpContext, RateLimitOptions option) + { + var clientId = "client"; + if (httpContext.Request.Headers.Keys.Contains(option.ClientIdHeader)) + { + clientId = httpContext.Request.Headers[option.ClientIdHeader].First(); + } + + return new ClientRequestIdentity( + clientId, + httpContext.Request.Path.ToString().ToLowerInvariant(), + httpContext.Request.Method.ToLowerInvariant() + ); + } + + public bool IsWhitelisted(ClientRequestIdentity requestIdentity, RateLimitOptions option) + { + if (option.ClientWhitelist.Contains(requestIdentity.ClientId)) + { + return true; + } + return false; + } + + public virtual void LogBlockedRequest(HttpContext httpContext, ClientRequestIdentity identity, RateLimitCounter counter, RateLimitRule rule) + { + _logger.LogDebug($"Request {identity.HttpVerb}:{identity.Path} from ClientId {identity.ClientId} has been blocked, quota {rule.Limit}/{rule.Period} exceeded by {counter.TotalRequests}. Blocked by rule { DownstreamRoute.ReRoute.UpstreamPathTemplate }, TraceIdentifier {httpContext.TraceIdentifier}."); + } + + public virtual Task ReturnQuotaExceededResponse(HttpContext httpContext, RateLimitOptions option, string retryAfter) + { + var message = string.IsNullOrEmpty(option.QuotaExceededMessage) ? $"API calls quota exceeded! maximum admitted {option.RateLimitRule.Limit} per {option.RateLimitRule.Period}." : option.QuotaExceededMessage; + + if (!option.DisableRateLimitHeaders) + { + httpContext.Response.Headers["Retry-After"] = retryAfter; + } + + httpContext.Response.StatusCode = option.HttpStatusCode; + return httpContext.Response.WriteAsync(message); + } + + private Task SetRateLimitHeaders(object rateLimitHeaders) + { + var headers = (RateLimitHeaders)rateLimitHeaders; + + headers.Context.Response.Headers["X-Rate-Limit-Limit"] = headers.Limit; + headers.Context.Response.Headers["X-Rate-Limit-Remaining"] = headers.Remaining; + headers.Context.Response.Headers["X-Rate-Limit-Reset"] = headers.Reset; + + return Task.CompletedTask; + } + + } +} + + diff --git a/src/Ocelot/RateLimit/Middleware/RateLimitMiddlewareExtensions.cs b/src/Ocelot/RateLimit/Middleware/RateLimitMiddlewareExtensions.cs new file mode 100644 index 00000000..b27d6d9d --- /dev/null +++ b/src/Ocelot/RateLimit/Middleware/RateLimitMiddlewareExtensions.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Builder; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Ocelot.RateLimit.Middleware +{ + public static class RateLimitMiddlewareExtensions + { + public static IApplicationBuilder UseRateLimiting(this IApplicationBuilder builder) + { + return builder.UseMiddleware(); + } + } +} diff --git a/src/Ocelot/RateLimit/RateLimitCore.cs b/src/Ocelot/RateLimit/RateLimitCore.cs new file mode 100644 index 00000000..07627f8d --- /dev/null +++ b/src/Ocelot/RateLimit/RateLimitCore.cs @@ -0,0 +1,125 @@ +using Microsoft.AspNetCore.Http; +using Ocelot.Configuration; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; + +namespace Ocelot.RateLimit +{ + public class RateLimitCore + { + private readonly IRateLimitCounterHandler _counterHandler; + private static readonly object _processLocker = new object(); + + public RateLimitCore(IRateLimitCounterHandler counterStore) + { + _counterHandler = counterStore; + } + + public RateLimitCounter ProcessRequest(ClientRequestIdentity requestIdentity, RateLimitOptions option) + { + RateLimitCounter counter = new RateLimitCounter(DateTime.UtcNow, 1); + var rule = option.RateLimitRule; + + var counterId = ComputeCounterKey(requestIdentity, option); + + // serial reads and writes + lock (_processLocker) + { + var entry = _counterHandler.Get(counterId); + if (entry.HasValue) + { + // entry has not expired + if (entry.Value.Timestamp + rule.PeriodTimespan >= DateTime.UtcNow) + { + // increment request count + var totalRequests = entry.Value.TotalRequests + 1; + + // deep copy + counter = new RateLimitCounter(entry.Value.Timestamp, totalRequests); + + } + } + // stores: id (string) - timestamp (datetime) - total_requests (long) + _counterHandler.Set(counterId, counter, rule.PeriodTimespan); + } + + return counter; + } + + public RateLimitHeaders GetRateLimitHeaders(HttpContext context, ClientRequestIdentity requestIdentity, RateLimitOptions option) + { + var rule = option.RateLimitRule; + RateLimitHeaders headers = null; + var counterId = ComputeCounterKey(requestIdentity, option); + var entry = _counterHandler.Get(counterId); + if (entry.HasValue) + { + headers = new RateLimitHeaders(context, rule.Period, + (rule.Limit - entry.Value.TotalRequests).ToString(), + (entry.Value.Timestamp + ConvertToTimeSpan(rule.Period)).ToUniversalTime().ToString("o", DateTimeFormatInfo.InvariantInfo) + ); + } + else + { + headers = new RateLimitHeaders(context, + rule.Period, + rule.Limit.ToString(), + (DateTime.UtcNow + ConvertToTimeSpan(rule.Period)).ToUniversalTime().ToString("o", DateTimeFormatInfo.InvariantInfo)); + + } + + return headers; + } + + public string ComputeCounterKey(ClientRequestIdentity requestIdentity, RateLimitOptions option) + { + var key = $"{option.RateLimitCounterPrefix}_{requestIdentity.ClientId}_{option.RateLimitRule.Period}_{requestIdentity.HttpVerb}_{requestIdentity.Path}"; + + var idBytes = Encoding.UTF8.GetBytes(key); + + byte[] hashBytes; + + using (var algorithm = SHA1.Create()) + { + hashBytes = algorithm.ComputeHash(idBytes); + } + + return BitConverter.ToString(hashBytes).Replace("-", string.Empty); + } + + public string RetryAfterFrom(DateTime timestamp, RateLimitRule rule) + { + var secondsPast = Convert.ToInt32((DateTime.UtcNow - timestamp).TotalSeconds); + var retryAfter = Convert.ToInt32(rule.PeriodTimespan.TotalSeconds); + retryAfter = retryAfter > 1 ? retryAfter - secondsPast : 1; + return retryAfter.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + + public TimeSpan ConvertToTimeSpan(string timeSpan) + { + var l = timeSpan.Length - 1; + var value = timeSpan.Substring(0, l); + var type = timeSpan.Substring(l, 1); + + switch (type) + { + case "d": + return TimeSpan.FromDays(double.Parse(value)); + case "h": + return TimeSpan.FromHours(double.Parse(value)); + case "m": + return TimeSpan.FromMinutes(double.Parse(value)); + case "s": + return TimeSpan.FromSeconds(double.Parse(value)); + default: + throw new FormatException($"{timeSpan} can't be converted to TimeSpan, unknown type {type}"); + } + } + + } +} diff --git a/src/Ocelot/RateLimit/RateLimitCounter.cs b/src/Ocelot/RateLimit/RateLimitCounter.cs new file mode 100644 index 00000000..42dd03b7 --- /dev/null +++ b/src/Ocelot/RateLimit/RateLimitCounter.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Ocelot.RateLimit +{ + /// + /// Stores the initial access time and the numbers of calls made from that point + /// + public struct RateLimitCounter + { + public RateLimitCounter(DateTime timestamp, long totalRequest) + { + Timestamp = timestamp; + TotalRequests = totalRequest; + } + + public DateTime Timestamp { get; private set; } + + public long TotalRequests { get; private set; } + } +} diff --git a/src/Ocelot/RateLimit/RateLimitHeaders.cs b/src/Ocelot/RateLimit/RateLimitHeaders.cs new file mode 100644 index 00000000..909f656d --- /dev/null +++ b/src/Ocelot/RateLimit/RateLimitHeaders.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Http; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Ocelot.RateLimit +{ + public class RateLimitHeaders + { + public RateLimitHeaders(HttpContext context, string limit, string remaining, string reset) + { + Context = context; + Limit = limit; + Remaining = remaining; + Reset = reset; + } + + public HttpContext Context { get; private set; } + + public string Limit { get; private set; } + + public string Remaining { get; private set; } + + public string Reset { get; private set; } + } +} diff --git a/test/Ocelot.AcceptanceTests/ClientRateLimitTests.cs b/test/Ocelot.AcceptanceTests/ClientRateLimitTests.cs new file mode 100644 index 00000000..959777c6 --- /dev/null +++ b/test/Ocelot.AcceptanceTests/ClientRateLimitTests.cs @@ -0,0 +1,165 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Ocelot.Configuration.File; +using Shouldly; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using TestStack.BDDfy; +using Xunit; + +namespace Ocelot.AcceptanceTests +{ + public class ClientRateLimitTests : IDisposable + { + private IWebHost _builder; + private readonly Steps _steps; + private int _counterOne; + + + public ClientRateLimitTests() + { + _steps = new Steps(); + } + + + public void Dispose() + { + _builder?.Dispose(); + _steps.Dispose(); + } + + [Fact] + public void should_call_withratelimiting() + { + var configuration = new FileConfiguration + { + ReRoutes = new List + { + new FileReRoute + { + DownstreamPathTemplate = "/api/ClientRateLimit", + DownstreamPort = 51879, + DownstreamScheme = "http", + DownstreamHost = "localhost", + UpstreamPathTemplate = "/api/ClientRateLimit", + UpstreamHttpMethod = "Get", + RequestIdKey = _steps.RequestIdKey, + + RateLimitOptions = new FileRateLimitRule() + { + EnableRateLimiting = true, + ClientWhitelist = new List(), + Limit = 3, + Period = "1s", + PeriodTimespan = 1000 + } + } + }, + GlobalConfiguration = new FileGlobalConfiguration() + { + RateLimitOptions = new FileRateLimitOptions() + { + ClientIdHeader = "ClientId", + DisableRateLimitHeaders = false, + QuotaExceededMessage = "", + RateLimitCounterPrefix = "", + HttpStatusCode = 428 + + }, + RequestIdKey ="oceclientrequest" + } + }; + + this.Given(x => x.GivenThereIsAServiceRunningOn("http://localhost:51879/api/ClientRateLimit")) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunning()) + .When(x => _steps.WhenIGetUrlOnTheApiGatewayMultipleTimesForRateLimit("/api/ClientRateLimit",1)) + .Then(x => _steps.ThenTheStatusCodeShouldBe(200)) + .When(x => _steps.WhenIGetUrlOnTheApiGatewayMultipleTimesForRateLimit("/api/ClientRateLimit", 2)) + .Then(x => _steps.ThenTheStatusCodeShouldBe(200)) + .When(x => _steps.WhenIGetUrlOnTheApiGatewayMultipleTimesForRateLimit("/api/ClientRateLimit",1)) + .Then(x => _steps.ThenTheStatusCodeShouldBe(428)) + .BDDfy(); + } + + + [Fact] + public void should_call_middleware_withWhitelistClient() + { + var configuration = new FileConfiguration + { + ReRoutes = new List + { + new FileReRoute + { + DownstreamPathTemplate = "/api/ClientRateLimit", + DownstreamPort = 51879, + DownstreamScheme = "http", + DownstreamHost = "localhost", + UpstreamPathTemplate = "/api/ClientRateLimit", + UpstreamHttpMethod = "Get", + RequestIdKey = _steps.RequestIdKey, + + RateLimitOptions = new FileRateLimitRule() + { + EnableRateLimiting = true, + ClientWhitelist = new List() { "ocelotclient1"}, + Limit = 3, + Period = "1s", + PeriodTimespan = 100 + } + } + }, + GlobalConfiguration = new FileGlobalConfiguration() + { + RateLimitOptions = new FileRateLimitOptions() + { + ClientIdHeader = "ClientId", + DisableRateLimitHeaders = false, + QuotaExceededMessage = "", + RateLimitCounterPrefix = "" + }, + RequestIdKey = "oceclientrequest" + } + }; + + this.Given(x => x.GivenThereIsAServiceRunningOn("http://localhost:51879/api/ClientRateLimit")) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunning()) + .When(x => _steps.WhenIGetUrlOnTheApiGatewayMultipleTimesForRateLimit("/api/ClientRateLimit", 4)) + .Then(x => _steps.ThenTheStatusCodeShouldBe(200)) + .BDDfy(); + } + + + private void GivenThereIsAServiceRunningOn(string url) + { + _builder = new WebHostBuilder() + .UseUrls(url) + .UseKestrel() + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseIISIntegration() + .UseUrls(url) + .Configure(app => + { + app.Run(context => + { + _counterOne++; + context.Response.StatusCode = 200; + context.Response.WriteAsync(_counterOne.ToString()); + return Task.CompletedTask; + }); + }) + .Build(); + + _builder.Start(); + } + + + } +} \ No newline at end of file diff --git a/test/Ocelot.AcceptanceTests/Steps.cs b/test/Ocelot.AcceptanceTests/Steps.cs index 9b5faa04..ebced901 100644 --- a/test/Ocelot.AcceptanceTests/Steps.cs +++ b/test/Ocelot.AcceptanceTests/Steps.cs @@ -11,6 +11,7 @@ using CacheManager.Core; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Ocelot.Configuration.File; @@ -100,7 +101,6 @@ namespace Ocelot.AcceptanceTests }) .WithDictionaryHandle(); }; - s.AddOcelotOutputCaching(settings); s.AddOcelotFileConfiguration(configuration); s.AddOcelot(); @@ -183,6 +183,17 @@ namespace Ocelot.AcceptanceTests count.ShouldBeGreaterThan(0); } + public void WhenIGetUrlOnTheApiGatewayMultipleTimesForRateLimit(string url, int times) + { + for (int i = 0; i < times; i++) + { + var clientId = "ocelotclient1"; + var request = new HttpRequestMessage(new HttpMethod("GET"), url); + request.Headers.Add("ClientId", clientId); + _response = _ocelotClient.SendAsync(request).Result; + } + } + public void WhenIGetUrlOnTheApiGateway(string url, string requestId) { _ocelotClient.DefaultRequestHeaders.TryAddWithoutValidation(RequestIdKey, requestId); @@ -210,6 +221,13 @@ namespace Ocelot.AcceptanceTests _response.StatusCode.ShouldBe(expectedHttpStatusCode); } + + public void ThenTheStatusCodeShouldBe(int expectedHttpStatusCode) + { + var responseStatusCode = (int)_response.StatusCode; + responseStatusCode.ShouldBe(expectedHttpStatusCode); + } + public void Dispose() { _ocelotClient?.Dispose(); diff --git a/test/Ocelot.AcceptanceTests/configuration.json b/test/Ocelot.AcceptanceTests/configuration.json new file mode 100755 index 00000000..aba4cdf9 --- /dev/null +++ b/test/Ocelot.AcceptanceTests/configuration.json @@ -0,0 +1 @@ +{"ReRoutes":[{"DownstreamPathTemplate":"41879/","UpstreamPathTemplate":"/","UpstreamHttpMethod":"Get","AuthenticationOptions":{"Provider":null,"ProviderRootUrl":null,"ScopeName":null,"RequireHttps":false,"AdditionalScopes":[],"ScopeSecret":null},"AddHeadersToRequest":{},"AddClaimsToRequest":{},"RouteClaimsRequirement":{},"AddQueriesToRequest":{},"RequestIdKey":null,"FileCacheOptions":{"TtlSeconds":0},"ReRouteIsCaseSensitive":false,"ServiceName":null,"DownstreamScheme":"http","DownstreamHost":"localhost","DownstreamPort":41879,"QoSOptions":{"ExceptionsAllowedBeforeBreaking":0,"DurationOfBreak":0,"TimeoutValue":0},"LoadBalancer":null,"RateLimitOptions":{"ClientWhitelist":[],"EnableRateLimiting":false,"Period":null,"PeriodTimespan":0.0,"Limit":0}}],"GlobalConfiguration":{"RequestIdKey":null,"ServiceDiscoveryProvider":{"Provider":null,"Host":null,"Port":0},"RateLimitOptions":{"ClientIdHeader":"ClientId","QuotaExceededMessage":null,"RateLimitCounterPrefix":"ocelot","DisableRateLimitHeaders":false,"HttpStatusCode":429}}} \ No newline at end of file diff --git a/test/Ocelot.AcceptanceTests/project.json b/test/Ocelot.AcceptanceTests/project.json index f1aa378b..2dd3094e 100644 --- a/test/Ocelot.AcceptanceTests/project.json +++ b/test/Ocelot.AcceptanceTests/project.json @@ -23,7 +23,6 @@ "Microsoft.AspNetCore.Http": "1.1.0", "Microsoft.DotNet.InternalAbstractions": "1.0.0", "Ocelot": "0.0.0-dev", - "xunit": "2.2.0-beta2-build3300", "dotnet-test-xunit": "2.2.0-preview2-build1029", "Ocelot.ManualTest": "0.0.0-dev", "Microsoft.AspNetCore.TestHost": "1.1.0", @@ -33,7 +32,9 @@ "Microsoft.NETCore.App": "1.1.0", "Shouldly": "2.8.2", "TestStack.BDDfy": "4.3.2", - "Consul": "0.7.2.1" + "Consul": "0.7.2.1", + "Microsoft.Extensions.Caching.Memory": "1.1.0", + "xunit": "2.2.0-rc1-build3507" }, "runtimes": { "win10-x64": {}, diff --git a/test/Ocelot.ManualTest/Startup.cs b/test/Ocelot.ManualTest/Startup.cs index 70448fcf..767aed43 100644 --- a/test/Ocelot.ManualTest/Startup.cs +++ b/test/Ocelot.ManualTest/Startup.cs @@ -37,7 +37,6 @@ namespace Ocelot.ManualTest }) .WithDictionaryHandle(); }; - services.AddOcelotOutputCaching(settings); services.AddOcelotFileConfiguration(Configuration); services.AddOcelot(); diff --git a/test/Ocelot.UnitTests/RateLimit/ClientRateLimitMiddlewareTests.cs b/test/Ocelot.UnitTests/RateLimit/ClientRateLimitMiddlewareTests.cs new file mode 100644 index 00000000..8b9b40ee --- /dev/null +++ b/test/Ocelot.UnitTests/RateLimit/ClientRateLimitMiddlewareTests.cs @@ -0,0 +1,149 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.AspNetCore.Http; +using Moq; +using Ocelot.Infrastructure.RequestData; +using Ocelot.RateLimit; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Ocelot.Logging; +using System.IO; +using Ocelot.RateLimit.Middleware; +using Ocelot.DownstreamRouteFinder; +using Ocelot.Responses; +using Xunit; +using TestStack.BDDfy; +using Ocelot.Configuration.Builder; +using Shouldly; +using Ocelot.Configuration; + +namespace Ocelot.UnitTests.RateLimit +{ + public class ClientRateLimitMiddlewareTests + { + private readonly Mock _scopedRepository; + private readonly string _url; + private readonly TestServer _server; + private readonly HttpClient _client; + private OkResponse _downstreamRoute; + private int responseStatusCode; + + public ClientRateLimitMiddlewareTests() + { + _url = "http://localhost:51879/api/ClientRateLimit"; + _scopedRepository = new Mock(); + var builder = new WebHostBuilder() + .ConfigureServices(x => + { + x.AddSingleton(); + x.AddLogging(); + x.AddMemoryCache(); + x.AddSingleton(); + x.AddSingleton(_scopedRepository.Object); + }) + .UseUrls(_url) + .UseKestrel() + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseIISIntegration() + .UseUrls(_url) + .Configure(app => + { + app.UseRateLimiting(); + app.Run(async context => + { + context.Response.StatusCode = 200; + await context.Response.WriteAsync("This is ratelimit test"); + }); + }); + + _server = new TestServer(builder); + _client = _server.CreateClient(); + } + + + [Fact] + public void should_call_middleware_and_ratelimiting() + { + var downstreamRoute = new DownstreamRoute(new List(), + new ReRouteBuilder().WithEnableRateLimiting(true).WithRateLimitOptions( + new Ocelot.Configuration.RateLimitOptions(true, "ClientId", new List(), false, "", "", new Ocelot.Configuration.RateLimitRule("1s", TimeSpan.FromSeconds(100), 3), 429)) + .WithUpstreamHttpMethod("Get") + .Build()); + + this.Given(x => x.GivenTheDownStreamRouteIs(downstreamRoute)) + .When(x => x.WhenICallTheMiddlewareMultipleTime(2)) + .Then(x => x.ThenresponseStatusCodeIs200()) + .When(x => x.WhenICallTheMiddlewareMultipleTime(2)) + .Then(x => x.ThenresponseStatusCodeIs429()) + .BDDfy(); + } + + [Fact] + public void should_call_middleware_withWhitelistClient() + { + var downstreamRoute = new DownstreamRoute(new List(), + new ReRouteBuilder().WithEnableRateLimiting(true).WithRateLimitOptions( + new Ocelot.Configuration.RateLimitOptions(true, "ClientId", new List() { "ocelotclient2" }, false, "", "", new RateLimitRule( "1s", TimeSpan.FromSeconds(100),3),429)) + .WithUpstreamHttpMethod("Get") + .Build()); + + this.Given(x => x.GivenTheDownStreamRouteIs(downstreamRoute)) + .When(x => x.WhenICallTheMiddlewareWithWhiteClient()) + .Then(x => x.ThenresponseStatusCodeIs200()) + .BDDfy(); + } + + + private void GivenTheDownStreamRouteIs(DownstreamRoute downstreamRoute) + { + _downstreamRoute = new OkResponse(downstreamRoute); + _scopedRepository + .Setup(x => x.Get(It.IsAny())) + .Returns(_downstreamRoute); + } + + private void WhenICallTheMiddlewareMultipleTime(int times) + { + var clientId = "ocelotclient1"; + // Act + for (int i = 0; i < times; i++) + { + var request = new HttpRequestMessage(new HttpMethod("GET"), _url); + request.Headers.Add("ClientId", clientId); + + var response = _client.SendAsync(request); + responseStatusCode = (int)response.Result.StatusCode; + } + + } + + private void WhenICallTheMiddlewareWithWhiteClient() + { + var clientId = "ocelotclient2"; + // Act + for (int i = 0; i < 10; i++) + { + var request = new HttpRequestMessage(new HttpMethod("GET"), _url); + request.Headers.Add("ClientId", clientId); + + var response = _client.SendAsync(request); + responseStatusCode = (int)response.Result.StatusCode; + } + } + + private void ThenresponseStatusCodeIs429() + { + responseStatusCode.ShouldBe(429); + } + + private void ThenresponseStatusCodeIs200() + { + responseStatusCode.ShouldBe(200); + } + } +} diff --git a/test/Ocelot.UnitTests/project.json b/test/Ocelot.UnitTests/project.json index 3151ac57..2f42c283 100644 --- a/test/Ocelot.UnitTests/project.json +++ b/test/Ocelot.UnitTests/project.json @@ -14,7 +14,6 @@ "Microsoft.Extensions.Options.ConfigurationExtensions": "1.1.0", "Microsoft.AspNetCore.Http": "1.1.0", "Ocelot": "0.0.0-dev", - "xunit": "2.2.0-beta2-build3300", "dotnet-test-xunit": "2.2.0-preview2-build1029", "Moq": "4.6.38-alpha", "Microsoft.AspNetCore.TestHost": "1.1.0", @@ -24,7 +23,8 @@ "Shouldly": "2.8.2", "TestStack.BDDfy": "4.3.2", "Microsoft.AspNetCore.Authentication.OAuth": "1.1.0", - "Microsoft.DotNet.InternalAbstractions": "1.0.0" + "Microsoft.DotNet.InternalAbstractions": "1.0.0", + "xunit": "2.2.0-rc1-build3507" }, "runtimes": { "win10-x64": {},