566 lines
20 KiB
C#
566 lines
20 KiB
C#
using System.Globalization;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Options;
|
|
using Tiku.Application.Auth;
|
|
using Tiku.Domain.Common;
|
|
using Tiku.Domain.Tenancy;
|
|
using Tiku.Infrastructure.Persistence;
|
|
using Tiku.Application.Security;
|
|
using Tiku.Infrastructure.Security;
|
|
|
|
namespace Tiku.Infrastructure.Auth;
|
|
|
|
public sealed class SmsVerificationService(
|
|
TikuDbContext dbContext,
|
|
ISmsProvider smsProvider,
|
|
IRedisSecurityStore redisSecurityStore,
|
|
IOptions<SmsSecurityOptions> securityOptions) : ISmsVerificationService
|
|
{
|
|
public SmsVerificationService(
|
|
TikuDbContext dbContext,
|
|
ISmsProvider smsProvider,
|
|
IOptions<SmsSecurityOptions> securityOptions)
|
|
: this(dbContext, smsProvider, new NullRedisSecurityStore(), securityOptions)
|
|
{
|
|
}
|
|
|
|
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)
|
|
{
|
|
EnsureValidOptions();
|
|
|
|
var phone = SmsCodeHashing.NormalizePhone(request.Phone);
|
|
var now = DateTimeOffset.UtcNow;
|
|
await ConsumeRateLimitsAsync(request, phone, now, cancellationToken);
|
|
|
|
var code = RandomNumberGenerator
|
|
.GetInt32(100000, 1000000)
|
|
.ToString(CultureInfo.InvariantCulture);
|
|
var codeHash = SmsCodeHashing.Hash(
|
|
request.TenantId,
|
|
phone,
|
|
request.Purpose,
|
|
code,
|
|
options.CodePepper);
|
|
|
|
SmsProviderSendResult sendResult;
|
|
try
|
|
{
|
|
sendResult = await smsProvider.SendAsync(
|
|
new SmsProviderSendRequest(
|
|
request.TenantId,
|
|
phone,
|
|
request.Purpose,
|
|
code,
|
|
request.IpAddress,
|
|
request.UserAgent),
|
|
cancellationToken);
|
|
}
|
|
catch (Exception exception) when (exception is not OperationCanceledException)
|
|
{
|
|
dbContext.SmsVerificationCodes.Add(new SmsVerificationCode
|
|
{
|
|
TenantId = request.TenantId,
|
|
Phone = phone,
|
|
Purpose = request.Purpose,
|
|
CodeHash = codeHash,
|
|
Provider = "failed",
|
|
Status = SmsVerificationStatus.Failed,
|
|
ExpiresAt = now,
|
|
IpAddress = request.IpAddress,
|
|
UserAgent = request.UserAgent,
|
|
Metadata = JsonDefaults.Object()
|
|
});
|
|
dbContext.AuthLoginEvents.Add(new AuthLoginEvent
|
|
{
|
|
TenantId = request.TenantId,
|
|
Provider = "sms",
|
|
Identifier = phone,
|
|
Result = AuthLoginResult.Failed,
|
|
FailureCode = "sms_provider_send_failed",
|
|
IpAddress = request.IpAddress,
|
|
UserAgent = request.UserAgent
|
|
});
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
|
|
throw exception is SmsProviderException
|
|
? exception
|
|
: new SmsProviderException(
|
|
"SMS provider failed to send the verification code.",
|
|
"sms_provider_send_failed",
|
|
exception);
|
|
}
|
|
|
|
await ExpirePreviousCodesAsync(
|
|
request.TenantId,
|
|
phone,
|
|
request.Purpose,
|
|
now,
|
|
cancellationToken);
|
|
|
|
var verification = new SmsVerificationCode
|
|
{
|
|
TenantId = request.TenantId,
|
|
Phone = phone,
|
|
Purpose = request.Purpose,
|
|
CodeHash = codeHash,
|
|
Provider = sendResult.Provider,
|
|
Status = SmsVerificationStatus.Sent,
|
|
ExpiresAt = now.Add(CodeLifetime),
|
|
IpAddress = request.IpAddress,
|
|
UserAgent = request.UserAgent
|
|
};
|
|
|
|
dbContext.SmsVerificationCodes.Add(verification);
|
|
dbContext.AuthLoginEvents.Add(new AuthLoginEvent
|
|
{
|
|
TenantId = request.TenantId,
|
|
Provider = "sms",
|
|
Identifier = phone,
|
|
Result = AuthLoginResult.Sent,
|
|
IpAddress = request.IpAddress,
|
|
UserAgent = request.UserAgent,
|
|
Metadata = JsonSerializer.SerializeToElement(new
|
|
{
|
|
verificationId = verification.Id,
|
|
sendResult.Provider,
|
|
sendResult.Status,
|
|
sendResult.MessageId,
|
|
request.DeviceId
|
|
})
|
|
});
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
|
|
return new SmsSendResult(verification.Id, verification.ExpiresAt);
|
|
}
|
|
|
|
public async Task VerifyCodeAsync(
|
|
Guid tenantId,
|
|
string phone,
|
|
SmsPurpose purpose,
|
|
string code,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
EnsureValidOptions();
|
|
|
|
var normalizedPhone = SmsCodeHashing.NormalizePhone(phone);
|
|
var now = DateTimeOffset.UtcNow;
|
|
await ConsumeVerificationLimitAsync(tenantId, normalizedPhone, purpose, cancellationToken);
|
|
var codeHash = SmsCodeHashing.Hash(
|
|
tenantId,
|
|
normalizedPhone,
|
|
purpose,
|
|
code,
|
|
options.CodePepper);
|
|
var verification = await dbContext.SmsVerificationCodes
|
|
.AsNoTracking()
|
|
.Where(entity =>
|
|
entity.TenantId == tenantId &&
|
|
entity.Phone == normalizedPhone &&
|
|
entity.Purpose == purpose &&
|
|
entity.ConsumedAt == null &&
|
|
entity.Status == SmsVerificationStatus.Sent)
|
|
.OrderByDescending(entity => entity.CreatedAt)
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
|
|
if (verification is null)
|
|
{
|
|
throw new InvalidCredentialsException("invalid_sms_code");
|
|
}
|
|
|
|
if (verification.ExpiresAt <= now)
|
|
{
|
|
await MarkExpiredAsync(verification.Id, now, cancellationToken);
|
|
throw new InvalidCredentialsException("invalid_sms_code");
|
|
}
|
|
|
|
if (HashesMatch(verification.CodeHash, codeHash))
|
|
{
|
|
var consumed = await TryConsumeAsync(verification.Id, now, cancellationToken);
|
|
if (consumed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
throw new InvalidCredentialsException("invalid_sms_code");
|
|
}
|
|
|
|
await RecordFailedAttemptAsync(verification.Id, now, cancellationToken);
|
|
throw new InvalidCredentialsException("invalid_sms_code");
|
|
}
|
|
|
|
private async Task ConsumeVerificationLimitAsync(
|
|
Guid tenantId,
|
|
string phone,
|
|
SmsPurpose purpose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!redisSecurityStore.IsConfigured)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var phoneHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(phone)))
|
|
.ToLowerInvariant();
|
|
try
|
|
{
|
|
var result = await redisSecurityStore.ConsumeAsync(
|
|
[
|
|
new DistributedRateLimitBucket(
|
|
$"sms-verify:{tenantId:N}:{purpose.ToString().ToLowerInvariant()}:{phoneHash}",
|
|
options.MaxVerificationAttempts,
|
|
CodeLifetime)
|
|
], cancellationToken);
|
|
if (!result.Allowed)
|
|
{
|
|
throw new SmsRateLimitedException();
|
|
}
|
|
}
|
|
catch (RedisSecurityUnavailableException)
|
|
{
|
|
throw new AuthSecurityUnavailableException();
|
|
}
|
|
}
|
|
|
|
private async Task ConsumeRateLimitsAsync(
|
|
SendSmsCodeRequest request,
|
|
string phone,
|
|
DateTimeOffset now,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var limits = BuildRateLimits(request, phone);
|
|
var bucketStart = TruncateToHour(now);
|
|
|
|
if (redisSecurityStore.IsConfigured)
|
|
{
|
|
try
|
|
{
|
|
var distributed = await redisSecurityStore.ConsumeAsync(
|
|
limits.Select(limit => new DistributedRateLimitBucket(
|
|
$"sms-send:{request.TenantId:N}:{ToSnakeCase(limit.Dimension)}:{limit.ScopeHash}",
|
|
limit.Maximum,
|
|
TimeSpan.FromHours(1))).ToArray(),
|
|
cancellationToken);
|
|
if (!distributed.Allowed)
|
|
{
|
|
throw new SmsRateLimitedException();
|
|
}
|
|
}
|
|
catch (RedisSecurityUnavailableException)
|
|
{
|
|
throw new AuthSecurityUnavailableException();
|
|
}
|
|
}
|
|
|
|
if (!dbContext.Database.IsRelational())
|
|
{
|
|
await ConsumeInMemoryRateLimitsAsync(limits, request.TenantId, bucketStart, now, cancellationToken);
|
|
return;
|
|
}
|
|
|
|
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
|
foreach (var limit in limits)
|
|
{
|
|
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);
|
|
|
|
if (affected == 0)
|
|
{
|
|
await transaction.RollbackAsync(cancellationToken);
|
|
throw new SmsRateLimitedException();
|
|
}
|
|
}
|
|
|
|
await transaction.CommitAsync(cancellationToken);
|
|
}
|
|
|
|
private async Task ConsumeInMemoryRateLimitsAsync(
|
|
IReadOnlyCollection<RateLimitSpec> limits,
|
|
Guid tenantId,
|
|
DateTimeOffset bucketStart,
|
|
DateTimeOffset now,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await InMemoryRateLimitLock.WaitAsync(cancellationToken);
|
|
try
|
|
{
|
|
var counters = new List<(RateLimitSpec Limit, SmsSendRateLimit? Counter)>();
|
|
foreach (var limit in limits)
|
|
{
|
|
var counter = await dbContext.SmsSendRateLimits.FindAsync(
|
|
[tenantId, limit.Dimension, limit.ScopeHash, bucketStart],
|
|
cancellationToken);
|
|
if (counter?.RequestCount >= limit.Maximum)
|
|
{
|
|
throw new SmsRateLimitedException();
|
|
}
|
|
|
|
counters.Add((limit, counter));
|
|
}
|
|
|
|
foreach (var (limit, existingCounter) in counters)
|
|
{
|
|
var counter = existingCounter;
|
|
if (counter is null)
|
|
{
|
|
counter = new SmsSendRateLimit
|
|
{
|
|
TenantId = tenantId,
|
|
Dimension = limit.Dimension,
|
|
ScopeHash = limit.ScopeHash,
|
|
BucketStart = bucketStart
|
|
};
|
|
dbContext.SmsSendRateLimits.Add(counter);
|
|
}
|
|
|
|
counter.RequestCount++;
|
|
counter.UpdatedAt = now;
|
|
}
|
|
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
}
|
|
finally
|
|
{
|
|
InMemoryRateLimitLock.Release();
|
|
}
|
|
}
|
|
|
|
private IReadOnlyList<RateLimitSpec> BuildRateLimits(SendSmsCodeRequest request, string phone)
|
|
{
|
|
var limits = new List<RateLimitSpec>
|
|
{
|
|
CreateLimit(SmsRateLimitDimension.Tenant, $"tenant:{request.TenantId:N}", options.TenantRequestsPerHour),
|
|
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;
|
|
}
|
|
|
|
private RateLimitSpec CreateLimit(SmsRateLimitDimension dimension, string scope, int maximum)
|
|
{
|
|
return new RateLimitSpec(
|
|
dimension,
|
|
SmsCodeHashing.HashScope(scope, options.CodePepper),
|
|
maximum);
|
|
}
|
|
|
|
private async Task ExpirePreviousCodesAsync(
|
|
Guid tenantId,
|
|
string phone,
|
|
SmsPurpose purpose,
|
|
DateTimeOffset now,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var query = dbContext.SmsVerificationCodes.Where(entity =>
|
|
entity.TenantId == tenantId &&
|
|
entity.Phone == phone &&
|
|
entity.Purpose == purpose &&
|
|
entity.ConsumedAt == null &&
|
|
(entity.Status == SmsVerificationStatus.Pending ||
|
|
entity.Status == SmsVerificationStatus.Sent));
|
|
|
|
if (dbContext.Database.IsRelational())
|
|
{
|
|
await query.ExecuteUpdateAsync(
|
|
setters => setters
|
|
.SetProperty(entity => entity.Status, SmsVerificationStatus.Expired)
|
|
.SetProperty(entity => entity.ExpiresAt, now),
|
|
cancellationToken);
|
|
return;
|
|
}
|
|
|
|
foreach (var verification in await query.ToListAsync(cancellationToken))
|
|
{
|
|
verification.Status = SmsVerificationStatus.Expired;
|
|
verification.ExpiresAt = now;
|
|
}
|
|
}
|
|
|
|
private async Task MarkExpiredAsync(Guid id, DateTimeOffset now, CancellationToken cancellationToken)
|
|
{
|
|
if (dbContext.Database.IsRelational())
|
|
{
|
|
await dbContext.SmsVerificationCodes
|
|
.Where(entity =>
|
|
entity.Id == id &&
|
|
entity.Status == SmsVerificationStatus.Sent &&
|
|
entity.ConsumedAt == null &&
|
|
entity.ExpiresAt <= now)
|
|
.ExecuteUpdateAsync(
|
|
setters => setters.SetProperty(entity => entity.Status, SmsVerificationStatus.Expired),
|
|
cancellationToken);
|
|
return;
|
|
}
|
|
|
|
var verification = await dbContext.SmsVerificationCodes.FindAsync([id], cancellationToken);
|
|
if (verification is not null &&
|
|
verification.Status == SmsVerificationStatus.Sent &&
|
|
verification.ConsumedAt is null &&
|
|
verification.ExpiresAt <= now)
|
|
{
|
|
verification.Status = SmsVerificationStatus.Expired;
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
}
|
|
}
|
|
|
|
private async Task<bool> TryConsumeAsync(Guid id, DateTimeOffset now, CancellationToken cancellationToken)
|
|
{
|
|
if (dbContext.Database.IsRelational())
|
|
{
|
|
var affected = await dbContext.SmsVerificationCodes
|
|
.Where(entity =>
|
|
entity.Id == id &&
|
|
entity.Status == SmsVerificationStatus.Sent &&
|
|
entity.ConsumedAt == null &&
|
|
entity.ExpiresAt > now &&
|
|
entity.Attempts < options.MaxVerificationAttempts)
|
|
.ExecuteUpdateAsync(
|
|
setters => setters
|
|
.SetProperty(entity => entity.Status, SmsVerificationStatus.Verified)
|
|
.SetProperty(entity => entity.ConsumedAt, now),
|
|
cancellationToken);
|
|
return affected == 1;
|
|
}
|
|
|
|
var verification = await dbContext.SmsVerificationCodes.FindAsync([id], cancellationToken);
|
|
if (verification is null ||
|
|
verification.Status != SmsVerificationStatus.Sent ||
|
|
verification.ConsumedAt is not null ||
|
|
verification.ExpiresAt <= now ||
|
|
verification.Attempts >= options.MaxVerificationAttempts)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
verification.Status = SmsVerificationStatus.Verified;
|
|
verification.ConsumedAt = now;
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
return true;
|
|
}
|
|
|
|
private async Task RecordFailedAttemptAsync(Guid id, DateTimeOffset now, CancellationToken cancellationToken)
|
|
{
|
|
if (dbContext.Database.IsRelational())
|
|
{
|
|
await dbContext.SmsVerificationCodes
|
|
.Where(entity =>
|
|
entity.Id == id &&
|
|
entity.Status == SmsVerificationStatus.Sent &&
|
|
entity.ConsumedAt == null &&
|
|
entity.ExpiresAt > now &&
|
|
entity.Attempts < options.MaxVerificationAttempts)
|
|
.ExecuteUpdateAsync(
|
|
setters => setters
|
|
.SetProperty(entity => entity.Attempts, entity => entity.Attempts + 1)
|
|
.SetProperty(
|
|
entity => entity.Status,
|
|
entity => entity.Attempts + 1 >= options.MaxVerificationAttempts
|
|
? SmsVerificationStatus.Blocked
|
|
: SmsVerificationStatus.Sent),
|
|
cancellationToken);
|
|
return;
|
|
}
|
|
|
|
var verification = await dbContext.SmsVerificationCodes.FindAsync([id], cancellationToken);
|
|
if (verification is null ||
|
|
verification.Status != SmsVerificationStatus.Sent ||
|
|
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);
|
|
}
|
|
|
|
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)
|
|
{
|
|
try
|
|
{
|
|
return CryptographicOperations.FixedTimeEquals(
|
|
Convert.FromHexString(expected),
|
|
Convert.FromHexString(actual));
|
|
}
|
|
catch (FormatException)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static string ToSnakeCase(SmsRateLimitDimension dimension)
|
|
{
|
|
return dimension.ToString().ToLowerInvariant();
|
|
}
|
|
|
|
private static DateTimeOffset TruncateToHour(DateTimeOffset value)
|
|
{
|
|
return new DateTimeOffset(
|
|
value.Year,
|
|
value.Month,
|
|
value.Day,
|
|
value.Hour,
|
|
0,
|
|
0,
|
|
value.Offset);
|
|
}
|
|
|
|
private sealed record RateLimitSpec(
|
|
SmsRateLimitDimension Dimension,
|
|
string ScopeHash,
|
|
int Maximum);
|
|
}
|