[Python-checkins] cpython (3.5): - Issue #15014: SMTP.auth() and SMTP.login() now support RFC 4954's optional

barry.warsaw python-checkins at python.org
Thu Jul 9 16:58:16 CEST 2015


https://hg.python.org/cpython/rev/97a29b86a2dc
changeset:   96890:97a29b86a2dc
branch:      3.5
parent:      96888:f2b98a1a86ae
user:        Barry Warsaw <barry at python.org>
date:        Thu Jul 09 10:39:55 2015 -0400
summary:
  - Issue #15014: SMTP.auth() and SMTP.login() now support RFC 4954's optional
  initial-response argument to the SMTP AUTH command.

files:
  Doc/library/smtplib.rst  |  45 +++++++++----
  Lib/smtplib.py           |  52 ++++++++++----
  Lib/test/test_smtplib.py |  92 +++++++++++++++++++++++++--
  Misc/NEWS                |   3 +
  4 files changed, 153 insertions(+), 39 deletions(-)


diff --git a/Doc/library/smtplib.rst b/Doc/library/smtplib.rst
--- a/Doc/library/smtplib.rst
+++ b/Doc/library/smtplib.rst
@@ -288,7 +288,7 @@
       Many sites disable SMTP ``VRFY`` in order to foil spammers.
 
 
-.. method:: SMTP.login(user, password)
+.. method:: SMTP.login(user, password, *, initial_response_ok=True)
 
    Log in on an SMTP server that requires authentication. The arguments are the
    username and the password to authenticate with. If there has been no previous
@@ -309,14 +309,21 @@
       No suitable authentication method was found.
 
    Each of the authentication methods supported by :mod:`smtplib` are tried in
-   turn if they are advertised as supported by the server (see :meth:`auth`
-   for a list of supported authentication methods).
+   turn if they are advertised as supported by the server.  See :meth:`auth`
+   for a list of supported authentication methods.  *initial_response_ok* is
+   passed through to :meth:`auth`.
+
+   Optional keyword argument *initial_response_ok* specifies whether, for
+   authentication methods that support it, an "initial response" as specified
+   in :rfc:`4954` can be sent along with the ``AUTH`` command, rather than
+   requiring a challenge/response.
 
    .. versionchanged:: 3.5
-      :exc:`SMTPNotSupportedError` may be raised.
+      :exc:`SMTPNotSupportedError` may be raised, and the
+      *initial_response_ok* parameter was added.
 
 
-.. method:: SMTP.auth(mechanism, authobject)
+.. method:: SMTP.auth(mechanism, authobject, *, initial_response_ok=True)
 
    Issue an ``SMTP`` ``AUTH`` command for the specified authentication
    *mechanism*, and handle the challenge response via *authobject*.
@@ -325,13 +332,23 @@
    be used as argument to the ``AUTH`` command; the valid values are
    those listed in the ``auth`` element of :attr:`esmtp_features`.
 
-   *authobject* must be a callable object taking a single argument:
+   *authobject* must be a callable object taking an optional single argument:
 
-     data = authobject(challenge)
+     data = authobject(challenge=None)
 
-   It will be called to process the server's challenge response; the
-   *challenge* argument it is passed will be a ``bytes``.  It should return
-   ``bytes`` *data* that will be base64 encoded and sent to the server.
+   If optional keyword argument *initial_response_ok* is true,
+   ``authobject()`` will be called first with no argument.  It can return the
+   :rfc:`4954` "initial response" bytes which will be encoded and sent with
+   the ``AUTH`` command as below.  If the ``authobject()`` does not support an
+   initial response (e.g. because it requires a challenge), it should return
+   None when called with ``challenge=None``.  If *initial_response_ok* is
+   false, then ``authobject()`` will not be called first with None.
+
+   If the initial response check returns None, or if *initial_response_ok* is
+   false, ``authobject()`` will be called to process the server's challenge
+   response; the *challenge* argument it is passed will be a ``bytes``.  It
+   should return ``bytes`` *data* that will be base64 encoded and sent to the
+   server.
 
    The ``SMTP`` class provides ``authobjects`` for the ``CRAM-MD5``, ``PLAIN``,
    and ``LOGIN`` mechanisms; they are named ``SMTP.auth_cram_md5``,
@@ -340,10 +357,10 @@
    set to appropriate values.
 
    User code does not normally need to call ``auth`` directly, but can instead
-   call the :meth:`login` method, which will try each of the above mechanisms in
-   turn, in the order listed.  ``auth`` is exposed to facilitate the
-   implementation of authentication methods not (or not yet) supported directly
-   by :mod:`smtplib`.
+   call the :meth:`login` method, which will try each of the above mechanisms
+   in turn, in the order listed.  ``auth`` is exposed to facilitate the
+   implementation of authentication methods not (or not yet) supported
+   directly by :mod:`smtplib`.
 
    .. versionadded:: 3.5
 
diff --git a/Lib/smtplib.py b/Lib/smtplib.py
--- a/Lib/smtplib.py
+++ b/Lib/smtplib.py
@@ -601,7 +601,7 @@
                 if not (200 <= code <= 299):
                     raise SMTPHeloError(code, resp)
 
