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 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); } }