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/LockoutCooldownProperties.java b/authentication_lib/src/main/java/org/octri/authentication/config/LockoutCooldownProperties.java new file mode 100644 index 0000000..6192854 --- /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 minute. + */ + 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/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..ff9369a --- /dev/null +++ b/authentication_lib/src/main/java/org/octri/authentication/server/security/scheduled/LockoutCooldownJob.java @@ -0,0 +1,67 @@ +package org.octri.authentication.server.security.scheduled; + +import java.time.Instant; +import java.util.Date; + +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 LockoutCooldownProperties lockoutCooldownProperties; + private final LoginAttemptService loginAttemptService; + final private UserService userService; + + /** + * Constructor. + * + * @param lockoutCooldownProperties + * @param loginAttemptService + * @param userService + */ + public LockoutCooldownJob(LockoutCooldownProperties lockoutCooldownProperties, + LoginAttemptService loginAttemptService, UserService userService) { + this.lockoutCooldownProperties = lockoutCooldownProperties; + 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. + */ + @Scheduled(cron = "${octri.authentication.lockout-cooldown.polling-schedule:0 */1 * * * *}") + public void checkLockoutCooldown() { + var cooldownThreshold = Date.from(Instant.now() + .minus(lockoutCooldownProperties.getDuration())); + + 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. 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..b5f9480 --- /dev/null +++ b/authentication_lib/src/test/java/org/octri/authentication/server/security/scheduled/LockoutCooldownJobTest.java @@ -0,0 +1,114 @@ +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.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.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; +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 LockoutCooldownProperties lockoutCooldownProperties; + + @Mock + private LoginAttemptService loginAttemptService; + + @Mock + private UserService userService; + + private LockoutCooldownJob lockoutCooldownJob; + + private User user; + + @BeforeEach + public void beforeEach() { + lockoutCooldownJob = new LockoutCooldownJob(lockoutCooldownProperties, loginAttemptService, userService); + + user = new User(); + user.setUsername(USERNAME); + user.setAccountLocked(true); + } + + @Test + public void testCooldownHasElapsed() throws UserManagementException { + when(lockoutCooldownProperties.getDuration()).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(lockoutCooldownProperties.getDuration()).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(lockoutCooldownProperties.getDuration()).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(lockoutCooldownProperties.getDuration()).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); + } +} diff --git a/docs/CONFIGURATION_PROPERTIES.md b/docs/CONFIGURATION_PROPERTIES.md index c94c9d4..246df1e 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. Defaults to every minute. | ## Route Configuration