/*
* Copyright (c) 2014 EMC Corporation
* All Rights Reserved
*/
package com.emc.storageos.security.password;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import com.emc.storageos.coordinator.client.model.PropertyInfoExt;
import com.emc.storageos.model.auth.InvalidLogins;
import org.apache.commons.lang.math.NumberUtils;
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.emc.storageos.coordinator.client.service.CoordinatorClient;
import com.emc.storageos.coordinator.client.service.DistributedDataManager;
import com.emc.storageos.coordinator.common.impl.ZkPath;
import org.apache.curator.framework.recipes.locks.InterProcessLock;
import com.emc.storageos.security.exceptions.SecurityException;
import javax.servlet.http.HttpServletRequest;
/**
* The class to handle invalid login attempts.
* If the invalid login attempts from the same IP exceed the configured parameter (default 10) the IP will be blocked for
* a configured life time (default 10 minutes). After that time the IP will be cleared to allow logins from that IP to proceed.
* Successful login from a client IP will clear the record in ZK for that IP.
*
*/
public class InvalidLoginManager {
private static final Logger _log = LoggerFactory.getLogger(InvalidLoginManager.class);
private static final String MAX_AUTH_LOGIN_ATTEMPTS = "max_auth_login_attempts";
private static final String AUTH_LOCKOUT_TIME_IN_MINUTES = "auth_lockout_time_in_minutes";
private static final int MAX_AUTHN_LOGIN_ATTEMPTS_NODE_COUNT = 5000;
private static final int CLEANUP_THREAD_SCHEDULE_INTERVAL_IN_MINS = 10;
private static final long MIN_TO_MSECS = 60 * 1000;
private static final String INVALID_LOGIN_CLEANER_LOCK = "invalid_login_cleaner_lock";
private static final String INVALID_LOGIN_VERSION = "_2.2";
public static final String OLD_PASSWORD_INVALID_ERROR = "Old password is invalid"; // NOSONAR
// ("Suppressing: removing this hard-coded password since it's just an error message")
private final ScheduledExecutorService _invalidLoginCleanupExecutor = Executors.newScheduledThreadPool(1);
private CoordinatorClient _coordinator;
protected DistributedDataManager _distDataManager;
protected int _maxAuthnLoginAttemtsCount;
protected int _maxAuthnLoginAttemtsLifeTimeInMins;
public int getMaxAuthnLoginAttemtsCount() {
return _maxAuthnLoginAttemtsCount;
}
public int getMaxAuthnLoginAttemtsLifeTimeInMins() {
return _maxAuthnLoginAttemtsLifeTimeInMins;
}
public void setCoordinator(CoordinatorClient coordinator) {
_coordinator = coordinator;
_distDataManager = _coordinator.createDistributedDataManager(
ZkPath.AUTHN.toString(),
MAX_AUTHN_LOGIN_ATTEMPTS_NODE_COUNT);
if (null == _distDataManager) {
throw SecurityException.fatals.coordinatorNotInitialized();
}
loadParameterFromZK();
}
/**
* Check if the client IP is blocked.
* It is blocked if there are too many invalid logins from that IP. The default value for this parameter is 10.
*
* @brief Checks if the client IP is blocked
* @param clientIP Client IP address to be used to check the number of invalid login attempts from that IP
* @return true if the provided client IP is blocked because of too many invalid login attempts
*/
public boolean isTheClientIPBlocked(String clientIP) {
try {
if (null != clientIP && !clientIP.isEmpty()) {
String zkPath = getZkPath(clientIP);
InvalidLogins invLogins = (InvalidLogins) _distDataManager.getData(zkPath, false);
if (null != invLogins) {
if (isClientInvalidRecordExpired(invLogins)) {
removeInvalidRecord(clientIP);
return false;
}
if (invLogins.getLoginAttempts() < _maxAuthnLoginAttemtsCount) {
return false;
}
} else {
return false;
}
// This IP is blocked, too many invalid logins from that IP.
// It will be cleared after MAX_AUTHN_LOGIN_ATTEMPTS__LIFE_TIME_IN_MINS from the last invalid login.
_log.error("The client IP is blocked, too many error logins from that IP: {}", clientIP);
} else {
_log.error("The provided client IP is null or empty.");
}
} catch (Exception ex) {
_log.error("Failed to check the error login count", ex);
}
return true;
}
private boolean isClientInvalidRecordExpired(InvalidLogins invLogins) {
if (null != invLogins &&
(getCurrentTimeInMins() - invLogins.getLastAccessTimeInLong()) >= _maxAuthnLoginAttemtsLifeTimeInMins) {
return true;
}
return false;
}
/**
* This is NOOP if the client IP is not in ZK,
* if exists, get a lock INVALID_LOGIN_CLEANER_LOCK and then remove the record
*
* @brief Remove the client IP from the invalid login records list
* @param clientIP The client IP to be removed from the invalid login records list
*/
public void removeInvalidRecord(String clientIP) {
try {
if (isClientIPExist(clientIP)) {
// zk contains the ClientIP, start removing.
InterProcessLock lock = null;
try {
lock = _coordinator.getLock(INVALID_LOGIN_CLEANER_LOCK);
lock.acquire();
_log.info("Got ZK lock to remove a record created for invalid logins from this client IP: {}",
clientIP);
String zkPath = getZkPath(clientIP);
_distDataManager.removeNode(zkPath);
_log.info("Removed an invalid record entry: {}", zkPath);
} catch (Exception ex) {
_log.warn("Unexpected exception during db maintenance", ex);
} finally {
if (lock != null) {
try {
lock.release();
} catch (Exception ex) {
_log.warn("Unexpected exception unlocking the invalid login lock", ex);
}
}
}
} else {
_log.warn("Trying to remove an invalid record entry, the provided client IP is null or empty");
}
} catch (Exception ex) {
_log.error("Unexpected exception", ex);
}
}
/**
* check if zk contains the IP.
*
* @param clientIP
* @return
*/
public boolean isClientIPExist(String clientIP) {
if (StringUtils.isBlank(clientIP)) {
return false;
}
InvalidLogins invLogins = null;
try {
invLogins = (InvalidLogins) _distDataManager.getData(getZkPath(clientIP), false);
} catch (Exception ex) {
_log.error("Unexpected exception", ex);
}
if (null == invLogins) {
_log.debug("{} doesn't in zk", clientIP);
return false;
} else {
return true;
}
}
/**
* Generate version specific ZK node path like /authservice/192.168.2.1_2.0
*
* @param clientIP
* @return generated ZK node name or null if the provided clientIP is null or empty
*/
private String getZkPath(String clientIP) {
if (null != clientIP && !clientIP.isEmpty()) {
return ZkPath.AUTHN.toString() + "/" + clientIP + INVALID_LOGIN_VERSION;
}
return null;
}
/**
* The client failed to login. If an invalid login record exists for that client,
* increment the error count of that record.
* If that record does nor exists, create new entry.
*
* @brief Update the invalid login record for this client
* @param clientIP
*/
public void markErrorLogin(String clientIP) {
if (isDisabled()) {
return;
}
if (null != clientIP && !clientIP.isEmpty()) {
String zkPath = getZkPath(clientIP);
InterProcessLock lock = null;
try {
// Update the DB record. Get the lock first
lock = _coordinator.getLock(INVALID_LOGIN_CLEANER_LOCK);
lock.acquire();
_log.debug("Got a lock for updating the ZK");
InvalidLogins invLogins = (InvalidLogins) _distDataManager.getData(zkPath, false);
if (null == invLogins) {
// New entry for this invalid login
_distDataManager.createNode(zkPath, false);
invLogins = new InvalidLogins(clientIP, getCurrentTimeInMins(), 1);
_log.debug("Creating new record in the ZK for the client {}", clientIP);
} else {
invLogins.incrementErrorLoginCount();
}
// Update the last invalid login time stamp.
invLogins.setLastAccessTimeInLong(getCurrentTimeInMins());
_log.debug("Updating the record in the ZK for the client {}", clientIP);
_distDataManager.putData(zkPath, invLogins);
} catch (Exception ex) {
_log.error("Exception for the clientIP {} ", clientIP, ex);
} finally {
if (lock != null) {
try {
lock.release();
} catch (Exception ex) {
_log.error("Unexpected exception unlocking the lock for updating the ZK", ex);
}
}
}
} else {
_log.error("The provided clientIP is null or empty ");
}
return;
}
/**
* @brief Get the current time in minutes
* @return The current time in minutes
*/
protected long getCurrentTimeInMins() {
return System.currentTimeMillis() / (MIN_TO_MSECS);
}
/**
* @brief Invalid Login Records cleanup
*
* Walk through the list of records for Invalid Login Attempts,
* if bForce = false, for each record check the expiration time. If the record is expired, then delete that record.
* if bForce = true, clean up all records.
*
*/
public void invLoginCleanup(boolean bForce) {
String zkRoot = ZkPath.AUTHN.toString();
StringBuilder visitedRecords = new StringBuilder("");
StringBuilder removedRecords = new StringBuilder("");
int deletedCount = 0;
try {
List<String> recordNames = _distDataManager.getChildren(zkRoot);
if (null != recordNames) {
for (String clientIP : recordNames) {
String zkPath = zkRoot + "/" + clientIP;
visitedRecords.append(zkPath);
visitedRecords.append("; ");
InvalidLogins invLogins = (InvalidLogins) _distDataManager.getData(zkPath, false);
if (bForce || isClientInvalidRecordExpired(invLogins)) {
// The invalid login record need be cleaned up. Delete it. We already have a lock.
_distDataManager.removeNode(zkPath);
removedRecords.append(zkPath);
removedRecords.append("; ");
_log.debug("Invalid login record for the client IP {} is removed", clientIP);
deletedCount++;
}
}
if (deletedCount > 0) {
_log.info("Invalid login records cleanup: deleted {} records", deletedCount);
}
_log.debug("Invalid login records cleanup: visited records {}", visitedRecords);
_log.debug("Invalid login records cleanup: removed records {}", removedRecords);
}
} catch (Exception ex) {
_log.warn("Unexpected exception during db maintenance", ex);
}
}
/**
* Initialize the background task to be run every hour.
* At each run that task will walk through the Invalid Login records and clean all expired records.
*
* @brief Initialize the background thread to clean up the Invalid Login records
*/
public void init() throws Exception {
loadParameterFromZK();
_invalidLoginCleanupExecutor.scheduleWithFixedDelay(
new InvalidLoginCleaner(),
CLEANUP_THREAD_SCHEDULE_INTERVAL_IN_MINS,
CLEANUP_THREAD_SCHEDULE_INTERVAL_IN_MINS,
TimeUnit.MINUTES);
_log.info("Max invalid login attempts from the same client IP: {}", _maxAuthnLoginAttemtsCount);
_log.info("Life time in minutes of invalid login records for a client IP: {}",
_maxAuthnLoginAttemtsLifeTimeInMins);
_log.info("Cleanup thread schedule interval: {} minutes", CLEANUP_THREAD_SCHEDULE_INTERVAL_IN_MINS);
}
/**
* The class to implement the Invalid Login records cleanup thread
*
*/
private class InvalidLoginCleaner implements Runnable {
public void run() {
InterProcessLock lock = null;
try {
// Wait for random number of seconds to avoid all nodes in the cluster to access ZK at the same time
// move the wait code section out from invLoginCleanup to here, as waiting in lock is not a good practice.
Random random = new Random(System.currentTimeMillis());
long sleepInterval = (int) (random.nextDouble() * 100000); // interval is between 0 - 100,000 milliseconds
_log.debug("Sleeping for milliseconds: {}", sleepInterval);
Thread.sleep(sleepInterval);
_log.debug("Starting invalid login cleanup executor ...");
lock = _coordinator.getLock(INVALID_LOGIN_CLEANER_LOCK);
lock.acquire();
_log.debug("Got a lock for invalid login cleanup thread");
invLoginCleanup(false); // clean up expired records
} catch (Exception ex) {
_log.warn("Unexpected exception during db maintenance", ex);
} finally {
if (lock != null) {
try {
lock.release();
} catch (Exception ex) {
_log.warn("Unexpected exception unlocking the lock for invalid login records cleanup thread",
ex);
}
}
}
}
}
/**
*
* @brief Shutdown the background thread
*/
public void shutdown() {
if (null != _invalidLoginCleanupExecutor) {
try {
_invalidLoginCleanupExecutor.shutdown();
_invalidLoginCleanupExecutor.awaitTermination(15, TimeUnit.MINUTES);
} catch (Exception ex) {
_log.error("Failed to stop the background thread.", ex);
} finally {
_invalidLoginCleanupExecutor.shutdownNow();
}
}
if (_distDataManager != null) {
_distDataManager.close();
}
}
public static String getClientIP(HttpServletRequest req) {
if (req == null) {
return null;
}
String srcHost = req.getHeader("X-Real-IP");
if (srcHost == null) {
srcHost = req.getRemoteAddr();
}
return srcHost;
}
public List<InvalidLogins> listBlockedIPs() {
List<InvalidLogins> blockedIPs = new ArrayList<InvalidLogins>();
String zkRoot = ZkPath.AUTHN.toString();
try {
List<String> recordNames = _distDataManager.getChildren(zkRoot);
if (null != recordNames) {
for (String clientIP : recordNames) {
String zkPath = zkRoot + "/" + clientIP;
InvalidLogins invLogins = (InvalidLogins) _distDataManager.getData(zkPath, false);
if (invLogins != null) {
blockedIPs.add(invLogins);
}
}
}
} catch (Exception ex) {
_log.warn("Unexpected exception during db maintenance", ex);
}
return blockedIPs;
}
/**
* check if lockout feature enabled
*
* @return
*/
private boolean isLockoutEnabled() {
if (_maxAuthnLoginAttemtsCount == 0 || _maxAuthnLoginAttemtsLifeTimeInMins == 0) {
return false;
}
return true;
}
/**
* load parameter from system properties of ZooKeeper.
* if the properties do not exist, or exception when loading, use default values.
*/
public void loadParameterFromZK() {
try {
PropertyInfoExt params = _coordinator.getTargetInfo(PropertyInfoExt.class);
_maxAuthnLoginAttemtsCount = NumberUtils.toInt(params.getProperty(MAX_AUTH_LOGIN_ATTEMPTS),
Constants.DEFAULT_AUTH_LOGIN_ATTEMPTS);
_maxAuthnLoginAttemtsLifeTimeInMins = NumberUtils.toInt(params.getProperty(AUTH_LOCKOUT_TIME_IN_MINUTES),
Constants.DEFAULT_AUTH_LOCKOUT_TIME_IN_MINUTES);
} catch (Exception e) {
_log.warn("load parameter from ZK error, use default values.");
_maxAuthnLoginAttemtsCount = Constants.DEFAULT_AUTH_LOGIN_ATTEMPTS;
_maxAuthnLoginAttemtsLifeTimeInMins = Constants.DEFAULT_AUTH_LOCKOUT_TIME_IN_MINUTES;
}
}
/**
* check if ip block feature is disabled.
*
* @return
*/
private boolean isDisabled() {
if (_maxAuthnLoginAttemtsCount == 0 || _maxAuthnLoginAttemtsLifeTimeInMins == 0) {
return true;
}
return false;
}
/**
* get time difference in minutes, between current time and the specified client ip's last access time.
* if specified ip is not recorded in zk, return 0.
*
* @param clientIP
* @return
*/
public int getTimeLeftToUnblock(String clientIP) {
InvalidLogins invLogins = null;
try {
invLogins = (InvalidLogins) _distDataManager.getData(getZkPath(clientIP), false);
} catch (Exception ex) {
_log.error("Failed to get failed-login-ip record in zk", ex);
}
if (null == invLogins) {
_log.debug("{} doesn't in zk, return 0", clientIP);
return 0;
} else {
long lastAccesstime = invLogins.getLastAccessTimeInLong(); // number of minutes
int remainingTime = (int) (lastAccesstime + _maxAuthnLoginAttemtsLifeTimeInMins
- getCurrentTimeInMins());
return remainingTime > 0 ? remainingTime : 0;
}
}
}