mirror of https://github.com/python/cpython
- 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:
parent
b85b427507
commit
c5ea754e48
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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,32 +615,46 @@ 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()
|
||||||
(code, resp) = self.docmd("AUTH", mechanism)
|
initial_response = (authobject() if initial_response_ok else None)
|
||||||
# Server replies with 334 (challenge) or 535 (not supported)
|
if initial_response is not None:
|
||||||
if code == 334:
|
response = encode_base64(initial_response.encode('ascii'), eol='')
|
||||||
challenge = base64.decodebytes(resp)
|
(code, resp) = self.docmd("AUTH", mechanism + " " + response)
|
||||||
response = encode_base64(
|
else:
|
||||||
authobject(challenge).encode('ascii'), eol='')
|
(code, resp) = self.docmd("AUTH", mechanism)
|
||||||
(code, resp) = self.docmd(response)
|
# Server replies with 334 (challenge) or 535 (not supported)
|
||||||
if code in (235, 503):
|
if code == 334:
|
||||||
return (code, resp)
|
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)
|
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):
|
||||||
|
|
|
@ -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(
|
||||||
NonConnectingTests,
|
BadHELOServerTests,
|
||||||
BadHELOServerTests, SMTPSimTests,
|
DebuggingServerTests,
|
||||||
TooLongLineTests)
|
GeneralTests,
|
||||||
|
NonConnectingTests,
|
||||||
|
SMTPAUTHInitialResponseSimTests,
|
||||||
|
SMTPSimTests,
|
||||||
|
TooLongLineTests,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
test_main()
|
test_main()
|
||||||
|
|
|
@ -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?
|
||||||
==================================
|
==================================
|
||||||
|
|
Loading…
Reference in New Issue