- Issue #15014: SMTP.auth() and SMTP.login() now support RFC 4954's optional

initial-response argument to the SMTP AUTH command.
This commit is contained in:
Barry Warsaw 2015-07-09 10:39:55 -04:00
parent b85b427507
commit c5ea754e48
4 changed files with 153 additions and 39 deletions

View File

@ -288,7 +288,7 @@ An :class:`SMTP` instance has the following methods:
Many sites disable SMTP ``VRFY`` in order to foil spammers. 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 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 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. No suitable authentication method was found.
Each of the authentication methods supported by :mod:`smtplib` are tried in 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` turn if they are advertised as supported by the server. See :meth:`auth`
for a list of supported authentication methods). 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 .. 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 Issue an ``SMTP`` ``AUTH`` command for the specified authentication
*mechanism*, and handle the challenge response via *authobject*. *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 be used as argument to the ``AUTH`` command; the valid values are
those listed in the ``auth`` element of :attr:`esmtp_features`. 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 If optional keyword argument *initial_response_ok* is true,
*challenge* argument it is passed will be a ``bytes``. It should return ``authobject()`` will be called first with no argument. It can return the
``bytes`` *data* that will be base64 encoded and sent to the server. :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``, The ``SMTP`` class provides ``authobjects`` for the ``CRAM-MD5``, ``PLAIN``,
and ``LOGIN`` mechanisms; they are named ``SMTP.auth_cram_md5``, 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. set to appropriate values.
User code does not normally need to call ``auth`` directly, but can instead 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 call the :meth:`login` method, which will try each of the above mechanisms
turn, in the order listed. ``auth`` is exposed to facilitate the in turn, in the order listed. ``auth`` is exposed to facilitate the
implementation of authentication methods not (or not yet) supported directly implementation of authentication methods not (or not yet) supported
by :mod:`smtplib`. directly by :mod:`smtplib`.
.. versionadded:: 3.5 .. versionadded:: 3.5

View File

@ -601,7 +601,7 @@ class SMTP:
if not (200 <= code <= 299): if not (200 <= code <= 299):
raise SMTPHeloError(code, resp) raise SMTPHeloError(code, resp)
def auth(self, mechanism, authobject): def auth(self, mechanism, authobject, *, initial_response_ok=True):
"""Authentication command - requires response processing. """Authentication command - requires response processing.
'mechanism' specifies which authentication mechanism is to 'mechanism' specifies which authentication mechanism is to
@ -615,9 +615,20 @@ class SMTP:
It will be called to process the server's challenge response; the It will be called to process the server's challenge response; the
challenge argument it is passed will be a bytes. It should return challenge argument it is passed will be a bytes. It should return
bytes data that will be base64 encoded and sent to the server. 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() mechanism = mechanism.upper()
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) (code, resp) = self.docmd("AUTH", mechanism)
# Server replies with 334 (challenge) or 535 (not supported) # Server replies with 334 (challenge) or 535 (not supported)
if code == 334: if code == 334:
@ -629,18 +640,21 @@ class SMTP:
return (code, resp) return (code, resp)
raise SMTPAuthenticationError(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 """ Authobject to use with CRAM-MD5 authentication. Requires self.user
and self.password to be set.""" and self.password to be set."""
# CRAM-MD5 does not support initial-response.
if challenge is None:
return None
return self.user + " " + hmac.HMAC( return self.user + " " + hmac.HMAC(
self.password.encode('ascii'), challenge, 'md5').hexdigest() 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 """ Authobject to use with PLAIN authentication. Requires self.user and
self.password to be set.""" self.password to be set."""
return "\0%s\0%s" % (self.user, self.password) 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 """ Authobject to use with LOGIN authentication. Requires self.user and
self.password to be set.""" self.password to be set."""
(code, resp) = self.docmd( (code, resp) = self.docmd(
@ -649,13 +663,17 @@ class SMTP:
return self.password return self.password
raise SMTPAuthenticationError(code, resp) 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. """Log in on an SMTP server that requires authentication.
The arguments are: The arguments are:
- user: The user name to authenticate with. - user: The user name to authenticate with.
- password: The password for the authentication. - 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 If there has been no previous EHLO or HELO command this session, this
method tries ESMTP EHLO first. method tries ESMTP EHLO first.
@ -698,7 +716,9 @@ class SMTP:
for authmethod in authlist: for authmethod in authlist:
method_name = 'auth_' + authmethod.lower().replace('-', '_') method_name = 'auth_' + authmethod.lower().replace('-', '_')
try: 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' # 235 == 'Authentication successful'
# 503 == 'Error: already authenticated' # 503 == 'Error: already authenticated'
if code in (235, 503): if code in (235, 503):

View File

@ -1,6 +1,7 @@
import asyncore import asyncore
import email.mime.text import email.mime.text
from email.message import EmailMessage from email.message import EmailMessage
from email.base64mime import body_encode as encode_base64
import email.utils import email.utils
import socket import socket
import smtpd import smtpd
@ -814,11 +815,11 @@ class SMTPSimTests(unittest.TestCase):
def testVRFY(self): def testVRFY(self):
smtp = smtplib.SMTP(HOST, self.port, local_hostname='localhost', timeout=15) 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' % expected_known = (250, bytes('%s %s' %
(name, smtplib.quoteaddr(email)), (name, smtplib.quoteaddr(addr_spec)),
"ascii")) "ascii"))
self.assertEqual(smtp.vrfy(email), expected_known) self.assertEqual(smtp.vrfy(addr_spec), expected_known)
u = 'nobody@nowhere.com' u = 'nobody@nowhere.com'
expected_unknown = (550, ('No such user: %s' % u).encode('ascii')) expected_unknown = (550, ('No such user: %s' % u).encode('ascii'))
@ -851,7 +852,7 @@ class SMTPSimTests(unittest.TestCase):
def testAUTH_PLAIN(self): def testAUTH_PLAIN(self):
self.serv.add_feature("AUTH PLAIN") self.serv.add_feature("AUTH PLAIN")
smtp = smtplib.SMTP(HOST, self.port, local_hostname='localhost', timeout=15) 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: except smtplib.SMTPAuthenticationError as err:
self.assertIn(sim_auth_plain, str(err)) self.assertIn(sim_auth_plain, str(err))
smtp.close() smtp.close()
@ -892,7 +893,7 @@ class SMTPSimTests(unittest.TestCase):
'LOGIN': smtp.auth_login, 'LOGIN': smtp.auth_login,
} }
for mechanism, method in supported.items(): 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: except smtplib.SMTPAuthenticationError as err:
self.assertIn(sim_auth_credentials[mechanism.lower()].upper(), self.assertIn(sim_auth_credentials[mechanism.lower()].upper(),
str(err)) str(err))
@ -1142,12 +1143,85 @@ class SMTPUTF8SimTests(unittest.TestCase):
smtp.send_message(msg)) 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
@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 @support.reap_threads
def test_main(verbose=None): def test_main(verbose=None):
support.run_unittest(GeneralTests, DebuggingServerTests, support.run_unittest(
BadHELOServerTests,
DebuggingServerTests,
GeneralTests,
NonConnectingTests, NonConnectingTests,
BadHELOServerTests, SMTPSimTests, SMTPAUTHInitialResponseSimTests,
TooLongLineTests) SMTPSimTests,
TooLongLineTests,
)
if __name__ == '__main__': if __name__ == '__main__':
test_main() test_main()

View File

@ -22,6 +22,9 @@ Library
- Issue #24259: tarfile now raises a ReadError if an archive is truncated - Issue #24259: tarfile now raises a ReadError if an archive is truncated
inside a data segment. 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? What's New in Python 3.5.0 beta 3?
================================== ==================================