From 9b86f89d3a3bc306fd03d847e35275f0e3da31d7 Mon Sep 17 00:00:00 2001 From: Daniel Persson <17487347+saligiad@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:50:22 -0700 Subject: [PATCH 1/5] AUTHLIB-180 Lockout cooldown Added a scheduled job that looks for locked user accounts and, if a configured cooldown period has elapsed, unlocks those accounts. Added configuration includes: * `octri.authentication.lockout-cooldown-period`: Defaults to 15 minutes. * `octri.authentication.lockout-polling-schedule`: Defaults to 30 minutes. Controls frequency with which the unlock job runs. Implementation notes: * The current implementation checks that `user.enabled` is true but, otherwise, does not check things such as account or credential expiration. * Unlocking the account resets the number of login failures to 0. --- CHANGELOG.md | 4 ++ .../config/OctriAuthenticationProperties.java | 29 ++++++++ .../repository/LoginAttemptRepository.java | 8 ++- .../security/repository/UserRepository.java | 11 +++ .../scheduled/LockoutCooldownJob.java | 69 +++++++++++++++++++ .../security/service/LoginAttemptService.java | 14 +++- .../server/security/service/UserService.java | 9 +++ 7 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 authentication_lib/src/main/java/org/octri/authentication/server/security/scheduled/LockoutCooldownJob.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 15c26a3..7ff5896 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add polling to unlock accounts after a configurable cooldown period (AUTHLIB-180) + ## [4.3.0] - 2026-09-16 ### Added diff --git a/authentication_lib/src/main/java/org/octri/authentication/config/OctriAuthenticationProperties.java b/authentication_lib/src/main/java/org/octri/authentication/config/OctriAuthenticationProperties.java index 38f945f..4731356 100644 --- a/authentication_lib/src/main/java/org/octri/authentication/config/OctriAuthenticationProperties.java +++ b/authentication_lib/src/main/java/org/octri/authentication/config/OctriAuthenticationProperties.java @@ -88,6 +88,19 @@ public static enum RoleStyle { */ private Integer maxLoginAttempts = 7; + /** + * Minimum time (in minutes) that must elapse between the most recent failed login and automatic account unlock. + * Defaults to 30 minutes. + * + * If null, the cooldown is disabled and accounts will remain locked until changed by an admin. + */ + private Integer lockoutCooldownPeriod = 30; + + /** + * Schedule for cron task to check cooldown on locked accounts. Defaults to every 15 minutes. + */ + private String lockoutPollingSchedule = "0 */15 * * * *"; + /** * Length of time (in days) that table-based credentials are valid. After this period has elapsed, users will be * required to change their password. Defaults to 180 days. @@ -220,6 +233,22 @@ public void setMaxLoginAttempts(Integer maxLoginAttempts) { this.maxLoginAttempts = maxLoginAttempts; } + public Integer getLockoutCooldownPeriod() { + return lockoutCooldownPeriod; + } + + public void setLockoutCooldownPeriod(Integer lockoutCooldownPeriod) { + this.lockoutCooldownPeriod = lockoutCooldownPeriod; + } + + public String getLockoutPollingSchedule() { + return lockoutPollingSchedule; + } + + public void setLockoutPollingSchedule(String lockoutPollingSchedule) { + this.lockoutPollingSchedule = lockoutPollingSchedule; + } + /** * Gets the length of time (in days) that table-based credentials are valid. After this period has elapsed, users * will be required to change their password. diff --git a/authentication_lib/src/main/java/org/octri/authentication/server/security/repository/LoginAttemptRepository.java b/authentication_lib/src/main/java/org/octri/authentication/server/security/repository/LoginAttemptRepository.java index 2c22c85..a46ec0e 100644 --- a/authentication_lib/src/main/java/org/octri/authentication/server/security/repository/LoginAttemptRepository.java +++ b/authentication_lib/src/main/java/org/octri/authentication/server/security/repository/LoginAttemptRepository.java @@ -15,13 +15,15 @@ public interface LoginAttemptRepository extends JpaRepository { /** - * Finds the most recent successful login by the user with the given username. + * Finds the most recent login by the user with the given username and success status * * @param username * the username to check - * @return the user's most recent successful login, or null if the user has never logged in successfully + * @param successful + * the requirement of either successful or failed login + * @return the user's most recent login, or null if the no record matches the username and successful requirement */ - public LoginAttempt findFirstByUsernameAndSuccessfulIsTrueOrderByAttemptedAtDesc(String username); + public LoginAttempt findFirstByUsernameAndSuccessfulOrderByAttemptedAtDesc(String username, Boolean successful); /** * Finds the most recent failed login of the given type. diff --git a/authentication_lib/src/main/java/org/octri/authentication/server/security/repository/UserRepository.java b/authentication_lib/src/main/java/org/octri/authentication/server/security/repository/UserRepository.java index 77b0311..9bd6134 100644 --- a/authentication_lib/src/main/java/org/octri/authentication/server/security/repository/UserRepository.java +++ b/authentication_lib/src/main/java/org/octri/authentication/server/security/repository/UserRepository.java @@ -1,5 +1,7 @@ package org.octri.authentication.server.security.repository; +import java.util.List; + import org.octri.authentication.server.security.entity.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.repository.query.Param; @@ -48,4 +50,13 @@ public interface UserRepository extends JpaRepository { */ public User findByEmailIgnoreCase(@Param("email") String email); + /** + * Finds user acccounts based on whether or not they are locked/enabled + * + * @param accountLocked + * @param enabled + * @return users who match the accountLocked and enabled criteria + */ + public List findByAccountLockedAndEnabled(Boolean accountLocked, Boolean enabled); + } diff --git a/authentication_lib/src/main/java/org/octri/authentication/server/security/scheduled/LockoutCooldownJob.java b/authentication_lib/src/main/java/org/octri/authentication/server/security/scheduled/LockoutCooldownJob.java new file mode 100644 index 0000000..f645261 --- /dev/null +++ b/authentication_lib/src/main/java/org/octri/authentication/server/security/scheduled/LockoutCooldownJob.java @@ -0,0 +1,69 @@ +package org.octri.authentication.server.security.scheduled; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; + +import org.octri.authentication.config.OctriAuthenticationProperties; +import org.octri.authentication.server.security.exception.UserManagementException; +import org.octri.authentication.server.security.service.LoginAttemptService; +import org.octri.authentication.server.security.service.UserService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +public class LockoutCooldownJob { + + private static final Logger log = LoggerFactory.getLogger(LockoutCooldownJob.class); + + private final OctriAuthenticationProperties authenticationProperties; + private final LoginAttemptService loginAttemptService; + final private UserService userService; + + /** + * Constructor. + * + * @param authenticationProperties + * @param loginAttemptService + * @param userService + */ + public LockoutCooldownJob(OctriAuthenticationProperties authenticationProperties, + LoginAttemptService loginAttemptService, UserService userService) { + this.authenticationProperties = authenticationProperties; + this.loginAttemptService = loginAttemptService; + this.userService = userService; + } + + /** + * Scheduled task to periodically unlock accounts that have exceeded the maximum number of login attempts. + * + * Leverages {@link UserService} to poll the database for locked accounts, and checks the most recent failure from + * {@link LoginAttemptService} to unlock the account if the configured cooldown period has elapsed. + * + * If octri.authentication.lockout-cooldown-period is configured as null, accounts are left locked. + */ + @Scheduled(cron = "${octri.authentication.lockout-polling-schedule:0 */15 * * * *}") + public void checkLockoutCooldown() { + log.info("Running checkLockoutCooldown job"); + var cooldownPeriod = authenticationProperties.getLockoutCooldownPeriod(); + if (cooldownPeriod == null) + return; + + var cooldownThreshold = Date.from(Instant.now().minus(cooldownPeriod, ChronoUnit.MINUTES)); + userService.getUnlockableAccounts().stream().forEach(user -> { + var lastFailure = loginAttemptService.findLastFailure(user.getUsername()); + if (lastFailure == null) { + log.warn("No failed login attempts found for " + user.getUsername() + "'s locked account"); + } else if (lastFailure.getAttemptedAt().before(cooldownThreshold)) { + user.setAccountLocked(false); + try { + userService.save(user); + } catch (UserManagementException e) { + log.error("Failed to save unlocked user account", e); + } + } + }); + } +} diff --git a/authentication_lib/src/main/java/org/octri/authentication/server/security/service/LoginAttemptService.java b/authentication_lib/src/main/java/org/octri/authentication/server/security/service/LoginAttemptService.java index b3a379f..701cfce 100644 --- a/authentication_lib/src/main/java/org/octri/authentication/server/security/service/LoginAttemptService.java +++ b/authentication_lib/src/main/java/org/octri/authentication/server/security/service/LoginAttemptService.java @@ -88,7 +88,19 @@ public void deleteAll() { */ @Transactional(readOnly = true) public LoginAttempt findLastSuccess(String username) { - return loginAttemptRepository.findFirstByUsernameAndSuccessfulIsTrueOrderByAttemptedAtDesc(username); + return loginAttemptRepository.findFirstByUsernameAndSuccessfulOrderByAttemptedAtDesc(username, true); + } + + /** + * Finds the most recent failed login attempt for the given username. + * + * @param username + * the username to search for + * @return the most recent failed login attempt for the username, or null if they have never failed to log in + */ + @Transactional(readOnly = true) + public LoginAttempt findLastFailure(String username) { + return loginAttemptRepository.findFirstByUsernameAndSuccessfulOrderByAttemptedAtDesc(username, false); } /** diff --git a/authentication_lib/src/main/java/org/octri/authentication/server/security/service/UserService.java b/authentication_lib/src/main/java/org/octri/authentication/server/security/service/UserService.java index 6281af6..7722fbf 100644 --- a/authentication_lib/src/main/java/org/octri/authentication/server/security/service/UserService.java +++ b/authentication_lib/src/main/java/org/octri/authentication/server/security/service/UserService.java @@ -188,6 +188,15 @@ public User incrementFailedAttempts(User user) { return userRepository.save(user); } + /** + * Fetches user accounts that are locked, omitting disabled accounts. + * + * @return List of locked, enabled users + */ + public List getUnlockableAccounts() { + return userRepository.findByAccountLockedAndEnabled(true, true); + } + /** * Saves user with newPassword and updates {@link User#credentialsExpirationDate}. If validation fails, * the User is returned paired with a list of errors. From 04474e82d36f9df00d964671870e3ce2438b1bb4 Mon Sep 17 00:00:00 2001 From: Daniel Persson <17487347+saligiad@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:06:38 -0700 Subject: [PATCH 2/5] AUTHLIB-180 Remove null note Removed commend about null configuration, due to issues testing the configured value is null. --- .../config/OctriAuthenticationProperties.java | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/authentication_lib/src/main/java/org/octri/authentication/config/OctriAuthenticationProperties.java b/authentication_lib/src/main/java/org/octri/authentication/config/OctriAuthenticationProperties.java index 4731356..8cc748b 100644 --- a/authentication_lib/src/main/java/org/octri/authentication/config/OctriAuthenticationProperties.java +++ b/authentication_lib/src/main/java/org/octri/authentication/config/OctriAuthenticationProperties.java @@ -91,8 +91,6 @@ public static enum RoleStyle { /** * Minimum time (in minutes) that must elapse between the most recent failed login and automatic account unlock. * Defaults to 30 minutes. - * - * If null, the cooldown is disabled and accounts will remain locked until changed by an admin. */ private Integer lockoutCooldownPeriod = 30; @@ -233,18 +231,38 @@ public void setMaxLoginAttempts(Integer maxLoginAttempts) { this.maxLoginAttempts = maxLoginAttempts; } + /** + * Get the cooldown period for user lockouts due to failed login attempts + * + * @return + */ public Integer getLockoutCooldownPeriod() { return lockoutCooldownPeriod; } + /** + * Set the cooldown period for user lockouts due to failed login attempts + * + * @param lockoutCooldownPeriod + */ public void setLockoutCooldownPeriod(Integer lockoutCooldownPeriod) { this.lockoutCooldownPeriod = lockoutCooldownPeriod; } + /** + * Gets the cron schedule for checking the database for locked users + * + * @return + */ public String getLockoutPollingSchedule() { return lockoutPollingSchedule; } + /** + * Sets the cron schedule for checking the database for locked users + * + * @param lockoutPollingSchedule + */ public void setLockoutPollingSchedule(String lockoutPollingSchedule) { this.lockoutPollingSchedule = lockoutPollingSchedule; } From 2885383cdd66a5d10e9461e1afa28ffa6ee09ada Mon Sep 17 00:00:00 2001 From: Daniel Persson <17487347+saligiad@users.noreply.github.com> Date: Mon, 21 Sep 2026 11:21:15 -0700 Subject: [PATCH 3/5] AUTHLIB-180 PR Feedback Implement changes based on feedback: * Convert cooldown from Integer to Duration - updated naming from `lockoutCooldownPeriod` to `lockoutCooldownDuration` to match. * Update the polling default to every minute, to minimize default lockout time. --- .../config/OctriAuthenticationProperties.java | 20 ++- .../scheduled/LockoutCooldownJob.java | 10 +- .../scheduled/LockoutCooldownJobTest.java | 125 ++++++++++++++++++ 3 files changed, 143 insertions(+), 12 deletions(-) create mode 100644 authentication_lib/src/test/java/org/octri/authentication/server/security/scheduled/LockoutCooldownJobTest.java diff --git a/authentication_lib/src/main/java/org/octri/authentication/config/OctriAuthenticationProperties.java b/authentication_lib/src/main/java/org/octri/authentication/config/OctriAuthenticationProperties.java index 8cc748b..056d850 100644 --- a/authentication_lib/src/main/java/org/octri/authentication/config/OctriAuthenticationProperties.java +++ b/authentication_lib/src/main/java/org/octri/authentication/config/OctriAuthenticationProperties.java @@ -67,6 +67,11 @@ public static enum RoleStyle { */ public static final Duration DEFAULT_PASSWORD_TOKEN_DURATION = Duration.ofMinutes(30); + /** + * The default value of "octri.authentication.lockout-cooldown-duration" + */ + public static final Duration DEFAULT_LOCKOUT_COOLDOWN_DURATION = Duration.ofMinutes(30); + /** * Whether LDAP authentication should be enabled. Defaults to false. */ @@ -92,12 +97,13 @@ public static enum RoleStyle { * Minimum time (in minutes) that must elapse between the most recent failed login and automatic account unlock. * Defaults to 30 minutes. */ - private Integer lockoutCooldownPeriod = 30; + @DurationUnit(ChronoUnit.MINUTES) + private Duration lockoutCooldownDuration = DEFAULT_LOCKOUT_COOLDOWN_DURATION; /** * Schedule for cron task to check cooldown on locked accounts. Defaults to every 15 minutes. */ - private String lockoutPollingSchedule = "0 */15 * * * *"; + private String lockoutPollingSchedule = "0 */1 * * * *"; /** * Length of time (in days) that table-based credentials are valid. After this period has elapsed, users will be @@ -236,17 +242,17 @@ public void setMaxLoginAttempts(Integer maxLoginAttempts) { * * @return */ - public Integer getLockoutCooldownPeriod() { - return lockoutCooldownPeriod; + public Duration getLockoutCooldownDuration() { + return lockoutCooldownDuration; } /** * Set the cooldown period for user lockouts due to failed login attempts * - * @param lockoutCooldownPeriod + * @param lockoutCooldownDuration */ - public void setLockoutCooldownPeriod(Integer lockoutCooldownPeriod) { - this.lockoutCooldownPeriod = lockoutCooldownPeriod; + public void setLockoutCooldownDuration(Duration lockoutCooldownDuration) { + this.lockoutCooldownDuration = lockoutCooldownDuration; } /** diff --git a/authentication_lib/src/main/java/org/octri/authentication/server/security/scheduled/LockoutCooldownJob.java b/authentication_lib/src/main/java/org/octri/authentication/server/security/scheduled/LockoutCooldownJob.java index f645261..25e4eff 100644 --- a/authentication_lib/src/main/java/org/octri/authentication/server/security/scheduled/LockoutCooldownJob.java +++ b/authentication_lib/src/main/java/org/octri/authentication/server/security/scheduled/LockoutCooldownJob.java @@ -1,7 +1,6 @@ package org.octri.authentication.server.security.scheduled; import java.time.Instant; -import java.time.temporal.ChronoUnit; import java.util.Date; import org.octri.authentication.config.OctriAuthenticationProperties; @@ -44,14 +43,15 @@ public LockoutCooldownJob(OctriAuthenticationProperties authenticationProperties * * If octri.authentication.lockout-cooldown-period is configured as null, accounts are left locked. */ - @Scheduled(cron = "${octri.authentication.lockout-polling-schedule:0 */15 * * * *}") + @Scheduled(cron = "${octri.authentication.lockout-polling-schedule:0 */1 * * * *}") public void checkLockoutCooldown() { log.info("Running checkLockoutCooldown job"); - var cooldownPeriod = authenticationProperties.getLockoutCooldownPeriod(); - if (cooldownPeriod == null) + var cooldownDuration = authenticationProperties.getLockoutCooldownDuration(); + if (cooldownDuration == null) { return; + } - var cooldownThreshold = Date.from(Instant.now().minus(cooldownPeriod, ChronoUnit.MINUTES)); + var cooldownThreshold = Date.from(Instant.now().minus(cooldownDuration)); userService.getUnlockableAccounts().stream().forEach(user -> { var lastFailure = loginAttemptService.findLastFailure(user.getUsername()); if (lastFailure == null) { diff --git a/authentication_lib/src/test/java/org/octri/authentication/server/security/scheduled/LockoutCooldownJobTest.java b/authentication_lib/src/test/java/org/octri/authentication/server/security/scheduled/LockoutCooldownJobTest.java new file mode 100644 index 0000000..dde5e47 --- /dev/null +++ b/authentication_lib/src/test/java/org/octri/authentication/server/security/scheduled/LockoutCooldownJobTest.java @@ -0,0 +1,125 @@ +package org.octri.authentication.server.security.scheduled; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.octri.authentication.config.OctriAuthenticationProperties; +import org.octri.authentication.server.security.entity.LoginAttempt; +import org.octri.authentication.server.security.entity.User; +import org.octri.authentication.server.security.exception.UserManagementException; +import org.octri.authentication.server.security.service.LoginAttemptService; +import org.octri.authentication.server.security.service.UserService; + +@ExtendWith(MockitoExtension.class) +public class LockoutCooldownJobTest { + + private static final String USERNAME = "foo"; + private static final Duration COOLDOWN_DURATION = Duration.ofMinutes(30); + + @Mock + private OctriAuthenticationProperties authenticationProperties; + + @Mock + private LoginAttemptService loginAttemptService; + + @Mock + private UserService userService; + + private LockoutCooldownJob lockoutCooldownJob; + + private User user; + + @BeforeEach + public void beforeEach() { + lockoutCooldownJob = new LockoutCooldownJob(authenticationProperties, loginAttemptService, userService); + + user = new User(); + user.setUsername(USERNAME); + user.setAccountLocked(true); + } + + @Test + public void testCooldownDurationIsNull() { + when(authenticationProperties.getLockoutCooldownDuration()).thenReturn(null); + + lockoutCooldownJob.checkLockoutCooldown(); + + verify(userService, never()).getUnlockableAccounts(); + verify(loginAttemptService, never()).findLastFailure(anyString()); + } + + @Test + public void testCooldownHasElapsed() throws UserManagementException { + when(authenticationProperties.getLockoutCooldownDuration()).thenReturn(COOLDOWN_DURATION); + when(userService.getUnlockableAccounts()).thenReturn(List.of(user)); + + var lastFailure = new LoginAttempt(); + lastFailure.setAttemptedAt(Date.from(Instant.now().minus(COOLDOWN_DURATION).minusSeconds(60))); + when(loginAttemptService.findLastFailure(USERNAME)).thenReturn(lastFailure); + + lockoutCooldownJob.checkLockoutCooldown(); + + assertFalse(user.getAccountLocked(), "Account should be unlocked once the cooldown period has elapsed"); + verify(userService, times(1)).save(user); + } + + @Test + public void testCooldownHasNotElapsed() throws UserManagementException { + when(authenticationProperties.getLockoutCooldownDuration()).thenReturn(COOLDOWN_DURATION); + when(userService.getUnlockableAccounts()).thenReturn(List.of(user)); + + var lastFailure = new LoginAttempt(); + lastFailure.setAttemptedAt(Date.from(Instant.now().minus(COOLDOWN_DURATION).plusSeconds(60))); + when(loginAttemptService.findLastFailure(USERNAME)).thenReturn(lastFailure); + + lockoutCooldownJob.checkLockoutCooldown(); + + assertTrue(user.getAccountLocked(), "Account should remain locked until the cooldown period has elapsed"); + verify(userService, never()).save(user); + } + + @Test + public void testNoFailedLoginAttemptIsFound() throws UserManagementException { + when(authenticationProperties.getLockoutCooldownDuration()).thenReturn(COOLDOWN_DURATION); + when(userService.getUnlockableAccounts()).thenReturn(List.of(user)); + when(loginAttemptService.findLastFailure(USERNAME)).thenReturn(null); + + lockoutCooldownJob.checkLockoutCooldown(); + + assertTrue(user.getAccountLocked(), + "Account should remain locked when there is no failed login attempt to compare against"); + verify(userService, never()).save(user); + } + + @Test + public void testFailureToSaveIsHandledGracefully() throws UserManagementException { + when(authenticationProperties.getLockoutCooldownDuration()).thenReturn(COOLDOWN_DURATION); + when(userService.getUnlockableAccounts()).thenReturn(List.of(user)); + + var lastFailure = new LoginAttempt(); + lastFailure.setAttemptedAt(Date.from(Instant.now().minus(COOLDOWN_DURATION).minusSeconds(60))); + when(loginAttemptService.findLastFailure(USERNAME)).thenReturn(lastFailure); + when(userService.save(user)).thenThrow(new UserManagementException("Could not save user")); + + assertDoesNotThrow(() -> lockoutCooldownJob.checkLockoutCooldown(), + "UserManagementException from a failed save should be caught, not propagated"); + + verify(userService, times(1)).save(user); + } +} From bf0af0b1812b3bfd002860a488c5956398d0e42d Mon Sep 17 00:00:00 2001 From: Daniel Persson <17487347+saligiad@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:40:21 -0700 Subject: [PATCH 4/5] AUTHLIB-180 Conditional bean Use the `@ConditionalOnProperty` annotation to make `LockoutCooldownJob` a conditional bean, branching off of whether octri.authentication.lockout-cooldown.enabled` is set. With the feature flag in place, the code no longer uses `duration=null` to control behavior. Additionally, isolates properties in new `LockoutCooldownProperties` class. --- .../config/LockoutCooldownProperties.java | 91 +++++++++++++++++++ .../config/OctriAuthenticationProperties.java | 53 ----------- .../scheduled/LockoutCooldownJob.java | 26 +++--- .../scheduled/LockoutCooldownJobTest.java | 25 ++--- docs/CONFIGURATION_PROPERTIES.md | 3 + 5 files changed, 113 insertions(+), 85 deletions(-) create mode 100644 authentication_lib/src/main/java/org/octri/authentication/config/LockoutCooldownProperties.java diff --git a/authentication_lib/src/main/java/org/octri/authentication/config/LockoutCooldownProperties.java b/authentication_lib/src/main/java/org/octri/authentication/config/LockoutCooldownProperties.java new file mode 100644 index 0000000..79df996 --- /dev/null +++ b/authentication_lib/src/main/java/org/octri/authentication/config/LockoutCooldownProperties.java @@ -0,0 +1,91 @@ +package org.octri.authentication.config; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.convert.DurationUnit; + +/** + * Configuration properties to control automatic unlock of user accounts after cooldown period. + */ +@ConfigurationProperties(prefix = "octri.authentication.lockout-cooldown") +public class LockoutCooldownProperties { + + /** + * The default value of "octri.authentication.lockout-cooldown.duration" + */ + public static final Duration DEFAULT_COOLDOWN_DURATION = Duration.ofMinutes(30); + + /** + * Feature flag to control whether user accounts are automatically unlocked after a cooldown period. + */ + private Boolean enabled = false; + + /** + * Minimum time that must elapse between the most recent failed login and automatic account unlock. + * Defaults to 30 minutes. + */ + @DurationUnit(ChronoUnit.MINUTES) + private Duration duration = DEFAULT_COOLDOWN_DURATION; + + /** + * Schedule for cron task to check cooldown on locked accounts. Defaults to every 15 minutes. + */ + private String pollingSchedule = "0 */1 * * * *"; + + /** + * Set the feature flag that controls feature for automatic account unlock + * + * @return + */ + public Boolean getEnabled() { + return enabled; + } + + /** + * Get the feature flag that controls feature for automatic account unlock + * + * @param enabled + */ + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } + + /** + * Get the cooldown period for user lockouts due to failed login attempts + * + * @return + */ + public Duration getDuration() { + return duration; + } + + /** + * Set the cooldown period for user lockouts due to failed login attempts + * + * @param duration + */ + public void setDuration(Duration duration) { + this.duration = duration; + } + + /** + * Gets the cron schedule for checking the database for locked users + * + * @return + */ + public String getPollingSchedule() { + return pollingSchedule; + } + + /** + * Sets the cron schedule for checking the database for locked users + * + * @param pollingSchedule + */ + public void setPollingSchedule(String pollingSchedule) { + this.pollingSchedule = pollingSchedule; + } + +} diff --git a/authentication_lib/src/main/java/org/octri/authentication/config/OctriAuthenticationProperties.java b/authentication_lib/src/main/java/org/octri/authentication/config/OctriAuthenticationProperties.java index 056d850..38f945f 100644 --- a/authentication_lib/src/main/java/org/octri/authentication/config/OctriAuthenticationProperties.java +++ b/authentication_lib/src/main/java/org/octri/authentication/config/OctriAuthenticationProperties.java @@ -67,11 +67,6 @@ public static enum RoleStyle { */ public static final Duration DEFAULT_PASSWORD_TOKEN_DURATION = Duration.ofMinutes(30); - /** - * The default value of "octri.authentication.lockout-cooldown-duration" - */ - public static final Duration DEFAULT_LOCKOUT_COOLDOWN_DURATION = Duration.ofMinutes(30); - /** * Whether LDAP authentication should be enabled. Defaults to false. */ @@ -93,18 +88,6 @@ public static enum RoleStyle { */ private Integer maxLoginAttempts = 7; - /** - * Minimum time (in minutes) that must elapse between the most recent failed login and automatic account unlock. - * Defaults to 30 minutes. - */ - @DurationUnit(ChronoUnit.MINUTES) - private Duration lockoutCooldownDuration = DEFAULT_LOCKOUT_COOLDOWN_DURATION; - - /** - * Schedule for cron task to check cooldown on locked accounts. Defaults to every 15 minutes. - */ - private String lockoutPollingSchedule = "0 */1 * * * *"; - /** * Length of time (in days) that table-based credentials are valid. After this period has elapsed, users will be * required to change their password. Defaults to 180 days. @@ -237,42 +220,6 @@ public void setMaxLoginAttempts(Integer maxLoginAttempts) { this.maxLoginAttempts = maxLoginAttempts; } - /** - * Get the cooldown period for user lockouts due to failed login attempts - * - * @return - */ - public Duration getLockoutCooldownDuration() { - return lockoutCooldownDuration; - } - - /** - * Set the cooldown period for user lockouts due to failed login attempts - * - * @param lockoutCooldownDuration - */ - public void setLockoutCooldownDuration(Duration lockoutCooldownDuration) { - this.lockoutCooldownDuration = lockoutCooldownDuration; - } - - /** - * Gets the cron schedule for checking the database for locked users - * - * @return - */ - public String getLockoutPollingSchedule() { - return lockoutPollingSchedule; - } - - /** - * Sets the cron schedule for checking the database for locked users - * - * @param lockoutPollingSchedule - */ - public void setLockoutPollingSchedule(String lockoutPollingSchedule) { - this.lockoutPollingSchedule = lockoutPollingSchedule; - } - /** * Gets the length of time (in days) that table-based credentials are valid. After this period has elapsed, users * will be required to change their password. diff --git a/authentication_lib/src/main/java/org/octri/authentication/server/security/scheduled/LockoutCooldownJob.java b/authentication_lib/src/main/java/org/octri/authentication/server/security/scheduled/LockoutCooldownJob.java index 25e4eff..ff9369a 100644 --- a/authentication_lib/src/main/java/org/octri/authentication/server/security/scheduled/LockoutCooldownJob.java +++ b/authentication_lib/src/main/java/org/octri/authentication/server/security/scheduled/LockoutCooldownJob.java @@ -3,34 +3,38 @@ import java.time.Instant; import java.util.Date; -import org.octri.authentication.config.OctriAuthenticationProperties; +import org.octri.authentication.config.LockoutCooldownProperties; import org.octri.authentication.server.security.exception.UserManagementException; import org.octri.authentication.server.security.service.LoginAttemptService; import org.octri.authentication.server.security.service.UserService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @Component +@EnableConfigurationProperties(LockoutCooldownProperties.class) +@ConditionalOnProperty(value = "octri.authentication.lockout-cooldown.enabled", havingValue = "true", matchIfMissing = false) public class LockoutCooldownJob { private static final Logger log = LoggerFactory.getLogger(LockoutCooldownJob.class); - private final OctriAuthenticationProperties authenticationProperties; + private final LockoutCooldownProperties lockoutCooldownProperties; private final LoginAttemptService loginAttemptService; final private UserService userService; /** * Constructor. * - * @param authenticationProperties + * @param lockoutCooldownProperties * @param loginAttemptService * @param userService */ - public LockoutCooldownJob(OctriAuthenticationProperties authenticationProperties, + public LockoutCooldownJob(LockoutCooldownProperties lockoutCooldownProperties, LoginAttemptService loginAttemptService, UserService userService) { - this.authenticationProperties = authenticationProperties; + this.lockoutCooldownProperties = lockoutCooldownProperties; this.loginAttemptService = loginAttemptService; this.userService = userService; } @@ -40,18 +44,12 @@ public LockoutCooldownJob(OctriAuthenticationProperties authenticationProperties * * Leverages {@link UserService} to poll the database for locked accounts, and checks the most recent failure from * {@link LoginAttemptService} to unlock the account if the configured cooldown period has elapsed. - * - * If octri.authentication.lockout-cooldown-period is configured as null, accounts are left locked. */ - @Scheduled(cron = "${octri.authentication.lockout-polling-schedule:0 */1 * * * *}") + @Scheduled(cron = "${octri.authentication.lockout-cooldown.polling-schedule:0 */1 * * * *}") public void checkLockoutCooldown() { - log.info("Running checkLockoutCooldown job"); - var cooldownDuration = authenticationProperties.getLockoutCooldownDuration(); - if (cooldownDuration == null) { - return; - } + var cooldownThreshold = Date.from(Instant.now() + .minus(lockoutCooldownProperties.getDuration())); - var cooldownThreshold = Date.from(Instant.now().minus(cooldownDuration)); userService.getUnlockableAccounts().stream().forEach(user -> { var lastFailure = loginAttemptService.findLastFailure(user.getUsername()); if (lastFailure == null) { diff --git a/authentication_lib/src/test/java/org/octri/authentication/server/security/scheduled/LockoutCooldownJobTest.java b/authentication_lib/src/test/java/org/octri/authentication/server/security/scheduled/LockoutCooldownJobTest.java index dde5e47..b5f9480 100644 --- a/authentication_lib/src/test/java/org/octri/authentication/server/security/scheduled/LockoutCooldownJobTest.java +++ b/authentication_lib/src/test/java/org/octri/authentication/server/security/scheduled/LockoutCooldownJobTest.java @@ -3,7 +3,6 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -19,7 +18,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.octri.authentication.config.OctriAuthenticationProperties; +import org.octri.authentication.config.LockoutCooldownProperties; import org.octri.authentication.server.security.entity.LoginAttempt; import org.octri.authentication.server.security.entity.User; import org.octri.authentication.server.security.exception.UserManagementException; @@ -33,7 +32,7 @@ public class LockoutCooldownJobTest { private static final Duration COOLDOWN_DURATION = Duration.ofMinutes(30); @Mock - private OctriAuthenticationProperties authenticationProperties; + private LockoutCooldownProperties lockoutCooldownProperties; @Mock private LoginAttemptService loginAttemptService; @@ -47,26 +46,16 @@ public class LockoutCooldownJobTest { @BeforeEach public void beforeEach() { - lockoutCooldownJob = new LockoutCooldownJob(authenticationProperties, loginAttemptService, userService); + lockoutCooldownJob = new LockoutCooldownJob(lockoutCooldownProperties, loginAttemptService, userService); user = new User(); user.setUsername(USERNAME); user.setAccountLocked(true); } - @Test - public void testCooldownDurationIsNull() { - when(authenticationProperties.getLockoutCooldownDuration()).thenReturn(null); - - lockoutCooldownJob.checkLockoutCooldown(); - - verify(userService, never()).getUnlockableAccounts(); - verify(loginAttemptService, never()).findLastFailure(anyString()); - } - @Test public void testCooldownHasElapsed() throws UserManagementException { - when(authenticationProperties.getLockoutCooldownDuration()).thenReturn(COOLDOWN_DURATION); + when(lockoutCooldownProperties.getDuration()).thenReturn(COOLDOWN_DURATION); when(userService.getUnlockableAccounts()).thenReturn(List.of(user)); var lastFailure = new LoginAttempt(); @@ -81,7 +70,7 @@ public void testCooldownHasElapsed() throws UserManagementException { @Test public void testCooldownHasNotElapsed() throws UserManagementException { - when(authenticationProperties.getLockoutCooldownDuration()).thenReturn(COOLDOWN_DURATION); + when(lockoutCooldownProperties.getDuration()).thenReturn(COOLDOWN_DURATION); when(userService.getUnlockableAccounts()).thenReturn(List.of(user)); var lastFailure = new LoginAttempt(); @@ -96,7 +85,7 @@ public void testCooldownHasNotElapsed() throws UserManagementException { @Test public void testNoFailedLoginAttemptIsFound() throws UserManagementException { - when(authenticationProperties.getLockoutCooldownDuration()).thenReturn(COOLDOWN_DURATION); + when(lockoutCooldownProperties.getDuration()).thenReturn(COOLDOWN_DURATION); when(userService.getUnlockableAccounts()).thenReturn(List.of(user)); when(loginAttemptService.findLastFailure(USERNAME)).thenReturn(null); @@ -109,7 +98,7 @@ public void testNoFailedLoginAttemptIsFound() throws UserManagementException { @Test public void testFailureToSaveIsHandledGracefully() throws UserManagementException { - when(authenticationProperties.getLockoutCooldownDuration()).thenReturn(COOLDOWN_DURATION); + when(lockoutCooldownProperties.getDuration()).thenReturn(COOLDOWN_DURATION); when(userService.getUnlockableAccounts()).thenReturn(List.of(user)); var lastFailure = new LoginAttempt(); diff --git a/docs/CONFIGURATION_PROPERTIES.md b/docs/CONFIGURATION_PROPERTIES.md index c94c9d4..ea60437 100644 --- a/docs/CONFIGURATION_PROPERTIES.md +++ b/docs/CONFIGURATION_PROPERTIES.md @@ -22,6 +22,9 @@ At least one of the authentication methods must be enabled (see `octri.authentic | octri.authentication.role-style | OCTRI_AUTHENTICATION_ROLE_STYLE | string | multiple | Role style. Determines how the role selector on the user form is rendered. Valid options are `single`, `multiple`, or `custom`. | | octri.authentication.username-style | OCTRI_AUTHENTICATION_USERNAMESTYLE | string | plain | Username style. Valid options are `plain`, `email`, or `mixed`. | | octri.authentication.enable-password-visibility-toggle | OCTRI_AUTHENTICATION_ENABLE_PASSWORD_VISIBILITY_TOGGLE | boolean | true | Whether to enable the password visibility toggle button. | +| octri.authentication.lockout-cooldown.enabled | OCTRI_AUTHENTICATION_LOCKOUTCOOLDOWN_ENABLED | boolean | false | Whether to enable automatic account unlocking with configurable cooldown. | +| octri.authentication.lockout-cooldown.duration | OCTRI_AUTHENTICATION_LOCKOUTCOOLDOWN_DURATION | duration | 30m | Minimum lockout duration before account is unlocked. | +| octri.authentication.lockout-cooldown.pollingSchedule | OCTRI_AUTHENTICATION_LOCKOUTCOOLDOWN_POLLINGSCHEDULE | string | "0 */1 * * * *" | Cron schedule to unlock eligible accounts. | ## Route Configuration From 9ea0b9aa8589a9054a8eee44c7c0105dfc28b591 Mon Sep 17 00:00:00 2001 From: Daniel Persson <17487347+saligiad@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:32:41 -0700 Subject: [PATCH 5/5] AUTHLIB-180 Update docs Update comment to reflect accurate polling schedule default, and state the default schedule behavior in plain English in CONFIGURATION_PROPERTIES.md --- .../octri/authentication/config/LockoutCooldownProperties.java | 2 +- docs/CONFIGURATION_PROPERTIES.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/authentication_lib/src/main/java/org/octri/authentication/config/LockoutCooldownProperties.java b/authentication_lib/src/main/java/org/octri/authentication/config/LockoutCooldownProperties.java index 79df996..6192854 100644 --- a/authentication_lib/src/main/java/org/octri/authentication/config/LockoutCooldownProperties.java +++ b/authentication_lib/src/main/java/org/octri/authentication/config/LockoutCooldownProperties.java @@ -30,7 +30,7 @@ public class LockoutCooldownProperties { private Duration duration = DEFAULT_COOLDOWN_DURATION; /** - * Schedule for cron task to check cooldown on locked accounts. Defaults to every 15 minutes. + * Schedule for cron task to check cooldown on locked accounts. Defaults to every minute. */ private String pollingSchedule = "0 */1 * * * *"; diff --git a/docs/CONFIGURATION_PROPERTIES.md b/docs/CONFIGURATION_PROPERTIES.md index ea60437..246df1e 100644 --- a/docs/CONFIGURATION_PROPERTIES.md +++ b/docs/CONFIGURATION_PROPERTIES.md @@ -24,7 +24,7 @@ At least one of the authentication methods must be enabled (see `octri.authentic | octri.authentication.enable-password-visibility-toggle | OCTRI_AUTHENTICATION_ENABLE_PASSWORD_VISIBILITY_TOGGLE | boolean | true | Whether to enable the password visibility toggle button. | | octri.authentication.lockout-cooldown.enabled | OCTRI_AUTHENTICATION_LOCKOUTCOOLDOWN_ENABLED | boolean | false | Whether to enable automatic account unlocking with configurable cooldown. | | octri.authentication.lockout-cooldown.duration | OCTRI_AUTHENTICATION_LOCKOUTCOOLDOWN_DURATION | duration | 30m | Minimum lockout duration before account is unlocked. | -| octri.authentication.lockout-cooldown.pollingSchedule | OCTRI_AUTHENTICATION_LOCKOUTCOOLDOWN_POLLINGSCHEDULE | string | "0 */1 * * * *" | Cron schedule to unlock eligible accounts. | +| octri.authentication.lockout-cooldown.pollingSchedule | OCTRI_AUTHENTICATION_LOCKOUTCOOLDOWN_POLLINGSCHEDULE | string | "0 */1 * * * *" | Cron schedule to unlock eligible accounts. Defaults to every minute. | ## Route Configuration