Unlock account (#5984)

This commit is contained in:
Anthony Stirling
2026-03-30 16:07:57 +01:00
committed by GitHub
parent 1e97a32d4b
commit 82a3b8c770
10 changed files with 335 additions and 35 deletions

View File

@@ -47,6 +47,7 @@ import stirling.software.proprietary.security.model.dto.AdminUserSummary;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.DatabaseService;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.MfaService;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
@@ -71,6 +72,7 @@ public class ProprietaryUIDataController {
private final UserLicenseSettingsService licenseSettingsService;
private final PersistentAuditEventRepository auditRepository;
private final MfaService mfaService;
private final LoginAttemptService loginAttemptService;
public ProprietaryUIDataController(
ApplicationProperties applicationProperties,
@@ -84,7 +86,8 @@ public class ProprietaryUIDataController {
@Qualifier("runningEE") boolean runningEE,
UserLicenseSettingsService licenseSettingsService,
PersistentAuditEventRepository auditRepository,
MfaService mfaService) {
MfaService mfaService,
LoginAttemptService loginAttemptService) {
this.applicationProperties = applicationProperties;
this.auditConfig = auditConfig;
this.sessionPersistentRegistry = sessionPersistentRegistry;
@@ -97,6 +100,7 @@ public class ProprietaryUIDataController {
this.licenseSettingsService = licenseSettingsService;
this.auditRepository = auditRepository;
this.mfaService = mfaService;
this.loginAttemptService = loginAttemptService;
}
/**
@@ -387,6 +391,7 @@ public class ProprietaryUIDataController {
data.setPremiumEnabled(premiumEnabled);
data.setMailEnabled(applicationProperties.getMail().isEnabled());
data.setUserSettings(userSettings);
data.setLockedUsers(loginAttemptService.getAllBlockedUsers());
return ResponseEntity.ok(data);
}
@@ -605,6 +610,7 @@ public class ProprietaryUIDataController {
private boolean premiumEnabled;
private boolean mailEnabled;
private Map<String, Map<String, String>> userSettings;
private List<String> lockedUsers;
}
@Data

View File

@@ -48,6 +48,7 @@ import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.EmailService;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.SaveUserRequest;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
@@ -67,6 +68,7 @@ public class UserController {
private final UserRepository userRepository;
private final Optional<EmailService> emailService;
private final UserLicenseSettingsService licenseSettingsService;
private final LoginAttemptService loginAttemptService;
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/register")
@@ -775,6 +777,14 @@ public class UserController {
Map.of("message", "User " + (enabled ? "enabled" : "disabled") + " successfully"));
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/admin/unlockUser/{username}")
@Audited(type = AuditEventType.SETTINGS_CHANGED, level = AuditLevel.BASIC)
public ResponseEntity<?> unlockUser(@PathVariable("username") String username) {
loginAttemptService.resetAttempts(username);
return ResponseEntity.ok(Map.of("message", "User account unlocked successfully"));
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/admin/deleteUser/{username}")
@Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC)

View File

@@ -1,8 +1,11 @@
package stirling.software.proprietary.security.service;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
@@ -79,6 +82,28 @@ public class LoginAttemptService {
return attemptCounter.getAttemptCount() >= MAX_ATTEMPT;
}
public void resetAttempts(String key) {
if (key == null || key.trim().isEmpty()) {
return;
}
String normalizedKey = key.toLowerCase(Locale.ROOT);
attemptsCache.remove(normalizedKey);
}
public boolean isBlockingEnabled() {
return isBlockedEnabled;
}
public List<String> getAllBlockedUsers() {
if (!isBlockedEnabled) {
return List.of();
}
return attemptsCache.entrySet().stream()
.filter(entry -> entry.getValue().getAttemptCount() >= MAX_ATTEMPT)
.map(Map.Entry::getKey)
.collect(Collectors.toList());
}
public int getRemainingAttempts(String key) {
if (!isBlockedEnabled || key == null || key.trim().isEmpty()) {
// Arbitrarily high number if tracking is disabled

View File

@@ -29,6 +29,7 @@ import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.DatabaseService;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.MfaService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
import stirling.software.proprietary.service.UserLicenseSettingsService;
@@ -47,6 +48,7 @@ class ProprietaryUIDataControllerTest {
@Mock private UserLicenseSettingsService licenseSettingsService;
@Mock private PersistentAuditEventRepository auditRepository;
@Mock private MfaService mfaService;
@Mock private LoginAttemptService loginAttemptService;
private ApplicationProperties applicationProperties;
private AuditConfigurationProperties auditConfig;
@@ -79,7 +81,8 @@ class ProprietaryUIDataControllerTest {
false,
licenseSettingsService,
auditRepository,
mfaService);
mfaService,
loginAttemptService);
}
@Test

View File

@@ -28,6 +28,7 @@ import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.EmailService;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
@@ -47,6 +48,7 @@ class UserControllerTest {
@Mock private UserRepository userRepository;
@Mock private EmailService emailService;
@Mock private UserLicenseSettingsService licenseSettingsService;
@Mock private LoginAttemptService loginAttemptService;
private ApplicationProperties applicationProperties;
private MockMvc mockMvc;
@@ -65,7 +67,8 @@ class UserControllerTest {
teamRepository,
userRepository,
Optional.of(emailService),
licenseSettingsService);
licenseSettingsService,
loginAttemptService);
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
}
@@ -138,4 +141,13 @@ class UserControllerTest {
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.error").value("User not found."));
}
@Test
void unlockUserCallsResetAttemptsAndReturnsOk() throws Exception {
mockMvc.perform(post("/api/v1/user/admin/unlockUser/lockeduser"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("User account unlocked successfully"));
verify(loginAttemptService).resetAttempts("lockeduser");
}
}

View File

@@ -6,6 +6,7 @@ import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.ConcurrentHashMap;
@@ -236,4 +237,145 @@ class LoginAttemptServiceTest {
// If you later clamp to 0, update this assertion accordingly and add a new test.
assertEquals(expected, actual, "Current behavior returns negative values without clamping");
}
@Test
@DisplayName("resetAttempts(): removes entry from cache for given key")
void resetAttempts_shouldRemoveEntryFromCache() throws Exception {
Object svc = constructLoginAttemptService();
setPrivateBoolean(svc, "isBlockedEnabled", true);
var attemptsCache = new ConcurrentHashMap<String, AttemptCounter>();
AttemptCounter counter = new AttemptCounter();
Field ac = AttemptCounter.class.getDeclaredField("attemptCount");
ac.setAccessible(true);
ac.setInt(counter, 5);
attemptsCache.put("blockeduser", counter);
setPrivate(svc, "attemptsCache", attemptsCache);
var method = svc.getClass().getMethod("resetAttempts", String.class);
method.invoke(svc, "BlockedUser"); // case-insensitive
assertFalse(
attemptsCache.containsKey("blockeduser"),
"resetAttempts should remove the user's entry from the cache");
}
@Test
@DisplayName("resetAttempts(): does nothing for null or blank key")
void resetAttempts_shouldDoNothingForNullOrBlankKey() throws Exception {
Object svc = constructLoginAttemptService();
setPrivateBoolean(svc, "isBlockedEnabled", true);
var attemptsCache = new ConcurrentHashMap<String, AttemptCounter>();
attemptsCache.put("existing", new AttemptCounter());
setPrivate(svc, "attemptsCache", attemptsCache);
var method = svc.getClass().getMethod("resetAttempts", String.class);
method.invoke(svc, (Object) null);
method.invoke(svc, " ");
assertEquals(1, attemptsCache.size(), "Null or blank key should not modify the cache");
}
@Test
@DisplayName("isBlockingEnabled(): returns true when blocking is enabled")
void isBlockingEnabled_shouldReturnTrueWhenEnabled() throws Exception {
Object svc = constructLoginAttemptService();
setPrivateBoolean(svc, "isBlockedEnabled", true);
var method = svc.getClass().getMethod("isBlockingEnabled");
boolean result = (Boolean) method.invoke(svc);
assertTrue(result, "isBlockingEnabled should return true when isBlockedEnabled is true");
}
@Test
@DisplayName("isBlockingEnabled(): returns false when blocking is disabled")
void isBlockingEnabled_shouldReturnFalseWhenDisabled() throws Exception {
Object svc = constructLoginAttemptService();
setPrivateBoolean(svc, "isBlockedEnabled", false);
var method = svc.getClass().getMethod("isBlockingEnabled");
boolean result = (Boolean) method.invoke(svc);
assertFalse(result, "isBlockingEnabled should return false when isBlockedEnabled is false");
}
@Test
@DisplayName("getAllBlockedUsers(): returns empty list when blocking is disabled")
void getAllBlockedUsers_shouldReturnEmptyWhenDisabled() throws Exception {
Object svc = constructLoginAttemptService();
setPrivateBoolean(svc, "isBlockedEnabled", false);
setPrivate(svc, "attemptsCache", new ConcurrentHashMap<String, AttemptCounter>());
var method = svc.getClass().getMethod("getAllBlockedUsers");
@SuppressWarnings("unchecked")
List<String> result = (List<String>) method.invoke(svc);
assertTrue(
result.isEmpty(),
"getAllBlockedUsers should return empty list when blocking is disabled");
}
@Test
@DisplayName("getAllBlockedUsers(): returns only users at or above MAX_ATTEMPT")
void getAllBlockedUsers_shouldReturnOnlyBlockedUsers() throws Exception {
Object svc = constructLoginAttemptService();
setPrivateBoolean(svc, "isBlockedEnabled", true);
setPrivate(svc, "MAX_ATTEMPT", 3);
var attemptsCache = new ConcurrentHashMap<String, AttemptCounter>();
Field ac = AttemptCounter.class.getDeclaredField("attemptCount");
ac.setAccessible(true);
// User with exactly MAX_ATTEMPT attempts (blocked)
AttemptCounter blocked1 = new AttemptCounter();
ac.setInt(blocked1, 3);
attemptsCache.put("blocked1", blocked1);
// User with more than MAX_ATTEMPT attempts (blocked)
AttemptCounter blocked2 = new AttemptCounter();
ac.setInt(blocked2, 5);
attemptsCache.put("blocked2", blocked2);
// User with fewer than MAX_ATTEMPT attempts (not blocked)
AttemptCounter notBlocked = new AttemptCounter();
ac.setInt(notBlocked, 2);
attemptsCache.put("safe", notBlocked);
setPrivate(svc, "attemptsCache", attemptsCache);
var method = svc.getClass().getMethod("getAllBlockedUsers");
@SuppressWarnings("unchecked")
List<String> result = (List<String>) method.invoke(svc);
assertEquals(2, result.size(), "Should return exactly 2 blocked users");
assertTrue(result.contains("blocked1"), "Should contain blocked1");
assertTrue(result.contains("blocked2"), "Should contain blocked2");
assertFalse(result.contains("safe"), "Should not contain safe user");
}
@Test
@DisplayName("getAllBlockedUsers(): returns empty list when no users are blocked")
void getAllBlockedUsers_shouldReturnEmptyWhenNoUsersBlocked() throws Exception {
Object svc = constructLoginAttemptService();
setPrivateBoolean(svc, "isBlockedEnabled", true);
setPrivate(svc, "MAX_ATTEMPT", 3);
var attemptsCache = new ConcurrentHashMap<String, AttemptCounter>();
Field ac = AttemptCounter.class.getDeclaredField("attemptCount");
ac.setAccessible(true);
AttemptCounter notBlocked = new AttemptCounter();
ac.setInt(notBlocked, 1);
attemptsCache.put("user1", notBlocked);
setPrivate(svc, "attemptsCache", attemptsCache);
var method = svc.getClass().getMethod("getAllBlockedUsers");
@SuppressWarnings("unchecked")
List<String> result = (List<String>) method.invoke(svc);
assertTrue(result.isEmpty(), "Should return empty list when no users exceed MAX_ATTEMPT");
}
}