diff --git a/docs/changelog/104320.yaml b/docs/changelog/104320.yaml new file mode 100644 index 0000000000000..d2b0d09070fb9 --- /dev/null +++ b/docs/changelog/104320.yaml @@ -0,0 +1,5 @@ +pr: 104320 +summary: Hot-reloadable LDAP bind password +area: Authentication +type: enhancement +issues: [] diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java index ba91f22527b0d..c59ccd8f73ed0 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java @@ -179,7 +179,6 @@ import org.elasticsearch.xpack.core.security.authc.Realm; import org.elasticsearch.xpack.core.security.authc.RealmConfig; import org.elasticsearch.xpack.core.security.authc.RealmSettings; -import org.elasticsearch.xpack.core.security.authc.jwt.JwtRealmSettings; import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken; import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine; import org.elasticsearch.xpack.core.security.authz.AuthorizationServiceField; @@ -372,6 +371,7 @@ import org.elasticsearch.xpack.security.rest.action.user.RestSetEnabledAction; import org.elasticsearch.xpack.security.support.CacheInvalidatorRegistry; import org.elasticsearch.xpack.security.support.ExtensionComponents; +import org.elasticsearch.xpack.security.support.ReloadableSecurityComponent; import org.elasticsearch.xpack.security.support.SecuritySystemIndices; import org.elasticsearch.xpack.security.transport.SecurityHttpSettings; import org.elasticsearch.xpack.security.transport.SecurityServerTransportInterceptor; @@ -410,7 +410,6 @@ import static org.elasticsearch.xpack.core.XPackSettings.API_KEY_SERVICE_ENABLED_SETTING; import static org.elasticsearch.xpack.core.XPackSettings.HTTP_SSL_ENABLED; import static org.elasticsearch.xpack.core.security.SecurityField.FIELD_LEVEL_SECURITY_FEATURE; -import static org.elasticsearch.xpack.core.security.authc.jwt.JwtRealmSettings.CLIENT_AUTHENTICATION_SHARED_SECRET; import static org.elasticsearch.xpack.core.security.authz.store.ReservedRolesStore.INCLUDED_RESERVED_ROLES_SETTING; import static org.elasticsearch.xpack.security.operator.OperatorPrivileges.OPERATOR_PRIVILEGES_ENABLED; import static org.elasticsearch.xpack.security.transport.SSLEngineUtils.extractClientCertificates; @@ -564,6 +563,7 @@ public class Security extends Plugin private final SetOnce workflowService = new SetOnce<>(); private final SetOnce realms = new SetOnce<>(); private final SetOnce client = new SetOnce<>(); + private final SetOnce> reloadableComponents = new SetOnce<>(); public Security(Settings settings) { this(settings, Collections.emptyList()); @@ -635,8 +635,8 @@ protected Client getClient() { return client.get(); } - protected Realms getRealms() { - return realms.get(); + protected List getReloadableSecurityComponents() { + return this.reloadableComponents.get(); } @Override @@ -1046,6 +1046,13 @@ Collection createComponents( cacheInvalidatorRegistry.validate(); + this.reloadableComponents.set( + components.stream() + .filter(ReloadableSecurityComponent.class::isInstance) + .map(ReloadableSecurityComponent.class::cast) + .collect(Collectors.toUnmodifiableList()) + ); + return components; } @@ -1948,11 +1955,13 @@ public void reload(Settings settings) throws Exception { reloadExceptions.add(ex); } - try { - reloadSharedSecretsForJwtRealms(settings); - } catch (Exception ex) { - reloadExceptions.add(ex); - } + this.getReloadableSecurityComponents().forEach(component -> { + try { + component.reload(settings); + } catch (Exception ex) { + reloadExceptions.add(ex); + } + }); if (false == reloadExceptions.isEmpty()) { final var combinedException = new ElasticsearchException( @@ -1966,16 +1975,6 @@ public void reload(Settings settings) throws Exception { } } - private void reloadSharedSecretsForJwtRealms(Settings settingsWithKeystore) { - getRealms().stream().filter(r -> JwtRealmSettings.TYPE.equals(r.realmRef().getType())).forEach(realm -> { - if (realm instanceof JwtRealm jwtRealm) { - jwtRealm.rotateClientSecret( - CLIENT_AUTHENTICATION_SHARED_SECRET.getConcreteSettingForNamespace(realm.realmRef().getName()).get(settingsWithKeystore) - ); - } - }); - } - /** * This method uses a transport action internally to access classes that are injectable but not part of the plugin contract. * See {@link TransportReloadRemoteClusterCredentialsAction} for more context. diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/Realms.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/Realms.java index 0735eccff9913..2ca70bee55d4e 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/Realms.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/Realms.java @@ -8,6 +8,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.elasticsearch.ElasticsearchException; import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.support.RefCountingRunnable; import org.elasticsearch.common.Strings; @@ -35,6 +36,7 @@ import org.elasticsearch.xpack.core.security.authc.kerberos.KerberosRealmSettings; import org.elasticsearch.xpack.security.Security; import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm; +import org.elasticsearch.xpack.security.support.ReloadableSecurityComponent; import java.io.Closeable; import java.io.IOException; @@ -57,7 +59,7 @@ /** * Serves as a realms registry (also responsible for ordering the realms appropriately) */ -public class Realms extends AbstractLifecycleComponent implements Iterable { +public class Realms extends AbstractLifecycleComponent implements Iterable, ReloadableSecurityComponent { private static final Logger logger = LogManager.getLogger(Realms.class); private static final DeprecationLogger deprecationLogger = DeprecationLogger.getLogger(logger.getName()); @@ -566,4 +568,23 @@ private static Map convertToMapOfLists(Map map) } return converted; } + + @Override + public void reload(Settings settings) { + final List reloadExceptions = new ArrayList<>(); + for (Realm realm : this.allConfiguredRealms) { + if (realm instanceof ReloadableSecurityComponent reloadableRealm) { + try { + reloadableRealm.reload(settings); + } catch (Exception e) { + reloadExceptions.add(e); + } + } + } + if (false == reloadExceptions.isEmpty()) { + final var combinedException = new ElasticsearchException("secure settings reload failed for one or more realms"); + reloadExceptions.forEach(combinedException::addSuppressed); + throw combinedException; + } + } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/jwt/JwtRealm.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/jwt/JwtRealm.java index d8b0575c54d36..bef342d330f34 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/jwt/JwtRealm.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/jwt/JwtRealm.java @@ -19,6 +19,7 @@ import org.elasticsearch.common.cache.CacheBuilder; import org.elasticsearch.common.settings.RotatableSecret; import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.settings.SettingsException; import org.elasticsearch.common.util.concurrent.ReleasableLock; import org.elasticsearch.common.util.concurrent.ThreadContext; @@ -40,6 +41,7 @@ import org.elasticsearch.xpack.core.ssl.SSLService; import org.elasticsearch.xpack.security.authc.support.ClaimParser; import org.elasticsearch.xpack.security.authc.support.DelegatedAuthorizationSupport; +import org.elasticsearch.xpack.security.support.ReloadableSecurityComponent; import java.util.Collection; import java.util.Collections; @@ -51,13 +53,14 @@ import static java.lang.String.join; import static org.elasticsearch.core.Strings.format; +import static org.elasticsearch.xpack.core.security.authc.jwt.JwtRealmSettings.CLIENT_AUTHENTICATION_SHARED_SECRET; import static org.elasticsearch.xpack.core.security.authc.jwt.JwtRealmSettings.CLIENT_AUTH_SHARED_SECRET_ROTATION_GRACE_PERIOD; /** * JWT realms supports JWTs as bearer tokens for authenticating to Elasticsearch. * For security, it is recommended to authenticate the client too. */ -public class JwtRealm extends Realm implements CachingRealm, Releasable { +public class JwtRealm extends Realm implements CachingRealm, ReloadableSecurityComponent, Releasable { private static final String LATEST_MALFORMED_JWT = "_latest_malformed_jwt"; @@ -399,7 +402,9 @@ public void usageStats(final ActionListener> listener) { }, listener::onFailure)); } - public void rotateClientSecret(SecureString clientSecret) { + @Override + public void reload(Settings settings) { + var clientSecret = CLIENT_AUTHENTICATION_SHARED_SECRET.getConcreteSettingForNamespace(this.realmRef().getName()).get(settings); this.clientAuthenticationSharedSecret.rotate(clientSecret, config.getSetting(CLIENT_AUTH_SHARED_SECRET_ROTATION_GRACE_PERIOD)); } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ldap/ActiveDirectorySessionFactory.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ldap/ActiveDirectorySessionFactory.java index 6908519483c3e..495fdfe4cc0f2 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ldap/ActiveDirectorySessionFactory.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ldap/ActiveDirectorySessionFactory.java @@ -46,6 +46,7 @@ import java.util.List; import java.util.Optional; import java.util.concurrent.ExecutionException; +import java.util.function.Supplier; import static org.elasticsearch.xpack.security.authc.ldap.support.LdapUtils.attributesToSearchFor; import static org.elasticsearch.xpack.security.authc.ldap.support.LdapUtils.createFilter; @@ -102,7 +103,8 @@ class ActiveDirectorySessionFactory extends PoolingSessionFactory { groupResolver, metadataResolver, domainDN, - threadPool + threadPool, + this::getBindRequest ); downLevelADAuthenticator = new DownLevelADAuthenticator( config, @@ -117,7 +119,8 @@ class ActiveDirectorySessionFactory extends PoolingSessionFactory { ldapPort, ldapsPort, gcLdapPort, - gcLdapsPort + gcLdapsPort, + this::getBindRequest ); upnADAuthenticator = new UpnADAuthenticator( config, @@ -127,7 +130,8 @@ class ActiveDirectorySessionFactory extends PoolingSessionFactory { groupResolver, metadataResolver, domainDN, - threadPool + threadPool, + this::getBindRequest ); } @@ -187,7 +191,7 @@ void getUnauthenticatedSessionWithoutPool(String user, ActionListener bindRequestSupplier; final ThreadPool threadPool; ADAuthenticator( @@ -288,7 +291,8 @@ abstract static class ADAuthenticator { String domainDN, Setting.AffixSetting userSearchFilterSetting, String defaultUserSearchFilter, - ThreadPool threadPool + ThreadPool threadPool, + Supplier bindRequestSupplier ) { this.realm = realm; this.timeout = timeout; @@ -296,11 +300,7 @@ abstract static class ADAuthenticator { this.logger = logger; this.groupsResolver = groupsResolver; this.metadataResolver = metadataResolver; - this.bindDN = getBindDN(realm); - this.bindPassword = realm.getSetting( - PoolingSessionFactorySettings.SECURE_BIND_PASSWORD, - () -> realm.getSetting(PoolingSessionFactorySettings.LEGACY_BIND_PASSWORD) - ); + this.bindRequestSupplier = bindRequestSupplier; this.threadPool = threadPool; userSearchDN = realm.getSetting(ActiveDirectorySessionFactorySettings.AD_USER_SEARCH_BASEDN_SETTING, () -> domainDN); userSearchScope = LdapSearchScope.resolve( @@ -348,10 +348,10 @@ protected void doRun() throws Exception { }, e -> { listener.onFailure(e); })); } }; - if (bindDN.isEmpty()) { + final SimpleBindRequest bind = bindRequestSupplier.get(); + if (bind.getBindDN().isEmpty()) { searchRunnable.run(); } else { - final SimpleBindRequest bind = new SimpleBindRequest(bindDN, CharArrays.toUtf8Bytes(bindPassword.getChars())); LdapUtils.maybeForkThenBind(connection, bind, true, threadPool, searchRunnable); } } @@ -423,7 +423,8 @@ static class DefaultADAuthenticator extends ADAuthenticator { GroupsResolver groupsResolver, LdapMetadataResolver metadataResolver, String domainDN, - ThreadPool threadPool + ThreadPool threadPool, + Supplier bindRequestSupplier ) { super( realm, @@ -435,7 +436,8 @@ static class DefaultADAuthenticator extends ADAuthenticator { domainDN, ActiveDirectorySessionFactorySettings.AD_USER_SEARCH_FILTER_SETTING, "(&(objectClass=user)(|(sAMAccountName={0})(userPrincipalName={0}@" + domainName(realm) + ")))", - threadPool + threadPool, + bindRequestSupplier ); domainName = domainName(realm); } @@ -503,7 +505,8 @@ static class DownLevelADAuthenticator extends ADAuthenticator { int ldapPort, int ldapsPort, int gcLdapPort, - int gcLdapsPort + int gcLdapsPort, + Supplier bindRequestSupplier ) { super( config, @@ -515,7 +518,8 @@ static class DownLevelADAuthenticator extends ADAuthenticator { domainDN, ActiveDirectorySessionFactorySettings.AD_DOWN_LEVEL_USER_SEARCH_FILTER_SETTING, DOWN_LEVEL_FILTER, - threadPool + threadPool, + bindRequestSupplier ); this.domainDN = domainDN; this.sslService = sslService; @@ -605,10 +609,9 @@ void netBiosDomainNameToDn( ) ); final byte[] passwordBytes = CharArrays.toUtf8Bytes(password.getChars()); - final boolean bindAsAuthenticatingUser = this.bindDN.isEmpty(); - final SimpleBindRequest bind = bindAsAuthenticatingUser - ? new SimpleBindRequest(username, passwordBytes) - : new SimpleBindRequest(bindDN, CharArrays.toUtf8Bytes(bindPassword.getChars())); + final SimpleBindRequest bindRequest = bindRequestSupplier.get(); + final boolean bindAsAuthenticatingUser = bindRequest.getBindDN().isEmpty(); + final SimpleBindRequest bind = bindAsAuthenticatingUser ? new SimpleBindRequest(username, passwordBytes) : bindRequest; ActionRunnable body = new ActionRunnable<>(listener) { @Override protected void doRun() throws Exception { @@ -705,7 +708,8 @@ static class UpnADAuthenticator extends ADAuthenticator { GroupsResolver groupsResolver, LdapMetadataResolver metadataResolver, String domainDN, - ThreadPool threadPool + ThreadPool threadPool, + Supplier bindRequestSupplier ) { super( config, @@ -717,7 +721,8 @@ static class UpnADAuthenticator extends ADAuthenticator { domainDN, ActiveDirectorySessionFactorySettings.AD_UPN_USER_SEARCH_FILTER_SETTING, UPN_USER_FILTER, - threadPool + threadPool, + bindRequestSupplier ); if (userSearchFilter.contains("{0}")) { deprecationLogger.warn( diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ldap/LdapRealm.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ldap/LdapRealm.java index 91b49f39b4b3c..48dd0fda5b569 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ldap/LdapRealm.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ldap/LdapRealm.java @@ -13,6 +13,7 @@ import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.support.ContextPreservingActionListener; import org.elasticsearch.common.settings.Setting; +import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.util.concurrent.AbstractRunnable; import org.elasticsearch.common.util.concurrent.EsExecutors; import org.elasticsearch.common.util.concurrent.ThreadContext; @@ -40,6 +41,7 @@ import org.elasticsearch.xpack.security.authc.support.DelegatedAuthorizationSupport; import org.elasticsearch.xpack.security.authc.support.mapper.CompositeRoleMapper; import org.elasticsearch.xpack.security.authc.support.mapper.NativeRoleMappingStore; +import org.elasticsearch.xpack.security.support.ReloadableSecurityComponent; import java.util.HashMap; import java.util.List; @@ -57,7 +59,7 @@ /** * Authenticates username/password tokens against ldap, locates groups and maps them to roles. */ -public final class LdapRealm extends CachingUsernamePasswordRealm { +public final class LdapRealm extends CachingUsernamePasswordRealm implements ReloadableSecurityComponent { private final SessionFactory sessionFactory; private final UserRoleMapper roleMapper; @@ -217,6 +219,11 @@ public void usageStats(ActionListener> listener) { }, listener::onFailure)); } + @Override + public void reload(Settings settings) { + this.sessionFactory.reload(settings); + } + private static void buildUser( LdapSession session, String username, diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ldap/LdapSessionFactory.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ldap/LdapSessionFactory.java index 4e390c86ba1f1..e0c57dc8b19a3 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ldap/LdapSessionFactory.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ldap/LdapSessionFactory.java @@ -13,6 +13,7 @@ import org.elasticsearch.action.ActionListener; import org.elasticsearch.common.Strings; import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.util.concurrent.AbstractRunnable; import org.elasticsearch.core.CharArrays; import org.elasticsearch.core.IOUtils; @@ -120,6 +121,11 @@ void loop() { } } + @Override + public void reload(Settings settings) { + // nothing to reload in DN template mode + } + /** * Securely escapes the username and inserts it into the template using MessageFormat * diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ldap/LdapUserSearchSessionFactory.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ldap/LdapUserSearchSessionFactory.java index d177ffbefebf5..362891ae9db7f 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ldap/LdapUserSearchSessionFactory.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ldap/LdapUserSearchSessionFactory.java @@ -127,6 +127,7 @@ void getSessionWithPool(LDAPConnectionPool connectionPool, String user, SecureSt void getSessionWithoutPool(String user, SecureString password, ActionListener listener) { try { final LDAPConnection connection = LdapUtils.privilegedConnect(serverSet::getConnection); + final SimpleBindRequest bindCredentials = this.getBindRequest(); LdapUtils.maybeForkThenBind(connection, bindCredentials, true, threadPool, new AbstractRunnable() { @Override protected void doRun() throws Exception { @@ -222,7 +223,7 @@ void getUnauthenticatedSessionWithPool(LDAPConnectionPool connectionPool, String void getUnauthenticatedSessionWithoutPool(String user, ActionListener listener) { try { final LDAPConnection connection = LdapUtils.privilegedConnect(serverSet::getConnection); - LdapUtils.maybeForkThenBind(connection, bindCredentials, true, threadPool, new AbstractRunnable() { + LdapUtils.maybeForkThenBind(connection, getBindRequest(), true, threadPool, new AbstractRunnable() { @Override protected void doRun() throws Exception { findUser(user, connection, ActionListener.wrap((entry) -> { diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ldap/PoolingSessionFactory.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ldap/PoolingSessionFactory.java index ae48b1c1dc1b2..24bdb9243aef7 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ldap/PoolingSessionFactory.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ldap/PoolingSessionFactory.java @@ -19,6 +19,7 @@ import org.elasticsearch.common.logging.DeprecationCategory; import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Setting; +import org.elasticsearch.common.settings.Settings; import org.elasticsearch.core.CharArrays; import org.elasticsearch.core.Nullable; import org.elasticsearch.core.Releasable; @@ -32,6 +33,7 @@ import org.elasticsearch.xpack.security.authc.ldap.support.LdapUtils; import org.elasticsearch.xpack.security.authc.ldap.support.SessionFactory; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Supplier; import static org.elasticsearch.core.Strings.format; @@ -46,7 +48,8 @@ abstract class PoolingSessionFactory extends SessionFactory implements Releasabl private final boolean useConnectionPool; private final LDAPConnectionPool connectionPool; - final SimpleBindRequest bindCredentials; + private final String bindDn; + private final AtomicReference bindRequest; final LdapSession.GroupsResolver groupResolver; /** @@ -69,27 +72,36 @@ abstract class PoolingSessionFactory extends SessionFactory implements Releasabl ) throws LDAPException { super(config, sslService, threadPool); this.groupResolver = groupResolver; + this.bindDn = bindDn; + this.bindRequest = new AtomicReference<>(buildBindRequest(config.settings())); + this.useConnectionPool = config.getSetting(poolingEnabled); + if (useConnectionPool) { + this.connectionPool = createConnectionPool(config, serverSet, timeout, logger, bindRequest.get(), healthCheckDNSupplier); + } else { + this.connectionPool = null; + } + } + private SimpleBindRequest buildBindRequest(Settings settings) { final byte[] bindPassword; - if (config.hasSetting(LEGACY_BIND_PASSWORD)) { - if (config.hasSetting(SECURE_BIND_PASSWORD)) { + final Setting legacyPasswordSetting = config.getConcreteSetting(LEGACY_BIND_PASSWORD); + final Setting securePasswordSetting = config.getConcreteSetting(SECURE_BIND_PASSWORD); + + if (legacyPasswordSetting.exists(settings)) { + if (securePasswordSetting.exists(settings)) { throw new IllegalArgumentException( - "You cannot specify both [" - + RealmSettings.getFullSettingKey(config, LEGACY_BIND_PASSWORD) - + "] and [" - + RealmSettings.getFullSettingKey(config, SECURE_BIND_PASSWORD) - + "]" + "You cannot specify both [" + legacyPasswordSetting.getKey() + "] and [" + securePasswordSetting.getKey() + "]" ); } - bindPassword = CharArrays.toUtf8Bytes(config.getSetting(LEGACY_BIND_PASSWORD).getChars()); - } else if (config.hasSetting(SECURE_BIND_PASSWORD)) { - bindPassword = CharArrays.toUtf8Bytes(config.getSetting(SECURE_BIND_PASSWORD).getChars()); + bindPassword = CharArrays.toUtf8Bytes(legacyPasswordSetting.get(settings).getChars()); + } else if (securePasswordSetting.exists(settings)) { + bindPassword = CharArrays.toUtf8Bytes(securePasswordSetting.get(settings).getChars()); } else { bindPassword = null; } - if (bindDn == null) { - bindCredentials = new SimpleBindRequest(); + if (this.bindDn == null) { + return new SimpleBindRequest(); } else { if (bindPassword == null) { deprecationLogger.critical( @@ -104,17 +116,32 @@ abstract class PoolingSessionFactory extends SessionFactory implements Releasabl RealmSettings.getFullSettingKey(config, LEGACY_BIND_PASSWORD) ); } - bindCredentials = new SimpleBindRequest(bindDn, bindPassword); + return new SimpleBindRequest(this.bindDn, bindPassword); } + } - this.useConnectionPool = config.getSetting(poolingEnabled); - if (useConnectionPool) { - this.connectionPool = createConnectionPool(config, serverSet, timeout, logger, bindCredentials, healthCheckDNSupplier); - } else { - this.connectionPool = null; + @Override + public void reload(Settings settings) { + final SimpleBindRequest oldRequest = bindRequest.get(); + final SimpleBindRequest newRequest = buildBindRequest(settings); + if (bindRequestEquals(newRequest, oldRequest) == false) { + if (bindRequest.compareAndSet(oldRequest, newRequest)) { + if (connectionPool != null) { + // When a connection is open and already bound, changing the bind password does not affect + // the existing pooled connections. LDAP connections are stateful, and once a connection is + // established and bound, it remains open until explicitly closed or until a connection + // timeout occurs. Changing the bind password on the LDAP server does not automatically + // invalidate existing connections. Hence, simply setting the new bind request is sufficient. + connectionPool.setBindRequest(bindRequest.get()); + } + } } } + private static boolean bindRequestEquals(SimpleBindRequest req1, SimpleBindRequest req2) { + return req1.getBindDN().contentEquals(req2.getBindDN()) && req1.getPassword().equalsIgnoreType(req2.getPassword()); + } + @Override public final void session(String user, SecureString password, ActionListener listener) { if (useConnectionPool) { @@ -238,4 +265,8 @@ LDAPConnectionPool getConnectionPool() { return connectionPool; } + SimpleBindRequest getBindRequest() { + return bindRequest.get(); + } + } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ldap/support/SessionFactory.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ldap/support/SessionFactory.java index 5d260266d3f20..2a8625b2d93fb 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ldap/support/SessionFactory.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ldap/support/SessionFactory.java @@ -27,6 +27,7 @@ import org.elasticsearch.xpack.core.security.authc.ldap.support.SessionFactorySettings; import org.elasticsearch.xpack.core.ssl.SSLConfigurationSettings; import org.elasticsearch.xpack.core.ssl.SSLService; +import org.elasticsearch.xpack.security.support.ReloadableSecurityComponent; import java.io.Closeable; import java.io.IOException; @@ -49,7 +50,7 @@ * } * */ -public abstract class SessionFactory implements Closeable { +public abstract class SessionFactory implements Closeable, ReloadableSecurityComponent { private static final Pattern STARTS_WITH_LDAPS = Pattern.compile("^ldaps:.*", Pattern.CASE_INSENSITIVE); private static final Pattern STARTS_WITH_LDAP = Pattern.compile("^ldap:.*", Pattern.CASE_INSENSITIVE); diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/ReloadableSecurityComponent.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/ReloadableSecurityComponent.java new file mode 100644 index 0000000000000..7bd9023964715 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/ReloadableSecurityComponent.java @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.support; + +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.plugins.ReloadablePlugin; + +/** + * This interface allows adding support for reload operations (on secure settings change) in a generic way for security components. + * The implementors of this interface will be called when the values of {@code SecureSetting}s should be reloaded by security plugin. + * For more information about reloading plugin secure settings, see {@link ReloadablePlugin}. + */ +public interface ReloadableSecurityComponent { + + /** + * Called when a reload security settings action is executed. The reload operation + * must be completed when this method returns. Strictly speaking, the + * settings argument should not be accessed outside of this method's + * call stack, as any values stored in the node's keystore (see {@code SecureSetting}) + * will not otherwise be retrievable. + *

+ * There is no guarantee that the secure setting's values have actually changed. + * Hence, it's up to implementor to detect if the actual internal reloading is + * necessary. + *

+ * Any failure during the reloading should be signaled by raising an exception. + *

+ * For additional info, see also: {@link ReloadablePlugin#reload(Settings)}. + * + * @param settings + * Settings include the initial node's settings and all decrypted + * secure settings from the keystore. Absence of a particular secure + * setting may mean that the setting was either never configured or + * that it was simply removed. + */ + void reload(Settings settings); + +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/SecurityTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/SecurityTests.java index 6cd12858a12c1..5cffc048d9416 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/SecurityTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/SecurityTests.java @@ -97,11 +97,13 @@ import org.elasticsearch.xpack.security.authc.Realms; import org.elasticsearch.xpack.security.authc.esnative.NativeUsersStore; import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm; +import org.elasticsearch.xpack.security.authc.jwt.JwtRealm; import org.elasticsearch.xpack.security.authc.service.CachingServiceAccountTokenStore; import org.elasticsearch.xpack.security.operator.DefaultOperatorOnlyRegistry; import org.elasticsearch.xpack.security.operator.OperatorOnlyRegistry; import org.elasticsearch.xpack.security.operator.OperatorPrivileges; import org.elasticsearch.xpack.security.operator.OperatorPrivilegesViolation; +import org.elasticsearch.xpack.security.support.ReloadableSecurityComponent; import org.hamcrest.Matchers; import org.junit.After; @@ -121,7 +123,6 @@ import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Collectors; -import java.util.stream.Stream; import static java.util.Collections.emptyMap; import static org.elasticsearch.test.LambdaMatchers.falseWith; @@ -142,7 +143,9 @@ import static org.hamcrest.Matchers.nullValue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -967,8 +970,8 @@ public void testReload() throws Exception { final PlainActionFuture value = new PlainActionFuture<>(); final Client mockedClient = mock(Client.class); - final Realms mockedRealms = mock(Realms.class); - when(mockedRealms.stream()).thenReturn(Stream.of()); + final JwtRealm mockedJwtRealm = mock(JwtRealm.class); + final List reloadableComponents = List.of(mockedJwtRealm); doAnswer((inv) -> { @SuppressWarnings("unchecked") @@ -984,8 +987,8 @@ protected Client getClient() { } @Override - protected Realms getRealms() { - return mockedRealms; + protected List getReloadableSecurityComponents() { + return reloadableComponents; } }; @@ -993,14 +996,16 @@ protected Realms getRealms() { security.reload(inputSettings); verify(mockedClient).execute(eq(ActionTypes.RELOAD_REMOTE_CLUSTER_CREDENTIALS_ACTION), any(), any()); - verify(mockedRealms).stream(); + verify(mockedJwtRealm).reload(same(inputSettings)); } - public void testReloadWithFailures() { + public void testReloadWithFailures() throws Exception { final Settings settings = Settings.builder().put("xpack.security.enabled", true).put("path.home", createTempDir()).build(); final boolean failRemoteClusterCredentialsReload = randomBoolean(); final Client mockedClient = mock(Client.class); + final JwtRealm mockedJwtRealm = mock(JwtRealm.class); + final List reloadableComponents = List.of(mockedJwtRealm); if (failRemoteClusterCredentialsReload) { doAnswer((inv) -> { @SuppressWarnings("unchecked") @@ -1017,12 +1022,9 @@ public void testReloadWithFailures() { }).when(mockedClient).execute(eq(ActionTypes.RELOAD_REMOTE_CLUSTER_CREDENTIALS_ACTION), any(), any()); } - final Realms mockedRealms = mock(Realms.class); final boolean failRealmsReload = (false == failRemoteClusterCredentialsReload) || randomBoolean(); if (failRealmsReload) { - when(mockedRealms.stream()).thenThrow(new RuntimeException("failed jwt realms reload")); - } else { - when(mockedRealms.stream()).thenReturn(Stream.of()); + doThrow(new RuntimeException("failed jwt realms reload")).when(mockedJwtRealm).reload(any()); } security = new Security(settings, Collections.emptyList()) { @Override @@ -1031,8 +1033,8 @@ protected Client getClient() { } @Override - protected Realms getRealms() { - return mockedRealms; + protected List getReloadableSecurityComponents() { + return reloadableComponents; } }; @@ -1050,7 +1052,7 @@ protected Realms getRealms() { } // Verify both called despite failure verify(mockedClient).execute(eq(ActionTypes.RELOAD_REMOTE_CLUSTER_CREDENTIALS_ACTION), any(), any()); - verify(mockedRealms).stream(); + verify(mockedJwtRealm).reload(same(inputSettings)); } public void testLoadNoExtensions() throws Exception { diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ldap/ActiveDirectoryRealmTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ldap/ActiveDirectoryRealmTests.java index e9af65bd8fc4a..2fb8a69ec9601 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ldap/ActiveDirectoryRealmTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ldap/ActiveDirectoryRealmTests.java @@ -589,6 +589,44 @@ public void testMandatorySettings() throws Exception { ); } + public void testReloadBindPassword() throws Exception { + final RealmConfig.RealmIdentifier realmIdentifier = realmId("testReloadBindPassword"); + final boolean useLegacyBindPassword = randomBoolean(); + final boolean pooled = randomBoolean(); + final Settings.Builder builder = Settings.builder() + .put(getFullSettingKey(realmIdentifier, PoolingSessionFactorySettings.BIND_DN), "CN=ironman@ad.test.elasticsearch.com") + .put(getFullSettingKey(realmIdentifier.getName(), ActiveDirectorySessionFactorySettings.POOL_ENABLED), pooled) + // explicitly disabling cache to always authenticate against AD server + .put(getFullSettingKey(realmIdentifier, CachingUsernamePasswordRealmSettings.CACHE_TTL_SETTING), -1) + // due to limitations of AD server we cannot change BIND password dynamically, + // so we start with the wrong (random) password and then reload and check if authentication succeeds + .put(bindPasswordSettings(realmIdentifier, useLegacyBindPassword, randomAlphaOfLengthBetween(3, 7))); + + Settings settings = settings(realmIdentifier, builder.build()); + RealmConfig config = setupRealm(realmIdentifier, settings); + try (ActiveDirectorySessionFactory sessionFactory = new ActiveDirectorySessionFactory(config, sslService, threadPool)) { + DnRoleMapper roleMapper = new DnRoleMapper(config, resourceWatcherService); + LdapRealm realm = new LdapRealm(config, sessionFactory, roleMapper, threadPool); + realm.initialize(Collections.singleton(realm), licenseState); + + { + final PlainActionFuture> future = new PlainActionFuture<>(); + realm.authenticate(new UsernamePasswordToken("CN=Thor", new SecureString(PASSWORD.toCharArray())), future); + final AuthenticationResult result = future.actionGet(); + assertThat(result.toString(), result.getStatus(), is(AuthenticationResult.Status.CONTINUE)); + } + + realm.reload(bindPasswordSettings(realmIdentifier, useLegacyBindPassword, PASSWORD)); + + { + final PlainActionFuture> future = new PlainActionFuture<>(); + realm.authenticate(new UsernamePasswordToken("CN=Thor", new SecureString(PASSWORD.toCharArray())), future); + final AuthenticationResult result = future.actionGet(); + assertThat(result.toString(), result.getStatus(), is(AuthenticationResult.Status.SUCCESS)); + } + } + } + private void assertSingleLdapServer(ActiveDirectorySessionFactory sessionFactory, String hostname, int port) { assertThat(sessionFactory.getServerSet(), instanceOf(FailoverServerSet.class)); FailoverServerSet fss = (FailoverServerSet) sessionFactory.getServerSet(); @@ -628,4 +666,17 @@ private User getAndVerifyAuthUser(PlainActionFuture> assertThat(user, is(notNullValue())); return user; } + + private Settings bindPasswordSettings(RealmConfig.RealmIdentifier realmIdentifier, boolean useLegacyBindPassword, String password) { + if (useLegacyBindPassword) { + return Settings.builder() + .put(getFullSettingKey(realmIdentifier, PoolingSessionFactorySettings.LEGACY_BIND_PASSWORD), password) + .build(); + } else { + final MockSecureSettings secureSettings = new MockSecureSettings(); + secureSettings.setString(getFullSettingKey(realmIdentifier, PoolingSessionFactorySettings.SECURE_BIND_PASSWORD), password); + return Settings.builder().setSecureSettings(secureSettings).build(); + } + } + } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ldap/LdapRealmReloadTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ldap/LdapRealmReloadTests.java new file mode 100644 index 0000000000000..cf62b8355644b --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ldap/LdapRealmReloadTests.java @@ -0,0 +1,250 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +package org.elasticsearch.xpack.security.authc.ldap; + +import com.unboundid.ldap.sdk.LDAPException; +import com.unboundid.ldap.sdk.LDAPResult; +import com.unboundid.ldap.sdk.Modification; +import com.unboundid.ldap.sdk.ModificationType; +import com.unboundid.ldap.sdk.ResultCode; + +import org.elasticsearch.action.support.PlainActionFuture; +import org.elasticsearch.common.settings.MockSecureSettings; +import org.elasticsearch.common.settings.SecureSettings; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Setting; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.ssl.SslVerificationMode; +import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.env.Environment; +import org.elasticsearch.env.TestEnvironment; +import org.elasticsearch.license.MockLicenseState; +import org.elasticsearch.threadpool.TestThreadPool; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.watcher.ResourceWatcherService; +import org.elasticsearch.xpack.core.security.authc.AuthenticationResult; +import org.elasticsearch.xpack.core.security.authc.RealmConfig; +import org.elasticsearch.xpack.core.security.authc.RealmSettings; +import org.elasticsearch.xpack.core.security.authc.ldap.LdapUserSearchSessionFactorySettings; +import org.elasticsearch.xpack.core.security.authc.ldap.PoolingSessionFactorySettings; +import org.elasticsearch.xpack.core.security.authc.ldap.SearchGroupsResolverSettings; +import org.elasticsearch.xpack.core.security.authc.ldap.support.LdapSearchScope; +import org.elasticsearch.xpack.core.security.authc.support.CachingUsernamePasswordRealmSettings; +import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken; +import org.elasticsearch.xpack.core.security.user.User; +import org.elasticsearch.xpack.core.ssl.SSLService; +import org.elasticsearch.xpack.security.Security; +import org.elasticsearch.xpack.security.authc.ldap.support.LdapTestCase; +import org.elasticsearch.xpack.security.authc.ldap.support.SessionFactory; +import org.junit.After; +import org.junit.Before; + +import java.util.Arrays; +import java.util.Collections; +import java.util.function.Function; + +import static org.elasticsearch.xpack.core.security.authc.RealmSettings.getFullSettingKey; +import static org.elasticsearch.xpack.core.security.authc.ldap.support.SessionFactorySettings.URLS_SETTING; +import static org.elasticsearch.xpack.core.ssl.SSLConfigurationSettings.VERIFICATION_MODE_SETTING_REALM; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class LdapRealmReloadTests extends LdapTestCase { + + public static final String BIND_DN = "cn=Thomas Masterman Hardy,ou=people,o=sevenSeas"; + public static final String INITIAL_BIND_PASSWORD = "pass"; + public static final UsernamePasswordToken LDAP_USER_AUTH_TOKEN = new UsernamePasswordToken( + "jsamuel@royalnavy.mod.uk", + new SecureString("pass".toCharArray()) + ); + + private static final Settings defaultRealmSettings = Settings.builder() + .put(getFullSettingKey(REALM_IDENTIFIER.getName(), LdapUserSearchSessionFactorySettings.SEARCH_BASE_DN), "") + .put(getFullSettingKey(REALM_IDENTIFIER, PoolingSessionFactorySettings.BIND_DN), BIND_DN) + .put(getFullSettingKey(REALM_IDENTIFIER, SearchGroupsResolverSettings.SCOPE), LdapSearchScope.SUB_TREE) + .put(getFullSettingKey(REALM_IDENTIFIER, VERIFICATION_MODE_SETTING_REALM), SslVerificationMode.CERTIFICATE) + // explicitly disabling cache to always authenticate against LDAP server + .put(getFullSettingKey(REALM_IDENTIFIER, CachingUsernamePasswordRealmSettings.CACHE_TTL_SETTING), -1) + .put(getFullSettingKey(REALM_IDENTIFIER, RealmSettings.ORDER_SETTING), 0) + .build(); + + private ResourceWatcherService resourceWatcherService; + private Settings defaultGlobalSettings; + private MockLicenseState licenseState; + private ThreadPool threadPool; + + @Before + public void init() throws Exception { + licenseState = mock(MockLicenseState.class); + threadPool = new TestThreadPool("ldap realm reload tests"); + resourceWatcherService = new ResourceWatcherService(Settings.EMPTY, threadPool); + defaultGlobalSettings = Settings.builder().put("path.home", createTempDir()).build(); + when(licenseState.isAllowed(Security.DELEGATED_AUTHORIZATION_FEATURE)).thenReturn(true); + } + + @After + public void shutdown() throws InterruptedException { + resourceWatcherService.close(); + terminate(threadPool); + } + + private RealmConfig getRealmConfig(RealmConfig.RealmIdentifier identifier, Settings settings) { + final Environment env = TestEnvironment.newEnvironment(settings); + return new RealmConfig(identifier, settings, env, new ThreadContext(settings)); + } + + public void testReloadWithoutConnectionPool() throws Exception { + final boolean useLegacyBindSetting = randomBoolean(); + final Settings bindPasswordSettings; + if (useLegacyBindSetting) { + bindPasswordSettings = Settings.builder() + .put(getFullSettingKey(REALM_IDENTIFIER, PoolingSessionFactorySettings.LEGACY_BIND_PASSWORD), INITIAL_BIND_PASSWORD) + .build(); + } else { + bindPasswordSettings = Settings.builder() + .setSecureSettings(secureSettings(PoolingSessionFactorySettings.SECURE_BIND_PASSWORD, INITIAL_BIND_PASSWORD)) + .build(); + } + final Settings settings = Settings.builder() + .put(getFullSettingKey(REALM_IDENTIFIER.getName(), LdapUserSearchSessionFactorySettings.POOL_ENABLED), false) + .putList(getFullSettingKey(REALM_IDENTIFIER, URLS_SETTING), ldapUrls()) + .put(defaultRealmSettings) + .put(defaultGlobalSettings) + .put(bindPasswordSettings) + .build(); + final RealmConfig config = getRealmConfig(REALM_IDENTIFIER, settings); + try (SessionFactory sessionFactory = LdapRealm.sessionFactory(config, new SSLService(config.env()), threadPool)) { + assertThat(sessionFactory, is(instanceOf(LdapUserSearchSessionFactory.class))); + + LdapRealm ldap = new LdapRealm(config, sessionFactory, buildGroupAsRoleMapper(resourceWatcherService), threadPool); + ldap.initialize(Collections.singleton(ldap), licenseState); + + // Verify authentication is successful before the password change + authenticateUserAndAssertStatus(ldap, AuthenticationResult.Status.SUCCESS); + + // Generate new password and reload only on ES side + final String newBindPassword = randomAlphaOfLengthBetween(5, 10); + final Settings updatedBindPasswordSettings; + if (useLegacyBindSetting) { + updatedBindPasswordSettings = Settings.builder() + .put(getFullSettingKey(REALM_IDENTIFIER, PoolingSessionFactorySettings.LEGACY_BIND_PASSWORD), newBindPassword) + .build(); + } else { + updatedBindPasswordSettings = Settings.builder() + .setSecureSettings(secureSettings(PoolingSessionFactorySettings.SECURE_BIND_PASSWORD, newBindPassword)) + .build(); + } + ldap.reload(updatedBindPasswordSettings); + authenticateUserAndAssertStatus(ldap, AuthenticationResult.Status.CONTINUE); + + // Change password on LDAP server side and check that authentication works + changeUserPasswordOnLdapServers(BIND_DN, newBindPassword); + authenticateUserAndAssertStatus(ldap, AuthenticationResult.Status.SUCCESS); + + if (useLegacyBindSetting) { + assertSettingDeprecationsAndWarnings( + new Setting[] { + PoolingSessionFactorySettings.LEGACY_BIND_PASSWORD.apply(REALM_IDENTIFIER.getType()) + .getConcreteSettingForNamespace(REALM_IDENTIFIER.getName()) } + ); + } + } + } + + public void testReloadWithConnectionPool() throws Exception { + final boolean useLegacyBindSetting = randomBoolean(); + final Settings bindPasswordSettings; + if (useLegacyBindSetting) { + bindPasswordSettings = Settings.builder() + .put(getFullSettingKey(REALM_IDENTIFIER, PoolingSessionFactorySettings.LEGACY_BIND_PASSWORD), INITIAL_BIND_PASSWORD) + .build(); + } else { + bindPasswordSettings = Settings.builder() + .setSecureSettings(secureSettings(PoolingSessionFactorySettings.SECURE_BIND_PASSWORD, INITIAL_BIND_PASSWORD)) + .build(); + } + final Settings settings = Settings.builder() + .put(getFullSettingKey(REALM_IDENTIFIER.getName(), LdapUserSearchSessionFactorySettings.POOL_ENABLED), true) + .putList(getFullSettingKey(REALM_IDENTIFIER, URLS_SETTING), ldapUrls()) + .put(defaultRealmSettings) + .put(defaultGlobalSettings) + .put(bindPasswordSettings) + .build(); + final RealmConfig config = getRealmConfig(REALM_IDENTIFIER, settings); + try (SessionFactory sessionFactory = LdapRealm.sessionFactory(config, new SSLService(config.env()), threadPool)) { + assertThat(sessionFactory, is(instanceOf(LdapUserSearchSessionFactory.class))); + + LdapRealm ldap = new LdapRealm(config, sessionFactory, buildGroupAsRoleMapper(resourceWatcherService), threadPool); + ldap.initialize(Collections.singleton(ldap), licenseState); + + // When a connection is open and already bound, changing the bind password generally + // does not affect the existing pooled connection. LDAP connections are stateful, + // and once a connection is established and bound, it remains open until explicitly closed + // or until a connection timeout occurs. Changing the bind password on the server + // does not automatically invalidate existing connections. Hence, we are skipping + // here the check that the authentication works before re-loading bind password, + // since this check would create and bind a new connection using old password. + + // Generate a new password and reload only on ES side + final String newBindPassword = randomAlphaOfLengthBetween(5, 10); + final Settings updatedBindPasswordSettings; + if (useLegacyBindSetting) { + updatedBindPasswordSettings = Settings.builder() + .put(getFullSettingKey(REALM_IDENTIFIER, PoolingSessionFactorySettings.LEGACY_BIND_PASSWORD), newBindPassword) + .build(); + } else { + updatedBindPasswordSettings = Settings.builder() + .setSecureSettings(secureSettings(PoolingSessionFactorySettings.SECURE_BIND_PASSWORD, newBindPassword)) + .build(); + } + ldap.reload(updatedBindPasswordSettings); + // Using new bind password should fail since we did not update it on LDAP server side. + authenticateUserAndAssertStatus(ldap, AuthenticationResult.Status.CONTINUE); + + // Change password on LDAP server side and check that authentication works now. + changeUserPasswordOnLdapServers(BIND_DN, newBindPassword); + authenticateUserAndAssertStatus(ldap, AuthenticationResult.Status.SUCCESS); + + if (useLegacyBindSetting) { + assertSettingDeprecationsAndWarnings( + new Setting[] { + PoolingSessionFactorySettings.LEGACY_BIND_PASSWORD.apply(REALM_IDENTIFIER.getType()) + .getConcreteSettingForNamespace(REALM_IDENTIFIER.getName()) } + ); + } + } + } + + private void authenticateUserAndAssertStatus(LdapRealm ldap, AuthenticationResult.Status expectedAuthStatus) { + final PlainActionFuture> future = new PlainActionFuture<>(); + ldap.authenticate(LDAP_USER_AUTH_TOKEN, future); + final AuthenticationResult result = future.actionGet(); + assertThat(result.getStatus(), is(expectedAuthStatus)); + } + + private void changeUserPasswordOnLdapServers(String userDn, String newPassword) { + Arrays.stream(ldapServers).forEach(ldapServer -> { + ldapServer.getPasswordAttributes().forEach(passwordAttribute -> { + try { + LDAPResult result = ldapServer.modify(userDn, new Modification(ModificationType.REPLACE, "userPassword", newPassword)); + assertThat(result.getResultCode(), equalTo(ResultCode.SUCCESS)); + } catch (LDAPException e) { + fail(e, "failed to change " + passwordAttribute + " for user: " + userDn); + } + }); + }); + } + + private static SecureSettings secureSettings(Function> settingFactory, String value) { + final MockSecureSettings secureSettings = new MockSecureSettings(); + secureSettings.setString(getFullSettingKey(REALM_IDENTIFIER, settingFactory), value); + return secureSettings; + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ldap/LdapUserSearchSessionFactoryTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ldap/LdapUserSearchSessionFactoryTests.java index 4daaee30e098d..acb4359b37323 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ldap/LdapUserSearchSessionFactoryTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ldap/LdapUserSearchSessionFactoryTests.java @@ -146,14 +146,14 @@ public void testUserSearchSubTree() throws Exception { try { // auth try (LdapSession ldap = session(sessionFactory, user, userPass)) { - assertConnectionValid(ldap.getConnection(), sessionFactory.bindCredentials); + assertConnectionValid(ldap.getConnection(), sessionFactory.getBindRequest()); String dn = ldap.userDn(); assertThat(dn, containsString(user)); } // lookup try (LdapSession ldap = unauthenticatedSession(sessionFactory, user)) { - assertConnectionValid(ldap.getConnection(), sessionFactory.bindCredentials); + assertConnectionValid(ldap.getConnection(), sessionFactory.getBindRequest()); String dn = ldap.userDn(); assertThat(dn, containsString(user)); } @@ -291,14 +291,14 @@ public void testUserSearchBaseScopePassesWithCorrectBaseDN() throws Exception { try { // auth try (LdapSession ldap = session(sessionFactory, user, userPass)) { - assertConnectionValid(ldap.getConnection(), sessionFactory.bindCredentials); + assertConnectionValid(ldap.getConnection(), sessionFactory.getBindRequest()); String dn = ldap.userDn(); assertThat(dn, containsString(user)); } // lookup try (LdapSession ldap = unauthenticatedSession(sessionFactory, user)) { - assertConnectionValid(ldap.getConnection(), sessionFactory.bindCredentials); + assertConnectionValid(ldap.getConnection(), sessionFactory.getBindRequest()); String dn = ldap.userDn(); assertThat(dn, containsString(user)); } @@ -378,14 +378,14 @@ public void testUserSearchOneLevelScopePassesWithCorrectBaseDN() throws Exceptio try { // auth try (LdapSession ldap = session(sessionFactory, user, userPass)) { - assertConnectionValid(ldap.getConnection(), sessionFactory.bindCredentials); + assertConnectionValid(ldap.getConnection(), sessionFactory.getBindRequest()); String dn = ldap.userDn(); assertThat(dn, containsString(user)); } // lookup try (LdapSession ldap = unauthenticatedSession(sessionFactory, user)) { - assertConnectionValid(ldap.getConnection(), sessionFactory.bindCredentials); + assertConnectionValid(ldap.getConnection(), sessionFactory.getBindRequest()); String dn = ldap.userDn(); assertThat(dn, containsString(user)); } @@ -451,14 +451,14 @@ public void testUserSearchWithoutAttributePasses() throws Exception { try { // auth try (LdapSession ldap = session(sessionFactory, user, userPass)) { - assertConnectionValid(ldap.getConnection(), sessionFactory.bindCredentials); + assertConnectionValid(ldap.getConnection(), sessionFactory.getBindRequest()); String dn = ldap.userDn(); assertThat(dn, containsString("William Bush")); } // lookup try (LdapSession ldap = unauthenticatedSession(sessionFactory, user)) { - assertConnectionValid(ldap.getConnection(), sessionFactory.bindCredentials); + assertConnectionValid(ldap.getConnection(), sessionFactory.getBindRequest()); String dn = ldap.userDn(); assertThat(dn, containsString("William Bush")); } @@ -600,8 +600,8 @@ public void testEmptyBindDNReturnsAnonymousBindRequest() throws LDAPException { new ThreadContext(globalSettings) ); try (LdapUserSearchSessionFactory searchSessionFactory = getLdapUserSearchSessionFactory(config, sslService, threadPool)) { - assertThat(searchSessionFactory.bindCredentials, notNullValue()); - assertThat(searchSessionFactory.bindCredentials.getBindDN(), is(emptyString())); + assertThat(searchSessionFactory.getBindRequest(), notNullValue()); + assertThat(searchSessionFactory.getBindRequest().getBindDN(), is(emptyString())); } assertDeprecationWarnings(config.identifier(), false, useLegacyBindPassword); } @@ -622,8 +622,8 @@ public void testThatBindRequestReturnsSimpleBindRequest() throws LDAPException { new ThreadContext(globalSettings) ); try (LdapUserSearchSessionFactory searchSessionFactory = getLdapUserSearchSessionFactory(config, sslService, threadPool)) { - assertThat(searchSessionFactory.bindCredentials, notNullValue()); - assertThat(searchSessionFactory.bindCredentials.getBindDN(), is("cn=ironman")); + assertThat(searchSessionFactory.getBindRequest(), notNullValue()); + assertThat(searchSessionFactory.getBindRequest().getBindDN(), is("cn=ironman")); } assertDeprecationWarnings(config.identifier(), false, useLegacyBindPassword); } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ldap/support/SessionFactoryLoadBalancingTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ldap/support/SessionFactoryLoadBalancingTests.java index a74b3bd426c75..466d0e3428d50 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ldap/support/SessionFactoryLoadBalancingTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ldap/support/SessionFactoryLoadBalancingTests.java @@ -471,5 +471,10 @@ protected TestSessionFactory(RealmConfig config, SSLService sslService, ThreadPo public void session(String user, SecureString password, ActionListener listener) { listener.onResponse(null); } + + @Override + public void reload(Settings settings) { + // no-op + } } } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ldap/support/SessionFactoryTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ldap/support/SessionFactoryTests.java index a49070786bb0e..e8804653d365e 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ldap/support/SessionFactoryTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ldap/support/SessionFactoryTests.java @@ -225,6 +225,11 @@ private SessionFactory createSessionFactory() { public void session(String user, SecureString password, ActionListener listener) { listener.onResponse(null); } + + @Override + public void reload(Settings settings) { + // no-op + } }; } }