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

153 lines
5.4 KiB
C#

using Microsoft.EntityFrameworkCore;
using Tiku.Domain.Common;
using Tiku.Application.Auth;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Auth;
public sealed class SmsVerificationService(
TikuDbContext dbContext,
ISmsProvider smsProvider) : ISmsVerificationService
{
private const int MaxPhoneRequestsPerHour = 5;
private static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(10);
public async Task<SmsSendResult> CreateCodeAsync(
SendSmsCodeRequest request,
CancellationToken cancellationToken = default)
{
var phone = SmsCodeHashing.NormalizePhone(request.Phone);
var bucketStart = TruncateToHour(DateTimeOffset.UtcNow);
var scopeHash = SmsCodeHashing.Hash(request.TenantId, phone, request.Purpose, "phone-bucket");
var rateLimit = await dbContext.SmsSendRateLimits.FindAsync(
[request.TenantId, SmsRateLimitDimension.Phone, scopeHash, bucketStart],
cancellationToken);
if (rateLimit is null)
{
rateLimit = new SmsSendRateLimit
{
TenantId = request.TenantId,
Dimension = SmsRateLimitDimension.Phone,
ScopeHash = scopeHash,
BucketStart = bucketStart
};
dbContext.SmsSendRateLimits.Add(rateLimit);
}
if (rateLimit.RequestCount >= MaxPhoneRequestsPerHour)
{
throw new SmsRateLimitedException();
}
rateLimit.RequestCount++;
rateLimit.UpdatedAt = DateTimeOffset.UtcNow;
var code = Random.Shared.Next(100000, 999999).ToString(System.Globalization.CultureInfo.InvariantCulture);
var codeHash = SmsCodeHashing.Hash(request.TenantId, phone, request.Purpose, code);
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 = DateTimeOffset.UtcNow,
IpAddress = request.IpAddress,
UserAgent = request.UserAgent,
Metadata = JsonDefaults.Object()
});
await dbContext.SaveChangesAsync(cancellationToken);
throw exception is SmsProviderException
? exception
: new SmsProviderException("SMS provider failed to send the verification code.", "sms_provider_send_failed", exception);
}
var verification = new SmsVerificationCode
{
TenantId = request.TenantId,
Phone = phone,
Purpose = request.Purpose,
CodeHash = codeHash,
Provider = sendResult.Provider,
Status = SmsVerificationStatus.Sent,
ExpiresAt = DateTimeOffset.UtcNow.Add(CodeLifetime),
IpAddress = request.IpAddress,
UserAgent = request.UserAgent
};
dbContext.SmsVerificationCodes.Add(verification);
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)
{
var normalizedPhone = SmsCodeHashing.NormalizePhone(phone);
var now = DateTimeOffset.UtcNow;
var codeHash = SmsCodeHashing.Hash(tenantId, normalizedPhone, purpose, code);
var verification = await dbContext.SmsVerificationCodes
.Where(entity =>
entity.TenantId == tenantId &&
entity.Phone == normalizedPhone &&
entity.Purpose == purpose &&
entity.ConsumedAt == null)
.OrderByDescending(entity => entity.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (verification is null ||
verification.ExpiresAt <= now ||
verification.Status != SmsVerificationStatus.Sent)
{
throw new InvalidCredentialsException("invalid_sms_code");
}
verification.Attempts++;
if (!string.Equals(verification.CodeHash, codeHash, StringComparison.Ordinal))
{
await dbContext.SaveChangesAsync(cancellationToken);
throw new InvalidCredentialsException("invalid_sms_code");
}
verification.Status = SmsVerificationStatus.Verified;
verification.ConsumedAt = now;
await dbContext.SaveChangesAsync(cancellationToken);
}
private static DateTimeOffset TruncateToHour(DateTimeOffset value)
{
return new DateTimeOffset(
value.Year,
value.Month,
value.Day,
value.Hour,
0,
0,
value.Offset);
}
}