Files
tiku-backend.net/Tiku.Infrastructure/Auth/SmsVerificationService.cs

499 lines
17 KiB
C#

using System.Globalization;
using System.Security.Cryptography;
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;
namespace Tiku.Infrastructure.Auth;
public sealed class SmsVerificationService(
TikuDbContext dbContext,
ISmsProvider smsProvider,
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 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;
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 ConsumeRateLimitsAsync(
SendSmsCodeRequest request,
string phone,
DateTimeOffset now,
CancellationToken cancellationToken)
{
var limits = BuildRateLimits(request, phone);
var bucketStart = TruncateToHour(now);
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);
}