using System.Text.Json; using AMREZ.EOP.Abstractions.Applications.Tenancy; using AMREZ.EOP.Abstractions.Infrastructures.Repositories; using AMREZ.EOP.Abstractions.Storage; using AMREZ.EOP.Domain.Entities.Authentications; using AMREZ.EOP.Domain.Entities.Tenancy; using AMREZ.EOP.Domain.Shared._Users; using AMREZ.EOP.Infrastructures.Data; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using StackExchange.Redis; namespace AMREZ.EOP.Infrastructures.Repositories; public class UserRepository : IUserRepository { private readonly IDbScope _scope; private readonly IConnectionMultiplexer? _redis; private readonly ITenantResolver _tenantResolver; private readonly IHttpContextAccessor _http; public UserRepository( IDbScope scope, IConnectionMultiplexer? redis, ITenantResolver tenantResolver, IHttpContextAccessor http) { _scope = scope; _redis = redis; _tenantResolver = tenantResolver; _http = http; } private Guid TenantId() { var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext"); var tc = _tenantResolver.Resolve(http) ?? throw new InvalidOperationException("No tenant"); _scope.EnsureForTenant(tc); if (!string.IsNullOrWhiteSpace(tc.Id) && Guid.TryParse(tc.Id, out var g) && g != Guid.Empty) return g; var key = (http.Items.TryGetValue("TargetTenantKey", out var v) ? v as string : null) ?? tc.Id; if (string.IsNullOrWhiteSpace(key)) throw new InvalidOperationException("Tenant key missing"); var db = _scope.Get(); var cfg = db.Set().AsNoTracking().FirstOrDefault(x => x.TenantKey == key); if (cfg is null) throw new InvalidOperationException($"Tenant '{key}' not found"); return cfg.TenantId; } private static string IdentityEmailKey(string tenantId, string email) => $"eop:{tenantId}:user:identity:email:{email.Trim().ToLowerInvariant()}"; private static string UserIdKey(string tenantId, Guid id) => $"eop:{tenantId}:user:id:{id:N}"; public async Task FindByIdAsync(Guid userId, CancellationToken ct = default) { var tid = TenantId(); var db = _scope.Get(); return await db.Users.AsNoTracking() .FirstOrDefaultAsync(u => u.TenantId == tid && u.Id == userId, ct); } public async Task FindActiveByEmailAsync(string email, CancellationToken ct = default) { var r = _redis?.GetDatabase(); var norm = email.Trim().ToLowerInvariant(); if (r is not null) { try { var cached = await r.StringGetAsync($"uidx:{norm}"); if (cached.HasValue) { var hit = JsonSerializer.Deserialize(cached!); if (hit?.IsActive == true) return hit; } } catch { /* ignore cache errors */ } } var db = _scope.Get(); var user = await ( from u in db.Users.AsNoTracking() join i in db.UserIdentities.AsNoTracking() on u.Id equals i.UserId where u.IsActive && i.Type == IdentityType.Email && i.Identifier == norm select u ).FirstOrDefaultAsync(ct); if (user is not null && r is not null) { try { var payload = JsonSerializer.Serialize(user); var ttl = TimeSpan.FromMinutes(5); await r.StringSetAsync($"uidx:{norm}", payload, ttl); await r.StringSetAsync($"user:{user.Id:N}", payload, ttl); } catch { /* ignore cache errors */ } } return user; } public async Task EmailExistsAsync(string email, CancellationToken ct = default) { var tid = TenantId(); var db = _scope.Get(); var norm = email.Trim().ToLowerInvariant(); return await db.UserIdentities.AsNoTracking() .AnyAsync(i => i.TenantId == tid && i.Type == IdentityType.Email && i.Identifier == norm, ct); } public async Task AddAsync(User user, CancellationToken ct = default) { var tid = TenantId(); var db = _scope.Get(); user.TenantId = tid; await db.Users.AddAsync(user, ct); await db.SaveChangesAsync(ct); var r = _redis?.GetDatabase(); if (r is not null) { try { var payload = JsonSerializer.Serialize(user); var ttl = TimeSpan.FromMinutes(5); await r.StringSetAsync(UserIdKey(tid.ToString(), user.Id), payload, ttl); var email = user.Identities .FirstOrDefault(i => i.Type == IdentityType.Email && i.IsPrimary)?.Identifier; if (!string.IsNullOrWhiteSpace(email)) await r.StringSetAsync(IdentityEmailKey(tid.ToString(), email!), payload, ttl); } catch { } } } public async Task AddIdentityAsync(Guid userId, IdentityType type, string identifier, bool isPrimary, CancellationToken ct = default) { var tid = TenantId(); var db = _scope.Get(); var norm = identifier.Trim().ToLowerInvariant(); if (isPrimary) { var existingPrimary = await db.UserIdentities .Where(i => i.TenantId == tid && i.UserId == userId && i.Type == type && i.IsPrimary) .ToListAsync(ct); foreach (var x in existingPrimary) x.IsPrimary = false; } var entity = new UserIdentity { TenantId = tid, UserId = userId, Type = type, Identifier = norm, IsPrimary = isPrimary }; await db.UserIdentities.AddAsync(entity, ct); await db.SaveChangesAsync(ct); } public async Task VerifyIdentityAsync(Guid userId, IdentityType type, string identifier, DateTimeOffset verifiedAt, CancellationToken ct = default) { var tid = TenantId(); var db = _scope.Get(); var norm = identifier.Trim().ToLowerInvariant(); var id = await db.UserIdentities .FirstOrDefaultAsync(i => i.TenantId == tid && i.UserId == userId && i.Type == type && i.Identifier == norm, ct); if (id is null) return; id.VerifiedAt = verifiedAt; await db.SaveChangesAsync(ct); } public async Task GetPrimaryIdentityAsync(Guid userId, IdentityType type, CancellationToken ct = default) { var tid = TenantId(); var db = _scope.Get(); return await db.UserIdentities.AsNoTracking() .FirstOrDefaultAsync(i => i.TenantId == tid && i.UserId == userId && i.Type == type && i.IsPrimary, ct); } public async Task ChangePasswordAsync(Guid userId, string newPasswordHash, CancellationToken ct = default) { var tid = TenantId(); var db = _scope.Get(); var user = await db.Users.FirstOrDefaultAsync(u => u.TenantId == tid && u.Id == userId, ct); if (user is null) return; user.PasswordHash = newPasswordHash; user.SecurityStamp = Guid.NewGuid().ToString("N"); await db.SaveChangesAsync(ct); } public async Task AddPasswordHistoryAsync(Guid userId, string passwordHash, CancellationToken ct = default) { var tid = TenantId(); var db = _scope.Get(); var h = new UserPasswordHistory { TenantId = tid, UserId = userId, PasswordHash = passwordHash, ChangedAt = DateTimeOffset.UtcNow }; await db.UserPasswordHistories.AddAsync(h, ct); await db.SaveChangesAsync(ct); } public async Task AddTotpFactorAsync(Guid userId, string label, string secret, CancellationToken ct = default) { var tid = TenantId(); var db = _scope.Get(); var f = new UserMfaFactor { TenantId = tid, UserId = userId, Type = MfaType.Totp, Label = label, Secret = secret, Enabled = true, AddedAt = DateTimeOffset.UtcNow }; await db.UserMfaFactors.AddAsync(f, ct); var u = await db.Users.FirstAsync(x => x.TenantId == tid && x.Id == userId, ct); u.MfaEnabled = true; await db.SaveChangesAsync(ct); return f; } public async Task DisableMfaFactorAsync(Guid factorId, CancellationToken ct = default) { var tid = TenantId(); var db = _scope.Get(); var f = await db.UserMfaFactors.FirstOrDefaultAsync(x => x.TenantId == tid && x.Id == factorId, ct); if (f is null) return; f.Enabled = false; var still = await db.UserMfaFactors.AnyAsync(x => x.TenantId == tid && x.UserId == f.UserId && x.Enabled, ct); if (!still) { var u = await db.Users.FirstAsync(x => x.TenantId == tid && x.Id == f.UserId, ct); u.MfaEnabled = false; } await db.SaveChangesAsync(ct); } public async Task HasAnyMfaAsync(Guid userId, CancellationToken ct = default) { var tid = TenantId(); var db = _scope.Get(); return await db.UserMfaFactors.AnyAsync(x => x.TenantId == tid && x.UserId == userId && x.Enabled, ct); } public async Task CreateSessionAsync(UserSession session, CancellationToken ct = default) { var db = _scope.Get(); await db.UserSessions.AddAsync(session, ct); await db.SaveChangesAsync(ct); return session; } public async Task FindSessionByRefreshHashAsync(Guid tenantId, string refreshTokenHash, CancellationToken ct = default) { var db = _scope.Get(); return await db.UserSessions .AsNoTracking() .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.RefreshTokenHash == refreshTokenHash, ct); } public async Task RotateSessionRefreshAsync(Guid tenantId, Guid sessionId, string newRefreshTokenHash, DateTimeOffset newIssuedAt, DateTimeOffset? newExpiresAt, CancellationToken ct = default) { var db = _scope.Get(); var s = await db.UserSessions.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == sessionId, ct); if (s is null || s.RevokedAt.HasValue) return false; s.RefreshTokenHash = newRefreshTokenHash; s.IssuedAt = newIssuedAt; s.ExpiresAt = newExpiresAt; await db.SaveChangesAsync(ct); return true; } public async Task RevokeSessionAsync(Guid userId, Guid sessionId, CancellationToken ct = default) { var tid = TenantId(); var db = _scope.Get(); var s = await db.UserSessions.FirstOrDefaultAsync(x => x.TenantId == tid && x.Id == sessionId && x.UserId == userId, ct); if (s is null) return 0; s.RevokedAt = DateTimeOffset.UtcNow; return await db.SaveChangesAsync(ct); } public async Task RevokeAllSessionsAsync(Guid userId, CancellationToken ct = default) { var tid = TenantId(); var db = _scope.Get(); var sessions = await db.UserSessions .Where(x => x.TenantId == tid && x.UserId == userId && x.RevokedAt == null) .ToListAsync(ct); foreach (var s in sessions) s.RevokedAt = DateTimeOffset.UtcNow; return await db.SaveChangesAsync(ct); } public async Task IsSessionActiveAsync(Guid userId, Guid sessionId, CancellationToken ct = default) { var tid = TenantId(); var db = _scope.Get(); var now = DateTimeOffset.UtcNow; return await db.UserSessions .AsNoTracking() .AnyAsync(x => x.TenantId == tid && x.Id == sessionId && x.UserId == userId && x.RevokedAt == null && (!x.ExpiresAt.HasValue || x.ExpiresAt > now), ct); } private static string TenantTokenVersionKey(Guid tenantId) => $"eop:{tenantId}:token:version"; public async Task GetTenantTokenVersionAsync(Guid tenantId, CancellationToken ct = default) { var r = _redis?.GetDatabase(); if (r is null) return "1"; var key = TenantTokenVersionKey(tenantId); var val = await r.StringGetAsync(key); if (val.HasValue) return val.ToString(); await r.StringSetAsync(key, "1"); return "1"; } public async Task BumpTenantTokenVersionAsync(Guid tenantId, CancellationToken ct = default) { var r = _redis?.GetDatabase(); if (r is null) return; var key = TenantTokenVersionKey(tenantId); await r.StringIncrementAsync(key); } public async Task GetUserSecurityStampAsync(Guid userId, CancellationToken ct = default) { var db = _scope.Get(); return await db.Users .AsNoTracking() .Where(x => x.Id == userId) .Select(x => x.SecurityStamp) .FirstOrDefaultAsync(ct); } public async Task BumpUserSecurityStampAsync(Guid userId, CancellationToken ct = default) { var tid = TenantId(); var db = _scope.Get(); var u = await db.Users.FirstOrDefaultAsync(x => x.TenantId == tid && x.Id == userId, ct); if (u is null) return; u.SecurityStamp = Guid.NewGuid().ToString("N"); await db.SaveChangesAsync(ct); } }