Fix Access/Refres Token

This commit is contained in:
Thanakarn Klangkasame
2025-10-05 17:24:30 +07:00
parent d266463c9f
commit ad0d9e41ba
12 changed files with 191 additions and 143 deletions

View File

@@ -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
}); });
} }

View File

@@ -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);
} }

View File

@@ -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);
} }

View File

@@ -15,92 +15,67 @@ 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); var refreshRaw = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
var refreshHash = Sha256(refreshRaw);
var now = DateTimeOffset.UtcNow;
try var session = await _users.CreateSessionAsync(new UserSession
{ {
var tn = await _tenants.GetAsync(tenantCtx.Id, ct); Id = Guid.NewGuid(),
var tenantId = tn.TenantId; TenantId = tenantId,
UserId = request.UserId,
RefreshTokenHash = refreshHash,
IssuedAt = now,
ExpiresAt = now.AddDays(RefreshDays),
DeviceId = http.Request.Headers["X-Device-Id"].FirstOrDefault(),
UserAgent = http.Request.Headers["User-Agent"].FirstOrDefault(),
IpAddress = http.Connection.RemoteIpAddress?.ToString()
}, ct);
var refreshRaw = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)); var tv = await _users.GetTenantTokenVersionAsync(tenantId, ct);
var refreshHash = Sha256(refreshRaw); var sstamp = await _users.GetUserSecurityStampAsync(request.UserId, ct) ?? string.Empty;
var now = DateTimeOffset.UtcNow;
var session = await _users.CreateSessionAsync(new UserSession var claims = new List<Claim>
{
Id = Guid.NewGuid(),
TenantId = tenantId,
UserId = request.UserId,
RefreshTokenHash = refreshHash,
IssuedAt = now,
ExpiresAt = now.AddDays(RefreshDays),
DeviceId = http.Request.Headers["X-Device-Id"].FirstOrDefault(),
UserAgent = http.Request.Headers["User-Agent"].FirstOrDefault(),
IpAddress = http.Connection.RemoteIpAddress?.ToString()
}, ct);
// 6) tv / sstamp
var tv = await _users.GetTenantTokenVersionAsync(tenantId, ct);
var sstamp = await _users.GetUserSecurityStampAsync(request.UserId, ct) ?? string.Empty;
var claims = new List<Claim>
{
new("sub", request.UserId.ToString()),
new("tenant_id", tenantId.ToString()),
new("sid", session.Id.ToString()),
new("jti", Guid.NewGuid().ToString("N")),
new("tv", tv),
new("sstamp", sstamp)
};
var (access, accessExp) = _jwt.CreateAccessToken(claims);
await _uow.CommitAsync(ct);
return new IssueTokenPairResponse
{
AccessToken = access,
AccessExpiresAt = accessExp,
RefreshToken = refreshRaw, // raw ออกให้ client
RefreshExpiresAt = session.ExpiresAt
};
}
catch
{ {
await _uow.RollbackAsync(ct); new("sub", request.UserId.ToString()),
throw; new("tenant_id", tenantId.ToString()),
} new("sid", session.Id.ToString()),
new("jti", Guid.NewGuid().ToString("N")),
new("tv", tv),
new("sstamp", sstamp)
};
var (access, accessExp) = _jwt.CreateAccessToken(claims);
return new IssueTokenPairResponse
{
AccessToken = access,
AccessExpiresAt = accessExp,
RefreshToken = refreshRaw, // ส่ง raw กลับให้ client
RefreshExpiresAt = session.ExpiresAt
};
} }
private static string Sha256(string raw) private static string Sha256(string raw)

View File

@@ -7,44 +7,68 @@ 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
{ {
private readonly ITenantResolver _tenantResolver; public sealed class LoginUseCase : ILoginUseCase
private readonly IUserRepository _users;
private readonly IPasswordHasher _hasher;
private readonly IHttpContextAccessor _http;
private readonly IUnitOfWork _uow;
public LoginUseCase(ITenantResolver r, IUserRepository u, IPasswordHasher h, IHttpContextAccessor http, IUnitOfWork uow)
{ _tenantResolver = r; _users = u; _hasher = h; _http = http; _uow = uow; }
public async Task<LoginResponse?> ExecuteAsync(LoginRequest request, CancellationToken ct = default)
{ {
var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext"); private readonly ITenantResolver _tenantResolver;
var tenant = _tenantResolver.Resolve(http, request); private readonly IUserRepository _users;
if (tenant is null) return null; private readonly ITenantRepository _tenants;
private readonly IPasswordHasher _hasher;
private readonly IHttpContextAccessor _http;
private readonly IUnitOfWork _uow;
await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct); public LoginUseCase(
try 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)
{
var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
var platform = _tenantResolver.Resolve(http, "@platform");
if (platform is null) return null;
var email = request.Email.Trim().ToLowerInvariant(); var email = request.Email.Trim().ToLowerInvariant();
var user = await _users.FindActiveByEmailAsync(email, ct);
if (user is null || !_hasher.Verify(request.Password, user.PasswordHash)) await _uow.BeginAsync(platform, IsolationLevel.ReadCommitted, ct);
try
{
var user = await _users.FindActiveByEmailAsync(email, ct);
if (user is null || !_hasher.Verify(request.Password, user.PasswordHash))
{
await _uow.RollbackAsync(ct);
return null;
}
var tenantKey = await _tenants.GetTenantKeyByTenantIdAsync(user.TenantId, ct);
if (string.IsNullOrWhiteSpace(tenantKey))
{
await _uow.RollbackAsync(ct);
return null;
}
await _uow.CommitAsync(ct);
return new LoginResponse(user.Id, user.TenantId, email, tenantKey);
}
catch
{ {
await _uow.RollbackAsync(ct); await _uow.RollbackAsync(ct);
return null; throw;
} }
await _uow.CommitAsync(ct);
return new LoginResponse(user.Id , email, tenant.Id);
}
catch
{
await _uow.RollbackAsync(ct);
throw;
} }
} }
} }

View File

@@ -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!;
} }

View File

@@ -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!;
} }

View File

@@ -1,7 +1,8 @@
namespace AMREZ.EOP.Contracts.DTOs.Authentications.Login; 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
); );

View File

@@ -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);
}
} }

View File

@@ -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);
} }

View File

@@ -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);
} }

View File

@@ -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;