This is an automated email from the ASF dual-hosted git repository.
ramackri pushed a commit to branch RANGER-5690-backport-ranger-2.9
in repository https://gitbox.apache.org/repos/asf/ranger.git
The following commit(s) were added to
refs/heads/RANGER-5690-backport-ranger-2.9 by this push:
new afe88f0d9 RANGER-5690: Add account-associated lockout for UnixAuth
distributed guessing
afe88f0d9 is described below
commit afe88f0d9913b9b8bb5d5de2d534126135ae23f9
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 8c9f61238..a937ecd0a 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 90262e4f5..0779b10ed 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.name()).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.name()).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.name()).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 {