feat(security): add distributed authorization foundation

This commit is contained in:
2026-07-29 10:40:10 +08:00
parent c7f9a4e3c9
commit df88fa19cb
76 changed files with 22020 additions and 88 deletions

View File

@@ -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);