Repository: celery/kombu

Issue:
Qpid broker uses broker PLAIN sasl mech unexpectedly
With some versions of saslwrapper, when using a broker string that does not contain a username@ or username:password@ PLAIN is unexpectedly used. The sasl mech should be driven by the broker string.



Discussion:

Resolving patch:
diff --git a/kombu/transport/qpid.py b/kombu/transport/qpid.py
--- a/kombu/transport/qpid.py
+++ b/kombu/transport/qpid.py
@@ -42,6 +42,7 @@
 import time
 
 from itertools import count
+from gettext import gettext as _
 
 import amqp.protocol
 
@@ -1167,7 +1168,6 @@ class Connection(object):
 
     # A class reference to the :class:`Channel` object
     Channel = Channel
-    SASL_ANONYMOUS_MECH = 'ANONYMOUS'
 
     def __init__(self, **connection_options):
         """Instantiate a Connection object.
@@ -1177,26 +1177,31 @@ def __init__(self, **connection_options):
         * host: The host that connections should connect to.
         * port: The port that connection should connect to.
         * username: The username that connections should connect with.
+              Optional.
         * password: The password that connections should connect with.
-        * transport: The transport type that connections should use. Either
-              'tcp', or 'ssl' are expected as values.
-        * timeout: the timeout used when a Connection connects to the broker.
+              Optional but requires a username.
+        * transport: The transport type that connections should use.
+              Either 'tcp', or 'ssl' are expected as values.
+        * timeout: the timeout used when a Connection connects to the
+              broker.
         * sasl_mechanisms: The sasl authentication mechanism type to use.
-              refer to SASL documentation for an explanation of valid values.
+              refer to SASL documentation for an explanation of valid
+              values.
 
         Creates a :class:`qpid.messaging.endpoints.Connection` object with
         the saved parameters, and stores it as _qpid_conn.
 
         qpid.messaging has an AuthenticationFailure exception type, but
         instead raises a ConnectionError with a message that indicates an
-        authentication failure occurred in those situations. ConnectionError
-        is listed as a recoverable error type, so kombu will attempt to retry
-        if a ConnectionError is raised. Retrying the operation without
-        adjusting the credentials is not correct, so this method specifically
-        checks for a ConnectionError that indicates an Authentication Failure
-        occurred. In those situations, the error type is mutated while
-        preserving the original message and raised so kombu will allow the
-        exception to not be considered recoverable.
+        authentication failure occurred in those situations.
+        ConnectionError is listed as a recoverable error type, so kombu
+        will attempt to retry if a ConnectionError is raised. Retrying
+        the operation without adjusting the credentials is not correct,
+        so this method specifically checks for a ConnectionError that
+        indicates an Authentication Failure occurred. In those
+        situations, the error type is mutated while preserving the
+        original message and raised so kombu will allow the exception to
+        not be considered recoverable.
 
         """
         self.connection_options = connection_options
@@ -1205,66 +1210,42 @@ def __init__(self, **connection_options):
         self._qpid_conn = None
         establish = qpid.messaging.Connection.establish
 
