Files
ERP_Auth_Service/Services/RecoveryManager/RecoveryManagerService.cs
T
Dhananjaya99 de7b40a147 ini
2026-07-08 11:11:50 +05:30

520 lines
19 KiB
C#

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<object> 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",
$@"
<h2>Password Reset Request</h2>
<p>Hello {user.FullName ?? user.UserName},</p>
<p>You requested to reset your password. Click the link below to proceed:</p>
<p><a href='{resetLink}'>Reset Password</a></p>
<p>This link will expire in 15 minutes.</p>
<p>If you didn't request this, please ignore this email.</p>
<p><strong>Reference Number:</strong> {recovery.RecoveryReferenceNum}</p>
");
}
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",
$@"
<h2>Password Recovery OTP</h2>
<p>Hello {user.FullName ?? user.UserName},</p>
<p>Your OTP code is: <strong style='font-size: 24px;'>{otpCode}</strong></p>
<p>This code expires in 10 minutes.</p>
<p><strong>Reference Number:</strong> {recovery.RecoveryReferenceNum}</p>
");
}
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<object> 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<object> 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",
$@"
<h2>Password Reset Successful</h2>
<p>Hello {user.FullName ?? user.UserName},</p>
<p>Your password has been successfully reset.</p>
<p>If you did not make this change, please contact support immediately.</p>
<p><strong>Time:</strong> {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC</p>
");
}
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<object> 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",
$@"
<h2>Password Reset Successful</h2>
<p>Hello {user.FullName ?? user.UserName},</p>
<p>Your password has been successfully reset.</p>
<p>If you did not make this change, please contact support immediately.</p>
<p><strong>Time:</strong> {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC</p>
");
}
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<string> 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}";
}
}