@@ -5,10 +5,10 @@ using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Auth;
|
||||
@@ -20,6 +20,10 @@ public sealed class SmsVerificationService(
|
||||
IFeatureAccessService featureAccessService,
|
||||
IOptions<SmsSecurityOptions> securityOptions) : ISmsVerificationService
|
||||
{
|
||||
private static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(10);
|
||||
private static readonly SemaphoreSlim InMemoryRateLimitLock = new(1, 1);
|
||||
private readonly SmsSecurityOptions options = securityOptions.Value;
|
||||
|
||||
public SmsVerificationService(
|
||||
TikuDbContext dbContext,
|
||||
ISmsProvider smsProvider,
|
||||
@@ -29,10 +33,6 @@ public sealed class SmsVerificationService(
|
||||
{
|
||||
}
|
||||
|
||||
private static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(10);
|
||||
private static readonly SemaphoreSlim InMemoryRateLimitLock = new(1, 1);
|
||||
private readonly SmsSecurityOptions options = securityOptions.Value;
|
||||
|
||||
public async Task<SmsSendResult> CreateCodeAsync(
|
||||
SendSmsCodeRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -48,11 +48,9 @@ public sealed class SmsVerificationService(
|
||||
1,
|
||||
cancellationToken);
|
||||
if (!quotaReserved)
|
||||
{
|
||||
throw new FeatureAccessException(
|
||||
"Tenant SMS quota is exhausted.",
|
||||
"feature_quota_exhausted");
|
||||
}
|
||||
|
||||
var code = RandomNumberGenerator
|
||||
.GetInt32(100000, 1000000)
|
||||
@@ -116,13 +114,11 @@ public sealed class SmsVerificationService(
|
||||
finally
|
||||
{
|
||||
if (!providerAccepted)
|
||||
{
|
||||
await featureAccessService.ReleaseQuotaAsync(
|
||||
request.TenantId,
|
||||
SaasQuotaMetricCatalog.SmsCount,
|
||||
1,
|
||||
CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
await ExpirePreviousCodesAsync(
|
||||
@@ -197,10 +193,7 @@ public sealed class SmsVerificationService(
|
||||
.OrderByDescending(entity => entity.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (verification is null)
|
||||
{
|
||||
throw new InvalidCredentialsException("invalid_sms_code");
|
||||
}
|
||||
if (verification is null) throw new InvalidCredentialsException("invalid_sms_code");
|
||||
|
||||
if (verification.ExpiresAt <= now)
|
||||
{
|
||||
@@ -211,10 +204,7 @@ public sealed class SmsVerificationService(
|
||||
if (HashesMatch(verification.CodeHash, codeHash))
|
||||
{
|
||||
var consumed = await TryConsumeAsync(verification.Id, now, cancellationToken);
|
||||
if (consumed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (consumed) return;
|
||||
|
||||
throw new InvalidCredentialsException("invalid_sms_code");
|
||||
}
|
||||
@@ -229,10 +219,7 @@ public sealed class SmsVerificationService(
|
||||
SmsPurpose purpose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!redisSecurityStore.IsConfigured)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!redisSecurityStore.IsConfigured) return;
|
||||
|
||||
var phoneHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(phone)))
|
||||
.ToLowerInvariant();
|
||||
@@ -245,10 +232,7 @@ public sealed class SmsVerificationService(
|
||||
options.MaxVerificationAttempts,
|
||||
CodeLifetime)
|
||||
], cancellationToken);
|
||||
if (!result.Allowed)
|
||||
{
|
||||
throw new SmsRateLimitedException();
|
||||
}
|
||||
if (!result.Allowed) throw new SmsRateLimitedException();
|
||||
}
|
||||
catch (RedisSecurityUnavailableException)
|
||||
{
|
||||
@@ -266,7 +250,6 @@ public sealed class SmsVerificationService(
|
||||
var bucketStart = TruncateToHour(now);
|
||||
|
||||
if (redisSecurityStore.IsConfigured)
|
||||
{
|
||||
try
|
||||
{
|
||||
var distributed = await redisSecurityStore.ConsumeAsync(
|
||||
@@ -275,16 +258,12 @@ public sealed class SmsVerificationService(
|
||||
limit.Maximum,
|
||||
TimeSpan.FromHours(1))).ToArray(),
|
||||
cancellationToken);
|
||||
if (!distributed.Allowed)
|
||||
{
|
||||
throw new SmsRateLimitedException();
|
||||
}
|
||||
if (!distributed.Allowed) throw new SmsRateLimitedException();
|
||||
}
|
||||
catch (RedisSecurityUnavailableException)
|
||||
{
|
||||
throw new AuthSecurityUnavailableException();
|
||||
}
|
||||
}
|
||||
|
||||
if (!dbContext.Database.IsRelational())
|
||||
{
|
||||
@@ -297,16 +276,16 @@ public sealed class SmsVerificationService(
|
||||
{
|
||||
var dimension = ToSnakeCase(limit.Dimension);
|
||||
var affected = await dbContext.Database.ExecuteSqlInterpolatedAsync($$"""
|
||||
INSERT INTO sms_send_rate_limits
|
||||
(tenant_id, dimension, scope_hash, bucket_start, request_count, updated_at)
|
||||
VALUES
|
||||
({{request.TenantId}}, {{dimension}}, {{limit.ScopeHash}}, {{bucketStart}}, 1, {{now}})
|
||||
ON CONFLICT (tenant_id, dimension, scope_hash, bucket_start)
|
||||
DO UPDATE SET
|
||||
request_count = sms_send_rate_limits.request_count + 1,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
WHERE sms_send_rate_limits.request_count < {{limit.Maximum}}
|
||||
""", cancellationToken);
|
||||
INSERT INTO sms_send_rate_limits
|
||||
(tenant_id, dimension, scope_hash, bucket_start, request_count, updated_at)
|
||||
VALUES
|
||||
({{request.TenantId}}, {{dimension}}, {{limit.ScopeHash}}, {{bucketStart}}, 1, {{now}})
|
||||
ON CONFLICT (tenant_id, dimension, scope_hash, bucket_start)
|
||||
DO UPDATE SET
|
||||
request_count = sms_send_rate_limits.request_count + 1,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
WHERE sms_send_rate_limits.request_count < {{limit.Maximum}}
|
||||
""", cancellationToken);
|
||||
|
||||
if (affected == 0)
|
||||
{
|
||||
@@ -334,10 +313,7 @@ public sealed class SmsVerificationService(
|
||||
var counter = await dbContext.SmsSendRateLimits.FindAsync(
|
||||
[tenantId, limit.Dimension, limit.ScopeHash, bucketStart],
|
||||
cancellationToken);
|
||||
if (counter?.RequestCount >= limit.Maximum)
|
||||
{
|
||||
throw new SmsRateLimitedException();
|
||||
}
|
||||
if (counter?.RequestCount >= limit.Maximum) throw new SmsRateLimitedException();
|
||||
|
||||
counters.Add((limit, counter));
|
||||
}
|
||||
@@ -374,27 +350,24 @@ public sealed class SmsVerificationService(
|
||||
var limits = new List<RateLimitSpec>
|
||||
{
|
||||
CreateLimit(SmsRateLimitDimension.Tenant, $"tenant:{request.TenantId:N}", options.TenantRequestsPerHour),
|
||||
CreateLimit(SmsRateLimitDimension.Phone, $"phone:{request.TenantId:N}:{phone}", options.PhoneRequestsPerHour)
|
||||
CreateLimit(SmsRateLimitDimension.Phone, $"phone:{request.TenantId:N}:{phone}",
|
||||
options.PhoneRequestsPerHour)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.IpAddress))
|
||||
{
|
||||
limits.Add(CreateLimit(
|
||||
SmsRateLimitDimension.Ip,
|
||||
$"ip:{request.IpAddress.Trim()}",
|
||||
options.IpRequestsPerHour));
|
||||
}
|
||||
|
||||
var deviceKey = string.IsNullOrWhiteSpace(request.DeviceId)
|
||||
? request.UserAgent
|
||||
: request.DeviceId;
|
||||
if (!string.IsNullOrWhiteSpace(deviceKey))
|
||||
{
|
||||
limits.Add(CreateLimit(
|
||||
SmsRateLimitDimension.Device,
|
||||
$"device:{deviceKey.Trim()}",
|
||||
options.DeviceRequestsPerHour));
|
||||
}
|
||||
|
||||
return limits;
|
||||
}
|
||||
@@ -491,9 +464,7 @@ public sealed class SmsVerificationService(
|
||||
verification.ConsumedAt is not null ||
|
||||
verification.ExpiresAt <= now ||
|
||||
verification.Attempts >= options.MaxVerificationAttempts)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
verification.Status = SmsVerificationStatus.Verified;
|
||||
verification.ConsumedAt = now;
|
||||
@@ -530,15 +501,11 @@ public sealed class SmsVerificationService(
|
||||
verification.ConsumedAt is not null ||
|
||||
verification.ExpiresAt <= now ||
|
||||
verification.Attempts >= options.MaxVerificationAttempts)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
verification.Attempts++;
|
||||
if (verification.Attempts >= options.MaxVerificationAttempts)
|
||||
{
|
||||
verification.Status = SmsVerificationStatus.Blocked;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -546,11 +513,9 @@ public sealed class SmsVerificationService(
|
||||
private void EnsureValidOptions()
|
||||
{
|
||||
if (!SmsSecurityOptions.BeValid(options))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{SmsSecurityOptions.SectionName} must contain a pepper of at least 32 characters, " +
|
||||
"exactly five verification attempts, and positive rate limits.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HashesMatch(string expected, string actual)
|
||||
@@ -588,4 +553,4 @@ public sealed class SmsVerificationService(
|
||||
SmsRateLimitDimension Dimension,
|
||||
string ScopeHash,
|
||||
int Maximum);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user