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
commit 5b6869b3ec3c577824d5f84b5d093232fab5557a Author: ramk <[email protected]> AuthorDate: Tue Jul 21 20:26:32 2026 +0530 RANGER-5690: UnixAuth service lacks rate limiting on authentication attempts (#1080) Back-port to ranger-2.9. Preserve TLSv1.2 SSL algorithm used on 2.9 branch. --- .../conf.dist/ranger-ugsync-default.xml | 20 + .../ranger/authentication/LoginAttemptTracker.java | 100 ++++ .../ranger/authentication/PasswordValidator.java | 270 +++++---- .../authentication/UnixAuthenticationService.java | 658 ++++++++++----------- .../authentication/LoginAttemptTrackerTest.java | 100 ++++ .../authentication/TestPasswordValidator.java | 267 +++++++++ 6 files changed, 940 insertions(+), 475 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 c924932ae..0416ca644 100644 --- a/unixauthservice/src/main/java/org/apache/ranger/authentication/PasswordValidator.java +++ b/unixauthservice/src/main/java/org/apache/ranger/authentication/PasswordValidator.java @@ -17,7 +17,10 @@ * under the License. */ - package org.apache.ranger.authentication; +package org.apache.ranger.authentication; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.BufferedReader; import java.io.IOException; @@ -27,135 +30,140 @@ import java.net.Socket; import java.util.List; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - public class PasswordValidator implements Runnable { - - private static final Logger LOG = LoggerFactory.getLogger(PasswordValidator.class); - - private static String validatorProgram = null; - - private static List<String> adminUserList; - - private static String adminRoleNames; - - private Socket client; - - public PasswordValidator(Socket client) { - this.client = client; - } - - @Override - public void run() { - BufferedReader reader = null; - PrintWriter writer = null; - - String userName = null; - - try { - reader = new BufferedReader(new InputStreamReader(client.getInputStream())); - writer = new PrintWriter(new OutputStreamWriter(client.getOutputStream())); - String request = reader.readLine(); - - 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) { - String res = "FAILED: Unable to validate credentials."; - writer.println(res); - writer.flush(); - LOG.error("Response [" + res + "] for user: " + userName + " as ValidatorProgram is not defined in configuration."); - - } - else { - - BufferedReader pReader = null; - PrintWriter pWriter = null; - Process p = null; - - try { - p = Runtime.getRuntime().exec(validatorProgram); - - pReader = new BufferedReader(new InputStreamReader(p.getInputStream())); - - pWriter = new PrintWriter(new OutputStreamWriter(p.getOutputStream())); - - pWriter.println(request); pWriter.flush(); - - String res = pReader.readLine(); - - - if (res != null && res.startsWith("OK")) { - if (adminRoleNames != null && adminUserList != null) { - if (adminUserList.contains(userName)) { - res = res + " " + adminRoleNames; - } - } - } - - LOG.info("Response [" + res + "] for user: " + userName); - - writer.println(res); writer.flush(); - } - finally { - if (p != null) { - p.destroy(); - } - } - } - - } - catch(Throwable t) { - if (userName != null && writer != null ) { - String res = "FAILED: unable to validate due to error " + t.getMessage(); - writer.println(res); - LOG.error("Response [" + res + "] for user: " + userName+", "+ t.getMessage()); - } - } - finally { - try { - if (client != null) { - client.close(); - } - } - catch(IOException ioe){ - LOG.debug("Close socket failure. Detail: \n", ioe); - } - finally { - client = null; - } - } - } - - - public static String getValidatorProgram() { - return validatorProgram; - } - - public static void setValidatorProgram(String validatorProgram) { - PasswordValidator.validatorProgram = validatorProgram; - } - - public static List<String> getAdminUserList() { - return adminUserList; - } - - public static void setAdminUserList(List<String> adminUserList) { - PasswordValidator.adminUserList = adminUserList; - } - - public static String getAdminRoleNames() { - return adminRoleNames; - } - - public static void setAdminRoleNames(String adminRoleNames) { - PasswordValidator.adminRoleNames = adminRoleNames; - } - + 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, LoginAttemptTracker loginAttemptTracker) { + this.client = client; + this.loginAttemptTracker = loginAttemptTracker; + } + + public static String getValidatorProgram() { + return validatorProgram; + } + + public static void setValidatorProgram(String validatorProgram) { + PasswordValidator.validatorProgram = validatorProgram; + } + + public static List<String> getAdminUserList() { + return adminUserList; + } + + public static void setAdminUserList(List<String> adminUserList) { + PasswordValidator.adminUserList = adminUserList; + } + + public static String getAdminRoleNames() { + return adminRoleNames; + } + + public static void setAdminRoleNames(String adminRoleNames) { + PasswordValidator.adminRoleNames = adminRoleNames; + } + + @Override + public void run() { + BufferedReader reader; + PrintWriter writer = null; + + 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(' '); + if (passwordAt != -1) { + userName = line.substring(0, passwordAt).trim(); + } + } + + if (validatorProgram == null) { + 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(); + } else { + BufferedReader pReader; + PrintWriter pWriter; + Process p = null; + + try { + p = Runtime.getRuntime().exec(validatorProgram); + + pReader = new BufferedReader(new InputStreamReader(p.getInputStream())); + pWriter = new PrintWriter(new OutputStreamWriter(p.getOutputStream())); + + pWriter.println(request); + pWriter.flush(); + + 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: {} from [{}]", res, userName, remoteIp); + + writer.println(res); + writer.flush(); + } finally { + if (p != null) { + p.destroy(); + } + } + } + } catch (Throwable t) { + loginAttemptTracker.recordFailure(remoteIp); + if (userName != null && writer != null) { + LOG.error("Response [{}] for user: {} from [{}], {}", GENERIC_FAILURE_RESPONSE, userName, remoteIp, t.getMessage()); + writer.println(GENERIC_FAILURE_RESPONSE); + writer.flush(); + } + } finally { + try { + if (client != null) { + client.close(); + } + } catch (IOException ioe) { + LOG.debug("Close socket failure. Detail: ", ioe); + } finally { + client = null; + } + } + } } 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 d3d14a175..8c9f61238 100644 --- a/unixauthservice/src/main/java/org/apache/ranger/authentication/UnixAuthenticationService.java +++ b/unixauthservice/src/main/java/org/apache/ranger/authentication/UnixAuthenticationService.java @@ -19,6 +19,23 @@ package org.apache.ranger.authentication; +import org.apache.ranger.credentialapi.CredentialReader; +import org.apache.ranger.plugin.util.XMLUtils; +import org.apache.ranger.unixusersync.config.UserGroupSyncConfig; +import org.apache.ranger.unixusersync.ha.UserSyncHAInitializerImpl; +import org.apache.ranger.usergroupsync.UserGroupSync; +import org.apache.ranger.usergroupsync.UserSyncMetricsProducer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLServerSocket; +import javax.net.ssl.SSLServerSocketFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; + import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; @@ -35,354 +52,307 @@ import java.util.Properties; import java.util.Set; -import javax.net.ssl.KeyManager; -import javax.net.ssl.KeyManagerFactory; -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLServerSocket; -import javax.net.ssl.SSLServerSocketFactory; -import javax.net.ssl.TrustManager; -import javax.net.ssl.TrustManagerFactory; +public class UnixAuthenticationService { + private static final Logger LOG = LoggerFactory.getLogger(UnixAuthenticationService.class); -import org.apache.ranger.unixusersync.ha.UserSyncHAInitializerImpl; -import org.apache.ranger.credentialapi.CredentialReader; -import org.apache.ranger.plugin.util.XMLUtils; -import org.apache.ranger.unixusersync.config.UserGroupSyncConfig; -import org.apache.ranger.usergroupsync.UserGroupSync; -import org.apache.ranger.usergroupsync.UserSyncMetricsProducer; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; + private static final String serviceName = "UnixAuthenticationService"; + private static final String SSL_ALGORITHM = "TLSv1.2"; + private static final String REMOTE_LOGIN_AUTH_SERVICE_PORT_PARAM = "ranger.usersync.port"; + private static final String SSL_KEYSTORE_PATH_PARAM = "ranger.usersync.keystore.file"; + private static final String SSL_TRUSTSTORE_PATH_PARAM = "ranger.usersync.truststore.file"; + private static final String SSL_KEYSTORE_FILE_TYPE_PARAM = "ranger.keystore.file.type"; + private static final String SSL_TRUSTSTORE_FILE_TYPE_PARAM = "ranger.truststore.file.type"; + private static final String SSL_KEYSTORE_PATH_PASSWORD_ALIAS = "usersync.ssl.key.password"; + private static final String SSL_TRUSTSTORE_PATH_PASSWORD_ALIAS = "usersync.ssl.truststore.password"; + private static final String CRED_VALIDATOR_PROG = "ranger.usersync.passwordvalidator.path"; + private static final String ADMIN_USER_LIST_PARAM = "admin.users"; + private static final String ADMIN_ROLE_LIST_PARAM = "admin.roleNames"; + 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"; -public class UnixAuthenticationService { + private static boolean enableUnixAuth; + + private final List<String> adminUserList = new ArrayList<>(); + + private String adminRoleNames; + private String keyStorePath; + private String keyStorePathPassword; + private String keyStoreType; + private String trustStorePath; + private String trustStorePathPassword; + private String trustStoreType; + private List<String> enabledProtocolsList; + private List<String> enabledCipherSuiteList; + private UserSyncHAInitializerImpl userSyncHAInitializerImpl; + private int portNum; + private boolean sslEnabled; + private boolean requireClientAuth; + private LoginAttemptTracker loginAttemptTracker; + private int socketTimeoutMs; + + public UnixAuthenticationService() { + } + + public static void main(String[] args) { + enableUnixAuth = Arrays.stream(args).anyMatch("-enableUnixAuth"::equalsIgnoreCase); + UnixAuthenticationService service = new UnixAuthenticationService(); + service.userSyncHAInitializerImpl = UserSyncHAInitializerImpl.getInstance(UserGroupSyncConfig.getInstance().getUserGroupConfig()); + service.run(); + } + + public void run() { + try { + LOG.info("Starting User Sync Service!"); + startUnixUserGroupSyncProcess(); + Thread.sleep(5000); + if (enableUnixAuth) { + LOG.info("Enabling Unix Auth Service!"); + init(); + startService(); + } else { + LOG.info("Unix Auth Service Disabled!"); + } + } catch (Throwable t) { + LOG.error("ERROR: Service: {}", serviceName, t); + } finally { + LOG.info("Service: {} - STOPPED", serviceName); + if (this.userSyncHAInitializerImpl != null) { + LOG.info("Stopping curator leader latch service as main thread is closing"); + this.userSyncHAInitializerImpl.stop(); + } + } + } + + public void startService() throws Throwable { + SSLContext context = SSLContext.getInstance(SSL_ALGORITHM); - private static final Logger LOG = LoggerFactory.getLogger(UnixAuthenticationService.class); - - private static final String serviceName = "UnixAuthenticationService"; - - private static final String SSL_ALGORITHM = "TLSv1.2"; - private static final String REMOTE_LOGIN_AUTH_SERVICE_PORT_PARAM = "ranger.usersync.port"; - - private static final String SSL_KEYSTORE_PATH_PARAM = "ranger.usersync.keystore.file"; - private static final String SSL_TRUSTSTORE_PATH_PARAM = "ranger.usersync.truststore.file"; - - private static final String SSL_KEYSTORE_FILE_TYPE_PARAM = "ranger.keystore.file.type"; - private static final String SSL_TRUSTSTORE_FILE_TYPE_PARAM = "ranger.truststore.file.type"; - - private static final String SSL_KEYSTORE_PATH_PASSWORD_ALIAS = "usersync.ssl.key.password"; - private static final String SSL_TRUSTSTORE_PATH_PASSWORD_ALIAS = "usersync.ssl.truststore.password"; - - private static final String CRED_VALIDATOR_PROG = "ranger.usersync.passwordvalidator.path"; - private static final String ADMIN_USER_LIST_PARAM = "admin.users"; - private static final String ADMIN_ROLE_LIST_PARAM = "admin.roleNames"; - private static final String SSL_ENABLED_PARAM = "ranger.usersync.ssl"; - - private static final String CREDSTORE_FILENAME_PARAM = "ranger.usersync.credstore.filename"; - - private String keyStorePath; - private String keyStoreType; - private List<String> enabledProtocolsList; - private List<String> enabledCipherSuiteList; - private String keyStorePathPassword; - private String trustStorePath; - private String trustStorePathPassword; - private String trustStoreType; - private List<String> adminUserList = new ArrayList<String>(); - private String adminRoleNames; - private UserSyncHAInitializerImpl userSyncHAInitializerImpl = null; - - private int portNum; - - private boolean SSLEnabled = false; - - static private boolean enableUnixAuth = false; - - private static final String[] UGSYNC_CONFIG_XML_FILES = { "ranger-ugsync-default.xml", "ranger-ugsync-site.xml" }; - - public static void main(String[] args) { - if (args.length > 0) { - for (String s : args) { - if ("-enableUnixAuth".equalsIgnoreCase(s)) { - enableUnixAuth = true; - break; - } - } - } - UnixAuthenticationService service = new UnixAuthenticationService(); - service.userSyncHAInitializerImpl = UserSyncHAInitializerImpl.getInstance(UserGroupSyncConfig.getInstance().getUserGroupConfig()); - service.run(); - } - - public UnixAuthenticationService() { - } - - - public void run() { - try { - LOG.info("Starting User Sync Service!"); - startUnixUserGroupSyncProcess(); - Thread.sleep(5000); - if (enableUnixAuth) { - LOG.info("Enabling Unix Auth Service!"); - init(); - startService(); - } else { - LOG.info("Unix Auth Service Disabled!"); - } - } - catch(Throwable t) { - LOG.error("ERROR: Service: " + serviceName , t); - } - finally { - LOG.info("Service: " + serviceName + " - STOPPED."); - if(this.userSyncHAInitializerImpl != null) { - LOG.info("Stopping curator leader latch service as main thread is closing"); - this.userSyncHAInitializerImpl.stop(); - } - } - } - - private void startUnixUserGroupSyncProcess() { - // - // Start the synchronization service ... - // - LOG.info("Start : startUnixUserGroupSyncProcess " ); - UserGroupSync syncProc = new UserGroupSync(); - Thread newSyncProcThread = new Thread(syncProc); - newSyncProcThread.setName("UnixUserSyncThread"); - // If this thread is set as daemon, then the entire process will terminate if enableUnixAuth is false - // Therefore this is marked as non-daemon thread. Don't change the following line - newSyncProcThread.setDaemon(false); - newSyncProcThread.start(); + KeyManager[] km = null; + + if (keyStorePath != null && !keyStorePath.isEmpty()) { + KeyStore ks = KeyStore.getInstance(keyStoreType); + + try (InputStream in = getFileInputStream(keyStorePath)) { + if (keyStorePathPassword == null) { + keyStorePathPassword = ""; + } + ks.load(in, keyStorePathPassword.toCharArray()); + } + + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(ks, keyStorePathPassword.toCharArray()); + km = kmf.getKeyManagers(); + } + + TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + + KeyStore trustStoreKeyStore = null; + + if (trustStorePath != null && !trustStorePath.isEmpty()) { + trustStoreKeyStore = KeyStore.getInstance(trustStoreType); + + try (InputStream in = getFileInputStream(trustStorePath)) { + if (trustStorePathPassword == null) { + trustStorePathPassword = ""; + } + trustStoreKeyStore.load(in, trustStorePathPassword.toCharArray()); + } + } + + trustManagerFactory.init(trustStoreKeyStore); + + TrustManager[] tm = trustManagerFactory.getTrustManagers(); + + SecureRandom random = new SecureRandom(); + + context.init(km, tm, random); + + SSLServerSocketFactory sf = context.getServerSocketFactory(); + + try (ServerSocket socket = (sslEnabled ? sf.createServerSocket(portNum) : new ServerSocket(portNum))) { + if (sslEnabled) { + SSLServerSocket secureSocket = (SSLServerSocket) socket; + String[] protocols = secureSocket.getEnabledProtocols(); + Set<String> allowedProtocols = new HashSet<>(); + for (String ep : protocols) { + if (enabledProtocolsList.contains(ep.toUpperCase())) { + LOG.info("Enabling Protocol: [{}]", ep); + allowedProtocols.add(ep); + } else { + LOG.info("Disabling Protocol: [{}]", ep); + } + } + + if (!allowedProtocols.isEmpty()) { + secureSocket.setEnabledProtocols(allowedProtocols.toArray(new String[0])); + } + String[] enabledCipherSuites = secureSocket.getEnabledCipherSuites(); + Set<String> allowedCipherSuites = new HashSet<>(); + for (String enabledCipherSuite : enabledCipherSuites) { + if (enabledCipherSuiteList.contains(enabledCipherSuite)) { + LOG.debug("Enabling CipherSuite : [{}]", enabledCipherSuite); + allowedCipherSuites.add(enabledCipherSuite); + } else { + 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])); + } + } + + Socket client; + + try { + while ((client = socket.accept()) != null) { + 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) { + socket.close(); + throw (e); + } + } + } + + private void startUnixUserGroupSyncProcess() { + // Start user group synchronization service + LOG.info("Start : startUnixUserGroupSyncProcess "); + UserGroupSync syncProc = new UserGroupSync(); + Thread newSyncProcThread = new Thread(syncProc); + newSyncProcThread.setName("UnixUserSyncThread"); + + /* If this thread is set as daemon, then the entire process will terminate if enableUnixAuth is false + Therefore this is marked as non-daemon thread. Don't change the following line + */ + newSyncProcThread.setDaemon(false); + newSyncProcThread.start(); LOG.info("UnixUserSyncThread started"); - LOG.info("creating UserSyncMetricsProducer thread with default metrics location : "+System.getProperty("logdir")); - //Start the user sync metrics - boolean isUserSyncMetricsEnabled = UserGroupSyncConfig.getInstance().isUserSyncMetricsEnabled(); - if (isUserSyncMetricsEnabled) { - UserSyncMetricsProducer userSyncMetricsProducer = new UserSyncMetricsProducer(); - Thread userSyncMetricsProducerThread = new Thread(userSyncMetricsProducer); - userSyncMetricsProducerThread.setName("UserSyncMetricsProducerThread"); - userSyncMetricsProducerThread.setDaemon(false); - userSyncMetricsProducerThread.start(); - LOG.info("UserSyncMetricsProducer started"); - } else { - LOG.info(" Ranger userSync metrics is not enabled"); - } - } - - - //TODO: add more validation code - private void init() throws Throwable { - Properties prop = new Properties(); - - for (String fn : UGSYNC_CONFIG_XML_FILES ) { + LOG.info("creating UserSyncMetricsProducer thread with default metrics location : {}", System.getProperty("logdir")); + + //Start the user sync metrics + boolean isUserSyncMetricsEnabled = UserGroupSyncConfig.getInstance().isUserSyncMetricsEnabled(); + if (isUserSyncMetricsEnabled) { + UserSyncMetricsProducer userSyncMetricsProducer = new UserSyncMetricsProducer(); + Thread userSyncMetricsProducerThread = new Thread(userSyncMetricsProducer); + userSyncMetricsProducerThread.setName("UserSyncMetricsProducerThread"); + userSyncMetricsProducerThread.setDaemon(false); + userSyncMetricsProducerThread.start(); + LOG.info("UserSyncMetricsProducer started"); + } else { + LOG.info(" Ranger userSync metrics is not enabled"); + } + } + + //TODO: add more validation code + private void init() { + Properties prop = new Properties(); + + for (String fn : UGSYNC_CONFIG_XML_FILES) { XMLUtils.loadConfig(fn, prop); - } - - String credStoreFileName = prop.getProperty(CREDSTORE_FILENAME_PARAM); - - keyStorePath = prop.getProperty(SSL_KEYSTORE_PATH_PARAM); - - keyStoreType = prop.getProperty(SSL_KEYSTORE_FILE_TYPE_PARAM, KeyStore.getDefaultType()); - trustStoreType = prop.getProperty(SSL_TRUSTSTORE_FILE_TYPE_PARAM, KeyStore.getDefaultType()); - - if (credStoreFileName == null) { - throw new RuntimeException("Credential file is not defined. param = [" + CREDSTORE_FILENAME_PARAM + "]"); - } - - File credFile = new File(credStoreFileName); - - if (! credFile.exists()) { - throw new RuntimeException("Credential file [" + credStoreFileName + "]: does not exists." ); - } - - if ( ! credFile.canRead() ) { - throw new RuntimeException("Credential file [" + credStoreFileName + "]: can not be read." ); - } - - if ("bcfks".equalsIgnoreCase(keyStoreType)) { - String crendentialProviderPrefixBcfks= "bcfks" + "://file"; - credStoreFileName = crendentialProviderPrefixBcfks + credStoreFileName; - } - keyStorePathPassword = CredentialReader.getDecryptedString(credStoreFileName, SSL_KEYSTORE_PATH_PASSWORD_ALIAS, keyStoreType); - trustStorePathPassword = CredentialReader.getDecryptedString(credStoreFileName,SSL_TRUSTSTORE_PATH_PASSWORD_ALIAS, trustStoreType); - - trustStorePath = prop.getProperty(SSL_TRUSTSTORE_PATH_PARAM); - portNum = Integer.parseInt(prop.getProperty(REMOTE_LOGIN_AUTH_SERVICE_PORT_PARAM)); - String validatorProg = prop.getProperty(CRED_VALIDATOR_PROG); - if (validatorProg != null) { - PasswordValidator.setValidatorProgram(validatorProg); - } - - String adminUsers = prop.getProperty(ADMIN_USER_LIST_PARAM); - - if (adminUsers != null && adminUsers.trim().length() > 0) { - for(String u : adminUsers.split(",")) { - LOG.info("Adding Admin User:" + u.trim()); - adminUserList.add(u.trim()); - } - PasswordValidator.setAdminUserList(adminUserList); - } - - - adminRoleNames = prop.getProperty(ADMIN_ROLE_LIST_PARAM); - - if (adminRoleNames != null) { - LOG.info("Adding Admin Group:" + adminRoleNames); - PasswordValidator.setAdminRoleNames(adminRoleNames); - } - - String SSLEnabledProp = prop.getProperty(SSL_ENABLED_PARAM); - - SSLEnabled = (SSLEnabledProp != null && (SSLEnabledProp.equalsIgnoreCase("true"))); - String defaultEnabledProtocols = "TLSv1.2"; - String enabledProtocols = prop.getProperty("ranger.usersync.https.ssl.enabled.protocols", defaultEnabledProtocols); - String enabledCipherSuites = prop.getProperty("ranger.usersync.https.ssl.enabled.cipher.suites", ""); - enabledProtocolsList=new ArrayList<String>(Arrays.asList(enabledProtocols.toUpperCase().trim().split("\\s*,\\s*"))); - enabledCipherSuiteList = new ArrayList<String>(Arrays.asList(enabledCipherSuites.toUpperCase().trim().split("\\s*,\\s*"))); -// LOG.info("Key:" + keyStorePath); -// LOG.info("KeyPassword:" + keyStorePathPassword); -// LOG.info("TrustStore:" + trustStorePath); -// LOG.info("PortNum:" + portNum); -// LOG.info("ValidatorProg:" + validatorProg); - - } - - - public void startService() throws Throwable { - - SSLContext context = SSLContext.getInstance(SSL_ALGORITHM); - - KeyManager[] km = null; - - if (keyStorePath != null && ! keyStorePath.isEmpty()) { - KeyStore ks = KeyStore.getInstance(keyStoreType); - - InputStream in = null; - - in = getFileInputStream(keyStorePath); - - try { - if (keyStorePathPassword == null) { - keyStorePathPassword = ""; - } - ks.load(in, keyStorePathPassword.toCharArray()); - } - finally { - if (in != null) { - in.close(); - } - } - - KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); - kmf.init(ks, keyStorePathPassword.toCharArray()); - km = kmf.getKeyManagers(); - } - - - TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - - KeyStore trustStoreKeyStore = null; - - if (trustStorePath != null && ! trustStorePath.isEmpty()) { - trustStoreKeyStore = KeyStore.getInstance(trustStoreType); - - InputStream in = null; - - in = getFileInputStream(trustStorePath); - - try { - if (trustStorePathPassword == null) { - trustStorePathPassword = ""; - } - trustStoreKeyStore.load(in, trustStorePathPassword.toCharArray()); - } - finally { - if (in != null) { - in.close(); - } - } - } - - trustManagerFactory.init(trustStoreKeyStore); - - TrustManager[] tm = trustManagerFactory.getTrustManagers(); - - SecureRandom random = new SecureRandom(); - - context.init(km, tm, random); - - SSLServerSocketFactory sf = context.getServerSocketFactory(); - - try(ServerSocket socket = (SSLEnabled ? sf.createServerSocket(portNum) : new ServerSocket(portNum) )) { - - if (SSLEnabled) { - SSLServerSocket secureSocket = (SSLServerSocket) socket; - String[] protocols = secureSocket.getEnabledProtocols(); - Set<String> allowedProtocols = new HashSet<String>(); - for (String ep : protocols) { - if (enabledProtocolsList.contains(ep.toUpperCase())) { - LOG.info("Enabling Protocol: [" + ep + "]"); - allowedProtocols.add(ep); - } else { - LOG.info("Disabling Protocol: [" + ep + "]"); - } - } - - if (!allowedProtocols.isEmpty()) { - secureSocket.setEnabledProtocols(allowedProtocols.toArray(new String[0])); - } - String[] enabledCipherSuites = secureSocket.getEnabledCipherSuites(); - Set<String> allowedCipherSuites = new HashSet<String>(); - for (String enabledCipherSuite : enabledCipherSuites) { - if (enabledCipherSuiteList.contains(enabledCipherSuite)) { - if (LOG.isDebugEnabled()) { - LOG.debug("Enabling CipherSuite : [" + enabledCipherSuite + "]"); - } - allowedCipherSuites.add(enabledCipherSuite); - } else { - if (LOG.isDebugEnabled()) { - LOG.debug("Disabling CipherSuite : [" + enabledCipherSuite + "]"); - } - } - } - if (!allowedCipherSuites.isEmpty()) { - secureSocket.setEnabledCipherSuites(allowedCipherSuites.toArray(new String[0])); - } - } - - - Socket client = null; - - try { - - while ((client = socket.accept()) != null) { - Thread clientValidatorThread = new Thread(new PasswordValidator(client)); - clientValidatorThread.start(); - } - } catch (IOException e) { - socket.close(); - throw (e); - } - } - - } - - private InputStream getFileInputStream(String path) throws FileNotFoundException { - - InputStream ret = null; - - File f = new File(path); - - if (f.exists()) { - ret = new FileInputStream(f); - } - else { - ret = getClass().getResourceAsStream(path); - if (ret == null) { - ret = getClass().getResourceAsStream(File.separator + path); - } - } - - return ret; - } + } + + String credStoreFileName = prop.getProperty(CREDSTORE_FILENAME_PARAM); + keyStorePath = prop.getProperty(SSL_KEYSTORE_PATH_PARAM); + trustStorePath = prop.getProperty(SSL_TRUSTSTORE_PATH_PARAM); + keyStoreType = prop.getProperty(SSL_KEYSTORE_FILE_TYPE_PARAM, KeyStore.getDefaultType()); + trustStoreType = prop.getProperty(SSL_TRUSTSTORE_FILE_TYPE_PARAM, KeyStore.getDefaultType()); + + if (credStoreFileName == null) { + throw new RuntimeException("Credential file is not defined. param = [" + CREDSTORE_FILENAME_PARAM + "]"); + } + + File credFile = new File(credStoreFileName); + + if (!credFile.exists()) { + throw new RuntimeException("Credential file [" + credStoreFileName + "]: does not exists."); + } + + if (!credFile.canRead()) { + throw new RuntimeException("Credential file [" + credStoreFileName + "]: can not be read."); + } + + if ("bcfks".equalsIgnoreCase(keyStoreType)) { + String crendentialProviderPrefixBcfks = "bcfks://file"; + credStoreFileName = crendentialProviderPrefixBcfks + credStoreFileName; + } + + keyStorePathPassword = CredentialReader.getDecryptedString(credStoreFileName, SSL_KEYSTORE_PATH_PASSWORD_ALIAS, keyStoreType); + trustStorePathPassword = CredentialReader.getDecryptedString(credStoreFileName, SSL_TRUSTSTORE_PATH_PASSWORD_ALIAS, trustStoreType); + portNum = Integer.parseInt(prop.getProperty(REMOTE_LOGIN_AUTH_SERVICE_PORT_PARAM)); + String validatorProg = prop.getProperty(CRED_VALIDATOR_PROG); + + if (validatorProg != null) { + PasswordValidator.setValidatorProgram(validatorProg); + } + + String adminUsers = prop.getProperty(ADMIN_USER_LIST_PARAM); + + if (adminUsers != null && !adminUsers.trim().isEmpty()) { + for (String u : adminUsers.split(",")) { + LOG.info("Adding Admin User: {}", u.trim()); + adminUserList.add(u.trim()); + } + PasswordValidator.setAdminUserList(adminUserList); + } + + adminRoleNames = prop.getProperty(ADMIN_ROLE_LIST_PARAM); + + if (adminRoleNames != null) { + LOG.info("Adding Admin Group: {}", adminRoleNames); + PasswordValidator.setAdminRoleNames(adminRoleNames); + } + + String sslEnabledProp = prop.getProperty(SSL_ENABLED_PARAM); + + sslEnabled = (sslEnabledProp != null && (sslEnabledProp.equalsIgnoreCase("true"))); + String defaultEnabledProtocols = "TLSv1.2"; + String enabledProtocols = prop.getProperty("ranger.usersync.https.ssl.enabled.protocols", defaultEnabledProtocols); + 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 { + InputStream ret; + + File f = new File(path); + + if (f.exists()) { + ret = new FileInputStream(f); + } else { + ret = getClass().getResourceAsStream(path); + if (ret == null) { + ret = getClass().getResourceAsStream(File.separator + path); + } + } + return ret; + } } 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 new file mode 100644 index 000000000..0c19a04fb --- /dev/null +++ b/unixauthservice/src/test/java/org/apache/ranger/authentication/TestPasswordValidator.java @@ -0,0 +1,267 @@ +/* + * 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.MethodOrderer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +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; + +/** + * @generated by Cursor + * @description : Unit Test cases for PasswordValidator + */ + +@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"); + PasswordValidator.setAdminUserList(Collections.singletonList("root")); + PasswordValidator.setAdminRoleNames("ROLE_SUPER"); + + assertEquals("/tmp/prog", PasswordValidator.getValidatorProgram()); + assertEquals(Collections.singletonList("root"), PasswordValidator.getAdminUserList()); + assertEquals("ROLE_SUPER", PasswordValidator.getAdminRoleNames()); + } + + @Test + public void test01_nullProgramWritesFailedAndClosesSocket() throws Exception { + PasswordValidator.setValidatorProgram(null); + PasswordValidator.setAdminUserList(null); + PasswordValidator.setAdminRoleNames(null); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + Socket socket = buildSocket("LOGIN: alice secret", out); + + new PasswordValidator(socket, permissiveTracker()).run(); + + String response = out.toString(StandardCharsets.UTF_8.name()).trim(); + assertEquals("FAILED: Authentication failed.", response); + verify(socket, times(1)).close(); + } + + @Test + public void test02_okResponseAppendsAdminRoleAndDestroysProcess() throws Exception { + PasswordValidator.setValidatorProgram("/bin/validator"); + PasswordValidator.setAdminUserList(Arrays.asList("alice", "bob")); + PasswordValidator.setAdminRoleNames("ROLE_ADMIN"); + + 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()); + + 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, permissiveTracker()).run(); + } + + String response = out.toString(StandardCharsets.UTF_8).trim(); + assertEquals("OK ROLE_ADMIN", response); + verify(process, times(1)).destroy(); + verify(socket, times(1)).close(); + } + + @Test + public void test03_okResponseWithoutAdminDoesNotAppendRole() throws Exception { + PasswordValidator.setValidatorProgram("/bin/validator"); + PasswordValidator.setAdminUserList(Collections.singletonList("bob")); + PasswordValidator.setAdminRoleNames("ROLE_ADMIN"); + + 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()); + + 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, permissiveTracker()).run(); + } + + String response = out.toString(StandardCharsets.UTF_8.name()).trim(); + assertEquals("OK", response); + verify(process, times(1)).destroy(); + verify(socket, times(1)).close(); + } + + @Test + public void test04_execThrowsWritesFailed() throws Exception { + PasswordValidator.setValidatorProgram("/bin/validator"); + PasswordValidator.setAdminUserList(Collections.singletonList("alice")); + PasswordValidator.setAdminRoleNames("ROLE_ADMIN"); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + Socket socket = buildSocket("LOGIN: alice secret", out); + + try (MockedStatic<Runtime> runtimeStatic = mockStatic(Runtime.class)) { + Runtime rt = mock(Runtime.class); + runtimeStatic.when(Runtime::getRuntime).thenReturn(rt); + when(rt.exec("/bin/validator")).thenThrow(new IOException("boom")); + + new PasswordValidator(socket, permissiveTracker()).run(); + } + + String response = out.toString(StandardCharsets.UTF_8).trim(); + assertEquals("FAILED: Authentication failed.", response); + verify(socket, times(1)).close(); + } + + @Test + public void test05_closeSocketIOExceptionHandled() throws Exception { + PasswordValidator.setValidatorProgram(null); + PasswordValidator.setAdminUserList(null); + PasswordValidator.setAdminRoleNames(null); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + Socket socket = buildSocket("LOGIN: alice secret", out); + doThrow(new IOException("close failure")).when(socket).close(); + + 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)); + OutputStream out = responseOut; + when(socket.getInputStream()).thenReturn(in); + when(socket.getOutputStream()).thenReturn(out); + return socket; + } +}
