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

@@ -404,6 +404,10 @@ public sealed class AuthService(
request.TenantId.Value,
user.Id,
cancellationToken);
// Persist the external identity and membership together only after the
// tenant policy and existing membership state have accepted the login.
// A denied first login must not leave a user or provider identity behind.
await dbContext.SaveChangesAsync(cancellationToken);
return await CompleteSuccessfulLoginAsync(
request.Realm,
@@ -502,7 +506,6 @@ public sealed class AuthService(
existingIdentity.UserId = user.Id;
existingIdentity.OpenId = wechatIdentity.OpenId;
existingIdentity.UnionId = wechatIdentity.UnionId;
await dbContext.SaveChangesAsync(cancellationToken);
return user;
}
@@ -552,22 +555,26 @@ public sealed class AuthService(
membership.UserId == userId &&
membership.Role == TenantRole.Student,
cancellationToken);
if (studentMembership is null)
if (studentMembership is not null)
{
dbContext.TenantMemberships.Add(new TenantMembership
{
TenantId = tenantId,
UserId = userId,
Role = TenantRole.Student,
Status = MembershipStatus.Active
});
}
else
{
studentMembership.Status = MembershipStatus.Active;
// Invited and Disabled memberships require an explicit administrator action.
throw new TenantAccessDeniedException();
}
await dbContext.SaveChangesAsync(cancellationToken);
var policy = await dbContext.TenantAuthPolicies.AsNoTracking()
.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken);
if (policy is not null && !policy.AllowExternalStudentSelfRegistration)
{
throw new TenantAccessDeniedException();
}
dbContext.TenantMemberships.Add(new TenantMembership
{
TenantId = tenantId,
UserId = userId,
Role = TenantRole.Student,
Status = MembershipStatus.Active
});
}
private async Task<TenantMembership?> FindActiveMembershipAsync(

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