Repository: celery/kombu

Issue:
Qpid + saslwrapper sometimes prompts on stdin for a password with sasl PLAIN
I've observed a machine where kombu configured with Qpid seems to halt. Upon stopping the process the logs show this strange line: `Please enter your password:`. kombu uses a default username of `guest` but it has an empty password so having a prompt for a password could happen. On the affected machine it currently has the following sasl libraries and qpid libraries.

```
[root@machine ~]# rpm -qa | grep sasl
cyrus-sasl-2.1.26-17.el7.x86_64
cyrus-sasl-md5-2.1.26-17.el7.x86_64
saslwrapper-0.22-5.el7sat.x86_64
cyrus-sasl-plain-2.1.26-17.el7.x86_64
cyrus-sasl-lib-2.1.26-17.el7.x86_64
python-saslwrapper-0.22-5.el7sat.x86_64

[root@machine ~]# rpm -qa | grep qpid
qpid-cpp-server-linearstore-0.30-9.el7.x86_64
qpid-java-client-0.30-3.el7.noarch
qpid-tools-0.30-4.el7.noarch
qpid-cpp-client-0.30-9.el7.x86_64
qpid-cpp-client-devel-0.30-9.el7.x86_64
qpid-java-common-0.30-3.el7.noarch
python-gofer-qpid-2.6.2-2.el7sat.noarch
libqpid-dispatch-0.4-7.el7.x86_64
qpid-dispatch-router-0.4-7.el7.x86_64
python-qpid-0.30-6.el7.noarch
python-qpid-qmf-0.30-5.el7.x86_64
qpid-proton-c-0.9-4.el7.x86_64
qpid-cpp-server-0.30-9.el7.x86_64
ruby193-rubygem-qpid_messaging-0.30.0-1.el7sat.x86_64
qpid-qmf-0.30-5.el7.x86_64
```



Discussion:
After some investigation I've discovered two things:
1. Definitively, the SASL libraries are producing the interactive password prompt. I've found that exact string in the SASL library code.
2. Per RFC 4616 (SASL PLAIN), the password must be at least 1 non-null UTF-8 character. Presumably libsasl will continue to trigger a SASL_INTERACT while there is no recognized password.


Resolving patch:
diff --git a/kombu/transport/qpid.py b/kombu/transport/qpid.py
--- a/kombu/transport/qpid.py
+++ b/kombu/transport/qpid.py
@@ -27,6 +27,55 @@
     This transport should be used with caution due to a known
     potential deadlock. See `Issue 2199`_ for more details.
 
