Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit c5ea754

Browse files
committed
- Issue #15014: SMTP.auth() and SMTP.login() now support RFC 4954's optional
initial-response argument to the SMTP AUTH command.
1 parent b85b427 commit c5ea754

4 files changed

Lines changed: 153 additions & 39 deletions

File tree

Doc/library/smtplib.rst

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,7 @@ An :class:`SMTP` instance has the following methods:
288288
Many sites disable SMTP ``VRFY`` in order to foil spammers.
289289

290290

291-
.. method:: SMTP.login(user, password)
291+
.. method:: SMTP.login(user, password, *, initial_response_ok=True)
292292

293293
Log in on an SMTP server that requires authentication. The arguments are the
294294
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:
309309
No suitable authentication method was found.
310310

311311
Each of the authentication methods supported by :mod:`smtplib` are tried in
312-
turn if they are advertised as supported by the server (see :meth:`auth`
313-
for a list of supported authentication methods).
312+
turn if they are advertised as supported by the server. See :meth:`auth`
313+
for a list of supported authentication methods. *initial_response_ok* is
314+
passed through to :meth:`auth`.
315+
316+
Optional keyword argument *initial_response_ok* specifies whether, for
317+
authentication methods that support it, an "initial response" as specified
318+
in :rfc:`4954` can be sent along with the ``AUTH`` command, rather than
319+
requiring a challenge/response.
314320

315321
.. versionchanged:: 3.5
316-
:exc:`SMTPNotSupportedError` may be raised.
322+
:exc:`SMTPNotSupportedError` may be raised, and the
323+
*initial_response_ok* parameter was added.
317324

318325

319-
.. method:: SMTP.auth(mechanism, authobject)
326+
.. method:: SMTP.auth(mechanism, authobject, *, initial_response_ok=True)
320327

321328
Issue an ``SMTP`` ``AUTH`` command for the specified authentication
322329
*mechanism*, and handle the challenge response via *authobject*.
@@ -325,13 +332,23 @@ An :class:`SMTP` instance has the following methods:
325332
be used as argument to the ``AUTH`` command; the valid values are
326333
those listed in the ``auth`` element of :attr:`esmtp_features`.
327334

328-
*authobject* must be a callable object taking a single argument:
335+
*authobject* must be a callable object taking an optional single argument:
336+
337+
data = authobject(challenge=None)
329338

330-
data = authobject(challenge)
339+
If optional keyword argument *initial_response_ok* is true,
340+
``authobject()`` will be called first with no argument. It can return the
341+
:rfc:`4954` "initial response" bytes which will be encoded and sent with
342+
the ``AUTH`` command as below. If the ``authobject()`` does not support an
343+
initial response (e.g. because it requires a challenge), it should return
344+
None when called with ``challenge=None``. If *initial_response_ok* is
345+
false, then ``authobject()`` will not be called first with None.
331346

332-
It will be called to process the server's challenge response; the
333-
*challenge* argument it is passed will be a ``bytes``. It should return
334-
``bytes`` *data* that will be base64 encoded and sent to the server.
347+
If the initial response check returns None, or if *initial_response_ok* is
348+
false, ``authobject()`` will be called to process the server's challenge
349+
response; the *challenge* argument it is passed will be a ``bytes``. It
350+
should return ``bytes`` *data* that will be base64 encoded and sent to the
351+
server.
335352

336353
The ``SMTP`` class provides ``authobjects`` for the ``CRAM-MD5``, ``PLAIN``,
337354
and ``LOGIN`` mechanisms; they are named ``SMTP.auth_cram_md5``,
@@ -340,10 +357,10 @@ An :class:`SMTP` instance has the following methods:
340357
set to appropriate values.
341358

342359
User code does not normally need to call ``auth`` directly, but can instead
343-
call the :meth:`login` method, which will try each of the above mechanisms in
344-
turn, in the order listed. ``auth`` is exposed to facilitate the
345-
implementation of authentication methods not (or not yet) supported directly
346-
by :mod:`smtplib`.
360+
call the :meth:`login` method, which will try each of the above mechanisms
361+
in turn, in the order listed. ``auth`` is exposed to facilitate the
362+
implementation of authentication methods not (or not yet) supported
363+
directly by :mod:`smtplib`.
347364

348365
.. versionadded:: 3.5
349366

Lib/smtplib.py

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -601,7 +601,7 @@ def ehlo_or_helo_if_needed(self):
601601
if not (200 <= code <= 299):
602602
raise SMTPHeloError(code, resp)
603603

