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 options, IAccessSecurityCache? configuredAccessSecurityCache = null, IOptions? 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 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 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 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 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> 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 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); }