165 lines
5.9 KiB
C#
165 lines
5.9 KiB
C#
using System.Net;
|
|
using System.Reflection;
|
|
using System.Text;
|
|
using System.ComponentModel.DataAnnotations;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.RateLimiting;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Tiku.Api.Controllers;
|
|
using Tiku.Api.Middleware;
|
|
using Tiku.Api.Options;
|
|
|
|
namespace Tiku.IntegrationTests.Api;
|
|
|
|
public sealed class AuthRateLimitPolicyTests
|
|
{
|
|
[Fact]
|
|
public void Authentication_rate_limits_bind_from_the_named_configuration_section()
|
|
{
|
|
var configuration = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
[$"{AuthRateLimitOptions.SectionName}:PasswordPermitLimit"] = "7",
|
|
[$"{AuthRateLimitOptions.SectionName}:PasswordWindowSeconds"] = "600",
|
|
[$"{AuthRateLimitOptions.SectionName}:SmsPermitLimit"] = "3",
|
|
[$"{AuthRateLimitOptions.SectionName}:SmsWindowSeconds"] = "90"
|
|
})
|
|
.Build();
|
|
|
|
var options = configuration
|
|
.GetSection(AuthRateLimitOptions.SectionName)
|
|
.Get<AuthRateLimitOptions>();
|
|
|
|
Assert.NotNull(options);
|
|
Assert.Equal(7, options.PasswordPermitLimit);
|
|
Assert.Equal(600, options.PasswordWindowSeconds);
|
|
Assert.Equal(3, options.SmsPermitLimit);
|
|
Assert.Equal(90, options.SmsWindowSeconds);
|
|
}
|
|
|
|
[Fact]
|
|
public void Authentication_rate_limit_values_must_be_positive()
|
|
{
|
|
var options = new AuthRateLimitOptions
|
|
{
|
|
PasswordPermitLimit = 0,
|
|
SmsWindowSeconds = 0
|
|
};
|
|
var validationResults = new List<ValidationResult>();
|
|
|
|
var valid = Validator.TryValidateObject(
|
|
options,
|
|
new ValidationContext(options),
|
|
validationResults,
|
|
validateAllProperties: true);
|
|
|
|
Assert.False(valid);
|
|
Assert.Equal(2, validationResults.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void Password_login_uses_the_password_named_policy()
|
|
{
|
|
AssertPolicy(nameof(AuthController.LoginWithPassword), AuthRateLimitPolicies.Password);
|
|
}
|
|
|
|
[Fact]
|
|
public void Sms_send_uses_the_sms_named_policy()
|
|
{
|
|
AssertPolicy(nameof(AuthController.SendSmsCode), AuthRateLimitPolicies.Sms);
|
|
}
|
|
|
|
[Fact]
|
|
public void Required_password_change_uses_the_password_named_policy()
|
|
{
|
|
AssertPolicy(nameof(AuthController.ChangeRequiredPassword), AuthRateLimitPolicies.Password);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Password_partition_combines_account_and_ip_without_exposing_the_account()
|
|
{
|
|
var first = await CapturePartitionAsync(
|
|
AuthRateLimitPolicies.Password,
|
|
"""{"Phone":"13800000000","password":"secret"}""",
|
|
"127.0.0.1");
|
|
var same = await CapturePartitionAsync(
|
|
AuthRateLimitPolicies.Password,
|
|
"""{"phone":"13800000000","password":"different"}""",
|
|
"127.0.0.1");
|
|
var differentAccount = await CapturePartitionAsync(
|
|
AuthRateLimitPolicies.Password,
|
|
"""{"phone":"13900000000","password":"secret"}""",
|
|
"127.0.0.1");
|
|
var differentIp = await CapturePartitionAsync(
|
|
AuthRateLimitPolicies.Password,
|
|
"""{"phone":"13800000000","password":"secret"}""",
|
|
"127.0.0.2");
|
|
|
|
Assert.Equal(first, same);
|
|
Assert.NotEqual(first, differentAccount);
|
|
Assert.NotEqual(first, differentIp);
|
|
Assert.DoesNotContain("13800000000", first, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("secret", first, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Sms_partition_combines_phone_and_ip_without_exposing_the_phone()
|
|
{
|
|
var first = await CapturePartitionAsync(
|
|
AuthRateLimitPolicies.Sms,
|
|
"""{"phone":"13800000000","deviceId":"device-one"}""",
|
|
"127.0.0.1");
|
|
var differentPhone = await CapturePartitionAsync(
|
|
AuthRateLimitPolicies.Sms,
|
|
"""{"phone":"13900000000","deviceId":"device-one"}""",
|
|
"127.0.0.1");
|
|
var differentIp = await CapturePartitionAsync(
|
|
AuthRateLimitPolicies.Sms,
|
|
"""{"phone":"13800000000","deviceId":"device-one"}""",
|
|
"127.0.0.2");
|
|
|
|
Assert.NotEqual(first, differentPhone);
|
|
Assert.NotEqual(first, differentIp);
|
|
Assert.DoesNotContain("13800000000", first, StringComparison.Ordinal);
|
|
}
|
|
|
|
private static void AssertPolicy(string methodName, string expectedPolicy)
|
|
{
|
|
var method = typeof(AuthController).GetMethod(methodName, BindingFlags.Public | BindingFlags.Instance);
|
|
var attribute = method?.GetCustomAttribute<EnableRateLimitingAttribute>();
|
|
|
|
Assert.NotNull(attribute);
|
|
Assert.Equal(expectedPolicy, attribute.PolicyName);
|
|
}
|
|
|
|
private static async Task<string> CapturePartitionAsync(
|
|
string policyName,
|
|
string json,
|
|
string ipAddress)
|
|
{
|
|
var context = new DefaultHttpContext();
|
|
context.Connection.RemoteIpAddress = IPAddress.Parse(ipAddress);
|
|
context.Request.Method = HttpMethods.Post;
|
|
context.Request.ContentType = "application/json";
|
|
context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json));
|
|
context.SetEndpoint(new Endpoint(
|
|
_ => Task.CompletedTask,
|
|
new EndpointMetadataCollection(new EnableRateLimitingAttribute(policyName)),
|
|
"auth-rate-limit-test"));
|
|
|
|
string? partition = null;
|
|
var middleware = new AuthRateLimitPartitionMiddleware(async nextContext =>
|
|
{
|
|
partition = AuthRateLimitPartitionKey.Resolve(nextContext, policyName);
|
|
using var reader = new StreamReader(
|
|
nextContext.Request.Body,
|
|
Encoding.UTF8,
|
|
leaveOpen: true);
|
|
Assert.Equal(json, await reader.ReadToEndAsync());
|
|
});
|
|
|
|
await middleware.InvokeAsync(context);
|
|
return Assert.IsType<string>(partition);
|
|
}
|
|
}
|