using DXPDAL.Auth; using DXPDAL.Party; using GB5Shared.DTO.Framework.Login; using GB5Shared.EntityHandler; using GB5Shared.GenerateAutoNumber; using GB5Shared.QueryExecutor; using GB5Shared.Resource.Response; using GB5Shared.Telemetry; using static GB5Shared.GB5Constant.Constant; namespace DXPBLL.Auth; public class AuthBLL : IAuthBLL { private readonly IAuthDAL _AuthDAL; private readonly IPartyDAL _PartyDAL; private readonly IDXPJwtService _JwtService; private readonly AutoNumber _AutoNumber; private readonly IQueryExecutor _QueryExecutor; private readonly BaseEntityAppService _BaseEntityAppServiceUser; private readonly BaseEntityAppService _BaseEntityAppServiceRole; public AuthBLL( IAuthDAL authDAL, IPartyDAL partyDAL, IDXPJwtService jwtService, AutoNumber autoNumber, IQueryExecutor queryExecutor, BaseEntityAppService baseEntityAppServiceUser, BaseEntityAppService baseEntityAppServiceRole) { _AuthDAL = authDAL; _PartyDAL = partyDAL; _JwtService = jwtService; _AutoNumber = autoNumber; _QueryExecutor = queryExecutor; _BaseEntityAppServiceUser = baseEntityAppServiceUser; _BaseEntityAppServiceRole = baseEntityAppServiceRole; } // ── Register ────────────────────────────────────────────────────────────── public async Task RegisterAsync( string fullName, string email, string? mobile, string password, LoginDTO systemLogin, CancellationToken ct) { if (string.IsNullOrWhiteSpace(fullName)) throw new ArgumentException("FullName is required."); if (string.IsNullOrWhiteSpace(email)) throw new ArgumentException("Email is required."); if (string.IsNullOrWhiteSpace(password) || password.Length < 8) throw new ArgumentException("Password must be at least 8 characters."); var existing = await _AuthDAL.GetUserByEmailAsync(email, systemLogin, ct).ConfigureAwait(false); if (existing != null) throw new InvalidOperationException("An account with this email already exists."); var dto = new UserDTO { FullName = fullName, Email = email, Mobile = mobile, PasswordHash = DXPPasswordHasher.Hash(password), Status = 1 }; var Trans = await _QueryExecutor.BeginTransactionAsync(systemLogin).ConfigureAwait(false); try { GB5Trace.Step("register-dxp-user", new { email }); var auto = await _AutoNumber.GetNumberAsync(1, AUTONUMBERCONSTANT.DXPUSER, systemLogin) .ConfigureAwait(false); dto.DxpUserId = auto.StartNumber; // Self-registration — no acting user exists yet, so the new user is its own creator/ // modifier, matching how a self-service signup is audited elsewhere in GB5. Found via // live verification (2026-07-16): CreatedOn/ModifiedOn were never stamped, defaulting to // DateTime.MinValue (0001-01-01) — below SQL Server's datetime minimum (1753-01-01), // which throws SqlTypeException at INSERT time rather than at compile/build time. var now = DateTime.UtcNow; dto.CreatedById = dto.DxpUserId; dto.CreatedOn = now; dto.ModifiedById = dto.DxpUserId; dto.ModifiedOn = now; await _BaseEntityAppServiceUser.ExecuteSaveAsync( EntityConstant.OBJECTDXPUSER, EventTypeConstant.SAVEDXPUSEREVENTTYPEID, dto, systemLogin, async tx => { await _AuthDAL.SaveUserAsync(dto, systemLogin, tx, ct).ConfigureAwait(false); return dto.DxpUserId; }, null, -1, -1, Trans).ConfigureAwait(false); await _QueryExecutor.CommitAsync(Trans).ConfigureAwait(false); return $"{SuccessResponse.SaveSuccessMessage} {dto.DxpUserId}"; } catch (Exception ex) { await _QueryExecutor.RollbackAsync(Trans).ConfigureAwait(false); GB5Trace.MarkFailed("register-dxp-user-failed", ex); throw; } } // ── Login ───────────────────────────────────────────────────────────────── public async Task LoginAsync( string email, string password, string? deviceInfo, LoginDTO systemLogin, CancellationToken ct) { var user = await _AuthDAL.GetUserByEmailAsync(email, systemLogin, ct).ConfigureAwait(false) ?? throw new InvalidOperationException("Invalid email or password."); if (user.Status == 2) throw new InvalidOperationException("This account is locked. Contact your administrator."); if (!DXPPasswordHasher.Verify(password, user.PasswordHash)) { var Trans = await _QueryExecutor.BeginTransactionAsync(systemLogin).ConfigureAwait(false); try { user.FailedLoginCount++; await _AuthDAL.UpdateLoginStatsAsync(user, systemLogin, Trans, ct).ConfigureAwait(false); await _QueryExecutor.CommitAsync(Trans).ConfigureAwait(false); } catch { await _QueryExecutor.RollbackAsync(Trans).ConfigureAwait(false); } throw new InvalidOperationException("Invalid email or password."); } GB5Trace.Step("login-dxp-user", new { user.DxpUserId }); var successTx = await _QueryExecutor.BeginTransactionAsync(systemLogin).ConfigureAwait(false); try { user.LastLoginOn = DateTime.UtcNow; user.FailedLoginCount = 0; await _AuthDAL.UpdateLoginStatsAsync(user, systemLogin, successTx, ct).ConfigureAwait(false); await _QueryExecutor.CommitAsync(successTx).ConfigureAwait(false); } catch (Exception ex) { await _QueryExecutor.RollbackAsync(successTx).ConfigureAwait(false); GB5Trace.MarkFailed("login-dxp-user-stats-failed", ex); throw; } var contexts = await BuildContextsAsync(user.DxpUserId, systemLogin, ct).ConfigureAwait(false); var result = new DXPLoginResultDTO { DxpUserId = user.DxpUserId, FullName = user.FullName, Contexts = contexts }; // Single-context fast path — skip the extra context-switch round trip. var allCombos = contexts.SelectMany(c => c.Links.Select(l => (c, l))).ToList(); if (allCombos.Count == 1) { var (ctx, link) = allCombos[0]; var issued = await IssueTokensAsync( user.DxpUserId, ctx.DxpUserPartyRoleId, ctx.DxpPartyId, ctx.RoleCode, link, deviceInfo, systemLogin, ct).ConfigureAwait(false); result.Tokens = issued.TokenPair; } else { // Multiple contexts — caller must pick one via ContextSwitch. Issue an // intermediate token (dxp_user_id only) so ContextSwitch can still read the // caller's identity from a verified Bearer token rather than the request body. result.IntermediateToken = await _JwtService.IssueAccessTokenAsync(new DXPAccessTokenClaims { DxpUserId = user.DxpUserId, RoleCode = string.Empty }, ct).ConfigureAwait(false); } return result; } public async Task> GetContextsAsync(int dxpUserId, LoginDTO systemLogin, CancellationToken ct) => await BuildContextsAsync(dxpUserId, systemLogin, ct).ConfigureAwait(false); private async Task> BuildContextsAsync(int dxpUserId, LoginDTO systemLogin, CancellationToken ct) { var roles = await _AuthDAL.GetMyContextsAsync(dxpUserId, systemLogin, ct).ConfigureAwait(false); var contexts = new List(); foreach (var role in roles) { var links = await _PartyDAL.GetPartyLinksAsync(role.DxpPartyId, systemLogin, ct).ConfigureAwait(false); contexts.Add(new DXPContextDTO { DxpUserPartyRoleId = role.DxpUserPartyRoleId, DxpPartyId = role.DxpPartyId, PartyLegalName = role.PartyLegalName ?? string.Empty, PartyTypeCode = role.PartyTypeCode, RoleCode = role.RoleCode, Links = links.Select(l => new DXPContextLinkDTO { DxpPartyLinkId = l.DxpPartyLinkId, TenantId = l.TenantId, DatabaseName = l.DatabaseName, DatabaseType = l.DatabaseType, LocalPartyId = l.LocalPartyId, RelationshipType = l.RelationshipType }).ToList() }); } return contexts; } // ── Context switch ─────────────────────────────────────────────────────── public async Task ContextSwitchAsync( int dxpUserId, int dxpUserPartyRoleId, int dxpPartyLinkId, string? deviceInfo, LoginDTO systemLogin, CancellationToken ct) { var role = await _AuthDAL.GetUserPartyRoleAsync(dxpUserPartyRoleId, dxpUserId, systemLogin, ct) .ConfigureAwait(false) ?? throw new InvalidOperationException("Role not found for this user."); var links = await _PartyDAL.GetPartyLinksAsync(role.DxpPartyId, systemLogin, ct).ConfigureAwait(false); var link = links.FirstOrDefault(l => l.DxpPartyLinkId == dxpPartyLinkId) ?? throw new InvalidOperationException("Tenant relationship not found for this party."); var issued = await IssueTokensAsync( dxpUserId, role.DxpUserPartyRoleId, role.DxpPartyId, role.RoleCode, new DXPContextLinkDTO { DxpPartyLinkId = link.DxpPartyLinkId, TenantId = link.TenantId, DatabaseName = link.DatabaseName, DatabaseType = link.DatabaseType, LocalPartyId = link.LocalPartyId, RelationshipType = link.RelationshipType }, deviceInfo, systemLogin, ct).ConfigureAwait(false); return issued.TokenPair; } // Wraps the newly-issued token pair with the id of the DB row it was persisted as — needed // internally so RefreshAsync can link the OLD token's ReplacedByTokenId to this NEW token's // id (the actual reuse-detection fix; see RefreshAsync below). Not part of IAuthBLL — every // public caller still only ever sees DXPTokenPair, unchanged. private sealed class DXPIssuedTokenPair { public DXPTokenPair TokenPair { get; init; } = null!; public int DxpRefreshTokenId { get; init; } } private async Task IssueTokensAsync( int dxpUserId, int dxpUserPartyRoleId, int dxpPartyId, string roleCode, DXPContextLinkDTO link, string? deviceInfo, LoginDTO systemLogin, CancellationToken ct) { // Both expiries now come from one place — DXPJwtService.IssueTokenPairAsync, backed by // DXPJwtOptions — computed exactly once and reused verbatim below. This replaces the old // hardcoded "AccessTokenExpiresOn = ...AddMinutes(15)" and "var refreshDays = 30" literals, // which could silently disagree with the JWT's own claims/config if AccessTokenMinutes was // ever changed — see AuthBLLTests.cs's characterization tests for the bug this fixes. var pair = await _JwtService.IssueTokenPairAsync(new DXPAccessTokenClaims { DxpUserId = dxpUserId, DxpPartyId = dxpPartyId, TenantId = link.TenantId, DatabaseName = link.DatabaseName, DatabaseType = link.DatabaseType, LocalPartyId = link.LocalPartyId, RoleCode = roleCode }, ct).ConfigureAwait(false); var refreshHash = _JwtService.HashRefreshToken(pair.RefreshToken); var refreshDto = new RefreshTokenDTO { DxpUserId = dxpUserId, DxpUserPartyRoleId = dxpUserPartyRoleId, DxpPartyLinkId = link.DxpPartyLinkId, TokenHash = refreshHash, DeviceInfo = deviceInfo, IssuedOn = DateTime.UtcNow, ExpiresOn = pair.RefreshTokenExpiresOn }; var Trans = await _QueryExecutor.BeginTransactionAsync(systemLogin).ConfigureAwait(false); try { var auto = await _AutoNumber.GetNumberAsync(1, AUTONUMBERCONSTANT.DXPREFRESHTOKEN, systemLogin) .ConfigureAwait(false); refreshDto.DxpRefreshTokenId = auto.StartNumber; refreshDto.CreatedById = dxpUserId; refreshDto.CreatedOn = DateTime.UtcNow; await _AuthDAL.SaveRefreshTokenAsync(refreshDto, systemLogin, Trans, ct).ConfigureAwait(false); await _QueryExecutor.CommitAsync(Trans).ConfigureAwait(false); } catch (Exception ex) { await _QueryExecutor.RollbackAsync(Trans).ConfigureAwait(false); GB5Trace.MarkFailed("issue-dxp-refresh-token-failed", ex); throw; } return new DXPIssuedTokenPair { TokenPair = pair, DxpRefreshTokenId = refreshDto.DxpRefreshTokenId }; } // ── Refresh / logout ───────────────────────────────────────────────────── public async Task RefreshAsync( string rawRefreshToken, string? deviceInfo, LoginDTO systemLogin, CancellationToken ct) { var hash = _JwtService.HashRefreshToken(rawRefreshToken); var existing = await _AuthDAL.GetRefreshTokenByHashAsync(hash, systemLogin, ct).ConfigureAwait(false) ?? throw new InvalidOperationException("Invalid refresh token."); if (existing.RevokedOn != null) { // Theft signal: this exact token hash was already exchanged once before (it carries a // RevokedOn from a prior legitimate rotation) and is being presented again — someone // holds a copy of a token that should no longer be usable. Kill the entire forward // lineage, not just this row, then reject — mirrors // EntitlementBLL.Auth.ClientAuthBLL.RefreshAsync's identical reuse-detected branch. // Before this fix, DXP rejected the replay correctly but never cascaded, because // ReplacedByTokenId was never populated on legitimate rotations in the first place — // see the branch below. GB5Trace.MarkFailed("dxp-refresh-token-reuse-detected", new InvalidOperationException($"Refresh token reuse detected for DxpUserId {existing.DxpUserId}")); await CascadeRevokeChainAsync(existing.DxpRefreshTokenId, systemLogin, ct).ConfigureAwait(false); throw new InvalidOperationException("This refresh token has been revoked. Please log in again."); } if (existing.ExpiresOn < DateTime.UtcNow) throw new InvalidOperationException("Refresh token expired. Please log in again."); var role = await _AuthDAL.GetUserPartyRoleAsync(existing.DxpUserPartyRoleId, existing.DxpUserId, systemLogin, ct) .ConfigureAwait(false) ?? throw new InvalidOperationException("Role no longer active."); var links = await _PartyDAL.GetPartyLinksAsync(role.DxpPartyId, systemLogin, ct).ConfigureAwait(false); var link = links.FirstOrDefault(l => l.DxpPartyLinkId == existing.DxpPartyLinkId) ?? throw new InvalidOperationException("Tenant relationship no longer active."); var issued = await IssueTokensAsync( existing.DxpUserId, role.DxpUserPartyRoleId, role.DxpPartyId, role.RoleCode, new DXPContextLinkDTO { DxpPartyLinkId = link.DxpPartyLinkId, TenantId = link.TenantId, DatabaseName = link.DatabaseName, DatabaseType = link.DatabaseType, LocalPartyId = link.LocalPartyId, RelationshipType = link.RelationshipType }, deviceInfo, systemLogin, ct).ConfigureAwait(false); // Rotate — revoke the presented token now that its replacement exists. This is the actual // fix: ReplacedByTokenId is populated for real, right here, in the same transaction as the // new token's insert — so a later reuse of THIS exact token hash finds RevokedOn != null // AND has a real chain to cascade-revoke via the branch above. var Trans = await _QueryExecutor.BeginTransactionAsync(systemLogin).ConfigureAwait(false); try { await _AuthDAL.RevokeRefreshTokenAsync(existing.DxpRefreshTokenId, issued.DxpRefreshTokenId, systemLogin, Trans, ct) .ConfigureAwait(false); await _QueryExecutor.CommitAsync(Trans).ConfigureAwait(false); } catch (Exception ex) { await _QueryExecutor.RollbackAsync(Trans).ConfigureAwait(false); GB5Trace.MarkFailed("revoke-dxp-refresh-token-failed", ex); throw; } return issued.TokenPair; } private async Task CascadeRevokeChainAsync(int fromDxpRefreshTokenId, LoginDTO systemLogin, CancellationToken ct) { var descendants = await _AuthDAL.GetChainDescendantsAsync(fromDxpRefreshTokenId, systemLogin, ct).ConfigureAwait(false); var stillActive = descendants.Where(d => d.RevokedOn is null).ToList(); if (stillActive.Count == 0) return; var Trans = await _QueryExecutor.BeginTransactionAsync(systemLogin).ConfigureAwait(false); try { foreach (var token in stillActive) await _AuthDAL.RevokeRefreshTokenAsync(token.DxpRefreshTokenId, replacedByTokenId: null, systemLogin, Trans, ct) .ConfigureAwait(false); await _QueryExecutor.CommitAsync(Trans).ConfigureAwait(false); GB5Trace.Step("dxp-refresh-chain-cascade-revoked", new { FromDxpRefreshTokenId = fromDxpRefreshTokenId, RevokedCount = stillActive.Count }); } catch (Exception ex) { await _QueryExecutor.RollbackAsync(Trans).ConfigureAwait(false); GB5Trace.MarkFailed("dxp-refresh-chain-cascade-revoke-failed", ex); throw; } } public async Task LogoutAsync(string rawRefreshToken, LoginDTO systemLogin, CancellationToken ct) { var hash = _JwtService.HashRefreshToken(rawRefreshToken); var existing = await _AuthDAL.GetRefreshTokenByHashAsync(hash, systemLogin, ct).ConfigureAwait(false); if (existing == null || existing.RevokedOn != null) return; // already gone — logout is idempotent var Trans = await _QueryExecutor.BeginTransactionAsync(systemLogin).ConfigureAwait(false); try { await _AuthDAL.RevokeRefreshTokenAsync(existing.DxpRefreshTokenId, null, systemLogin, Trans, ct) .ConfigureAwait(false); await _QueryExecutor.CommitAsync(Trans).ConfigureAwait(false); } catch (Exception ex) { await _QueryExecutor.RollbackAsync(Trans).ConfigureAwait(false); GB5Trace.MarkFailed("logout-dxp-user-failed", ex); throw; } } // ── Role grant (admin/onboarding side) ─────────────────────────────────── public async Task GrantUserPartyRoleAsync(UserPartyRoleDTO dto, LoginDTO systemLogin, CancellationToken ct) { if (dto is null) throw new ArgumentNullException(nameof(dto)); if (dto.DxpUserId <= 0) throw new ArgumentException("DxpUserId is required.", nameof(dto)); if (dto.DxpPartyId <= 0) throw new ArgumentException("DxpPartyId is required.", nameof(dto)); if (string.IsNullOrWhiteSpace(dto.RoleCode)) throw new ArgumentException("RoleCode is required.", nameof(dto)); var Trans = await _QueryExecutor.BeginTransactionAsync(systemLogin).ConfigureAwait(false); try { GB5Trace.Step("grant-dxp-user-party-role", new { dto.DxpUserId, dto.DxpPartyId, dto.RoleCode }); var auto = await _AutoNumber.GetNumberAsync(1, AUTONUMBERCONSTANT.DXPUSERPARTYROLE, systemLogin) .ConfigureAwait(false); dto.DxpUserPartyRoleId = auto.StartNumber; dto.GrantedOn = DateTime.UtcNow; dto.GrantedById = systemLogin.UserId; await _BaseEntityAppServiceRole.ExecuteSaveAsync( EntityConstant.OBJECTDXPUSERPARTYROLE, EventTypeConstant.SAVEDXPUSERPARTYROLEEVENTTYPEID, dto, systemLogin, async tx => { await _AuthDAL.SaveUserPartyRoleAsync(dto, systemLogin, tx, ct).ConfigureAwait(false); return dto.DxpUserPartyRoleId; }, null, -1, -1, Trans).ConfigureAwait(false); await _QueryExecutor.CommitAsync(Trans).ConfigureAwait(false); return $"{SuccessResponse.SaveSuccessMessage} {dto.DxpUserPartyRoleId}"; } catch (Exception ex) { await _QueryExecutor.RollbackAsync(Trans).ConfigureAwait(false); GB5Trace.MarkFailed("grant-dxp-user-party-role-failed", ex); throw; } } }