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 securityOptions) : ISmsVerificationService { public SmsVerificationService( TikuDbContext dbContext, ISmsProvider smsProvider, IOptions 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 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 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 BuildRateLimits(SendSmsCodeRequest request, string phone) { var limits = new List { 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 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); }