This is an automated email from the ASF dual-hosted git repository.

ramackri pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ranger.git


The following commit(s) were added to refs/heads/master by this push:
     new 514f2a376 RANGER-5690: UnixAuth service lacks rate limiting on 
authentication attempts (#1080)
514f2a376 is described below

commit 514f2a376be3ad9059d8a44ec823ed257b4e887b
Author: Vyom Mani Tiwari <[email protected]>
AuthorDate: Tue Jul 21 20:18:52 2026 +0530

    RANGER-5690: UnixAuth service lacks rate limiting on authentication 
attempts (#1080)
---
 .../conf.dist/ranger-ugsync-default.xml            |  20 +++++
 .../ranger/authentication/LoginAttemptTracker.java | 100 +++++++++++++++++++++
 .../ranger/authentication/PasswordValidator.java   |  40 +++++++--
 .../authentication/UnixAuthenticationService.java  |  31 ++++++-
 .../authentication/LoginAttemptTrackerTest.java    | 100 +++++++++++++++++++++
 .../authentication/TestPasswordValidator.java      | 100 +++++++++++++++++++--
 6 files changed, 373 insertions(+), 18 deletions(-)

diff --git a/unixauthservice/conf.dist/ranger-ugsync-default.xml 
b/unixauthservice/conf.dist/ranger-ugsync-default.xml
index 9cedc99e5..2e48da7e6 100644
--- a/unixauthservice/conf.dist/ranger-ugsync-default.xml
+++ b/unixauthservice/conf.dist/ranger-ugsync-default.xml
@@ -29,6 +29,26 @@
                <name>ranger.usersync.https.ssl.enabled.protocols</name>
                <value>TLSv1.2</value>
        </property>
+       <property>
+               <name>ranger.usersync.unixauth.max.failed.attempts</name>
+               <value>5</value>
+       </property>
+       <property>
+               <name>ranger.usersync.unixauth.attempt.window.ms</name>
+               <value>60000</value>
+       </property>
+       <property>
+               <name>ranger.usersync.unixauth.lockout.duration.ms</name>
+               <value>30000</value>
+       </property>
+       <property>
+               <name>ranger.usersync.unixauth.require.client.auth</name>
+               <value>false</value>
+       </property>
+       <property>
+               <name>ranger.usersync.unixauth.socket.timeout.ms</name>
+               <value>10000</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
new file mode 100644
index 000000000..b448a7b19
--- /dev/null
+++ 
b/unixauthservice/src/main/java/org/apache/ranger/authentication/LoginAttemptTracker.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.ranger.authentication;
+
+import java.util.Iterator;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+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();
+    }
+
+    public boolean isBlocked(String sourceIp) {
+        Record record = attemptsByIp.get(sourceIp);
+        if (record == null) {
+            return false;
+        }
+        return record.lockedUntil > System.currentTimeMillis();
+    }
+
+    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;
+            }
+        }
+    }
+
+    public void recordSuccess(String sourceIp) {
+        attemptsByIp.remove(sourceIp);
+    }
+
+    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 = 
attemptsByIp.entrySet().iterator();
+        while (it.hasNext()) {
+            Record record = it.next().getValue();
+            if (record.lastActivity < cutoff && record.lockedUntil <= now) {
+                it.remove();
+            }
+        }
+    }
+
+    private static final class Record {
+        final AtomicInteger failCount = new AtomicInteger(0);
+        volatile long windowStart;
+        volatile long lastActivity;
+        volatile long lockedUntil;
+
+        Record(long 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 0df92cafd..0416ca644 100644
--- 
a/unixauthservice/src/main/java/org/apache/ranger/authentication/PasswordValidator.java
+++ 
b/unixauthservice/src/main/java/org/apache/ranger/authentication/PasswordValidator.java
@@ -32,15 +32,19 @@
 
 public class PasswordValidator implements Runnable {
     private static final Logger LOG = 
LoggerFactory.getLogger(PasswordValidator.class);
+    private static final String GENERIC_FAILURE_RESPONSE = "FAILED: 
Authentication failed.";
+    private static final String LOCKED_OUT_RESPONSE = "FAILED: Too many 
attempts. Try again later.";
 
     private static List<String> adminUserList;
     private static String adminRoleNames;
     private static String validatorProgram;
 
+    private final LoginAttemptTracker loginAttemptTracker;
     private Socket client;
 
-    public PasswordValidator(Socket client) {
+    public PasswordValidator(Socket client, LoginAttemptTracker 
loginAttemptTracker) {
         this.client = client;
+        this.loginAttemptTracker = loginAttemptTracker;
     }
 
     public static String getValidatorProgram() {
@@ -74,11 +78,24 @@ public void run() {
 
         String userName = null;
 
+        String remoteIp = client.getInetAddress() != null ? 
client.getInetAddress().getHostAddress() : "unknown";
         try {
             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);
+                writer.println(LOCKED_OUT_RESPONSE);
+                writer.flush();
+                return;
+            }
+            if (request == null) {
+                loginAttemptTracker.recordFailure(remoteIp);
+                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(' ');
@@ -88,10 +105,10 @@ public void run() {
             }
 
             if (validatorProgram == null) {
-                String res = "FAILED: Unable to validate credentials.";
-                writer.println(res);
+                loginAttemptTracker.recordFailure(remoteIp);
+                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();
-                LOG.error("Response [{}] for user: {} as ValidatorProgram is 
not defined in configuration", res, userName);
             } else {
                 BufferedReader pReader;
                 PrintWriter    pWriter;
@@ -109,14 +126,18 @@ public void run() {
                     String res = pReader.readLine();
 
                     if (res != null && res.startsWith("OK")) {
+                        loginAttemptTracker.recordSuccess(remoteIp);
                         if (adminRoleNames != null && adminUserList != null) {
                             if (adminUserList.contains(userName)) {
                                 res = res + " " + adminRoleNames;
                             }
                         }
+                    } else {
+                        loginAttemptTracker.recordFailure(remoteIp);
+                        res = GENERIC_FAILURE_RESPONSE;
                     }
 
-                    LOG.info("Response [{}] for user: {}", res, userName);
+                    LOG.info("Response [{}] for user: {} from [{}]", res, 
userName, remoteIp);
 
                     writer.println(res);
                     writer.flush();
@@ -127,10 +148,11 @@ public void run() {
                 }
             }
         } catch (Throwable t) {
+            loginAttemptTracker.recordFailure(remoteIp);
             if (userName != null && writer != null) {
-                String res = "FAILED: unable to validate due to error " + 
t.getMessage();
-                writer.println(res);
-                LOG.error("Response [{}] for user: {} message: {}", res, 
userName, t.getMessage());
+                LOG.error("Response [{}] for user: {} from [{}], {}", 
GENERIC_FAILURE_RESPONSE, userName, remoteIp, t.getMessage());
+                writer.println(GENERIC_FAILURE_RESPONSE);
+                writer.flush();
             }
         } finally {
             try {
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 7c53f4d2c..6dcc75d5f 100644
--- 
a/unixauthservice/src/main/java/org/apache/ranger/authentication/UnixAuthenticationService.java
+++ 
b/unixauthservice/src/main/java/org/apache/ranger/authentication/UnixAuthenticationService.java
@@ -70,6 +70,11 @@ public class UnixAuthenticationService {
     private static final String SSL_ENABLED_PARAM                    = 
"ranger.usersync.ssl";
     private static final String CREDSTORE_FILENAME_PARAM             = 
"ranger.usersync.credstore.filename";
     private static final String[] UGSYNC_CONFIG_XML_FILES            = 
{"ranger-ugsync-default.xml", "ranger-ugsync-site.xml"};
+    private static final String UNIXAUTH_MAX_FAILED_ATTEMPTS_PARAM   = 
"ranger.usersync.unixauth.max.failed.attempts";
+    private static final String UNIXAUTH_ATTEMPT_WINDOW_MS_PARAM     = 
"ranger.usersync.unixauth.attempt.window.ms";
+    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 boolean enableUnixAuth;
 
@@ -87,6 +92,9 @@ public class UnixAuthenticationService {
     private UserSyncHAInitializerImpl userSyncHAInitializerImpl;
     private int portNum;
     private boolean sslEnabled;
+    private boolean requireClientAuth;
+    private LoginAttemptTracker loginAttemptTracker;
+    private int socketTimeoutMs;
 
     public UnixAuthenticationService() {
     }
@@ -193,6 +201,13 @@ public void startService() throws Throwable {
                         LOG.debug("Disabling CipherSuite : [{}]", 
enabledCipherSuite);
                     }
                 }
+                if (requireClientAuth) {
+                    if (trustStoreKeyStore == null) {
+                        throw new RuntimeException("[" + 
UNIXAUTH_REQUIRE_CLIENT_AUTH_PARAM + "] is enabled but [" + 
SSL_TRUSTSTORE_PATH_PARAM + "] is not configured - a truststore is required to 
validate client certificates.");
+                    }
+                    LOG.info("Enabling TLS client certificate authentication 
for UnixAuth listener.");
+                    secureSocket.setNeedClientAuth(true);
+                }
                 if (!allowedCipherSuites.isEmpty()) {
                     
secureSocket.setEnabledCipherSuites(allowedCipherSuites.toArray(new String[0]));
                 }
@@ -202,7 +217,12 @@ public void startService() throws Throwable {
 
             try {
                 while ((client = socket.accept()) != null) {
-                    Thread clientValidatorThread = new Thread(new 
PasswordValidator(client));
+                    try {
+                        client.setSoTimeout(socketTimeoutMs);
+                    } catch (java.net.SocketException se) {
+                        LOG.warn("Failed to set socket timeout for connection 
from [{}]: {}", client.getInetAddress(), se.getMessage());
+                    }
+                    Thread clientValidatorThread = new Thread(new 
PasswordValidator(client, loginAttemptTracker));
                     clientValidatorThread.start();
                 }
             } catch (IOException e) {
@@ -308,6 +328,15 @@ private void init() {
         String enabledCipherSuites     = 
prop.getProperty("ranger.usersync.https.ssl.enabled.cipher.suites", "");
         enabledProtocolsList           = new 
ArrayList<>(Arrays.asList(enabledProtocols.toUpperCase().trim().split("\\s*,\\s*")));
         enabledCipherSuiteList         = new 
ArrayList<>(Arrays.asList(enabledCipherSuites.toUpperCase().trim().split("\\s*,\\s*")));
+        int  maxFailedAttempts = 
Integer.parseInt(prop.getProperty(UNIXAUTH_MAX_FAILED_ATTEMPTS_PARAM, "5"));
+        long attemptWindowMs   = 
Long.parseLong(prop.getProperty(UNIXAUTH_ATTEMPT_WINDOW_MS_PARAM, "60000"));
+        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);
+
+        String requireClientAuthProp = 
prop.getProperty(UNIXAUTH_REQUIRE_CLIENT_AUTH_PARAM, "false");
+        requireClientAuth = 
requireClientAuthProp.trim().equalsIgnoreCase("true");
     }
 
     private InputStream getFileInputStream(String path) throws 
FileNotFoundException {
diff --git 
a/unixauthservice/src/test/java/org/apache/ranger/authentication/LoginAttemptTrackerTest.java
 
b/unixauthservice/src/test/java/org/apache/ranger/authentication/LoginAttemptTrackerTest.java
new file mode 100644
index 000000000..3ff5fb9ab
--- /dev/null
+++ 
b/unixauthservice/src/test/java/org/apache/ranger/authentication/LoginAttemptTrackerTest.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.ranger.authentication;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class LoginAttemptTrackerTest {
+    @Test
+    public void allowsAttemptsBelowThreshold() {
+        LoginAttemptTracker tracker = new LoginAttemptTracker(5, 60_000, 
30_000);
+        String ip = "10.0.0.1";
+        for (int i = 0; i < 4; i++) {
+            assertFalse(tracker.isBlocked(ip), "should not be blocked before 
threshold");
+            tracker.recordFailure(ip);
+        }
+        assertFalse(tracker.isBlocked(ip), "still not blocked at 4 failures 
with threshold 5");
+    }
+
+    @Test
+    public void locksOutAfterThresholdReached() {
+        LoginAttemptTracker tracker = new LoginAttemptTracker(5, 60_000, 
30_000);
+        String ip = "10.0.0.2";
+        for (int i = 0; i < 5; i++) {
+            tracker.recordFailure(ip);
+        }
+        assertTrue(tracker.isBlocked(ip), "should be locked out at threshold");
+    }
+
+    @Test
+    public void successClearsFailureHistory() {
+        LoginAttemptTracker tracker = new LoginAttemptTracker(5, 60_000, 
30_000);
+        String ip = "10.0.0.3";
+        for (int i = 0; i < 4; i++) {
+            tracker.recordFailure(ip);
+        }
+        tracker.recordSuccess(ip);
+        assertFalse(tracker.isBlocked(ip));
+        // failure count should have reset - one more failure should not lock 
out
+        tracker.recordFailure(ip);
+        assertFalse(tracker.isBlocked(ip));
+    }
+
+    @Test
+    public void windowExpiryResetsFailureCount() throws InterruptedException {
+        // very short window so the test runs fast
+        LoginAttemptTracker tracker = new LoginAttemptTracker(3, 50, 30_000);
+        String ip = "10.0.0.4";
+        tracker.recordFailure(ip);
+        tracker.recordFailure(ip);
+        assertFalse(tracker.isBlocked(ip));
+        Thread.sleep(100); // let the window expire
+        tracker.recordFailure(ip); // should start a fresh window, count = 1
+        assertFalse(tracker.isBlocked(ip), "window should have reset the 
failure count");
+    }
+
+    @Test
+    public void reLocksImmediatelyIfRetriedRightAfterLockoutExpires() throws 
InterruptedException {
+        // short lockout, long window - so lockout expires but window doesn't
+        LoginAttemptTracker tracker = new LoginAttemptTracker(3, 60_000, 50);
+        String ip = "10.0.0.5";
+        tracker.recordFailure(ip);
+        tracker.recordFailure(ip);
+        tracker.recordFailure(ip);
+        assertTrue(tracker.isBlocked(ip));
+        Thread.sleep(100); // let the lockout expire, window is still active
+        assertFalse(tracker.isBlocked(ip), "lockout should have expired");
+        tracker.recordFailure(ip); // one more failure within the same window
+        assertTrue(tracker.isBlocked(ip), "should re-lock immediately since 
window's failure count carried over");
+    }
+
+    @Test
+    public void independentIpsTrackedSeparately() {
+        LoginAttemptTracker tracker = new LoginAttemptTracker(3, 60_000, 
30_000);
+        for (int i = 0; i < 3; i++) {
+            tracker.recordFailure("10.0.0.6");
+        }
+        assertTrue(tracker.isBlocked("10.0.0.6"));
+        assertFalse(tracker.isBlocked("10.0.0.7"), "a different source IP must 
not be affected");
+    }
+}
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 69860af0b..0c19a04fb 100644
--- 
a/unixauthservice/src/test/java/org/apache/ranger/authentication/TestPasswordValidator.java
+++ 
b/unixauthservice/src/test/java/org/apache/ranger/authentication/TestPasswordValidator.java
@@ -37,9 +37,11 @@
 import java.util.Collections;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -52,6 +54,10 @@
 @ExtendWith(MockitoExtension.class)
 @TestMethodOrder(MethodOrderer.MethodName.class)
 public class TestPasswordValidator {
+    private static LoginAttemptTracker permissiveTracker() {
+        return new LoginAttemptTracker(1000, 60_000, 30_000);
+    }
+
     @Test
     public void test00_staticGettersAndSetters() {
         PasswordValidator.setValidatorProgram("/tmp/prog");
@@ -72,10 +78,10 @@ public void test01_nullProgramWritesFailedAndClosesSocket() 
throws Exception {
         ByteArrayOutputStream out    = new ByteArrayOutputStream();
         Socket                socket = buildSocket("LOGIN: alice secret", out);
 
-        new PasswordValidator(socket).run();
+        new PasswordValidator(socket, permissiveTracker()).run();
 
         String response = out.toString(StandardCharsets.UTF_8.name()).trim();
-        assertEquals("FAILED: Unable to validate credentials.", response);
+        assertEquals("FAILED: Authentication failed.", response);
         verify(socket, times(1)).close();
     }
 
@@ -98,10 +104,10 @@ public void 
test02_okResponseAppendsAdminRoleAndDestroysProcess() throws Excepti
             runtimeStatic.when(Runtime::getRuntime).thenReturn(rt);
             when(rt.exec("/bin/validator")).thenReturn(process);
 
-            new PasswordValidator(socket).run();
+            new PasswordValidator(socket, permissiveTracker()).run();
         }
 
-        String response = out.toString(StandardCharsets.UTF_8.name()).trim();
+        String response = out.toString(StandardCharsets.UTF_8).trim();
         assertEquals("OK ROLE_ADMIN", response);
         verify(process, times(1)).destroy();
         verify(socket, times(1)).close();
@@ -126,7 +132,7 @@ public void 
test03_okResponseWithoutAdminDoesNotAppendRole() throws Exception {
             runtimeStatic.when(Runtime::getRuntime).thenReturn(rt);
             when(rt.exec("/bin/validator")).thenReturn(process);
 
-            new PasswordValidator(socket).run();
+            new PasswordValidator(socket, permissiveTracker()).run();
         }
 
         String response = out.toString(StandardCharsets.UTF_8.name()).trim();
@@ -149,10 +155,11 @@ public void test04_execThrowsWritesFailed() throws 
Exception {
             runtimeStatic.when(Runtime::getRuntime).thenReturn(rt);
             when(rt.exec("/bin/validator")).thenThrow(new IOException("boom"));
 
-            new PasswordValidator(socket).run();
+            new PasswordValidator(socket, permissiveTracker()).run();
         }
 
-        // Output may not be flushed in catch path; verify socket is closed 
indicating catch executed
+        String response = out.toString(StandardCharsets.UTF_8).trim();
+        assertEquals("FAILED: Authentication failed.", response);
         verify(socket, times(1)).close();
     }
 
@@ -166,12 +173,89 @@ public void test05_closeSocketIOExceptionHandled() throws 
Exception {
         Socket                socket = buildSocket("LOGIN: alice secret", out);
         doThrow(new IOException("close failure")).when(socket).close();
 
-        new PasswordValidator(socket).run();
+        new PasswordValidator(socket, permissiveTracker()).run();
 
         // No exception should be thrown even if close() fails; nothing to 
assert besides method completed
         verify(socket, times(1)).close();
     }
 
+    @Test
+    public void test06_blockedIpSkipsValidatorAndReturnsLockedOutResponse() 
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())).thenReturn(true);
+        new PasswordValidator(socket, tracker).run();
+        String response = out.toString(StandardCharsets.UTF_8).trim();
+        assertEquals("FAILED: Too many attempts. Try again later.", response);
+        verify(socket, times(1)).close();
+    }
+
+    @Test
+    public void test07_failedValidatorResponseRecordsFailureOnTracker() throws 
Exception {
+        PasswordValidator.setValidatorProgram("/bin/validator");
+        PasswordValidator.setAdminUserList(null);
+        PasswordValidator.setAdminRoleNames(null);
+
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        Socket socket = buildSocket("LOGIN: alice secret", out);
+
+        Process process = mock(Process.class);
+        ByteArrayInputStream processStdout = new ByteArrayInputStream("FAILED: 
Password did not match.".getBytes(StandardCharsets.UTF_8));
+        when(process.getInputStream()).thenReturn(processStdout);
+        when(process.getOutputStream()).thenReturn(new 
ByteArrayOutputStream());
+
+        LoginAttemptTracker tracker = mock(LoginAttemptTracker.class);
+        when(tracker.isBlocked(anyString())).thenReturn(false);
+
+        try (MockedStatic<Runtime> runtimeStatic = mockStatic(Runtime.class)) {
+            Runtime rt = mock(Runtime.class);
+            runtimeStatic.when(Runtime::getRuntime).thenReturn(rt);
+            when(rt.exec("/bin/validator")).thenReturn(process);
+            new PasswordValidator(socket, tracker).run();
+        }
+
+        String response = out.toString(StandardCharsets.UTF_8).trim();
+        assertEquals("FAILED: Authentication failed.", response);
+        verify(tracker, times(1)).recordFailure(anyString());
+        verify(tracker, never()).recordSuccess(anyString());
+    }
+
+    @Test
+    public void test08_okValidatorResponseRecordsSuccessOnTracker() throws 
Exception {
+        PasswordValidator.setValidatorProgram("/bin/validator");
+        PasswordValidator.setAdminUserList(null);
+        PasswordValidator.setAdminRoleNames(null);
+
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        Socket socket = buildSocket("LOGIN: alice secret", out);
+
+        Process process = mock(Process.class);
+        ByteArrayInputStream processStdout = new 
ByteArrayInputStream("OK".getBytes(StandardCharsets.UTF_8));
+        when(process.getInputStream()).thenReturn(processStdout);
+        when(process.getOutputStream()).thenReturn(new 
ByteArrayOutputStream());
+
+        LoginAttemptTracker tracker = mock(LoginAttemptTracker.class);
+        when(tracker.isBlocked(anyString())).thenReturn(false);
+
+        try (MockedStatic<Runtime> runtimeStatic = mockStatic(Runtime.class)) {
+            Runtime rt = mock(Runtime.class);
+            runtimeStatic.when(Runtime::getRuntime).thenReturn(rt);
+            when(rt.exec("/bin/validator")).thenReturn(process);
+
+            new PasswordValidator(socket, tracker).run();
+        }
+
+        String response = out.toString(StandardCharsets.UTF_8).trim();
+        assertEquals("OK", response);
+        verify(tracker, times(1)).recordSuccess(anyString());
+        verify(tracker, never()).recordFailure(anyString());
+    }
+
     private Socket buildSocket(String requestLine, ByteArrayOutputStream 
responseOut) throws IOException {
         Socket       socket = mock(Socket.class);
         InputStream  in     = new ByteArrayInputStream((requestLine + 
"\n").getBytes(StandardCharsets.UTF_8));

Reply via email to