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 { [$"{AuthRateLimitOptions.SectionName}:PasswordPermitLimit"] = "7", [$"{AuthRateLimitOptions.SectionName}:PasswordWindowSeconds"] = "600", [$"{AuthRateLimitOptions.SectionName}:SmsPermitLimit"] = "3", [$"{AuthRateLimitOptions.SectionName}:SmsWindowSeconds"] = "90", [$"{AuthRateLimitOptions.SectionName}:MfaPermitLimit"] = "4", [$"{AuthRateLimitOptions.SectionName}:MfaWindowSeconds"] = "120" }) .Build(); var options = configuration .GetSection(AuthRateLimitOptions.SectionName) .Get(); Assert.NotNull(options); Assert.Equal(7, options.PasswordPermitLimit); Assert.Equal(600, options.PasswordWindowSeconds); Assert.Equal(3, options.SmsPermitLimit); Assert.Equal(90, options.SmsWindowSeconds); Assert.Equal(4, options.MfaPermitLimit); Assert.Equal(120, options.MfaWindowSeconds); } [Fact] public void Authentication_rate_limit_values_must_be_positive() { var options = new AuthRateLimitOptions { PasswordPermitLimit = 0, MfaWindowSeconds = 0 }; var validationResults = new List(); 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); } [Theory] [InlineData(nameof(AuthController.SetupTotp))] [InlineData(nameof(AuthController.ConfirmTotp))] [InlineData(nameof(AuthController.VerifyTotp))] public void Mfa_challenge_endpoints_use_the_mfa_named_policy(string methodName) { AssertPolicy(methodName, AuthRateLimitPolicies.Mfa); } [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); } [Fact] public async Task Mfa_partition_uses_the_challenge_token_and_resets_the_request_body() { const string body = """{"challengeToken":"challenge-one","code":"123456"}"""; var first = await CapturePartitionAsync( AuthRateLimitPolicies.Mfa, body, "127.0.0.1"); var second = await CapturePartitionAsync( AuthRateLimitPolicies.Mfa, """{"challengeToken":"challenge-two","code":"123456"}""", "127.0.0.1"); Assert.NotEqual(first, second); Assert.DoesNotContain("challenge-one", 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(); Assert.NotNull(attribute); Assert.Equal(expectedPolicy, attribute.PolicyName); } private static async Task 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(partition); } }