Commit 76e13c1c authored by R David Murray's avatar R David Murray

#15014: Add 'auth' command to implement auth mechanisms and use it in login.

Patch by Milan Oberkirch.
parent d8b129f2
...@@ -240,8 +240,7 @@ An :class:`SMTP` instance has the following methods: ...@@ -240,8 +240,7 @@ An :class:`SMTP` instance has the following methods:
the server is stored as the :attr:`ehlo_resp` attribute, :attr:`does_esmtp` the server is stored as the :attr:`ehlo_resp` attribute, :attr:`does_esmtp`
is set to true or false depending on whether the server supports ESMTP, and is set to true or false depending on whether the server supports ESMTP, and
:attr:`esmtp_features` will be a dictionary containing the names of the :attr:`esmtp_features` will be a dictionary containing the names of the
SMTP service extensions this server supports, and their SMTP service extensions this server supports, and their parameters (if any).
parameters (if any).
Unless you wish to use :meth:`has_extn` before sending mail, it should not be Unless you wish to use :meth:`has_extn` before sending mail, it should not be
necessary to call this method explicitly. It will be implicitly called by necessary to call this method explicitly. It will be implicitly called by
...@@ -291,6 +290,42 @@ An :class:`SMTP` instance has the following methods: ...@@ -291,6 +290,42 @@ An :class:`SMTP` instance has the following methods:
:exc:`SMTPException` :exc:`SMTPException`
No suitable authentication method was found. 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).
.. method:: SMTP.auth(mechanism, authobject)
Issue an ``SMTP`` ``AUTH`` command for the specified authentication
*mechanism*, and handle the challenge response via *authobject*.
*mechanism* specifies which authentication mechanism is to
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:
data = authobject(challenge)
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.
The ``SMTP`` class provides ``authobjects`` for the ``CRAM-MD5``, ``PLAIN``,
and ``LOGIN`` mechanisms; they are named ``SMTP.auth_cram_md5``,
``SMTP.auth_plain``, and ``SMTP.auth_login`` respectively. They all require
that the ``user`` and ``password`` properties of the ``SMTP`` instance are
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`.
.. versionadded:: 3.5
.. method:: SMTP.starttls(keyfile=None, certfile=None, context=None) .. method:: SMTP.starttls(keyfile=None, certfile=None, context=None)
......
...@@ -221,6 +221,13 @@ smtpd ...@@ -221,6 +221,13 @@ smtpd
addresses in the :class:`~smtpd.SMTPServer` constructor, and have it addresses in the :class:`~smtpd.SMTPServer` constructor, and have it
successfully connect. (Contributed by Milan Oberkirch in :issue:`14758`.) successfully connect. (Contributed by Milan Oberkirch in :issue:`14758`.)
smtplib
-------
* A new :meth:`~smtplib.SMTP.auth` method provides a convenient way to
implement custom authentication mechanisms (contributed by Milan Oberkirch in
:issue:`15014`).
socket socket
------ ------
......
...@@ -571,12 +571,60 @@ class SMTP: ...@@ -571,12 +571,60 @@ 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):
"""Authentication command - requires response processing.
'mechanism' specifies which authentication mechanism is to
be used - the valid values are those listed in the 'auth'
element of 'esmtp_features'.
'authobject' must be a callable object taking a single argument:
data = authobject(challenge)
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.
"""
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)
raise SMTPAuthenticationError(code, resp)
def auth_cram_md5(self, challenge):
""" Authobject to use with CRAM-MD5 authentication. Requires self.user
and self.password to be set."""
return self.user + " " + hmac.HMAC(
self.password.encode('ascii'), challenge, 'md5').hexdigest()
def auth_plain(self, challenge):
""" 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):
""" Authobject to use with LOGIN authentication. Requires self.user and
self.password to be set."""
(code, resp) = self.docmd(
encode_base64(self.user.encode('ascii'), eol=''))
if code == 334:
return self.password
raise SMTPAuthenticationError(code, resp)
def login(self, user, password): def login(self, user, password):
"""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.
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.
...@@ -593,63 +641,40 @@ class SMTP: ...@@ -593,63 +641,40 @@ class SMTP:
found. found.
""" """
def encode_cram_md5(challenge, user, password):
challenge = base64.decodebytes(challenge)
response = user + " " + hmac.HMAC(password.encode('ascii'),
challenge, 'md5').hexdigest()
return encode_base64(response.encode('ascii'), eol='')
def encode_plain(user, password):
s = "\0%s\0%s" % (user, password)
return encode_base64(s.encode('ascii'), eol='')
AUTH_PLAIN = "PLAIN"
AUTH_CRAM_MD5 = "CRAM-MD5"
AUTH_LOGIN = "LOGIN"
self.ehlo_or_helo_if_needed() self.ehlo_or_helo_if_needed()
if not self.has_extn("auth"): if not self.has_extn("auth"):
raise SMTPException("SMTP AUTH extension not supported by server.") raise SMTPException("SMTP AUTH extension not supported by server.")
# Authentication methods the server claims to support # Authentication methods the server claims to support
advertised_authlist = self.esmtp_features["auth"].split() advertised_authlist = self.esmtp_features["auth"].split()
# List of authentication methods we support: from preferred to # Authentication methods we can handle in our preferred order:
# less preferred methods. Except for the purpose of testing the weaker preferred_auths = ['CRAM-MD5', 'PLAIN', 'LOGIN']
# ones, we prefer stronger methods like CRAM-MD5:
preferred_auths = [AUTH_CRAM_MD5, AUTH_PLAIN, AUTH_LOGIN]
# We try the authentication methods the server advertises, but only the # We try the supported authentications in our preferred order, if
# ones *we* support. And in our preferred order. # the server supports them.
authlist = [auth for auth in preferred_auths if auth in advertised_authlist] authlist = [auth for auth in preferred_auths
if auth in advertised_authlist]
if not authlist: if not authlist:
raise SMTPException("No suitable authentication method found.") raise SMTPException("No suitable authentication method found.")
# Some servers advertise authentication methods they don't really # Some servers advertise authentication methods they don't really
# support, so if authentication fails, we continue until we've tried # support, so if authentication fails, we continue until we've tried
# all methods. # all methods.
self.user, self.password = user, password
for authmethod in authlist: for authmethod in authlist:
if authmethod == AUTH_CRAM_MD5: method_name = 'auth_' + authmethod.lower().replace('-', '_')
(code, resp) = self.docmd("AUTH", AUTH_CRAM_MD5) try:
if code == 334: (code, resp) = self.auth(authmethod, getattr(self, method_name))
(code, resp) = self.docmd(encode_cram_md5(resp, user, password)) # 235 == 'Authentication successful'
elif authmethod == AUTH_PLAIN: # 503 == 'Error: already authenticated'
(code, resp) = self.docmd("AUTH", if code in (235, 503):
AUTH_PLAIN + " " + encode_plain(user, password)) return (code, resp)
elif authmethod == AUTH_LOGIN: except SMTPAuthenticationError as e:
(code, resp) = self.docmd("AUTH", last_exception = e
"%s %s" % (AUTH_LOGIN, encode_base64(user.encode('ascii'), eol='')))
if code == 334: # We could not login successfully. Return result of last attempt.
(code, resp) = self.docmd(encode_base64(password.encode('ascii'), eol='')) raise last_exception
# 235 == 'Authentication successful'
# 503 == 'Error: already authenticated'
if code in (235, 503):
return (code, resp)
# We could not login sucessfully. Return result of last attempt.
raise SMTPAuthenticationError(code, resp)
def starttls(self, keyfile=None, certfile=None, context=None): def starttls(self, keyfile=None, certfile=None, context=None):
"""Puts the connection to the SMTP server into TLS mode. """Puts the connection to the SMTP server into TLS mode.
......
...@@ -10,6 +10,7 @@ import sys ...@@ -10,6 +10,7 @@ import sys
import time import time
import select import select
import errno import errno
import base64
import unittest import unittest
from test import support, mock_socket from test import support, mock_socket
...@@ -605,7 +606,8 @@ sim_auth_credentials = { ...@@ -605,7 +606,8 @@ sim_auth_credentials = {
'cram-md5': ('TXIUQUBZB21LD2HLCMUUY29TIDG4OWQ0MJ' 'cram-md5': ('TXIUQUBZB21LD2HLCMUUY29TIDG4OWQ0MJ'
'KWZGQ4ODNMNDA4NTGXMDRLZWMYZJDMODG1'), 'KWZGQ4ODNMNDA4NTGXMDRLZWMYZJDMODG1'),
} }
sim_auth_login_password = 'C29TZXBHC3N3B3JK' sim_auth_login_user = 'TXIUQUBZB21LD2HLCMUUY29T'
sim_auth_plain = 'AE1YLKFAC29TZXDOZXJLLMNVBQBZB21LCGFZC3DVCMQ='
sim_lists = {'list-1':['Mr.A@somewhere.com','Mrs.C@somewhereesle.com'], sim_lists = {'list-1':['Mr.A@somewhere.com','Mrs.C@somewhereesle.com'],
'list-2':['Ms.B@xn--fo-fka.com',], 'list-2':['Ms.B@xn--fo-fka.com',],
...@@ -659,18 +661,16 @@ class SimSMTPChannel(smtpd.SMTPChannel): ...@@ -659,18 +661,16 @@ class SimSMTPChannel(smtpd.SMTPChannel):
self.push('550 No access for you!') self.push('550 No access for you!')
def smtp_AUTH(self, arg): def smtp_AUTH(self, arg):
if arg.strip().lower()=='cram-md5': mech = arg.strip().lower()
if mech=='cram-md5':
self.push('334 {}'.format(sim_cram_md5_challenge)) self.push('334 {}'.format(sim_cram_md5_challenge))
return elif mech not in sim_auth_credentials:
mech, auth = arg.split()
mech = mech.lower()
if mech not in sim_auth_credentials:
self.push('504 auth type unimplemented') self.push('504 auth type unimplemented')
return return
if mech == 'plain' and auth==sim_auth_credentials['plain']: elif mech=='plain':
self.push('235 plain auth ok') self.push('334 ')
elif mech=='login' and auth==sim_auth_credentials['login']: elif mech=='login':
self.push('334 Password:') self.push('334 ')
else: else:
self.push('550 No access for you!') self.push('550 No access for you!')
...@@ -818,28 +818,28 @@ class SMTPSimTests(unittest.TestCase): ...@@ -818,28 +818,28 @@ class SMTPSimTests(unittest.TestCase):
self.assertEqual(smtp.expn(u), expected_unknown) self.assertEqual(smtp.expn(u), expected_unknown)
smtp.quit() smtp.quit()
def testAUTH_PLAIN(self): # SimSMTPChannel doesn't fully support AUTH because it requires a
self.serv.add_feature("AUTH PLAIN") # synchronous read to obtain the credentials...so instead smtpd
smtp = smtplib.SMTP(HOST, self.port, local_hostname='localhost', timeout=15)
expected_auth_ok = (235, b'plain auth ok')
self.assertEqual(smtp.login(sim_auth[0], sim_auth[1]), expected_auth_ok)
smtp.close()
# SimSMTPChannel doesn't fully support LOGIN or CRAM-MD5 auth because they
# require a synchronous read to obtain the credentials...so instead smtpd
# sees the credential sent by smtplib's login method as an unknown command, # sees the credential sent by smtplib's login method as an unknown command,
# which results in smtplib raising an auth error. Fortunately the error # which results in smtplib raising an auth error. Fortunately the error
# message contains the encoded credential, so we can partially check that it # message contains the encoded credential, so we can partially check that it
# was generated correctly (partially, because the 'word' is uppercased in # was generated correctly (partially, because the 'word' is uppercased in
# the error message). # the error message).
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])
except smtplib.SMTPAuthenticationError as err:
self.assertIn(sim_auth_plain, str(err))
smtp.close()
def testAUTH_LOGIN(self): def testAUTH_LOGIN(self):
self.serv.add_feature("AUTH LOGIN") self.serv.add_feature("AUTH LOGIN")
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])
except smtplib.SMTPAuthenticationError as err: except smtplib.SMTPAuthenticationError as err:
self.assertIn(sim_auth_login_password, str(err)) self.assertIn(sim_auth_login_user, str(err))
smtp.close() smtp.close()
def testAUTH_CRAM_MD5(self): def testAUTH_CRAM_MD5(self):
...@@ -857,7 +857,23 @@ class SMTPSimTests(unittest.TestCase): ...@@ -857,7 +857,23 @@ class SMTPSimTests(unittest.TestCase):
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])
except smtplib.SMTPAuthenticationError as err: except smtplib.SMTPAuthenticationError as err:
self.assertIn(sim_auth_login_password, str(err)) self.assertIn(sim_auth_login_user, str(err))
smtp.close()
def test_auth_function(self):
smtp = smtplib.SMTP(HOST, self.port,
local_hostname='localhost', timeout=15)
self.serv.add_feature("AUTH CRAM-MD5")
smtp.user, smtp.password = sim_auth[0], sim_auth[1]
supported = {'CRAM-MD5': smtp.auth_cram_md5,
'PLAIN': smtp.auth_plain,
'LOGIN': smtp.auth_login,
}
for mechanism, method in supported.items():
try: smtp.auth(mechanism, method)
except smtplib.SMTPAuthenticationError as err:
self.assertIn(sim_auth_credentials[mechanism.lower()].upper(),
str(err))
smtp.close() smtp.close()
def test_with_statement(self): def test_with_statement(self):
......
...@@ -103,6 +103,9 @@ Core and Builtins ...@@ -103,6 +103,9 @@ Core and Builtins
Library Library
------- -------
- Issue #15014: Added 'auth' method to smtplib to make implementing auth
mechanisms simpler, and used it internally in the login method.
- Issue #21151: Fixed a segfault in the winreg module when ``None`` is passed - Issue #21151: Fixed a segfault in the winreg module when ``None`` is passed
as a ``REG_BINARY`` value to SetValueEx. Patch by John Ehresman. as a ``REG_BINARY`` value to SetValueEx. Patch by John Ehresman.
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment