423 lines
15 KiB
C#
423 lines
15 KiB
C#
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<AppDbContext>();
|
|
var cfg = db.Set<TenantConfig>().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<User?> FindByIdAsync(Guid userId, CancellationToken ct = default)
|
|
{
|
|
var tid = TenantId();
|
|
var db = _scope.Get<AppDbContext>();
|
|
return await db.Users.AsNoTracking()
|
|
.FirstOrDefaultAsync(u => u.TenantId == tid && u.Id == userId, ct);
|
|
}
|
|
|
|
public async Task<User?> FindActiveByEmailAsync(string email, CancellationToken ct = default)
|
|
{
|
|
var norm = email.Trim().ToLowerInvariant();
|
|
var db = _scope.Get<AppDbContext>();
|
|
|
|
return await db.Users
|
|
.AsNoTracking()
|
|
.Include(u => u.UserRoles)
|
|
.ThenInclude(ur => ur.Role)
|
|
.Where(u => u.IsActive &&
|
|
u.Identities.Any(i => i.Type == IdentityType.Email && i.Identifier == norm))
|
|
.FirstOrDefaultAsync(ct);
|
|
}
|
|
|
|
public async Task<bool> EmailExistsAsync(string email, CancellationToken ct = default)
|
|
{
|
|
var tid = TenantId();
|
|
var db = _scope.Get<AppDbContext>();
|
|
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<AppDbContext>();
|
|
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<string[]> GetRoleCodesByUserIdAsync(Guid userId, Guid tenantId, CancellationToken ct = default)
|
|
{
|
|
var r = _redis?.GetDatabase();
|
|
var cacheKey = $"urole:{tenantId:N}:{userId:N}";
|
|
|
|
// ---- cache hit ----
|
|
if (r is not null)
|
|
{
|
|
try
|
|
{
|
|
var cached = await r.StringGetAsync(cacheKey);
|
|
if (cached.HasValue)
|
|
{
|
|
var arr = JsonSerializer.Deserialize<string[]>(cached!) ?? Array.Empty<string>();
|
|
if (arr.Length > 0) return arr;
|
|
}
|
|
}
|
|
catch { /* ignore cache errors */ }
|
|
}
|
|
|
|
var db = _scope.Get<AppDbContext>();
|
|
|
|
// NOTE:
|
|
// - ไม่อ้างอิง r.TenantId เพื่อให้คอมไพล์ได้แม้ Role ยังไม่มีฟิลด์ TenantId
|
|
// - ถ้าคุณเพิ่ม Role.TenantId แล้ว และต้องการกรองตาม tenant ให้เติม .Where(role => role.TenantId == tenantId)
|
|
var roles = await db.UserRoles
|
|
.AsNoTracking()
|
|
.Where(ur => ur.UserId == userId)
|
|
.Select(ur => ur.Role)
|
|
.Where(role => role != null)
|
|
.Select(role => role!.Code) // ใช้ Code เป็นค่าของ claim "role"
|
|
.Distinct()
|
|
.ToArrayAsync(ct);
|
|
|
|
// ---- cache miss → set ----
|
|
if (r is not null)
|
|
{
|
|
try
|
|
{
|
|
await r.StringSetAsync(cacheKey, JsonSerializer.Serialize(roles), TimeSpan.FromMinutes(5));
|
|
}
|
|
catch { /* ignore cache errors */ }
|
|
}
|
|
|
|
return roles;
|
|
}
|
|
|
|
public async Task AddIdentityAsync(Guid userId, IdentityType type, string identifier, bool isPrimary,
|
|
CancellationToken ct = default)
|
|
{
|
|
var tid = TenantId();
|
|
var db = _scope.Get<AppDbContext>();
|
|
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<AppDbContext>();
|
|
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<UserIdentity?> GetPrimaryIdentityAsync(Guid userId, IdentityType type,
|
|
CancellationToken ct = default)
|
|
{
|
|
var tid = TenantId();
|
|
var db = _scope.Get<AppDbContext>();
|
|
|
|
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<AppDbContext>();
|
|
|
|
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<AppDbContext>();
|
|
|
|
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<UserMfaFactor> AddTotpFactorAsync(Guid userId, string label, string secret,
|
|
CancellationToken ct = default)
|
|
{
|
|
var tid = TenantId();
|
|
var db = _scope.Get<AppDbContext>();
|
|
|
|
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<AppDbContext>();
|
|
|
|
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<bool> HasAnyMfaAsync(Guid userId, CancellationToken ct = default)
|
|
{
|
|
var tid = TenantId();
|
|
var db = _scope.Get<AppDbContext>();
|
|
return await db.UserMfaFactors.AnyAsync(x => x.TenantId == tid && x.UserId == userId && x.Enabled, ct);
|
|
}
|
|
|
|
public async Task<UserSession> CreateSessionAsync(UserSession session, CancellationToken ct = default)
|
|
{
|
|
var db = _scope.Get<AppDbContext>();
|
|
await db.UserSessions.AddAsync(session, ct);
|
|
await db.SaveChangesAsync(ct);
|
|
return session;
|
|
}
|
|
|
|
public async Task<UserSession?> FindSessionByRefreshHashAsync(Guid tenantId, string refreshTokenHash,
|
|
CancellationToken ct = default)
|
|
{
|
|
var db = _scope.Get<AppDbContext>();
|
|
return await db.UserSessions
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.RefreshTokenHash == refreshTokenHash, ct);
|
|
}
|
|
|
|
public async Task<bool> RotateSessionRefreshAsync(Guid tenantId, Guid sessionId, string newRefreshTokenHash,
|
|
DateTimeOffset newIssuedAt, DateTimeOffset? newExpiresAt, CancellationToken ct = default)
|
|
{
|
|
var db = _scope.Get<AppDbContext>();
|
|
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<int> RevokeSessionAsync(Guid userId, Guid sessionId, CancellationToken ct = default)
|
|
{
|
|
var tid = TenantId();
|
|
var db = _scope.Get<AppDbContext>();
|
|
|
|
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<int> RevokeAllSessionsAsync(Guid userId, CancellationToken ct = default)
|
|
{
|
|
var tid = TenantId();
|
|
var db = _scope.Get<AppDbContext>();
|
|
|
|
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<bool> IsSessionActiveAsync(Guid userId, Guid sessionId, CancellationToken ct = default)
|
|
{
|
|
var tid = TenantId();
|
|
var db = _scope.Get<AppDbContext>();
|
|
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<string> 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<string?> GetUserSecurityStampAsync(Guid userId, CancellationToken ct = default)
|
|
{
|
|
var db = _scope.Get<AppDbContext>();
|
|
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<AppDbContext>();
|
|
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);
|
|
}
|
|
} |