This is an automated email from the ASF dual-hosted git repository. ramackri pushed a commit to branch RANGER-5690-account-lockout-master in repository https://gitbox.apache.org/repos/asf/ranger.git
commit 803a491539659019b427f98544a8d2a76ea7444b Author: ramk <[email protected]> AuthorDate: Wed Jul 22 18:54:38 2026 +0530 RANGER-5690: Add account-associated lockout for UnixAuth distributed guessing Track failed login attempts per username in addition to per-source-IP throttling. Account lockout uses separate configurable threshold, window, and lockout duration. Defaults match IP policy; account lockout can be disabled via config. --- .../conf.dist/ranger-ugsync-default.xml | 16 +++ .../ranger/authentication/LoginAttemptTracker.java | 146 +++++++++++++++------ .../ranger/authentication/PasswordValidator.java | 35 +++-- .../authentication/UnixAuthenticationService.java | 12 +- .../authentication/LoginAttemptTrackerTest.java | 33 +++++ .../authentication/TestPasswordValidator.java | 38 ++++-- 6 files changed, 213 insertions(+), 67 deletions(-) diff --git a/unixauthservice/conf.dist/ranger-ugsync-default.xml b/unixauthservice/conf.dist/ranger-ugsync-default.xml index 2e48da7e6..18004ba7d 100644 --- a/unixauthservice/conf.dist/ranger-ugsync-default.xml +++ b/unixauthservice/conf.dist/ranger-ugsync-default.xml @@ -49,6 +49,22 @@ <name>ranger.usersync.unixauth.socket.timeout.ms</name> <value>10000</value> </property> + <property> + <name>ranger.usersync.unixauth.account.lockout.enabled</name> + <value>true</value> + </property> + <property> + <name>ranger.usersync.unixauth.account.max.failed.attempts</name> + <value>5</value> + </property> + <property> + <name>ranger.usersync.unixauth.account.attempt.window.ms</name> + <value>60000</value> + </property> + <property> + <name>ranger.usersync.unixauth.account.lockout.duration.ms</name> + <value>30000</value> + </property> <property> <name>ranger.usersync.passwordvalidator.path</name> <value>./native/credValidator.uexe</value> diff --git a/unixauthservice/src/main/java/org/apache/ranger/authentication/LoginAttemptTracker.java b/unixauthservice/src/main/java/org/apache/ranger/authentication/LoginAttemptTracker.java index b448a7b19..bc1284988 100644 --- a/unixauthservice/src/main/java/org/apache/ranger/authentication/LoginAttemptTracker.java +++ b/unixauthservice/src/main/java/org/apache/ranger/authentication/LoginAttemptTracker.java @@ -27,61 +27,121 @@ public class LoginAttemptTracker { private static final long CLEANUP_INTERVAL_MS = TimeUnit.MINUTES.toMillis(5); - private final ConcurrentHashMap<String, Record> attemptsByIp = new ConcurrentHashMap<>(); - - private final int maxFailures; - private final long windowMs; - private final long lockoutMs; - private volatile long lastCleanupTime; - - public LoginAttemptTracker(int maxFailures, long windowMs, long lockoutMs) { - this.maxFailures = maxFailures; - this.windowMs = windowMs; - this.lockoutMs = lockoutMs; - this.lastCleanupTime = System.currentTimeMillis(); + + private final AttemptCounter ipAttempts; + private final AttemptCounter accountAttempts; + private final boolean accountLockoutEnabled; + + public LoginAttemptTracker(int maxIpFailures, long ipWindowMs, long ipLockoutMs) { + this(maxIpFailures, ipWindowMs, ipLockoutMs, true, maxIpFailures, ipWindowMs, ipLockoutMs); + } + + public LoginAttemptTracker(int maxIpFailures, long ipWindowMs, long ipLockoutMs, + boolean accountLockoutEnabled, + int maxAccountFailures, long accountWindowMs, long accountLockoutMs) { + this.ipAttempts = new AttemptCounter(maxIpFailures, ipWindowMs, ipLockoutMs); + this.accountAttempts = new AttemptCounter(maxAccountFailures, accountWindowMs, accountLockoutMs); + this.accountLockoutEnabled = accountLockoutEnabled; } public boolean isBlocked(String sourceIp) { - Record record = attemptsByIp.get(sourceIp); - if (record == null) { - return false; - } - return record.lockedUntil > System.currentTimeMillis(); + return ipAttempts.isBlocked(sourceIp); + } + + public boolean isAccountBlocked(String account) { + return accountLockoutEnabled && accountAttempts.isBlocked(account); + } + + public boolean isBlocked(String sourceIp, String account) { + return isBlocked(sourceIp) || isAccountBlocked(account); } public void recordFailure(String sourceIp) { - maybeCleanup(); - long now = System.currentTimeMillis(); - Record record = attemptsByIp.computeIfAbsent(sourceIp, k -> new Record(now)); - synchronized (record) { - if (now - record.windowStart > windowMs) { - record.windowStart = now; - record.failCount.set(0); - } - record.lastActivity = now; - int count = record.failCount.incrementAndGet(); - if (count >= maxFailures) { - record.lockedUntil = now + lockoutMs; - } + recordFailure(sourceIp, null); + } + + public void recordFailure(String sourceIp, String account) { + ipAttempts.recordFailure(sourceIp); + if (accountLockoutEnabled) { + accountAttempts.recordFailure(account); } } public void recordSuccess(String sourceIp) { - attemptsByIp.remove(sourceIp); + recordSuccess(sourceIp, null); + } + + public void recordSuccess(String sourceIp, String account) { + ipAttempts.recordSuccess(sourceIp); + if (accountLockoutEnabled) { + accountAttempts.recordSuccess(account); + } } - private void maybeCleanup() { - long now = System.currentTimeMillis(); - if (now - lastCleanupTime < CLEANUP_INTERVAL_MS) { - return; + private static final class AttemptCounter { + private final ConcurrentHashMap<String, Record> attempts = new ConcurrentHashMap<>(); + private final int maxFailures; + private final long windowMs; + private final long lockoutMs; + private volatile long lastCleanupTime; + + AttemptCounter(int maxFailures, long windowMs, long lockoutMs) { + this.maxFailures = maxFailures; + this.windowMs = windowMs; + this.lockoutMs = lockoutMs; + } + + boolean isBlocked(String key) { + if (key == null || key.isEmpty()) { + return false; + } + Record record = attempts.get(key); + if (record == null) { + return false; + } + return record.lockedUntil > System.currentTimeMillis(); } - lastCleanupTime = now; - long cutoff = now - windowMs; - Iterator<Map.Entry<String, Record>> it = attemptsByIp.entrySet().iterator(); - while (it.hasNext()) { - Record record = it.next().getValue(); - if (record.lastActivity < cutoff && record.lockedUntil <= now) { - it.remove(); + + void recordFailure(String key) { + if (key == null || key.isEmpty()) { + return; + } + maybeCleanup(); + long now = System.currentTimeMillis(); + Record record = attempts.computeIfAbsent(key, k -> new Record(now)); + synchronized (record) { + if (now - record.windowStart > windowMs) { + record.windowStart = now; + record.failCount.set(0); + } + record.lastActivity = now; + int count = record.failCount.incrementAndGet(); + if (count >= maxFailures) { + record.lockedUntil = now + lockoutMs; + } + } + } + + void recordSuccess(String key) { + if (key == null || key.isEmpty()) { + return; + } + attempts.remove(key); + } + + private void maybeCleanup() { + long now = System.currentTimeMillis(); + if (now - lastCleanupTime < CLEANUP_INTERVAL_MS) { + return; + } + lastCleanupTime = now; + long cutoff = now - windowMs; + Iterator<Map.Entry<String, Record>> it = attempts.entrySet().iterator(); + while (it.hasNext()) { + Record record = it.next().getValue(); + if (record.lastActivity < cutoff && record.lockedUntil <= now) { + it.remove(); + } } } } @@ -93,7 +153,7 @@ private static final class Record { volatile long lockedUntil; Record(long now) { - this.windowStart = now; + this.windowStart = now; this.lastActivity = now; } } diff --git a/unixauthservice/src/main/java/org/apache/ranger/authentication/PasswordValidator.java b/unixauthservice/src/main/java/org/apache/ranger/authentication/PasswordValidator.java index 0416ca644..c3c80661f 100644 --- a/unixauthservice/src/main/java/org/apache/ranger/authentication/PasswordValidator.java +++ b/unixauthservice/src/main/java/org/apache/ranger/authentication/PasswordValidator.java @@ -83,29 +83,24 @@ public void run() { reader = new BufferedReader(new InputStreamReader(client.getInputStream())); writer = new PrintWriter(new OutputStreamWriter(client.getOutputStream())); String request = reader.readLine(); - if (loginAttemptTracker.isBlocked(remoteIp)) { - LOG.warn("Rejected UnixAuth attempt from [{}] - source IP is temporarily locked out due to repeated failures.", remoteIp); + userName = parseUserName(request); + + if (loginAttemptTracker.isBlocked(remoteIp, userName)) { + LOG.warn("Rejected UnixAuth attempt from [{}] for user [{}] - temporarily locked out due to repeated failures.", remoteIp, userName); writer.println(LOCKED_OUT_RESPONSE); writer.flush(); return; } if (request == null) { - loginAttemptTracker.recordFailure(remoteIp); + loginAttemptTracker.recordFailure(remoteIp, userName); LOG.warn("Rejected UnixAuth attempt from [{}] - connection closed before sending any data.", remoteIp); writer.println(GENERIC_FAILURE_RESPONSE); writer.flush(); return; } - if (request.startsWith("LOGIN:")) { - String line = request.substring(6).trim(); - int passwordAt = line.indexOf(' '); - if (passwordAt != -1) { - userName = line.substring(0, passwordAt).trim(); - } - } if (validatorProgram == null) { - loginAttemptTracker.recordFailure(remoteIp); + loginAttemptTracker.recordFailure(remoteIp, userName); LOG.error("Response [{}] for user: {} from [{}] as ValidatorProgram is not defined in configuration.", GENERIC_FAILURE_RESPONSE, userName, remoteIp); writer.println(GENERIC_FAILURE_RESPONSE); writer.flush(); @@ -126,14 +121,14 @@ public void run() { String res = pReader.readLine(); if (res != null && res.startsWith("OK")) { - loginAttemptTracker.recordSuccess(remoteIp); + loginAttemptTracker.recordSuccess(remoteIp, userName); if (adminRoleNames != null && adminUserList != null) { if (adminUserList.contains(userName)) { res = res + " " + adminRoleNames; } } } else { - loginAttemptTracker.recordFailure(remoteIp); + loginAttemptTracker.recordFailure(remoteIp, userName); res = GENERIC_FAILURE_RESPONSE; } @@ -148,7 +143,7 @@ public void run() { } } } catch (Throwable t) { - loginAttemptTracker.recordFailure(remoteIp); + loginAttemptTracker.recordFailure(remoteIp, userName); if (userName != null && writer != null) { LOG.error("Response [{}] for user: {} from [{}], {}", GENERIC_FAILURE_RESPONSE, userName, remoteIp, t.getMessage()); writer.println(GENERIC_FAILURE_RESPONSE); @@ -166,4 +161,16 @@ public void run() { } } } + + private static String parseUserName(String request) { + if (request == null || !request.startsWith("LOGIN:")) { + return null; + } + String line = request.substring(6).trim(); + int passwordAt = line.indexOf(' '); + if (passwordAt == -1) { + return null; + } + return line.substring(0, passwordAt).trim(); + } } diff --git a/unixauthservice/src/main/java/org/apache/ranger/authentication/UnixAuthenticationService.java b/unixauthservice/src/main/java/org/apache/ranger/authentication/UnixAuthenticationService.java index 6dcc75d5f..ca64f2395 100644 --- a/unixauthservice/src/main/java/org/apache/ranger/authentication/UnixAuthenticationService.java +++ b/unixauthservice/src/main/java/org/apache/ranger/authentication/UnixAuthenticationService.java @@ -75,6 +75,10 @@ public class UnixAuthenticationService { private static final String UNIXAUTH_LOCKOUT_DURATION_MS_PARAM = "ranger.usersync.unixauth.lockout.duration.ms"; private static final String UNIXAUTH_REQUIRE_CLIENT_AUTH_PARAM = "ranger.usersync.unixauth.require.client.auth"; private static final String UNIXAUTH_SOCKET_TIMEOUT_MS_PARAM = "ranger.usersync.unixauth.socket.timeout.ms"; + private static final String UNIXAUTH_ACCOUNT_LOCKOUT_ENABLED_PARAM = "ranger.usersync.unixauth.account.lockout.enabled"; + private static final String UNIXAUTH_ACCOUNT_MAX_FAILED_ATTEMPTS_PARAM = "ranger.usersync.unixauth.account.max.failed.attempts"; + private static final String UNIXAUTH_ACCOUNT_ATTEMPT_WINDOW_MS_PARAM = "ranger.usersync.unixauth.account.attempt.window.ms"; + private static final String UNIXAUTH_ACCOUNT_LOCKOUT_DURATION_MS_PARAM = "ranger.usersync.unixauth.account.lockout.duration.ms"; private static boolean enableUnixAuth; @@ -333,7 +337,13 @@ private void init() { long lockoutDurationMs = Long.parseLong(prop.getProperty(UNIXAUTH_LOCKOUT_DURATION_MS_PARAM, "30000")); socketTimeoutMs = Integer.parseInt(prop.getProperty(UNIXAUTH_SOCKET_TIMEOUT_MS_PARAM, "10000")); - loginAttemptTracker = new LoginAttemptTracker(maxFailedAttempts, attemptWindowMs, lockoutDurationMs); + boolean accountLockoutEnabled = Boolean.parseBoolean(prop.getProperty(UNIXAUTH_ACCOUNT_LOCKOUT_ENABLED_PARAM, "true")); + int maxAccountFailedAttempts = Integer.parseInt(prop.getProperty(UNIXAUTH_ACCOUNT_MAX_FAILED_ATTEMPTS_PARAM, "5")); + long accountAttemptWindowMs = Long.parseLong(prop.getProperty(UNIXAUTH_ACCOUNT_ATTEMPT_WINDOW_MS_PARAM, "60000")); + long accountLockoutDurationMs = Long.parseLong(prop.getProperty(UNIXAUTH_ACCOUNT_LOCKOUT_DURATION_MS_PARAM, "30000")); + + loginAttemptTracker = new LoginAttemptTracker(maxFailedAttempts, attemptWindowMs, lockoutDurationMs, + accountLockoutEnabled, maxAccountFailedAttempts, accountAttemptWindowMs, accountLockoutDurationMs); String requireClientAuthProp = prop.getProperty(UNIXAUTH_REQUIRE_CLIENT_AUTH_PARAM, "false"); requireClientAuth = requireClientAuthProp.trim().equalsIgnoreCase("true"); diff --git a/unixauthservice/src/test/java/org/apache/ranger/authentication/LoginAttemptTrackerTest.java b/unixauthservice/src/test/java/org/apache/ranger/authentication/LoginAttemptTrackerTest.java index 3ff5fb9ab..159bf4a27 100644 --- a/unixauthservice/src/test/java/org/apache/ranger/authentication/LoginAttemptTrackerTest.java +++ b/unixauthservice/src/test/java/org/apache/ranger/authentication/LoginAttemptTrackerTest.java @@ -97,4 +97,37 @@ public void independentIpsTrackedSeparately() { assertTrue(tracker.isBlocked("10.0.0.6")); assertFalse(tracker.isBlocked("10.0.0.7"), "a different source IP must not be affected"); } + + @Test + public void accountFailuresAccumulateAcrossIps() { + LoginAttemptTracker tracker = new LoginAttemptTracker(100, 60_000, 30_000, true, 3, 60_000, 30_000); + tracker.recordFailure("10.0.0.1", "alice"); + tracker.recordFailure("10.0.0.2", "alice"); + assertFalse(tracker.isAccountBlocked("alice")); + tracker.recordFailure("10.0.0.3", "alice"); + assertTrue(tracker.isAccountBlocked("alice")); + assertFalse(tracker.isBlocked("10.0.0.99")); + assertTrue(tracker.isBlocked("10.0.0.99", "alice")); + } + + @Test + public void accountLockoutCanBeDisabled() { + LoginAttemptTracker tracker = new LoginAttemptTracker(100, 60_000, 30_000, false, 3, 60_000, 30_000); + for (int i = 0; i < 5; i++) { + tracker.recordFailure("10.0.0." + i, "alice"); + } + assertFalse(tracker.isAccountBlocked("alice")); + assertFalse(tracker.isBlocked("10.0.0.99", "alice")); + } + + @Test + public void accountSuccessClearsAccountFailureHistory() { + LoginAttemptTracker tracker = new LoginAttemptTracker(100, 60_000, 30_000, true, 3, 60_000, 30_000); + tracker.recordFailure("10.0.0.1", "alice"); + tracker.recordFailure("10.0.0.2", "alice"); + tracker.recordSuccess("10.0.0.3", "alice"); + assertFalse(tracker.isAccountBlocked("alice")); + tracker.recordFailure("10.0.0.4", "alice"); + assertFalse(tracker.isAccountBlocked("alice")); + } } diff --git a/unixauthservice/src/test/java/org/apache/ranger/authentication/TestPasswordValidator.java b/unixauthservice/src/test/java/org/apache/ranger/authentication/TestPasswordValidator.java index 0c19a04fb..0c43473e2 100644 --- a/unixauthservice/src/test/java/org/apache/ranger/authentication/TestPasswordValidator.java +++ b/unixauthservice/src/test/java/org/apache/ranger/authentication/TestPasswordValidator.java @@ -37,6 +37,7 @@ import java.util.Collections; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; @@ -47,8 +48,7 @@ import static org.mockito.Mockito.when; /** - * @generated by Cursor - * @description : Unit Test cases for PasswordValidator + * Unit Test cases for PasswordValidator */ @ExtendWith(MockitoExtension.class) @@ -188,7 +188,7 @@ public void test06_blockedIpSkipsValidatorAndReturnsLockedOutResponse() throws E ByteArrayOutputStream out = new ByteArrayOutputStream(); Socket socket = buildSocket("LOGIN: alice secret", out); LoginAttemptTracker tracker = mock(LoginAttemptTracker.class); - when(tracker.isBlocked(anyString())).thenReturn(true); + when(tracker.isBlocked(anyString(), any())).thenReturn(true); new PasswordValidator(socket, tracker).run(); String response = out.toString(StandardCharsets.UTF_8).trim(); assertEquals("FAILED: Too many attempts. Try again later.", response); @@ -210,7 +210,7 @@ public void test07_failedValidatorResponseRecordsFailureOnTracker() throws Excep when(process.getOutputStream()).thenReturn(new ByteArrayOutputStream()); LoginAttemptTracker tracker = mock(LoginAttemptTracker.class); - when(tracker.isBlocked(anyString())).thenReturn(false); + when(tracker.isBlocked(anyString(), any())).thenReturn(false); try (MockedStatic<Runtime> runtimeStatic = mockStatic(Runtime.class)) { Runtime rt = mock(Runtime.class); @@ -221,8 +221,8 @@ public void test07_failedValidatorResponseRecordsFailureOnTracker() throws Excep String response = out.toString(StandardCharsets.UTF_8).trim(); assertEquals("FAILED: Authentication failed.", response); - verify(tracker, times(1)).recordFailure(anyString()); - verify(tracker, never()).recordSuccess(anyString()); + verify(tracker, times(1)).recordFailure(anyString(), anyString()); + verify(tracker, never()).recordSuccess(anyString(), anyString()); } @Test @@ -240,7 +240,7 @@ public void test08_okValidatorResponseRecordsSuccessOnTracker() throws Exception when(process.getOutputStream()).thenReturn(new ByteArrayOutputStream()); LoginAttemptTracker tracker = mock(LoginAttemptTracker.class); - when(tracker.isBlocked(anyString())).thenReturn(false); + when(tracker.isBlocked(anyString(), any())).thenReturn(false); try (MockedStatic<Runtime> runtimeStatic = mockStatic(Runtime.class)) { Runtime rt = mock(Runtime.class); @@ -252,8 +252,28 @@ public void test08_okValidatorResponseRecordsSuccessOnTracker() throws Exception String response = out.toString(StandardCharsets.UTF_8).trim(); assertEquals("OK", response); - verify(tracker, times(1)).recordSuccess(anyString()); - verify(tracker, never()).recordFailure(anyString()); + verify(tracker, times(1)).recordSuccess(anyString(), anyString()); + verify(tracker, never()).recordFailure(anyString(), anyString()); + } + + @Test + public void test09_blockedAccountSkipsValidatorAndReturnsLockedOutResponse() throws Exception { + PasswordValidator.setValidatorProgram("/bin/validator"); + PasswordValidator.setAdminUserList(null); + PasswordValidator.setAdminRoleNames(null); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + Socket socket = buildSocket("LOGIN: alice secret", out); + LoginAttemptTracker tracker = mock(LoginAttemptTracker.class); + when(tracker.isBlocked(anyString(), any())).thenAnswer(invocation -> { + String account = invocation.getArgument(1); + return "alice".equals(account); + }); + new PasswordValidator(socket, tracker).run(); + String response = out.toString(StandardCharsets.UTF_8.name()).trim(); + assertEquals("FAILED: Too many attempts. Try again later.", response); + verify(tracker, never()).recordFailure(anyString(), anyString()); + verify(socket, times(1)).close(); } private Socket buildSocket(String requestLine, ByteArrayOutputStream responseOut) throws IOException {
