-
Notifications
You must be signed in to change notification settings - Fork 232
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Added action filters for rate limits
- Loading branch information
1 parent
73f3a7c
commit 82b923d
Showing
10 changed files
with
395 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
using System.Collections.Generic; | ||
using Microsoft.AspNetCore.Http; | ||
using Microsoft.AspNetCore.Mvc; | ||
using Microsoft.AspNetCore.Mvc.Abstractions; | ||
using Microsoft.AspNetCore.Mvc.Filters; | ||
using Microsoft.AspNetCore.Routing; | ||
using Microsoft.Extensions.Primitives; | ||
using Moq; | ||
|
||
namespace RateLimiter.Tests.Helpers; | ||
|
||
public class ActionFilterHelper | ||
{ | ||
public static ActionExecutingContext CreateActionExecutingContext(string token, string actionName) | ||
{ | ||
var httpContext = new DefaultHttpContext | ||
{ | ||
Request = | ||
{ | ||
Headers = | ||
{ | ||
new KeyValuePair<string, StringValues>("AccessToken", token) | ||
} | ||
} | ||
}; | ||
|
||
var actionContext = new ActionContext | ||
{ | ||
HttpContext = httpContext, | ||
RouteData = new RouteData(), | ||
ActionDescriptor = new ActionDescriptor | ||
{ | ||
DisplayName = actionName | ||
} | ||
}; | ||
|
||
var actionExecutingContext = new ActionExecutingContext( | ||
actionContext, | ||
new List<IFilterMetadata>(), | ||
new Dictionary<string, object>(), | ||
new Mock<Controller>().Object | ||
); | ||
|
||
return actionExecutingContext; | ||
} | ||
} |
42 changes: 42 additions & 0 deletions
42
RateLimiter.Tests/MinimumTimespanBetweenCallsAttributeTests.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
using Microsoft.AspNetCore.Mvc; | ||
using NUnit.Framework; | ||
using RateLimiter.Attributes; | ||
using RateLimiter.Tests.Helpers; | ||
|
||
namespace RateLimiter.Tests; | ||
|
||
[TestFixture] | ||
public class MinimumTimespanBetweenCallsAttributeTests | ||
{ | ||
[Test] | ||
public void MinimumTimespanBetweenCalls_AllowsRequest_AfterInterval() | ||
{ | ||
// Arrange | ||
var filter = new MinimumTimespanBetweenCallsAttribute(5); | ||
var context = ActionFilterHelper.CreateActionExecutingContext("token", "ActionName"); | ||
|
||
// Act | ||
filter.OnActionExecuting(context); | ||
System.Threading.Thread.Sleep(5001); // Wait for the interval to pass | ||
filter.OnActionExecuting(context); | ||
|
||
// Assert | ||
Assert.IsNull(context.Result); | ||
} | ||
|
||
[Test] | ||
public void MinimumTimespanBetweenCalls_BlocksRequest_BeforeInterval() | ||
{ | ||
// Arrange | ||
var filter = new MinimumTimespanBetweenCallsAttribute(5); | ||
var context = ActionFilterHelper.CreateActionExecutingContext("token", "ActionName"); | ||
|
||
// Act | ||
filter.OnActionExecuting(context); | ||
filter.OnActionExecuting(context); // Should be blocked | ||
|
||
// Assert | ||
Assert.IsInstanceOf<ContentResult>(context.Result); | ||
Assert.AreEqual(429, ((ContentResult)context.Result).StatusCode); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
using Microsoft.AspNetCore.Mvc; | ||
using NUnit.Framework; | ||
using RateLimiter.Attributes; | ||
using RateLimiter.Tests.Helpers; | ||
|
||
namespace RateLimiter.Tests; | ||
|
||
[TestFixture] | ||
public class RateLimitPerTimespanAttributeTests | ||
{ | ||
[Test] | ||
[TestCase(5)] | ||
public void RateLimitPerTimespan_AllowsRequest_WithinLimit(int maxRequests) | ||
{ | ||
// Arrange | ||
var filter = new RateLimitPerTimespanAttribute(maxRequests, 60); | ||
var context = ActionFilterHelper.CreateActionExecutingContext("token", "ActionName"); | ||
|
||
for (var i = 0; i < maxRequests; i++) | ||
{ | ||
// Act | ||
filter.OnActionExecuting(context); | ||
} | ||
|
||
// Assert | ||
Assert.IsNull(context.Result); | ||
} | ||
|
||
[Test] | ||
public void RateLimitPerTimespan_BlocksRequest_ExceedsLimit() | ||
{ | ||
// Arrange | ||
var filter = new RateLimitPerTimespanAttribute(1, 60); | ||
var context = ActionFilterHelper.CreateActionExecutingContext("token", "ActionName"); | ||
|
||
// Act | ||
filter.OnActionExecuting(context); | ||
filter.OnActionExecuting(context); // Should be blocked | ||
|
||
// Assert | ||
Assert.IsInstanceOf<ContentResult>(context.Result); | ||
Assert.AreEqual(429, ((ContentResult)context.Result).StatusCode); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
using Microsoft.AspNetCore.Mvc; | ||
using NUnit.Framework; | ||
using RateLimiter.Attributes; | ||
using RateLimiter.Tests.Helpers; | ||
|
||
namespace RateLimiter.Tests; | ||
|
||
[TestFixture] | ||
public class RegionBasedRateLimitAttributeTests | ||
{ | ||
[Test] | ||
[TestCase(5)] | ||
public void RegionBasedRateLimit_AllowsUSRequest_WithinLimit(int maxRequests) | ||
{ | ||
// Arrange | ||
var filter = new RegionBasedRateLimitAttribute(maxRequests, 60, 10); | ||
var context = ActionFilterHelper.CreateActionExecutingContext("US-token", "ActionName"); | ||
|
||
for (var i = 0; i < maxRequests; i++) | ||
{ | ||
// Act | ||
filter.OnActionExecuting(context); | ||
} | ||
|
||
// Assert | ||
Assert.IsNull(context.Result); | ||
} | ||
|
||
[Test] | ||
public void RegionBasedRateLimit_AllowsUKRequest_AfterInterval() | ||
{ | ||
// Arrange | ||
var filter = new RegionBasedRateLimitAttribute(5, 60, 1); | ||
var context = ActionFilterHelper.CreateActionExecutingContext("UK-token", "ActionName"); | ||
|
||
// Act | ||
filter.OnActionExecuting(context); | ||
|
||
// Wait for the interval to pass | ||
System.Threading.Thread.Sleep(1001); | ||
|
||
// Act | ||
filter.OnActionExecuting(context); | ||
|
||
// Assert | ||
Assert.IsNull(context.Result); | ||
} | ||
|
||
[Test] | ||
public void RegionBasedRateLimit_BlocksUSRequest_ExceedsLimit() | ||
{ | ||
// Arrange | ||
var filter = new RegionBasedRateLimitAttribute(1, 60, 10); | ||
var context = ActionFilterHelper.CreateActionExecutingContext("US-token", "ActionName"); | ||
|
||
// Act | ||
filter.OnActionExecuting(context); | ||
filter.OnActionExecuting(context); // Exceed the limit | ||
|
||
// Assert | ||
Assert.IsInstanceOf<ContentResult>(context.Result); | ||
Assert.AreEqual(429, ((ContentResult)context.Result).StatusCode); | ||
} | ||
|
||
[Test] | ||
public void RegionBasedRateLimit_BlocksUKRequest_BeforeInterval() | ||
{ | ||
// Arrange | ||
var filter = new RegionBasedRateLimitAttribute(5, 60, 2); | ||
var context = ActionFilterHelper.CreateActionExecutingContext("UK-token", "ActionName"); | ||
|
||
// Act | ||
filter.OnActionExecuting(context); | ||
filter.OnActionExecuting(context); // Should be blocked | ||
|
||
// Assert | ||
Assert.IsInstanceOf<ContentResult>(context.Result); | ||
Assert.AreEqual(429, ((ContentResult)context.Result).StatusCode); | ||
} | ||
} |
55 changes: 55 additions & 0 deletions
55
RateLimiter/Attributes/MinimumTimespanBetweenCallsAttribute.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
using System; | ||
using System.Collections.Generic; | ||
using Microsoft.AspNetCore.Mvc; | ||
using Microsoft.AspNetCore.Mvc.Filters; | ||
using RateLimiter.Interfaces; | ||
|
||
namespace RateLimiter.Attributes; | ||
|
||
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] | ||
public class MinimumTimespanBetweenCallsAttribute : ActionFilterAttribute, IRateLimitingRule | ||
{ | ||
private readonly TimeSpan _timespan; | ||
private readonly Dictionary<string, DateTime> _lastRequests = new(); | ||
|
||
/// <summary> | ||
/// Request-limiting with a certain timespan passed since the last call rule. | ||
/// <param name="seconds">Defines a certain timespan passed since the last call.</param> | ||
/// </summary> | ||
public MinimumTimespanBetweenCallsAttribute(int seconds) | ||
{ | ||
_timespan = TimeSpan.FromSeconds(seconds); | ||
} | ||
|
||
public override void OnActionExecuting(ActionExecutingContext context) | ||
{ | ||
var token = context.HttpContext.Request.Headers["AccessToken"].ToString(); | ||
var resource = context.ActionDescriptor.DisplayName; | ||
|
||
if (!IsRequestAllowed(token, resource)) | ||
{ | ||
context.Result = new ContentResult | ||
{ | ||
StatusCode = 429, | ||
Content = "Rate limit exceeded." | ||
}; | ||
} | ||
|
||
base.OnActionExecuting(context); | ||
} | ||
|
||
public bool IsRequestAllowed(string token, string resource) | ||
{ | ||
var key = $"{token}_{resource}"; | ||
var now = DateTime.UtcNow; | ||
|
||
if (_lastRequests.TryGetValue(key, out var lastCall) && now - lastCall < _timespan) | ||
{ | ||
return false; | ||
} | ||
|
||
_lastRequests[key] = now; | ||
|
||
return true; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
using System; | ||
using System.Collections.Generic; | ||
using Microsoft.AspNetCore.Mvc; | ||
using Microsoft.AspNetCore.Mvc.Filters; | ||
using RateLimiter.Interfaces; | ||
|
||
namespace RateLimiter.Attributes; | ||
|
||
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] | ||
public class RateLimitPerTimespanAttribute : ActionFilterAttribute, IRateLimitingRule | ||
{ | ||
private readonly int _maxRequests; | ||
private readonly TimeSpan _timespan; | ||
private readonly Dictionary<string, List<DateTime>> _requests = new(); | ||
|
||
/// <summary> | ||
/// Request-limiting with X requests per timespan rule. | ||
/// <param name="maxRequests">Defines a maximum count of requests.</param> | ||
/// <param name="seconds">Defines a timespan.</param> | ||
/// </summary> | ||
public RateLimitPerTimespanAttribute(int maxRequests, int seconds) | ||
{ | ||
_maxRequests = maxRequests; | ||
_timespan = TimeSpan.FromSeconds(seconds); | ||
} | ||
|
||
public override void OnActionExecuting(ActionExecutingContext context) | ||
{ | ||
var token = context.HttpContext.Request.Headers["AccessToken"].ToString(); | ||
var resource = context.ActionDescriptor.DisplayName; | ||
|
||
if (!IsRequestAllowed(token, resource)) | ||
{ | ||
context.Result = new ContentResult | ||
{ | ||
StatusCode = 429, | ||
Content = "Rate limit exceeded." | ||
}; | ||
} | ||
|
||
base.OnActionExecuting(context); | ||
} | ||
|
||
public bool IsRequestAllowed(string token, string resource) | ||
{ | ||
var key = $"{token}_{resource}"; | ||
var now = DateTime.UtcNow; | ||
|
||
if (!_requests.ContainsKey(key)) | ||
{ | ||
_requests[key] = new List<DateTime>(); | ||
} | ||
|
||
_requests[key].Add(now); | ||
_requests[key].RemoveAll(timestamp => timestamp < now - _timespan); | ||
|
||
return _requests[key].Count <= _maxRequests; | ||
} | ||
} |
Oops, something went wrong.