From ffdf91b3a246659145b622c2bbe283ba49fcefde Mon Sep 17 00:00:00 2001 From: Varun Deep Saini Date: Wed, 7 Jan 2026 22:28:00 +0530 Subject: [PATCH] AMM-118: Add time-based account lockout with auto-unlock --- bin | 1 + .../controller/users/IEMRAdminController.java | 38 ++++ .../java/com/iemr/common/data/users/User.java | 4 + .../users/IEMRUserRepositoryCustom.java | 2 +- .../service/users/IEMRAdminUserService.java | 4 +- .../users/IEMRAdminUserServiceImpl.java | 192 ++++++++++++++---- src/main/resources/application.properties | 3 + 7 files changed, 208 insertions(+), 36 deletions(-) create mode 160000 bin diff --git a/bin b/bin new file mode 160000 index 00000000..9ffa450d --- /dev/null +++ b/bin @@ -0,0 +1 @@ +Subproject commit 9ffa450d1a5f73d42e60582b36442f0e1619e438 diff --git a/src/main/java/com/iemr/common/controller/users/IEMRAdminController.java b/src/main/java/com/iemr/common/controller/users/IEMRAdminController.java index 8bc0e74d..50255b71 100644 --- a/src/main/java/com/iemr/common/controller/users/IEMRAdminController.java +++ b/src/main/java/com/iemr/common/controller/users/IEMRAdminController.java @@ -1230,4 +1230,42 @@ public ResponseEntity getUserDetails(@PathVariable("userName") String userNam } } + + @Operation(summary = "Unlock user account locked due to failed login attempts") + @RequestMapping(value = "/unlockUserAccount", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON, headers = "Authorization") + public String unlockUserAccount(@RequestBody String request) { + OutputResponse response = new OutputResponse(); + try { + Long userId = parseUserIdFromRequest(request); + boolean unlocked = iemrAdminUserServiceImpl.unlockUserAccount(userId); + response.setResponse(unlocked ? "User account successfully unlocked" : "User account was not locked"); + } catch (Exception e) { + logger.error("Error unlocking user account: " + e.getMessage(), e); + response.setError(e); + } + return response.toString(); + } + + @Operation(summary = "Get user account lock status") + @RequestMapping(value = "/getUserLockStatus", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON, headers = "Authorization") + public String getUserLockStatus(@RequestBody String request) { + OutputResponse response = new OutputResponse(); + try { + Long userId = parseUserIdFromRequest(request); + String lockStatusJson = iemrAdminUserServiceImpl.getUserLockStatusJson(userId); + response.setResponse(lockStatusJson); + } catch (Exception e) { + logger.error("Error getting user lock status: " + e.getMessage(), e); + response.setError(e); + } + return response.toString(); + } + + private Long parseUserIdFromRequest(String request) throws IEMRException { + JsonObject requestObj = JsonParser.parseString(request).getAsJsonObject(); + if (!requestObj.has("userId") || requestObj.get("userId").isJsonNull()) { + throw new IEMRException("userId is required"); + } + return requestObj.get("userId").getAsLong(); + } } diff --git a/src/main/java/com/iemr/common/data/users/User.java b/src/main/java/com/iemr/common/data/users/User.java index 275b0ec6..677cd012 100644 --- a/src/main/java/com/iemr/common/data/users/User.java +++ b/src/main/java/com/iemr/common/data/users/User.java @@ -213,6 +213,10 @@ public class User implements Serializable { @Column(name = "dhistoken") private String dhistoken; + @Expose + @Column(name = "lock_timestamp") + private Timestamp lockTimestamp; + /* * protected User() { } */ diff --git a/src/main/java/com/iemr/common/repository/users/IEMRUserRepositoryCustom.java b/src/main/java/com/iemr/common/repository/users/IEMRUserRepositoryCustom.java index 3ee48ab3..f10c5e03 100644 --- a/src/main/java/com/iemr/common/repository/users/IEMRUserRepositoryCustom.java +++ b/src/main/java/com/iemr/common/repository/users/IEMRUserRepositoryCustom.java @@ -75,7 +75,7 @@ UserSecurityQMapping verifySecurityQuestionAnswers(@Param("UserID") Long UserID, @Query("SELECT u FROM User u WHERE u.userID=5718") User getAllExistingUsers(); - + User findByUserID(Long userID); } diff --git a/src/main/java/com/iemr/common/service/users/IEMRAdminUserService.java b/src/main/java/com/iemr/common/service/users/IEMRAdminUserService.java index d7dc6e2e..581ad183 100644 --- a/src/main/java/com/iemr/common/service/users/IEMRAdminUserService.java +++ b/src/main/java/com/iemr/common/service/users/IEMRAdminUserService.java @@ -123,6 +123,8 @@ public List getUserServiceRoleMappingForProvider(Integ List getUserIdbyUserName(String userName) throws IEMRException; + boolean unlockUserAccount(Long userId) throws IEMRException; + + String getUserLockStatusJson(Long userId) throws IEMRException; - } diff --git a/src/main/java/com/iemr/common/service/users/IEMRAdminUserServiceImpl.java b/src/main/java/com/iemr/common/service/users/IEMRAdminUserServiceImpl.java index 44bd2247..9f7cad0e 100644 --- a/src/main/java/com/iemr/common/service/users/IEMRAdminUserServiceImpl.java +++ b/src/main/java/com/iemr/common/service/users/IEMRAdminUserServiceImpl.java @@ -129,6 +129,8 @@ public class IEMRAdminUserServiceImpl implements IEMRAdminUserService { private SessionObject sessionObject; @Value("${failedLoginAttempt}") private String failedLoginAttempt; + @Value("${account.lock.duration.hours:24}") + private int accountLockDurationHours; // @Autowired // private ServiceRoleScreenMappingRepository ; @@ -222,7 +224,25 @@ public void setValidator(Validator validator) { private void checkUserAccountStatus(User user) throws IEMRException { if (user.getDeleted()) { - throw new IEMRException("Your account is locked or de-activated. Please contact administrator"); + if (user.getLockTimestamp() != null) { + long lockTimeMillis = user.getLockTimestamp().getTime(); + long currentTimeMillis = System.currentTimeMillis(); + long lockDurationMillis = (long) accountLockDurationHours * 60 * 60 * 1000; + + if (currentTimeMillis - lockTimeMillis >= lockDurationMillis) { + user.setDeleted(false); + user.setFailedAttempt(0); + user.setLockTimestamp(null); + iEMRUserRepositoryCustom.save(user); + logger.info("User account auto-unlocked after {} hours lock period for user: {}", + accountLockDurationHours, user.getUserName()); + return; + } else { + throw new IEMRException(generateLockoutErrorMessage(user.getLockTimestamp())); + } + } else { + throw new IEMRException("Your account is locked or de-activated. Please contact administrator"); + } } else if (user.getStatusID() > 2) { throw new IEMRException("Your account is not active. Please contact administrator"); } @@ -265,32 +285,27 @@ public List userAuthenticate(String userName, String password) throws Exce checkUserAccountStatus(user); iEMRUserRepositoryCustom.save(user); } else if (validatePassword == 0) { - if (user.getFailedAttempt() + 1 < failedAttempt) { - user.setFailedAttempt(user.getFailedAttempt() + 1); + int currentAttempts = (user.getFailedAttempt() != null) ? user.getFailedAttempt() : 0; + if (currentAttempts + 1 < failedAttempt) { + user.setFailedAttempt(currentAttempts + 1); user = iEMRUserRepositoryCustom.save(user); logger.warn("User Password Wrong"); throw new IEMRException("Invalid username or password"); - } else if (user.getFailedAttempt() + 1 >= failedAttempt) { - user.setFailedAttempt(user.getFailedAttempt() + 1); + } else { + java.sql.Timestamp lockTime = new java.sql.Timestamp(System.currentTimeMillis()); + user.setFailedAttempt(currentAttempts + 1); user.setDeleted(true); + user.setLockTimestamp(lockTime); user = iEMRUserRepositoryCustom.save(user); logger.warn("User Account has been locked after reaching the limit of {} failed login attempts.", ConfigProperties.getInteger("failedLoginAttempt")); - - throw new IEMRException( - "Invalid username or password. Please contact administrator."); - } else { - user.setFailedAttempt(user.getFailedAttempt() + 1); - user = iEMRUserRepositoryCustom.save(user); - logger.warn("Failed login attempt {} of {} for a user account.", - user.getFailedAttempt(), ConfigProperties.getInteger("failedLoginAttempt")); - throw new IEMRException( - "Invalid username or password. Please contact administrator."); + throw new IEMRException(generateLockoutErrorMessage(lockTime)); } } else { checkUserAccountStatus(user); - if (user.getFailedAttempt() != 0) { + if (user.getFailedAttempt() != null && user.getFailedAttempt() != 0) { user.setFailedAttempt(0); + user.setLockTimestamp(null); user = iEMRUserRepositoryCustom.save(user); } } @@ -313,6 +328,37 @@ private void resetUserLoginFailedAttempt(User user) throws IEMRException { } + private String generateLockoutErrorMessage(java.sql.Timestamp lockTimestamp) { + if (lockTimestamp == null) { + return "Your account has been locked. Please contact the administrator."; + } + + long lockTimeMillis = lockTimestamp.getTime(); + long currentTimeMillis = System.currentTimeMillis(); + long lockDurationMillis = (long) accountLockDurationHours * 60 * 60 * 1000; + long unlockTimeMillis = lockTimeMillis + lockDurationMillis; + long remainingMillis = unlockTimeMillis - currentTimeMillis; + + if (remainingMillis <= 0) { + return "Your account lock has expired. Please try logging in again."; + } + + long remainingHours = remainingMillis / (60 * 60 * 1000); + long remainingMinutes = (remainingMillis % (60 * 60 * 1000)) / (60 * 1000); + + String timeMessage; + if (remainingHours > 0 && remainingMinutes > 0) { + timeMessage = String.format("%d hours %d minutes", remainingHours, remainingMinutes); + } else if (remainingHours > 0) { + timeMessage = String.format("%d hours", remainingHours); + } else { + timeMessage = String.format("%d minutes", remainingMinutes); + } + + return String.format("Your account has been locked. You can try again in %s, or contact the administrator.", timeMessage); + + } + /** * Super Admin login */ @@ -351,32 +397,27 @@ public User superUserAuthenticate(String userName, String password) throws Excep iEMRUserRepositoryCustom.save(user); } else if (validatePassword == 0) { - if (user.getFailedAttempt() + 1 < failedAttempt) { - user.setFailedAttempt(user.getFailedAttempt() + 1); + int currentAttempts = (user.getFailedAttempt() != null) ? user.getFailedAttempt() : 0; + if (currentAttempts + 1 < failedAttempt) { + user.setFailedAttempt(currentAttempts + 1); user = iEMRUserRepositoryCustom.save(user); logger.warn("User Password Wrong"); throw new IEMRException("Invalid username or password"); - } else if (user.getFailedAttempt() + 1 >= failedAttempt) { - user.setFailedAttempt(user.getFailedAttempt() + 1); + } else { + java.sql.Timestamp lockTime = new java.sql.Timestamp(System.currentTimeMillis()); + user.setFailedAttempt(currentAttempts + 1); user.setDeleted(true); + user.setLockTimestamp(lockTime); user = iEMRUserRepositoryCustom.save(user); logger.warn("User Account has been locked after reaching the limit of {} failed login attempts.", ConfigProperties.getInteger("failedLoginAttempt")); - - throw new IEMRException( - "Invalid username or password. Please contact administrator."); - } else { - user.setFailedAttempt(user.getFailedAttempt() + 1); - user = iEMRUserRepositoryCustom.save(user); - logger.warn("Failed login attempt {} of {} for a user account.", - user.getFailedAttempt(), ConfigProperties.getInteger("failedLoginAttempt")); - throw new IEMRException( - "Invalid username or password. Please contact administrator."); + throw new IEMRException(generateLockoutErrorMessage(lockTime)); } } else { checkUserAccountStatus(user); - if (user.getFailedAttempt() != 0) { + if (user.getFailedAttempt() != null && user.getFailedAttempt() != 0) { user.setFailedAttempt(0); + user.setLockTimestamp(null); user = iEMRUserRepositoryCustom.save(user); } } @@ -1205,12 +1246,12 @@ public User getUserById(Long userId) throws IEMRException { try { // Fetch user from custom repository by userId User user = iEMRUserRepositoryCustom.findByUserID(userId); - + // Check if user is found if (user == null) { throw new IEMRException("User not found with ID: " + userId); } - + return user; } catch (Exception e) { // Log and throw custom exception in case of errors @@ -1221,7 +1262,90 @@ public User getUserById(Long userId) throws IEMRException { @Override public List getUserIdbyUserName(String userName) { - return iEMRUserRepositoryCustom.findByUserName(userName); } + + @Override + public boolean unlockUserAccount(Long userId) throws IEMRException { + try { + User user = iEMRUserRepositoryCustom.findById(userId).orElse(null); + + if (user == null) { + throw new IEMRException("User not found with ID: " + userId); + } + + if (user.getDeleted() != null && user.getDeleted() && user.getLockTimestamp() != null) { + user.setDeleted(false); + user.setFailedAttempt(0); + user.setLockTimestamp(null); + iEMRUserRepositoryCustom.save(user); + logger.info("Admin manually unlocked user account for userID: {}", userId); + return true; + } else if (user.getDeleted() != null && user.getDeleted() && user.getLockTimestamp() == null) { + throw new IEMRException("User account is deactivated by administrator. Use user management to reactivate."); + } else { + logger.info("User account is not locked for userID: {}", userId); + return false; + } + } catch (IEMRException e) { + throw e; + } catch (Exception e) { + logger.error("Error unlocking user account with ID: " + userId, e); + throw new IEMRException("Error unlocking user account: " + e.getMessage(), e); + } + } + + @Override + public String getUserLockStatusJson(Long userId) throws IEMRException { + try { + User user = iEMRUserRepositoryCustom.findById(userId).orElse(null); + if (user == null) { + throw new IEMRException("User not found with ID: " + userId); + } + + org.json.JSONObject status = new org.json.JSONObject(); + status.put("userId", user.getUserID()); + status.put("userName", user.getUserName()); + status.put("failedAttempts", user.getFailedAttempt() != null ? user.getFailedAttempt() : 0); + status.put("statusID", user.getStatusID()); + + boolean isDeleted = user.getDeleted() != null && user.getDeleted(); + boolean isLockedDueToFailedAttempts = isDeleted && user.getLockTimestamp() != null; + + status.put("isLocked", isDeleted); + status.put("isLockedDueToFailedAttempts", isLockedDueToFailedAttempts); + + if (isLockedDueToFailedAttempts) { + long lockDurationMillis = (long) accountLockDurationHours * 60 * 60 * 1000; + long remainingMillis = (user.getLockTimestamp().getTime() + lockDurationMillis) - System.currentTimeMillis(); + boolean lockExpired = remainingMillis <= 0; + + status.put("lockExpired", lockExpired); + status.put("lockTimestamp", user.getLockTimestamp().toString()); + status.put("remainingTime", lockExpired ? "Lock expired - will unlock on next login" : formatRemainingTime(remainingMillis)); + if (!lockExpired) { + status.put("unlockTime", new java.sql.Timestamp(user.getLockTimestamp().getTime() + lockDurationMillis).toString()); + } + } else { + status.put("lockExpired", false); + status.put("lockTimestamp", org.json.JSONObject.NULL); + status.put("remainingTime", org.json.JSONObject.NULL); + } + + return status.toString(); + } catch (IEMRException e) { + throw e; + } catch (Exception e) { + logger.error("Error fetching user lock status with ID: " + userId, e); + throw new IEMRException("Error fetching user lock status: " + e.getMessage(), e); + } + } + + private String formatRemainingTime(long remainingMillis) { + long hours = remainingMillis / (60 * 60 * 1000); + long minutes = (remainingMillis % (60 * 60 * 1000)) / (60 * 1000); + if (hours > 0 && minutes > 0) return String.format("%d hours %d minutes", hours, minutes); + if (hours > 0) return String.format("%d hours", hours); + return String.format("%d minutes", minutes); + } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 18723465..c0d0ccc6 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -169,6 +169,9 @@ quality-Audit-PageSize=5 ## max no of failed login attempt failedLoginAttempt=5 +## account lock duration in hours (24 hours = 1 day for auto-unlock) +account.lock.duration.hours=24 + #Jwt Token configuration jwt.access.expiration=28800000 jwt.refresh.expiration=604800000