+Authentication
+==============
+
+This transport supports SASL authentication with the Qpid broker. Normally,
+SASL mechanisms are negotiated from a client list and a server list of
+possible mechanisms, but in practice, different SASL client libraries give
+different behaviors. These different behaviors cause the expected SASL
+mechanism to not be selected in many cases. As such, this transport restricts
+the mechanism types based on Kombu's configuration according to the following
+table.
+
++------------------------------------+--------------------+
+| **Broker String**                  | **SASL Mechanism** |
++------------------------------------+--------------------+
+| qpid://hostname/                   | ANONYMOUS          |
++------------------------------------+--------------------+
+| qpid://username:password@hostname/ | PLAIN              |
++------------------------------------+--------------------+
+| see instructions below             | EXTERNAL           |
++------------------------------------+--------------------+
+
+The user can override the above SASL selection behaviors and specify the SASL
+string using the :attr:`~kombu.Connection.login_method` argument to the
+:class:`~kombu.Connection` object. The string can be a single SASL mechanism
+or a space separated list of SASL mechanisms. If you are using Celery with
+Kombu, this can be accomplished by setting the *BROKER_LOGIN_METHOD* Celery
+option.
+
+.. note::
+
+    While using SSL, Qpid users may want to override the SASL mechanism to
+    use *EXTERNAL*. In that case, Qpid requires a username to be presented
+    that matches the *CN* of the SSL client certificate. Ensure that the
+    broker string contains the corresponding username. For example, if the
+    client certificate has *CN=asdf* and the client connects to *example.com*
+    on port 5671, the broker string should be:
+
+        **qpid://asdf@example.com:5671/**
+
+Transport Options
+=================
+
+The :attr:`~kombu.Connection.transport_options` argument to the
+:class:`~kombu.Connection` object are passed directly to the
+:class:`qpid.messaging.endpoints.Connection` as keyword arguments. These
+options override and replace any other default or specified values. If using
+Celery with Kombu, this can be accomplished by setting the
+*BROKER_TRANSPORT_OPTIONS* Celery option.
+
 """
 from __future__ import absolute_import
 
@@ -77,8 +126,6 @@
 logger = get_logger(__name__)
 
 
-DEFAULT_PORT = 5672
-
 OBJECT_ALREADY_EXISTS_STRING = 'object already exists'
 
 VERSION = (1, 0, 0)
@@ -655,13 +702,14 @@ def exchange_declare(self, exchange='', type='direct', durable=False,
         functionality.
 
         :keyword type: The exchange type. Valid values include 'direct',
-        'topic', and 'fanout'.
+            'topic', and 'fanout'.
         :type type: str
         :keyword exchange: The name of the exchange to be created. If no
-        exchange is specified, then a blank string will be used as the name.
+            exchange is specified, then a blank string will be used as the
+            name.
         :type exchange: str
         :keyword durable: True if the exchange should be durable, or False
-        otherwise.
+            otherwise.
         :type durable: bool
         """
         options = {'durable': durable}
@@ -1211,8 +1259,10 @@ def __init__(self, **connection_options):
         establish = qpid.messaging.Connection.establish
 
         # 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.
+        # used on different systems. Although qpid.messaging allows
+        # multiple space separated sasl mechanisms, this implementation
+        # only advertises one type to the server. These are either
+        # ANONYMOUS, PLAIN, or an overridden value specified by the user.
 
         sasl_mech = connection_options['sasl_mechanisms']
 
@@ -1246,7 +1296,6 @@ def __init__(self, **connection_options):
                 raise AuthenticationFailure(sys.exc_info()[1])
             raise
 
-
     def get_qpid_connection(self):
         """Return the existing connection (singleton).
 
@@ -1393,9 +1442,6 @@ class Transport(base.Transport):
     # Reference to the class that should be used as the Connection object
     Connection = Connection
 
-    # The default port
-    default_port = DEFAULT_PORT
-
     # This Transport does not specify a polling interval.
     polling_interval = None
 
@@ -1602,22 +1648,39 @@ def establish_connection(self):
                 conninfo.transport_options['ssl_skip_hostname_check'] = True
         else:
             conninfo.qpid_transport = 'tcp'
-        opts = dict({
+
+        credentials = {}
+        if conninfo.login_method is None:
+            if conninfo.userid is not None and conninfo.password is not None:
+                sasl_mech = 'PLAIN'
+                credentials['username'] = conninfo.userid
+                credentials['password'] = conninfo.password
+            elif conninfo.userid is None and conninfo.password is not None:
+                raise Exception(
+                    'Password configured but no username. SASL PLAIN '
+                    'requires a username when using a password.')
+            elif conninfo.userid is not None and conninfo.password is None:
+                raise Exception(
+                    'Username configured but no password. SASL PLAIN '
+                    'requires a password when using a username.')
+            else:
+                sasl_mech = 'ANONYMOUS'
+        else:
+            sasl_mech = conninfo.login_method
+            if conninfo.userid is not None:
+                credentials['username'] = conninfo.userid
+
+        opts = {
             'host': conninfo.hostname,
             'port': conninfo.port,
-            'sasl_mechanisms': conninfo.sasl_mechanisms,
+            'sasl_mechanisms': sasl_mech,
             '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
+        }
+
+        opts.update(credentials)
+        opts.update(conninfo.transport_options)
+
         conn = self.Connection(**opts)
         conn.client = self.client
         self.session = conn.get_qpid_connection().session()
@@ -1699,11 +1762,7 @@ def default_connection_params(self):
         """
         return {
             'hostname': 'localhost',
-            'password': None,
-            'port': self.default_port,
-            'sasl_mechanisms': 'ANONYMOUS',
-            'userid': None,
-            'virtual_host': ''
+            'port': 5672,
         }
 
     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