604-
def auth(self, mechanism, authobject):
604+
def auth(self, mechanism, authobject, *, initial_response_ok=True):
605605
"""Authentication command - requires response processing.
606606
607607
'mechanism' specifies which authentication mechanism is to
@@ -615,32 +615,46 @@ def auth(self, mechanism, authobject):
615615
It will be called to process the server's challenge response; the
616616
challenge argument it is passed will be a bytes. It should return
617617
bytes data that will be base64 encoded and sent to the server.
618-
"""
619618
619+
Keyword arguments:
620+
- initial_response_ok: Allow sending the RFC 4954 initial-response
621+
to the AUTH command, if the authentication methods supports it.
622+
"""
623+
# RFC 4954 allows auth methods to provide an initial response. Not all
624+
# methods support it. By definition, if they return something other
625+
# than None when challenge is None, then they do. See issue #15014.
620626
mechanism = mechanism.upper()
621-
(code, resp) = self.docmd("AUTH", mechanism)
622-
# Server replies with 334 (challenge) or 535 (not supported)
623-
if code == 334:
624-
challenge = base64.decodebytes(resp)
625-
response = encode_base64(
626-
authobject(challenge).encode('ascii'), eol='')
627-
(code, resp) = self.docmd(response)
628-
if code in (235, 503):
629-
return (code, resp)
627+
initial_response = (authobject() if initial_response_ok else None)
628+
if initial_response is not None:
629+
response = encode_base64(initial_response.encode('ascii'), eol='')
630+
(code, resp) = self.docmd("AUTH", mechanism + " " + response)
631+
else:
632+
(code, resp) = self.docmd("AUTH", mechanism)
633+
# Server replies with 334 (challenge) or 535 (not supported)
634+
if code == 334:
635+
challenge = base64.decodebytes(resp)
636+
response = encode_base64(
637+
authobject(challenge).encode('ascii'), eol='')
638+
(code, resp) = self.docmd(response)
639+
if code in (235, 503):
640+
return (code, resp)
630641
raise SMTPAuthenticationError(code, resp)
631642

632-
def auth_cram_md5(self, challenge):
643+
def auth_cram_md5(self, challenge=None):
633644
""" Authobject to use with CRAM-MD5 authentication. Requires self.user
634645
and self.password to be set."""
646+
# CRAM-MD5 does not support initial-response.
647+
if challenge is None:
648+
return None
635649
return self.user + " " + hmac.HMAC(
636650
self.password.encode('ascii'), challenge, 'md5').hexdigest()
637651

638-
def auth_plain(self, challenge):
652+
def auth_plain(self, challenge=None):
639653
""" Authobject to use with PLAIN authentication. Requires self.user and
640654
self.password to be set."""
641655
return "\0%s\0%s" % (self.user, self.password)
642656

643-
def auth_login(self, challenge):
657+
def auth_login(self, challenge=None):
644658
""" Authobject to use with LOGIN authentication. Requires self.user and
645659
self.password to be set."""
646660
(code, resp) = self.docmd(
@@ -649,13 +663,17 @@ def auth_login(self, challenge):
649663
return self.password
650664
raise SMTPAuthenticationError(code, resp)
651665

652-
def login(self, user, password):
666+
def login(self, user, password, *, initial_response_ok=True):
653667
"""Log in on an SMTP server that requires authentication.
654668
655669
The arguments are:
656670
- user: The user name to authenticate with.
657671
- password: The password for the authentication.
658672
673+
Keyword arguments:
674+
- initial_response_ok: Allow sending the RFC 4954 initial-response
675+
to the AUTH command, if the authentication methods supports it.
676+
659677
If there has been no previous EHLO or HELO command this session, this
660678
method tries ESMTP EHLO first.
661679
@@ -698,7 +716,9 @@ def login(self, user, password):
698716
for authmethod in authlist:
699717
method_name = 'auth_' + authmethod.lower().replace('-', '_')
700718
try:
701-
(code, resp) = self.auth(authmethod, getattr(self, method_name))
719+
(code, resp) = self.auth(
720+
authmethod, getattr(self, method_name),
721+
initial_response_ok=initial_response_ok)
702722
# 235 == 'Authentication successful'
703723
# 503 == 'Error: already authenticated'
704724
if code in (235, 503):

Lib/test/test_smtplib.py

Lines changed: 83 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncore
22
import email.mime.text
33
from email.message import EmailMessage
4+
from email.base64mime import body_encode as encode_base64
45
import email.utils
56
import socket
67
import smtpd
@@ -814,11 +815,11 @@ def testEHLO(self):
814815
def testVRFY(self):
815816
smtp = smtplib.SMTP(HOST, self.port, local_hostname='localhost', timeout=15)
816817

817-
for email, name in sim_users.items():
818+
for addr_spec, name in sim_users.items():
818819
expected_known = (250, bytes('%s %s' %
819-
(name, smtplib.quoteaddr(email)),
820+
(name, smtplib.quoteaddr(addr_spec)),
820821
"ascii"))
821-
self.assertEqual(smtp.vrfy(email), expected_known)
822+
self.assertEqual(smtp.vrfy(addr_spec), expected_known)
822823

