feat(auth): add Redis authorization caching
This commit is contained in:
@@ -9,15 +9,22 @@ using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Auth;
|
||||
|
||||
public sealed class AuthSessionStore(
|
||||
TikuDbContext dbContext,
|
||||
ITokenService tokenService,
|
||||
IOptions<JwtOptions> options) : IAuthSessionStore
|
||||
IOptions<JwtOptions> options,
|
||||
IAccessSecurityCache? configuredAccessSecurityCache = null,
|
||||
IOptions<AuthorizationCacheOptions>? configuredCacheOptions = null,
|
||||
IAuthorizationStateInvalidator? configuredStateInvalidator = null) : IAuthSessionStore
|
||||
{
|
||||
private readonly JwtOptions options = options.Value;
|
||||
private readonly IAccessSecurityCache accessSecurityCache = configuredAccessSecurityCache ?? new NullAuthorizationCache();
|
||||
private readonly AuthorizationCacheOptions cacheOptions = configuredCacheOptions?.Value ?? new AuthorizationCacheOptions();
|
||||
private readonly IAuthorizationStateInvalidator stateInvalidator = configuredStateInvalidator ?? new NullAuthorizationStateInvalidator();
|
||||
|
||||
public string GenerateRefreshToken(AuthRealm realm, Guid? tenantId, Guid sessionId)
|
||||
{
|
||||
@@ -80,6 +87,7 @@ public sealed class AuthSessionStore(
|
||||
|
||||
var tokenHash = HashRefreshToken(refreshToken);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
AuthorizationCacheTelemetry.PostgresFallback();
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
var current = await dbContext.AuthSessions.SingleOrDefaultAsync(
|
||||
item => item.Id == locator.SessionId && item.Realm == locator.Realm &&
|
||||
@@ -141,6 +149,7 @@ public sealed class AuthSessionStore(
|
||||
dbContext.AuthSessions.Add(next);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
await stateInvalidator.InvalidateSessionAsync(current.Id, cancellationToken);
|
||||
return CreatePair(request, next, nextToken);
|
||||
}
|
||||
|
||||
@@ -151,29 +160,63 @@ public sealed class AuthSessionStore(
|
||||
Guid? tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var lookup = new AccessSecurityCacheLookup(sessionId, userId, realm, tenantId);
|
||||
AccessSecurityCacheState? shadowState = null;
|
||||
if (cacheOptions.Mode == AuthorizationCacheMode.Active && accessSecurityCache.IsConfigured)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cached = await accessSecurityCache.GetAsync(lookup, cancellationToken);
|
||||
if (cached is not null)
|
||||
{
|
||||
var platformVersionStale = realm == AuthRealm.Platform &&
|
||||
cached.PlatformAccess!.AuthorizationVersion != cached.AuthorizationVersion!.Version;
|
||||
if (!platformVersionStale)
|
||||
{
|
||||
return ValidateCached(cached, lookup);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
// Redis is an acceleration layer; PostgreSQL remains authoritative.
|
||||
}
|
||||
}
|
||||
else if (cacheOptions.Mode == AuthorizationCacheMode.Shadow && accessSecurityCache.IsConfigured)
|
||||
{
|
||||
try
|
||||
{
|
||||
shadowState = await accessSecurityCache.GetAsync(lookup, cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
// Shadow failures never affect the PostgreSQL-authoritative decision.
|
||||
}
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var state = await (
|
||||
SessionValidationState? state;
|
||||
try
|
||||
{
|
||||
state = await (
|
||||
from session in dbContext.AuthSessions.AsNoTracking()
|
||||
join user in dbContext.Users.AsNoTracking() on session.UserId equals user.Id
|
||||
where session.Id == sessionId &&
|
||||
session.UserId == userId &&
|
||||
session.Realm == realm &&
|
||||
session.TenantId == tenantId &&
|
||||
session.RevokedAt == null &&
|
||||
session.ExpiresAt > now
|
||||
select new
|
||||
{
|
||||
UserStatus = user.Status,
|
||||
UserSecurityStamp = user.SecurityStamp,
|
||||
SessionSecurityStamp = session.SecurityStamp,
|
||||
TenantAllowed = realm != AuthRealm.Tenant ||
|
||||
session.TenantId == tenantId
|
||||
select new SessionValidationState(
|
||||
user.Status,
|
||||
user.SecurityStamp!,
|
||||
session.SecurityStamp,
|
||||
realm != AuthRealm.Tenant ||
|
||||
(tenantId != null &&
|
||||
dbContext.Tenants.Any(item => item.Id == tenantId && item.Status == TenantStatus.Active) &&
|
||||
dbContext.TenantMemberships.Any(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.UserId == userId &&
|
||||
item.Status == MembershipStatus.Active)),
|
||||
PlatformAllowed = realm != AuthRealm.Platform ||
|
||||
realm != AuthRealm.Platform ||
|
||||
(from userRole in dbContext.PlatformBackendUserRoles
|
||||
join role in dbContext.PlatformBackendRoles on userRole.RoleId equals role.Id
|
||||
join binding in dbContext.PlatformBackendRolePermissions on role.Id equals binding.RoleId
|
||||
@@ -181,21 +224,130 @@ public sealed class AuthSessionStore(
|
||||
where userRole.UserId == userId &&
|
||||
role.Status == BackendRoleStatus.Active &&
|
||||
(permission.Area == BackendPermissionArea.Platform || permission.Area == BackendPermissionArea.Both)
|
||||
select permission.Id).Any()
|
||||
})
|
||||
select permission.Id).Any(),
|
||||
realm == AuthRealm.Tenant && tenantId != null
|
||||
? dbContext.Tenants.Where(item => item.Id == tenantId).Select(item => (TenantStatus?)item.Status).FirstOrDefault()
|
||||
: null,
|
||||
realm == AuthRealm.Tenant && tenantId != null
|
||||
? dbContext.TenantMemberships.Where(item => item.TenantId == tenantId && item.UserId == userId)
|
||||
.Select(item => (MembershipStatus?)item.Status).FirstOrDefault()
|
||||
: null,
|
||||
dbContext.AuthorizationScopeVersions
|
||||
.Where(item => item.Realm == realm && item.TenantId == tenantId)
|
||||
.Select(item => (long?)item.Version).FirstOrDefault() ?? 1L,
|
||||
session.ExpiresAt,
|
||||
session.RevokedAt != null))
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
throw new AuthorizationSecurityUnavailableException(exception);
|
||||
}
|
||||
if (state is null ||
|
||||
state.SessionRevoked ||
|
||||
state.SessionExpiresAt <= now ||
|
||||
state.UserStatus != UserStatus.Active ||
|
||||
!string.Equals(state.UserSecurityStamp, state.SessionSecurityStamp, StringComparison.Ordinal) ||
|
||||
!state.TenantAllowed ||
|
||||
!state.PlatformAllowed)
|
||||
{
|
||||
if (state is not null &&
|
||||
cacheOptions.Mode is AuthorizationCacheMode.Active or AuthorizationCacheMode.Shadow &&
|
||||
accessSecurityCache.IsConfigured)
|
||||
{
|
||||
try
|
||||
{
|
||||
await accessSecurityCache.SetAsync(ToCacheState(
|
||||
state, sessionId, userId, realm, tenantId), cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
// A negative cache write failure does not change the denial decision.
|
||||
}
|
||||
}
|
||||
if (shadowState is not null)
|
||||
{
|
||||
AuthorizationCacheTelemetry.ShadowCompared(ValidateCached(shadowState, lookup) is null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AuthSessionValidationResult(userId, realm, tenantId);
|
||||
var result = new AuthSessionValidationResult(userId, realm, tenantId, state.AuthorizationVersion);
|
||||
if (shadowState is not null)
|
||||
{
|
||||
AuthorizationCacheTelemetry.ShadowCompared(ValidateCached(shadowState, lookup) == result);
|
||||
}
|
||||
if (cacheOptions.Mode is AuthorizationCacheMode.Active or AuthorizationCacheMode.Shadow && accessSecurityCache.IsConfigured)
|
||||
{
|
||||
try
|
||||
{
|
||||
await accessSecurityCache.SetAsync(
|
||||
ToCacheState(state, sessionId, userId, realm, tenantId), cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
// The database result is authoritative and remains usable.
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static AuthSessionValidationResult? ValidateCached(
|
||||
AccessSecurityCacheState state, AccessSecurityCacheLookup lookup)
|
||||
{
|
||||
var session = state.Session!;
|
||||
var user = state.User!;
|
||||
var version = state.AuthorizationVersion!;
|
||||
if (session.SessionId != lookup.SessionId || session.UserId != lookup.UserId ||
|
||||
session.Realm != lookup.Realm || session.TenantId != lookup.TenantId ||
|
||||
session.Revoked || session.ExpiresAt <= DateTimeOffset.UtcNow ||
|
||||
user.UserId != lookup.UserId || user.Status != UserStatus.Active ||
|
||||
!string.Equals(user.SecurityStamp, session.SecurityStamp, StringComparison.Ordinal) ||
|
||||
version.Realm != lookup.Realm || version.TenantId != lookup.TenantId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (lookup.Realm == AuthRealm.Tenant &&
|
||||
(state.Tenant!.Status != TenantStatus.Active || state.Membership!.Status != MembershipStatus.Active))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (lookup.Realm == AuthRealm.Platform &&
|
||||
(!state.PlatformAccess!.Allowed || state.PlatformAccess.AuthorizationVersion != version.Version))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return new AuthSessionValidationResult(lookup.UserId, lookup.Realm, lookup.TenantId, version.Version);
|
||||
}
|
||||
|
||||
private static AccessSecurityCacheState ToCacheState(
|
||||
SessionValidationState state, Guid sessionId, Guid userId, AuthRealm realm, Guid? tenantId) => new(
|
||||
new CachedSessionSecurityState(sessionId, userId, realm, tenantId,
|
||||
state.SessionSecurityStamp, state.SessionExpiresAt, state.SessionRevoked),
|
||||
new CachedUserSecurityState(userId, state.UserStatus, state.UserSecurityStamp),
|
||||
realm == AuthRealm.Tenant && state.TenantStatus.HasValue
|
||||
? new CachedTenantSecurityState(tenantId!.Value, state.TenantStatus.Value)
|
||||
: null,
|
||||
realm == AuthRealm.Tenant && state.MembershipStatus.HasValue
|
||||
? new CachedMembershipSecurityState(tenantId!.Value, userId, state.MembershipStatus.Value)
|
||||
: null,
|
||||
realm == AuthRealm.Platform
|
||||
? new CachedPlatformAccessState(userId, state.AuthorizationVersion, state.PlatformAllowed)
|
||||
: null,
|
||||
new CachedAuthorizationVersion(realm, tenantId, state.AuthorizationVersion));
|
||||
|
||||
private sealed record SessionValidationState(
|
||||
UserStatus UserStatus,
|
||||
string UserSecurityStamp,
|
||||
string SessionSecurityStamp,
|
||||
bool TenantAllowed,
|
||||
bool PlatformAllowed,
|
||||
TenantStatus? TenantStatus,
|
||||
MembershipStatus? MembershipStatus,
|
||||
long AuthorizationVersion,
|
||||
DateTimeOffset SessionExpiresAt,
|
||||
bool SessionRevoked);
|
||||
|
||||
public async Task<AuthSessionValidationResult?> ResolveActiveSessionAsync(
|
||||
Guid sessionId,
|
||||
Guid userId,
|
||||
@@ -237,6 +389,9 @@ public sealed class AuthSessionStore(
|
||||
public async Task RevokeAllAsync(Guid userId, string reason, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var sessionIds = await dbContext.AuthSessions.AsNoTracking()
|
||||
.Where(item => item.UserId == userId && item.RevokedAt == null)
|
||||
.Select(item => item.Id).ToArrayAsync(cancellationToken);
|
||||
var count = await dbContext.AuthSessions.Where(item => item.UserId == userId && item.RevokedAt == null)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(item => item.RevokedAt, DateTimeOffset.UtcNow)
|
||||
@@ -252,6 +407,11 @@ public sealed class AuthSessionStore(
|
||||
Details = System.Text.Json.JsonSerializer.SerializeToElement(new { reason, count, revokedAt = now })
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
foreach (var sessionId in sessionIds)
|
||||
{
|
||||
await stateInvalidator.InvalidateSessionAsync(sessionId, cancellationToken);
|
||||
}
|
||||
await stateInvalidator.InvalidateUserAsync(userId, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,6 +424,9 @@ public sealed class AuthSessionStore(
|
||||
{
|
||||
ValidateRealm(realm, tenantId);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var sessionIds = await dbContext.AuthSessions.AsNoTracking()
|
||||
.Where(item => item.UserId == userId && item.Realm == realm && item.TenantId == tenantId && item.RevokedAt == null)
|
||||
.Select(item => item.Id).ToArrayAsync(cancellationToken);
|
||||
var count = await dbContext.AuthSessions
|
||||
.Where(item => item.UserId == userId && item.Realm == realm && item.TenantId == tenantId && item.RevokedAt == null)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
@@ -281,6 +444,10 @@ public sealed class AuthSessionStore(
|
||||
Details = System.Text.Json.JsonSerializer.SerializeToElement(new { realm, reason, count, revokedAt = now })
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
foreach (var sessionId in sessionIds)
|
||||
{
|
||||
await stateInvalidator.InvalidateSessionAsync(sessionId, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,6 +567,9 @@ public sealed class AuthSessionStore(
|
||||
|
||||
private async Task<int> RevokeFamilyCoreAsync(Guid familyId, string reason, DateTimeOffset now, CancellationToken cancellationToken)
|
||||
{
|
||||
var sessionIds = await dbContext.AuthSessions.AsNoTracking()
|
||||
.Where(item => item.TokenFamilyId == familyId && item.RevokedAt == null)
|
||||
.Select(item => item.Id).ToArrayAsync(cancellationToken);
|
||||
var owner = await dbContext.AuthSessions.AsNoTracking()
|
||||
.Where(item => item.TokenFamilyId == familyId)
|
||||
.Select(item => new { item.UserId, item.TenantId })
|
||||
@@ -420,6 +590,10 @@ public sealed class AuthSessionStore(
|
||||
Details = System.Text.Json.JsonSerializer.SerializeToElement(new { reason, count, revokedAt = now })
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
foreach (var sessionId in sessionIds)
|
||||
{
|
||||
await stateInvalidator.InvalidateSessionAsync(sessionId, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
|
||||
Reference in New Issue
Block a user