-    def auth(self, mechanism, authobject):
+    def auth(self, mechanism, authobject, *, initial_response_ok=True):
         """Authentication command - requires response processing.
 
         'mechanism' specifies which authentication mechanism is to
@@ -615,32 +615,46 @@
         It will be called to process the server's challenge response; the
         challenge argument it is passed will be a bytes.  It should return
         bytes data that will be base64 encoded and sent to the server.
+
+        Keyword arguments:
+            - initial_response_ok: Allow sending the RFC 4954 initial-response
+              to the AUTH command, if the authentication methods supports it.
         """
-
+        # RFC 4954 allows auth methods to provide an initial response.  Not all
+        # methods support it.  By definition, if they return something other
+        # than None when challenge is None, then they do.  See issue #15014.
         mechanism = mechanism.upper()
-        (code, resp) = self.docmd("AUTH", mechanism)
-        # Server replies with 334 (challenge) or 535 (not supported)
-        if code == 334:
-            challenge = base64.decodebytes(resp)
-            response = encode_base64(
-                authobject(challenge).encode('ascii'), eol='')
-            (code, resp) = self.docmd(response)
-            if code in (235, 503):
-                return (code, resp)
+        initial_response = (authobject() if initial_response_ok else None)
+        if initial_response is not None:
+            response = encode_base64(initial_response.encode('ascii'), eol='')
+            (code, resp) = self.docmd("AUTH", mechanism + " " + response)
+        else:
+            (code, resp) = self.docmd("AUTH", mechanism)
+            # Server replies with 334 (challenge) or 535 (not supported)
+            if code == 334:
+                challenge = base64.decodebytes(resp)
+                response = encode_base64(
+                    authobject(challenge).encode('ascii'), eol='')
+                (code, resp) = self.docmd(response)
+        if code in (235, 503):
+            return (code, resp)
         raise SMTPAuthenticationError(code, resp)
 
-    def auth_cram_md5(self, challenge):
+    def auth_cram_md5(self, challenge=None):
         """ Authobject to use with CRAM-MD5 authentication. Requires self.user
         and self.password to be set."""
+        # CRAM-MD5 does not support initial-response.
+        if challenge is None:
+            return None
         return self.user + " " + hmac.HMAC(
             self.password.encode('ascii'), challenge, 'md5').hexdigest()
 
-    def auth_plain(self, challenge):
+    def auth_plain(self, challenge=None):
         """ Authobject to use with PLAIN authentication. Requires self.user and
         self.password to be set."""
         return "\0%s\0%s" % (self.user, self.password)
 
