forked from gongxuegit/tiku-backend.net
feat: harden SaaS authentication and authorization
This commit is contained in:
188
Tiku.IntegrationTests/Api/AuthRateLimitPolicyTests.cs
Normal file
188
Tiku.IntegrationTests/Api/AuthRateLimitPolicyTests.cs
Normal file
@@ -0,0 +1,188 @@
|
||||
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",
|
||||
[$"{AuthRateLimitOptions.SectionName}:MfaPermitLimit"] = "4",
|
||||
[$"{AuthRateLimitOptions.SectionName}:MfaWindowSeconds"] = "120"
|
||||
})
|
||||
.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);
|
||||
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<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);
|
||||
}
|
||||
|
||||
[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<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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user