From c5ea754e484d73f04b1a361d82d0eed1b51dfdc8 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Thu, 9 Jul 2015 10:39:55 -0400 Subject: - Issue #15014: SMTP.auth() and SMTP.login() now support RFC 4954's optional initial-response argument to the SMTP AUTH command. --- 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 index 25279f2..a71ee58 100644 --- a/Doc/library/smtplib.rst +++ b/Doc/library/smtplib.rst @@ -288,7 +288,7 @@ An :class:`SMTP` instance has the following methods: 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 @@ An :class:`SMTP` instance has the following methods: 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 @@ An :class:`SMTP` instance has the following methods: 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=None) - data = authobject(challenge) + 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. - 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 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 @@ An :class:`SMTP` instance has the following methods: 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 index 71ccd2a..4fe6aaf 100755 --- a/Lib/smtplib.py +++ b/Lib/smtplib.py @@ -601,7 +601,7 @@ class SMTP: 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 @@ class SMTP: 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 @@ class SMTP: 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 @@ class SMTP: 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 index e66ae9b..8e362414 100644 --- 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 @@ class SMTPSimTests(unittest.TestCase): 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@nowhere.com' expected_unknown = (550, ('No such user: %s' % u).encode('ascii')) @@ -851,7 +852,7 @@ class SMTPSimTests(unittest.TestCase): 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 @@ class SMTPSimTests(unittest.TestCase): '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 @@ class SMTPUTF8SimTests(unittest.TestCase): 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 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 + + +@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 index 5d27e0a..da90361 100644 --- a/Misc/NEWS +++ b/Misc/NEWS @@ -22,6 +22,9 @@ Library - 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? ================================== -- cgit v0.12