diff --git a/conf/cassandra.yaml b/conf/cassandra.yaml index af65d54b4bb4..0b0187011f4b 100644 --- a/conf/cassandra.yaml +++ b/conf/cassandra.yaml @@ -191,10 +191,12 @@ batchlog_replay_throttle: 1024KiB # ... # # - AllowAllAuthenticator performs no checks - set it to disable authentication. -# - PasswordAuthenticator relies on username/password pairs to authenticate -# users. It keeps usernames and hashed passwords in system_auth.roles table. -# Please increase system_auth keyspace replication factor if you use this authenticator. -# If using PasswordAuthenticator, CassandraRoleManager must also be used (see below) +# - PasswordAuthenticator relies on username/password pairs to authenticate users. It keeps usernames and +# hashed passwords in system_auth.roles table. Please increase system_auth keyspace replication factor +# if you use this authenticator. +# If using PasswordAuthenticator, CassandraRoleManager must also be used (see below). +# - MutualTlsAuthenticator is certificate-based authentication, using the same certificates used to establish +# connection security. authenticator: class_name: AllowAllAuthenticator # MutualTlsAuthenticator can be configured using the following configuration. One can add their own validator @@ -204,6 +206,31 @@ authenticator: # parameters: # validator_class_name: org.apache.cassandra.auth.SpiffeCertificateValidator +# Configure authenticator negotiation, to allow nodes to support multiple authentication mechanisms and +# negotiate (resolve) a specific mechanism with a client at connection time. When this configuration is present, +# it supersedes the 'authenticator' configuration entirely. +# authenticator_negotiation: + # If true, client connections will go through authn negotiation. If false, they will use the authenticator + # configured by the default_authenticator key. + # enabled: false + # If true, all configured authenticators, including the default authenticator, must 'require authenticiation': + # i.e., 'AllowAllAuthenticator' and other authenticators that don't require authentication cannot be used and + # will cause the node to fail at starttup. This prevents 'fail-open' negotiation. If false, AllowAllAuthenticator + # and other non-authenticating authenticators are allowed. This is discouraged but may make migrating to + # negotiated authentication easier for deployments that do not enforce authentication today. + # require_authentication: true + # The authenticator to be used for clients that don't support negotiation, or clients that support none of + # the authenticators listed in the 'authenticators' section. + # default_authenticator: + # class_name: PasswordAuthenticator + # The authenticators that this node supports for negotiation. These are configured as documented for the + # 'authenticator' section. List authenticators in order from most- to least-preferred. For negotiating + # clients, the server will select the first authenticator in this list that the client indicates it can + # support. + # authenticators: + # - class_name: PasswordAuthenticator + # - class_name: AllowAllAuthenticator + # Authorization backend, implementing IAuthorizer; used to limit access/provide permissions # Out of the box, Cassandra provides org.apache.cassandra.auth.{AllowAllAuthorizer, # CassandraAuthorizer}. @@ -1924,7 +1951,7 @@ client_encryption_options: # Custom auth settings which can be used as alternatives to JMX's out of the box auth utilities. # JAAS login modules can be used for authentication using this property.Cassandra ships with a # LoginModule implementation - org.apache.cassandra.auth.CassandraLoginModule - which delegates - # to the IAuthenticator configured in cassandra.yaml. + # to the default IAuthenticator configured in cassandra.yaml. # # login_config_name refers to the Application Name in the JAAS configuration under which the # desired LoginModule(s) are configured. diff --git a/src/java/org/apache/cassandra/auth/AuthConfig.java b/src/java/org/apache/cassandra/auth/AuthConfig.java index 86e1f626ff81..9e3b73fa7601 100644 --- a/src/java/org/apache/cassandra/auth/AuthConfig.java +++ b/src/java/org/apache/cassandra/auth/AuthConfig.java @@ -18,7 +18,9 @@ package org.apache.cassandra.auth; +import java.util.ArrayList; import java.util.List; +import java.util.Optional; import com.google.common.annotations.VisibleForTesting; @@ -38,8 +40,33 @@ public final class AuthConfig { private static final Logger logger = LoggerFactory.getLogger(AuthConfig.class); + private static final String AUTH_PACKAGE = AuthConfig.class.getPackage().getName(); + private static boolean initialized; + /** + * Normalized authenticator configuration that abstracts away the difference between + * legacy single-authenticator config and negotiated multi-authenticator config. + */ + private static class AuthenticatorConfig + { + final IAuthenticator defaultAuthenticator; + final List negotiableAuthenticators; + final boolean requireAuthentication; + final boolean isNegotiationEnabled; + + AuthenticatorConfig(IAuthenticator defaultAuthenticator, + List negotiableAuthenticators, + boolean requireAuthentication, + boolean isNegotiationEnabled) + { + this.defaultAuthenticator = defaultAuthenticator; + this.negotiableAuthenticators = negotiableAuthenticators; + this.requireAuthentication = requireAuthentication; + this.isNegotiationEnabled = isNegotiationEnabled; + } + } + /** * Resets the initialized flag, enabling AuthConfig to be reconfigured multiple times within a single * test case. @@ -60,48 +87,54 @@ public static void applyAuth() Config conf = DatabaseDescriptor.getRawConfig(); - - /* Authentication, authorization and role management backend, implementing IAuthenticator, I*Authorizer & IRoleManager */ - - IAuthenticator authenticator = authInstantiate(conf.authenticator, AllowAllAuthenticator.class); + // Load and normalize authenticator configuration + AuthenticatorConfig authConfig = loadAuthenticatorConfig(conf); // the configuration options regarding credentials caching are only guaranteed to - // work with PasswordAuthenticator, so log a message if some other authenticator - // is in use and non-default values are detected - if (!(authenticator instanceof PasswordAuthenticator || authenticator instanceof MutualTlsAuthenticator) + // work with PasswordAuthenticator and MutualTlsAuthenticator, so log a message if none of the + // configured authenticators use credentials caching and non-default values are detected + boolean hasCredentialsCaching = authConfig.negotiableAuthenticators.stream() + .anyMatch(auth -> auth instanceof PasswordAuthenticator || auth instanceof MutualTlsAuthenticator); + + if (!hasCredentialsCaching && (conf.credentials_update_interval != null || conf.credentials_validity.toMilliseconds() != 2000 || conf.credentials_cache_max_entries != 1000)) { logger.info("Configuration options credentials_update_interval, credentials_validity and " + - "credentials_cache_max_entries may not be applicable for the configured authenticator ({})", - authenticator.getClass().getName()); + "credentials_cache_max_entries may not be applicable for the configured authenticators"); } - DatabaseDescriptor.setAuthenticator(authenticator); - - // authorizer + DatabaseDescriptor.setDefaultAuthenticator(authConfig.defaultAuthenticator); + DatabaseDescriptor.setNegotiableAuthenticators(authConfig.negotiableAuthenticators); - IAuthorizer authorizer = authInstantiate(conf.authorizer, AllowAllAuthorizer.class); - - if (!authenticator.requireAuthentication() && authorizer.requireAuthorization()) + // Validate require_authentication setting if negotiation is configured + if (authConfig.isNegotiationEnabled) { - throw new ConfigurationException(authorizer.getClass().getName() + " has authorization enabled which requires " + - authenticator.getClass().getName() + " to enable authentication", false); + validateRequireAuthentication(authConfig); } + // authorizer + + IAuthorizer authorizer = authInstantiate(conf.authorizer, AllowAllAuthorizer.class); + validateAuthenticatorAuthorizerCompatibility(authConfig, authorizer); DatabaseDescriptor.setAuthorizer(authorizer); // role manager IRoleManager roleManager = authInstantiate(conf.role_manager, CassandraRoleManager.class); - if (authenticator instanceof PasswordAuthenticator && !(roleManager instanceof CassandraRoleManager)) - throw new ConfigurationException(authenticator.getClass().getName() + " requires " + CassandraRoleManager.class.getName(), false); + // PasswordAuthenticator requires CassandraRoleManager. Check if any negotiable authenticator is + // a PasswordAuthenticator. + boolean hasPasswordAuth = authConfig.negotiableAuthenticators.stream() + .anyMatch(auth -> auth instanceof PasswordAuthenticator); + + if (hasPasswordAuth && !(roleManager instanceof CassandraRoleManager)) + throw new ConfigurationException("PasswordAuthenticator requires " + CassandraRoleManager.class.getName(), false); DatabaseDescriptor.setRoleManager(roleManager); - // authenticator + // internode authenticator IInternodeAuthenticator internodeAuthenticator = authInstantiate(conf.internode_authenticator, AllowAllInternodeAuthenticator.class); @@ -110,29 +143,19 @@ public static void applyAuth() // network authorizer INetworkAuthorizer networkAuthorizer = authInstantiate(conf.network_authorizer, AllowAllNetworkAuthorizer.class); - - if (networkAuthorizer.requireAuthorization() && !authenticator.requireAuthentication()) - { - throw new ConfigurationException(conf.network_authorizer + " can't be used with " + conf.authenticator.class_name, false); - } - + validateAuthenticatorNetworkAuthorizerCompatibility(authConfig, networkAuthorizer); DatabaseDescriptor.setNetworkAuthorizer(networkAuthorizer); // cidr authorizer ICIDRAuthorizer cidrAuthorizer = authInstantiate(conf.cidr_authorizer, AllowAllCIDRAuthorizer.class); - - if (cidrAuthorizer.requireAuthorization() && !authenticator.requireAuthentication()) - { - throw new ConfigurationException(conf.cidr_authorizer + " can't be used with " + conf.authenticator, false); - } - + validateAuthenticatorCIDRAuthorizerCompatibility(authConfig, cidrAuthorizer); DatabaseDescriptor.setCIDRAuthorizer(cidrAuthorizer); // Validate at last to have authenticator, authorizer, role-manager and internode-auth setup // in case these rely on each other. - authenticator.validateConfiguration(); + authConfig.negotiableAuthenticators.forEach(IAuthenticator::validateConfiguration); authorizer.validateConfiguration(); roleManager.validateConfiguration(); networkAuthorizer.validateConfiguration(); @@ -141,12 +164,19 @@ public static void applyAuth() } private static T authInstantiate(ParameterizedClass authCls, Class defaultCls) { + return (T) authInstantiate(authCls).orElseGet(() -> defaultAuthInstantiate(defaultCls)); + } + + private static Optional authInstantiate(ParameterizedClass authCls) { if (authCls != null && authCls.class_name != null) { - String authPackage = AuthConfig.class.getPackage().getName(); - return ParameterizedClass.newInstance(authCls, List.of("", authPackage)); + return Optional.of(ParameterizedClass.newInstance(authCls, List.of("", AUTH_PACKAGE))); } + return Optional.empty(); + } + + private static T defaultAuthInstantiate(Class defaultCls) { // for now, this has to stay and can not be replaced by ParameterizedClass.newInstance as above // due to that failing for simulator dtests. See CASSANDRA-20450 for more information. try @@ -158,4 +188,225 @@ private static T authInstantiate(ParameterizedClass authCls, Class defaul throw new ConfigurationException("Failed to instantiate " + defaultCls.getName(), e); } } + + /** + * Validates the require_authentication setting when authenticator negotiation is configured. If + * require_authentication is true, all authenticators must require authentication. If require_authentication is + * false and non-authenticating authenticators are present, logs a warning and continues. + */ + private static void validateRequireAuthentication(AuthenticatorConfig authConfig) + { + // Check all negotiable authenticators (includes default) + for (IAuthenticator authenticator : authConfig.negotiableAuthenticators) + { + if (!authenticator.requireAuthentication()) + { + if (authConfig.requireAuthentication) + { + throw new ConfigurationException( + "require_authentication is true but authenticator doesn't require authentication: " + + authenticator.getClass().getName(), false); + } + else + { + logger.warn("require_authentication is false and authenticator doesn't require authentication: {}. " + + "This may allow unauthenticated access.", + authenticator.getClass().getName()); + } + } + } + } + + /** + * Validates compatibility between authenticators and authorizer when negotiation is configured. + * If any authenticator doesn't require authentication and the authorizer requires authorization: + * - require_authentication: true -> fail (strict mode) + * - require_authentication: false -> warn (permissive mode for migration) + */ + private static void validateAuthenticatorAuthorizerCompatibility(AuthenticatorConfig authConfig, + IAuthorizer authorizer) + { + if (!authorizer.requireAuthorization()) + return; + + // If negotiating, all authenticators have to work with the authorizer (require authentication). + if (authConfig.isNegotiationEnabled) + { + validateAuthorizerCompatibility(authConfig, authorizer.getClass().getName(), + "limited access based on 'anonymous' role permissions"); + return; + } + + // Otherwise, just the default authenticator has to work with the authorizer. + if (!authConfig.defaultAuthenticator.requireAuthentication()) + throw new ConfigurationException(authorizer.getClass().getName() + " has authorization enabled which requires " + + authConfig.defaultAuthenticator.getClass().getName() + " to enable authentication", false); + } + + /** + * Validates compatibility between authenticators and network authorizer when negotiation is configured. + */ + private static void validateAuthenticatorNetworkAuthorizerCompatibility(AuthenticatorConfig authConfig, + INetworkAuthorizer networkAuthorizer) + { + if (!networkAuthorizer.requireAuthorization()) + return; + + // If negotiating, all authenticators have to work with the authorizer (require authentication). + if (authConfig.isNegotiationEnabled) + { + validateAuthorizerCompatibility(authConfig, networkAuthorizer.getClass().getName(), + "limited network access"); + return; + } + + // Otherwise, just the default authenticator has to work with the authorizer. + if (!authConfig.defaultAuthenticator.requireAuthentication()) + throw new ConfigurationException(networkAuthorizer.getClass().getName() + " can't be used with " + + authConfig.defaultAuthenticator.getClass().getName(), false); + } + + /** + * Validates compatibility between authenticators and CIDR authorizer when negotiation is configured. + */ + private static void validateAuthenticatorCIDRAuthorizerCompatibility(AuthenticatorConfig authConfig, + ICIDRAuthorizer cidrAuthorizer) + { + if (!cidrAuthorizer.requireAuthorization()) + return; + + // If negotiating, all authenticators have to work with the authorizer (require authentication). + if (authConfig.isNegotiationEnabled) + { + validateAuthorizerCompatibility(authConfig, cidrAuthorizer.getClass().getName(), + "limited CIDR-based access"); + return; + } + + // Otherwise, just the default authenticator has to work with the authorizer. + if (!authConfig.defaultAuthenticator.requireAuthentication()) + throw new ConfigurationException(cidrAuthorizer.getClass().getName() + " can't be used with " + + authConfig.defaultAuthenticator.getClass().getName(), false); + } + + /** + * Common validation logic for authorizer compatibility. + * Checks if any authenticator doesn't require authentication when an authorizer requires authorization. + */ + private static void validateAuthorizerCompatibility(AuthenticatorConfig authConfig, + String authorizerName, + String accessDescription) + { + boolean hasNonAuthenticating = authConfig.negotiableAuthenticators.stream() + .anyMatch(authenticator -> !authenticator.requireAuthentication()); + + if (hasNonAuthenticating) + { + if (authConfig.requireAuthentication) + { + throw new ConfigurationException( + "require_authentication is true but some negotiable authenticators don't require authentication. " + + "This is incompatible with " + authorizerName + " which requires authorization.", false); + } + else + { + logger.warn("{} requires authorization but some negotiable authenticators don't require authentication. " + + "Unauthenticated clients will have {}. " + + "Set require_authentication: true to enforce authentication.", + authorizerName, accessDescription); + } + } + } + + /** + * Loads and normalizes authenticator configuration from either legacy or negotiation config. + * Returns a normalized structure containing default authenticator, negotiable authenticators list, + * and configuration flags. + */ + private static AuthenticatorConfig loadAuthenticatorConfig(Config conf) + { + // Determine if authenticator_negotiation was enabled + boolean negotiationEnabled = conf.authenticator_negotiation.enabled; + + // Determine default authenticator based on configuration precedence + IAuthenticator defaultAuthenticator; + + if (negotiationEnabled) + { + ParameterizedClass defaultAuthenticatorConfig = conf.authenticator_negotiation.default_authenticator; + + if (defaultAuthenticatorConfig == null) + // authenticator_negotiation is configured but default_authenticator is missing - fail to start + throw new ConfigurationException( + "authenticator_negotiation section requires default_authenticator to be specified", false); + + defaultAuthenticator = (IAuthenticator) authInstantiate(defaultAuthenticatorConfig) + .orElseThrow(() -> new ConfigurationException( + "Unable to load default_authenticator from authenticator_negotiation section: " + + conf.authenticator_negotiation.default_authenticator.class_name, false + )); + } + else + { + // Fall back to legacy authenticator config + defaultAuthenticator = authInstantiate(conf.authenticator, AllowAllAuthenticator.class); + } + + List negotiableAuthenticators = new ArrayList<>(); + + if (negotiationEnabled) + { + logger.info("Authentication negotiation enabled: initializing authenticators"); + List authenticators = conf.authenticator_negotiation.authenticators; + + if (authenticators != null) + { + for (ParameterizedClass clazz: authenticators) + { + // We generally can't instantiate multiple instances of an authenticator, so if the + // default is also in the list of negotiable authenticators, just re-use the instance + // that we have. + // TODO - ParameterizedClass.equals() fails when comparing configs with null vs empty parameters + // (e.g., 'default_authenticator: PasswordAuthenticator' vs 'default_authenticator:\n\t- class_name: PasswordAuthenticator'). + // This causes duplicate authenticator instances and incorrect negotiation order. + // Fix: Normalize null to empty map in ParameterizedClass.equals()/hashCode(). + // https://issues.apache.org/jira/browse/CASSANDRA-21238 + if (clazz.equals(conf.authenticator_negotiation.default_authenticator)) + { + negotiableAuthenticators.add(defaultAuthenticator); + continue; + } + + Optional authenticator = authInstantiate(clazz); + + if (authenticator.isEmpty()) + { + logger.warn("Unable to instantiate configured authenticator {}", clazz.class_name); + } + else + { + negotiableAuthenticators.add(authenticator.get()); + } + } + } + + logger.info("Configured negotiable authenticators {}", negotiableAuthenticators); + } + + // Ensure default authenticator is available for negotiation (as lowest priority) so clients can + // explicitly signal support for it, rather than falling back to it blindly when negotiation fails. + if (!negotiableAuthenticators.contains(defaultAuthenticator)) + { + logger.info("Adding default authenticator as least-preferred for negotiation: {}", + defaultAuthenticator.getClass().getName()); + negotiableAuthenticators.add(defaultAuthenticator); + } + + return new AuthenticatorConfig( + defaultAuthenticator, + negotiableAuthenticators, + conf.authenticator_negotiation.require_authentication, + negotiationEnabled + ); + } } diff --git a/src/java/org/apache/cassandra/auth/AuthenticatorNegotiator.java b/src/java/org/apache/cassandra/auth/AuthenticatorNegotiator.java new file mode 100644 index 000000000000..b8e7328179e9 --- /dev/null +++ b/src/java/org/apache/cassandra/auth/AuthenticatorNegotiator.java @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.cassandra.auth; + +import java.util.Collections; +import java.util.Set; +import java.util.stream.Collectors; + +import javax.annotation.Nonnull; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.apache.cassandra.config.DatabaseDescriptor; + +/** + * Implements the business logic for selecting an authenticator for a given session, when both the node and the + * client support authenticator negotiation. + */ +public class AuthenticatorNegotiator +{ + private static final Logger logger = LoggerFactory.getLogger(AuthenticatorNegotiator.class); + + /** + * Selects an {@link IAuthenticator authenticator} based on client-provided authentication modes and this + * node's own prioritized list of supported authenticators. + * + * @param clientAuthenticators Comma-separated list of {@link IAuthenticator.AuthenticationMode} names supported + * by the client. May be null or empty if the client doesn't support or is not + * configured for negotiation + * @return The node's most preferred authenticator that supports at least one of the client's offered authentication + * modes, or its default authenticator if no modes are provided or none match. + */ + public static IAuthenticator negotiateAuthenticator(@Nonnull Set clientAuthenticators) + { + Set clientAuthenticationModes = + clientAuthenticators.stream() + .map(name -> new IAuthenticator.AuthenticationMode(name) {}) + .collect(Collectors.toSet()); + + for (IAuthenticator authenticator : DatabaseDescriptor.getNegotiableAuthenticators()) + { + if (!Collections.disjoint(clientAuthenticationModes, authenticator.getSupportedAuthenticationModes())) + { + logger.info("Negotiated authenticator with client with options {}: selected {}", + clientAuthenticationModes, authenticator.getClass().getName()); + return authenticator; + } + } + + logger.info("Auth negotiation failed for client options {}: continuing with default authenticator", clientAuthenticators); + + return DatabaseDescriptor.getDefaultAuthenticator(); + } +} diff --git a/src/java/org/apache/cassandra/auth/CassandraLoginModule.java b/src/java/org/apache/cassandra/auth/CassandraLoginModule.java index 6c648d4770f0..f703000905a3 100644 --- a/src/java/org/apache/cassandra/auth/CassandraLoginModule.java +++ b/src/java/org/apache/cassandra/auth/CassandraLoginModule.java @@ -36,11 +36,11 @@ import org.apache.cassandra.config.DatabaseDescriptor; import org.apache.cassandra.exceptions.AuthenticationException; -import org.apache.cassandra.service.StorageService; +import org.apache.cassandra.service.CassandraDaemon; /** - * LoginModule which authenticates a user towards the Cassandra database using - * the internal authentication mechanism. + * LoginModule which authenticates a user towards the Cassandra database using the default authentication + * mechanism. This is used to support JMX authentication. */ public class CassandraLoginModule implements LoginModule { @@ -140,10 +140,10 @@ public boolean login() throws LoginException private void authenticate() { - if (!StorageService.instance.isAuthSetupComplete()) + if (!CassandraDaemon.isAuthSetupComplete()) throw new AuthenticationException("Cannot login as server authentication setup is not yet completed"); - IAuthenticator authenticator = DatabaseDescriptor.getAuthenticator(); + IAuthenticator authenticator = DatabaseDescriptor.getDefaultAuthenticator(); Map credentials = new HashMap<>(); credentials.put(PasswordAuthenticator.USERNAME_KEY, username); credentials.put(PasswordAuthenticator.PASSWORD_KEY, String.valueOf(password)); diff --git a/src/java/org/apache/cassandra/auth/IAuthenticator.java b/src/java/org/apache/cassandra/auth/IAuthenticator.java index a4e888e5057c..7f2667329fcb 100644 --- a/src/java/org/apache/cassandra/auth/IAuthenticator.java +++ b/src/java/org/apache/cassandra/auth/IAuthenticator.java @@ -31,6 +31,8 @@ import org.apache.cassandra.service.ClientState; import org.apache.cassandra.transport.messages.AuthenticateMessage; +import static org.apache.cassandra.utils.LocalizeString.toLowerCaseLocalized; + public interface IAuthenticator { /** @@ -248,6 +250,7 @@ default AuthenticationMode getAuthenticationMode() abstract class AuthenticationMode { private final String displayName; + private final String normalizedDisplayName; /** * @param displayName How this mode should be displayed in tooling and JMX beans. Note that it is desirable @@ -256,6 +259,7 @@ abstract class AuthenticationMode public AuthenticationMode(@Nonnull String displayName) { this.displayName = displayName; + this.normalizedDisplayName = toLowerCaseLocalized(displayName); } /** @@ -283,15 +287,15 @@ public String toString() public boolean equals(Object o) { if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; + if (!(o instanceof AuthenticationMode)) return false; AuthenticationMode that = (AuthenticationMode) o; - return displayName.equals(that.displayName); + return normalizedDisplayName.equals(that.normalizedDisplayName); } @Override public int hashCode() { - return Objects.hash(displayName); + return Objects.hash(normalizedDisplayName); } } } diff --git a/src/java/org/apache/cassandra/auth/PasswordAuthenticator.java b/src/java/org/apache/cassandra/auth/PasswordAuthenticator.java index 9b810f644b8d..236749d6269e 100644 --- a/src/java/org/apache/cassandra/auth/PasswordAuthenticator.java +++ b/src/java/org/apache/cassandra/auth/PasswordAuthenticator.java @@ -75,6 +75,7 @@ public class PasswordAuthenticator implements IAuthenticator, AuthCache.BulkLoad public static final String USERNAME_KEY = "username"; public static final String PASSWORD_KEY = "password"; private static final Set AUTHENTICATION_MODES = Collections.singleton(AuthenticationMode.PASSWORD); + static final byte NUL = 0; @VisibleForTesting static final Set SUPPORTED_ROLE_OPTIONS = @@ -89,17 +90,9 @@ public class PasswordAuthenticator implements IAuthenticator, AuthCache.BulkLoad IRoleManager.Option.HASHED_PASSWORD, IRoleManager.Option.GENERATED_PASSWORD); - static final byte NUL = 0; + private static CredentialsCache cache; private SelectStatement authenticateStatement; - private final CredentialsCache cache; - - public PasswordAuthenticator() - { - cache = new CredentialsCache(this); - AuthCacheService.instance.register(cache); - } - /** * {@inheritDoc} */ @@ -248,6 +241,17 @@ public void validateConfiguration() throws ConfigurationException public void setup() { + // Cache initialization is deferred to setup() to avoid duplicate cache registration when subclasses + // of PasswordAuthenticator (e.g., MutualTlsWithPasswordFallbackAuthenticator) are also instantiated. + synchronized (PasswordAuthenticator.class) + { + if (cache == null) + { + cache = new CredentialsCache(this); + AuthCacheService.instance.register(cache); + } + } + String query = String.format("SELECT %s FROM %s.%s WHERE role = ?", SALTED_HASH, SchemaConstants.AUTH_KEYSPACE_NAME, diff --git a/src/java/org/apache/cassandra/auth/jmx/AuthorizationProxy.java b/src/java/org/apache/cassandra/auth/jmx/AuthorizationProxy.java index 183050b4c467..50c07447eb99 100644 --- a/src/java/org/apache/cassandra/auth/jmx/AuthorizationProxy.java +++ b/src/java/org/apache/cassandra/auth/jmx/AuthorizationProxy.java @@ -53,7 +53,7 @@ import org.apache.cassandra.auth.RoleResource; import org.apache.cassandra.auth.Roles; import org.apache.cassandra.config.DatabaseDescriptor; -import org.apache.cassandra.service.StorageService; +import org.apache.cassandra.service.CassandraDaemon; import org.apache.cassandra.utils.JmxInvocationListener; import org.apache.cassandra.utils.MBeanWrapper; @@ -148,10 +148,10 @@ public class AuthorizationProxy implements InvocationHandler protected Function> queryNames = (name) -> mbs.queryNames(name, null); /* - Used to determine whether auth setup has completed so we know whether the expect the IAuthorizer + Used to determine whether auth setup has completed so we know whether to expect the IAuthorizer to be ready. Can be overridden for testing. */ - protected BooleanSupplier isAuthSetupComplete = () -> StorageService.instance.isAuthSetupComplete(); + protected BooleanSupplier isAuthSetupComplete = () -> CassandraDaemon.isAuthSetupComplete(); protected JmxInvocationListener listener = AuditLogManager.instance; diff --git a/src/java/org/apache/cassandra/config/Config.java b/src/java/org/apache/cassandra/config/Config.java index dd75d910c830..9c073fd07460 100644 --- a/src/java/org/apache/cassandra/config/Config.java +++ b/src/java/org/apache/cassandra/config/Config.java @@ -19,10 +19,12 @@ import java.lang.reflect.Field; import java.lang.reflect.Modifier; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeMap; @@ -83,6 +85,23 @@ public static Set splitCommaDelimited(String src) public static final String PROPERTY_PREFIX = "cassandra."; public String cluster_name = "Test Cluster"; + + /** + * Configuration for authenticator negotiation, enabling nodes to support multiple authentication mechanisms + * and negotiate with clients at connection time. + *

