diff --git a/Crexi.Auctions.API/Controllers/AuctionsController.cs b/Crexi.Auctions.API/Controllers/AuctionsController.cs new file mode 100644 index 00000000..8c5660a0 --- /dev/null +++ b/Crexi.Auctions.API/Controllers/AuctionsController.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Crexi.Auctions.API.Controllers +{ + [ApiController] + [Route("[controller]")] + public class AuctionsController : ControllerBase + { + private readonly ILogger _logger; + + public AuctionsController(ILogger logger) + { + _logger = logger; + } + + [HttpGet] + public int Get() + { + return 1; + } + } +} diff --git a/Crexi.Auctions.API/Crexi.Auctions.API.csproj b/Crexi.Auctions.API/Crexi.Auctions.API.csproj new file mode 100644 index 00000000..e8362740 --- /dev/null +++ b/Crexi.Auctions.API/Crexi.Auctions.API.csproj @@ -0,0 +1,22 @@ + + + + net6.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/Crexi.Auctions.API/Middleware/RateLimiter/RateLimiterConfigurator.cs b/Crexi.Auctions.API/Middleware/RateLimiter/RateLimiterConfigurator.cs new file mode 100644 index 00000000..00b4b581 --- /dev/null +++ b/Crexi.Auctions.API/Middleware/RateLimiter/RateLimiterConfigurator.cs @@ -0,0 +1,41 @@ +using Crexi.API.Common.RateLimiter.Interfaces; +using Crexi.API.Common.RateLimiter.Rules; + +namespace Crexi.Auctions.API.Middleware.RateLimiter +{ + public static class RateLimiterConfigurator + { + public static void ConfigureRateLimitingRules(IRateLimiter rateLimiter) + { + var usRateLimitRule = new FixedWindowRateLimitRule( + maxRequestsPerClient: 10, + timeWindow: TimeSpan.FromSeconds(10) + ); + + var euRateLimitRule = new TimeSinceLastCallRule( + requiredInterval: TimeSpan.FromSeconds(2) + ); + + var usConditionalRule = new ConditionalRateLimitRule( + condition: client => client.Location == "US", + rule: usRateLimitRule + ); + + var euConditionalRule = new ConditionalRateLimitRule( + condition: client => client.Location == "EU", + rule: euRateLimitRule + ); + + var compositeRule = new CompositeRateLimitRule(new[] + { + usConditionalRule, + euConditionalRule + }); + + rateLimiter.ConfigureResource("/Auctions", compositeRule); + + var anotherRule = new FixedWindowRateLimitRule(50, TimeSpan.FromMinutes(10)); + rateLimiter.ConfigureResource("/AuctionSettings", anotherRule); + } + } +} diff --git a/Crexi.Auctions.API/Middleware/RateLimiter/RateLimiterMiddleware.cs b/Crexi.Auctions.API/Middleware/RateLimiter/RateLimiterMiddleware.cs new file mode 100644 index 00000000..f61e588a --- /dev/null +++ b/Crexi.Auctions.API/Middleware/RateLimiter/RateLimiterMiddleware.cs @@ -0,0 +1,42 @@ +using Crexi.API.Common.RateLimiter.Interfaces; + +public class RateLimiterMiddleware +{ + private readonly RequestDelegate _next; + private readonly IRateLimiter _rateLimiter; + private readonly ITokenToClientConverter _tokenToClientConverter; + private readonly ITokenExtractor _tokenExtractor; + + public RateLimiterMiddleware(RequestDelegate next, IRateLimiter rateLimiter, ITokenToClientConverter tokenToClientConverter, ITokenExtractor tokenExtractor) + { + _next = next; + _rateLimiter = rateLimiter; + _tokenToClientConverter = tokenToClientConverter; + _tokenExtractor = tokenExtractor; + } + + public async Task InvokeAsync(HttpContext context) + { + var accessToken = _tokenExtractor.ExtractToken(context); + + var client = _tokenToClientConverter.ConvertTokenToClient(accessToken); + + if (client == null) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + await context.Response.WriteAsync("Invalid or malformed access token."); + return; + } + + var resource = context.Request.Path.Value; + + if (!_rateLimiter.IsRequestAllowed(client, resource)) + { + context.Response.StatusCode = StatusCodes.Status429TooManyRequests; + await context.Response.WriteAsync("Rate limit exceeded."); + return; + } + + await _next(context); + } +} diff --git a/Crexi.Auctions.API/Middleware/RateLimiter/Util/ITokenExtractor.cs b/Crexi.Auctions.API/Middleware/RateLimiter/Util/ITokenExtractor.cs new file mode 100644 index 00000000..42524111 --- /dev/null +++ b/Crexi.Auctions.API/Middleware/RateLimiter/Util/ITokenExtractor.cs @@ -0,0 +1,5 @@ + +public interface ITokenExtractor +{ + string ExtractToken(HttpContext context); +} \ No newline at end of file diff --git a/Crexi.Auctions.API/Middleware/RateLimiter/Util/ITokenToClientConverter.cs b/Crexi.Auctions.API/Middleware/RateLimiter/Util/ITokenToClientConverter.cs new file mode 100644 index 00000000..39ae0afa --- /dev/null +++ b/Crexi.Auctions.API/Middleware/RateLimiter/Util/ITokenToClientConverter.cs @@ -0,0 +1,6 @@ +using Crexi.API.Common.RateLimiter.Models; + +public interface ITokenToClientConverter +{ + Client ConvertTokenToClient(string token); +} \ No newline at end of file diff --git a/Crexi.Auctions.API/Middleware/RateLimiter/Util/TokenExtractor.cs b/Crexi.Auctions.API/Middleware/RateLimiter/Util/TokenExtractor.cs new file mode 100644 index 00000000..9747146c --- /dev/null +++ b/Crexi.Auctions.API/Middleware/RateLimiter/Util/TokenExtractor.cs @@ -0,0 +1,13 @@ +public class TokenExtractor : ITokenExtractor +{ + public string ExtractToken(HttpContext context) + { + if (context.Request.Headers.TryGetValue("Authorization", out var authorizationHeader)) + { + var token = authorizationHeader.ToString().Split(' ').Last(); + return token; + } + + return null; + } +} diff --git a/Crexi.Auctions.API/Middleware/RateLimiter/Util/TokenToClientConverter.cs b/Crexi.Auctions.API/Middleware/RateLimiter/Util/TokenToClientConverter.cs new file mode 100644 index 00000000..9c03d9a8 --- /dev/null +++ b/Crexi.Auctions.API/Middleware/RateLimiter/Util/TokenToClientConverter.cs @@ -0,0 +1,30 @@ +using Crexi.API.Common.RateLimiter.Models; +using System.IdentityModel.Tokens.Jwt; + +public class TokenToClientConverter : ITokenToClientConverter +{ + public Client ConvertTokenToClient(string token) + { + try + { + var handler = new JwtSecurityTokenHandler(); + var jwtToken = handler.ReadJwtToken(token); + + // Extract client ID and location from token claims + var clientId = jwtToken.Claims.FirstOrDefault(c => c.Type == "client_id")?.Value; + var clientLocation = jwtToken.Claims.FirstOrDefault(c => c.Type == "location")?.Value; + + if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(clientLocation)) + { + return null; + } + + return new Client(clientId, clientLocation); + } + catch (Exception ex) + { + // TODO logging + return null; + } + } +} diff --git a/Crexi.Auctions.API/Program.cs b/Crexi.Auctions.API/Program.cs new file mode 100644 index 00000000..52eb67ec --- /dev/null +++ b/Crexi.Auctions.API/Program.cs @@ -0,0 +1,67 @@ +using Crexi.API.Common.RateLimiter; +using Crexi.API.Common.RateLimiter.Interfaces; +using Crexi.Auctions.API.Middleware.RateLimiter; +using Microsoft.OpenApi.Models; + +var builder = WebApplication.CreateBuilder(args); + +// Register services +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => +{ + c.SwaggerDoc("v1", new OpenApiInfo { Title = "Crexi Auction API", Version = "v1" }); + + // Add the security definition for Bearer tokens + c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + Description = "JWT Authorization header using the Bearer scheme. Example: \"Bearer {token}\"", + Name = "Authorization", + In = ParameterLocation.Header, + Type = SecuritySchemeType.ApiKey, + Scheme = "Bearer" + }); + + // Add the security requirement to include the Bearer token globally + c.AddSecurityRequirement(new OpenApiSecurityRequirement() + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + }, + Scheme = "oauth2", + Name = "Bearer", + In = ParameterLocation.Header, + }, + new List() + } + }); +}); + +builder.Services.AddSingleton(); +builder.Services.AddTransient(); +builder.Services.AddTransient(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.UseAuthentication(); +app.UseAuthorization(); + +RateLimiterConfigurator.ConfigureRateLimitingRules(app.Services.GetRequiredService()); +app.UseMiddleware(); + +app.MapControllers(); + +app.Run(); diff --git a/Crexi.Auctions.API/Properties/launchSettings.json b/Crexi.Auctions.API/Properties/launchSettings.json new file mode 100644 index 00000000..cbd84a50 --- /dev/null +++ b/Crexi.Auctions.API/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:42884", + "sslPort": 44366 + } + }, + "profiles": { + "Crexi.Auctions.API": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7145;http://localhost:5071", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Crexi.Auctions.API/appsettings.Development.json b/Crexi.Auctions.API/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/Crexi.Auctions.API/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Crexi.Auctions.API/appsettings.json b/Crexi.Auctions.API/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/Crexi.Auctions.API/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/RateLimiter.Tests/RateLimiter.Tests.csproj b/RateLimiter.Tests/Crexi.API.Common.RateLimiter.Tests.csproj similarity index 84% rename from RateLimiter.Tests/RateLimiter.Tests.csproj rename to RateLimiter.Tests/Crexi.API.Common.RateLimiter.Tests.csproj index 5cbfc4e8..75c0085a 100644 --- a/RateLimiter.Tests/RateLimiter.Tests.csproj +++ b/RateLimiter.Tests/Crexi.API.Common.RateLimiter.Tests.csproj @@ -5,7 +5,7 @@ enable - + diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs deleted file mode 100644 index 172d44a7..00000000 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ /dev/null @@ -1,13 +0,0 @@ -using NUnit.Framework; - -namespace RateLimiter.Tests; - -[TestFixture] -public class RateLimiterTest -{ - [Test] - public void Example() - { - Assert.That(true, Is.True); - } -} \ No newline at end of file diff --git a/RateLimiter.Tests/RateLimiterTests.cs b/RateLimiter.Tests/RateLimiterTests.cs new file mode 100644 index 00000000..ca8905d3 --- /dev/null +++ b/RateLimiter.Tests/RateLimiterTests.cs @@ -0,0 +1,129 @@ +using Crexi.API.Common.RateLimiter; +using Crexi.API.Common.RateLimiter.Models; +using Crexi.API.Common.RateLimiter.Rules; +using NUnit.Framework; +using System; +using System.Threading.Tasks; + +[TestFixture] +public class RateLimiterTests +{ + [Test] + public void FixedWindowRateLimitRule_Allows_Under_Limit() + { + var maxRequests = 5; + var timeWindow = TimeSpan.FromMinutes(1); + var rule = new FixedWindowRateLimitRule(maxRequests, timeWindow); + var client = new Client("client1", "US"); + bool allowed = true; + + for (int i = 0; i < maxRequests; i++) + { + allowed &= rule.IsRequestAllowed(client, "ResourceA"); + } + + Assert.IsTrue(allowed); + } + + [Test] + public void FixedWindowRateLimitRule_Blocks_Over_Limit() + { + var maxRequests = 5; + var timeWindow = TimeSpan.FromMinutes(1); + + var rule = new FixedWindowRateLimitRule(maxRequests, timeWindow); + var client = new Client("client1", "US"); + + for (int i = 0; i < maxRequests; i++) + { + rule.IsRequestAllowed(client, "ResourceA"); + } + + bool allowed = rule.IsRequestAllowed(client, "ResourceA"); + Assert.IsFalse(allowed); + } + + [Test] + public void TimeSinceLastCallRule_Allows_After_Interval() + { + var rule = new TimeSinceLastCallRule(TimeSpan.FromSeconds(1)); + var client = new Client("client1", "EU"); + + bool firstCall = rule.IsRequestAllowed(client, "ResourceA"); + Task.Delay(1100).Wait(); // Wait for more than 1 second + bool secondCall = rule.IsRequestAllowed(client, "ResourceA"); + + Assert.IsTrue(firstCall); + Assert.IsTrue(secondCall); + } + + [Test] + public void TimeSinceLastCallRule_Blocks_Before_Interval() + { + var interval = TimeSpan.FromSeconds(1); + var rule = new TimeSinceLastCallRule(interval); + var client = new Client("client1", "EU"); + + bool firstCall = rule.IsRequestAllowed(client, "ResourceA"); + bool secondCall = rule.IsRequestAllowed(client, "ResourceA"); + + Assert.IsTrue(firstCall); + Assert.IsFalse(secondCall); + } + + [Test] + public void ConditionalRateLimitRule_Applies_Correct_Rules() + { + var maxRequests = 2; + var usaTimeWindow = TimeSpan.FromMinutes(1); + var usRule = new FixedWindowRateLimitRule(maxRequests, usaTimeWindow); + var euInterval = TimeSpan.FromSeconds(1); + var euRule = new TimeSinceLastCallRule(euInterval); + + var usConditionalRule = new ConditionalRateLimitRule( + client => client.Location == "US", + usRule + ); + + var euConditionalRule = new ConditionalRateLimitRule( + client => client.Location == "EU", + euRule + ); + + var compositeRule = new CompositeRateLimitRule(new[] { usConditionalRule, euConditionalRule }); + var clientUS = new Client("clientUS", "US"); + var clientEU = new Client("clientEU", "EU"); + + // US client tests + Assert.IsTrue(compositeRule.IsRequestAllowed(clientUS, "ResourceA")); + Assert.IsTrue(compositeRule.IsRequestAllowed(clientUS, "ResourceA")); + Assert.IsFalse(compositeRule.IsRequestAllowed(clientUS, "ResourceA")); + + // EU client tests + Assert.IsTrue(compositeRule.IsRequestAllowed(clientEU, "ResourceA")); + Assert.IsFalse(compositeRule.IsRequestAllowed(clientEU, "ResourceA")); + Task.Delay(1100).Wait(); // Wait for more than 1 second + Assert.IsTrue(compositeRule.IsRequestAllowed(clientEU, "ResourceA")); + } + + [Test] + public void RateLimiter_Allows_And_Blocks_As_Configured() + { + var rateLimiter = new RateLimiter(); + var maxRequests = 2; + var timeWindow = TimeSpan.FromMinutes(1); + var usRule = new FixedWindowRateLimitRule(maxRequests, timeWindow); + var usConditionalRule = new ConditionalRateLimitRule( + client => client.Location == "US", + usRule + ); + + rateLimiter.ConfigureResource("ResourceA", usConditionalRule); + + var clientUS = new Client("clientUS", "US"); + + Assert.IsTrue(rateLimiter.IsRequestAllowed(clientUS, "ResourceA")); + Assert.IsTrue(rateLimiter.IsRequestAllowed(clientUS, "ResourceA")); + Assert.IsFalse(rateLimiter.IsRequestAllowed(clientUS, "ResourceA")); + } +} diff --git a/RateLimiter.sln b/RateLimiter.sln index 626a7bfa..4de0f17e 100644 --- a/RateLimiter.sln +++ b/RateLimiter.sln @@ -1,17 +1,21 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.26730.15 +# Visual Studio Version 17 +VisualStudioVersion = 17.12.35514.174 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RateLimiter", "RateLimiter\RateLimiter.csproj", "{36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Crexi.API.Common.RateLimiter", "RateLimiter\Crexi.API.Common.RateLimiter.csproj", "{36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RateLimiter.Tests", "RateLimiter.Tests\RateLimiter.Tests.csproj", "{C4F9249B-010E-46BE-94B8-DD20D82F1E60}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Crexi.API.Common.RateLimiter.Tests", "RateLimiter.Tests\Crexi.API.Common.RateLimiter.Tests.csproj", "{C4F9249B-010E-46BE-94B8-DD20D82F1E60}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{9B206889-9841-4B5E-B79B-D5B2610CCCFF}" ProjectSection(SolutionItems) = preProject README.md = README.md EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Crexi.Auctions.API", "Crexi.Auctions.API\Crexi.Auctions.API.csproj", "{69783054-D4D7-4A44-9FD1-9408084E1BD1}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{7BEB412D-D4AF-454B-A8CC-9D8AE43D2A2C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -26,10 +30,17 @@ Global {C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Debug|Any CPU.Build.0 = Debug|Any CPU {C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Release|Any CPU.ActiveCfg = Release|Any CPU {C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Release|Any CPU.Build.0 = Release|Any CPU + {69783054-D4D7-4A44-9FD1-9408084E1BD1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {69783054-D4D7-4A44-9FD1-9408084E1BD1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {69783054-D4D7-4A44-9FD1-9408084E1BD1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {69783054-D4D7-4A44-9FD1-9408084E1BD1}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {C4F9249B-010E-46BE-94B8-DD20D82F1E60} = {7BEB412D-D4AF-454B-A8CC-9D8AE43D2A2C} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {67D05CB6-8603-4C96-97E5-C6CEFBEC6134} EndGlobalSection diff --git a/RateLimiter/RateLimiter.csproj b/RateLimiter/Crexi.API.Common.RateLimiter.csproj similarity index 100% rename from RateLimiter/RateLimiter.csproj rename to RateLimiter/Crexi.API.Common.RateLimiter.csproj diff --git a/RateLimiter/Interfaces/IRateLimitRule.cs b/RateLimiter/Interfaces/IRateLimitRule.cs new file mode 100644 index 00000000..adf0b6ab --- /dev/null +++ b/RateLimiter/Interfaces/IRateLimitRule.cs @@ -0,0 +1,9 @@ +using Crexi.API.Common.RateLimiter.Models; + +namespace Crexi.API.Common.RateLimiter.Interfaces +{ + public interface IRateLimitRule + { + bool IsRequestAllowed(Client client, string resource); + } +} diff --git a/RateLimiter/Interfaces/IRateLimiter.cs b/RateLimiter/Interfaces/IRateLimiter.cs new file mode 100644 index 00000000..2daaefec --- /dev/null +++ b/RateLimiter/Interfaces/IRateLimiter.cs @@ -0,0 +1,11 @@ +using Crexi.API.Common.RateLimiter.Models; + +namespace Crexi.API.Common.RateLimiter.Interfaces +{ + public interface IRateLimiter + { + bool IsRequestAllowed(Client client, string resource); + + void ConfigureResource(string resource, IRateLimitRule rule); + } +} diff --git a/RateLimiter/Models/Client.cs b/RateLimiter/Models/Client.cs new file mode 100644 index 00000000..fffd9381 --- /dev/null +++ b/RateLimiter/Models/Client.cs @@ -0,0 +1,15 @@ +namespace Crexi.API.Common.RateLimiter.Models +{ + public class Client + { + public string Id { get; } + public string Location { get; } + + public Client(string id, string location) + { + Id = id; + Location = location; + } + } + +} diff --git a/RateLimiter/RateLimiter.cs b/RateLimiter/RateLimiter.cs new file mode 100644 index 00000000..7bb5a52e --- /dev/null +++ b/RateLimiter/RateLimiter.cs @@ -0,0 +1,31 @@ +using Crexi.API.Common.RateLimiter.Interfaces; +using Crexi.API.Common.RateLimiter.Models; +using System.Collections.Concurrent; + +namespace Crexi.API.Common.RateLimiter +{ + public class RateLimiter : IRateLimiter + { + private readonly ConcurrentDictionary _resourceRules; + + public RateLimiter() + { + _resourceRules = new ConcurrentDictionary(); + } + + public void ConfigureResource(string resource, IRateLimitRule rule) + { + _resourceRules[resource] = rule; + } + + public bool IsRequestAllowed(Client client, string resource) + { + if (_resourceRules.TryGetValue(resource, out var rule)) + { + return rule.IsRequestAllowed(client, resource); + } + // Allow by default if no rules are configured + return true; + } + } +} \ No newline at end of file diff --git a/RateLimiter/Rules/CompositeRateLimitRule.cs b/RateLimiter/Rules/CompositeRateLimitRule.cs new file mode 100644 index 00000000..c60ba29a --- /dev/null +++ b/RateLimiter/Rules/CompositeRateLimitRule.cs @@ -0,0 +1,29 @@ +using Crexi.API.Common.RateLimiter.Interfaces; +using Crexi.API.Common.RateLimiter.Models; +using System.Collections.Generic; + +namespace Crexi.API.Common.RateLimiter.Rules +{ + public class CompositeRateLimitRule : IRateLimitRule + { + private readonly IEnumerable _rules; + + public CompositeRateLimitRule(IEnumerable rules) + { + _rules = rules; + } + + public bool IsRequestAllowed(Client client, string resource) + { + foreach (var rule in _rules) + { + if (!rule.IsRequestAllowed(client, resource)) + { + return false; + } + } + return true; + } + } + +} \ No newline at end of file diff --git a/RateLimiter/Rules/ConditionalRateLimitRule.cs b/RateLimiter/Rules/ConditionalRateLimitRule.cs new file mode 100644 index 00000000..54f1d134 --- /dev/null +++ b/RateLimiter/Rules/ConditionalRateLimitRule.cs @@ -0,0 +1,28 @@ +using Crexi.API.Common.RateLimiter.Interfaces; +using Crexi.API.Common.RateLimiter.Models; +using System; + +namespace Crexi.API.Common.RateLimiter.Rules +{ + + public class ConditionalRateLimitRule : IRateLimitRule + { + private readonly Func _condition; + private readonly IRateLimitRule _rule; + + public ConditionalRateLimitRule(Func condition, IRateLimitRule rule) + { + _condition = condition; + _rule = rule; + } + + public bool IsRequestAllowed(Client client, string resource) + { + if (_condition(client)) + { + return _rule.IsRequestAllowed(client, resource); + } + return true; + } + } +} diff --git a/RateLimiter/Rules/FixedWindowRateLimitRule.cs b/RateLimiter/Rules/FixedWindowRateLimitRule.cs new file mode 100644 index 00000000..45ecea51 --- /dev/null +++ b/RateLimiter/Rules/FixedWindowRateLimitRule.cs @@ -0,0 +1,49 @@ +using Crexi.API.Common.RateLimiter.Interfaces; +using Crexi.API.Common.RateLimiter.Models; +using System; +using System.Collections.Concurrent; + +namespace Crexi.API.Common.RateLimiter.Rules +{ + public class FixedWindowRateLimitRule : IRateLimitRule + { + private readonly int _maxRequestsPerClientToResource; + private readonly TimeSpan _timeWindow; + private readonly ConcurrentDictionary _clientResourseRequestCounters; + + public FixedWindowRateLimitRule(int maxRequestsPerClient, TimeSpan timeWindow) + { + _maxRequestsPerClientToResource = maxRequestsPerClient; + _timeWindow = timeWindow; + _clientResourseRequestCounters = new ConcurrentDictionary(); + } + + public bool IsRequestAllowed(Client client, string resource) + { + string clientIdResourceCompositeKey = $"{client.Id}:{resource}"; + var currentClientRequestTime = DateTime.UtcNow; + + var reqCounterForClientToResource = _clientResourseRequestCounters.GetOrAdd(clientIdResourceCompositeKey, (0, currentClientRequestTime)); + + // Determine if current time has moved past the current rate limit window. If it has, reqCounter should be reset to start a new window. + var clientRequestInNewWindow = currentClientRequestTime - reqCounterForClientToResource.RateLimitWindowStart >= _timeWindow; + if (clientRequestInNewWindow) + { + reqCounterForClientToResource = (0, currentClientRequestTime); + } + + var clientToResourceLimitReached = reqCounterForClientToResource.RequestCountMadeByClientToResource >= _maxRequestsPerClientToResource; + if (clientToResourceLimitReached) + { + _clientResourseRequestCounters[clientIdResourceCompositeKey] = reqCounterForClientToResource; + return false; + } + + // Reset the counter if the current time has moved past the current rate limit window. + reqCounterForClientToResource = (reqCounterForClientToResource.RequestCountMadeByClientToResource + 1, reqCounterForClientToResource.RateLimitWindowStart); + _clientResourseRequestCounters[clientIdResourceCompositeKey] = reqCounterForClientToResource; + + return true; + } + } +} \ No newline at end of file diff --git a/RateLimiter/Rules/TimeSinceLastCallRule.cs b/RateLimiter/Rules/TimeSinceLastCallRule.cs new file mode 100644 index 00000000..0858eded --- /dev/null +++ b/RateLimiter/Rules/TimeSinceLastCallRule.cs @@ -0,0 +1,36 @@ +using Crexi.API.Common.RateLimiter.Interfaces; +using Crexi.API.Common.RateLimiter.Models; +using System; +using System.Collections.Concurrent; + +namespace Crexi.API.Common.RateLimiter.Rules +{ + public class TimeSinceLastCallRule : IRateLimitRule + { + private readonly TimeSpan _requiredIntervalBetweenRequestsByClientToResource; + private readonly ConcurrentDictionary _lastCallTimesByClientsToResources; + + public TimeSinceLastCallRule(TimeSpan requiredInterval) + { + _requiredIntervalBetweenRequestsByClientToResource = requiredInterval; + _lastCallTimesByClientsToResources = new ConcurrentDictionary(); + } + + public bool IsRequestAllowed(Client client, string resource) + { + string clientIdResourceCompositeKey = $"{client.Id}:{resource}"; + var callTime = DateTime.UtcNow; + + var lastCallTimeByClientToResource = _lastCallTimesByClientsToResources.GetOrAdd(clientIdResourceCompositeKey, DateTime.MinValue); + + bool isRateLimitExceeded = callTime - lastCallTimeByClientToResource < _requiredIntervalBetweenRequestsByClientToResource; + if (isRateLimitExceeded) + { + return false; + } + + _lastCallTimesByClientsToResources[clientIdResourceCompositeKey] = callTime; + return true; + } + } +} \ No newline at end of file