forked from gongxuegit/tiku-backend.net
feat: harden SaaS authentication and authorization
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Domain.Common;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
@@ -8,44 +12,33 @@ namespace Tiku.Infrastructure.Auth;
|
||||
|
||||
public sealed class SmsVerificationService(
|
||||
TikuDbContext dbContext,
|
||||
ISmsProvider smsProvider) : ISmsVerificationService
|
||||
ISmsProvider smsProvider,
|
||||
IOptions<SmsSecurityOptions> securityOptions) : ISmsVerificationService
|
||||
{
|
||||
private const int MaxPhoneRequestsPerHour = 5;
|
||||
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<SmsSendResult> CreateCodeAsync(
|
||||
SendSmsCodeRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureValidOptions();
|
||||
|
||||
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);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
await ConsumeRateLimitsAsync(request, phone, now, cancellationToken);
|
||||
|
||||
if (rateLimit is null)
|
||||
{
|
||||
rateLimit = new SmsSendRateLimit
|
||||
{
|
||||
TenantId = request.TenantId,
|
||||
Dimension = SmsRateLimitDimension.Phone,
|
||||
ScopeHash = scopeHash,
|
||||
BucketStart = bucketStart
|
||||
};
|
||||
dbContext.SmsSendRateLimits.Add(rateLimit);
|
||||
}
|
||||
var code = RandomNumberGenerator
|
||||
.GetInt32(100000, 1000000)
|
||||
.ToString(CultureInfo.InvariantCulture);
|
||||
var codeHash = SmsCodeHashing.Hash(
|
||||
request.TenantId,
|
||||
phone,
|
||||
request.Purpose,
|
||||
code,
|
||||
options.CodePepper);
|
||||
|
||||
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
|
||||
{
|
||||
@@ -69,18 +62,38 @@ public sealed class SmsVerificationService(
|
||||
CodeHash = codeHash,
|
||||
Provider = "failed",
|
||||
Status = SmsVerificationStatus.Failed,
|
||||
ExpiresAt = DateTimeOffset.UtcNow,
|
||||
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);
|
||||
: 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,
|
||||
@@ -89,12 +102,29 @@ public sealed class SmsVerificationService(
|
||||
CodeHash = codeHash,
|
||||
Provider = sendResult.Provider,
|
||||
Status = SmsVerificationStatus.Sent,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.Add(CodeLifetime),
|
||||
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);
|
||||
@@ -107,35 +137,346 @@ public sealed class SmsVerificationService(
|
||||
string code,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureValidOptions();
|
||||
|
||||
var normalizedPhone = SmsCodeHashing.NormalizePhone(phone);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var codeHash = SmsCodeHashing.Hash(tenantId, normalizedPhone, purpose, code);
|
||||
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.ConsumedAt == null &&
|
||||
entity.Status == SmsVerificationStatus.Sent)
|
||||
.OrderByDescending(entity => entity.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (verification is null ||
|
||||
verification.ExpiresAt <= now ||
|
||||
verification.Status != SmsVerificationStatus.Sent)
|
||||
if (verification is null)
|
||||
{
|
||||
throw new InvalidCredentialsException("invalid_sms_code");
|
||||
}
|
||||
|
||||
verification.Attempts++;
|
||||
if (!string.Equals(verification.CodeHash, codeHash, StringComparison.Ordinal))
|
||||
if (verification.ExpiresAt <= now)
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
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 ConsumeRateLimitsAsync(
|
||||
SendSmsCodeRequest request,
|
||||
string phone,
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var limits = BuildRateLimits(request, phone);
|
||||
var bucketStart = TruncateToHour(now);
|
||||
|
||||
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<RateLimitSpec> 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<RateLimitSpec> BuildRateLimits(SendSmsCodeRequest request, string phone)
|
||||
{
|
||||
var limits = new List<RateLimitSpec>
|
||||
{
|
||||
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<bool> 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)
|
||||
@@ -149,4 +490,9 @@ public sealed class SmsVerificationService(
|
||||
0,
|
||||
value.Offset);
|
||||
}
|
||||
|
||||
private sealed record RateLimitSpec(
|
||||
SmsRateLimitDimension Dimension,
|
||||
string ScopeHash,
|
||||
int Maximum);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user