+ * When configured, these settings take precedence over the legacy 'authenticator' configuration. The + * 'default_authenticator' is used for non-negotiating clients or when negotiation fails to resolve. + */ + public static class AuthenticatorNegotiationConfig + { + public boolean enabled = false; + public boolean require_authentication = true; + public ParameterizedClass default_authenticator; + public List authenticators = new ArrayList<>(); + } + + public AuthenticatorNegotiationConfig authenticator_negotiation = new AuthenticatorNegotiationConfig(); public ParameterizedClass authenticator; public ParameterizedClass authorizer; public ParameterizedClass role_manager; diff --git a/src/java/org/apache/cassandra/config/DatabaseDescriptor.java b/src/java/org/apache/cassandra/config/DatabaseDescriptor.java index bafe1715e752..c742fc13731d 100644 --- a/src/java/org/apache/cassandra/config/DatabaseDescriptor.java +++ b/src/java/org/apache/cassandra/config/DatabaseDescriptor.java @@ -224,7 +224,12 @@ public class DatabaseDescriptor private static DiskAccessMode compactionReadDiskAccessMode; private static AbstractCryptoProvider cryptoProvider; - private static IAuthenticator authenticator; + + // Cached value: true if any authenticator requires authentication + private static boolean isAuthenticationRequired; + private static List negotiableAuthenticators = List.of(); + private static IAuthenticator defaultAuthenticator; + private static IAuthorizer authorizer; private static INetworkAuthorizer networkAuthorizer; private static ICIDRAuthorizer cidrAuthorizer; @@ -393,7 +398,7 @@ public static void clientInitialization() } /** - * Equivalent to {@link #clientInitialization(boolean) clientInitialization(true, Config::new)}. + * Equivalent to {@link #clientInitialization(boolean, Supplier) clientInitialization(failIfDaemonOrTool, Config::new)}. */ public static void clientInitialization(boolean failIfDaemonOrTool) { @@ -2037,12 +2042,45 @@ public static void setCryptoProvider(AbstractCryptoProvider cryptoProvider) DatabaseDescriptor.cryptoProvider = cryptoProvider; } + @VisibleForTesting /* Only for testing */ + public static void setAuthenticatorNegotationEnabled(boolean isEnabled) + { + conf.authenticator_negotiation.enabled = isEnabled; + } + + public static boolean isAuthenticatorNegotiationEnabled() + { + return conf.authenticator_negotiation.enabled && + !negotiableAuthenticators.isEmpty(); + } + + public static List getNegotiableAuthenticators() + { + return negotiableAuthenticators; + } + + public static void setNegotiableAuthenticators(List authenticators) + { + negotiableAuthenticators = new ArrayList<>(authenticators); + isAuthenticationRequired = computeAuthenticationRequired(); + } + /** - * Returns the authenticator configured for this node. + * Returns the default authenticator configured for this node. + * @deprecated Use {@link #getDefaultAuthenticator()} instead. */ + @Deprecated(since = "6.0.0") public static IAuthenticator getAuthenticator() { - return authenticator; + return getDefaultAuthenticator(); + } + + /** + * Returns the default authenticator configured for this node. + */ + public static IAuthenticator getDefaultAuthenticator() + { + return defaultAuthenticator; } /** @@ -2053,36 +2091,55 @@ public static IAuthenticator getAuthenticator() */ public static Optional getAuthenticator(Class clazz) { - return hasAuthenticator(clazz) ? Optional.of(clazz.cast(authenticator)) : Optional.empty(); + return negotiableAuthenticators.stream() + .filter(auth -> clazz.isAssignableFrom(auth.getClass())) + .findFirst() + .map(clazz::cast); } /** * Sets the authenticator used by this node to authenticate clients. + * @deprecated Use {@link #setDefaultAuthenticator(IAuthenticator)} instead. */ + @Deprecated(since = "6.0.0" ) public static void setAuthenticator(IAuthenticator authenticator) { - DatabaseDescriptor.authenticator = authenticator; + setDefaultAuthenticator(authenticator); + } + + public static void setDefaultAuthenticator(IAuthenticator authenticator) + { + defaultAuthenticator = authenticator; + isAuthenticationRequired = computeAuthenticationRequired(); } /** - * Indicates if this node uses an authenticator that requires authentication. + * Computes whether authentication is required based on current authenticator configuration. + * Checks both the default authenticator and any negotiable authenticators. + * Returns true if ANY authenticator requires authentication, false otherwise. */ - public static boolean isAuthenticationRequired() + private static boolean computeAuthenticationRequired() { - return authenticator.requireAuthentication(); + // Check default authenticator first (handles legacy setAuthenticator() calls) + if (defaultAuthenticator != null && defaultAuthenticator.requireAuthentication()) + return true; + + // Also check negotiable authenticators list + return negotiableAuthenticators.stream() + .anyMatch(IAuthenticator::requireAuthentication); } /** - * Indicates if this node is configured with an authenticator of the specified type. - * @param clazz The class of the authenticator. - * @return True if this node has an authenticator of the specified type, false otherwise. + * Indicates if this node uses an authenticator that requires authentication. With authenticator negotiation, + * returns true if ANY negotiable authenticator requires authentication. This determines whether authentication + * infrastructure (caches, role management) should be enabled, and whether anonymous (i.e. unauthenticated) + * users can elevate permissions to perform CREATE/DROP TRIGGER and other operations. */ - private static boolean hasAuthenticator(Class clazz) + public static boolean isAuthenticationRequired() { - return clazz.isAssignableFrom(authenticator.getClass()); + return isAuthenticationRequired; } - public static IAuthorizer getAuthorizer() { return authorizer; @@ -4881,7 +4938,6 @@ public static void validateGCParams(long logThreshold, long warnThreshold) public static EncryptionContext getEncryptionContext() { return encryptionContext; - } public static long getGCWarnThreshold() diff --git a/src/java/org/apache/cassandra/config/ParameterizedClass.java b/src/java/org/apache/cassandra/config/ParameterizedClass.java index d772629f194c..255c46f431b1 100644 --- a/src/java/org/apache/cassandra/config/ParameterizedClass.java +++ b/src/java/org/apache/cassandra/config/ParameterizedClass.java @@ -148,6 +148,6 @@ public int hashCode() @Override public String toString() { - return class_name + parameters; + return class_name + (parameters == null ? "{}" : parameters); } } diff --git a/src/java/org/apache/cassandra/service/CassandraDaemon.java b/src/java/org/apache/cassandra/service/CassandraDaemon.java index 2825041f9635..2cee8e164f66 100644 --- a/src/java/org/apache/cassandra/service/CassandraDaemon.java +++ b/src/java/org/apache/cassandra/service/CassandraDaemon.java @@ -35,6 +35,7 @@ import java.util.Set; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Predicate; import java.util.stream.Stream; @@ -53,7 +54,9 @@ import org.apache.cassandra.audit.AuditLogManager; import org.apache.cassandra.auth.AuthCacheService; +import org.apache.cassandra.auth.AuthSchemaChangeListener; import org.apache.cassandra.auth.AuthenticatedUser; +import org.apache.cassandra.auth.IAuthenticator; import org.apache.cassandra.concurrent.ScheduledExecutors; import org.apache.cassandra.config.CassandraRelevantProperties; import org.apache.cassandra.config.DatabaseDescriptor; @@ -422,7 +425,7 @@ protected void setup() }; ScheduledExecutors.optionalTasks.schedule(viewRebuild, StorageService.RING_DELAY_MILLIS, TimeUnit.MILLISECONDS); - StorageService.instance.doAuthSetup(); + doAuthSetup(); // Apply overrides before re-enabling auto-compaction setCompactionStrategyOverrides(Schema.instance.getKeyspaces()); @@ -637,6 +640,75 @@ else if (ksTable.length == 2) return entitiesToChangeCompaction; } + // Auth setup state - static so it can be accessed from anywhere + private static final AtomicBoolean authSetupCalled = new AtomicBoolean(CassandraRelevantProperties.SKIP_AUTH_SETUP.getBoolean()); + private static volatile boolean authSetupComplete = false; + + /** + * Initialize authentication and authorization subsystems. + * This should be called after storage initialization since auth data is stored in system tables. + */ + public static void doAuthSetup() + { + doAuthSetup(true); + } + + /** + * Initialize authentication and authorization subsystems. + * + * @param async if true, initialize the role manager asynchronously + */ + @VisibleForTesting + public static void doAuthSetup(boolean async) + { + if (!authSetupCalled.getAndSet(true)) + { + DatabaseDescriptor.getRoleManager().setup(async); + + // Call setup() on all negotiable authenticators (includes default authenticator) + List authenticators = DatabaseDescriptor.getNegotiableAuthenticators(); + if (authenticators.isEmpty()) + { + // Fallback: if negotiation not configured, setup default authenticator + DatabaseDescriptor.getDefaultAuthenticator().setup(); + } + else + { + for (IAuthenticator authenticator : authenticators) + authenticator.setup(); + } + + DatabaseDescriptor.getAuthorizer().setup(); + DatabaseDescriptor.getNetworkAuthorizer().setup(); + DatabaseDescriptor.getCIDRAuthorizer().setup(); + AuthCacheService.initializeAndRegisterCaches(); + Schema.instance.registerListener(new AuthSchemaChangeListener()); + authSetupComplete = true; + } + } + + public static boolean isAuthSetupComplete() + { + return authSetupComplete; + } + + @VisibleForTesting + public static boolean isAuthSetupCalled() + { + return authSetupCalled.get(); + } + + /** + * Reset auth setup state for testing purposes. + * This allows tests to re-run auth setup in the same JVM. + */ + @VisibleForTesting + public static void resetAuthSetup() + { + authSetupCalled.set(false); + authSetupComplete = false; + } + public void setupVirtualKeyspaces() { VirtualKeyspaceRegistry.instance.register(VirtualSchemaKeyspace.instance); diff --git a/src/java/org/apache/cassandra/service/ClientState.java b/src/java/org/apache/cassandra/service/ClientState.java index c119d5f4c333..6a267d0eda94 100644 --- a/src/java/org/apache/cassandra/service/ClientState.java +++ b/src/java/org/apache/cassandra/service/ClientState.java @@ -39,6 +39,7 @@ import org.apache.cassandra.auth.AuthenticatedUser; import org.apache.cassandra.auth.DataResource; import org.apache.cassandra.auth.FunctionResource; +import org.apache.cassandra.auth.IAuthenticator; import org.apache.cassandra.auth.IResource; import org.apache.cassandra.auth.Permission; import org.apache.cassandra.auth.Resources; @@ -115,6 +116,9 @@ public class ClientState private volatile boolean issuedPreparedStatementsUseWarning; private volatile boolean issuedWarningForUneligiblePreparedStatements; + // The authenticator used for this connection (set after negotiation/selection) + private volatile IAuthenticator authenticator; + private static final QueryHandler cqlQueryHandler; static { @@ -199,8 +203,6 @@ protected ClientState(InetSocketAddress remoteAddress) { this.isInternal = false; this.remoteAddress = remoteAddress; - if (!DatabaseDescriptor.isAuthenticationRequired()) - this.user = AuthenticatedUser.ANONYMOUS_USER; } protected ClientState(ClientState source) @@ -212,6 +214,7 @@ protected ClientState(ClientState source) this.driverName = source.driverName; this.driverVersion = source.driverVersion; this.clientOptions = source.clientOptions; + this.authenticator = source.authenticator; } /** @@ -409,6 +412,31 @@ public void login(AuthenticatedUser user) throw new AuthenticationException(String.format("%s is not permitted to log in", user.getName())); } + /** + * Sets the authenticator used for this connection. + * Should be called after authenticator negotiation/selection completes. + * If the authenticator doesn't require authentication, sets the user to anonymous. + * + * @throws IllegalStateException if authenticator has already been set + */ + public void setAuthenticator(IAuthenticator authenticator) + { + if (this.authenticator != null) + throw new IllegalStateException("Authenticator already set for this connection"); + + this.authenticator = authenticator; + if (!authenticator.requireAuthentication()) + this.user = AuthenticatedUser.ANONYMOUS_USER; + } + + /** + * Returns the authenticator used for this connection, or null if not yet set. + */ + public IAuthenticator getAuthenticator() + { + return authenticator; + } + private boolean canLogin(AuthenticatedUser user) { try @@ -620,14 +648,27 @@ public void ensureNotAnonymous() */ public boolean isOrdinaryUser() { - return !isSuper() && !isSystem(); + // isSuper() depends on auth setup being complete. Prior to that, all clients are system only, + // so check that first to avoid warnings during node startup. + return !isSystem() && !isSuper(); } /** * Checks if this user is a super user. + *

+ * Returns true if either no authenticators require authentication (for backward compatibility with + * AllowAllAuthenticator), or if the user has been granted superuser role. With mixed authentication (some + * authenticators require authentication, some don't), unauthenticated clients are checked against the "anonymous" + * role and do not receive superuser privileges. */ public boolean isSuper() { + // Warn if no authenticator is configured for this client (shouldn't happen in normal operation). + if (authenticator == null) + { + logger.warn("isSuper() called before client state authenticator was set"); + } + return !DatabaseDescriptor.isAuthenticationRequired() || (user != null && user.isSuper()); } diff --git a/src/java/org/apache/cassandra/service/StorageService.java b/src/java/org/apache/cassandra/service/StorageService.java index b7460b1be50e..59852a110750 100644 --- a/src/java/org/apache/cassandra/service/StorageService.java +++ b/src/java/org/apache/cassandra/service/StorageService.java @@ -44,7 +44,6 @@ import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Supplier; @@ -81,8 +80,6 @@ import org.apache.cassandra.audit.AuditLogManager; import org.apache.cassandra.audit.AuditLogOptions; -import org.apache.cassandra.auth.AuthCacheService; -import org.apache.cassandra.auth.AuthSchemaChangeListener; import org.apache.cassandra.batchlog.BatchlogManager; import org.apache.cassandra.concurrent.ExecutorLocals; import org.apache.cassandra.concurrent.FutureTask; @@ -474,8 +471,6 @@ public static List> getAllRanges(List sortedTokens) private boolean isSurveyMode = TEST_WRITE_SURVEY.getBoolean(false); /* true if node is rebuilding and receiving data */ private volatile boolean initialized = false; - private final AtomicBoolean authSetupCalled = new AtomicBoolean(CassandraRelevantProperties.SKIP_AUTH_SETUP.getBoolean()); - private volatile boolean authSetupComplete = false; /* the probability for tracing any particular request, 0 disables tracing and 1 enables for all */ private double traceProbability = 0.0; @@ -1127,27 +1122,6 @@ private void exitWriteSurveyMode() InProgressSequences.finishInProgressSequences(id); } - void doAuthSetup() - { - doAuthSetup(true); - } - - @VisibleForTesting - public void doAuthSetup(boolean async) - { - if (!authSetupCalled.getAndSet(true)) - { - DatabaseDescriptor.getRoleManager().setup(async); - DatabaseDescriptor.getAuthenticator().setup(); - DatabaseDescriptor.getAuthorizer().setup(); - DatabaseDescriptor.getNetworkAuthorizer().setup(); - DatabaseDescriptor.getCIDRAuthorizer().setup(); - AuthCacheService.initializeAndRegisterCaches(); - Schema.instance.registerListener(new AuthSchemaChangeListener()); - authSetupComplete = true; - } - } - public void doAutoRepairSetup() { AutoRepairService.setup(); @@ -1159,17 +1133,6 @@ public void doAutoRepairSetup() } } - public boolean isAuthSetupComplete() - { - return authSetupComplete; - } - - @VisibleForTesting - public boolean authSetupCalled() - { - return authSetupCalled.get(); - } - public boolean isJoined() { return ClusterMetadata.current().myNodeState() == JOINED && !isSurveyMode; diff --git a/src/java/org/apache/cassandra/transport/InitialConnectionHandler.java b/src/java/org/apache/cassandra/transport/InitialConnectionHandler.java index 556a5607934d..511708d89a98 100644 --- a/src/java/org/apache/cassandra/transport/InitialConnectionHandler.java +++ b/src/java/org/apache/cassandra/transport/InitialConnectionHandler.java @@ -28,6 +28,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.apache.cassandra.config.DatabaseDescriptor; import org.apache.cassandra.cql3.QueryProcessor; import org.apache.cassandra.net.AsyncChannelPromise; import org.apache.cassandra.transport.ClientResourceLimits.Overload; @@ -87,6 +88,8 @@ protected void decode(ChannelHandlerContext ctx, ByteBuf buffer, List li compressions.add("lz4"); Map> supportedOptions = new HashMap<>(); + if (DatabaseDescriptor.isAuthenticatorNegotiationEnabled()) + supportedOptions.put(StartupMessage.AUTHENTICATORS, List.of()); supportedOptions.put(StartupMessage.CQL_VERSION, cqlVersions); supportedOptions.put(StartupMessage.COMPRESSION, compressions); supportedOptions.put(StartupMessage.PROTOCOL_VERSIONS, ProtocolVersion.supportedVersions()); diff --git a/src/java/org/apache/cassandra/transport/ServerConnection.java b/src/java/org/apache/cassandra/transport/ServerConnection.java index 50986d1b27e6..c55d33ddca2c 100644 --- a/src/java/org/apache/cassandra/transport/ServerConnection.java +++ b/src/java/org/apache/cassandra/transport/ServerConnection.java @@ -27,7 +27,6 @@ import org.slf4j.LoggerFactory; import org.apache.cassandra.auth.IAuthenticator; -import org.apache.cassandra.config.DatabaseDescriptor; import org.apache.cassandra.service.ClientState; import org.apache.cassandra.service.QueryState; @@ -38,7 +37,6 @@ public class ServerConnection extends Connection { private static final Logger logger = LoggerFactory.getLogger(ServerConnection.class); - private volatile IAuthenticator.SaslNegotiator saslNegotiator; private final ClientState clientState; private volatile ConnectionStage stage; public final Counter requests = new Counter(); @@ -105,8 +103,6 @@ else if (responseType == Message.Type.READY) if (responseType == Message.Type.READY || responseType == Message.Type.AUTH_SUCCESS) { stage = ConnectionStage.READY; - // we won't use the authenticator again, null it so that it can be GC'd - saslNegotiator = null; } break; case READY: @@ -118,10 +114,13 @@ else if (responseType == Message.Type.READY) public IAuthenticator.SaslNegotiator getSaslNegotiator(QueryState queryState) { - if (saslNegotiator == null) - saslNegotiator = DatabaseDescriptor.getAuthenticator() - .newSaslNegotiator(queryState.getClientAddress(), certificates()); - return saslNegotiator; + // Get the authenticator that was negotiated/selected for this connection + IAuthenticator authenticator = queryState.getClientState().getAuthenticator(); + + if (authenticator == null) + throw new IllegalStateException("Authenticator must be set in ClientState before creating SASL negotiator"); + + return authenticator.newSaslNegotiator(queryState.getClientAddress(), certificates()); } private Certificate[] certificates() diff --git a/src/java/org/apache/cassandra/transport/messages/OptionsMessage.java b/src/java/org/apache/cassandra/transport/messages/OptionsMessage.java index 77a76e336897..43b87218985f 100644 --- a/src/java/org/apache/cassandra/transport/messages/OptionsMessage.java +++ b/src/java/org/apache/cassandra/transport/messages/OptionsMessage.java @@ -17,14 +17,9 @@ */ package org.apache.cassandra.transport.messages; -import java.util.ArrayList; import java.util.HashMap; -import java.util.List; -import java.util.Map; -import org.apache.cassandra.cql3.QueryProcessor; import org.apache.cassandra.service.QueryState; -import org.apache.cassandra.transport.Compressor; import org.apache.cassandra.transport.Dispatcher; import org.apache.cassandra.transport.Message; import org.apache.cassandra.transport.ProtocolVersion; @@ -61,21 +56,8 @@ public OptionsMessage() @Override protected Message.Response execute(QueryState state, Dispatcher.RequestTime requestTime, boolean traceRequest) { - List cqlVersions = new ArrayList(); - cqlVersions.add(QueryProcessor.CQL_VERSION.toString()); - - List compressions = new ArrayList(); - if (Compressor.SnappyCompressor.instance != null) - compressions.add("snappy"); - // LZ4 is always available since worst case scenario it default to a pure JAVA implem. - compressions.add("lz4"); - - Map> supported = new HashMap>(); - supported.put(StartupMessage.CQL_VERSION, cqlVersions); - supported.put(StartupMessage.COMPRESSION, compressions); - supported.put(StartupMessage.PROTOCOL_VERSIONS, ProtocolVersion.supportedVersions()); - - return new SupportedMessage(supported); + // Execute is handled in InitialConnectionHandler.decode(). + return new SupportedMessage(new HashMap<>()); } @Override diff --git a/src/java/org/apache/cassandra/transport/messages/StartupMessage.java b/src/java/org/apache/cassandra/transport/messages/StartupMessage.java index c76db6826e30..8a8fb0808c2c 100644 --- a/src/java/org/apache/cassandra/transport/messages/StartupMessage.java +++ b/src/java/org/apache/cassandra/transport/messages/StartupMessage.java @@ -19,9 +19,12 @@ import java.util.HashMap; import java.util.Map; +import java.util.Set; +import org.apache.commons.lang3.StringUtils; + +import org.apache.cassandra.auth.AuthenticatorNegotiator; import org.apache.cassandra.auth.IAuthenticator; -import org.apache.cassandra.config.DatabaseDescriptor; import org.apache.cassandra.db.guardrails.Guardrails; import org.apache.cassandra.service.ClientState; import org.apache.cassandra.service.QueryState; @@ -45,6 +48,7 @@ */ public class StartupMessage extends Message.Request { + public static final String AUTHENTICATORS = "AUTHENTICATORS"; public static final String CQL_VERSION = "CQL_VERSION"; public static final String COMPRESSION = "COMPRESSION"; public static final String PROTOCOL_VERSIONS = "PROTOCOL_VERSIONS"; @@ -134,37 +138,43 @@ else if (compression.equals("lz4")) Guardrails.minimumClientDriverVersion.guard(driverName, driverVersion, clientState); - IAuthenticator authenticator = DatabaseDescriptor.getAuthenticator(); - if (authenticator.requireAuthentication()) + Set clientAuthenticators = Set.of(StringUtils.defaultIfEmpty(options.get(AUTHENTICATORS), StringUtils.EMPTY).split(",")); + + IAuthenticator authenticator = AuthenticatorNegotiator.negotiateAuthenticator(clientAuthenticators); + + // Set the authenticator for this connection + clientState.setAuthenticator(authenticator); + + if (!authenticator.requireAuthentication()) { + return new ReadyMessage(); + } + + // If the authenticator supports early authentication, attempt to authenticate. + if (authenticator.supportsEarlyAuthentication()) { - // If the authenticator supports early authentication, attempt to authenticate. - if (authenticator.supportsEarlyAuthentication()) + IAuthenticator.SaslNegotiator saslNegotiator = ((ServerConnection) connection).getSaslNegotiator(state); + // If the negotiator determines that sending an authenticate message is not necessary, attempt to authenticate here, + // otherwise, send an Authenticate message to begin the traditional authentication flow. + if (!saslNegotiator.shouldSendAuthenticateMessage()) { - IAuthenticator.SaslNegotiator negotiator = ((ServerConnection) connection).getSaslNegotiator(state); - // If the negotiator determines that sending an authenticate message is not necessary, attempt to authenticate here, - // otherwise, send an Authenticate message to begin the traditional authentication flow. - if (!negotiator.shouldSendAuthenticateMessage()) + // Attempt to authenticate the user. + return AuthUtil.handleLogin(connection, state, EMPTY_CLIENT_RESPONSE, (negotiationComplete, challenge) -> { - // Attempt to authenticate the user. - return AuthUtil.handleLogin(connection, state, EMPTY_CLIENT_RESPONSE, (negotiationComplete, challenge) -> + if (negotiationComplete) + { + // Authentication was successful, proceed. + return new ReadyMessage(); + } else { - if (negotiationComplete) - { - // Authentication was successful, proceed. - return new ReadyMessage(); - } else - { - // It's expected that any negotiator that requires a challenge will likely not support early - // authentication, in this case we can just go through the traditional auth flow. - return authenticator.getAuthenticateMessage(clientState); - } - }); - } + // It's expected that any negotiator that requires a challenge will likely not support early + // authentication, in this case we can just go through the traditional auth flow. + return authenticator.getAuthenticateMessage(clientState); + } + }); } - return authenticator.getAuthenticateMessage(clientState); } - else - return new ReadyMessage(); + + return authenticator.getAuthenticateMessage(clientState); } private static Map upperCaseKeys(Map options) diff --git a/test/conf/cassandra-auth-negotiation-invalid.yaml b/test/conf/cassandra-auth-negotiation-invalid.yaml new file mode 100644 index 000000000000..ebf750470d32 --- /dev/null +++ b/test/conf/cassandra-auth-negotiation-invalid.yaml @@ -0,0 +1,103 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# +# Minimal cassandra.yaml for testing invalid authenticator negotiation (configured to require authentication +# but defaults to AllowAllAuthenticator). +# +cluster_name: Test Cluster +memtable_allocation_type: offheap_objects +commitlog_sync: periodic +commitlog_sync_period: 10s +commitlog_segment_size: 5MiB +commitlog_directory: build/test/cassandra/commitlog +cdc_raw_directory: build/test/cassandra/cdc_raw +cdc_enabled: false +hints_directory: build/test/cassandra/hints +partitioner: org.apache.cassandra.dht.ByteOrderedPartitioner +listen_address: 127.0.0.1 +storage_port: 7012 +ssl_storage_port: 17012 +start_native_transport: true +native_transport_port: 9042 +column_index_size: 4KiB +saved_caches_directory: build/test/cassandra/saved_caches +data_file_directories: + - build/test/cassandra/data +disk_access_mode: mmap_index_only +seed_provider: + - class_name: org.apache.cassandra.locator.SimpleSeedProvider + parameters: + - seeds: "127.0.0.1:7012" +endpoint_snitch: org.apache.cassandra.locator.SimpleSnitch +dynamic_snitch: true +incremental_backups: true +concurrent_compactors: 4 +compaction_throughput: 0MiB/s +row_cache_class_name: org.apache.cassandra.cache.OHCProvider +row_cache_size: 16MiB +prepared_statements_cache_size: 1MiB +corrupted_tombstone_strategy: exception +stream_entire_sstables: true +stream_throughput_outbound: 23841858MiB/s +sasi_indexes_enabled: true +materialized_views_enabled: true +drop_compact_storage_enabled: true +file_cache_enabled: true +auto_hints_cleanup_enabled: true +default_keyspace_rf: 1 + +client_encryption_options: + enabled: true + require_client_auth: true + keystore: test/conf/cassandra_ssl_test.keystore + keystore_password: cassandra + truststore: test/conf/cassandra_ssl_test.truststore + truststore_password: cassandra + +server_encryption_options: + internode_encryption: all + enabled: true + keystore: test/conf/cassandra_ssl_test.keystore + keystore_password: cassandra + outbound_keystore: test/conf/cassandra_ssl_test_outbound.keystore + outbound_keystore_password: cassandra + truststore: test/conf/cassandra_ssl_test.truststore + truststore_password: cassandra + require_client_auth: true +internode_authenticator: + class_name : org.apache.cassandra.auth.MutualTlsInternodeAuthenticator + parameters : + validator_class_name: org.apache.cassandra.auth.SpiffeCertificateValidator +authenticator: + class_name : org.apache.cassandra.auth.PasswordAuthenticator +authenticator_negotiation: + enabled: true + require_authentication: true + default_authenticator: + class_name: AllowAllAuthenticator + authenticators: + - class_name: MutualTlsAuthenticator + parameters: + validator_class_name: org.apache.cassandra.auth.SpiffeCertificateValidator + - class_name: PasswordAuthenticator +role_manager: + class_name: CassandraRoleManager + parameters: +accord: + journal_directory: build/test/cassandra/accord_journal diff --git a/test/conf/cassandra-auth-negotiation-permissive.yaml b/test/conf/cassandra-auth-negotiation-permissive.yaml new file mode 100644 index 000000000000..531dbaf48a30 --- /dev/null +++ b/test/conf/cassandra-auth-negotiation-permissive.yaml @@ -0,0 +1,103 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# +# Minimal cassandra.yaml for testing permissive authenticator negotiation (some authenticators don't +# require authentication). +# +cluster_name: Test Cluster +memtable_allocation_type: offheap_objects +commitlog_sync: periodic +commitlog_sync_period: 10s +commitlog_segment_size: 5MiB +commitlog_directory: build/test/cassandra/commitlog +cdc_raw_directory: build/test/cassandra/cdc_raw +cdc_enabled: false +hints_directory: build/test/cassandra/hints +partitioner: org.apache.cassandra.dht.ByteOrderedPartitioner +listen_address: 127.0.0.1 +storage_port: 7012 +ssl_storage_port: 17012 +start_native_transport: true +native_transport_port: 9042 +column_index_size: 4KiB +saved_caches_directory: build/test/cassandra/saved_caches +data_file_directories: + - build/test/cassandra/data +disk_access_mode: mmap_index_only +seed_provider: + - class_name: org.apache.cassandra.locator.SimpleSeedProvider + parameters: + - seeds: "127.0.0.1:7012" +endpoint_snitch: org.apache.cassandra.locator.SimpleSnitch +dynamic_snitch: true +incremental_backups: true +concurrent_compactors: 4 +compaction_throughput: 0MiB/s +row_cache_class_name: org.apache.cassandra.cache.OHCProvider +row_cache_size: 16MiB +prepared_statements_cache_size: 1MiB +corrupted_tombstone_strategy: exception +stream_entire_sstables: true +stream_throughput_outbound: 23841858MiB/s +sasi_indexes_enabled: true +materialized_views_enabled: true +drop_compact_storage_enabled: true +file_cache_enabled: true +auto_hints_cleanup_enabled: true +default_keyspace_rf: 1 + +client_encryption_options: + enabled: true + require_client_auth: true + keystore: test/conf/cassandra_ssl_test.keystore + keystore_password: cassandra + truststore: test/conf/cassandra_ssl_test.truststore + truststore_password: cassandra + +server_encryption_options: + internode_encryption: all + enabled: true + keystore: test/conf/cassandra_ssl_test.keystore + keystore_password: cassandra + outbound_keystore: test/conf/cassandra_ssl_test_outbound.keystore + outbound_keystore_password: cassandra + truststore: test/conf/cassandra_ssl_test.truststore + truststore_password: cassandra + require_client_auth: true +internode_authenticator: + class_name : org.apache.cassandra.auth.MutualTlsInternodeAuthenticator + parameters : + validator_class_name: org.apache.cassandra.auth.SpiffeCertificateValidator +authenticator: + class_name : org.apache.cassandra.auth.PasswordAuthenticator +authenticator_negotiation: + enabled: true + require_authentication: false + default_authenticator: + class_name: AllowAllAuthenticator + authenticators: + - class_name: MutualTlsAuthenticator + parameters: + validator_class_name: org.apache.cassandra.auth.SpiffeCertificateValidator + - class_name: PasswordAuthenticator +role_manager: + class_name: CassandraRoleManager + parameters: +accord: + journal_directory: build/test/cassandra/accord_journal diff --git a/test/conf/cassandra-auth-negotiation-strict.yaml b/test/conf/cassandra-auth-negotiation-strict.yaml new file mode 100644 index 000000000000..13a71b6642a1 --- /dev/null +++ b/test/conf/cassandra-auth-negotiation-strict.yaml @@ -0,0 +1,102 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# +# Minimal cassandra.yaml for testing strict authenticator negotiation (all authenticators must enforce authentication). +# +cluster_name: Test Cluster +memtable_allocation_type: offheap_objects +commitlog_sync: periodic +commitlog_sync_period: 10s +commitlog_segment_size: 5MiB +commitlog_directory: build/test/cassandra/commitlog +cdc_raw_directory: build/test/cassandra/cdc_raw +cdc_enabled: false +hints_directory: build/test/cassandra/hints +partitioner: org.apache.cassandra.dht.ByteOrderedPartitioner +listen_address: 127.0.0.1 +storage_port: 7012 +ssl_storage_port: 17012 +start_native_transport: true +native_transport_port: 9042 +column_index_size: 4KiB +saved_caches_directory: build/test/cassandra/saved_caches +data_file_directories: + - build/test/cassandra/data +disk_access_mode: mmap_index_only +seed_provider: + - class_name: org.apache.cassandra.locator.SimpleSeedProvider + parameters: + - seeds: "127.0.0.1:7012" +endpoint_snitch: org.apache.cassandra.locator.SimpleSnitch +dynamic_snitch: true +incremental_backups: true +concurrent_compactors: 4 +compaction_throughput: 0MiB/s +row_cache_class_name: org.apache.cassandra.cache.OHCProvider +row_cache_size: 16MiB +prepared_statements_cache_size: 1MiB +corrupted_tombstone_strategy: exception +stream_entire_sstables: true +stream_throughput_outbound: 23841858MiB/s +sasi_indexes_enabled: true +materialized_views_enabled: true +drop_compact_storage_enabled: true +file_cache_enabled: true +auto_hints_cleanup_enabled: true +default_keyspace_rf: 1 + +client_encryption_options: + enabled: true + require_client_auth: true + keystore: test/conf/cassandra_ssl_test.keystore + keystore_password: cassandra + truststore: test/conf/cassandra_ssl_test.truststore + truststore_password: cassandra + +server_encryption_options: + internode_encryption: all + enabled: true + keystore: test/conf/cassandra_ssl_test.keystore + keystore_password: cassandra + outbound_keystore: test/conf/cassandra_ssl_test_outbound.keystore + outbound_keystore_password: cassandra + truststore: test/conf/cassandra_ssl_test.truststore + truststore_password: cassandra + require_client_auth: true +internode_authenticator: + class_name : org.apache.cassandra.auth.MutualTlsInternodeAuthenticator + parameters : + validator_class_name: org.apache.cassandra.auth.SpiffeCertificateValidator +authenticator: + class_name : org.apache.cassandra.auth.PasswordAuthenticator +authenticator_negotiation: + enabled: true + require_authentication: true + default_authenticator: + class_name: PasswordAuthenticator + authenticators: + - class_name: MutualTlsAuthenticator + parameters: + validator_class_name: org.apache.cassandra.auth.SpiffeCertificateValidator + - class_name: PasswordAuthenticator +role_manager: + class_name: CassandraRoleManager + parameters: +accord: + journal_directory: build/test/cassandra/accord_journal diff --git a/test/distributed/org/apache/cassandra/distributed/impl/Instance.java b/test/distributed/org/apache/cassandra/distributed/impl/Instance.java index 6f18007c6705..3e540d33a3ab 100644 --- a/test/distributed/org/apache/cassandra/distributed/impl/Instance.java +++ b/test/distributed/org/apache/cassandra/distributed/impl/Instance.java @@ -935,7 +935,7 @@ else if (cluster instanceof Cluster) public void postStartup() { sync(() -> - StorageService.instance.doAuthSetup(false) + CassandraDaemon.doAuthSetup(false) ).run(); } diff --git a/test/distributed/org/apache/cassandra/distributed/test/AuthTest.java b/test/distributed/org/apache/cassandra/distributed/test/AuthTest.java index 6e66921ad8ed..68ce76aff725 100644 --- a/test/distributed/org/apache/cassandra/distributed/test/AuthTest.java +++ b/test/distributed/org/apache/cassandra/distributed/test/AuthTest.java @@ -37,7 +37,7 @@ import org.apache.cassandra.distributed.api.TokenSupplier; import org.apache.cassandra.locator.SimpleSeedProvider; import org.apache.cassandra.net.Verb; -import org.apache.cassandra.service.StorageService; +import org.apache.cassandra.service.CassandraDaemon; import static java.util.concurrent.TimeUnit.SECONDS; import static org.apache.cassandra.distributed.api.Feature.GOSSIP; @@ -52,8 +52,7 @@ public class AuthTest extends TestBaseImpl { /** * Simply tests that initialisation of a test Instance results in - * StorageService.instance.doAuthSetup being called as the regular - * startup does in CassandraDaemon.setup + * CassandraDaemon.doAuthSetup being called just as the regular startup does. */ @Test public void authSetupIsCalledAfterStartup() throws IOException @@ -64,7 +63,7 @@ public void authSetupIsCalledAfterStartup() throws IOException await().pollDelay(1, SECONDS) .pollInterval(1, SECONDS) .atMost(10, SECONDS) - .until(() -> instance.callOnInstance(() -> StorageService.instance.authSetupCalled())); + .until(() -> instance.callOnInstance(() -> CassandraDaemon.isAuthSetupCalled())); } } diff --git a/test/distributed/org/apache/cassandra/distributed/test/auth/AuthenticatorNegotiationTest.java b/test/distributed/org/apache/cassandra/distributed/test/auth/AuthenticatorNegotiationTest.java new file mode 100644 index 000000000000..59aebae0b43e --- /dev/null +++ b/test/distributed/org/apache/cassandra/distributed/test/auth/AuthenticatorNegotiationTest.java @@ -0,0 +1,365 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.distributed.test.auth; + +import java.io.IOException; + +import com.datastax.driver.core.PlainTextAuthProvider; +import com.datastax.driver.core.Session; + +import org.junit.Test; + +import org.apache.cassandra.distributed.Cluster; +import org.apache.cassandra.distributed.test.TestBaseImpl; + +import static org.apache.cassandra.distributed.api.Feature.GOSSIP; +import static org.apache.cassandra.distributed.api.Feature.NATIVE_PROTOCOL; +import static org.apache.cassandra.distributed.api.Feature.NETWORK; +import static org.apache.cassandra.distributed.util.Auth.waitForExistingRoles; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Exercises authenticator negotation for non-negotiating clients. When negotiation is enabled, non-negotiating + * clients should always get the configured default authenticator. In addition, when negotiation is enabled with at + * least one authenticator that requires authentication, clients authenticating through the AllowAllAuthenticator (or + * any other authenticator that does not require authentication) should authenticate as 'anonymous' and should not be + * granted super-user privileges by default. + *

+ * This is in contrast to 'anonymous' behavior when negotiation is not enabled. In that case, all clients use the + * same authenticator, and if that authenticator does not require authentication the 'anonymous' user will default + * to having super-user privileges. + */ +public class AuthenticatorNegotiationTest extends TestBaseImpl +{ + /** + * Tests that unauthenticated clients do not receive automatic superuser privileges when authentication is + * required globally. This validates the security fix in ClientState.isSuper() where it checks + * DatabaseDescriptor.isAuthenticationRequired() instead of per-connection authenticator.requireAuthentication(). + * + * Configuration: negotiation enabled with PasswordAuthenticator in negotiable list, + * but default=AllowAllAuthenticator so non-negotiating clients connect unauthenticated. + * Since ANY negotiable authenticator requires auth, unauthenticated clients should NOT + * receive automatic superuser privileges. + */ + @Test + public void testUnauthenticatedClientsGetAnonymousRole() throws IOException + { + try (Cluster cluster = builder().withNodes(1) + .withConfig(config -> { + config.with(NETWORK, GOSSIP, NATIVE_PROTOCOL) + .set("authenticator", "AllowAllAuthenticator") + .set("authorizer", "CassandraAuthorizer"); + + // Configure negotiation: default allows all, but negotiable includes PasswordAuthenticator + // This means isAuthenticationRequired() returns true (PasswordAuthenticator requires auth) + config.set("authenticator_negotiation", + new java.util.HashMap() {{ + put("enabled", true); + put("require_authentication", false); // permissive mode + put("default_authenticator", new java.util.HashMap() {{ + put("class_name", "AllowAllAuthenticator"); + }}); + put("authenticators", java.util.Arrays.asList( + new java.util.HashMap() {{ + put("class_name", "AllowAllAuthenticator"); + }}, + new java.util.HashMap() {{ + put("class_name", "PasswordAuthenticator"); + }} + )); + }}); + }) + .start()) + { + // Non-negotiating client connects without credentials, falls back to AllowAllAuthenticator. Gets + // anonymous user, but should NOT receive automatic superuser privileges because isAuthenticationRequired() + // is true. + com.datastax.driver.core.Cluster.Builder builder = + com.datastax.driver.core.Cluster.builder() + .addContactPoint("127.0.0.1"); + + try (com.datastax.driver.core.Cluster c = builder.build(); + Session session = c.connect()) + { + assertNotNull("Session should be established", session); + + // Verify we're logged in as anonymous + assertCurrentRole(session, "anonymous"); + + // Positive test: Anonymous user SHOULD be able to read from system tables + com.datastax.driver.core.ResultSet rs = session.execute("SELECT * FROM system.local"); + assertNotNull("Anonymous user should be able to read system.local", rs); + assertTrue("Should get at least one row", rs.iterator().hasNext()); + + // Negative test: Anonymous user should NOT be able to create roles (requires superuser) + // This verifies no automatic superuser privileges are granted + try + { + session.execute("CREATE ROLE test_role"); + org.junit.Assert.fail("Anonymous user should not be able to create roles (no automatic superuser privileges)"); + } + catch (com.datastax.driver.core.exceptions.UnauthorizedException e) + { + // Expected - anonymous user doesn't have permission + assertTrue("Should get permission denied", + e.getMessage().contains("User anonymous does not have sufficient privileges to perform the requested operation")); + } + } + } + } + + /** + * Tests that automatic superuser privileges are not granted to unauthenticated clients when authentication + * is required globally. This directly tests the security fix where isSuper() checks + * DatabaseDescriptor.isAuthenticationRequired() instead of the per-connection authenticator.requireAuthentication(). + * + * Configuration: negotiation enabled with PasswordAuthenticator in negotiable list, + * but default=AllowAllAuthenticator so non-negotiating clients connect unauthenticated. + * Since ANY negotiable authenticator requires auth, isSuper() should return false for anonymous. + */ + @Test + public void testSuperuserBypassDisabledWithAuthenticationRequired() throws IOException + { + try (Cluster cluster = builder().withNodes(1) + .withConfig(config -> { + config.with(NETWORK, GOSSIP, NATIVE_PROTOCOL) + .set("authenticator", "AllowAllAuthenticator"); + + // Configure negotiation: default allows all, but negotiable includes PasswordAuthenticator + config.set("authenticator_negotiation", + new java.util.HashMap() {{ + put("enabled", true); + put("require_authentication", false); // permissive mode + put("default_authenticator", new java.util.HashMap() {{ + put("class_name", "AllowAllAuthenticator"); + }}); + put("authenticators", java.util.Arrays.asList( + new java.util.HashMap() {{ + put("class_name", "AllowAllAuthenticator"); + }}, + new java.util.HashMap() {{ + put("class_name", "PasswordAuthenticator"); + }} + )); + }}); + }) + .start()) + { + // Create a test table first (as authenticated user) + waitForExistingRoles(cluster.get(1)); + + com.datastax.driver.core.Cluster.Builder authBuilder = + com.datastax.driver.core.Cluster.builder() + .addContactPoint("127.0.0.1") + .withAuthProvider(new PlainTextAuthProvider("cassandra", "cassandra")); + + try (com.datastax.driver.core.Cluster c = authBuilder.build(); + Session session = c.connect()) + { + session.execute("CREATE KEYSPACE IF NOT EXISTS test_ks WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1}"); + session.execute("CREATE TABLE IF NOT EXISTS test_ks.test_table (id int PRIMARY KEY, value text)"); + } + + // Non-negotiating client connects without credentials, falls back to AllowAllAuthenticator + com.datastax.driver.core.Cluster.Builder anonBuilder = + com.datastax.driver.core.Cluster.builder() + .addContactPoint("127.0.0.1"); + + try (com.datastax.driver.core.Cluster c = anonBuilder.build(); + Session session = c.connect()) + { + // Verify we're logged in as anonymous + assertCurrentRole(session, "anonymous"); + + // CREATE TRIGGER calls ensureIsSuperuser() which checks isSuper() + // With the fix, isSuper() returns false because isAuthenticationRequired() is true + try + { + session.execute("CREATE TRIGGER test_trigger ON test_ks.test_table USING 'org.apache.cassandra.triggers.AuditTrigger'"); + org.junit.Assert.fail("Anonymous user should not be able to create triggers (no automatic superuser privileges)"); + } + catch (com.datastax.driver.core.exceptions.UnauthorizedException e) + { + // Expected - isSuper() returned false, no automatic superuser privileges granted + assertTrue("Should get superuser required message", + e.getMessage().contains("Only superusers are allowed to perform CREATE TRIGGER queries")); + } + } + + // Cleanup + try (com.datastax.driver.core.Cluster c = authBuilder.build(); + Session session = c.connect()) + { + session.execute("DROP KEYSPACE IF EXISTS test_ks"); + } + } + } + + /** + * Tests that authenticated superusers retain their privileges when authenticator negotiation is enabled. + * This is a positive control test to ensure the permission system works correctly and isn't just blocking + * all privileged access. + * + * Configuration: default=PasswordAuthenticator, negotiable=[PasswordAuthenticator, AllowAllAuthenticator] + * Non-negotiating client falls back to PasswordAuthenticator and must authenticate. + */ + @Test + public void testAuthenticatedSuperuserHasPrivileges() throws IOException + { + try (Cluster cluster = builder().withNodes(1) + .withConfig(config -> { + config.with(NETWORK, GOSSIP, NATIVE_PROTOCOL) + .set("authenticator", "PasswordAuthenticator") + .set("authorizer", "CassandraAuthorizer"); + + // Configure negotiation with PasswordAuthenticator as default + config.set("authenticator_negotiation", + new java.util.HashMap() {{ + put("enabled", true); + put("require_authentication", false); // permissive mode + put("default_authenticator", new java.util.HashMap() {{ + put("class_name", "PasswordAuthenticator"); + }}); + put("authenticators", java.util.Arrays.asList( + new java.util.HashMap() {{ + put("class_name", "PasswordAuthenticator"); + }}, + new java.util.HashMap() {{ + put("class_name", "AllowAllAuthenticator"); + }} + )); + }}); + }) + .start()) + { + waitForExistingRoles(cluster.get(1)); + + // Non-negotiating client with credentials falls back to PasswordAuthenticator + com.datastax.driver.core.Cluster.Builder authBuilder = + com.datastax.driver.core.Cluster.builder() + .addContactPoint("127.0.0.1") + .withAuthProvider(new PlainTextAuthProvider("cassandra", "cassandra")); + + try (com.datastax.driver.core.Cluster c = authBuilder.build(); + Session session = c.connect()) + { + assertNotNull("Authenticated session should be established", session); + + // Verify we're logged in as cassandra + assertCurrentRole(session, "cassandra"); + + // Authenticated superuser should be able to create roles + session.execute("CREATE ROLE IF NOT EXISTS test_role"); + + // Verify the role was created + com.datastax.driver.core.ResultSet rs = session.execute("LIST ROLES"); + assertNotNull("Should be able to list roles", rs); + + // Clean up + session.execute("DROP ROLE IF EXISTS test_role"); + } + } + } + + /** + * Tests that when authenticator negotiation is enabled with a mix of authenticators, a client that doesn't + * support negotiation can still connect by falling back to the default authenticator (PasswordAuthenticator). + * + * Configuration: default=PasswordAuthenticator, negotiable=[PasswordAuthenticator, AllowAllAuthenticator] + * This ensures we can verify the server actually picked the default, not just any authenticator. + */ + @Test + public void testFallbackToDefaultAuthenticator() throws IOException + { + try (Cluster cluster = builder().withNodes(1) + .withConfig(config -> { + config.with(NETWORK, GOSSIP, NATIVE_PROTOCOL) + .set("authenticator", "PasswordAuthenticator"); + + // Configure authenticator negotiation with nested config + config.set("authenticator_negotiation", + new java.util.HashMap() {{ + put("enabled", true); + put("require_authentication", false); // permissive mode + put("default_authenticator", new java.util.HashMap() {{ + put("class_name", "PasswordAuthenticator"); + }}); + put("authenticators", java.util.Arrays.asList( + new java.util.HashMap() {{ + put("class_name", "PasswordAuthenticator"); + }}, + new java.util.HashMap() {{ + put("class_name", "AllowAllAuthenticator"); + }} + )); + }}); + }) + .start()) + { + waitForExistingRoles(cluster.get(1)); + + // Use DataStax driver which doesn't support negotiation protocol + // It should fall back to default (PasswordAuthenticator) and require credentials + com.datastax.driver.core.Cluster.Builder builder = + com.datastax.driver.core.Cluster.builder() + .addContactPoint("127.0.0.1") + .withAuthProvider(new PlainTextAuthProvider("cassandra", "cassandra")); + + + try (com.datastax.driver.core.Cluster c = builder.build(); + Session session = c.connect()) + { + // Verify we're logged in as cassandra (authenticated via PasswordAuthenticator) + assertCurrentRole(session, "cassandra"); + + // If we successfully connected with credentials, the server fell back to PasswordAuthenticator + // (If it had picked AllowAllAuthenticator, credentials wouldn't be required) + assertNotNull("Session should be established via default authenticator fallback", session); + + // Execute a query to verify the connection is fully functional + session.execute("SELECT * FROM system.local"); + } + } + } + + /** + * Helper method to verify the current authenticated user identity by executing LIST ROLES. + * + * @param session the session to execute the query on + * @param expectedRole the expected role name (e.g., "anonymous", "cassandra") + */ + private void assertCurrentRole(Session session, String expectedRole) + { + String actualRole; + try + { + com.datastax.driver.core.ResultSet rs = session.execute("LIST ROLES"); + actualRole = rs.one().getString("role"); + } + catch (com.datastax.driver.core.exceptions.UnauthorizedException e) + { + // LIST ROLES calls ensureNotAnonymous(), so exception probably means we're anonymous + actualRole = e.getMessage().contains("not anonymous") ? "anonymous" : null; + } + + assertEquals("Current authenticated role", expectedRole, actualRole); + } +} diff --git a/test/distributed/org/apache/cassandra/distributed/test/auth/LegacyAuthenticationTest.java b/test/distributed/org/apache/cassandra/distributed/test/auth/LegacyAuthenticationTest.java new file mode 100644 index 000000000000..a447edf6d1c0 --- /dev/null +++ b/test/distributed/org/apache/cassandra/distributed/test/auth/LegacyAuthenticationTest.java @@ -0,0 +1,175 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.distributed.test.auth; + +import java.io.IOException; + +import com.datastax.driver.core.PlainTextAuthProvider; +import com.datastax.driver.core.Session; +import com.datastax.driver.core.exceptions.AuthenticationException; + +import org.junit.Test; + +import org.apache.cassandra.distributed.Cluster; +import org.apache.cassandra.distributed.test.TestBaseImpl; + +import static org.apache.cassandra.distributed.api.Feature.GOSSIP; +import static org.apache.cassandra.distributed.api.Feature.NATIVE_PROTOCOL; +import static org.apache.cassandra.distributed.api.Feature.NETWORK; +import static org.apache.cassandra.distributed.util.Auth.waitForExistingRoles; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +/** + * Tests for legacy (pre-negotiation) authentication behavior. + * These tests verify authentication works correctly when: + * 1. No authenticator_negotiation config is present (legacy single authenticator) + * 2. authenticator_negotiation.enabled is explicitly set to false + */ +public class LegacyAuthenticationTest extends TestBaseImpl +{ + /** + * Tests legacy PasswordAuthenticator configuration (no negotiation config). + * Verifies that authentication is required and works correctly. + */ + @Test + public void testLegacyPasswordAuthenticator() throws IOException + { + try (Cluster cluster = builder().withNodes(1) + .withConfig(config -> config.with(NETWORK, GOSSIP, NATIVE_PROTOCOL) + .set("authenticator", "PasswordAuthenticator")) + .start()) + { + waitForExistingRoles(cluster.get(1)); + + // Should be able to connect with valid credentials + com.datastax.driver.core.Cluster.Builder authBuilder = + com.datastax.driver.core.Cluster.builder() + .addContactPoint("127.0.0.1") + .withAuthProvider(new PlainTextAuthProvider("cassandra", "cassandra")); + + try (com.datastax.driver.core.Cluster c = authBuilder.build(); + Session session = c.connect()) + { + assertNotNull("Should connect with valid credentials", session); + assertCurrentRole(session, "cassandra"); + session.execute("SELECT * FROM system.local"); + } + + // Should NOT be able to connect without credentials + com.datastax.driver.core.Cluster.Builder noAuthBuilder = + com.datastax.driver.core.Cluster.builder() + .addContactPoint("127.0.0.1"); + + try (com.datastax.driver.core.Cluster c = noAuthBuilder.build()) + { + c.connect(); + org.junit.Assert.fail("Should not be able to connect without credentials"); + } + catch (AuthenticationException e) + { + // Expected - authentication required + } + } + } + + /** + * Tests legacy AllowAllAuthenticator configuration (no negotiation config). + * Verifies that no authentication is required. + */ + @Test + public void testLegacyAllowAllAuthenticator() throws IOException + { + try (Cluster cluster = builder().withNodes(1) + .withConfig(config -> config.with(NETWORK, GOSSIP, NATIVE_PROTOCOL) + .set("authenticator", "AllowAllAuthenticator")) + .start()) + { + // Should be able to connect without credentials + com.datastax.driver.core.Cluster.Builder builder = + com.datastax.driver.core.Cluster.builder() + .addContactPoint("127.0.0.1"); + + try (com.datastax.driver.core.Cluster c = builder.build(); + Session session = c.connect()) + { + assertNotNull("Should connect without credentials", session); + assertCurrentRole(session, "anonymous"); + session.execute("SELECT * FROM system.local"); + } + } + } + + /** + * Tests that when authenticator_negotiation.enabled is explicitly set to false, + * the system behaves like legacy mode (uses the single authenticator config). + */ + @Test + public void testNegotiationExplicitlyDisabled() throws IOException + { + try (Cluster cluster = builder().withNodes(1) + .withConfig(config -> { + config.with(NETWORK, GOSSIP, NATIVE_PROTOCOL) + .set("authenticator", "PasswordAuthenticator"); + + // Explicitly disable negotiation + config.set("authenticator_negotiation", + new java.util.HashMap() {{ + put("enabled", false); + }}); + }) + .start()) + { + waitForExistingRoles(cluster.get(1)); + + // Should behave exactly like legacy PasswordAuthenticator + com.datastax.driver.core.Cluster.Builder authBuilder = + com.datastax.driver.core.Cluster.builder() + .addContactPoint("127.0.0.1") + .withAuthProvider(new PlainTextAuthProvider("cassandra", "cassandra")); + + try (com.datastax.driver.core.Cluster c = authBuilder.build(); + Session session = c.connect()) + { + assertNotNull("Should connect with valid credentials", session); + assertCurrentRole(session, "cassandra"); + session.execute("SELECT * FROM system.local"); + } + } + } + + /** + * Helper method to verify the current authenticated user identity. + */ + private void assertCurrentRole(Session session, String expectedRole) + { + String actualRole; + try + { + com.datastax.driver.core.ResultSet rs = session.execute("LIST ROLES"); + actualRole = rs.one().getString("role"); + } + catch (com.datastax.driver.core.exceptions.UnauthorizedException e) + { + actualRole = e.getMessage().contains("not anonymous") ? "anonymous" : null; + } + + assertEquals("Current authenticated role", expectedRole, actualRole); + } +} diff --git a/test/unit/org/apache/cassandra/auth/AuthConfigTest.java b/test/unit/org/apache/cassandra/auth/AuthConfigTest.java index ecbc72f0d517..1b497e533d98 100644 --- a/test/unit/org/apache/cassandra/auth/AuthConfigTest.java +++ b/test/unit/org/apache/cassandra/auth/AuthConfigTest.java @@ -23,14 +23,18 @@ import java.security.cert.CertificateException; import java.util.Arrays; import java.util.Collections; +import java.util.List; import org.junit.After; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.ExpectedException; import org.apache.cassandra.config.Config; import org.apache.cassandra.config.DatabaseDescriptor; import org.apache.cassandra.config.ParameterizedClass; +import org.apache.cassandra.exceptions.ConfigurationException; import org.apache.cassandra.locator.InetAddressAndPort; import org.apache.cassandra.utils.MBeanWrapper; @@ -39,6 +43,7 @@ import static org.apache.cassandra.auth.IInternodeAuthenticator.InternodeConnectionDirection.INBOUND; import static org.apache.cassandra.config.YamlConfigurationLoaderTest.load; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; @@ -53,6 +58,9 @@ public class AuthConfigTest private static final String CREDENTIALS_CACHE_MBEAN = MBEAN_NAME_BASE + PasswordAuthenticator.CredentialsCacheMBean.CACHE_NAME; + @Rule + public ExpectedException expectedException = ExpectedException.none(); + @Before public void setup() { @@ -65,6 +73,55 @@ public void teardown() unregisterCaches(); } + @Test + public void testConfigureAuthenticatorNegotiation() + { + Config config = load("cassandra-auth-negotiation-permissive.yaml"); + DatabaseDescriptor.unsafeDaemonInitialization(()->config); + + IAuthenticator authenticator = DatabaseDescriptor.getDefaultAuthenticator(); + assertThat(authenticator instanceof PasswordAuthenticator); + assertNotNull(authenticator); + assertTrue(DatabaseDescriptor.isAuthenticatorNegotiationEnabled()); + assertTrue(DatabaseDescriptor.isAuthenticationRequired()); + List negotiableAuthenticators = DatabaseDescriptor.getNegotiableAuthenticators(); + assertNotNull(negotiableAuthenticators); + assertEquals(3, negotiableAuthenticators.size()); + assertTrue(DatabaseDescriptor.getAuthenticator(PasswordAuthenticator.class).isPresent()); + assertTrue(DatabaseDescriptor.getAuthenticator(MutualTlsAuthenticator.class).isPresent()); + assertTrue(DatabaseDescriptor.getAuthenticator(AllowAllAuthenticator.class).isPresent()); + } + + @Test + public void testConfigureAuthenticatorNegotiationStrict() + { + Config config = load("cassandra-auth-negotiation-strict.yaml"); + DatabaseDescriptor.unsafeDaemonInitialization(() -> config); + + IAuthenticator authenticator = DatabaseDescriptor.getDefaultAuthenticator(); + assertThat(authenticator instanceof PasswordAuthenticator); + assertNotNull(authenticator); + assertTrue(DatabaseDescriptor.isAuthenticatorNegotiationEnabled()); + assertTrue(DatabaseDescriptor.isAuthenticationRequired()); + List negotiableAuthenticators = DatabaseDescriptor.getNegotiableAuthenticators(); + assertNotNull(negotiableAuthenticators); + assertEquals(2, negotiableAuthenticators.size()); + assertTrue(DatabaseDescriptor.getAuthenticator(PasswordAuthenticator.class).isPresent()); + assertTrue(DatabaseDescriptor.getAuthenticator(MutualTlsAuthenticator.class).isPresent()); + assertFalse(DatabaseDescriptor.getAuthenticator(AllowAllAuthenticator.class).isPresent()); + } + + @Test + public void testRequireAuthenticationRejectsAllowAll() + { + expectedException.expect(ConfigurationException.class); + expectedException.expectMessage("require_authentication"); + expectedException.expectMessage("AllowAllAuthenticator"); + + Config config = load("cassandra-auth-negotiation-invalid.yaml"); + DatabaseDescriptor.unsafeDaemonInitialization(() -> config); + } + @Test public void testNewInstanceForMutualTlsInternodeAuthenticator() throws IOException, CertificateException { @@ -93,9 +150,12 @@ public void testNewInstanceForPasswordAuthenticator() Config config = load("cassandra-passwordauth.yaml"); DatabaseDescriptor.unsafeDaemonInitialization(()->config); - IAuthenticator authenticator = DatabaseDescriptor.getAuthenticator(); + IAuthenticator authenticator = DatabaseDescriptor.getDefaultAuthenticator(); assertNotNull(authenticator); + // Verify negotiation is NOT enabled for legacy config + assertFalse(DatabaseDescriptor.isAuthenticatorNegotiationEnabled()); + assertThat(DatabaseDescriptor.getAuthenticator(PasswordAuthenticator.class)) .isPresent() .get() @@ -116,7 +176,7 @@ public void testNewInstanceForMutualTlsWithPasswordFallbackAuthenticator() config.authenticator.parameters = Collections.singletonMap("validator_class_name", "org.apache.cassandra.auth.SpiffeCertificateValidator"); DatabaseDescriptor.unsafeDaemonInitialization(()->config); - IAuthenticator authenticator = DatabaseDescriptor.getAuthenticator(); + IAuthenticator authenticator = DatabaseDescriptor.getDefaultAuthenticator(); assertNotNull(authenticator); // MutualTlsWithPasswordFallbackAuthenticator is-a PasswordAuthenticator, so we expect getAuthenticator to @@ -144,7 +204,7 @@ public void testNewInstanceForMutualTlsAuthenticator() Config config = load("cassandra-mtls.yaml"); DatabaseDescriptor.unsafeDaemonInitialization(()->config); - IAuthenticator authenticator = DatabaseDescriptor.getAuthenticator(); + IAuthenticator authenticator = DatabaseDescriptor.getDefaultAuthenticator(); assertNotNull(authenticator); assertThat(DatabaseDescriptor.getAuthenticator(PasswordAuthenticator.class)).isEmpty(); diff --git a/test/unit/org/apache/cassandra/auth/AuthTestUtils.java b/test/unit/org/apache/cassandra/auth/AuthTestUtils.java index d17d39ec4e09..2edd62c3d288 100644 --- a/test/unit/org/apache/cassandra/auth/AuthTestUtils.java +++ b/test/unit/org/apache/cassandra/auth/AuthTestUtils.java @@ -240,6 +240,47 @@ UntypedResultSet process(String query, ConsistencyLevel cl) } } + /** + * Functionally identical to LocalPasswordAuthenticator, but is used to differentiate between default and + * negotiated authenticators in negotiation test cases. Delegates to a shared LocalPasswordAuthenticator + * instance to avoid duplicate cache initialization. + */ + public static class LocalDefaultPasswordAuthenticator extends LocalPasswordAuthenticator + { + private final LocalPasswordAuthenticator delegate; + + public LocalDefaultPasswordAuthenticator(LocalPasswordAuthenticator delegate) + { + this.delegate = delegate; + } + + @Override + public void setup() + { + // Delegate handles setup + } + + @Override + public SaslNegotiator newSaslNegotiator(InetAddress clientAddress) + { + return delegate.newSaslNegotiator(clientAddress); + } + + @Override + public AuthenticatedUser legacyAuthenticate(Map credentials) + { + return delegate.legacyAuthenticate(credentials); + } + } + + public static class LocalMutualTLSAuthenticator extends MutualTlsAuthenticator + { + public LocalMutualTLSAuthenticator(Map parameters) + { + super(parameters); + } + } + public static class LocalMutualTlsWithPasswordFallbackAuthenticator extends MutualTlsWithPasswordFallbackAuthenticator { diff --git a/test/unit/org/apache/cassandra/auth/AuthenticatorNegotiatorTest.java b/test/unit/org/apache/cassandra/auth/AuthenticatorNegotiatorTest.java new file mode 100644 index 000000000000..18004292b7ac --- /dev/null +++ b/test/unit/org/apache/cassandra/auth/AuthenticatorNegotiatorTest.java @@ -0,0 +1,200 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.cassandra.auth; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Set; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import org.apache.cassandra.config.Config; +import org.apache.cassandra.config.DatabaseDescriptor; + +import static org.apache.cassandra.auth.AuthCache.MBEAN_NAME_BASE; +import static org.apache.cassandra.config.YamlConfigurationLoaderTest.load; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +/** + * Tests the authenticator negotiation logic in {@link AuthenticatorNegotiator}. + * Runs the same tests with both permissive (allows AllowAllAuthenticator) and strict + * (requires authentication) configurations. + */ +@RunWith(Parameterized.class) +public class AuthenticatorNegotiatorTest +{ + private static final String IDENTITIES_CACHE_MBEAN = MBEAN_NAME_BASE + MutualTlsAuthenticator.CACHE_NAME; + private static final String CREDENTIALS_CACHE_MBEAN = MBEAN_NAME_BASE + PasswordAuthenticator.CredentialsCacheMBean.CACHE_NAME; + + @Parameterized.Parameter + public String configFile; + + @Parameterized.Parameters(name = "{0}") + public static Collection configs() + { + return Arrays.asList( + "test/conf/cassandra-auth-negotiation-permissive.yaml", + "test/conf/cassandra-auth-negotiation-strict.yaml" + ); + } + + @Before + public void setup() + { + AuthConfig.reset(); + } + + @After + public void teardown() + { + unregisterCaches(); + } + + private void unregisterCaches() + { + try + { + org.apache.cassandra.utils.MBeanWrapper.instance.unregisterMBean(IDENTITIES_CACHE_MBEAN); + } + catch (Exception ignored) {} + + try + { + org.apache.cassandra.utils.MBeanWrapper.instance.unregisterMBean(CREDENTIALS_CACHE_MBEAN); + } + catch (Exception ignored) {} + } + + private void initializeWithConfig() + { + Config config = load(configFile); + DatabaseDescriptor.unsafeDaemonInitialization(() -> config); + } + + // Server will use the default authenticator if the client doesn't provide any options. + @Test + public void testEmptyClientAuthenticators() + { + initializeWithConfig(); + + Set clientModes = Set.of(); + IAuthenticator result = AuthenticatorNegotiator.negotiateAuthenticator(clientModes); + + assertSame(DatabaseDescriptor.getDefaultAuthenticator(), result); + } + + // Server supports MTLS, Password and AllowAll. Client supports MTLS and Password. Server should pick MTLS + // as the most preferred shared preference. + @Test + public void testMatchesServersPreferredAuthenticator() + { + initializeWithConfig(); + + Set clientModes = Set.of("MutualTls", "Password"); + IAuthenticator result = AuthenticatorNegotiator.negotiateAuthenticator(clientModes); + + assertTrue(result instanceof MutualTlsAuthenticator); + } + + // Server supports MTLS, Password and AllowAll. Client supports Password or no-auth. Server should choose Password + // auth as it's the most preferred option of the ones the client can support (even though the server would prefer + // MTLS). + @Test + public void testMatchesServersAcceptedAuthenticator() + { + initializeWithConfig(); + + Set clientModes = Set.of("Password", "Unauthenticated"); + IAuthenticator result = AuthenticatorNegotiator.negotiateAuthenticator(clientModes); + + // Should return Password authenticator + assertTrue(result instanceof PasswordAuthenticator); + } + + // Server supports MTLS, Password and AllowAll. Client supports Kerberos, JWT and OAuth. Since the server and + // client don't appear able to support any common authentication scheme, the server will offer its default + // authenticator and hope the client can work with it. + @Test + public void testNoMatchingAuthenticatorUsesDefaultAuthenticator() + { + initializeWithConfig(); + + Set clientModes = Set.of("Kerberos", "JWT", "OAuth"); + IAuthenticator result = AuthenticatorNegotiator.negotiateAuthenticator(clientModes); + + assertSame(DatabaseDescriptor.getDefaultAuthenticator(), result); + } + + // Server supports MTLS, Password and AllowAll. Client supports Password and MTLS but doesn't agree on + // case (for whatever reason). Server should correctly settle on MTLS regardless. + @Test + public void testCaseInsensitiveMatching() + { + initializeWithConfig(); + + Set clientModes = Set.of("password", "MUTUALTLS"); + IAuthenticator result = AuthenticatorNegotiator.negotiateAuthenticator(clientModes); + + assertTrue(result instanceof MutualTlsAuthenticator); + } + + // Server supports MTLS, Password and AllowAll. Client supports AllowAll, Password and MTLS. Server should + // select its most preferred option (MTLS) even though it's not the first option offered by the client. + @Test + public void testPriorityOrderWithMultipleMatches() + { + initializeWithConfig(); + + Set clientModes = Set.of("Unauthenticated", "Password", "MutualTls"); + IAuthenticator result = AuthenticatorNegotiator.negotiateAuthenticator(clientModes); + + assertTrue(result instanceof MutualTlsAuthenticator); + } + + // Client sends duplicate authentication modes including case variations. Server should handle this gracefully + // and select based on its priority order, not be confused by the duplicates. + @Test + public void testDuplicateClientAuthenticators() + { + initializeWithConfig(); + + Set clientModes = Set.of("Password", "MutualTls", "password"); + IAuthenticator result = AuthenticatorNegotiator.negotiateAuthenticator(clientModes); + + assertTrue(result instanceof MutualTlsAuthenticator); + } + + // Server is not configured for negotiation. Client attempts to offer authentication options anyway. + // Server should simply respond with its default authenticator (password auth). + @Test + public void testNegotiationDisabled() + { + Config config = load("cassandra-passwordauth.yaml"); + DatabaseDescriptor.unsafeDaemonInitialization(() -> config); + + Set clientModes = Set.of("MutualTls"); + IAuthenticator result = AuthenticatorNegotiator.negotiateAuthenticator(clientModes); + + assertSame(DatabaseDescriptor.getDefaultAuthenticator(), result); + } +} diff --git a/test/unit/org/apache/cassandra/config/AuthenticatorNegotiationConfigTest.java b/test/unit/org/apache/cassandra/config/AuthenticatorNegotiationConfigTest.java new file mode 100644 index 000000000000..75b6da46641e --- /dev/null +++ b/test/unit/org/apache/cassandra/config/AuthenticatorNegotiationConfigTest.java @@ -0,0 +1,212 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.config; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; + +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class AuthenticatorNegotiationConfigTest +{ + @Test + public void testLoadFullConfiguration() + { + Map yaml = Map.of( + "authenticator_negotiation", Map.of( + "enabled", true, + "require_authentication", true, + "default_authenticator", Map.of("class_name", "PasswordAuthenticator"), + "authenticators", List.of( + Map.of( + "class_name", "MutualTlsAuthenticator", + "parameters", Map.of( + "validator_class_name", "org.apache.cassandra.auth.SpiffeCertificateValidator" + ) + ), + Map.of("class_name", "PasswordAuthenticator"), + Map.of( + "class_name", "com.example.authn.CustomAuthenticator", + "parameters", Map.of("custom_param", "custom_value") + ) + ) + ) + ); + + Config config = YamlConfigurationLoader.fromMap(yaml, Config.class); + + assertNotNull(config.authenticator_negotiation); + assertTrue(config.authenticator_negotiation.enabled); + assertTrue(config.authenticator_negotiation.require_authentication); + + assertNotNull(config.authenticator_negotiation.default_authenticator); + assertEquals("PasswordAuthenticator", config.authenticator_negotiation.default_authenticator.class_name); + + assertEquals(3, config.authenticator_negotiation.authenticators.size()); + + // Order of authenticators must be preserved. + assertThat(config.authenticator_negotiation.authenticators) + .extracting(pc -> pc.class_name) + .containsExactly("MutualTlsAuthenticator", + "PasswordAuthenticator", + "com.example.authn.CustomAuthenticator"); + + // Validate expected parameters for each authenticator + ParameterizedClass mtls = config.authenticator_negotiation.authenticators.get(0); + assertEquals("org.apache.cassandra.auth.SpiffeCertificateValidator", + mtls.parameters.get("validator_class_name")); + + ParameterizedClass password = config.authenticator_negotiation.authenticators.get(1); + assertNull(password.parameters); + + ParameterizedClass custom = config.authenticator_negotiation.authenticators.get(2); + assertEquals("custom_value", custom.parameters.get("custom_param")); + } + + @Test + public void testDefaultsWhenNotConfigured() + { + Config config = YamlConfigurationLoader.fromMap(Collections.emptyMap(), Config.class); + + assertNotNull(config.authenticator_negotiation); + assertFalse(config.authenticator_negotiation.enabled); + assertTrue(config.authenticator_negotiation.require_authentication); + assertNull(config.authenticator_negotiation.default_authenticator); + assertNotNull(config.authenticator_negotiation.authenticators); + assertTrue(config.authenticator_negotiation.authenticators.isEmpty()); + } + + + @Test + public void testPartialConfiguration() + { + Map yaml = ImmutableMap.of( + "authenticator_negotiation", ImmutableMap.of( + "enabled", true, + "default_authenticator", ImmutableMap.of("class_name", "PasswordAuthenticator") + ) + ); + + Config config = YamlConfigurationLoader.fromMap(yaml, Config.class); + + assertNotNull(config.authenticator_negotiation); + assertTrue(config.authenticator_negotiation.enabled); + assertTrue(config.authenticator_negotiation.require_authentication); + assertNotNull(config.authenticator_negotiation.default_authenticator); + assertEquals("PasswordAuthenticator", config.authenticator_negotiation.default_authenticator.class_name); + assertNotNull(config.authenticator_negotiation.authenticators); + assertTrue(config.authenticator_negotiation.authenticators.isEmpty()); + } + + @Test + public void testLoadsAuthenticatorsWhenNegotiationDisabled() + { + Map yaml = ImmutableMap.of( + "authenticator_negotiation", ImmutableMap.of( + "enabled", false, + "default_authenticator", ImmutableMap.of("class_name", "PasswordAuthenticator"), + "authenticators", ImmutableList.of( + ImmutableMap.of("class_name", "PasswordAuthenticator") + ) + ) + ); + + Config config = YamlConfigurationLoader.fromMap(yaml, Config.class); + + assertFalse(config.authenticator_negotiation.enabled); + assertNotNull(config.authenticator_negotiation.default_authenticator); + assertEquals("PasswordAuthenticator", config.authenticator_negotiation.default_authenticator.class_name); + assertEquals(1, config.authenticator_negotiation.authenticators.size()); + } + + @Test + public void testEmptyAuthenticatorsList() + { + Map yaml = ImmutableMap.of( + "authenticator_negotiation", ImmutableMap.of( + "enabled", true, + "authenticators", Collections.emptyList() + ) + ); + + Config config = YamlConfigurationLoader.fromMap(yaml, Config.class); + + assertTrue(config.authenticator_negotiation.enabled); + assertTrue(config.authenticator_negotiation.authenticators.isEmpty()); + } + + @Test + public void testDefaultAuthenticatorWithParameters() + { + Map yaml = ImmutableMap.of( + "authenticator_negotiation", ImmutableMap.of( + "enabled", true, + "default_authenticator", ImmutableMap.of( + "class_name", "MutualTlsAuthenticator", + "parameters", ImmutableMap.of( + "validator_class_name", "org.apache.cassandra.auth.SpiffeCertificateValidator" + ) + ), + "authenticators", ImmutableList.of( + ImmutableMap.of("class_name", "PasswordAuthenticator") + ) + ) + ); + + Config config = YamlConfigurationLoader.fromMap(yaml, Config.class); + + assertNotNull(config.authenticator_negotiation.default_authenticator); + assertEquals("MutualTlsAuthenticator", config.authenticator_negotiation.default_authenticator.class_name); + assertNotNull(config.authenticator_negotiation.default_authenticator.parameters); + assertEquals("org.apache.cassandra.auth.SpiffeCertificateValidator", + config.authenticator_negotiation.default_authenticator.parameters.get("validator_class_name")); + } + + @Test + public void testUpdateInPlace() + { + Config config = new Config(); + + assertFalse(config.authenticator_negotiation.enabled); + assertTrue(config.authenticator_negotiation.require_authentication); + + // Update to negate defaults. + Map yaml = ImmutableMap.of( + "authenticator_negotiation.enabled", true, + "authenticator_negotiation.require_authentication", false + ); + + Config updated = YamlConfigurationLoader.updateFromMap(yaml, true, config); + + assertThat(updated).isSameAs(config); + assertTrue(config.authenticator_negotiation.enabled); + assertFalse(config.authenticator_negotiation.require_authentication); + } +} diff --git a/test/unit/org/apache/cassandra/config/DatabaseDescriptorRefTest.java b/test/unit/org/apache/cassandra/config/DatabaseDescriptorRefTest.java index cdeb89d1cdc7..15a1785a4ed0 100644 --- a/test/unit/org/apache/cassandra/config/DatabaseDescriptorRefTest.java +++ b/test/unit/org/apache/cassandra/config/DatabaseDescriptorRefTest.java @@ -90,6 +90,7 @@ public class DatabaseDescriptorRefTest "org.apache.cassandra.config.CassandraRelevantProperties$PropertyConverter", "org.apache.cassandra.config.Config", "org.apache.cassandra.config.Config$1", + "org.apache.cassandra.config.Config$AuthenticatorNegotiationConfig", "org.apache.cassandra.config.Config$CommitFailurePolicy", "org.apache.cassandra.config.Config$CQLStartTime", "org.apache.cassandra.config.Config$CommitLogSync", diff --git a/test/unit/org/apache/cassandra/cql3/CQLTester.java b/test/unit/org/apache/cassandra/cql3/CQLTester.java index e4eaec08eab4..c24f36336b58 100644 --- a/test/unit/org/apache/cassandra/cql3/CQLTester.java +++ b/test/unit/org/apache/cassandra/cql3/CQLTester.java @@ -83,6 +83,7 @@ import com.datastax.shaded.netty.channel.EventLoopGroup; import com.google.common.base.Objects; import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; @@ -110,11 +111,10 @@ import org.apache.cassandra.SchemaLoader; import org.apache.cassandra.ServerTestUtils; import org.apache.cassandra.Util; -import org.apache.cassandra.auth.AuthCacheService; -import org.apache.cassandra.auth.AuthSchemaChangeListener; import org.apache.cassandra.auth.AuthTestUtils; import org.apache.cassandra.auth.IAuthenticator; import org.apache.cassandra.auth.IRoleManager; +import org.apache.cassandra.auth.SpiffeCertificateValidator; import org.apache.cassandra.concurrent.Stage; import org.apache.cassandra.config.CassandraRelevantProperties; import org.apache.cassandra.config.Config; @@ -184,6 +184,7 @@ import org.apache.cassandra.schema.SchemaKeyspace; import org.apache.cassandra.schema.TableMetadata; import org.apache.cassandra.serializers.TypeSerializer; +import org.apache.cassandra.service.CassandraDaemon; import org.apache.cassandra.service.ClientState; import org.apache.cassandra.service.ClientWarn; import org.apache.cassandra.service.QueryState; @@ -632,7 +633,57 @@ protected static void requireAuthentication() protected static void requireAuthentication(final IAuthenticator authenticator) { - DatabaseDescriptor.setAuthenticator(authenticator); + // Setup for single authenticator mode (no negotiation) + DatabaseDescriptor.setDefaultAuthenticator(authenticator); + DatabaseDescriptor.setAuthenticatorNegotationEnabled(false); + DatabaseDescriptor.setNegotiableAuthenticators(List.of()); + + commonAuthSetup(); + } + + protected static void requireAuthenticatorNegotiation() + { + final Map authenticatorParams = ImmutableMap.of("validator_class_name", SpiffeCertificateValidator.class.getSimpleName()); + requireAuthenticatorNegotiation(new AuthTestUtils.LocalPasswordAuthenticator(), new AuthTestUtils.LocalMutualTLSAuthenticator(authenticatorParams)); + } + + protected static void requireAuthenticatorNegotiation(IAuthenticator... negotiableAuthenticators) + { + // Setup for negotiation mode with multiple authenticators + // Find or create the LocalPasswordAuthenticator instance that will be shared + AuthTestUtils.LocalPasswordAuthenticator passwordAuth = null; + for (IAuthenticator auth : negotiableAuthenticators) + { + if (auth instanceof AuthTestUtils.LocalPasswordAuthenticator) + { + passwordAuth = (AuthTestUtils.LocalPasswordAuthenticator) auth; + break; + } + } + if (passwordAuth == null) + passwordAuth = new AuthTestUtils.LocalPasswordAuthenticator(); + + // Create default authenticator that delegates to the shared instance + IAuthenticator defaultAuthenticator = new AuthTestUtils.LocalDefaultPasswordAuthenticator(passwordAuth); + DatabaseDescriptor.setDefaultAuthenticator(defaultAuthenticator); + DatabaseDescriptor.setAuthenticatorNegotationEnabled(true); + + // Build negotiable list and ensure the shared password authenticator is included + List negotiableList = new ArrayList<>(List.of(negotiableAuthenticators)); + if (!negotiableList.contains(passwordAuth)) + negotiableList.add(passwordAuth); + + DatabaseDescriptor.setNegotiableAuthenticators(negotiableList); + + commonAuthSetup(); + } + + /** + * Common auth setup for both single and negotiation modes. + * Sets up authorizers, role manager, and calls doAuthSetup(). + */ + private static void commonAuthSetup() + { DatabaseDescriptor.setAuthorizer(new AuthTestUtils.LocalCassandraAuthorizer()); DatabaseDescriptor.setNetworkAuthorizer(new AuthTestUtils.LocalCassandraNetworkAuthorizer()); DatabaseDescriptor.setCIDRAuthorizer(new AuthTestUtils.LocalCassandraCIDRAuthorizer()); @@ -652,14 +703,10 @@ public void setup() DatabaseDescriptor.setRoleManager(roleManager); //TODO //MigrationManager.announceNewKeyspace(AuthKeyspace.metadata(), true); - DatabaseDescriptor.getRoleManager().setup(); - DatabaseDescriptor.getAuthenticator().setup(); - DatabaseDescriptor.getAuthorizer().setup(); - DatabaseDescriptor.getNetworkAuthorizer().setup(); - DatabaseDescriptor.getCIDRAuthorizer().setup(); - Schema.instance.registerListener(new AuthSchemaChangeListener()); - - AuthCacheService.initializeAndRegisterCaches(); + + // Use centralized auth setup - use sync mode for tests + CassandraDaemon.resetAuthSetup(); + CassandraDaemon.doAuthSetup(false); // false = synchronous setup for tests } /** diff --git a/test/unit/org/apache/cassandra/transport/AuthenticatorNegotiationTest.java b/test/unit/org/apache/cassandra/transport/AuthenticatorNegotiationTest.java new file mode 100644 index 000000000000..928448c2622d --- /dev/null +++ b/test/unit/org/apache/cassandra/transport/AuthenticatorNegotiationTest.java @@ -0,0 +1,227 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.transport; + +import java.io.IOException; +import java.util.Map; + +import org.junit.BeforeClass; +import org.junit.Test; + +import org.apache.cassandra.auth.AuthTestUtils; +import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.QueryProcessor; +import org.apache.cassandra.exceptions.AuthenticationException; +import org.apache.cassandra.transport.messages.AuthResponse; +import org.apache.cassandra.transport.messages.AuthSuccess; +import org.apache.cassandra.transport.messages.AuthenticateMessage; +import org.apache.cassandra.transport.messages.ErrorMessage; +import org.apache.cassandra.transport.messages.OptionsMessage; +import org.apache.cassandra.transport.messages.StartupMessage; +import org.apache.cassandra.transport.messages.SupportedMessage; + +import static org.apache.cassandra.auth.AuthTestUtils.getToken; +import static org.apache.cassandra.transport.messages.StartupMessage.AUTHENTICATORS; +import static org.apache.cassandra.transport.messages.StartupMessage.CQL_VERSION; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Tests the protocol flow for a server that is configured to support authenticator negotiation, including + * compatibility scenarios defined in CEP-50. Covers the compatibility matrix for negotiating and non-negotiating + * clients. + */ +public class AuthenticatorNegotiationTest extends CQLTester +{ + @BeforeClass + public static void setup() + { + requireNetwork(); + requireAuthenticatorNegotiation(); + } + + // Scenario 1: Negotiating client + Negotiating server - OPTIONS/SUPPORTED handshake + @Test + public void testFullNegotiationHandshake() + { + try (SimpleClient client = SimpleClient.builder(nativeAddr.getHostAddress(), nativePort).build()) + { + // Negotiating client: send OPTIONS + client.establishConnection(); + OptionsMessage options = new OptionsMessage(); + Message.Response optionsResponse = client.execute(options); + + assertTrue("Server should respond with SUPPORTED", optionsResponse instanceof SupportedMessage); + + SupportedMessage supported = (SupportedMessage) optionsResponse; + assertTrue("Negotiating server should include AUTHENTICATORS in SUPPORTED", + supported.supported.containsKey(AUTHENTICATORS)); + assertNotNull("AUTHENTICATORS value should not be null", + supported.supported.get(AUTHENTICATORS)); + + // Client sends STARTUP with AUTHENTICATORS + StartupMessage startup = new StartupMessage(Map.of(CQL_VERSION, QueryProcessor.CQL_VERSION.toString(), + AUTHENTICATORS, "Password,MutualTls")); + Message.Response startupResponse = client.execute(startup); + + assertTrue("Server should respond with AUTHENTICATE after negotiation", + startupResponse instanceof AuthenticateMessage); + assertEquals(((AuthenticateMessage) startupResponse).authenticator, + AuthTestUtils.LocalPasswordAuthenticator.class.getName()); + + // Complete authentication with server's preferred authenticator (Password) + AuthResponse authResponse = new AuthResponse(getToken("cassandra", "cassandra")); + Message.Response authResult = client.execute(authResponse); + + assertTrue("Authentication should succeed with default authenticator", + authResult instanceof AuthSuccess); + } + catch (IOException e) + { + fail("Error establishing connection: " + e.getMessage()); + } + } + + // Scenario 2: "Short" negotiation - client skips sending Options message and immediately sends STARTUP with + // a list of authenticators it can use. + @Test + public void testShortNegotiationHandshake() + { + try (SimpleClient client = SimpleClient.builder(nativeAddr.getHostAddress(), nativePort).build()) + { + client.establishConnection(); + StartupMessage startup = new StartupMessage(Map.of(CQL_VERSION, QueryProcessor.CQL_VERSION.toString(), + AUTHENTICATORS, "Password,MutualTls,Unauthenticated")); + Message.Response startupResponse = client.execute(startup); + + assertTrue("Server should respond with AUTHENTICATE", startupResponse instanceof AuthenticateMessage); + assertEquals(((AuthenticateMessage) startupResponse).authenticator, + AuthTestUtils.LocalPasswordAuthenticator.class.getName()); + + AuthResponse authResponse = new AuthResponse(getToken("cassandra", "cassandra")); + Message.Response authResult = client.execute(authResponse); + + assertTrue("Authentication should succeed with correct credentials", + authResult instanceof AuthSuccess); + } + catch (IOException e) + { + fail("Error establishing connection: " + e.getMessage()); + } + } + + // Scenario 3: Non-negotiating client + Negotiating server + // Client sends STARTUP without AUTHENTICATORS option, server falls back to default authenticator + @Test + public void testNonNegotiatingClientWithNegotiatingServer() + { + try (SimpleClient client = SimpleClient.builder(nativeAddr.getHostAddress(), nativePort).build()) + { + // Non-negotiating client: STARTUP without AUTHENTICATORS + client.establishConnection(); + StartupMessage startup = new StartupMessage(Map.of(CQL_VERSION, QueryProcessor.CQL_VERSION.toString())); + Message.Response startupResponse = client.execute(startup); + + assertTrue("Server should respond with AUTHENTICATE for non-negotiating client", + startupResponse instanceof AuthenticateMessage); + assertEquals(((AuthenticateMessage) startupResponse).authenticator, + AuthTestUtils.LocalDefaultPasswordAuthenticator.class.getName()); + + // Complete authentication with default authenticator (Password) + AuthResponse authResponse = new AuthResponse(getToken("cassandra", "cassandra")); + Message.Response authResult = client.execute(authResponse); + + assertTrue("Authentication should succeed with default authenticator", + authResult instanceof AuthSuccess); + } + catch (IOException e) + { + fail("Error establishing connection: " + e.getMessage()); + } + } + + // Scenario 4: Negotating client + Negotiating server + // Full negotiation but no matching authenticators: falls back to default authenticator + @Test + public void testFullNegotiationNoMatch() + { + try (SimpleClient client = SimpleClient.builder(nativeAddr.getHostAddress(), nativePort).build()) + { + // Client offers authenticators the server doesn't support + client.establishConnection(); + StartupMessage startup = new StartupMessage(Map.of(CQL_VERSION, QueryProcessor.CQL_VERSION.toString(), + AUTHENTICATORS, "Kerberos,OAuth,JWT")); + Message.Response startupResponse = client.execute(startup, false); + + assertTrue("Server should respond with AUTHENTICATE using default authenticator when no match", + startupResponse instanceof AuthenticateMessage); + assertEquals(((AuthenticateMessage) startupResponse).authenticator, + AuthTestUtils.LocalDefaultPasswordAuthenticator.class.getName()); + + // Complete authentication with default authenticator (Password) + AuthResponse authResponse = new AuthResponse(getToken("cassandra", "cassandra")); + Message.Response authResult = client.execute(authResponse); + + assertTrue("Authentication should succeed with default authenticator", + authResult instanceof AuthSuccess); + } + catch (IOException e) + { + fail("Error establishing connection: " + e.getMessage()); + } + } + + // Scenario 5: Negotating client + Negotiating server + // Successful negotiation but failed authentication should result in ERROR to client + @Test + public void testNegotiatedAuthenticationFailure() + { + try (SimpleClient client = SimpleClient.builder(nativeAddr.getHostAddress(), nativePort).build()) + { + client.establishConnection(); + StartupMessage startup = new StartupMessage(Map.of(CQL_VERSION, QueryProcessor.CQL_VERSION.toString(), + AUTHENTICATORS, "Password,MutualTls,Unauthenticated")); + Message.Response startupResponse = client.execute(startup); + + assertTrue("Server should respond with AUTHENTICATE", startupResponse instanceof AuthenticateMessage); + assertEquals(((AuthenticateMessage) startupResponse).authenticator, + AuthTestUtils.LocalPasswordAuthenticator.class.getName()); + + AuthResponse authResponse = new AuthResponse(getToken("cassandra", "badpassword")); + Message.Response response = client.execute(authResponse, false); + + if (response instanceof ErrorMessage) + { + ErrorMessage errorMessage = (ErrorMessage) response; + assertTrue("Expected AuthenticationException, got: " + errorMessage.error, + errorMessage.error instanceof AuthenticationException); + } + else + { + fail("Expected ErrorMessage but got: " + response); + } + } + catch (IOException e) + { + fail("Error establishing connection: " + e.getMessage()); + } + } +} diff --git a/test/unit/org/apache/cassandra/transport/AuthenticatorWithoutNegotiationTest.java b/test/unit/org/apache/cassandra/transport/AuthenticatorWithoutNegotiationTest.java new file mode 100644 index 000000000000..5c1e1d54c6cf --- /dev/null +++ b/test/unit/org/apache/cassandra/transport/AuthenticatorWithoutNegotiationTest.java @@ -0,0 +1,129 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.transport; + +import java.io.IOException; +import java.util.Map; + +import org.junit.BeforeClass; +import org.junit.Test; + +import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.QueryProcessor; +import org.apache.cassandra.transport.messages.AuthResponse; +import org.apache.cassandra.transport.messages.AuthSuccess; +import org.apache.cassandra.transport.messages.AuthenticateMessage; +import org.apache.cassandra.transport.messages.OptionsMessage; +import org.apache.cassandra.transport.messages.StartupMessage; +import org.apache.cassandra.transport.messages.SupportedMessage; + +import static org.apache.cassandra.auth.AuthTestUtils.getToken; +import static org.apache.cassandra.transport.messages.StartupMessage.AUTHENTICATORS; +import static org.apache.cassandra.transport.messages.StartupMessage.CQL_VERSION; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Tests authentication protocol flow when server does NOT support negotiation. + * Covers scenario 3 from CEP-50: Negotiating client + Non-negotiating server. + */ +public class AuthenticatorWithoutNegotiationTest extends CQLTester +{ + @BeforeClass + public static void setup() + { + requireNetwork(); + requireAuthentication(); + } + + // Scenario 1: Negotiating client + Non-negotiating server + // Client sends OPTIONS, server SUPPORTED lacks AUTHENTICATORS, client falls back + @Test + public void testNegotiatingClientWithNonNegotiatingServer() + { + try (SimpleClient client = SimpleClient.builder(nativeAddr.getHostAddress(), nativePort).build()) + { + // Negotiating client: send OPTIONS first + client.establishConnection(); + OptionsMessage options = new OptionsMessage(); + Message.Response optionsResponse = client.execute(options); + + assertTrue("Server should respond with SUPPORTED", optionsResponse instanceof SupportedMessage); + + SupportedMessage supported = (SupportedMessage) optionsResponse; + assertFalse("Non-negotiating server should not include AUTHENTICATORS in SUPPORTED", + supported.supported.containsKey(AUTHENTICATORS)); + + // Client detects no negotiation support, sends STARTUP without AUTHENTICATORS + StartupMessage startup = new StartupMessage(Map.of(CQL_VERSION, QueryProcessor.CQL_VERSION.toString())); + Message.Response startupResponse = client.execute(startup); + + assertTrue("Server should respond with AUTHENTICATE", startupResponse instanceof AuthenticateMessage); + + // Complete authentication + AuthResponse authResponse = new AuthResponse(getToken("cassandra", "cassandra")); + Message.Response authResult = client.execute(authResponse); + + assertTrue("Authentication should succeed", authResult instanceof AuthSuccess); + } + catch (IOException e) + { + fail("Error establishing connection: " + e.getMessage()); + } + } + + // Scenario 2: Stubborn negotiating client + Non-negotiating server. + // Client sends OPTIONS, server SUPPORTED lacks AUTHENTICATORS, client sends authenticators with STARTUP message + // anyway, server ignores and executes non-negotiating auth flow. + @Test + public void testStubbornNegotiatingClientWithNonNegotiatingServer() + { + try (SimpleClient client = SimpleClient.builder(nativeAddr.getHostAddress(), nativePort).build()) + { + // Negotiating client: send OPTIONS first + client.establishConnection(); + OptionsMessage options = new OptionsMessage(); + Message.Response optionsResponse = client.execute(options); + + assertTrue("Server should respond with SUPPORTED", optionsResponse instanceof SupportedMessage); + + SupportedMessage supported = (SupportedMessage) optionsResponse; + assertFalse("Non-negotiating server should not include AUTHENTICATORS in SUPPORTED", + supported.supported.containsKey(AUTHENTICATORS)); + + // Client ignores signal that server lacks negotiation support, sends STARTUP with AUTHENTICATORS + StartupMessage startup = new StartupMessage(Map.of(CQL_VERSION, QueryProcessor.CQL_VERSION.toString(), + AUTHENTICATORS, "Password,MutualTls,Unauthenticated")); + Message.Response startupResponse = client.execute(startup); + + assertTrue("Server should respond with AUTHENTICATE", startupResponse instanceof AuthenticateMessage); + + // Complete authentication + AuthResponse authResponse = new AuthResponse(getToken("cassandra", "cassandra")); + Message.Response authResult = client.execute(authResponse); + + assertTrue("Authentication should succeed", authResult instanceof AuthSuccess); + } + catch (IOException e) + { + fail("Error establishing connection: " + e.getMessage()); + } + } +} diff --git a/test/unit/org/apache/cassandra/transport/ConnectionTrackerTest.java b/test/unit/org/apache/cassandra/transport/ConnectionTrackerTest.java index 0664f6d6944b..f0783e1a7cdc 100644 --- a/test/unit/org/apache/cassandra/transport/ConnectionTrackerTest.java +++ b/test/unit/org/apache/cassandra/transport/ConnectionTrackerTest.java @@ -188,13 +188,18 @@ public boolean isRunning() } }); + ClientState state = connection.getClientState(); + + // Set authenticator so ClientState behaves correctly + state.setAuthenticator(DatabaseDescriptor.getDefaultAuthenticator()); + if (user == null) { + // For anonymous connections, the authenticator should have already set ANONYMOUS_USER + // if authentication is not required return connection; } - ClientState state = connection.getClientState(); - ClientState spyState = Mockito.spy(state); Mockito.when(spyState.getUser()).thenReturn(user);