823824
824825
expected_unknown = (550, ('No such user: %s' % u).encode('ascii'))
@@ -851,7 +852,7 @@ def testEXPN(self):
851852
def testAUTH_PLAIN(self):
852853
self.serv.add_feature("AUTH PLAIN")
853854
smtp = smtplib.SMTP(HOST, self.port, local_hostname='localhost', timeout=15)
854-
try: smtp.login(sim_auth[0], sim_auth[1])
855+
try: smtp.login(sim_auth[0], sim_auth[1], initial_response_ok=False)
855856
except smtplib.SMTPAuthenticationError as err:
856857
self.assertIn(sim_auth_plain, str(err))
857858
smtp.close()
@@ -892,7 +893,7 @@ def test_auth_function(self):
892893
'LOGIN': smtp.auth_login,
893894
}
894895
for mechanism, method in supported.items():
895-
try: smtp.auth(mechanism, method)
896+
try: smtp.auth(mechanism, method, initial_response_ok=False)
896897
except smtplib.SMTPAuthenticationError as err:
897898
self.assertIn(sim_auth_credentials[mechanism.lower()].upper(),
898899
str(err))
@@ -1142,12 +1143,85 @@ def test_send_message_error_on_non_ascii_addrs_if_no_smtputf8(self):
11421143
smtp.send_message(msg))
11431144

11441145

1146+
EXPECTED_RESPONSE = encode_base64(b'\0psu\0doesnotexist', eol='')
1147+
1148+
class SimSMTPAUTHInitialResponseChannel(SimSMTPChannel):
1149+
def smtp_AUTH(self, arg):
1150+
# RFC 4954's AUTH command allows for an optional initial-response.
1151+
# Not all AUTH methods support this; some require a challenge. AUTH
1152+
# PLAIN does those, so test that here. See issue #15014.
1153+
args = arg.split()
1154+
if args[0].lower() == 'plain':
1155+
if len(args) == 2:
1156+
# AUTH PLAIN <initial-response> with the response base 64
1157+
# encoded. Hard code the expected response for the test.
1158+
if args[1] == EXPECTED_RESPONSE:
1159+
self.push('235 Ok')
1160+
return
1161+
self.push('571 Bad authentication')
1162+
1163+
class SimSMTPAUTHInitialResponseServer(SimSMTPServer):
1164+
channel_class = SimSMTPAUTHInitialResponseChannel
1165+
1166+
1167+
@unittest.skipUnless(threading, 'Threading required for this test.')
1168+
class SMTPAUTHInitialResponseSimTests(unittest.TestCase):
1169+
def setUp(self):
1170+
self.real_getfqdn = socket.getfqdn
1171+
socket.getfqdn = mock_socket.getfqdn
1172+
self.serv_evt = threading.Event()
1173+
self.client_evt = threading.Event()
1174+
# Pick a random unused port by passing 0 for the port number
1175+
self.serv = SimSMTPAUTHInitialResponseServer(
1176+
(HOST, 0), ('nowhere', -1), decode_data=True)
1177+
# Keep a note of what port was assigned
1178+
self.port = self.serv.socket.getsockname()[1]
1179+
serv_args = (self.serv, self.serv_evt, self.client_evt)
1180+
self.thread = threading.Thread(target=debugging_server, args=serv_args)
1181+
self.thread.start()
1182+
1183+
# wait until server thread has assigned a port number
1184+
self.serv_evt.wait()
1185+
self.serv_evt.clear()
1186+
1187+
def tearDown(self):
1188+
socket.getfqdn = self.real_getfqdn
1189+
# indicate that the client is finished
1190+
self.client_evt.set()
1191+
# wait for the server thread to terminate
1192+
self.serv_evt.wait()
1193+
self.thread.join()
1194+
1195+
def testAUTH_PLAIN_initial_response_login(self):
1196+
self.serv.add_feature('AUTH PLAIN')
1197+
smtp = smtplib.SMTP(HOST, self.port,
1198+
local_hostname='localhost', timeout=15)
1199+
smtp.login('psu', 'doesnotexist')
1200+
smtp.close()
1201+
1202+
def testAUTH_PLAIN_initial_response_auth(self):
1203+
self.serv.add_feature('AUTH PLAIN')
1204+
smtp = smtplib.SMTP(HOST, self.port,
1205+
local_hostname='localhost', timeout=15)
1206+
smtp.user = 'psu'
1207+
smtp.password = 'doesnotexist'
1208+
code, response = smtp.auth('plain', smtp.auth_plain)
1209+
smtp.close()
1210+
self.assertEqual(code, 235)
1211+
1212+
11451213
@support.reap_threads
11461214
def test_main(verbose=None):
1147-
support.run_unittest(GeneralTests, DebuggingServerTests,
1148-
NonConnectingTests,
1149-
BadHELOServerTests, SMTPSimTests,
1150-
TooLongLineTests)
1215+
support.run_unittest(
1216+
BadHELOServerTests,
1217+
DebuggingServerTests,
1218+
GeneralTests,
1219+
NonConnectingTests,
1220+
SMTPAUTHInitialResponseSimTests,
1221+
SMTPSimTests,
1222+
TooLongLineTests,
1223+
)
1224+
11511225

11521226
if __name__ == '__main__':
11531227
test_main()

Misc/NEWS

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ Library
2222
- Issue #24259: tarfile now raises a ReadError if an archive is truncated
2323
inside a data segment.
2424

25+
- Issue #15014: SMTP.auth() and SMTP.login() now support RFC 4954's optional
26+
initial-response argument to the SMTP AUTH command.
27+
2528

2629
What's New in Python 3.5.0 beta 3?
2730
==================================

0 commit comments

Comments
 (0)