using AuthHex.Infra.UoW; using AuthHex.Interfaces; using AuthHex.Models; using AuthHex.Models.IOs; using AuthHex.Utility.passwordHasher; using AuthSystem.API.Utils; using Core_Archi.Helpers; using Microsoft.EntityFrameworkCore; using System.Security.Cryptography; using AuthHex.Utility; namespace AuthHex.Services.RecoveryManager; public class RecoveryManagerService { private readonly IRecoveryManagerRepository _repository; private readonly IUserManageRepository _userRepository; private readonly IUnitOfWork _uow; private readonly JwtTokenHelper _jwt; private readonly AppDBContext _dbContext; private readonly ThirdPartyService _thirdPartyService; private readonly IConfiguration _configuration; public RecoveryManagerService( IRecoveryManagerRepository Recoveryrepository, IUserManageRepository userRepository, IUnitOfWork uow, JwtTokenHelper jwt, AppDBContext dbContext, ThirdPartyService thirdPartyService, IConfiguration configuration) { _repository = Recoveryrepository; _userRepository = userRepository; _uow = uow; _jwt = jwt; _dbContext = dbContext; _thirdPartyService = thirdPartyService; _configuration = configuration; } public async Task ForgotPassword(object payload, HttpContext httpContext) { await _uow.BeginAsync(); try { var data = DeserializePayload.Deserialize(payload); var identifier = data.ContainsKey("identifier") ? data["identifier"].GetString() : null; var useResetLink = data.ContainsKey("useResetLink") ? data["useResetLink"].GetBoolean() : false; var numberOfDigits = data.ContainsKey("numberOfDigits") ? data["numberOfDigits"].GetInt32() : 6; if (string.IsNullOrEmpty(identifier)) throw new ArgumentException("Identifier is required."); var user = await _userRepository.GetUserByIdentifierAndType(identifier, null); if (user == null) throw new KeyNotFoundException("No user found with the provided identifier."); if (user.IsLocked == true) throw new InvalidOperationException("User account is locked. Please contact support."); if (user.IsActive == false) throw new InvalidOperationException("User account is inactive. Please contact support."); // invalidate all previous pending recovery attempts for this user .. await _userRepository.InvalidateUserSessionsAsync(user.Id); var pendingRecoveries = await _repository.GetPendingRecoveriesByUserIdAsync(user.Id); foreach (var oldRecovery in pendingRecoveries) { oldRecovery.Status = "Expired"; oldRecovery.IsUsed = true; await _repository.UpdateRecoveryAsync(oldRecovery); } Recovery recovery; if (useResetLink) { var resetToken = GenerateSecureResetToken(); var tokenHash = HashToken(resetToken); recovery = new Recovery { UserId = user.Id, RecoveryReferenceNum = GenerateSecureReferenceNumber(), ResetTokenHash = tokenHash, Status = "Pending", IsUsed = false, CreatedAt = DateTime.UtcNow, ExpirationTime = DateTime.UtcNow.AddMinutes(15), RecoveryType = "ResetLink" }; await _repository.AddRecoveryAsync(recovery); await _uow.CommitAsync(); // send reset link via email if (!string.IsNullOrWhiteSpace(user.Email)) { var frontendUrl = _configuration["AppSettings:FrontendUrl"] ?? "http://localhost:3000"; //wanna add app.setting frontend URL var resetLink = $"{frontendUrl}/reset-password?token={resetToken}"; await _thirdPartyService.EmailConfiguration( user.Email!, resetToken, "Password Reset Request", $@"

Password Reset Request

Hello {user.FullName ?? user.UserName},

You requested to reset your password. Click the link below to proceed:

Reset Password

This link will expire in 15 minutes.

If you didn't request this, please ignore this email.

Reference Number: {recovery.RecoveryReferenceNum}

"); } return new { Success = true, Message = "Password reset link sent to your email.", Data = new { ReferenceNumber = recovery.RecoveryReferenceNum, ExpiresAt = recovery.ExpirationTime, RecoveryType = "ResetLink" } }; } else { var otpCode = await GenerateOtpCode(numberOfDigits); recovery = new Recovery { UserId = user.Id, RecoveryReferenceNum = GenerateSecureReferenceNumber(), OTP = otpCode, Status = "Pending", IsUsed = false, CreatedAt = DateTime.UtcNow, ExpirationTime = DateTime.UtcNow.AddMinutes(10), RecoveryType = "OTP" }; await _repository.AddRecoveryAsync(recovery); await _uow.CommitAsync(); if (!string.IsNullOrWhiteSpace(user.MobileNumber)) { await _thirdPartyService.SendSmsAsync( user.MobileNumber!, otpCode, $"Your password recovery OTP is: {otpCode}. It expires in 10 minutes. Ref: {recovery.RecoveryReferenceNum}"); } if (!string.IsNullOrWhiteSpace(user.Email)) { await _thirdPartyService.EmailConfiguration( user.Email!, otpCode, "Password Recovery OTP", $@"

Password Recovery OTP

Hello {user.FullName ?? user.UserName},

Your OTP code is: {otpCode}

This code expires in 10 minutes.

Reference Number: {recovery.RecoveryReferenceNum}

"); } return new { Success = true, Message = "OTP sent successfully.", Data = new { ReferenceNumber = recovery.RecoveryReferenceNum, ExpiresAt = recovery.ExpirationTime, RecoveryType = "OTP" } }; } } catch { await _uow.RollbackAsync(); throw; } } public async Task VerifyOTP(object payload, HttpContext httpContext) { await _uow.BeginAsync(); try { var data = DeserializePayload.Deserialize(payload); var referenceNumber = data.ContainsKey("referenceNumber") ? data["referenceNumber"].GetString() : null; var otpCode = data.ContainsKey("otpCode") ? data["otpCode"].GetString() : null; if (string.IsNullOrEmpty(referenceNumber) || string.IsNullOrEmpty(otpCode)) throw new ArgumentException("Reference number and OTP code are required."); var recovery = await _repository.GetRecoveryByReferenceNumAsync(referenceNumber); if (recovery == null) throw new KeyNotFoundException("Invalid reference number."); if (recovery.IsUsed) throw new InvalidOperationException("This OTP has already been used."); if (recovery.Status != "Pending") throw new InvalidOperationException("This OTP is no longer valid."); if (DateTime.UtcNow > recovery.ExpirationTime) { recovery.Status = "Expired"; await _repository.UpdateRecoveryAsync(recovery); await _uow.CommitAsync(); throw new InvalidOperationException("This OTP has expired. Please request a new one."); } if (recovery.OTP != otpCode) throw new InvalidOperationException("Invalid OTP code."); recovery.Status = "Verified"; await _repository.UpdateRecoveryAsync(recovery); await _uow.CommitAsync(); return new { Success = true, Message = "OTP verified successfully. You can now reset your password.", Data = new { ReferenceNumber = recovery.RecoveryReferenceNum, UserId = recovery.UserId, Verified = true } }; } catch { await _uow.RollbackAsync(); throw; } } public async Task ResetPasswordWithToken(object payload, HttpContext httpContext) { await _uow.BeginAsync(); try { var data = DeserializePayload.Deserialize(payload); var resetToken = data.ContainsKey("resetToken") ? data["resetToken"].GetString() : null; var newPassword = data.ContainsKey("newPassword") ? data["newPassword"].GetString() : null; var confirmPassword = data.ContainsKey("confirmPassword") ? data["confirmPassword"].GetString() : null; if (string.IsNullOrEmpty(resetToken)) throw new ArgumentException("Reset token is required."); if (string.IsNullOrEmpty(newPassword) || string.IsNullOrEmpty(confirmPassword)) throw new ArgumentException("New password and confirmation are required."); if (newPassword != confirmPassword) throw new ArgumentException("Passwords do not match."); if (newPassword.Length < 8) throw new ArgumentException("Password must be at least 8 characters long."); var tokenHash = HashToken(resetToken); var recovery = await _repository.GetRecoveryByTokenHashAsync(tokenHash); if (recovery == null) throw new KeyNotFoundException("Invalid or expired reset token."); if (recovery.IsUsed) throw new InvalidOperationException("This reset link has already been used."); if (recovery.Status != "Pending") throw new InvalidOperationException("This reset link is no longer valid."); if (DateTime.UtcNow > recovery.ExpirationTime) { recovery.Status = "Expired"; recovery.IsUsed = true; await _repository.UpdateRecoveryAsync(recovery); await _uow.CommitAsync(); throw new InvalidOperationException("This reset link has expired. Please request a new one."); } if (recovery.RecoveryType != "ResetLink") throw new InvalidOperationException("Invalid recovery method."); // Get user var user = await _userRepository.GetUserByIdAsync((Guid)recovery.UserId); if (user == null) throw new KeyNotFoundException("User not found."); if (user.IsLocked == true) throw new InvalidOperationException("User account is locked. Please contact support."); user.PasswordHash = PasswordHasher.Hash(newPassword); // Update user await _dbContext.SaveChangesAsync(); recovery.Status = "Used"; recovery.IsUsed = true; await _repository.UpdateRecoveryAsync(recovery); // invalid all user sessions (force re-login) await _userRepository.InvalidateUserSessionsAsync(user.Id); // can change BE requirement await _uow.CommitAsync(); if (!string.IsNullOrWhiteSpace(user.Email)) { await _thirdPartyService.EmailConfiguration( user.Email!, "SUCCESS", "Password Reset Successful", $@"

Password Reset Successful

Hello {user.FullName ?? user.UserName},

Your password has been successfully reset.

If you did not make this change, please contact support immediately.

Time: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC

"); } return new { Success = true, Message = "Password reset successful. Please login with your new password.", Data = new { UserId = user.Id, Email = user.Email, ResetAt = DateTime.UtcNow } }; } catch { await _uow.RollbackAsync(); throw; } } public async Task ResetPassword(object payload, HttpContext httpContext) { await _uow.BeginAsync(); try { var data = DeserializePayload.Deserialize(payload); var referenceNumber = data.ContainsKey("referenceNumber") ? data["referenceNumber"].GetString() : null; var newPassword = data.ContainsKey("newPassword") ? data["newPassword"].GetString() : null; var confirmPassword = data.ContainsKey("confirmPassword") ? data["confirmPassword"].GetString() : null; if (string.IsNullOrEmpty(referenceNumber)) throw new ArgumentException("Reference number is required."); if (string.IsNullOrEmpty(newPassword) || string.IsNullOrEmpty(confirmPassword)) throw new ArgumentException("New password and confirmation are required."); if (newPassword != confirmPassword) throw new ArgumentException("Passwords do not match."); if (newPassword.Length < 8) throw new ArgumentException("Password must be at least 8 characters long."); var recovery = await _repository.GetRecoveryByReferenceNumAsync(referenceNumber); if (recovery == null) throw new KeyNotFoundException("Invalid reference number."); if (recovery.Status != "Verified") throw new InvalidOperationException("Please verify your OTP first before resetting password."); if (recovery.IsUsed) throw new InvalidOperationException("This recovery request has already been used."); if (DateTime.UtcNow > recovery.ExpirationTime) { recovery.Status = "Expired"; recovery.IsUsed = true; await _repository.UpdateRecoveryAsync(recovery); await _uow.CommitAsync(); throw new InvalidOperationException("This recovery request has expired."); } var user = await _userRepository.GetUserByIdAsync((Guid)recovery.UserId); if (user == null) throw new KeyNotFoundException("User not found."); if (user.IsLocked == true) throw new InvalidOperationException("User account is locked. Please contact support."); user.PasswordHash = PasswordHasher.Hash(newPassword); await _dbContext.SaveChangesAsync(); recovery.Status = "Used"; recovery.IsUsed = true; await _repository.UpdateRecoveryAsync(recovery); await _userRepository.InvalidateUserSessionsAsync(user.Id); //as previous comment can be changed BE requirement await _uow.CommitAsync(); // Send confirmation email if (!string.IsNullOrWhiteSpace(user.Email)) { await _thirdPartyService.EmailConfiguration( user.Email!, "SUCCESS", "Password Reset Successful", $@"

Password Reset Successful

Hello {user.FullName ?? user.UserName},

Your password has been successfully reset.

If you did not make this change, please contact support immediately.

Time: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC

"); } return new { Success = true, Message = "Password reset successful. Please login with your new password.", Data = new { UserId = user.Id, Email = user.Email, ResetAt = DateTime.UtcNow } }; } catch { await _uow.RollbackAsync(); throw; } } //------------------------------- HELPER METHODS ------------------------------- private string GenerateSecureResetToken() { using var rng = RandomNumberGenerator.Create(); byte[] tokenBytes = new byte[32]; rng.GetBytes(tokenBytes); // Convert to URL-safe base64 string return Convert.ToBase64String(tokenBytes) .Replace("+", "-") .Replace("/", "_") .Replace("=", ""); } private string HashToken(string token) { using var sha256 = SHA256.Create(); byte[] hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(token)); return Convert.ToBase64String(hashBytes); } private async Task GenerateOtpCode(int numberOfDigits) { if (numberOfDigits <= 0 ) throw new ArgumentException("Number of digits must be at least 1", nameof(numberOfDigits)); using var rng = RandomNumberGenerator.Create(); var otpCode = ""; for (int i = 0; i < numberOfDigits; i++) { byte[] randomBytes = new byte[4]; rng.GetBytes(randomBytes); uint randomValue = BitConverter.ToUInt32(randomBytes, 0); otpCode += (randomValue % 10).ToString(); } return await Task.FromResult(otpCode); } private string GenerateSecureReferenceNumber() { var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); using var rng = RandomNumberGenerator.Create(); byte[] randomBytes = new byte[8]; rng.GetBytes(randomBytes); var randomPart = Convert.ToBase64String(randomBytes) .Replace("/", "") .Replace("+", "") .Replace("=", "") .Substring(0, 8).ToUpper(); return $"REC{timestamp}{randomPart}"; } }