using System.Security.Cryptography; using Microsoft.AspNetCore.Identity; 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; namespace Tiku.Infrastructure.Auth; public sealed class AuthSessionStore( TikuDbContext dbContext, ITokenService tokenService, IOptions options) : IAuthSessionStore { private readonly JwtOptions options = options.Value; 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, StringSplitOptions.None) ?? []; 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) => Convert.ToHexString(SHA256.HashData(System.Text.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; 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); return CreatePair(request, next, nextToken); } public async Task ValidateAccessSessionAsync( Guid sessionId, Guid userId, AuthRealm realm, Guid? tenantId, CancellationToken cancellationToken = default) { var now = DateTimeOffset.UtcNow; var session = await dbContext.AuthSessions.AsNoTracking().SingleOrDefaultAsync( item => item.Id == sessionId && item.UserId == userId && item.Realm == realm && item.TenantId == tenantId && item.RevokedAt == null && item.ExpiresAt > now, cancellationToken); if (session is null) { return null; } var user = await dbContext.Users.AsNoTracking().SingleOrDefaultAsync(item => item.Id == userId, cancellationToken); if (user is null || user.Status != UserStatus.Active || !string.Equals(user.SecurityStamp, session.SecurityStamp, StringComparison.Ordinal)) { return null; } try { await AssertRealmAccessAsync( realm, tenantId, userId, cancellationToken); } catch (TenantAccessDeniedException) { return null; } return new AuthSessionValidationResult(userId, realm, tenantId); } 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 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 = System.Text.Json.JsonSerializer.SerializeToElement(new { reason, count, revokedAt = now }) }); await dbContext.SaveChangesAsync(cancellationToken); } } public async Task RevokeRealmAsync( Guid userId, AuthRealm realm, Guid? tenantId, string reason, CancellationToken cancellationToken = default) { ValidateRealm(realm, tenantId); var now = DateTimeOffset.UtcNow; 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 = System.Text.Json.JsonSerializer.SerializeToElement(new { realm, reason, count, revokedAt = now }) }); await dbContext.SaveChangesAsync(cancellationToken); } } private AuthSession CreateSession(AuthSessionIssueRequest request, Guid sessionId) => new() { 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 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 = System.Text.Json.JsonSerializer.SerializeToElement(new { reason, count, revokedAt = now }) }); await dbContext.SaveChangesAsync(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."); } } }