Merge pull request 'Main' (#4) from main into dev

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-10-18 12:36:32 +05:30
4 changed files with 194 additions and 42 deletions
+14
View File
@@ -27,6 +27,11 @@ namespace AuthHex.Services.FunctionHandler
{"UpdateUser", UserManagerService.UpdateUser},
{"VerifyOtpForLogin",UserManagerService.VerifyOtpForLogin},
// Admin-only — require X-Admin-Api-Key, act on an arbitrary target user.
{"AdminUpdateUser", UserManagerService.AdminUpdateUser},
{"AdminSetUserStatus", UserManagerService.AdminSetUserStatus},
{"deleteUser", UserManagerService.DeleteUser},
// 2FA Management Functions
{ "initiateTwoFASetup", async (payload, context) => await UserManagerService.InitiateTwoFASetup(context) },
{ "completeTwoFASetup", UserManagerService.CompleteTwoFASetup },
@@ -57,6 +62,15 @@ namespace AuthHex.Services.FunctionHandler
Data = results
};
}
catch (UnauthorizedAccessException ex)
{
return new ApiResponse
{
StatusCode = 401,
Message = ex.Message,
Data = null
};
}
catch (KeyNotFoundException ex)
{
return new ApiResponse
@@ -118,15 +118,10 @@ public class RecoveryManagerService
}
return new
{
Success = true,
Message = "Password reset link sent to your email.",
Data = new
{
ReferenceNumber = recovery.RecoveryReferenceNum,
ExpiresAt = recovery.ExpirationTime,
RecoveryType = "ResetLink"
}
};
}
else
@@ -175,15 +170,10 @@ public class RecoveryManagerService
}
return new
{
Success = true,
Message = "OTP sent successfully.",
Data = new
{
ReferenceNumber = recovery.RecoveryReferenceNum,
ExpiresAt = recovery.ExpirationTime,
RecoveryType = "OTP"
}
};
}
}
@@ -236,15 +226,10 @@ public class RecoveryManagerService
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
@@ -342,15 +327,10 @@ public class RecoveryManagerService
}
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
@@ -440,15 +420,10 @@ public class RecoveryManagerService
}
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
+161 -1
View File
@@ -28,8 +28,9 @@ public class UserManagerService
private readonly AppDBContext _dbContext;
private readonly ThirdPartyService _thirdPartyService;
private readonly UserHelper _userHelper;
private readonly IConfiguration _configuration;
public UserManagerService(IUserManageRepository repository, IRecoveryManagerRepository recoveryRepository, IUnitOfWork uow, JwtTokenHelper jwt, AppDBContext dbContext, ThirdPartyService thirdPartyService, UserHelper userHelper)
public UserManagerService(IUserManageRepository repository, IRecoveryManagerRepository recoveryRepository, IUnitOfWork uow, JwtTokenHelper jwt, AppDBContext dbContext, ThirdPartyService thirdPartyService, UserHelper userHelper, IConfiguration configuration)
{
_repository = repository;
_recoveryRepository = recoveryRepository;
@@ -38,6 +39,7 @@ public class UserManagerService
_dbContext = dbContext;
_thirdPartyService = thirdPartyService;
_userHelper = userHelper;
_configuration = configuration;
}
public async Task<object> RegisterUser(object payload, HttpContext httpContext)
@@ -1259,4 +1261,162 @@ public class UserManagerService
return System.Text.RegularExpressions.Regex.IsMatch(cleaned, @"^\d{7,15}$");
}
// ---- Admin-only functions (require X-Admin-Api-Key) ----------------------------
// Everything above acts on the caller's own account, resolved from the bearer token's
// claims. These three act on an arbitrary target user (by id, or by identifier for
// delete) on an admin's behalf — ERPCore calls them with a shared secret instead of a
// user bearer token, since the admin and the target are different people.
private bool IsAuthorizedAdmin(HttpContext httpContext)
{
var expected = _configuration["AuthHex:AdminApiKey"];
var provided = httpContext.Request.Headers["X-Admin-Api-Key"].FirstOrDefault();
return !string.IsNullOrEmpty(expected) && provided == expected;
}
public async Task<object> AdminUpdateUser(object payload, HttpContext httpContext)
{
if (!IsAuthorizedAdmin(httpContext))
throw new UnauthorizedAccessException("A valid X-Admin-Api-Key header is required for this operation.");
var data = DeserializePayload.Deserialize(payload);
if (!data.ContainsKey("userId"))
throw new ArgumentException("userId is required");
var userId = Guid.Parse(data["userId"].GetString() ?? throw new ArgumentException("userId is required"));
var user = await _repository.GetUserByIdAsync(userId)
?? throw new ArgumentException("User not found");
await _uow.BeginAsync();
try
{
if (data.ContainsKey("fullName")) user.FullName = data["fullName"].GetString();
if (data.ContainsKey("userName")) user.UserName = data["userName"].GetString();
if (data.ContainsKey("email"))
{
user.Email = data["email"].GetString();
user.EmailVerified = false;
}
if (data.ContainsKey("mobileNumber"))
{
user.MobileNumber = data["mobileNumber"].GetString();
user.MobileNumberVerified = false;
}
await _uow.CommitAsync();
return new
{
user.Id,
user.FullName,
user.UserName,
user.Email,
user.MobileNumber,
user.IsActive
};
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> AdminSetUserStatus(object payload, HttpContext httpContext)
{
if (!IsAuthorizedAdmin(httpContext))
throw new UnauthorizedAccessException("A valid X-Admin-Api-Key header is required for this operation.");
var data = DeserializePayload.Deserialize(payload);
if (!data.ContainsKey("userId"))
throw new ArgumentException("userId is required");
var userId = Guid.Parse(data["userId"].GetString() ?? throw new ArgumentException("userId is required"));
var isActive = data.ContainsKey("isActive") ? data["isActive"].GetBoolean() : (bool?)null;
if (isActive == null)
throw new ArgumentException("isActive is required");
var user = await _repository.GetUserByIdAsync(userId)
?? throw new ArgumentException("User not found");
await _uow.BeginAsync();
try
{
user.IsActive = isActive.Value;
await _uow.CommitAsync();
// Deactivating must actually end any live sessions, not just flip a flag that
// existing refresh tokens ignore.
if (!isActive.Value)
await _repository.InvalidateUserSessionsAsync(user.Id);
return new
{
user.Id,
user.FullName,
user.UserName,
user.Email,
user.IsActive
};
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
/// <summary>
/// Hard-deletes one or more users matched by any of the given identifiers. Sessions,
/// tokens, MFA and provider links cascade at the DB level (AppDBContext.OnModelCreating);
/// Recovery rows have a plain Restrict FK and must be removed explicitly first.
/// </summary>
public async Task<object> DeleteUser(object payload, HttpContext httpContext)
{
if (!IsAuthorizedAdmin(httpContext))
throw new UnauthorizedAccessException("A valid X-Admin-Api-Key header is required for this operation.");
var data = DeserializePayload.Deserialize(payload);
string? email = data.ContainsKey("email") ? data["email"].GetString() : null;
string? mobileNumber = data.ContainsKey("mobileNumber") ? data["mobileNumber"].GetString() : null;
string? nic = data.ContainsKey("nic") ? data["nic"].GetString() : null;
string? userName = data.ContainsKey("userName") ? data["userName"].GetString() : null;
if (string.IsNullOrWhiteSpace(email) && string.IsNullOrWhiteSpace(mobileNumber)
&& string.IsNullOrWhiteSpace(nic) && string.IsNullOrWhiteSpace(userName))
throw new ArgumentException("At least one of email, mobileNumber, nic or userName is required.");
var matches = await _dbContext.Users
.Where(u =>
(email != null && u.Email == email) ||
(mobileNumber != null && u.MobileNumber == mobileNumber) ||
(nic != null && u.Nic == nic) ||
(userName != null && u.UserName == userName))
.ToListAsync();
if (matches.Count == 0)
throw new ArgumentException("No matching user was found.");
var ids = matches.Select(u => u.Id).ToList();
var deleted = matches.Select(u => new { u.Id, u.FullName, u.UserName, u.Email }).ToList();
await _uow.BeginAsync();
try
{
_dbContext.Recovery.RemoveRange(_dbContext.Recovery.Where(r => r.UserId != null && ids.Contains(r.UserId.Value)));
_dbContext.AuthEventLogs.RemoveRange(_dbContext.AuthEventLogs.Where(l => l.UserId != null && ids.Contains(l.UserId.Value)));
_dbContext.Users.RemoveRange(matches);
await _uow.CommitAsync();
return new { deletedCount = deleted.Count, deleted };
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
}
+4 -1
View File
@@ -13,7 +13,10 @@
},
"AllowedHosts": "*",
"AppSettings": {
"FrontendUrl": "http://localhost:3000"
"FrontendUrl": "https://erp.hexdive.com"
},
"AuthHex": {
"AdminApiKey": "fd85fb9db2e0533fe82a1652ad42936b5a313a9146dc536e46b1e736861290c9"
},
"Kestrel": {
"Endpoints": {