feat(security): add distributed authorization foundation
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -7,14 +8,25 @@ 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<SmsSecurityOptions> securityOptions) : ISmsVerificationService
|
||||
{
|
||||
public SmsVerificationService(
|
||||
TikuDbContext dbContext,
|
||||
ISmsProvider smsProvider,
|
||||
IOptions<SmsSecurityOptions> 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;
|
||||
@@ -141,6 +153,7 @@ public sealed class SmsVerificationService(
|
||||
|
||||
var normalizedPhone = SmsCodeHashing.NormalizePhone(phone);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
await ConsumeVerificationLimitAsync(tenantId, normalizedPhone, purpose, cancellationToken);
|
||||
var codeHash = SmsCodeHashing.Hash(
|
||||
tenantId,
|
||||
normalizedPhone,
|
||||
@@ -184,6 +197,39 @@ public sealed class SmsVerificationService(
|
||||
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,
|
||||
@@ -193,6 +239,27 @@ public sealed class SmsVerificationService(
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user