@@ -1693,8 +1693,9 @@ class MockClient(object):
         self.client.connect_timeout = 4
         self.client.ssl = False
         self.client.transport_options = {}
-        self.client.userid = ''
-        self.client.password = ''
+        self.client.userid = None
+        self.client.password = None
+        self.client.login_method = None
         self.transport = Transport(self.client)
         self.mock_conn = Mock()
         self.transport.Connection = self.mock_conn
@@ -1706,11 +1707,12 @@ def tearDown(self):
         self.patcher.stop()
 
     def test_transport_establish_conn_new_option_overwrites_default(self):
-        new_userid_string = 'new-userid'
-        self.client.userid = new_userid_string
+        self.client.userid = 'new-userid'
+        self.client.password = 'new-password'
         self.transport.establish_connection()
         self.mock_conn.assert_called_once_with(
-            username=new_userid_string,
+            username=self.client.userid,
+            password=self.client.password,
             sasl_mechanisms='PLAIN',
             host='127.0.0.1',
             timeout=4,
@@ -1752,10 +1754,38 @@ def test_transport_establish_conn_transform_localhost_to_127_0_0_1(self):
             transport='tcp',
         )
 
-    def test_transport_password_no_username_raises_exception(self):
+    def test_transport_password_no_userid_raises_exception(self):
         self.client.password = 'somepass'
         self.assertRaises(Exception, self.transport.establish_connection)
 
+    def test_transport_userid_no_password_raises_exception(self):
+        self.client.userid = 'someusername'
+        self.assertRaises(Exception, self.transport.establish_connection)
+
+    def test_transport_overrides_sasl_mech_from_login_method(self):
+        self.client.login_method = 'EXTERNAL'
+        self.transport.establish_connection()
+        self.mock_conn.assert_called_once_with(
+            sasl_mechanisms='EXTERNAL',
+            host='127.0.0.1',
+            timeout=4,
+            port=5672,
+            transport='tcp',
+        )
+
+    def test_transport_overrides_sasl_mech_has_username(self):
+        self.client.userid = 'new-userid'
+        self.client.login_method = 'EXTERNAL'
+        self.transport.establish_connection()
+        self.mock_conn.assert_called_once_with(
+            username=self.client.userid,
+            sasl_mechanisms='EXTERNAL',
+            host='127.0.0.1',
+            timeout=4,
+            port=5672,
+            transport='tcp',
+        )
+
     def test_transport_establish_conn_set_password(self):
         self.client.userid = 'someuser'
         self.client.password = 'somepass'
@@ -1867,9 +1897,6 @@ class TestTransportClassAttributes(Case):
     def test_verify_Connection_attribute(self):
         self.assertIs(Connection, Transport.Connection)
 
-    def test_verify_default_port(self):
-        self.assertEqual(5672, Transport.default_port)
-
     def test_verify_polling_disabled(self):
         self.assertIsNone(Transport.polling_interval)
 
@@ -2016,11 +2043,7 @@ def test_default_connection_params(self):
         """Test that the default_connection_params are correct"""
         correct_params = {
             'hostname': 'localhost',
-            'password': None,
             'port': 5672,
-            'sasl_mechanisms': 'ANONYMOUS',
-            'userid': None,
-            'virtual_host': '',
         }
         my_transport = Transport(self.mock_client)
         result_params = my_transport.default_connection_params