-    def auth_login(self, challenge):
+    def auth_login(self, challenge=None):
         """ Authobject to use with LOGIN authentication. Requires self.user and
         self.password to be set."""
         (code, resp) = self.docmd(
@@ -649,13 +663,17 @@
             return self.password
         raise SMTPAuthenticationError(code, resp)
 
-    def login(self, user, password):
+    def login(self, user, password, *, initial_response_ok=True):
         """Log in on an SMTP server that requires authentication.
 
         The arguments are:
             - user:         The user name to authenticate with.
             - password:     The password for the authentication.
 
+        Keyword arguments:
+            - initial_response_ok: Allow sending the RFC 4954 initial-response
+              to the AUTH command, if the authentication methods supports it.
+
         If there has been no previous EHLO or HELO command this session, this
         method tries ESMTP EHLO first.
 
@@ -698,7 +716,9 @@
         for authmethod in authlist:
             method_name = 'auth_' + authmethod.lower().replace('-', '_')
             try:
-                (code, resp) = self.auth(authmethod, getattr(self, method_name))
+                (code, resp) = self.auth(
+                    authmethod, getattr(self, method_name),
+                    initial_response_ok=initial_response_ok)
                 # 235 == 'Authentication successful'
                 # 503 == 'Error: already authenticated'
                 if code in (235, 503):
diff --git a/Lib/test/test_smtplib.py b/Lib/test/test_smtplib.py
--- a/Lib/test/test_smtplib.py
+++ b/Lib/test/test_smtplib.py
@@ -1,6 +1,7 @@
 import asyncore
 import email.mime.text
 from email.message import EmailMessage
+from email.base64mime import body_encode as encode_base64
 import email.utils
 import socket
 import smtpd
@@ -814,11 +815,11 @@
     def testVRFY(self):
         smtp = smtplib.SMTP(HOST, self.port, local_hostname='localhost', timeout=15)
 
-        for email, name in sim_users.items():
+        for addr_spec, name in sim_users.items():
             expected_known = (250, bytes('%s %s' %
-                                         (name, smtplib.quoteaddr(email)),
+                                         (name, smtplib.quoteaddr(addr_spec)),
                                          "ascii"))
-            self.assertEqual(smtp.vrfy(email), expected_known)
+            self.assertEqual(smtp.vrfy(addr_spec), expected_known)
 
         u = 'nobody at nowhere.com'
         expected_unknown = (550, ('No such user: %s' % u).encode('ascii'))
@@ -851,7 +852,7 @@
     def testAUTH_PLAIN(self):
         self.serv.add_feature("AUTH PLAIN")
         smtp = smtplib.SMTP(HOST, self.port, local_hostname='localhost', timeout=15)
-        try: smtp.login(sim_auth[0], sim_auth[1])
+        try: smtp.login(sim_auth[0], sim_auth[1], initial_response_ok=False)
         except smtplib.SMTPAuthenticationError as err:
             self.assertIn(sim_auth_plain, str(err))
         smtp.close()
@@ -892,7 +893,7 @@
                      'LOGIN': smtp.auth_login,
                     }
         for mechanism, method in supported.items():
-            try: smtp.auth(mechanism, method)
+            try: smtp.auth(mechanism, method, initial_response_ok=False)
             except smtplib.SMTPAuthenticationError as err:
                 self.assertIn(sim_auth_credentials[mechanism.lower()].upper(),
                               str(err))
@@ -1142,12 +1143,85 @@
                           smtp.send_message(msg))
 
 
+EXPECTED_RESPONSE = encode_base64(b'\0psu\0doesnotexist', eol='')
+
+class SimSMTPAUTHInitialResponseChannel(SimSMTPChannel):
+    def smtp_AUTH(self, arg):
+        # RFC 4954's AUTH command allows for an optional initial-response.
+        # Not all AUTH methods support this; some require a challenge.  AUTH
+        # PLAIN does those, so test that here.  See issue #15014.
+        args = arg.split()
+        if args[0].lower() == 'plain':
+            if len(args) == 2:
+                # AUTH PLAIN <initial-response> with the response base 64
+                # encoded.  Hard code the expected response for the test.
+                if args[1] == EXPECTED_RESPONSE:
+                    self.push('235 Ok')
+                    return
+        self.push('571 Bad authentication')
+
+class SimSMTPAUTHInitialResponseServer(SimSMTPServer):
+    channel_class = SimSMTPAUTHInitialResponseChannel
+
+
+ at unittest.skipUnless(threading, 'Threading required for this test.')
+class SMTPAUTHInitialResponseSimTests(unittest.TestCase):
+    def setUp(self):
+        self.real_getfqdn = socket.getfqdn
+        socket.getfqdn = mock_socket.getfqdn
+        self.serv_evt = threading.Event()
+        self.client_evt = threading.Event()
+        # Pick a random unused port by passing 0 for the port number
+        self.serv = SimSMTPAUTHInitialResponseServer(
+            (HOST, 0), ('nowhere', -1), decode_data=True)
+        # Keep a note of what port was assigned
+        self.port = self.serv.socket.getsockname()[1]
+        serv_args = (self.serv, self.serv_evt, self.client_evt)
+        self.thread = threading.Thread(target=debugging_server, args=serv_args)
+        self.thread.start()
+
+        # wait until server thread has assigned a port number
+        self.serv_evt.wait()
+        self.serv_evt.clear()
+
+    def tearDown(self):
+        socket.getfqdn = self.real_getfqdn
+        # indicate that the client is finished
+        self.client_evt.set()
+        # wait for the server thread to terminate
+        self.serv_evt.wait()
+        self.thread.join()
+
+    def testAUTH_PLAIN_initial_response_login(self):
+        self.serv.add_feature('AUTH PLAIN')
+        smtp = smtplib.SMTP(HOST, self.port,
+                            local_hostname='localhost', timeout=15)
+        smtp.login('psu', 'doesnotexist')
+        smtp.close()
+
+    def testAUTH_PLAIN_initial_response_auth(self):
+        self.serv.add_feature('AUTH PLAIN')
+        smtp = smtplib.SMTP(HOST, self.port,
+                            local_hostname='localhost', timeout=15)
+        smtp.user = 'psu'
+        smtp.password = 'doesnotexist'
+        code, response = smtp.auth('plain', smtp.auth_plain)
+        smtp.close()
+        self.assertEqual(code, 235)
+
+
 @support.reap_threads
 def test_main(verbose=None):
-    support.run_unittest(GeneralTests, DebuggingServerTests,
-                              NonConnectingTests,
-                              BadHELOServerTests, SMTPSimTests,
-                              TooLongLineTests)
+    support.run_unittest(
+        BadHELOServerTests,
+        DebuggingServerTests,
+        GeneralTests,
+        NonConnectingTests,
+        SMTPAUTHInitialResponseSimTests,
+        SMTPSimTests,
+        TooLongLineTests,
+        )
+
 
 if __name__ == '__main__':
     test_main()
diff --git a/Misc/NEWS b/Misc/NEWS
--- a/Misc/NEWS
+++ b/Misc/NEWS
@@ -22,6 +22,9 @@
 - Issue #24259: tarfile now raises a ReadError if an archive is truncated
   inside a data segment.
 
+- Issue #15014: SMTP.auth() and SMTP.login() now support RFC 4954's optional
+  initial-response argument to the SMTP AUTH command.
+
 
 What's New in Python 3.5.0 beta 3?
 ==================================

-- 
Repository URL: https://hg.python.org/cpython


More information about the Python-checkins mailing list