114 lines
4.0 KiB
C#
114 lines
4.0 KiB
C#
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<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 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);
|
|
}
|
|
}
|