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 {

Reply via email to