From e87f58d308fc4391c1433e87ba925c254f10b6a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Slobodan=20Adamovi=C4=87?= Date: Fri, 2 Feb 2024 10:37:01 +0100 Subject: [PATCH] Hot-reloadable LDAP bind password (#104320) This PR enables `secure_bind_password` setting to be updated via `reload_secure_settings` API, without the need to restart nodes. The `secure_bind_password` must be updated on the AD/LDAP server, changed in the Elasticsearch keystore and reloaded via `reload_secure_settings` API. This change does not include a support for the grace period, where both old and new passwords are active. The new password change is active immediately after reload and will be used when establishing new LDAP 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 will not affect and invalidate existing connections. --- docs/changelog/104320.yaml | 5 + .../xpack/security/Security.java | 37 ++- .../xpack/security/authc/Realms.java | 23 +- .../xpack/security/authc/jwt/JwtRealm.java | 9 +- .../ldap/ActiveDirectorySessionFactory.java | 53 ++-- .../xpack/security/authc/ldap/LdapRealm.java | 9 +- .../authc/ldap/LdapSessionFactory.java | 6 + .../ldap/LdapUserSearchSessionFactory.java | 3 +- .../authc/ldap/PoolingSessionFactory.java | 69 +++-- .../authc/ldap/support/SessionFactory.java | 3 +- .../support/ReloadableSecurityComponent.java | 43 +++ .../xpack/security/SecurityTests.java | 30 ++- .../authc/ldap/ActiveDirectoryRealmTests.java | 51 ++++ .../authc/ldap/LdapRealmReloadTests.java | 250 ++++++++++++++++++ .../LdapUserSearchSessionFactoryTests.java | 24 +- .../SessionFactoryLoadBalancingTests.java | 5 + .../ldap/support/SessionFactoryTests.java | 5 + 17 files changed, 531 insertions(+), 94 deletions(-) create mode 100644 docs/changelog/104320.yaml create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/ReloadableSecurityComponent.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ldap/LdapRealmReloadTests.java 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 + } }; } }