diff --git a/Services/FunctionHandler/UserManager.cs b/Services/FunctionHandler/UserManager.cs index 8ce73c6..9d1d46f 100644 --- a/Services/FunctionHandler/UserManager.cs +++ b/Services/FunctionHandler/UserManager.cs @@ -26,6 +26,11 @@ namespace AuthHex.Services.FunctionHandler {"LogoutUser" , UserManagerService.LogoutUser}, {"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) }, @@ -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 diff --git a/Services/RecoveryManager/RecoveryManagerService.cs b/Services/RecoveryManager/RecoveryManagerService.cs index eff7471..52ee4d4 100644 --- a/Services/RecoveryManager/RecoveryManagerService.cs +++ b/Services/RecoveryManager/RecoveryManagerService.cs @@ -119,14 +119,9 @@ 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" - } + ReferenceNumber = recovery.RecoveryReferenceNum, + ExpiresAt = recovery.ExpirationTime, + RecoveryType = "ResetLink" }; } else @@ -176,14 +171,9 @@ public class RecoveryManagerService return new { - Success = true, - Message = "OTP sent successfully.", - Data = new - { - ReferenceNumber = recovery.RecoveryReferenceNum, - ExpiresAt = recovery.ExpirationTime, - RecoveryType = "OTP" - } + ReferenceNumber = recovery.RecoveryReferenceNum, + ExpiresAt = recovery.ExpirationTime, + RecoveryType = "OTP" }; } } @@ -237,14 +227,9 @@ public class RecoveryManagerService return new { - Success = true, - Message = "OTP verified successfully. You can now reset your password.", - Data = new - { - ReferenceNumber = recovery.RecoveryReferenceNum, - UserId = recovery.UserId, - Verified = true - } + ReferenceNumber = recovery.RecoveryReferenceNum, + UserId = recovery.UserId, + Verified = true }; } catch @@ -343,14 +328,9 @@ 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 - } + UserId = user.Id, + Email = user.Email, + ResetAt = DateTime.UtcNow }; } catch @@ -441,14 +421,9 @@ 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 - } + UserId = user.Id, + Email = user.Email, + ResetAt = DateTime.UtcNow }; } catch diff --git a/Services/UserManager/UserManagerService.cs b/Services/UserManager/UserManagerService.cs index dbe14f0..59e7293 100644 --- a/Services/UserManager/UserManagerService.cs +++ b/Services/UserManager/UserManagerService.cs @@ -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 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 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 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; + } + } + + /// + /// 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. + /// + public async Task 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; + } + } + } diff --git a/appsettings.json b/appsettings.json index 696599b..f0a7fb8 100644 --- a/appsettings.json +++ b/appsettings.json @@ -13,7 +13,10 @@ }, "AllowedHosts": "*", "AppSettings": { - "FrontendUrl": "http://localhost:3000" + "FrontendUrl": "https://erp.hexdive.com" + }, + "AuthHex": { + "AdminApiKey": "fd85fb9db2e0533fe82a1652ad42936b5a313a9146dc536e46b1e736861290c9" }, "Kestrel": { "Endpoints": {