forked from gongxuegit/tiku-backend.net
feat: harden SaaS authentication and authorization
This commit is contained in:
231
Tiku.UnitTests/Auth/SmsVerificationServiceTests.cs
Normal file
231
Tiku.UnitTests/Auth/SmsVerificationServiceTests.cs
Normal file
@@ -0,0 +1,231 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Auth;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.UnitTests.Auth;
|
||||
|
||||
public sealed class SmsVerificationServiceTests
|
||||
{
|
||||
private const string Pepper = "unit-test-sms-code-pepper-32-characters";
|
||||
|
||||
[Fact]
|
||||
public void Hash_uses_the_server_pepper()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
|
||||
var first = SmsCodeHashing.Hash(tenantId, "13800000000", SmsPurpose.Login, "123456", Pepper);
|
||||
var second = SmsCodeHashing.Hash(
|
||||
tenantId,
|
||||
"13800000000",
|
||||
SmsPurpose.Login,
|
||||
"123456",
|
||||
"another-unit-test-pepper-32-characters");
|
||||
|
||||
Assert.NotEqual(first, second);
|
||||
Assert.Equal(64, first.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_code_generates_six_digits_and_consumes_all_available_rate_limit_dimensions()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var tenantId = await SeedTenantAsync(context);
|
||||
var provider = new CapturingSmsProvider();
|
||||
var service = CreateService(context, provider);
|
||||
|
||||
await service.CreateCodeAsync(new SendSmsCodeRequest(
|
||||
tenantId,
|
||||
"13800000000",
|
||||
SmsPurpose.Login,
|
||||
"127.0.0.1",
|
||||
"test-device"));
|
||||
|
||||
Assert.Matches("^[0-9]{6}$", Assert.Single(provider.Requests).Code);
|
||||
Assert.Equal(4, context.SmsSendRateLimits.Count());
|
||||
Assert.Contains(context.SmsSendRateLimits, item => item.Dimension == SmsRateLimitDimension.Tenant);
|
||||
Assert.Contains(context.SmsSendRateLimits, item => item.Dimension == SmsRateLimitDimension.Phone);
|
||||
Assert.Contains(context.SmsSendRateLimits, item => item.Dimension == SmsRateLimitDimension.Ip);
|
||||
Assert.Contains(context.SmsSendRateLimits, item => item.Dimension == SmsRateLimitDimension.Device);
|
||||
var sendEvent = Assert.Single(context.AuthLoginEvents);
|
||||
Assert.Equal(AuthLoginResult.Sent, sendEvent.Result);
|
||||
Assert.Equal("sms", sendEvent.Provider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fifth_invalid_attempt_blocks_code_and_correct_code_is_then_rejected()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var tenantId = await SeedTenantAsync(context);
|
||||
var verification = await SeedCodeAsync(context, tenantId, "123456");
|
||||
var service = CreateService(context);
|
||||
|
||||
for (var attempt = 0; attempt < 5; attempt++)
|
||||
{
|
||||
await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
|
||||
service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "999999"));
|
||||
}
|
||||
|
||||
context.ChangeTracker.Clear();
|
||||
var blocked = await context.SmsVerificationCodes.FindAsync(verification.Id);
|
||||
Assert.NotNull(blocked);
|
||||
Assert.Equal(5, blocked.Attempts);
|
||||
Assert.Equal(SmsVerificationStatus.Blocked, blocked.Status);
|
||||
await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
|
||||
service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "123456"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Expired_code_is_transitioned_to_expired()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var tenantId = await SeedTenantAsync(context);
|
||||
var verification = await SeedCodeAsync(
|
||||
context,
|
||||
tenantId,
|
||||
"123456",
|
||||
DateTimeOffset.UtcNow.AddSeconds(-1));
|
||||
var service = CreateService(context);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
|
||||
service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "123456"));
|
||||
|
||||
context.ChangeTracker.Clear();
|
||||
Assert.Equal(
|
||||
SmsVerificationStatus.Expired,
|
||||
(await context.SmsVerificationCodes.FindAsync(verification.Id))!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Successful_code_can_only_be_consumed_once()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var tenantId = await SeedTenantAsync(context);
|
||||
var verification = await SeedCodeAsync(context, tenantId, "123456");
|
||||
var service = CreateService(context);
|
||||
|
||||
await service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "123456");
|
||||
await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
|
||||
service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "123456"));
|
||||
|
||||
context.ChangeTracker.Clear();
|
||||
var consumed = await context.SmsVerificationCodes.FindAsync(verification.Id);
|
||||
Assert.Equal(SmsVerificationStatus.Verified, consumed!.Status);
|
||||
Assert.NotNull(consumed.ConsumedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Phone_limit_applies_across_ip_and_device_changes()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var tenantId = await SeedTenantAsync(context);
|
||||
var options = new SmsSecurityOptions
|
||||
{
|
||||
CodePepper = Pepper,
|
||||
TenantRequestsPerHour = 100,
|
||||
PhoneRequestsPerHour = 1,
|
||||
IpRequestsPerHour = 20,
|
||||
DeviceRequestsPerHour = 10
|
||||
};
|
||||
var service = CreateService(context, options: options);
|
||||
|
||||
await service.CreateCodeAsync(new SendSmsCodeRequest(
|
||||
tenantId,
|
||||
"13800000000",
|
||||
SmsPurpose.Login,
|
||||
"127.0.0.1",
|
||||
"device-one"));
|
||||
|
||||
await Assert.ThrowsAsync<SmsRateLimitedException>(() =>
|
||||
service.CreateCodeAsync(new SendSmsCodeRequest(
|
||||
tenantId,
|
||||
"13800000000",
|
||||
SmsPurpose.Login,
|
||||
"127.0.0.2",
|
||||
"device-two")));
|
||||
}
|
||||
|
||||
private static SmsVerificationService CreateService(
|
||||
TikuDbContext context,
|
||||
ISmsProvider? provider = null,
|
||||
SmsSecurityOptions? options = null)
|
||||
{
|
||||
return new SmsVerificationService(
|
||||
context,
|
||||
provider ?? new CapturingSmsProvider(),
|
||||
Options.Create(options ?? ValidOptions()));
|
||||
}
|
||||
|
||||
private static SmsSecurityOptions ValidOptions()
|
||||
{
|
||||
return new SmsSecurityOptions
|
||||
{
|
||||
CodePepper = Pepper,
|
||||
TenantRequestsPerHour = 100,
|
||||
PhoneRequestsPerHour = 5,
|
||||
IpRequestsPerHour = 20,
|
||||
DeviceRequestsPerHour = 10
|
||||
};
|
||||
}
|
||||
|
||||
private static TikuDbContext CreateContext()
|
||||
{
|
||||
return new TikuDbContext(
|
||||
new DbContextOptionsBuilder<TikuDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options);
|
||||
}
|
||||
|
||||
private static async Task<Guid> SeedTenantAsync(TikuDbContext context)
|
||||
{
|
||||
var tenant = new Tenant
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Slug = Guid.NewGuid().ToString("N"),
|
||||
Name = "SMS Test Tenant"
|
||||
};
|
||||
context.Tenants.Add(tenant);
|
||||
await context.SaveChangesAsync();
|
||||
return tenant.Id;
|
||||
}
|
||||
|
||||
private static async Task<SmsVerificationCode> SeedCodeAsync(
|
||||
TikuDbContext context,
|
||||
Guid tenantId,
|
||||
string code,
|
||||
DateTimeOffset? expiresAt = null)
|
||||
{
|
||||
var verification = new SmsVerificationCode
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Phone = "13800000000",
|
||||
Purpose = SmsPurpose.Login,
|
||||
CodeHash = SmsCodeHashing.Hash(
|
||||
tenantId,
|
||||
"13800000000",
|
||||
SmsPurpose.Login,
|
||||
code,
|
||||
Pepper),
|
||||
Status = SmsVerificationStatus.Sent,
|
||||
ExpiresAt = expiresAt ?? DateTimeOffset.UtcNow.AddMinutes(5)
|
||||
};
|
||||
context.SmsVerificationCodes.Add(verification);
|
||||
await context.SaveChangesAsync();
|
||||
return verification;
|
||||
}
|
||||
|
||||
private sealed class CapturingSmsProvider : ISmsProvider
|
||||
{
|
||||
public List<SmsProviderSendRequest> Requests { get; } = [];
|
||||
|
||||
public Task<SmsProviderSendResult> SendAsync(
|
||||
SmsProviderSendRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Requests.Add(request);
|
||||
return Task.FromResult(new SmsProviderSendResult("test", "sent", "message-id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user