ini
This commit is contained in:
@@ -0,0 +1,496 @@
|
||||
using AuthHex.Infra.UoW;
|
||||
using AuthHex.Interfaces;
|
||||
using AuthHex.Models;
|
||||
using AuthHex.Models.IOs;
|
||||
using AuthHex.Utility;
|
||||
using AuthHex.Utility.passwordHasher;
|
||||
using AuthSystem.API.Utils;
|
||||
using Core_Archi.Helpers;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
|
||||
namespace AuthHex.Services.AltOptionManager;
|
||||
|
||||
public class AltOptionManagerService
|
||||
{
|
||||
private readonly IRecoveryManagerRepository _repository;
|
||||
private readonly IUserManageRepository _userRepository;
|
||||
private readonly IAltOptionManagerRepository _altOptionRepository;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly JwtTokenHelper _jwt;
|
||||
private readonly AppDBContext _dbContext;
|
||||
private readonly ThirdPartyService _thirdPartyService;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly UserHelper _userHelper;
|
||||
|
||||
public AltOptionManagerService(
|
||||
IRecoveryManagerRepository Recoveryrepository,
|
||||
IUserManageRepository userRepository,
|
||||
IAltOptionManagerRepository altOptionRepository,
|
||||
IUnitOfWork uow,
|
||||
JwtTokenHelper jwt,
|
||||
AppDBContext dbContext,
|
||||
ThirdPartyService thirdPartyService,
|
||||
IConfiguration configuration,
|
||||
UserHelper userHelper)
|
||||
{
|
||||
_repository = Recoveryrepository;
|
||||
_userRepository = userRepository;
|
||||
_altOptionRepository = altOptionRepository;
|
||||
_uow = uow;
|
||||
_jwt = jwt;
|
||||
_dbContext = dbContext;
|
||||
_thirdPartyService = thirdPartyService;
|
||||
_configuration = configuration;
|
||||
_userHelper = userHelper;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public async Task<object> IsAvailable(object payload, HttpContext httpContext)
|
||||
{
|
||||
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var Identifier = data.ContainsKey("Identifier") ? data["Identifier"].GetString() : null;
|
||||
var Recovery = data.ContainsKey("Recovery") ? data["Recovery"].GetString() : null;
|
||||
|
||||
var existingUsers = await _userRepository.GetUserByIdentifiers(
|
||||
email: Identifier,
|
||||
mobileNumber: Identifier,
|
||||
nic: Identifier,
|
||||
username: Identifier,
|
||||
Optional1: Identifier
|
||||
);
|
||||
|
||||
if (existingUsers == null || !existingUsers.Any())
|
||||
{
|
||||
return new { IsAvailable = true, Message = "Identifier is available" };
|
||||
}
|
||||
|
||||
if (Recovery == null)
|
||||
{
|
||||
return new { IsAvailable = false, Message = "Identifier already in use" };
|
||||
}
|
||||
|
||||
return new { existingUsers };
|
||||
|
||||
}
|
||||
|
||||
public async Task<object> SendOtp(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
|
||||
var identifier = data.ContainsKey("identifier") ? data["identifier"].GetString() : null;
|
||||
var numberOfDigits = data.ContainsKey("numberOfDigits") ? data["numberOfDigits"].GetInt32() : 6;
|
||||
|
||||
var newUser = data.ContainsKey("newUser") ? data["newUser"].GetBoolean() : false;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(identifier))
|
||||
throw new ArgumentException("Identifier is required.");
|
||||
|
||||
var otpCode = await GenerateOtpCode(numberOfDigits);
|
||||
Recovery recovery;
|
||||
|
||||
|
||||
if (newUser)
|
||||
{
|
||||
recovery = new Recovery
|
||||
{
|
||||
RecoveryReferenceNum = GenerateSecureReferenceNumber(),
|
||||
OTP = otpCode,
|
||||
Status = "Pending",
|
||||
IsUsed = false,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
ExpirationTime = DateTime.UtcNow.AddMinutes(10),
|
||||
RecoveryType = "OTP FOR NEW USER"
|
||||
};
|
||||
|
||||
await _repository.AddRecoveryAsync(recovery);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(identifier))
|
||||
{
|
||||
if (IsEmail(identifier))
|
||||
{
|
||||
await _thirdPartyService.EmailConfiguration(
|
||||
identifier,
|
||||
otpCode,
|
||||
"Verification OTP",
|
||||
$@"
|
||||
<h2>Verification OTP</h2>
|
||||
<p>Your OTP code is:</p>
|
||||
<h1>{otpCode}</h1>
|
||||
<p>This code expires in 10 minutes.</p>
|
||||
<p><strong>Reference Number:</strong> {recovery.RecoveryReferenceNum}</p>
|
||||
"
|
||||
);
|
||||
}
|
||||
else if (IsMobileNumber(identifier))
|
||||
{
|
||||
await _thirdPartyService.SendSmsAsync(
|
||||
identifier,
|
||||
otpCode,
|
||||
$"Your OTP is {otpCode}. It expires in 10 minutes. Ref: {recovery.RecoveryReferenceNum}"
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new ArgumentException("Invalid identifier format. Must be a valid email or mobile number.");
|
||||
}
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
Success = true,
|
||||
Message = "OTP sent successfully for new user.",
|
||||
Data = new
|
||||
{
|
||||
ReferenceNumber = recovery.RecoveryReferenceNum,
|
||||
ExpiresAt = recovery.ExpirationTime,
|
||||
RecoveryType = recovery.RecoveryType
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
//exisiting one
|
||||
|
||||
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.");
|
||||
|
||||
recovery = new Recovery
|
||||
{
|
||||
UserId = user.Id,
|
||||
RecoveryReferenceNum = GenerateSecureReferenceNumber(),
|
||||
OTP = otpCode,
|
||||
Status = "Pending",
|
||||
IsUsed = false,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
ExpirationTime = DateTime.UtcNow.AddMinutes(10),
|
||||
RecoveryType = "OTP FOR LOGIN"
|
||||
};
|
||||
|
||||
await _repository.AddRecoveryAsync(recovery);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(user.MobileNumber))
|
||||
{
|
||||
await _thirdPartyService.SendSmsAsync(
|
||||
user.MobileNumber,
|
||||
otpCode,
|
||||
$"Your login OTP is {otpCode}. It expires in 10 minutes. Ref: {recovery.RecoveryReferenceNum}"
|
||||
);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(user.Email))
|
||||
{
|
||||
await _thirdPartyService.EmailConfiguration(
|
||||
user.Email,
|
||||
otpCode,
|
||||
"Login OTP",
|
||||
$@"
|
||||
<h2>Login OTP</h2>
|
||||
<p>Hello {user.FullName ?? user.UserName},</p>
|
||||
<p>Your OTP code is:</p>
|
||||
<h1>{otpCode}</h1>
|
||||
<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 = recovery.RecoveryType
|
||||
}
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public async Task<object> VerifyOTP(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
|
||||
var ipAddress = httpContext.Connection.RemoteIpAddress?.ToString();
|
||||
var userAgent = httpContext.Request.Headers["User-Agent"].ToString();
|
||||
|
||||
|
||||
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
Guid? userId = null;
|
||||
if (data.ContainsKey("userId") && Guid.TryParse(data["userId"].GetString(), out var parsedUserId))
|
||||
{
|
||||
userId = parsedUserId;
|
||||
}
|
||||
var referenceNumber = data.ContainsKey("referenceNumber") ? data["referenceNumber"].GetString() : null;
|
||||
var otpCode = data.ContainsKey("otpCode") ? data["otpCode"].GetString() : null;
|
||||
var newUser = data.ContainsKey("newUser") ? data["newUser"].GetBoolean() : false;
|
||||
var identifier = data.ContainsKey("identifier") ? data["identifier"].GetString() : null;
|
||||
var deviceName = data.ContainsKey("deviceName") ? data["deviceName"].GetString() : "Unknown Device";
|
||||
|
||||
|
||||
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";
|
||||
recovery.IsUsed = true;
|
||||
await _repository.UpdateRecoveryAsync(recovery);
|
||||
|
||||
User? user = null;
|
||||
if (!string.IsNullOrWhiteSpace(identifier))
|
||||
{
|
||||
user = await _userRepository.GetUserByIdentifierAndType(identifier, null);
|
||||
}
|
||||
|
||||
if (user is null && userId.HasValue)
|
||||
{
|
||||
user = await _userRepository.GetUserByIdAsync(userId.Value);
|
||||
}
|
||||
|
||||
if (user is null && recovery.UserId.HasValue)
|
||||
{
|
||||
user = await _userRepository.GetUserByIdAsync(recovery.UserId.Value);
|
||||
}
|
||||
|
||||
if (user is null)
|
||||
throw new KeyNotFoundException("No user found for OTP verification.");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(identifier))
|
||||
{
|
||||
if (IsEmail(identifier))
|
||||
{
|
||||
user.Email = identifier;
|
||||
user.EmailVerified = true;
|
||||
}
|
||||
else if (IsMobileNumber(identifier))
|
||||
{
|
||||
user.MobileNumber = identifier;
|
||||
user.MobileNumberVerified = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
user.Optional1 = identifier;
|
||||
user.Optional1Verified = true;
|
||||
}
|
||||
}
|
||||
|
||||
await _userRepository.UpdateUserAsync(user);
|
||||
|
||||
var jwtToken = await _jwt.GenerateToken(user);
|
||||
var refreshToken = Guid.NewGuid().ToString();
|
||||
var refreshTokenHash = PasswordHasher.Hash(refreshToken);
|
||||
|
||||
var userSession = new UserSession
|
||||
{
|
||||
UserId = user.Id,
|
||||
RefreshTokenHash = refreshTokenHash,
|
||||
DeviceName = deviceName,
|
||||
IPAddress = ipAddress,
|
||||
Browser = _userHelper.ExtractBrowser(userAgent),
|
||||
OS = _userHelper.ExtractOS(userAgent),
|
||||
ExpiresAt = DateTime.UtcNow.AddDays(30),
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_dbContext.UserSessions.Add(userSession);
|
||||
|
||||
await LogAuthEvent(user.Id, "LOGIN_SUCCESS", ipAddress, userAgent);
|
||||
|
||||
await _uow.CommitAsync();
|
||||
|
||||
|
||||
return new
|
||||
{
|
||||
Success = true,
|
||||
Message = newUser
|
||||
? "OTP verified successfully. Session started."
|
||||
: "OTP verified successfully.",
|
||||
Data = new
|
||||
{
|
||||
ReferenceNumber = recovery.RecoveryReferenceNum,
|
||||
UserId = user.Id,
|
||||
Verified = true,
|
||||
AccessToken = jwtToken,
|
||||
RefreshToken = refreshToken,
|
||||
ExpiresIn = 3600,
|
||||
User = new
|
||||
{
|
||||
user.Id,
|
||||
user.FullName,
|
||||
user.UserName,
|
||||
user.Email,
|
||||
user.MobileNumber,
|
||||
user.EmailVerified,
|
||||
user.MobileNumberVerified,
|
||||
user.IsMfaEnabled,
|
||||
RoleId = user.RoleId,
|
||||
UserTypeId = user.UserTypeId
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
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}";
|
||||
}
|
||||
|
||||
private bool IsEmail(string identifier)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(identifier))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var emailRegex = new System.Text.RegularExpressions.Regex(
|
||||
@"^[^@\s]+@[^@\s]+\.[^@\s]+$",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
|
||||
return emailRegex.IsMatch(identifier);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsMobileNumber(string identifier)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(identifier))
|
||||
return false;
|
||||
|
||||
var cleaned = identifier.Replace(" ", "").Replace("-", "").Replace("(", "").Replace(")", "").Replace("+", "");
|
||||
return System.Text.RegularExpressions.Regex.IsMatch(cleaned, @"^\d{7,15}$");
|
||||
}
|
||||
|
||||
private async Task LogAuthEvent(Guid? userId, string eventType, string? ipAddress, string? userAgent)
|
||||
{
|
||||
var authEvent = new AuthEventLog
|
||||
{
|
||||
UserId = userId,
|
||||
EventType = eventType,
|
||||
IPAddress = ipAddress,
|
||||
UserAgent = userAgent,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_dbContext.AuthEventLogs.Add(authEvent);
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using AuthHex.Models;
|
||||
using AuthHex.Models.IOs;
|
||||
using AuthHex.Services.AltOptionManager;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AuthHex.Services.FunctionHandler
|
||||
{
|
||||
public class AltOptionManager
|
||||
{
|
||||
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
|
||||
public readonly AltOptionManagerService _altOptionManagerService;
|
||||
|
||||
public AltOptionManager(AltOptionManagerService altOptionManagerService)
|
||||
{
|
||||
_altOptionManagerService = altOptionManagerService;
|
||||
_functionHandler = new()
|
||||
{
|
||||
{"IsAvailable", altOptionManagerService.IsAvailable},
|
||||
{"sendOtp", altOptionManagerService.SendOtp },
|
||||
{"VerifyOTP", altOptionManagerService.VerifyOTP }
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ApiResponse> Execute(ApiRequest request, HttpContext httpContext)
|
||||
{
|
||||
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
|
||||
{
|
||||
return new ApiResponse
|
||||
{
|
||||
StatusCode = 404,
|
||||
Message = $"Function '{request.FunctionName}' not found.",
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
try
|
||||
{
|
||||
var results = await handler(request.Payload, httpContext);
|
||||
return new ApiResponse
|
||||
{
|
||||
StatusCode = 200,
|
||||
Message = "success",
|
||||
Data = results
|
||||
};
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return new ApiResponse
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return new ApiResponse
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return new ApiResponse
|
||||
{
|
||||
StatusCode = 500,
|
||||
Message = ex.Message,
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using AuthHex.Models;
|
||||
using AuthHex.Models.IOs;
|
||||
using AuthHex.Services.RecoveryManager;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AuthHex.Services.FunctionHandler
|
||||
{
|
||||
public class RecoveryManager
|
||||
{
|
||||
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
|
||||
|
||||
public RecoveryManager(RecoveryManagerService RecoveryManagerService)
|
||||
{
|
||||
_functionHandler = new()
|
||||
{
|
||||
{ "forgotPassword", RecoveryManagerService.ForgotPassword },
|
||||
{ "verifyOTP", RecoveryManagerService.VerifyOTP },
|
||||
{ "resetPasswordWithToken", RecoveryManagerService.ResetPasswordWithToken },
|
||||
{ "resetPassword", RecoveryManagerService.ResetPassword }
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ApiResponse> Execute(ApiRequest request, HttpContext httpContext)
|
||||
{
|
||||
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
|
||||
{
|
||||
return new ApiResponse
|
||||
{
|
||||
StatusCode = 404,
|
||||
Message = $"Function '{request.FunctionName}' not found.",
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
try
|
||||
{
|
||||
var results = await handler(request.Payload, httpContext);
|
||||
return new ApiResponse
|
||||
{
|
||||
StatusCode = 200,
|
||||
Message = "success",
|
||||
Data = results
|
||||
};
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return new ApiResponse
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return new ApiResponse
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return new ApiResponse
|
||||
{
|
||||
StatusCode = 500,
|
||||
Message = ex.Message,
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using AuthHex.Models;
|
||||
using AuthHex.Models.IOs;
|
||||
using AuthHex.Services.UserManager;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AuthHex.Services.FunctionHandler
|
||||
{
|
||||
public class UserManager
|
||||
{
|
||||
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
|
||||
|
||||
public UserManager(UserManagerService UserManagerService)
|
||||
{
|
||||
_functionHandler = new()
|
||||
{
|
||||
{ "registerUser", UserManagerService.RegisterUser },
|
||||
{ "loginUser", UserManagerService.LoginUser },
|
||||
{ "refreshToken", UserManagerService.RefreshToken },
|
||||
{ "getUserDetails", UserManagerService.GetUserDetails },
|
||||
{ "getUserSessions", async (payload, context) => await UserManagerService.GetUserSessions(context) },
|
||||
{ "ChangeUserStatus", UserManagerService.ChangeUserStatus},
|
||||
{"LockUserAccount" , UserManagerService.LockUserAccount },
|
||||
{"ChangeUserPassword" , UserManagerService.ChangeUserPassword },
|
||||
{"VerifyPassword" , UserManagerService.VerifyPassword },
|
||||
{"LogoutUser" , UserManagerService.LogoutUser},
|
||||
{"UpdateUser", UserManagerService.UpdateUser},
|
||||
{"VerifyOtpForLogin",UserManagerService.VerifyOtpForLogin},
|
||||
|
||||
// 2FA Management Functions
|
||||
{ "initiateTwoFASetup", async (payload, context) => await UserManagerService.InitiateTwoFASetup(context) },
|
||||
{ "completeTwoFASetup", UserManagerService.CompleteTwoFASetup },
|
||||
{ "verifyTwoFA", UserManagerService.VerifyTwoFA },
|
||||
{ "disableTwoFA", UserManagerService.DisableTwoFA },
|
||||
{ "getTwoFAStatus", async (payload, context) => await UserManagerService.GetTwoFAStatus(context) }
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ApiResponse> Execute(ApiRequest request, HttpContext httpContext)
|
||||
{
|
||||
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
|
||||
{
|
||||
return new ApiResponse
|
||||
{
|
||||
StatusCode = 404,
|
||||
Message = $"Function '{request.FunctionName}' not found.",
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
try
|
||||
{
|
||||
var results = await handler(request.Payload, httpContext);
|
||||
return new ApiResponse
|
||||
{
|
||||
StatusCode = 200,
|
||||
Message = "success",
|
||||
Data = results
|
||||
};
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return new ApiResponse
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return new ApiResponse
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return new ApiResponse
|
||||
{
|
||||
StatusCode = 500,
|
||||
Message = ex.Message,
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
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}";
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user