Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@
public interface LoginAttemptRepository extends JpaRepository<LoginAttempt, Long> {

/**
* 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -48,4 +50,13 @@ public interface UserRepository extends JpaRepository<User, Long> {
*/
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<User> findByAccountLockedAndEnabled(Boolean accountLocked, Boolean enabled);

}
Original file line number Diff line number Diff line change
@@ -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);
}
}
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<User> 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
3 changes: 3 additions & 0 deletions docs/CONFIGURATION_PROPERTIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading