forked from xiongyuxing/tiku-backend.net
344 lines
13 KiB
C#
344 lines
13 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Options;
|
|
using Tiku.Application.Auth;
|
|
using Tiku.Application.Security;
|
|
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")));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Successful_sms_consumes_quota_once_without_compensation()
|
|
{
|
|
await using var context = CreateContext();
|
|
var tenantId = await SeedTenantAsync(context);
|
|
var quota = new RecordingFeatureAccessService();
|
|
var service = CreateService(context, featureAccessService: quota);
|
|
|
|
await service.CreateCodeAsync(new SendSmsCodeRequest(
|
|
tenantId,
|
|
"13800000000",
|
|
SmsPurpose.Login,
|
|
"127.0.0.1",
|
|
"quota-success"));
|
|
|
|
Assert.Equal([(tenantId, SaasQuotaMetricCatalog.SmsCount, 1L)], quota.Consumptions);
|
|
Assert.Empty(quota.Releases);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Failed_sms_send_releases_reserved_quota()
|
|
{
|
|
await using var context = CreateContext();
|
|
var tenantId = await SeedTenantAsync(context);
|
|
var quota = new RecordingFeatureAccessService();
|
|
var service = CreateService(context, new FailingSmsProvider(), featureAccessService: quota);
|
|
|
|
await Assert.ThrowsAsync<SmsProviderException>(() => service.CreateCodeAsync(new SendSmsCodeRequest(
|
|
tenantId,
|
|
"13800000000",
|
|
SmsPurpose.Login,
|
|
"127.0.0.1",
|
|
"quota-failure")));
|
|
|
|
Assert.Equal([(tenantId, SaasQuotaMetricCatalog.SmsCount, 1L)], quota.Consumptions);
|
|
Assert.Equal([(tenantId, SaasQuotaMetricCatalog.SmsCount, 1L)], quota.Releases);
|
|
Assert.Equal(SmsVerificationStatus.Failed, Assert.Single(context.SmsVerificationCodes).Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Exhausted_sms_quota_rejects_before_provider_send()
|
|
{
|
|
await using var context = CreateContext();
|
|
var tenantId = await SeedTenantAsync(context);
|
|
var provider = new CapturingSmsProvider();
|
|
var quota = new RecordingFeatureAccessService { AllowConsumption = false };
|
|
var service = CreateService(context, provider, featureAccessService: quota);
|
|
|
|
var exception = await Assert.ThrowsAsync<FeatureAccessException>(() => service.CreateCodeAsync(new SendSmsCodeRequest(
|
|
tenantId,
|
|
"13800000000",
|
|
SmsPurpose.Login,
|
|
"127.0.0.1",
|
|
"quota-exhausted")));
|
|
|
|
Assert.Equal("feature_quota_exhausted", exception.Code);
|
|
Assert.Empty(provider.Requests);
|
|
Assert.Empty(quota.Releases);
|
|
}
|
|
|
|
private static SmsVerificationService CreateService(
|
|
TikuDbContext context,
|
|
ISmsProvider? provider = null,
|
|
SmsSecurityOptions? options = null,
|
|
IFeatureAccessService? featureAccessService = null)
|
|
{
|
|
return new SmsVerificationService(
|
|
context,
|
|
provider ?? new CapturingSmsProvider(),
|
|
featureAccessService ?? new RecordingFeatureAccessService(),
|
|
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"));
|
|
}
|
|
}
|
|
|
|
private sealed class FailingSmsProvider : ISmsProvider
|
|
{
|
|
public Task<SmsProviderSendResult> SendAsync(
|
|
SmsProviderSendRequest request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
throw new SmsProviderException("provider failed", "sms_provider_send_failed");
|
|
}
|
|
}
|
|
|
|
private sealed class RecordingFeatureAccessService : IFeatureAccessService
|
|
{
|
|
public bool AllowConsumption { get; init; } = true;
|
|
public List<(Guid TenantId, string MetricCode, long Amount)> Consumptions { get; } = [];
|
|
public List<(Guid TenantId, string MetricCode, long Amount)> Releases { get; } = [];
|
|
|
|
public Task<bool> TryConsumeQuotaAsync(
|
|
Guid tenantId,
|
|
string metricCode,
|
|
long amount,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
Consumptions.Add((tenantId, metricCode, amount));
|
|
return Task.FromResult(AllowConsumption);
|
|
}
|
|
|
|
public Task ReleaseQuotaAsync(
|
|
Guid tenantId,
|
|
string metricCode,
|
|
long amount,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
Releases.Add((tenantId, metricCode, amount));
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task<FeatureAccessDecision> EvaluateAsync(Guid tenantId, string featureCode, FeatureAccessOperation operation, CancellationToken cancellationToken = default) =>
|
|
throw new NotSupportedException();
|
|
|
|
public Task<IReadOnlySet<string>> GetEnabledFeaturesAsync(Guid tenantId, FeatureAccessOperation operation = FeatureAccessOperation.Read, CancellationToken cancellationToken = default) =>
|
|
throw new NotSupportedException();
|
|
|
|
public Task<IReadOnlySet<string>> FilterPermissionCodesAsync(Guid tenantId, IEnumerable<string> permissionCodes, FeatureAccessOperation operation = FeatureAccessOperation.Read, CancellationToken cancellationToken = default) =>
|
|
throw new NotSupportedException();
|
|
|
|
public Task<IReadOnlyCollection<FeatureQuotaSnapshot>> GetQuotaSummaryAsync(Guid tenantId, CancellationToken cancellationToken = default) =>
|
|
throw new NotSupportedException();
|
|
}
|
|
}
|