Fix Access/Refres Token
This commit is contained in:
@@ -74,7 +74,7 @@ public class AuthenticationController : ControllerBase
|
|||||||
new(ClaimTypes.NameIdentifier, res.UserId.ToString()),
|
new(ClaimTypes.NameIdentifier, res.UserId.ToString()),
|
||||||
new(ClaimTypes.Name, res.Email),
|
new(ClaimTypes.Name, res.Email),
|
||||||
new(ClaimTypes.Email, res.Email),
|
new(ClaimTypes.Email, res.Email),
|
||||||
new("tenant", res.TenantId)
|
new("tenant", res.TenantKey)
|
||||||
};
|
};
|
||||||
|
|
||||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, AuthPolicies.Scheme));
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, AuthPolicies.Scheme));
|
||||||
@@ -83,7 +83,8 @@ public class AuthenticationController : ControllerBase
|
|||||||
var tokenPair = await _issueTokens.ExecuteAsync(new IssueTokenPairRequest()
|
var tokenPair = await _issueTokens.ExecuteAsync(new IssueTokenPairRequest()
|
||||||
{
|
{
|
||||||
UserId = res.UserId,
|
UserId = res.UserId,
|
||||||
Tenant = res.TenantId,
|
TenantId = res.TenantId,
|
||||||
|
Tenant = res.TenantKey,
|
||||||
Email = res.Email
|
Email = res.Email
|
||||||
}, ct);
|
}, ct);
|
||||||
|
|
||||||
@@ -120,7 +121,7 @@ public class AuthenticationController : ControllerBase
|
|||||||
if (string.IsNullOrWhiteSpace(raw))
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
return Unauthorized(new { message = "Missing refresh token" });
|
return Unauthorized(new { message = "Missing refresh token" });
|
||||||
|
|
||||||
var res = await _refresh.ExecuteAsync(body, ct);
|
var res = await _refresh.ExecuteAsync(new RefreshRequest { RefreshToken = raw }, ct);
|
||||||
if (res is null) return Unauthorized(new { message = "Invalid/expired refresh token" });
|
if (res is null) return Unauthorized(new { message = "Invalid/expired refresh token" });
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(res.RefreshToken))
|
if (!string.IsNullOrWhiteSpace(res.RefreshToken))
|
||||||
@@ -131,8 +132,8 @@ public class AuthenticationController : ControllerBase
|
|||||||
new CookieOptions
|
new CookieOptions
|
||||||
{
|
{
|
||||||
HttpOnly = true,
|
HttpOnly = true,
|
||||||
Secure = true,
|
Secure = false,
|
||||||
SameSite = SameSiteMode.Strict,
|
SameSite = SameSiteMode.None,
|
||||||
Expires = res.RefreshExpiresAt?.UtcDateTime
|
Expires = res.RefreshExpiresAt?.UtcDateTime
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public sealed class StrictTenantGuardOptions
|
|||||||
("GET", "/swagger"),
|
("GET", "/swagger"),
|
||||||
("GET", "/swagger/index.html"),
|
("GET", "/swagger/index.html"),
|
||||||
("GET", "/swagger/"),
|
("GET", "/swagger/"),
|
||||||
|
("POST", "/api/authentication/login")
|
||||||
};
|
};
|
||||||
public Regex SlugRegex { get; set; } = new(@"^[a-z0-9\-]{1,64}$", RegexOptions.Compiled);
|
public Regex SlugRegex { get; set; } = new(@"^[a-z0-9\-]{1,64}$", RegexOptions.Compiled);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,4 +18,7 @@ public interface ITenantRepository
|
|||||||
|
|
||||||
Task<bool> AddBaseDomainAsync(string baseDomain, CancellationToken ct = default);
|
Task<bool> AddBaseDomainAsync(string baseDomain, CancellationToken ct = default);
|
||||||
Task<bool> RemoveBaseDomainAsync(string baseDomain, CancellationToken ct = default);
|
Task<bool> RemoveBaseDomainAsync(string baseDomain, CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task<string?> GetTenantKeyByTenantIdAsync(Guid tenantId, CancellationToken ct = default);
|
||||||
|
Task<Dictionary<Guid, string>> MapTenantKeysAsync(IEnumerable<Guid> tenantIds, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
@@ -15,44 +15,27 @@ namespace AMREZ.EOP.Application.UseCases.Authentications;
|
|||||||
|
|
||||||
public sealed class IssueTokenPairUseCase : IIssueTokenPairUseCase
|
public sealed class IssueTokenPairUseCase : IIssueTokenPairUseCase
|
||||||
{
|
{
|
||||||
private readonly ITenantResolver _tenantResolver; // ctx/key for UoW
|
|
||||||
private readonly ITenantRepository _tenants; // get TenantId (Guid)
|
|
||||||
private readonly IUserRepository _users;
|
private readonly IUserRepository _users;
|
||||||
private readonly IJwtFactory _jwt;
|
private readonly IJwtFactory _jwt;
|
||||||
private readonly IHttpContextAccessor _http;
|
private readonly IHttpContextAccessor _http;
|
||||||
private readonly IUnitOfWork _uow;
|
|
||||||
|
|
||||||
private const int RefreshDays = 14;
|
private const int RefreshDays = 14;
|
||||||
|
|
||||||
public IssueTokenPairUseCase(
|
public IssueTokenPairUseCase(
|
||||||
ITenantResolver resolver,
|
|
||||||
ITenantRepository tenants,
|
|
||||||
IUserRepository users,
|
IUserRepository users,
|
||||||
IJwtFactory jwt,
|
IJwtFactory jwt,
|
||||||
IHttpContextAccessor http,
|
IHttpContextAccessor http)
|
||||||
IUnitOfWork uow)
|
|
||||||
{
|
{
|
||||||
_tenantResolver = resolver;
|
|
||||||
_tenants = tenants;
|
|
||||||
_users = users;
|
_users = users;
|
||||||
_jwt = jwt;
|
_jwt = jwt;
|
||||||
_http = http;
|
_http = http;
|
||||||
_uow = uow;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IssueTokenPairResponse> ExecuteAsync(IssueTokenPairRequest request, CancellationToken ct = default)
|
public async Task<IssueTokenPairResponse> ExecuteAsync(IssueTokenPairRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
|
var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
|
||||||
|
|
||||||
var tenantCtx = _tenantResolver.Resolve(http, request);
|
var tenantId = request.TenantId;
|
||||||
if (tenantCtx is null) throw new InvalidOperationException("Cannot resolve tenant context");
|
|
||||||
|
|
||||||
await _uow.BeginAsync(tenantCtx, IsolationLevel.ReadCommitted, ct);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var tn = await _tenants.GetAsync(tenantCtx.Id, ct);
|
|
||||||
var tenantId = tn.TenantId;
|
|
||||||
|
|
||||||
var refreshRaw = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
|
var refreshRaw = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
|
||||||
var refreshHash = Sha256(refreshRaw);
|
var refreshHash = Sha256(refreshRaw);
|
||||||
@@ -71,7 +54,6 @@ public sealed class IssueTokenPairUseCase : IIssueTokenPairUseCase
|
|||||||
IpAddress = http.Connection.RemoteIpAddress?.ToString()
|
IpAddress = http.Connection.RemoteIpAddress?.ToString()
|
||||||
}, ct);
|
}, ct);
|
||||||
|
|
||||||
// 6) tv / sstamp
|
|
||||||
var tv = await _users.GetTenantTokenVersionAsync(tenantId, ct);
|
var tv = await _users.GetTenantTokenVersionAsync(tenantId, ct);
|
||||||
var sstamp = await _users.GetUserSecurityStampAsync(request.UserId, ct) ?? string.Empty;
|
var sstamp = await _users.GetUserSecurityStampAsync(request.UserId, ct) ?? string.Empty;
|
||||||
|
|
||||||
@@ -84,24 +66,17 @@ public sealed class IssueTokenPairUseCase : IIssueTokenPairUseCase
|
|||||||
new("tv", tv),
|
new("tv", tv),
|
||||||
new("sstamp", sstamp)
|
new("sstamp", sstamp)
|
||||||
};
|
};
|
||||||
var (access, accessExp) = _jwt.CreateAccessToken(claims);
|
|
||||||
|
|
||||||
await _uow.CommitAsync(ct);
|
var (access, accessExp) = _jwt.CreateAccessToken(claims);
|
||||||
|
|
||||||
return new IssueTokenPairResponse
|
return new IssueTokenPairResponse
|
||||||
{
|
{
|
||||||
AccessToken = access,
|
AccessToken = access,
|
||||||
AccessExpiresAt = accessExp,
|
AccessExpiresAt = accessExp,
|
||||||
RefreshToken = refreshRaw, // raw ออกให้ client
|
RefreshToken = refreshRaw, // ส่ง raw กลับให้ client
|
||||||
RefreshExpiresAt = session.ExpiresAt
|
RefreshExpiresAt = session.ExpiresAt
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
catch
|
|
||||||
{
|
|
||||||
await _uow.RollbackAsync(ct);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string Sha256(string raw)
|
private static string Sha256(string raw)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,29 +7,45 @@ using AMREZ.EOP.Abstractions.Security;
|
|||||||
using AMREZ.EOP.Contracts.DTOs.Authentications.Login;
|
using AMREZ.EOP.Contracts.DTOs.Authentications.Login;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
namespace AMREZ.EOP.Application.UseCases.Authentications;
|
namespace AMREZ.EOP.Application.UseCases.Authentications
|
||||||
|
{
|
||||||
public sealed class LoginUseCase : ILoginUseCase
|
public sealed class LoginUseCase : ILoginUseCase
|
||||||
{
|
{
|
||||||
private readonly ITenantResolver _tenantResolver;
|
private readonly ITenantResolver _tenantResolver;
|
||||||
private readonly IUserRepository _users;
|
private readonly IUserRepository _users;
|
||||||
|
private readonly ITenantRepository _tenants;
|
||||||
private readonly IPasswordHasher _hasher;
|
private readonly IPasswordHasher _hasher;
|
||||||
private readonly IHttpContextAccessor _http;
|
private readonly IHttpContextAccessor _http;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
|
|
||||||
public LoginUseCase(ITenantResolver r, IUserRepository u, IPasswordHasher h, IHttpContextAccessor http, IUnitOfWork uow)
|
public LoginUseCase(
|
||||||
{ _tenantResolver = r; _users = u; _hasher = h; _http = http; _uow = uow; }
|
ITenantResolver tenantResolver,
|
||||||
|
IUserRepository users,
|
||||||
|
ITenantRepository tenants,
|
||||||
|
IPasswordHasher hasher,
|
||||||
|
IHttpContextAccessor http,
|
||||||
|
IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
_tenantResolver = tenantResolver;
|
||||||
|
_users = users;
|
||||||
|
_tenants = tenants;
|
||||||
|
_hasher = hasher;
|
||||||
|
_http = http;
|
||||||
|
_uow = uow;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<LoginResponse?> ExecuteAsync(LoginRequest request, CancellationToken ct = default)
|
public async Task<LoginResponse?> ExecuteAsync(LoginRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
|
var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
|
||||||
var tenant = _tenantResolver.Resolve(http, request);
|
|
||||||
if (tenant is null) return null;
|
|
||||||
|
|
||||||
await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct);
|
var platform = _tenantResolver.Resolve(http, "@platform");
|
||||||
|
if (platform is null) return null;
|
||||||
|
|
||||||
|
var email = request.Email.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
await _uow.BeginAsync(platform, IsolationLevel.ReadCommitted, ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var email = request.Email.Trim().ToLowerInvariant();
|
|
||||||
var user = await _users.FindActiveByEmailAsync(email, ct);
|
var user = await _users.FindActiveByEmailAsync(email, ct);
|
||||||
if (user is null || !_hasher.Verify(request.Password, user.PasswordHash))
|
if (user is null || !_hasher.Verify(request.Password, user.PasswordHash))
|
||||||
{
|
{
|
||||||
@@ -37,9 +53,16 @@ public sealed class LoginUseCase : ILoginUseCase
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var tenantKey = await _tenants.GetTenantKeyByTenantIdAsync(user.TenantId, ct);
|
||||||
|
if (string.IsNullOrWhiteSpace(tenantKey))
|
||||||
|
{
|
||||||
|
await _uow.RollbackAsync(ct);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
await _uow.CommitAsync(ct);
|
await _uow.CommitAsync(ct);
|
||||||
|
|
||||||
return new LoginResponse(user.Id , email, tenant.Id);
|
return new LoginResponse(user.Id, user.TenantId, email, tenantKey);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -48,3 +71,4 @@ public sealed class LoginUseCase : ILoginUseCase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ namespace AMREZ.EOP.Contracts.DTOs.Authentications.IssueTokenPair;
|
|||||||
public sealed class IssueTokenPairRequest
|
public sealed class IssueTokenPairRequest
|
||||||
{
|
{
|
||||||
public Guid UserId { get; init; }
|
public Guid UserId { get; init; }
|
||||||
|
public Guid TenantId { get; init; } = default!;
|
||||||
public string Tenant { get; init; } = default!;
|
public string Tenant { get; init; } = default!;
|
||||||
public string Email { get; init; } = default!;
|
public string Email { get; init; } = default!;
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,6 @@ namespace AMREZ.EOP.Contracts.DTOs.Authentications.Login;
|
|||||||
|
|
||||||
public sealed class LoginRequest
|
public sealed class LoginRequest
|
||||||
{
|
{
|
||||||
public string? Tenant { get; set; }
|
|
||||||
public string Email { get; set; } = default!;
|
public string Email { get; set; } = default!;
|
||||||
public string Password { get; set; } = default!;
|
public string Password { get; set; } = default!;
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ namespace AMREZ.EOP.Contracts.DTOs.Authentications.Login;
|
|||||||
|
|
||||||
public sealed record LoginResponse(
|
public sealed record LoginResponse(
|
||||||
Guid UserId,
|
Guid UserId,
|
||||||
|
Guid TenantId,
|
||||||
string Email,
|
string Email,
|
||||||
string TenantId
|
string TenantKey
|
||||||
);
|
);
|
||||||
@@ -17,12 +17,17 @@ public sealed class TenantRepository : ITenantRepository
|
|||||||
private readonly IHttpContextAccessor _http;
|
private readonly IHttpContextAccessor _http;
|
||||||
|
|
||||||
public TenantRepository(IDbScope scope, ITenantResolver resolver, IHttpContextAccessor http)
|
public TenantRepository(IDbScope scope, ITenantResolver resolver, IHttpContextAccessor http)
|
||||||
{ _scope = scope; _resolver = resolver; _http = http; }
|
{
|
||||||
|
_scope = scope;
|
||||||
|
_resolver = resolver;
|
||||||
|
_http = http;
|
||||||
|
}
|
||||||
|
|
||||||
private AppDbContext Db()
|
private AppDbContext Db()
|
||||||
{
|
{
|
||||||
var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
|
var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
|
||||||
var platform = _resolver.Resolve(http, "@platform") ?? throw new InvalidOperationException("No platform tenant");
|
var platform = _resolver.Resolve(http, "@platform") ??
|
||||||
|
throw new InvalidOperationException("No platform tenant");
|
||||||
_scope.EnsureForTenant(platform);
|
_scope.EnsureForTenant(platform);
|
||||||
return _scope.Get<AppDbContext>();
|
return _scope.Get<AppDbContext>();
|
||||||
}
|
}
|
||||||
@@ -55,7 +60,8 @@ public sealed class TenantRepository : ITenantRepository
|
|||||||
return await Db().SaveChangesAsync(ct) > 0;
|
return await Db().SaveChangesAsync(ct) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> UpdateAsync(TenantConfig row, DateTimeOffset? ifUnmodifiedSince, CancellationToken ct = default)
|
public async Task<bool> UpdateAsync(TenantConfig row, DateTimeOffset? ifUnmodifiedSince,
|
||||||
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var key = Norm(row.TenantKey);
|
var key = Norm(row.TenantKey);
|
||||||
var db = Db();
|
var db = Db();
|
||||||
@@ -65,8 +71,12 @@ public sealed class TenantRepository : ITenantRepository
|
|||||||
if (ifUnmodifiedSince.HasValue && cur.UpdatedAtUtc > ifUnmodifiedSince.Value)
|
if (ifUnmodifiedSince.HasValue && cur.UpdatedAtUtc > ifUnmodifiedSince.Value)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
cur.Schema = row.Schema is null ? cur.Schema : (string.IsNullOrWhiteSpace(row.Schema) ? null : row.Schema.Trim());
|
cur.Schema = row.Schema is null
|
||||||
cur.ConnectionString = row.ConnectionString is null ? cur.ConnectionString : (string.IsNullOrWhiteSpace(row.ConnectionString) ? null : row.ConnectionString.Trim());
|
? cur.Schema
|
||||||
|
: (string.IsNullOrWhiteSpace(row.Schema) ? null : row.Schema.Trim());
|
||||||
|
cur.ConnectionString = row.ConnectionString is null
|
||||||
|
? cur.ConnectionString
|
||||||
|
: (string.IsNullOrWhiteSpace(row.ConnectionString) ? null : row.ConnectionString.Trim());
|
||||||
cur.Mode = row.Mode;
|
cur.Mode = row.Mode;
|
||||||
cur.IsActive = row.IsActive;
|
cur.IsActive = row.IsActive;
|
||||||
cur.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
cur.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||||
@@ -112,7 +122,13 @@ public sealed class TenantRepository : ITenantRepository
|
|||||||
var db = Db();
|
var db = Db();
|
||||||
var ex = await db.Set<TenantDomain>().FirstOrDefaultAsync(x => x.Domain == d, ct);
|
var ex = await db.Set<TenantDomain>().FirstOrDefaultAsync(x => x.Domain == d, ct);
|
||||||
if (ex is null)
|
if (ex is null)
|
||||||
await db.Set<TenantDomain>().AddAsync(new TenantDomain { Domain = d, TenantKey = key, IsPlatformBaseDomain = false, IsActive = true, UpdatedAtUtc = DateTimeOffset.UtcNow }, ct);
|
await db.Set<TenantDomain>()
|
||||||
|
.AddAsync(
|
||||||
|
new TenantDomain
|
||||||
|
{
|
||||||
|
Domain = d, TenantKey = key, IsPlatformBaseDomain = false, IsActive = true,
|
||||||
|
UpdatedAtUtc = DateTimeOffset.UtcNow
|
||||||
|
}, ct);
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
ex.TenantKey = key;
|
ex.TenantKey = key;
|
||||||
@@ -120,6 +136,7 @@ public sealed class TenantRepository : ITenantRepository
|
|||||||
ex.IsActive = true;
|
ex.IsActive = true;
|
||||||
ex.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
ex.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await db.SaveChangesAsync(ct) > 0;
|
return await db.SaveChangesAsync(ct) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +164,7 @@ public sealed class TenantRepository : ITenantRepository
|
|||||||
var key = Norm(tenantKey!);
|
var key = Norm(tenantKey!);
|
||||||
q = q.Where(x => x.TenantKey == key || x.IsPlatformBaseDomain);
|
q = q.Where(x => x.TenantKey == key || x.IsPlatformBaseDomain);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await q.OrderBy(x => x.Domain).ToListAsync(ct);
|
return await q.OrderBy(x => x.Domain).ToListAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +174,13 @@ public sealed class TenantRepository : ITenantRepository
|
|||||||
var db = Db();
|
var db = Db();
|
||||||
var ex = await db.Set<TenantDomain>().FirstOrDefaultAsync(x => x.Domain == d, ct);
|
var ex = await db.Set<TenantDomain>().FirstOrDefaultAsync(x => x.Domain == d, ct);
|
||||||
if (ex is null)
|
if (ex is null)
|
||||||
await db.Set<TenantDomain>().AddAsync(new TenantDomain { Domain = d, TenantKey = null, IsPlatformBaseDomain = true, IsActive = true, UpdatedAtUtc = DateTimeOffset.UtcNow }, ct);
|
await db.Set<TenantDomain>()
|
||||||
|
.AddAsync(
|
||||||
|
new TenantDomain
|
||||||
|
{
|
||||||
|
Domain = d, TenantKey = null, IsPlatformBaseDomain = true, IsActive = true,
|
||||||
|
UpdatedAtUtc = DateTimeOffset.UtcNow
|
||||||
|
}, ct);
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
ex.TenantKey = null;
|
ex.TenantKey = null;
|
||||||
@@ -164,6 +188,7 @@ public sealed class TenantRepository : ITenantRepository
|
|||||||
ex.IsActive = true;
|
ex.IsActive = true;
|
||||||
ex.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
ex.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await db.SaveChangesAsync(ct) > 0;
|
return await db.SaveChangesAsync(ct) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,4 +205,36 @@ public sealed class TenantRepository : ITenantRepository
|
|||||||
|
|
||||||
return await db.SaveChangesAsync(ct) > 0;
|
return await db.SaveChangesAsync(ct) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// คืนค่า TenantKey จาก TenantId (Guid) ถ้าไม่เจอหรือไม่ active คืน null
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string?> GetTenantKeyByTenantIdAsync(Guid tenantId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var key = await Db().Set<TenantConfig>()
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.TenantId == tenantId && x.IsActive)
|
||||||
|
.Select(x => x.TenantKey)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(key) ? null : key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// คืนค่าแม็พ TenantId -> TenantKey เฉพาะที่เจอและ active
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Dictionary<Guid, string>> MapTenantKeysAsync(IEnumerable<Guid> tenantIds, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var ids = (tenantIds ?? Array.Empty<Guid>()).Distinct().ToArray();
|
||||||
|
if (ids.Length == 0) return new Dictionary<Guid, string>();
|
||||||
|
|
||||||
|
var rows = await Db().Set<TenantConfig>()
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => ids.Contains(x.TenantId) && x.IsActive)
|
||||||
|
.Select(x => new { x.TenantId, x.TenantKey })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return rows.ToDictionary(x => x.TenantId, x => x.TenantKey);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -65,36 +65,33 @@ public class UserRepository : IUserRepository
|
|||||||
|
|
||||||
public async Task<User?> FindActiveByEmailAsync(string email, CancellationToken ct = default)
|
public async Task<User?> FindActiveByEmailAsync(string email, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var tid = TenantId();
|
|
||||||
var tidStr = tid.ToString();
|
|
||||||
var r = _redis?.GetDatabase();
|
var r = _redis?.GetDatabase();
|
||||||
|
var norm = email.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
if (r is not null)
|
if (r is not null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var cached = await r.StringGetAsync(IdentityEmailKey(tidStr, email));
|
var cached = await r.StringGetAsync($"uidx:{norm}");
|
||||||
if (cached.HasValue)
|
if (cached.HasValue)
|
||||||
{
|
{
|
||||||
var hit = JsonSerializer.Deserialize<User>(cached!);
|
var hit = JsonSerializer.Deserialize<User>(cached!);
|
||||||
if (hit?.IsActive == true) return hit;
|
if (hit?.IsActive == true) return hit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch { }
|
catch { /* ignore cache errors */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
var db = _scope.Get<AppDbContext>();
|
var db = _scope.Get<AppDbContext>();
|
||||||
var norm = email.Trim().ToLowerInvariant();
|
|
||||||
|
|
||||||
var user = await db.Users
|
var user = await (
|
||||||
.AsNoTracking()
|
from u in db.Users.AsNoTracking()
|
||||||
.Where(u => u.TenantId == tid && u.IsActive)
|
join i in db.UserIdentities.AsNoTracking() on u.Id equals i.UserId
|
||||||
.Where(u => db.UserIdentities.Any(i =>
|
where u.IsActive
|
||||||
i.TenantId == tid &&
|
&& i.Type == IdentityType.Email
|
||||||
i.UserId == u.Id &&
|
&& i.Identifier == norm
|
||||||
i.Type == IdentityType.Email &&
|
select u
|
||||||
i.Identifier == norm))
|
).FirstOrDefaultAsync(ct);
|
||||||
.FirstOrDefaultAsync(ct);
|
|
||||||
|
|
||||||
if (user is not null && r is not null)
|
if (user is not null && r is not null)
|
||||||
{
|
{
|
||||||
@@ -102,10 +99,10 @@ public class UserRepository : IUserRepository
|
|||||||
{
|
{
|
||||||
var payload = JsonSerializer.Serialize(user);
|
var payload = JsonSerializer.Serialize(user);
|
||||||
var ttl = TimeSpan.FromMinutes(5);
|
var ttl = TimeSpan.FromMinutes(5);
|
||||||
await r.StringSetAsync(IdentityEmailKey(tidStr, norm), payload, ttl);
|
await r.StringSetAsync($"uidx:{norm}", payload, ttl);
|
||||||
await r.StringSetAsync(UserIdKey(tidStr, user.Id), payload, ttl);
|
await r.StringSetAsync($"user:{user.Id:N}", payload, ttl);
|
||||||
}
|
}
|
||||||
catch { }
|
catch { /* ignore cache errors */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
@@ -291,9 +288,7 @@ public class UserRepository : IUserRepository
|
|||||||
|
|
||||||
public async Task<UserSession> CreateSessionAsync(UserSession session, CancellationToken ct = default)
|
public async Task<UserSession> CreateSessionAsync(UserSession session, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var tid = TenantId();
|
|
||||||
var db = _scope.Get<AppDbContext>();
|
var db = _scope.Get<AppDbContext>();
|
||||||
session.TenantId = tid;
|
|
||||||
await db.UserSessions.AddAsync(session, ct);
|
await db.UserSessions.AddAsync(session, ct);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return session;
|
return session;
|
||||||
@@ -383,11 +378,10 @@ public class UserRepository : IUserRepository
|
|||||||
|
|
||||||
public async Task<string?> GetUserSecurityStampAsync(Guid userId, CancellationToken ct = default)
|
public async Task<string?> GetUserSecurityStampAsync(Guid userId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var tid = TenantId();
|
|
||||||
var db = _scope.Get<AppDbContext>();
|
var db = _scope.Get<AppDbContext>();
|
||||||
return await db.Users
|
return await db.Users
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(x => x.TenantId == tid && x.Id == userId)
|
.Where(x => x.Id == userId)
|
||||||
.Select(x => x.SecurityStamp)
|
.Select(x => x.SecurityStamp)
|
||||||
.FirstOrDefaultAsync(ct);
|
.FirstOrDefaultAsync(ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,12 +28,11 @@ public sealed class JwtFactory : IJwtFactory
|
|||||||
|
|
||||||
public (string token, DateTimeOffset expiresAt) CreateAccessToken(IEnumerable<Claim> claims)
|
public (string token, DateTimeOffset expiresAt) CreateAccessToken(IEnumerable<Claim> claims)
|
||||||
{
|
{
|
||||||
var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
|
var tenantId = claims.FirstOrDefault(c => c.Type == "tenant_id")?.Value
|
||||||
var tenant = _resolver.Resolve(http) ?? throw new InvalidOperationException("No tenant context");
|
?? throw new InvalidOperationException("tenant_id claim missing");
|
||||||
|
|
||||||
var material = !string.IsNullOrWhiteSpace(tenant.Id) ? tenant.Id! : tenant.TenantKey!;
|
var keyBytes = SHA256.HashData(Encoding.UTF8.GetBytes(tenantId));
|
||||||
var keyBytes = SHA256.HashData(Encoding.UTF8.GetBytes(material));
|
var creds = new SigningCredentials(new SymmetricSecurityKey(keyBytes), SecurityAlgorithms.HmacSha256);
|
||||||
var cred = new SigningCredentials(new SymmetricSecurityKey(keyBytes), SecurityAlgorithms.HmacSha256);
|
|
||||||
|
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
var exp = now.AddMinutes(AccessMinutes);
|
var exp = now.AddMinutes(AccessMinutes);
|
||||||
@@ -44,7 +43,8 @@ public sealed class JwtFactory : IJwtFactory
|
|||||||
claims: claims,
|
claims: claims,
|
||||||
notBefore: now.UtcDateTime,
|
notBefore: now.UtcDateTime,
|
||||||
expires: exp.UtcDateTime,
|
expires: exp.UtcDateTime,
|
||||||
signingCredentials: cred);
|
signingCredentials: creds
|
||||||
|
);
|
||||||
|
|
||||||
return (new JwtSecurityTokenHandler().WriteToken(jwt), exp);
|
return (new JwtSecurityTokenHandler().WriteToken(jwt), exp);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ public sealed class DefaultTenantResolver : ITenantResolver
|
|||||||
private readonly TenantMap _map;
|
private readonly TenantMap _map;
|
||||||
private readonly ILogger<DefaultTenantResolver>? _log;
|
private readonly ILogger<DefaultTenantResolver>? _log;
|
||||||
|
|
||||||
// แพลตฟอร์มสลัก (ปรับตามระบบจริงได้) — ใช้ "public" เป็นค่าเริ่ม
|
|
||||||
private const string PlatformSlug = "public";
|
private const string PlatformSlug = "public";
|
||||||
|
|
||||||
public DefaultTenantResolver(
|
public DefaultTenantResolver(
|
||||||
@@ -76,13 +75,6 @@ public sealed class DefaultTenantResolver : ITenantResolver
|
|||||||
return bySub;
|
return bySub;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hint is LoginRequest body && !string.IsNullOrWhiteSpace(body.Tenant) &&
|
|
||||||
_map.TryGetBySlug(body.Tenant!, out var byBody))
|
|
||||||
{
|
|
||||||
_log?.LogInformation("Resolved by body: {Tenant} cid={Cid}", body.Tenant, cid);
|
|
||||||
return byBody;
|
|
||||||
}
|
|
||||||
|
|
||||||
_log?.LogWarning("Resolve FAILED host={Host} header={Header} cid={Cid}",
|
_log?.LogWarning("Resolve FAILED host={Host} header={Header} cid={Cid}",
|
||||||
host, string.IsNullOrWhiteSpace(header) ? "<empty>" : header, cid);
|
host, string.IsNullOrWhiteSpace(header) ? "<empty>" : header, cid);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user