using System.Data.Common; using System.Security.Cryptography; using System.Text; using System.Text.Json; using EntitlementBLL.Legal; using EntitlementBLL.Options; using EntitlementDAL.DTOs; using EntitlementDAL.Enums; using EntitlementDAL.Interfaces; using GB5Shared.DirectAction; using GB5Shared.DTO.DirectAction; using GB5Shared.ActionProcessor; using GB5Shared.DTO.Framework.Login; using GB5Shared.EncryptionHelper; using GB5Shared.EventLogPublish; using GB5Shared.GenerateAutoNumber; using GB5Shared.QueryExecutor; using GB5Shared.Resource.Response; using GB5Shared.Telemetry; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using static GB5Shared.GB5Constant.Constant; namespace EntitlementBLL.Auth; public class ClientAuthBLL : IClientAuthBLL { private const string PasswordResetActionCode = "ENTITLEMENT_PWRESET"; private const int PasswordResetMailTemplateId = -1389001001; private const int MinPasswordLength = 8; private readonly IClientUserDAL _ClientUserDAL; private readonly IClientUserRoleDAL _ClientUserRoleDAL; private readonly IClientRefreshTokenDAL _RefreshTokenDAL; private readonly IClientJwtService _JwtService; private readonly AutoNumber _AutoNumber; private readonly IQueryExecutor _QueryExecutor; private readonly ClientAccountLockoutOptions _LockoutOptions; private readonly IDirectActionTokenService _TokenService; private readonly IDirectActionTokenDAL _DirectActionTokenDAL; private readonly IEventActionRunDAL _EventActionRunDAL; private readonly IActionOutboxDAL _ActionOutboxDAL; private readonly ClientPasswordResetOptions _PasswordResetOptions; private readonly EventLogPublish _EventLog; private readonly IAgreementConsentProvider _AgreementConsentProvider; private readonly IClientProvisioningDAL _ClientProvisioningDAL; private readonly ILogger _Logger; public ClientAuthBLL( IClientUserDAL clientUserDAL, IClientUserRoleDAL clientUserRoleDAL, IClientRefreshTokenDAL refreshTokenDAL, IClientJwtService jwtService, AutoNumber autoNumber, IQueryExecutor queryExecutor, IOptions lockoutOptions, IDirectActionTokenService tokenService, IDirectActionTokenDAL directActionTokenDal, IEventActionRunDAL eventActionRunDal, IActionOutboxDAL actionOutboxDal, IOptions passwordResetOptions, EventLogPublish eventLog, IAgreementConsentProvider agreementConsentProvider, IClientProvisioningDAL clientProvisioningDAL, ILogger logger) { _ClientUserDAL = clientUserDAL; _ClientUserRoleDAL = clientUserRoleDAL; _RefreshTokenDAL = refreshTokenDAL; _JwtService = jwtService; _AutoNumber = autoNumber; _QueryExecutor = queryExecutor; _LockoutOptions = lockoutOptions.Value; _TokenService = tokenService; _DirectActionTokenDAL = directActionTokenDal; _EventActionRunDAL = eventActionRunDal; _ActionOutboxDAL = actionOutboxDal; _PasswordResetOptions = passwordResetOptions.Value; _EventLog = eventLog; _AgreementConsentProvider = agreementConsentProvider; _ClientProvisioningDAL = clientProvisioningDAL; _Logger = logger; } public async Task IssueAndPersistTokenPairAsync( ClientAccessTokenClaims claims, LoginDTO login, DbTransaction tx, CancellationToken ct) { GB5Trace.Step("issue-client-token-pair", new { claims.ClientUserId, claims.ClientId }); var pair = await _JwtService.IssueTokenPairAsync(claims, ct).ConfigureAwait(false); var auto = await _AutoNumber.GetNumberAsync(1, AUTONUMBERCONSTANT.ENTITLEMENTCLIENTREFRESHTOKEN, login).ConfigureAwait(false); var tokenId = auto.StartNumber; var now = DateTime.UtcNow; var tokenDto = new ClientRefreshTokenDTO { ClientRefreshTokenId = tokenId, ClientUserId = claims.ClientUserId, TokenHash = pair.RefreshTokenHash, IssuedOn = now, ExpiresOn = pair.RefreshTokenExpiresOn, RevokedOn = null, ReplacedByTokenId = null, CreatedById = login.UserId, CreatedOn = now }; GB5Trace.Step("save-client-refresh-token", new { ClientRefreshTokenId = tokenId, claims.ClientUserId }); await _RefreshTokenDAL.SaveAsync(tokenDto, login, tx, ct).ConfigureAwait(false); return new ClientIssuedTokenPair { TokenPair = pair, ClientRefreshTokenId = tokenId }; } public async Task RefreshAsync(string rawRefreshToken, LoginDTO login, CancellationToken ct) { var tokenHash = _JwtService.HashRefreshToken(rawRefreshToken); GB5Trace.Step("client-refresh-lookup", new { }); var existing = await _RefreshTokenDAL.GetByTokenHashAsync(tokenHash, login, ct).ConfigureAwait(false); var now = DateTime.UtcNow; if (existing is null || existing.ExpiresOn <= now) { // Plain rejection — the token hash was never issued, or it expired without ever // being rotated. Nothing was ever exchanged from it, so there is no chain to walk. _Logger.LogWarning("Entitlement client refresh rejected: token not found or expired"); return new ClientRefreshResult { Status = ClientRefreshResultStatus.Invalid }; } if (existing.RevokedOn is not 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. Logged as a distinct condition (not a // generic Invalid) so a future audit/alerting item has something to hook into. _Logger.LogWarning( "Entitlement client refresh token REUSE DETECTED for ClientUserId {ClientUserId}, " + "ClientRefreshTokenId {ClientRefreshTokenId} — cascading chain revocation", existing.ClientUserId, existing.ClientRefreshTokenId); GB5Trace.MarkFailed("client-refresh-token-reuse-detected", new InvalidOperationException($"Refresh token reuse detected for ClientUserId {existing.ClientUserId}")); await CascadeRevokeChainAsync(existing.ClientRefreshTokenId, login, ct).ConfigureAwait(false); return new ClientRefreshResult { Status = ClientRefreshResultStatus.ReuseDetected }; } var clientUser = await _ClientUserDAL.GetByIdAsync(existing.ClientUserId, login, ct).ConfigureAwait(false); if (clientUser is null || clientUser.Status != (byte)ClientUserStatusEnum.Active) { // The account was locked/deleted after this token was issued — honour that now // rather than silently reissuing a session for an account that should no longer log in. _Logger.LogWarning( "Entitlement client refresh rejected: ClientUserId {ClientUserId} is not Active", existing.ClientUserId); return new ClientRefreshResult { Status = ClientRefreshResultStatus.Invalid }; } var capabilities = await _ClientUserRoleDAL.GetActiveRoleCodesAsync(clientUser.ClientUserId, login, ct).ConfigureAwait(false); var claims = new ClientAccessTokenClaims { ClientUserId = clientUser.ClientUserId, ClientId = clientUser.ClientId, Role = clientUser.Role == (byte)ClientUserRoleEnum.ClientAdmin ? ClientRoleCodes.ClientAdmin : ClientRoleCodes.ClientUser, Capabilities = capabilities.ToList() }; var tx = await _QueryExecutor.BeginTransactionAsync(login).ConfigureAwait(false); try { GB5Trace.Step("client-refresh-rotate", new { existing.ClientRefreshTokenId, clientUser.ClientUserId }); var issued = await IssueAndPersistTokenPairAsync(claims, login, tx, ct).ConfigureAwait(false); // This is the actual fix: unlike DXP, 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 will find RevokedOn != null and take the cascade-revoke branch above. await _RefreshTokenDAL.RevokeAsync(existing.ClientRefreshTokenId, issued.ClientRefreshTokenId, login, tx, ct) .ConfigureAwait(false); await _QueryExecutor.CommitAsync(tx).ConfigureAwait(false); return new ClientRefreshResult { Status = ClientRefreshResultStatus.Success, TokenPair = issued.TokenPair }; } catch (Exception ex) { await _QueryExecutor.RollbackAsync(tx).ConfigureAwait(false); GB5Trace.MarkFailed("client-refresh-rotation-failed", ex); _Logger.LogError(ex, "Entitlement client refresh rotation failed for ClientUserId {ClientUserId}", clientUser.ClientUserId); throw; } } private async Task CascadeRevokeChainAsync(int fromClientRefreshTokenId, LoginDTO login, CancellationToken ct) { var descendants = await _RefreshTokenDAL.GetChainDescendantsAsync(fromClientRefreshTokenId, login, ct).ConfigureAwait(false); var stillActive = descendants.Where(d => d.RevokedOn is null).ToList(); if (stillActive.Count == 0) return; var tx = await _QueryExecutor.BeginTransactionAsync(login).ConfigureAwait(false); try { foreach (var token in stillActive) await _RefreshTokenDAL.RevokeAsync(token.ClientRefreshTokenId, replacedByTokenId: null, login, tx, ct) .ConfigureAwait(false); await _QueryExecutor.CommitAsync(tx).ConfigureAwait(false); GB5Trace.Step("client-refresh-chain-cascade-revoked", new { FromClientRefreshTokenId = fromClientRefreshTokenId, RevokedCount = stillActive.Count }); _Logger.LogWarning( "Entitlement client refresh chain cascade-revoked {RevokedCount} descendant token(s) starting after ClientRefreshTokenId {FromClientRefreshTokenId}", stillActive.Count, fromClientRefreshTokenId); } catch (Exception ex) { await _QueryExecutor.RollbackAsync(tx).ConfigureAwait(false); GB5Trace.MarkFailed("client-refresh-chain-cascade-revoke-failed", ex); _Logger.LogError(ex, "Entitlement client refresh chain cascade-revoke failed starting after ClientRefreshTokenId {FromClientRefreshTokenId}", fromClientRefreshTokenId); throw; } } // ── LoginAsync ─────────────────────────────────────────────────────────────────── public async Task LoginAsync(int clientId, string email, string password, LoginDTO login, CancellationToken ct) { GB5Trace.Step("client-login-lookup", new { clientId }); var user = await _ClientUserDAL.GetByClientAndEmailAsync(clientId, email, login, ct).ConfigureAwait(false); // Unknown email and a non-Active account (Locked/Deleted) both reject without touching // any counter — there is nothing to gain from counting an attempt against an account that // either doesn't exist or is already shut, and it avoids leaking account existence via a // different code path than the wrong-password branch below. if (user is null || user.Status != (byte)ClientUserStatusEnum.Active) { _Logger.LogWarning("Entitlement client login rejected: unknown email or non-Active account for ClientId {ClientId}", clientId); return new ClientLoginResult { Status = ClientLoginResultStatus.Invalid }; } if (!PasswordHasher.Verify(password, user.PasswordHash)) { var newCount = (short)(user.FailedLoginCount + 1); var newStatus = newCount >= _LockoutOptions.FailedLoginThreshold ? ClientUserStatusEnum.Locked : ClientUserStatusEnum.Active; var lockTx = await _QueryExecutor.BeginTransactionAsync(login).ConfigureAwait(false); try { await _ClientUserDAL.UpdateStatusAndFailedLoginCountAsync( user.ClientUserId, (byte)newStatus, newCount, login, lockTx, ct).ConfigureAwait(false); await _QueryExecutor.CommitAsync(lockTx).ConfigureAwait(false); } catch (Exception ex) { await _QueryExecutor.RollbackAsync(lockTx).ConfigureAwait(false); GB5Trace.MarkFailed("client-login-lockout-update-failed", ex); _Logger.LogError(ex, "Entitlement client login lockout update failed for ClientUserId {ClientUserId}", user.ClientUserId); throw; } if (newStatus == ClientUserStatusEnum.Locked) _Logger.LogWarning("Entitlement client user {ClientUserId} locked after {Count} failed login attempts", user.ClientUserId, newCount); else _Logger.LogWarning("Entitlement client login failed for ClientUserId {ClientUserId} — attempt {Count}", user.ClientUserId, newCount); return new ClientLoginResult { Status = ClientLoginResultStatus.Invalid }; } var tx = await _QueryExecutor.BeginTransactionAsync(login).ConfigureAwait(false); try { var now = DateTime.UtcNow; await _ClientUserDAL.UpdateFailedLoginCountAsync(user.ClientUserId, 0, login, tx, ct).ConfigureAwait(false); await _ClientUserDAL.UpdateLastLoginOnAsync(user.ClientUserId, now, login, tx, ct).ConfigureAwait(false); var capabilities = await _ClientUserRoleDAL.GetActiveRoleCodesAsync(user.ClientUserId, login, ct).ConfigureAwait(false); var claims = new ClientAccessTokenClaims { ClientUserId = user.ClientUserId, ClientId = user.ClientId, Role = user.Role == (byte)ClientUserRoleEnum.ClientAdmin ? ClientRoleCodes.ClientAdmin : ClientRoleCodes.ClientUser, Capabilities = capabilities.ToList() }; GB5Trace.Step("client-login-issue-token", new { user.ClientUserId, user.ClientId }); var issued = await IssueAndPersistTokenPairAsync(claims, login, tx, ct).ConfigureAwait(false); await _QueryExecutor.CommitAsync(tx).ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeConstant.ENTITLEMENTCLIENTLOGINSUCCESSEVENTTYPEID }); await _EventLog.PublishEventLogAsync( "Entitlement Client Login Success", user, EventTypeConstant.ENTITLEMENTCLIENTLOGINSUCCESSEVENTTYPEID, user.ClientUserId, login, ct: ct).ConfigureAwait(false); // Individual-user first-login gate (tracker §50.3/§51.12) — computed AFTER commit, on // the real committed identity, and never allowed to block the login itself: a real // login always succeeds and returns a real token; a failure here (jurisdiction lookup // OR pending-agreement lookup) only means the FE doesn't get an accurate // pending-agreements hint this one time, not that the user is locked out. Jurisdiction // resolved from the real MCLIENT.JurisdictionCode row (added §51.12) — empty/null // falls back to GetApplicableAgreementsAsync's own universal-version behavior. var pendingAgreementVersionIds = Array.Empty(); try { var jurisdictionCode = await _ClientProvisioningDAL .GetClientJurisdictionCodeAsync(user.ClientId, login, ct).ConfigureAwait(false) ?? string.Empty; var subjectType = user.Role == (byte)ClientUserRoleEnum.ClientAdmin ? AgreementSubjectType.ClientAdmin : AgreementSubjectType.EndUser; pendingAgreementVersionIds = await _AgreementConsentProvider .GetPendingIndividualAgreementVersionIdsAsync(subjectType, user.ClientUserId, jurisdictionCode, login, ct) .ConfigureAwait(false); } catch (Exception ex) { _Logger.LogWarning(ex, "Pending-agreement lookup failed for ClientUserId {ClientUserId} — login still succeeds", user.ClientUserId); } return new ClientLoginResult { Status = ClientLoginResultStatus.Success, TokenPair = issued.TokenPair, PendingAgreementVersionIds = pendingAgreementVersionIds, }; } catch (Exception ex) { await _QueryExecutor.RollbackAsync(tx).ConfigureAwait(false); GB5Trace.MarkFailed("client-login-failed", ex); _Logger.LogError(ex, "Entitlement client login failed for ClientUserId {ClientUserId}", user.ClientUserId); throw; } } // ── LogoutAsync ────────────────────────────────────────────────────────────────── public async Task LogoutAsync(string rawRefreshToken, LoginDTO login, CancellationToken ct) { var tokenHash = _JwtService.HashRefreshToken(rawRefreshToken); var existing = await _RefreshTokenDAL.GetByTokenHashAsync(tokenHash, login, ct).ConfigureAwait(false); // Idempotent no-op for an unknown or already-revoked token — a caller must never be able // to distinguish "already logged out" from "logged out just now". if (existing is null || existing.RevokedOn is not null) return; var tx = await _QueryExecutor.BeginTransactionAsync(login).ConfigureAwait(false); try { // Plain revoke, no ReplacedByTokenId — a voluntary logout is not a rotation and must // never trigger the reuse-detection cascade. await _RefreshTokenDAL.RevokeAsync(existing.ClientRefreshTokenId, null, login, tx, ct).ConfigureAwait(false); await _QueryExecutor.CommitAsync(tx).ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeConstant.ENTITLEMENTCLIENTLOGOUTEVENTTYPEID }); await _EventLog.PublishEventLogAsync( "Entitlement Client Logout", existing, EventTypeConstant.ENTITLEMENTCLIENTLOGOUTEVENTTYPEID, existing.ClientUserId, login, ct: ct).ConfigureAwait(false); } catch (Exception ex) { await _QueryExecutor.RollbackAsync(tx).ConfigureAwait(false); GB5Trace.MarkFailed("client-logout-failed", ex); _Logger.LogError(ex, "Entitlement client logout failed for ClientRefreshTokenId {ClientRefreshTokenId}", existing.ClientRefreshTokenId); throw; } } // ── AcceptPendingAgreementsAsync ───────────────────────────────────────────────── public async Task AcceptPendingAgreementsAsync( int clientUserId, string role, int[] agreementVersionIds, string? ipAddress, string? userAgent, LoginDTO login, CancellationToken ct) { if (agreementVersionIds is null || agreementVersionIds.Length == 0) throw new ArgumentException("At least one AgreementVersionId is required.", nameof(agreementVersionIds)); var subjectType = role == ClientRoleCodes.ClientAdmin ? AgreementSubjectType.ClientAdmin : AgreementSubjectType.EndUser; GB5Trace.Step("client-accept-agreements", new { clientUserId, agreementVersionIds.Length }); await _AgreementConsentProvider.RecordAcceptanceAsync( subjectType, clientUserId, agreementVersionIds, ipAddress, userAgent, AgreementAcceptanceMethod.Clickwrap, correlationKey: $"client-user-eula-{clientUserId}-{DateTime.UtcNow:yyyyMMddHHmmssfff}", login, ct).ConfigureAwait(false); } // ── ChangePasswordAsync ────────────────────────────────────────────────────────── public async Task ChangePasswordAsync( int clientUserId, string currentPassword, string newPassword, LoginDTO login, CancellationToken ct) { if (newPassword.Length < MinPasswordLength) throw new ArgumentException($"New password must be at least {MinPasswordLength} characters.", nameof(newPassword)); GB5Trace.Step("validate-change-password", new { clientUserId }); var user = await _ClientUserDAL.GetByIdAsync(clientUserId, login, ct).ConfigureAwait(false); // ClientUserId-not-found and wrong-current-password collapse to the same generic // rejection — never distinguished to the caller. if (user is null || !PasswordHasher.Verify(currentPassword, user.PasswordHash)) { return new ClientChangePasswordResult { Status = ClientChangePasswordResultStatus.CurrentPasswordInvalid, Message = ErrorResponse.InvalidCredentialsMessage }; } var tx = await _QueryExecutor.BeginTransactionAsync(login).ConfigureAwait(false); try { GB5Trace.Step("save-change-password", new { clientUserId }); await _ClientUserDAL.UpdatePasswordHashAsync(clientUserId, PasswordHasher.Hash(newPassword), login, tx, ct).ConfigureAwait(false); await _QueryExecutor.CommitAsync(tx).ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeConstant.ENTITLEMENTCLIENTPASSWORDCHANGEDEVENTTYPEID }); await _EventLog.PublishEventLogAsync( "Entitlement Client Password Changed", user, EventTypeConstant.ENTITLEMENTCLIENTPASSWORDCHANGEDEVENTTYPEID, clientUserId, login, ct: ct).ConfigureAwait(false); return new ClientChangePasswordResult { Status = ClientChangePasswordResultStatus.Success, Message = SuccessResponse.UpdateSuccess }; } catch (Exception ex) { await _QueryExecutor.RollbackAsync(tx).ConfigureAwait(false); GB5Trace.MarkFailed("client-change-password-failed", ex); _Logger.LogError(ex, "Entitlement client change-password failed for ClientUserId {ClientUserId}", clientUserId); throw; } } // ── CreateClientAdminAsync / CreateClientUserAsync ──────────────────────────────── public Task CreateClientAdminAsync(int clientId, string email, string fullName, LoginDTO login, CancellationToken ct) => CreateClientUserInternalAsync(clientId, email, fullName, ClientUserRoleEnum.ClientAdmin, login, ct); public Task CreateClientUserAsync(int clientId, string email, string fullName, LoginDTO login, CancellationToken ct) => CreateClientUserInternalAsync(clientId, email, fullName, ClientUserRoleEnum.ClientUser, login, ct); private async Task CreateClientUserInternalAsync( int clientId, string email, string fullName, ClientUserRoleEnum role, LoginDTO login, CancellationToken ct) { GB5Trace.Step("validate-create-client-user", new { clientId, role }); var existing = await _ClientUserDAL.GetByClientAndEmailAsync(clientId, email, login, ct).ConfigureAwait(false); if (existing is not null) { return new CreateClientUserResult { AlreadyExists = true, ClientUserId = existing.ClientUserId }; } var temporaryPassword = GenerateTemporaryPassword(); var auto = await _AutoNumber.GetNumberAsync(1, AUTONUMBERCONSTANT.ENTITLEMENTCLIENTUSER, login).ConfigureAwait(false); var newUserId = auto.StartNumber; var now = DateTime.UtcNow; var dto = new ClientUserDTO { ClientUserId = newUserId, ClientId = clientId, Email = email, FullName = fullName, PasswordHash = PasswordHasher.Hash(temporaryPassword), Role = (byte)role, Status = (byte)ClientUserStatusEnum.Active, FailedLoginCount = 0, CreatedById = login.UserId, CreatedOn = now, ModifiedById = login.UserId, ModifiedOn = now }; var tx = await _QueryExecutor.BeginTransactionAsync(login).ConfigureAwait(false); try { GB5Trace.Step("save-create-client-user", new { newUserId, role }); await _ClientUserDAL.SaveAsync(dto, login, tx, ct).ConfigureAwait(false); await _QueryExecutor.CommitAsync(tx).ConfigureAwait(false); var eventTypeId = role == ClientUserRoleEnum.ClientAdmin ? EventTypeConstant.CREATEENTITLEMENTCLIENTADMINEVENTTYPEID : EventTypeConstant.CREATEENTITLEMENTCLIENTUSEREVENTTYPEID; GB5Trace.Step("event-publish", new { eventTypeId }); await _EventLog.PublishEventLogAsync( role == ClientUserRoleEnum.ClientAdmin ? "Entitlement Client Admin Created" : "Entitlement Client User Created", dto, eventTypeId, newUserId, login, ct: ct).ConfigureAwait(false); return new CreateClientUserResult { AlreadyExists = false, ClientUserId = newUserId, TemporaryPassword = temporaryPassword }; } catch (Exception ex) { await _QueryExecutor.RollbackAsync(tx).ConfigureAwait(false); GB5Trace.MarkFailed("create-client-user-failed", ex); _Logger.LogError(ex, "Entitlement create client user failed for ClientId {ClientId}", clientId); throw; } } // ── SetClientUserStatusAsync ─────────────────────────────────────────────────────── public async Task SetClientUserStatusAsync(int clientUserId, byte status, LoginDTO login, CancellationToken ct) { if (!Enum.IsDefined(typeof(ClientUserStatusEnum), status)) throw new ArgumentException($"'{status}' is not a valid ClientUserStatus.", nameof(status)); GB5Trace.Step("save-client-user-status", new { clientUserId, status }); var tx = await _QueryExecutor.BeginTransactionAsync(login).ConfigureAwait(false); try { // Only reactivating TO Active also resets FailedLoginCount — Locked/Deleted go // through the plain status update since there is no lockout counter to clear. if (status == (byte)ClientUserStatusEnum.Active) await _ClientUserDAL.UpdateStatusAndFailedLoginCountAsync(clientUserId, status, 0, login, tx, ct).ConfigureAwait(false); else await _ClientUserDAL.UpdateStatusAsync(clientUserId, status, login, tx, ct).ConfigureAwait(false); await _QueryExecutor.CommitAsync(tx).ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeConstant.ENTITLEMENTCLIENTUSERSTATUSCHANGEDEVENTTYPEID }); await _EventLog.PublishEventLogAsync( "Entitlement Client User Status Changed", new { ClientUserId = clientUserId, Status = status }, EventTypeConstant.ENTITLEMENTCLIENTUSERSTATUSCHANGEDEVENTTYPEID, clientUserId, login, ct: ct).ConfigureAwait(false); } catch (Exception ex) { await _QueryExecutor.RollbackAsync(tx).ConfigureAwait(false); GB5Trace.MarkFailed("set-client-user-status-failed", ex); _Logger.LogError(ex, "Entitlement set client user status failed for ClientUserId {ClientUserId}", clientUserId); throw; } } // ── ForgotPasswordAsync ────────────────────────────────────────────────────────── public async Task ForgotPasswordAsync(int clientId, string email, LoginDTO login, CancellationToken ct) { GB5Trace.Step("client-forgot-password-lookup", new { clientId }); var user = await _ClientUserDAL.GetByClientAndEmailAsync(clientId, email, login, ct).ConfigureAwait(false); // Unknown email or a Deleted account: silent no-op — never reveals account existence. // Locked is deliberately NOT excluded here — forgot-password is the self-service unlock // path (see ResetPasswordAsync's reactivation logic), so a locked account must still be // able to request a reset link. if (user is null || user.Status == (byte)ClientUserStatusEnum.Deleted) return; var rawToken = _TokenService.Generate( PasswordResetActionCode, user.ClientUserId, clientId, user.ClientUserId, login.DatabaseName, TimeSpan.FromMinutes(_PasswordResetOptions.TokenExpiryMinutes)); var resetUrl = $"{_PasswordResetOptions.ResetPasswordBaseUrl}?token={Uri.EscapeDataString(rawToken)}"; await QueuePasswordResetEmailAsync(user, resetUrl, login, ct).ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeConstant.ENTITLEMENTCLIENTFORGOTPASSWORDEVENTTYPEID }); await _EventLog.PublishEventLogAsync( "Entitlement Client Forgot Password Triggered", user, EventTypeConstant.ENTITLEMENTCLIENTFORGOTPASSWORDEVENTTYPEID, user.ClientUserId, login, ct: ct).ConfigureAwait(false); } private async Task QueuePasswordResetEmailAsync(ClientUserDTO user, string resetUrl, LoginDTO login, CancellationToken ct) { var correlationKey = $"entitlement-pwreset-{user.ClientUserId}-{Guid.NewGuid():N}"; // ActionId = -1: explicitly triggered by ForgotPasswordAsync, not an MACTION rule // evaluated by EventSubBLL — mirrors CorrespondenceBLL.QueueDeliveryAsync's convention. var actionRunId = await _EventActionRunDAL.InsertAsync(new EventActionRunDTO { ActionId = -1, JobExecutionId = -1, EventTypeId = EventTypeConstant.ENTITLEMENTCLIENTFORGOTPASSWORDEVENTTYPEID, Payload = JsonSerializer.Serialize(new { user.FullName, ResetUrl = resetUrl }), CorrelationKey = correlationKey }, login, ct).ConfigureAwait(false); var actionEventDto = new ActionEventDto { ActionRunId = actionRunId, ActionId = -1, ActionType = 0, // Email TenantId = user.ClientId, DatabaseName = login.DatabaseName, SendTo = user.Email, ToDeliveryType = 3, // direct email address TemplateId = PasswordResetMailTemplateId, CorrelationKey = correlationKey, Payload = JsonSerializer.SerializeToElement(new { user.FullName, ResetUrl = resetUrl }) }; var partition = Math.Abs(user.ClientId % 5); await _ActionOutboxDAL.InsertAsync(new ActionOutboxDTO { ActionRunId = actionRunId, DestinationTopic = $"action-exec-p{partition}", Payload = JsonSerializer.Serialize(actionEventDto), TenantId = user.ClientId, CorrelationKey = correlationKey }, login, ct).ConfigureAwait(false); } // ── ResetPasswordAsync ─────────────────────────────────────────────────────────── public async Task ResetPasswordAsync(string rawToken, string newPassword, LoginDTO login, CancellationToken ct) { if (newPassword.Length < MinPasswordLength) throw new ArgumentException($"New password must be at least {MinPasswordLength} characters.", nameof(newPassword)); var invalid = new ClientPasswordResetResult { Status = ClientPasswordResetResultStatus.InvalidOrExpiredToken }; GB5Trace.Step("client-reset-password-validate", new { }); if (!_TokenService.TryValidate(rawToken, out var actionCode, out var contextId, out var tenantId, out var assigneeUserId, out var databaseName, out var expiresAt)) { _Logger.LogWarning("Entitlement client password reset rejected: bad signature or expired token"); return invalid; } if (actionCode != PasswordResetActionCode) { _Logger.LogWarning("Entitlement client password reset rejected: unexpected action code {ActionCode}", actionCode); return invalid; } var tokenHash = HashToken(rawToken); var alreadyUsed = await _DirectActionTokenDAL.GetByHashAsync(tokenHash, login, ct).ConfigureAwait(false); if (alreadyUsed is not null) { _Logger.LogWarning("Entitlement client password reset rejected: token already used, ContextId {ContextId}", contextId); return invalid; } var user = await _ClientUserDAL.GetByIdAsync(contextId, login, ct).ConfigureAwait(false); if (user is null || user.ClientId != tenantId || user.Status == (byte)ClientUserStatusEnum.Deleted) { _Logger.LogWarning("Entitlement client password reset rejected: ClientUserId {ContextId} not found, tenant mismatch, or deleted", contextId); return invalid; } var tx = await _QueryExecutor.BeginTransactionAsync(login).ConfigureAwait(false); try { GB5Trace.Step("save-reset-password", new { ClientUserId = user.ClientUserId }); await _ClientUserDAL.UpdatePasswordHashAsync(user.ClientUserId, PasswordHasher.Hash(newPassword), login, tx, ct).ConfigureAwait(false); // A successful reset always reactivates the account and clears any lockout counter — // the same reasoning as SetClientUserStatusAsync's reactivation branch, since // forgot/reset-password is this system's self-service unlock path. await _ClientUserDAL.UpdateStatusAndFailedLoginCountAsync( user.ClientUserId, (byte)ClientUserStatusEnum.Active, 0, login, tx, ct).ConfigureAwait(false); await _QueryExecutor.CommitAsync(tx).ConfigureAwait(false); } catch (Exception ex) { await _QueryExecutor.RollbackAsync(tx).ConfigureAwait(false); GB5Trace.MarkFailed("client-reset-password-failed", ex); _Logger.LogError(ex, "Entitlement client password reset failed for ClientUserId {ClientUserId}", user.ClientUserId); throw; } // Consume the token — outside the main transaction, mirroring IDirectActionTokenDAL's own // signature (no transaction parameter; this is a separate one-time-use audit insert). await _DirectActionTokenDAL.InsertUsedAsync(new DirectActionTokenDTO { TokenHash = tokenHash, ContextId = contextId, ActionCode = actionCode, AssigneeUserId = assigneeUserId, TenantId = tenantId, ExpiresAt = expiresAt, Status = 1, // Used CreatedOn = DateTime.UtcNow }, login, ct).ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeConstant.ENTITLEMENTCLIENTPASSWORDRESETEVENTTYPEID }); await _EventLog.PublishEventLogAsync( "Entitlement Client Password Reset", user, EventTypeConstant.ENTITLEMENTCLIENTPASSWORDRESETEVENTTYPEID, user.ClientUserId, login, ct: ct).ConfigureAwait(false); return new ClientPasswordResetResult { Status = ClientPasswordResetResultStatus.Success, Message = SuccessResponse.UpdateSuccess }; } // ── Helpers ────────────────────────────────────────────────────────────────────── private static string HashToken(string rawToken) { var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(rawToken)); return Convert.ToHexString(bytes); } private static string GenerateTemporaryPassword() { var bytes = RandomNumberGenerator.GetBytes(12); return Convert.ToBase64String(bytes) .Replace("+", "A").Replace("/", "B").Replace("=", "C"); } }