Files
tiku-backend.net/Tiku.Infrastructure/Auth/AuthSessionStore.cs
xiong c497a3ca8d
Some checks failed
ci / release-gate (push) Has been cancelled
清理代码
2026-08-03 12:31:39 +08:00

615 lines
28 KiB
C#

using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Tiku.Application.Auth;
using Tiku.Application.Security;
using Tiku.Domain.Identity;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
using Tiku.Infrastructure.Security;
using ZLinq;
namespace Tiku.Infrastructure.Auth;
public sealed class AuthSessionStore(
TikuDbContext dbContext,
ITokenService tokenService,
IOptions<JwtOptions> options,
IAccessSecurityCache? configuredAccessSecurityCache = null,
IOptions<AuthorizationCacheOptions>? configuredCacheOptions = null,
IAuthorizationStateInvalidator? configuredStateInvalidator = null) : IAuthSessionStore
{
private readonly IAccessSecurityCache accessSecurityCache =
configuredAccessSecurityCache ?? new NullAuthorizationCache();
private readonly AuthorizationCacheOptions cacheOptions =
configuredCacheOptions?.Value ?? new AuthorizationCacheOptions();
private readonly JwtOptions options = options.Value;
private readonly IAuthorizationStateInvalidator stateInvalidator =
configuredStateInvalidator ?? new NullAuthorizationStateInvalidator();
public string GenerateRefreshToken(AuthRealm realm, Guid? tenantId, Guid sessionId)
{
var realmCode = realm == AuthRealm.Tenant ? "t" : "p";
var tenant = tenantId?.ToString("N") ?? "-";
return $"v2.{realmCode}.{tenant}.{sessionId:N}.{Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(64))}";
}
public bool TryParseRefreshToken(string refreshToken, out RefreshTokenLocator locator)
{
locator = default;
var parts = refreshToken?.Split('.', 5) ?? [];
if (parts.Length != 5 || parts[0] != "v2" || parts[4].Length < 64 ||
!Guid.TryParseExact(parts[3], "N", out var sessionId))
return false;
if (parts[1] == "p" && parts[2] == "-")
{
locator = new RefreshTokenLocator(AuthRealm.Platform, null, sessionId);
return true;
}
if (parts[1] == "t" && Guid.TryParseExact(parts[2], "N", out var tenantId))
{
locator = new RefreshTokenLocator(AuthRealm.Tenant, tenantId, sessionId);
return true;
}
return false;
}
public string HashRefreshToken(string refreshToken)
{
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(refreshToken)))
.ToLowerInvariant();
}
public async Task<AuthTokenPair> IssueAsync(
AuthSessionIssueRequest request,
CancellationToken cancellationToken = default)
{
ValidateRealm(request.Realm, request.TenantId);
var session = CreateSession(request, Guid.NewGuid());
var refreshToken = GenerateRefreshToken(session.Realm, session.TenantId, session.Id);
session.TokenHash = HashRefreshToken(refreshToken);
dbContext.AuthSessions.Add(session);
await dbContext.SaveChangesAsync(cancellationToken);
return CreatePair(request, session, refreshToken);
}
public async Task<AuthTokenPair> RotateAsync(
string refreshToken,
string? ipAddress,
string? userAgent,
CancellationToken cancellationToken = default)
{
if (!TryParseRefreshToken(refreshToken, out var locator)) throw new SessionRevokedException();
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 &&
item.TenantId == locator.TenantId && item.TokenHash == tokenHash,
cancellationToken);
if (current is null) throw new SessionRevokedException();
if (current.RevokedAt.HasValue || current.ReplacedBySessionId.HasValue || current.ExpiresAt <= now)
{
await RevokeFamilyCoreAsync(current.TokenFamilyId, "refresh_token_reuse", now, cancellationToken);
await transaction.CommitAsync(cancellationToken);
throw new SessionRevokedException();
}
var user = await dbContext.Users.SingleOrDefaultAsync(item => item.Id == current.UserId, cancellationToken);
if (user is null || user.Status != UserStatus.Active ||
!string.Equals(user.SecurityStamp, current.SecurityStamp, StringComparison.Ordinal))
{
await RevokeFamilyCoreAsync(current.TokenFamilyId, "identity_state_changed", now, cancellationToken);
await transaction.CommitAsync(cancellationToken);
throw new SessionRevokedException();
}
try
{
await AssertRealmAccessAsync(
current.Realm, current.TenantId, current.UserId, cancellationToken);
}
catch (TenantAccessDeniedException)
{
await RevokeFamilyCoreAsync(current.TokenFamilyId, "realm_access_revoked", now, cancellationToken);
await transaction.CommitAsync(cancellationToken);
throw new SessionRevokedException();
}
var nextId = Guid.NewGuid();
var updated = await dbContext.AuthSessions
.Where(item => item.Id == current.Id && item.RevokedAt == null && item.ReplacedBySessionId == null)
.ExecuteUpdateAsync(setters => setters
.SetProperty(item => item.RevokedAt, now)
.SetProperty(item => item.RevokedReason, "rotated")
.SetProperty(item => item.ReplacedBySessionId, nextId), cancellationToken);
if (updated != 1)
{
await RevokeFamilyCoreAsync(current.TokenFamilyId, "refresh_token_reuse", now, cancellationToken);
await transaction.CommitAsync(cancellationToken);
throw new SessionRevokedException();
}
var request = new AuthSessionIssueRequest(
user.Id, user.Phone, user.Email, user.SecurityStamp ?? string.Empty,
current.Realm, current.TenantId, "refresh",
ipAddress, userAgent, current.TokenFamilyId, current.Id);
var next = CreateSession(request, nextId);
var nextToken = GenerateRefreshToken(next.Realm, next.TenantId, next.Id);
next.TokenHash = HashRefreshToken(nextToken);
dbContext.AuthSessions.Add(next);
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
await stateInvalidator.InvalidateSessionAsync(current.Id, cancellationToken);
return CreatePair(request, next, nextToken);
}
public async Task<AuthSessionValidationResult?> ValidateAccessSessionAsync(
Guid sessionId,
Guid userId,
AuthRealm realm,
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;
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
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)),
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
join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission
.Code
where userRole.UserId == userId &&
role.Status == BackendRoleStatus.Active &&
(permission.Area == BackendPermissionArea.Platform ||
permission.Area == BackendPermissionArea.Both)
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;
}
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;
}
public async Task<AuthSessionValidationResult?> ResolveActiveSessionAsync(
Guid sessionId,
Guid userId,
CancellationToken cancellationToken = default)
{
var session = await dbContext.AuthSessions.AsNoTracking()
.Where(item => item.Id == sessionId && item.UserId == userId)
.Select(item => new { item.Realm, item.TenantId })
.SingleOrDefaultAsync(cancellationToken);
if (session is null) return null;
return await ValidateAccessSessionAsync(
sessionId,
userId,
session.Realm,
session.TenantId,
cancellationToken);
}
public async Task RevokeFamilyAsync(string refreshToken, string reason,
CancellationToken cancellationToken = default)
{
if (!TryParseRefreshToken(refreshToken, out var locator)) return;
var hash = HashRefreshToken(refreshToken);
var session = await dbContext.AuthSessions.AsNoTracking().SingleOrDefaultAsync(
item => item.Id == locator.SessionId && item.TokenHash == hash, cancellationToken);
if (session is not null)
await RevokeFamilyCoreAsync(session.TokenFamilyId, reason, DateTimeOffset.UtcNow, cancellationToken);
}
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)
.SetProperty(item => item.RevokedReason, reason), cancellationToken);
if (count > 0)
{
dbContext.AuditLogs.Add(new AuditLog
{
ActorUserId = userId,
Action = "auth.sessions.revoked_all",
TargetType = "user",
TargetId = userId.ToString(),
Details = 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);
}
}
public async Task RevokeRealmAsync(
Guid userId,
AuthRealm realm,
Guid? tenantId,
string reason,
CancellationToken cancellationToken = default)
{
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
.SetProperty(item => item.RevokedAt, now)
.SetProperty(item => item.RevokedReason, reason), cancellationToken);
if (count > 0)
{
dbContext.AuditLogs.Add(new AuditLog
{
TenantId = tenantId,
ActorUserId = userId,
Action = "auth.sessions.realm_revoked",
TargetType = "user",
TargetId = userId.ToString(),
Details = JsonSerializer.SerializeToElement(new { realm, reason, count, revokedAt = now })
});
await dbContext.SaveChangesAsync(cancellationToken);
foreach (var sessionId in sessionIds)
await stateInvalidator.InvalidateSessionAsync(sessionId, cancellationToken);
}
}
public async Task<IReadOnlyCollection<AuthSessionSummary>> ListActiveAsync(
Guid userId,
Guid currentSessionId,
CancellationToken cancellationToken = default)
{
var current = await dbContext.AuthSessions.AsNoTracking()
.SingleOrDefaultAsync(item => item.Id == currentSessionId && item.UserId == userId,
cancellationToken)
?? throw new SessionRevokedException();
var now = DateTimeOffset.UtcNow;
var sessions = await dbContext.AuthSessions.AsNoTracking()
.Where(item => item.UserId == userId && item.Realm == current.Realm && item.TenantId == current.TenantId)
.OrderBy(item => item.CreatedAt)
.ToArrayAsync(cancellationToken);
return sessions
.AsValueEnumerable()
.GroupBy(item => item.TokenFamilyId)
.Select(group => new
{
All = group.ToArray(),
Active = group.LastOrDefault(item => item.RevokedAt == null && item.ExpiresAt > now)
})
.Where(value => value.Active is not null)
.Select(value => new AuthSessionSummary(
value.Active!.TokenFamilyId,
value.Active.Realm,
value.Active.TenantId,
value.Active.Provider,
value.All.Min(item => item.CreatedAt),
value.Active.CreatedAt,
value.Active.ExpiresAt,
MaskIpAddress(value.Active.IpAddress),
value.Active.UserAgent,
value.Active.Id == currentSessionId))
.OrderByDescending(item => item.IsCurrent)
.ThenByDescending(item => item.LastRotatedAt)
.ToArray();
}
public async Task RevokeOwnedFamilyAsync(
Guid userId,
Guid currentSessionId,
Guid sessionFamilyId,
CancellationToken cancellationToken = default)
{
var current = await dbContext.AuthSessions.AsNoTracking()
.SingleOrDefaultAsync(item => item.Id == currentSessionId && item.UserId == userId,
cancellationToken)
?? throw new SessionRevokedException();
if (current.TokenFamilyId == sessionFamilyId) throw new CurrentAuthSessionCannotBeRevokedException();
var owned = await dbContext.AuthSessions.AsNoTracking().AnyAsync(
item => item.UserId == userId && item.TokenFamilyId == sessionFamilyId &&
item.Realm == current.Realm && item.TenantId == current.TenantId,
cancellationToken);
if (!owned) throw new AuthSessionNotFoundException();
await RevokeFamilyCoreAsync(sessionFamilyId, "user_revoked_device", DateTimeOffset.UtcNow, cancellationToken);
}
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)
{
return new AccessSecurityCacheState(
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 AuthSession CreateSession(AuthSessionIssueRequest request, Guid sessionId)
{
return new AuthSession
{
Id = sessionId,
Realm = request.Realm,
TenantId = request.TenantId,
UserId = request.UserId,
TokenFamilyId = request.TokenFamilyId ?? sessionId,
ParentSessionId = request.ParentSessionId,
SecurityStamp = request.SecurityStamp,
Provider = request.Provider,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(options.RefreshTokenDays),
IpAddress = request.IpAddress,
UserAgent = request.UserAgent
};
}
private AuthTokenPair CreatePair(AuthSessionIssueRequest request, AuthSession session, string refreshToken)
{
var access = tokenService.CreateAccessToken(
request.UserId, session.Id, request.Phone, request.Email,
request.Realm, request.TenantId);
return new AuthTokenPair(access.Token, refreshToken, access.ExpiresAt, session.ExpiresAt);
}
private async Task AssertRealmAccessAsync(
AuthRealm realm,
Guid? tenantId,
Guid userId,
CancellationToken cancellationToken)
{
if (realm == AuthRealm.Tenant && tenantId.HasValue)
{
var active =
await dbContext.Tenants.AnyAsync(item => item.Id == tenantId && item.Status == TenantStatus.Active,
cancellationToken) &&
await dbContext.TenantMemberships.AnyAsync(
item => item.TenantId == tenantId && item.UserId == userId &&
item.Status == MembershipStatus.Active, cancellationToken);
if (active) return;
}
else if (realm == AuthRealm.Platform)
{
var active = await (
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
join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code
where userRole.UserId == userId && role.Status == BackendRoleStatus.Active &&
(permission.Area == BackendPermissionArea.Platform ||
permission.Area == BackendPermissionArea.Both)
select permission.Id).AnyAsync(cancellationToken);
if (active) return;
}
throw new TenantAccessDeniedException();
}
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 })
.FirstOrDefaultAsync(cancellationToken);
var count = await dbContext.AuthSessions.Where(item => item.TokenFamilyId == familyId && item.RevokedAt == null)
.ExecuteUpdateAsync(setters => setters
.SetProperty(item => item.RevokedAt, now)
.SetProperty(item => item.RevokedReason, reason), cancellationToken);
if (count > 0 && owner is not null)
{
dbContext.AuditLogs.Add(new AuditLog
{
TenantId = owner.TenantId,
ActorUserId = owner.UserId,
Action = "auth.session_family.revoked",
TargetType = "auth_session_family",
TargetId = familyId.ToString(),
Details = JsonSerializer.SerializeToElement(new { reason, count, revokedAt = now })
});
await dbContext.SaveChangesAsync(cancellationToken);
foreach (var sessionId in sessionIds)
await stateInvalidator.InvalidateSessionAsync(sessionId, cancellationToken);
}
return count;
}
private static void ValidateRealm(AuthRealm realm, Guid? tenantId)
{
if (realm == AuthRealm.Tenant != tenantId.HasValue)
throw new ArgumentException("Tenant sessions require a tenant and platform sessions must not have one.");
}
private static string? MaskIpAddress(string? value)
{
if (!IPAddress.TryParse(value, out var address)) return null;
var bytes = address.GetAddressBytes();
if (address.AddressFamily == AddressFamily.InterNetwork)
{
bytes[3] = 0;
return $"{new IPAddress(bytes)}/24";
}
Array.Clear(bytes, 8, bytes.Length - 8);
return $"{new IPAddress(bytes)}/64";
}
private sealed record SessionValidationState(
UserStatus UserStatus,
string UserSecurityStamp,
string SessionSecurityStamp,
bool TenantAllowed,
bool PlatformAllowed,
TenantStatus? TenantStatus,
MembershipStatus? MembershipStatus,
long AuthorizationVersion,
DateTimeOffset SessionExpiresAt,
bool SessionRevoked);
}