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));