-        # There is a behavior difference in qpid.messaging's sasl_mechanism
-        # selection method and cyrus-sasl's. The former will put PLAIN before
-        # ANONYMOUS if a username and password is given, but the latter will
-        # simply take whichever mech is listed first. Thus, if we had
-        # "ANONYMOUS PLAIN" as the default, the user would never be able to
-        # select PLAIN if cyrus-sasl was installed.
-
-        # The following code will put ANONYMOUS last in the mech list, and then
-        # try sasl mechs one by one. This should still result in secure
-        # behavior since it will select the first suitable mech. Unsuitable
-        # mechs will be rejected by the server.
-
-        _saslm = connection_options['sasl_mechanisms'].split()
-        sasl_mechanisms = [x for x in _saslm
-                           if x != self.SASL_ANONYMOUS_MECH]
-        if self.SASL_ANONYMOUS_MECH in _saslm:
-            sasl_mechanisms.append(self.SASL_ANONYMOUS_MECH)
-
-        for sasl_mech in sasl_mechanisms:
-            try:
-                logger.debug(
-                    'Attempting to connect to qpid '
-                    'with SASL mechanism %s', sasl_mech,
-                )
-                modified_conn_opts = self.connection_options
-                modified_conn_opts['sasl_mechanisms'] = sasl_mech
-                self._qpid_conn = establish(**modified_conn_opts)
-                # connection was successful if we got this far
-                logger.info(
-                    'Connected to qpid with SASL mechanism %s', sasl_mech,
-                )
-                break
-            except ConnectionError as conn_exc:
-                # if we get one of these errors, do not raise an exception.
-                # Raising will cause the connection to be retried. Instead,
-                # just continue on to the next mech.
-                coded_as_auth_failure = getattr(conn_exc, 'code', None) == 320
-                contains_auth_fail_text = \
-                    'Authentication failed' in conn_exc.text
-                contains_mech_fail_text = \
-                    'sasl negotiation failed: no mechanism agreed' \
-                    in conn_exc.text
-                contains_mech_unavail_text = 'no mechanism available' \
-                    in conn_exc.text
-                if coded_as_auth_failure or \
-                        contains_auth_fail_text or contains_mech_fail_text or \
-                        contains_mech_unavail_text:
-                    logger.debug(
-                        'Unable to connect to qpid with SASL mechanism %s',
-                        sasl_mech,
-                    )
-                    continue
-                raise
-
-        if not self.get_qpid_connection():
-            logger.error(
-                'Unable to authenticate to qpid using '
-                'the following mechanisms: %s', sasl_mechanisms,
-            )
-            raise AuthenticationFailure(sys.exc_info()[1])
+        # There are several inconsistent behaviors in the sasl libraries
+        # used on different systems. This implementation uses only
+        # advertises one type to the server either ANONYMOUS or PLAIN.
+
+        sasl_mech = connection_options['sasl_mechanisms']
+
+        try:
+            msg = _('Attempting to connect to qpid with '
+                    'SASL mechanism %s') % sasl_mech
+            logger.debug(msg)
+            self._qpid_conn = establish(**self.connection_options)
+            # connection was successful if we got this far
+            msg = _('Connected to qpid with SASL '
+                    'mechanism %s') % sasl_mech
+            logger.info(msg)
+        except ConnectionError as conn_exc:
+            # if we get one of these errors, do not raise an exception.
+            # Raising will cause the connection to be retried. Instead,
+            # just continue on to the next mech.
+            coded_as_auth_failure = getattr(conn_exc, 'code', None) == 320
+            contains_auth_fail_text = \
+                'Authentication failed' in conn_exc.text
+            contains_mech_fail_text = \
+                'sasl negotiation failed: no mechanism agreed' \
+                in conn_exc.text
+            contains_mech_unavail_text = 'no mechanism available' \
+                in conn_exc.text
+            if coded_as_auth_failure or \
+                    contains_auth_fail_text or contains_mech_fail_text or \
+                    contains_mech_unavail_text:
+                msg = _('Unable to connect to qpid with SASL '
+                        'mechanism %s') % sasl_mech
+                logger.error(msg)
+                raise AuthenticationFailure(sys.exc_info()[1])
+            raise
+
 
     def get_qpid_connection(self):
         """Return the existing connection (singleton).
@@ -1583,19 +1564,20 @@ def register_with_event_loop(self, connection, loop):
     def establish_connection(self):
         """Establish a Connection object.
 
-        Determines the correct options to use when creating any connections
-        needed by this Transport, and create a :class:`Connection` object
-        which saves those values for connections generated as they are
-        needed. The options are a mixture of what is passed in through the
-        creator of the Transport, and the defaults provided by
+        Determines the correct options to use when creating any
+        connections needed by this Transport, and create a
+        :class:`Connection` object which saves those values for
+        connections generated as they are needed. The options are a
+        mixture of what is passed in through the creator of the
+        Transport, and the defaults provided by
         :meth:`default_connection_params`. Options cover broker network
         settings, timeout behaviors, authentication, and identity
         verification settings.
 
         This method also creates and stores a
         :class:`~qpid.messaging.endpoints.Session` using the
-        :class:`~qpid.messaging.endpoints.Connection` created by this method.
-        The Session is stored on self.
+        :class:`~qpid.messaging.endpoints.Connection` created by this
+        method. The Session is stored on self.
 
         :return: The created :class:`Connection` object is returned.
         :rtype: :class:`Connection`
@@ -1620,13 +1602,22 @@ def establish_connection(self):
                 conninfo.transport_options['ssl_skip_hostname_check'] = True
         else:
             conninfo.qpid_transport = 'tcp'
-        opts = dict({'host': conninfo.hostname, 'port': conninfo.port,
-                     'username': conninfo.userid,
-                     'password': conninfo.password,
-                     'transport': conninfo.qpid_transport,
-                     'timeout': conninfo.connect_timeout,
-                     'sasl_mechanisms': conninfo.sasl_mechanisms},
-                    **conninfo.transport_options or {})
+        opts = dict({
+            'host': conninfo.hostname,
+            'port': conninfo.port,
+            'sasl_mechanisms': conninfo.sasl_mechanisms,
+            'timeout': conninfo.connect_timeout,
+            'transport': conninfo.qpid_transport
+        }, **conninfo.transport_options or {})
+        if conninfo.userid is not None:
+            opts['username'] = conninfo.userid
+            opts['sasl_mechanisms'] = 'PLAIN'
+        elif conninfo.password is not None:
+                raise Exception(
+                    'Password configured but no username. A username is '
+                    'required when using a password.')
+        if conninfo.password is not None:
+            opts['password'] = conninfo.password
         conn = self.Connection(**opts)
         conn.client = self.client
         self.session = conn.get_qpid_connection().session()
@@ -1703,18 +1694,17 @@ def default_connection_params(self):
         These connection parameters will be used whenever the creator of
         Transport does not specify a required parameter.
 
-        NOTE: password is set to '' by default instead of None so the a
-        connection is attempted[1]. An empty password is considered valid for
-        qpidd if "auth=no" is set on the server.
-
-        [1] https://issues.apache.org/jira/browse/QPID-6109
-
         :return: A dict containing the default parameters.
         :rtype: dict
         """
-        return {'userid': 'guest', 'password': '',
-                'port': self.default_port, 'virtual_host': '',
-                'hostname': 'localhost', 'sasl_mechanisms': 'PLAIN ANONYMOUS'}
+        return {
+            'hostname': 'localhost',
+            'password': None,
+            'port': self.default_port,
+            'sasl_mechanisms': 'ANONYMOUS',
+            'userid': None,
+            'virtual_host': ''
+        }
 
     def __del__(self):
         """


Test patch:
diff --git a/kombu/tests/transport/test_qpid.py b/kombu/tests/transport/test_qpid.py
--- a/kombu/tests/transport/test_qpid.py
+++ b/kombu/tests/transport/test_qpid.py
@@ -283,11 +283,9 @@ def setUp(self, mock_qpid):
         self.connection_options = {
             'host': 'localhost',
             'port': 5672,
-            'username': 'guest',
-            'password': '',
             'transport': 'tcp',
             'timeout': 10,
-            'sasl_mechanisms': 'ANONYMOUS PLAIN',
+            'sasl_mechanisms': 'ANONYMOUS',
         }
         self.mock_qpid_connection = mock_qpid.messaging.Connection
         self.conn = Connection(**self.connection_options)
@@ -301,7 +299,6 @@ def test_stores_connection_options(self):
         # ensure that only one mech was passed into connection. The other
         # options should all be passed through as-is
         modified_conn_opts = self.connection_options
-        modified_conn_opts['sasl_mechanisms'] = 'PLAIN'
         self.assertDictEqual(
             modified_conn_opts, self.conn.connection_options,
         )
@@ -312,7 +309,6 @@ def test_class_variables(self):
 
     def test_establishes_connection(self):
         modified_conn_opts = self.connection_options
-        modified_conn_opts['sasl_mechanisms'] = 'PLAIN'
         self.mock_qpid_connection.establish.assert_called_with(
             **modified_conn_opts
         )
@@ -1155,7 +1151,16 @@ def test_queue_declare_raises_exception_not_silenced(self):
         self.mock_broker.addQueue.side_effect = unique_exception
         with self.assertRaises(unique_exception.__class__):
             self.my_channel.queue_declare(mock_queue)
-        self.mock_broker.addQueue.assert_called_once()
+        self.mock_broker.addQueue.assert_called_once_with(
+            mock_queue,
+            options={
+                'exclusive': False,
+                'durable': False,
+                'qpid.policy_type':'ring',
+                'passive': False,
+                'arguments': None,
+                'auto-delete': True
+            })
 
     def test_exchange_declare_raises_exception_and_silenced(self):
         """Create exchange where an exception is raised and then silenced"""
