Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Do not throw on initial LDAP connection failure #9

Merged
merged 10 commits into from
Jan 10, 2018
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public class LdapConfiguration {
private Integer responseTimeoutMS = 3000; // time to wait until receiving response from ldap
private boolean debug = false;
private boolean keepAlive = true;
private Integer retryIntervalSeconds = 5;

public LdapConfiguration server (String server) {
this.server = server;
Expand Down Expand Up @@ -151,4 +152,31 @@ public LdapConfiguration poolMaxConnectionAgeMS(Integer poolMaxConnectionAgeMS)
this.poolMaxConnectionAgeMS = poolMaxConnectionAgeMS;
return this;
}

public Integer getRetryIntervalSeconds() {
return retryIntervalSeconds;
}

public LdapConfiguration retryIntervalSeconds(Integer retryIntervalSeconds) {
this.retryIntervalSeconds = retryIntervalSeconds;
return this;
}

@Override
public String toString() {
return "LdapConfiguration{" +
"server='" + server + '\'' +
", port=" + port +
", bindDn='" + bindDn + '\'' +
", useSSL=" + useSSL +
", trustStore='" + trustStore + '\'' +
", poolSize=" + poolSize +
", poolMaxConnectionAgeMS=" + poolMaxConnectionAgeMS +
", connectionTimeoutMS=" + connectionTimeoutMS +
", responseTimeoutMS=" + responseTimeoutMS +
", debug=" + debug +
", keepAlive=" + keepAlive +
", retryIntervalSeconds=" + retryIntervalSeconds +
'}';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
package org.esbtools.auth.ldap;

import org.esbtools.auth.util.RolesProvider;
import com.unboundid.ldap.sdk.BindRequest;
import com.unboundid.ldap.sdk.BindResult;
import com.unboundid.ldap.sdk.DN;
import com.unboundid.ldap.sdk.LDAPConnection;
Expand All @@ -32,19 +31,22 @@
import com.unboundid.ldap.sdk.SearchResult;
import com.unboundid.ldap.sdk.SearchResultEntry;
import com.unboundid.ldap.sdk.SearchScope;
import com.unboundid.ldap.sdk.SimpleBindRequest;
import com.unboundid.util.DebugType;
import com.unboundid.util.ssl.SSLUtil;
import com.unboundid.util.ssl.TrustStoreTrustManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.net.ssl.SSLSocketFactory;
import java.security.GeneralSecurityException;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;


/**
Expand All @@ -59,29 +61,51 @@
*
*/
public class LdapRolesProvider implements RolesProvider {
private final Logger LOGGER = LoggerFactory.getLogger(LdapRolesProvider.class);
private static final Logger LOGGER = LoggerFactory.getLogger(LdapRolesProvider.class);

private String searchBase;
private final String searchBase;

private LdapConfiguration ldapConfiguration;
private final LdapConfiguration ldapConfiguration;

private final LDAPConnection ldapConnection;

// Connection pool needs to be a singleton
/**
* @{code null} until {@link #connectIfNeeded()} is called and successful.
*/
private LDAPConnectionPool connectionPool;
private volatile LDAPException connectionException;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is odd to have an exception stored volatile.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some other thread might have assigned an exception while another thread goes to read it, we want to avoid reading an earlier failure or worse seeing null. It's not a huge deal if it's not immediately seen but because the variable won't be checked that often I figured better to be correct.

private volatile Instant lastConnectionAttempt;
private final AtomicBoolean attemptingConnect = new AtomicBoolean(false);

public LdapRolesProvider(String searchBase, LdapConfiguration ldapConfiguration) throws Exception {
this(searchBase, ldapConfiguration, true);
}

public LdapRolesProvider(String searchBase, LdapConfiguration ldapConfiguration,
boolean failFast) throws Exception {
LOGGER.debug("Creating esbtoolsLdapRoleProvider");

Objects.requireNonNull(searchBase);
Objects.requireNonNull(ldapConfiguration);

this.searchBase = searchBase;
this.ldapConfiguration = ldapConfiguration;

initializeFromConfiguration();
this.ldapConnection = getLdapConnection(ldapConfiguration);

try {
connectIfNeeded();
} catch (LDAPException e) {
if (failFast) {
throw e;
} else {
LOGGER.warn("Failed to connect to LDAP server, will retry on next lookup after " +
"{} seconds.", ldapConfiguration.getRetryIntervalSeconds(), e);
}
}
}

private void initializeFromConfiguration() throws Exception {

private static LDAPConnection getLdapConnection(LdapConfiguration ldapConfiguration) throws GeneralSecurityException {
if (ldapConfiguration.isDebug()) {
// bridge java.util.Logger output to log4j
System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager");
Expand All @@ -103,49 +127,86 @@ private void initializeFromConfiguration() throws Exception {
// A flag that indicates whether to use the SO_KEEPALIVE socket option to attempt to more quickly detect when idle TCP connections have been lost or to prevent them from being unexpectedly closed by intermediate network hardware. By default, the SO_KEEPALIVE socket option will be used.
options.setUseKeepAlive(ldapConfiguration.isKeepAlive());


if(ldapConfiguration.getUseSSL()) {
if (ldapConfiguration.getUseSSL()) {
TrustStoreTrustManager trustStoreTrustManager = new TrustStoreTrustManager(
ldapConfiguration.getTrustStore(),
ldapConfiguration.getTrustStorePassword().toCharArray(),
"JKS",
true);
ldapConfiguration.getTrustStore(),
ldapConfiguration.getTrustStorePassword().toCharArray(),
"JKS",
true);
SSLSocketFactory socketFactory = new SSLUtil(trustStoreTrustManager).createSSLSocketFactory();

ldapConnection = new LDAPConnection(
socketFactory,
options,
ldapConfiguration.getServer(),
ldapConfiguration.getPort(),
ldapConfiguration.getBindDn(),
ldapConfiguration.getBindDNPwd()
);
ldapConnection = new LDAPConnection(socketFactory, options);
} else {
LOGGER.warn("Not using SSL to connect to ldap. This is very insecure - do not use in prod environments!");

ldapConnection = new LDAPConnection(
options,
ldapConfiguration.getServer(),
ldapConfiguration.getPort(),
ldapConfiguration.getBindDn(),
ldapConfiguration.getBindDNPwd()
);
ldapConnection = new LDAPConnection(options);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This and the above branch use a different LDAPConnection constructor so we create an LDAPConnection without actually connecting yet.

}

return ldapConnection;
}

private void connectIfNeeded() throws LDAPException {
if (connectionPool != null) {
return;
}

BindRequest bindRequest = new SimpleBindRequest(ldapConfiguration.getBindDn(), ldapConfiguration.getBindDNPwd());
BindResult bindResult = ldapConnection.bind(bindRequest);
if (!readyForConnectionAttempt()) {
// It's too early to retry connecting again.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know we want to have automated connection retries after a certain time period (if no requests come in that trigger connectIfNeeded()), but if we're getting requests, why would we want to wait, as it is possible that the connection works the next time you try it before the specified time interval passes.

Copy link
Contributor Author

@alechenninger alechenninger Jan 8, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If all requests wait, and it takes a while, we could fill thread pools server side and/or client side as lots of requests could pile up. It could also cause cascading failure if timeouts are hit by clients. It's the same idea as hystrix. We can control how often we want to retry with the retry interval config parameter. There's not much advantage I think to retrying multiple times within a second for example, but I suppose someone could even configure that time to 0 and then we would constantly retry.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I guess we wouldn't define a long interval anyway, so what you're saying makes sense.

throw lastSeenConnectionException();
}

if (bindResult.getResultCode() != ResultCode.SUCCESS) {
LOGGER.error("Error binding to LDAP" + bindResult.getResultCode());
throw new LDAPException(bindResult.getResultCode(), "Error binding to LDAP");
// Multiple threads can get to this point, but establishing a connection is not thread safe
// (see LDAPConnection#connect), so we must synchronize access. Instead of blocking all
// threads except the one that makes it to this point and wins the race to connect, we fail
// the "loser" threads fast like when the connection retry interval has not been met, to
// prevent potentially many requests from waiting potentially a long wait. We do this via
// atomic compare and swap: see if attemptingConnect flag is false, and if so set it as one
// atomic operation. If attemptingConnect is true, we fail fast.
if (!attemptingConnect.compareAndSet(/*expect*/ false, /*update*/ true)) {
// attemptingConnect was true, which means another thread is connecting. Fail fast now
// instead of blocking.
throw lastSeenConnectionException();
}

connectionPool = new LDAPConnectionPool(ldapConnection, ldapConfiguration.getPoolSize());
connectionPool.setMaxConnectionAgeMillis(ldapConfiguration.getPoolMaxConnectionAgeMS());
LOGGER.info("Initialized LDAPConnectionPool: poolSize={}, poolMaxAge={}, connectionTimeout={}, responseTimeout={}, debug={}, keepAlive={}.",
try {
if (lastConnectionAttempt != null) {
LOGGER.info("Connection retry interval ({} seconds) passed, " +
"attempting connection recovery to LDAP at {}:{}",
ldapConfiguration.getRetryIntervalSeconds(),
ldapConfiguration.getServer(),
ldapConfiguration.getPort());
}

lastConnectionAttempt = Instant.now();
BindResult bindResult = null;

if (!ldapConnection.isConnected()) {
ldapConnection.connect(
ldapConfiguration.getServer(), ldapConfiguration.getPort());
bindResult = ldapConnection.bind(
ldapConfiguration.getBindDn(), ldapConfiguration.getBindDNPwd());
} else if (ldapConnection.getLastBindRequest() == null) {
bindResult = ldapConnection.bind(
ldapConfiguration.getBindDn(), ldapConfiguration.getBindDNPwd());
}

if (bindResult != null && bindResult.getResultCode() != ResultCode.SUCCESS) {
LOGGER.error("Error binding to LDAP" + bindResult.getResultCode());
throw new LDAPException(bindResult.getResultCode(), "Error binding to LDAP");
}

connectionPool = new LDAPConnectionPool(ldapConnection, ldapConfiguration.getPoolSize());
connectionPool.setMaxConnectionAgeMillis(ldapConfiguration.getPoolMaxConnectionAgeMS());

LOGGER.info("Initialized LDAPConnectionPool: poolSize={}, poolMaxAge={}, connectionTimeout={}, responseTimeout={}, debug={}, keepAlive={}.",
ldapConfiguration.getPoolSize(), ldapConfiguration.getPoolMaxConnectionAgeMS(), ldapConfiguration.getConnectionTimeoutMS(), ldapConfiguration.getResponseTimeoutMS(),
ldapConfiguration.isDebug(), ldapConfiguration.isKeepAlive());

} catch (LDAPException e) {
connectionException = e;
throw e;
} finally {
attemptingConnect.set(false);
}
}

@Override
Expand All @@ -154,6 +215,8 @@ public Set<String> getUserRoles(String username) throws Exception {

Objects.requireNonNull(username);

connectIfNeeded();

Set<String> roles = new HashSet<>();

String filter = "(uid=" + username + ")";
Expand Down Expand Up @@ -187,4 +250,25 @@ public Set<String> getUserRoles(String username) throws Exception {
return roles;
}

private LDAPException lastSeenConnectionException() {
if (connectionException == null) {
throw new IllegalStateException("Expected connection exception, but was null. There " +
"was probably a problem connecting but the exception is unknown. Please report " +
"this bug to maintainers: " +
"https://github.com/esbtools/cert-ldap-login-module/issues/new");
}

return connectionException;
}

private boolean readyForConnectionAttempt() {
if (lastConnectionAttempt == null) {
return true;
}

Duration timeSinceLastAttempt = Duration.between(lastConnectionAttempt, Instant.now());
Duration retryInterval = Duration.ofSeconds(ldapConfiguration.getRetryIntervalSeconds());

return timeSinceLastAttempt.compareTo(retryInterval) >= 0;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package org.esbtools.auth.ldap;

import static org.junit.Assert.fail;

import com.redhat.lightblue.ldap.test.LdapServerExternalResource;

import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
import com.unboundid.ldap.listener.InMemoryListenerConfig;
import com.unboundid.ldap.sdk.Attribute;
import com.unboundid.ldap.sdk.LDAPException;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;

public class LdapRoleProviderRecoveryTest {
private final LdapConfiguration ldapConfiguration = new LdapConfiguration()
.bindDn("uid=admin,dc=com")
.bindDNPwd("password")
.server("localhost")
.port(LdapServerExternalResource.DEFAULT_PORT)
.retryIntervalSeconds(1);

private InMemoryDirectoryServer ldapServer;

@Before
public void createButDoNotStartLdapServer() throws LDAPException {
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig("dc=com");
config.addAdditionalBindCredentials("uid=admin,dc=com", "password");

InMemoryListenerConfig listenerConfig = new InMemoryListenerConfig(
"test", null, LdapServerExternalResource.DEFAULT_PORT, null, null, null);
config.setListenerConfigs(listenerConfig);
config.setSchema(null);

ldapServer = new InMemoryDirectoryServer(config);

ldapServer.add("dc=com", new Attribute("objectClass", "top"),
new Attribute("objectClass", "domain"),
new Attribute("dc", "com"));
}

@After
public void stopLdapServer() {
ldapServer.shutDown(true);
}

@Test
public void eventuallyConnectsToLdapIfNotFailFast() throws Exception {
LdapRolesProvider provider = null;

try {
provider = new LdapRolesProvider(
LdapServerExternalResource.DEFAULT_BASE_DN,
ldapConfiguration, false);
} catch (LDAPException e) {
fail("Expected not to fail fast if failFast is false but got: " + e);
}

try {
provider.getUserRoles("test");
fail("Expected exception since LDAP server is not yet started, but no exception was thrown");
} catch (LDAPException expected) {
// fall through
}

// Above should fail even if server already started due to retry interval, however it's possible
// (if unlikely) the test could take a second before calling getUserRoles which would mean it
// would attempt the connection, succeed, then fail the test. Thus to avoid a flakey test,
// not testing that case exactly.
ldapServer.startListening();

// Wait retry interval...
Thread.sleep(1000);

try {
provider.getUserRoles("test");
// pass!
} catch (LDAPException e) {
fail("Expected to recover after retry interval but got: " + e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,13 @@ public class CertLdapLoginModule extends BaseCertLoginModule {
public static final String ROLES_CACHE_EXPIRY_MS = "rolesCacheExpiryMS";
public static final String ENVIRONMENT = "environment";
public static final String ALL_ACCESS_OU = "allAccessOu";
public static final String RETRY_INTERVAL_SECONDS = "retryIntervalSeconds";

private static final String[] ALL_VALID_OPTIONS = {
AUTH_ROLE_NAME, SERVER, PORT, SEARCH_BASE, BIND_DN, BIND_PWD, USE_SSL,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need to add RETRY_INTERVAL_SECONDS to ALL_VALID_OPTIONS, otherwise we'll get warnings in the logs

TRUST_STORE, TRUST_STORE_PASSWORD, POOL_SIZE, POOL_MAX_CONNECTION_AGE_MS,
CONNECTION_TIMEOUT_MS,RESPONSE_TIMEOUT_MS,DEBUG,KEEP_ALIVE,
ROLES_CACHE_EXPIRY_MS, ENVIRONMENT, ALL_ACCESS_OU};
ROLES_CACHE_EXPIRY_MS, ENVIRONMENT, ALL_ACCESS_OU, RETRY_INTERVAL_SECONDS};

public static final String UID = "uid";
public static final String CN = "cn";
Expand Down Expand Up @@ -119,13 +120,16 @@ public void initializeRolesProvider() throws Exception {
if (options.containsKey(POOL_MAX_CONNECTION_AGE_MS)) {
ldapConf.poolMaxConnectionAgeMS(Integer.parseInt((String)options.get(POOL_MAX_CONNECTION_AGE_MS)));
}
if (options.containsKey(RETRY_INTERVAL_SECONDS)) {
ldapConf.retryIntervalSeconds(Integer.parseInt((String) options.get(RETRY_INTERVAL_SECONDS)));
}

int rolesCacheExpiry = 5*60*1000; // default 5 minutes
if (options.containsKey(ROLES_CACHE_EXPIRY_MS)) {
rolesCacheExpiry = Integer.parseInt((String)options.get(ROLES_CACHE_EXPIRY_MS));
}

rolesProvider = new CachedRolesProvider(new LdapRolesProvider(searchBase, ldapConf), new RolesCache(rolesCacheExpiry));
rolesProvider = new CachedRolesProvider(new LdapRolesProvider(searchBase, ldapConf, false), new RolesCache(rolesCacheExpiry));
}
}
}
Expand Down
Loading