Add Login Module
This commit is contained in:
@@ -3,6 +3,7 @@ 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;
|
||||
@@ -25,16 +26,27 @@ public class UserRepository : IUserRepository
|
||||
IHttpContextAccessor http)
|
||||
{
|
||||
_scope = scope;
|
||||
_redis = redis; // null = ไม่มี/ต่อไม่ได้ → ข้าม cache
|
||||
_redis = redis;
|
||||
_tenantResolver = tenantResolver;
|
||||
_http = http;
|
||||
}
|
||||
|
||||
private Guid TenantId()
|
||||
{
|
||||
var http = _http.HttpContext;
|
||||
var tc = http is not null ? _tenantResolver.Resolve(http) : null;
|
||||
return Guid.TryParse(tc?.Id, out var g) ? g : Guid.Empty;
|
||||
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)
|
||||
@@ -45,8 +57,8 @@ public class UserRepository : IUserRepository
|
||||
|
||||
public async Task<User?> FindByIdAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var db = _scope.Get<AppDbContext>();
|
||||
var tid = TenantId();
|
||||
var db = _scope.Get<AppDbContext>();
|
||||
return await db.Users.AsNoTracking()
|
||||
.FirstOrDefaultAsync(u => u.TenantId == tid && u.Id == userId, ct);
|
||||
}
|
||||
@@ -57,7 +69,6 @@ public class UserRepository : IUserRepository
|
||||
var tidStr = tid.ToString();
|
||||
var r = _redis?.GetDatabase();
|
||||
|
||||
// 1) Redis
|
||||
if (r is not null)
|
||||
{
|
||||
try
|
||||
@@ -69,10 +80,9 @@ public class UserRepository : IUserRepository
|
||||
if (hit?.IsActive == true) return hit;
|
||||
}
|
||||
}
|
||||
catch { /* ignore → query DB */ }
|
||||
catch { }
|
||||
}
|
||||
|
||||
// 2) EF: join identities
|
||||
var db = _scope.Get<AppDbContext>();
|
||||
var norm = email.Trim().ToLowerInvariant();
|
||||
|
||||
@@ -86,7 +96,6 @@ public class UserRepository : IUserRepository
|
||||
i.Identifier == norm))
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
// 3) cache
|
||||
if (user is not null && r is not null)
|
||||
{
|
||||
try
|
||||
@@ -96,7 +105,7 @@ public class UserRepository : IUserRepository
|
||||
await r.StringSetAsync(IdentityEmailKey(tidStr, norm), payload, ttl);
|
||||
await r.StringSetAsync(UserIdKey(tidStr, user.Id), payload, ttl);
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
catch { }
|
||||
}
|
||||
|
||||
return user;
|
||||
@@ -104,8 +113,8 @@ public class UserRepository : IUserRepository
|
||||
|
||||
public async Task<bool> EmailExistsAsync(string email, CancellationToken ct = default)
|
||||
{
|
||||
var db = _scope.Get<AppDbContext>();
|
||||
var tid = TenantId();
|
||||
var db = _scope.Get<AppDbContext>();
|
||||
var norm = email.Trim().ToLowerInvariant();
|
||||
|
||||
return await db.UserIdentities.AsNoTracking()
|
||||
@@ -114,40 +123,36 @@ public class UserRepository : IUserRepository
|
||||
|
||||
public async Task AddAsync(User user, CancellationToken ct = default)
|
||||
{
|
||||
var tid = TenantId();
|
||||
var db = _scope.Get<AppDbContext>();
|
||||
user.TenantId = TenantId();
|
||||
user.TenantId = tid;
|
||||
|
||||
await db.Users.AddAsync(user, ct);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
// cache
|
||||
var r = _redis?.GetDatabase();
|
||||
if (r is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tid = user.TenantId.ToString();
|
||||
var payload = JsonSerializer.Serialize(user);
|
||||
var ttl = TimeSpan.FromMinutes(5);
|
||||
|
||||
await r.StringSetAsync(UserIdKey(tid, user.Id), payload, ttl);
|
||||
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, email!), payload, ttl);
|
||||
await r.StringSetAsync(IdentityEmailKey(tid.ToString(), email!), payload, ttl);
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
// ========= Identities =========
|
||||
|
||||
// (1) IMPLEMENTED: AddIdentityAsync
|
||||
public async Task AddIdentityAsync(Guid userId, IdentityType type, string identifier, bool isPrimary, CancellationToken ct = default)
|
||||
{
|
||||
var db = _scope.Get<AppDbContext>();
|
||||
var tid = TenantId();
|
||||
var db = _scope.Get<AppDbContext>();
|
||||
var norm = identifier.Trim().ToLowerInvariant();
|
||||
|
||||
if (isPrimary)
|
||||
@@ -171,11 +176,10 @@ public class UserRepository : IUserRepository
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
// (2) IMPLEMENTED: VerifyIdentityAsync
|
||||
public async Task VerifyIdentityAsync(Guid userId, IdentityType type, string identifier, DateTimeOffset verifiedAt, CancellationToken ct = default)
|
||||
{
|
||||
var db = _scope.Get<AppDbContext>();
|
||||
var tid = TenantId();
|
||||
var db = _scope.Get<AppDbContext>();
|
||||
var norm = identifier.Trim().ToLowerInvariant();
|
||||
|
||||
var id = await db.UserIdentities
|
||||
@@ -190,11 +194,10 @@ public class UserRepository : IUserRepository
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
// (3) IMPLEMENTED: GetPrimaryIdentityAsync
|
||||
public async Task<UserIdentity?> GetPrimaryIdentityAsync(Guid userId, IdentityType type, CancellationToken ct = default)
|
||||
{
|
||||
var db = _scope.Get<AppDbContext>();
|
||||
var tid = TenantId();
|
||||
var db = _scope.Get<AppDbContext>();
|
||||
|
||||
return await db.UserIdentities.AsNoTracking()
|
||||
.FirstOrDefaultAsync(i =>
|
||||
@@ -204,12 +207,10 @@ public class UserRepository : IUserRepository
|
||||
i.IsPrimary, ct);
|
||||
}
|
||||
|
||||
// ========= Password =========
|
||||
|
||||
public async Task ChangePasswordAsync(Guid userId, string newPasswordHash, CancellationToken ct = default)
|
||||
{
|
||||
var db = _scope.Get<AppDbContext>();
|
||||
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;
|
||||
@@ -221,8 +222,8 @@ public class UserRepository : IUserRepository
|
||||
|
||||
public async Task AddPasswordHistoryAsync(Guid userId, string passwordHash, CancellationToken ct = default)
|
||||
{
|
||||
var db = _scope.Get<AppDbContext>();
|
||||
var tid = TenantId();
|
||||
var db = _scope.Get<AppDbContext>();
|
||||
|
||||
var h = new UserPasswordHistory
|
||||
{
|
||||
@@ -236,12 +237,10 @@ public class UserRepository : IUserRepository
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
// ========= MFA =========
|
||||
|
||||
public async Task<UserMfaFactor> AddTotpFactorAsync(Guid userId, string label, string secret, CancellationToken ct = default)
|
||||
{
|
||||
var db = _scope.Get<AppDbContext>();
|
||||
var tid = TenantId();
|
||||
var db = _scope.Get<AppDbContext>();
|
||||
|
||||
var f = new UserMfaFactor
|
||||
{
|
||||
@@ -265,8 +264,8 @@ public class UserRepository : IUserRepository
|
||||
|
||||
public async Task DisableMfaFactorAsync(Guid factorId, CancellationToken ct = default)
|
||||
{
|
||||
var db = _scope.Get<AppDbContext>();
|
||||
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;
|
||||
@@ -285,26 +284,45 @@ public class UserRepository : IUserRepository
|
||||
|
||||
public async Task<bool> HasAnyMfaAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var db = _scope.Get<AppDbContext>();
|
||||
var tid = TenantId();
|
||||
var db = _scope.Get<AppDbContext>();
|
||||
return await db.UserMfaFactors.AnyAsync(x => x.TenantId == tid && x.UserId == userId && x.Enabled, ct);
|
||||
}
|
||||
|
||||
// ========= Sessions =========
|
||||
|
||||
public async Task<UserSession> CreateSessionAsync(UserSession session, CancellationToken ct = default)
|
||||
{
|
||||
var tid = TenantId();
|
||||
var db = _scope.Get<AppDbContext>();
|
||||
session.TenantId = TenantId();
|
||||
session.TenantId = tid;
|
||||
await db.UserSessions.AddAsync(session, ct);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return session;
|
||||
}
|
||||
|
||||
public async Task<int> RevokeSessionAsync(Guid userId, Guid sessionId, CancellationToken ct = default)
|
||||
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;
|
||||
@@ -315,8 +333,8 @@ public class UserRepository : IUserRepository
|
||||
|
||||
public async Task<int> RevokeAllSessionsAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var db = _scope.Get<AppDbContext>();
|
||||
var tid = TenantId();
|
||||
var db = _scope.Get<AppDbContext>();
|
||||
|
||||
var sessions = await db.UserSessions
|
||||
.Where(x => x.TenantId == tid && x.UserId == userId && x.RevokedAt == null)
|
||||
@@ -326,4 +344,61 @@ public class UserRepository : IUserRepository
|
||||
|
||||
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 tid = TenantId();
|
||||
var db = _scope.Get<AppDbContext>();
|
||||
return await db.Users
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tid && 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user