@@ -1308,10 +1313,10 @@ def test_basic_publish(self, mock_put,
         self.my_channel.basic_publish(
             mock_message, mock_exchange, mock_routing_key,
         )
-        mock_encode_body.assert_called_once(
+        mock_encode_body.assert_called_once_with(
             mock_original_body, mock_body_encoding,
         )
-        mock_buffer.assert_called_once(mock_encode_body)
+        mock_buffer.assert_called_once_with(mock_encoded_body)
         self.assertIs(mock_message['body'], mock_encoded_buffered_body)
         self.assertIs(
             mock_message['properties']['body_encoding'], mock_body_encoding,
@@ -1580,8 +1585,9 @@ def test_Transport___init___calls_verify_runtime_environment(self):
         self.mock_verify_runtime_environment.assert_called_once_with()
 
     def test_transport___init___calls_parent_class___init__(self):
-        Transport(Mock())
-        self.mock_base_Transport__init__.assert_caled_once_with()
+        m = Mock()
+        Transport(m)
+        self.mock_base_Transport__init__.assert_called_once_with(m)
 
     def test_transport___init___calls_os_pipe(self):
         Transport(Mock())
@@ -1687,6 +1693,8 @@ class MockClient(object):
         self.client.connect_timeout = 4
         self.client.ssl = False
         self.client.transport_options = {}
+        self.client.userid = ''
+        self.client.password = ''
         self.transport = Transport(self.client)
         self.mock_conn = Mock()
         self.transport.Connection = self.mock_conn
@@ -1703,23 +1711,9 @@ def test_transport_establish_conn_new_option_overwrites_default(self):
         self.transport.establish_connection()
         self.mock_conn.assert_called_once_with(
             username=new_userid_string,
-            sasl_mechanisms='PLAIN ANONYMOUS',
-            host='127.0.0.1',
-            timeout=4,
-            password='',
-            port=5672,
-            transport='tcp',
-        )
-
-    def test_transport_establish_conn_sasl_mech_sorting(self):
-        self.client.sasl_mechanisms = 'MECH1 MECH2'
-        self.transport.establish_connection()
-        self.mock_conn.assert_called_once_with(
-            username='guest',
-            sasl_mechanisms='MECH1 MECH2',
+            sasl_mechanisms='PLAIN',
             host='127.0.0.1',
             timeout=4,
-            password='',
             port=5672,
             transport='tcp',
         )
@@ -1727,11 +1721,9 @@ def test_transport_establish_conn_sasl_mech_sorting(self):
     def test_transport_establish_conn_empty_client_is_default(self):
         self.transport.establish_connection()
         self.mock_conn.assert_called_once_with(
-            username='guest',
-            sasl_mechanisms='PLAIN ANONYMOUS',
+            sasl_mechanisms='ANONYMOUS',
             host='127.0.0.1',
             timeout=4,
-            password='',
             port=5672,
             transport='tcp',
         )
@@ -1741,12 +1733,10 @@ def test_transport_establish_conn_additional_transport_option(self):
         self.client.transport_options['new_param'] = new_param_value
         self.transport.establish_connection()
         self.mock_conn.assert_called_once_with(
-            username='guest',
-            sasl_mechanisms='PLAIN ANONYMOUS',
+            sasl_mechanisms='ANONYMOUS',
             host='127.0.0.1',
             timeout=4,
             new_param=new_param_value,
-            password='',
             port=5672,
             transport='tcp',
         )
@@ -1755,24 +1745,27 @@ def test_transport_establish_conn_transform_localhost_to_127_0_0_1(self):
         self.client.hostname = 'localhost'
         self.transport.establish_connection()
         self.mock_conn.assert_called_once_with(
-            username='guest',
-            sasl_mechanisms='PLAIN ANONYMOUS',
+            sasl_mechanisms='ANONYMOUS',
             host='127.0.0.1',
             timeout=4,
-            password='',
             port=5672,
             transport='tcp',
         )
 
+    def test_transport_password_no_username_raises_exception(self):
+        self.client.password = 'somepass'
+        self.assertRaises(Exception, self.transport.establish_connection)
+
     def test_transport_establish_conn_set_password(self):
+        self.client.userid = 'someuser'
         self.client.password = 'somepass'
         self.transport.establish_connection()
         self.mock_conn.assert_called_once_with(
-            username='guest',
-            sasl_mechanisms='PLAIN ANONYMOUS',
+            username='someuser',
+            password='somepass',
+            sasl_mechanisms='PLAIN',
             host='127.0.0.1',
             timeout=4,
-            password='somepass',
             port=5672,
             transport='tcp',
         )
@@ -1781,11 +1774,9 @@ def test_transport_establish_conn_no_ssl_sets_transport_tcp(self):
         self.client.ssl = False
         self.transport.establish_connection()
         self.mock_conn.assert_called_once_with(
-            username='guest',
-            sasl_mechanisms='PLAIN ANONYMOUS',
+            sasl_mechanisms='ANONYMOUS',
             host='127.0.0.1',
             timeout=4,
-            password='',
             port=5672,
             transport='tcp',
         )
@@ -1799,15 +1790,13 @@ def test_transport_establish_conn_with_ssl_with_hostname_check(self):
         }
         self.transport.establish_connection()
         self.mock_conn.assert_called_once_with(
-            username='guest',
             ssl_certfile='my_certfile',
             ssl_trustfile='my_cacerts',
             timeout=4,
             ssl_skip_hostname_check=False,
-            sasl_mechanisms='PLAIN ANONYMOUS',
+            sasl_mechanisms='ANONYMOUS',
             host='127.0.0.1',
             ssl_keyfile='my_keyfile',
-            password='',
             port=5672, transport='ssl',
         )
 
@@ -1820,15 +1809,13 @@ def test_transport_establish_conn_with_ssl_skip_hostname_check(self):
         }
         self.transport.establish_connection()
         self.mock_conn.assert_called_once_with(
-            username='guest',
             ssl_certfile='my_certfile',
             ssl_trustfile='my_cacerts',
             timeout=4,
             ssl_skip_hostname_check=True,
-            sasl_mechanisms='PLAIN ANONYMOUS',
+            sasl_mechanisms='ANONYMOUS',
             host='127.0.0.1',
             ssl_keyfile='my_keyfile',
-            password='',
             port=5672, transport='ssl',
         )
 
@@ -1865,11 +1852,11 @@ def test_transport_establish_conn_ignores_hostname_if_not_localhost(self):
         self.client.hostname = 'some_other_hostname'
         self.transport.establish_connection()
         self.mock_conn.assert_called_once_with(
-            username='guest',
-            sasl_mechanisms='PLAIN ANONYMOUS',
+            sasl_mechanisms='ANONYMOUS',
             host='some_other_hostname',
-            timeout=4, password='',
-            port=5672, transport='tcp',
+            timeout=4,
+            port=5672,
+            transport='tcp',
         )
 
 
@@ -2028,10 +2015,12 @@ def test_close_connection(self):
     def test_default_connection_params(self):
         """Test that the default_connection_params are correct"""
         correct_params = {
-            'userid': 'guest', 'password': '',
-            'port': 5672, 'virtual_host': '',
             'hostname': 'localhost',
-            'sasl_mechanisms': 'PLAIN ANONYMOUS',
+            'password': None,
+            'port': 5672,
+            'sasl_mechanisms': 'ANONYMOUS',
+            'userid': None,
+            'virtual_host': '',
         }
         my_transport = Transport(self.mock_client)
         result_params = my_transport.default_connection_params
diff --git a/kombu/tests/transport/test_redis.py b/kombu/tests/transport/test_redis.py
--- a/kombu/tests/transport/test_redis.py
+++ b/kombu/tests/transport/test_redis.py
@@ -578,16 +578,18 @@ def test_delete(self):
         srem = x.client.srem = Mock()
 
         x._delete('queue', 'exchange', 'routing_key', None)
-        delete.assert_has_call('queue')
-        srem.assert_has_call(x.keyprefix_queue % ('exchange', ),
-                             x.sep.join(['routing_key', '', 'queue']))
+        delete.assert_any_call('queue')
+        srem.assert_called_once_with(
+            x.keyprefix_queue % ('exchange', ),
+            x.sep.join(['routing_key', '', 'queue'])
+        )
 
     def test_has_queue(self):
         self.channel._in_poll = False
         exists = self.channel.client.exists = Mock()
         exists.return_value = True
         self.assertTrue(self.channel._has_queue('foo'))
-        exists.assert_has_call('foo')
+        exists.assert_any_call('foo')
 
         exists.return_value = False
         self.assertFalse(self.channel._has_queue('foo'))