using Microsoft.EntityFrameworkCore; using Tiku.Application.Auth; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Auth; public sealed class SmsVerificationService(TikuDbContext dbContext) : 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 verification = new SmsVerificationCode { TenantId = request.TenantId, Phone = phone, Purpose = request.Purpose, CodeHash = SmsCodeHashing.Hash(request.TenantId, phone, request.Purpose, code), Provider = "mock", 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 is SmsVerificationStatus.Expired or SmsVerificationStatus.Blocked) { 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); } }