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( IIdentityPersistence identityPersistence, ITenancyPersistence tenancyPersistence, IJobsOperationsPersistence jobsOperationsPersistence, 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); identityPersistence.AuthSessions.Add(session); await identityPersistence.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 identityPersistence.Database.BeginTransactionAsync(cancellationToken); var current = await identityPersistence.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 identityPersistence.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 identityPersistence.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); identityPersistence.AuthSessions.Add(next); await identityPersistence.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 identityPersistence.AuthSessions.AsNoTracking() join user in identityPersistence.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 && tenancyPersistence.Tenants.Any(item => item.Id == tenantId && item.Status == TenantStatus.Active) && identityPersistence.TenantMemberships.Any(item => item.TenantId == tenantId && item.UserId == userId && item.Status == MembershipStatus.Active)), realm != AuthRealm.Platform || (from userRole in jobsOperationsPersistence.PlatformBackendUserRoles join role in jobsOperationsPersistence.PlatformBackendRoles on userRole.RoleId equals role.Id join binding in jobsOperationsPersistence.PlatformBackendRolePermissions on role.Id equals binding.RoleId join permission in jobsOperationsPersistence.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 ? tenancyPersistence.Tenants.Where(item => item.Id == tenantId) .Select(item => (TenantStatus?)item.Status).FirstOrDefault() : null, realm == AuthRealm.Tenant && tenantId != null ? identityPersistence.TenantMemberships .Where(item => item.TenantId == tenantId && item.UserId == userId) .Select(item => (MembershipStatus?)item.Status).FirstOrDefault() : null, jobsOperationsPersistence.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 identityPersistence.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 identityPersistence.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 identityPersistence.AuthSessions.AsNoTracking() .Where(item => item.UserId == userId && item.RevokedAt == null) .Select(item => item.Id).ToArrayAsync(cancellationToken); var count = await identityPersistence.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) { jobsOperationsPersistence.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 identityPersistence.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 identityPersistence.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 identityPersistence.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) { jobsOperationsPersistence.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 identityPersistence.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 identityPersistence.AuthSessions.AsNoTracking() .SingleOrDefaultAsync(item => item.Id == currentSessionId && item.UserId == userId, cancellationToken) ?? throw new SessionRevokedException(); var now = DateTimeOffset.UtcNow; var sessions = await identityPersistence.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 identityPersistence.AuthSessions.AsNoTracking() .SingleOrDefaultAsync(item => item.Id == currentSessionId && item.UserId == userId, cancellationToken) ?? throw new SessionRevokedException(); if (current.TokenFamilyId == sessionFamilyId) throw new CurrentAuthSessionCannotBeRevokedException(); var owned = await identityPersistence.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 tenancyPersistence.Tenants.AnyAsync(item => item.Id == tenantId && item.Status == TenantStatus.Active, cancellationToken) && await identityPersistence.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 jobsOperationsPersistence.PlatformBackendUserRoles join role in jobsOperationsPersistence.PlatformBackendRoles on userRole.RoleId equals role.Id join binding in jobsOperationsPersistence.PlatformBackendRolePermissions on role.Id equals binding.RoleId join permission in jobsOperationsPersistence.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 identityPersistence.AuthSessions.AsNoTracking() .Where(item => item.TokenFamilyId == familyId && item.RevokedAt == null) .Select(item => item.Id).ToArrayAsync(cancellationToken); var owner = await identityPersistence.AuthSessions.AsNoTracking() .Where(item => item.TokenFamilyId == familyId) .Select(item => new { item.UserId, item.TenantId }) .FirstOrDefaultAsync(cancellationToken); var count = await identityPersistence.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) { jobsOperationsPersistence.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 identityPersistence.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); }