Add Login Module

This commit is contained in:
Thanakarn Klangkasame
2025-10-02 11:18:44 +07:00
parent f505e31cfd
commit 563a341a99
52 changed files with 1127 additions and 2036 deletions

View File

@@ -0,0 +1,111 @@
using System.Data;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using AMREZ.EOP.Abstractions.Applications.Tenancy;
using AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
using AMREZ.EOP.Abstractions.Infrastructures.Common;
using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
using AMREZ.EOP.Abstractions.Security;
using AMREZ.EOP.Contracts.DTOs.Authentications.IssueTokenPair;
using AMREZ.EOP.Domain.Entities.Authentications;
using Microsoft.AspNetCore.Http;
namespace AMREZ.EOP.Application.UseCases.Authentications;
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 IJwtFactory _jwt;
private readonly IHttpContextAccessor _http;
private readonly IUnitOfWork _uow;
private const int RefreshDays = 14;
public IssueTokenPairUseCase(
ITenantResolver resolver,
ITenantRepository tenants,
IUserRepository users,
IJwtFactory jwt,
IHttpContextAccessor http,
IUnitOfWork uow)
{
_tenantResolver = resolver;
_tenants = tenants;
_users = users;
_jwt = jwt;
_http = http;
_uow = uow;
}
public async Task<IssueTokenPairResponse> ExecuteAsync(IssueTokenPairRequest request, CancellationToken ct = default)
{
var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
var tenantCtx = _tenantResolver.Resolve(http, request);
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 refreshHash = Sha256(refreshRaw);
var now = DateTimeOffset.UtcNow;
var session = await _users.CreateSessionAsync(new UserSession
{
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);
throw;
}
}
private static string Sha256(string raw)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
return Convert.ToHexString(bytes);
}
}

View File

@@ -39,8 +39,7 @@ public sealed class LoginUseCase : ILoginUseCase
await _uow.CommitAsync(ct);
// NOTE: ไม่ใช้ DisplayName ใน Entity แล้ว — ส่งกลับเป็นค่าว่าง/ไปดึงจาก HR ฝั่ง API
return new LoginResponse(user.Id, string.Empty, email, tenant.Id);
return new LoginResponse(user.Id , email, tenant.Id);
}
catch
{

View File

@@ -16,12 +16,7 @@ public sealed class LogoutAllUseCase : ILogoutAllUseCase
private readonly IHttpContextAccessor _http;
public LogoutAllUseCase(ITenantResolver r, IUnitOfWork uow, IUserRepository users, IHttpContextAccessor http)
{
_resolver = r;
_uow = uow;
_users = users;
_http = http;
}
{ _resolver = r; _uow = uow; _users = users; _http = http; }
public async Task<int> ExecuteAsync(LogoutAllRequest request, CancellationToken ct = default)
{

View File

@@ -4,6 +4,7 @@ using AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
using AMREZ.EOP.Abstractions.Infrastructures.Common;
using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
using AMREZ.EOP.Contracts.DTOs.Authentications.Logout;
using AMREZ.EOP.Domain.Entities.Authentications;
using Microsoft.AspNetCore.Http;
namespace AMREZ.EOP.Application.UseCases.Authentications;
@@ -31,6 +32,10 @@ public sealed class LogoutUseCase : ILogoutUseCase
await _uow.CommitAsync(ct);
return n > 0;
}
catch { await _uow.RollbackAsync(ct); throw; }
catch
{
await _uow.RollbackAsync(ct);
throw;
}
}
}

View File

@@ -0,0 +1,102 @@
using System.Data;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using AMREZ.EOP.Abstractions.Applications.Tenancy;
using AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
using AMREZ.EOP.Abstractions.Infrastructures.Common;
using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
using AMREZ.EOP.Abstractions.Security;
using AMREZ.EOP.Contracts.DTOs.Authentications.Refresh;
using Microsoft.AspNetCore.Http;
namespace AMREZ.EOP.Application.UseCases.Authentications;
public sealed class RefreshUseCase : IRefreshUseCase
{
private readonly ITenantResolver _resolver;
private readonly ITenantRepository _tenants;
private readonly IUserRepository _users;
private readonly IJwtFactory _jwt;
private readonly IHttpContextAccessor _http;
private readonly IUnitOfWork _uow;
private const int RefreshDays = 14;
public RefreshUseCase(
ITenantResolver resolver,
ITenantRepository tenants,
IUserRepository users,
IJwtFactory jwt,
IHttpContextAccessor http,
IUnitOfWork uow)
{ _resolver = resolver; _tenants = tenants; _users = users; _jwt = jwt; _http = http; _uow = uow; }
public async Task<RefreshResponse?> ExecuteAsync(RefreshRequest request, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(request.RefreshToken)) return null;
var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
var tenantCtx = _resolver.Resolve(http, request);
if (tenantCtx is null) return null;
await _uow.BeginAsync(tenantCtx, IsolationLevel.ReadCommitted, ct);
try
{
var tn = await _tenants.GetAsync(tenantCtx.TenantKey, ct);
var tenantId = tn.TenantId;
var hash = Sha256(request.RefreshToken);
var session = await _users.FindSessionByRefreshHashAsync(tenantId, hash, ct);
if (session is null || session.RevokedAt.HasValue ||
(session.ExpiresAt.HasValue && session.ExpiresAt.Value <= DateTimeOffset.UtcNow))
{ await _uow.RollbackAsync(ct); return null; }
if (!await _users.IsSessionActiveAsync(session.UserId, session.Id, ct))
{ await _uow.RollbackAsync(ct); return null; }
var tv = await _users.GetTenantTokenVersionAsync(tenantId, ct);
var sstamp = await _users.GetUserSecurityStampAsync(session.UserId, ct) ?? string.Empty;
var claims = new List<Claim>
{
new("sub", session.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);
var newRaw = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
var newHash = Sha256(newRaw);
var now = DateTimeOffset.UtcNow;
var newExp = now.AddDays(RefreshDays);
var ok = await _users.RotateSessionRefreshAsync(tenantId, session.Id, newHash, now, newExp, ct);
if (!ok) { await _uow.RollbackAsync(ct); return null; }
await _uow.CommitAsync(ct);
return new RefreshResponse
{
AccessToken = access,
AccessExpiresAt = accessExp,
RefreshToken = newRaw,
RefreshExpiresAt = newExp
};
}
catch
{
await _uow.RollbackAsync(ct);
throw;
}
}
private static string Sha256(string raw)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
return Convert.ToHexString(bytes);
}
}

View File

@@ -64,9 +64,9 @@ public sealed class RegisterUseCase : IRegisterUseCase
IsActive = true,
};
// แนบอัตลักษณ์แบบ Email (เก็บในตารางลูก)
user.Identities.Add(new UserIdentity
{
TenantId = tn.TenantId,
Type = IdentityType.Email,
Identifier = emailNorm,
IsPrimary = true,
@@ -76,7 +76,6 @@ public sealed class RegisterUseCase : IRegisterUseCase
await _users.AddAsync(user, ct);
await _uow.CommitAsync(ct);
// ไม่ส่ง DisplayName (ปล่อยให้ HR/Presentation สร้าง)
return new RegisterResponse(user.Id, string.Empty, emailNorm, tenant.Id);
}
catch