diff -r c43362d35d8d Doc/library/smtpd.rst --- a/Doc/library/smtpd.rst Fri Jul 04 17:00:25 2014 -0700 +++ b/Doc/library/smtpd.rst Wed Jul 16 20:23:15 2014 +0200 @@ -28,7 +28,7 @@ .. class:: SMTPServer(localaddr, remoteaddr, data_size_limit=33554432,\ - map=None, decode_data=True) + map=None, decode_data=True, enable_AUTH=False) Create a new :class:`SMTPServer` object, which binds to local address *localaddr*. It will treat *remoteaddr* as an upstream SMTP relayer. It @@ -46,6 +46,14 @@ compatibility reasons, but will change to ``False`` in Python 3.6. Specify the keyword value explicitly to avoid the :exc:`DeprecationWarning`. + If *enable_AUTH* is set to ``True`` the server advertises ``PLAIN`` and + ``LOGIN`` ``AUTH`` mechanisms and activates authentication and + authorization. + In this case :meth:`process_auth` is called during authentication to + validate user credentials and :meth:`accept_recipient` is called when the + client sends a ``RCPT TO`` command to decide wether the authenticated user + is allowed to send a message to the given recipient. + .. method:: process_message(peer, mailfrom, rcpttos, data) Raise :exc:`NotImplementedError` exception. Override this in subclasses to @@ -60,6 +68,31 @@ argument will be a unicode string. If it is set to ``False``, it will be a bytes object. + .. method:: process_auth(user, password) + + Raise :exc:`NotImplementedError` exception. Override this in subclasses + to support authentication. It is used and needs to be overwritten if the + init parameter *enable_AUTH* is set to ``True``. + This methods gets *user* and *password* as strings and should return a + boolean indicating weather the user credentials are valid. It may raise a + :exc:`smtplib.SMTPResponseException` which is passed to the client as + SMTP response. You also need to override :meth:`accept_recipient` to + support authorization as well. + + .. method:: accept_recipient(user, mailfrom, rcptto) + + Raise :exc:`NotImplementedError` exception. Override this in subclasses + to support authorization. It is used and needs to be overwritten if the + init parameter *enable_AUTH* is set to ``True``. + This methods gets the currently authenticated user as *user*, the address + given in the ``MAIL FROM`` command as *mailfrom* and the currently + proccessed recipiant adress as *rcptto*. All arguments are strings. This + method should return a boolean indicating weather the given *user* is + allowed to send emails from the address *mailfrom* to the address + *rcptto*. It may raise a :exc:`smtplib.SMTPResponseException` which is + passed to the client as SMTP response. You also need to override + :meth:`process_auth` to support authentication as well. + .. attribute:: channel_class Override this in subclasses to use a custom :class:`SMTPChannel` for @@ -68,8 +101,12 @@ .. versionchanged:: 3.4 The *map* argument was added. - .. versionchanged:: 3.5 the *decode_data* argument was added, and *localaddr* - and *remoteaddr* may now contain IPv6 addresses. + .. versionchanged:: 3.5 + *localaddr* and *remoteaddr* may now contain IPv6 addresses. + + .. versionadded:: 3.5 + The *decode_data* and *enable_AUTH* arguments and the abstract methods + :meth:`process_auth` and :meth:`accept_recipient` were added. DebuggingServer Objects diff -r c43362d35d8d Lib/smtpd.py --- a/Lib/smtpd.py Fri Jul 04 17:00:25 2014 -0700 +++ b/Lib/smtpd.py Wed Jul 16 20:23:15 2014 +0200 @@ -81,8 +81,10 @@ import socket import asyncore import asynchat +import base64 import collections from warnings import warn +from smtplib import SMTPResponseException from email._header_value_parser import get_addr_spec, get_angle_addr __all__ = ["SMTPServer","DebuggingServer","PureProxy","MailmanProxy"] @@ -112,13 +114,16 @@ class SMTPChannel(asynchat.async_chat): COMMAND = 0 DATA = 1 + AUTH = 2 command_size_limit = 512 command_size_limits = collections.defaultdict(lambda x=command_size_limit: x) - command_size_limits.update({ - 'MAIL': command_size_limit + 26, - }) - max_command_size_limit = max(command_size_limits.values()) + + @property + def max_command_size_limit(self): + if len(self.command_size_limits.values()): + return max(self.command_size_limits.values()) + return self.command_size_limits.default_factory() def __init__(self, server, conn, addr, data_size_limit=DATA_SIZE_DEFAULT, map=None, decode_data=None): @@ -151,6 +156,7 @@ self.received_data = '' self.fqdn = socket.getfqdn() self.num_bytes = 0 + self.authenticated_user = None try: self.peer = conn.getpeername() except OSError as err: @@ -338,6 +344,13 @@ return method(arg) return + elif self.smtp_state == self.AUTH: + try: + self.auth_object(line) + except SMTPResponseException as e: + self.smtp_state = self.COMMAND + self.push('%s %s' % (e.smtp_code, e.smtp_error)) + return else: if self.smtp_state != self.DATA: self.push('451 Internal confusion') @@ -389,11 +402,16 @@ if self.seen_greeting: self.push('503 Duplicate HELO/EHLO') else: + self.command_size_limits.clear() self.seen_greeting = arg self.extended_smtp = True self.push('250-%s' % self.fqdn) if self.data_size_limit: self.push('250-SIZE %s' % self.data_size_limit) + self.command_size_limits['MAIL'] += 26 + if self.smtp_server.enable_AUTH: + self.push('250-AUTH LOGIN PLAIN') + self.command_size_limits['MAIL'] += 500 self.push('250 HELP') def smtp_NOOP(self, arg): @@ -466,6 +484,80 @@ self.push('250 Supported commands: EHLO HELO MAIL RCPT DATA ' 'RSET NOOP QUIT VRFY') + def smtp_AUTH(self, arg): + if not self.seen_greeting: + self.push('503 Error: send EHLO first') + return + if not self.extended_smtp or not self.smtp_server.enable_AUTH: + self.push('500 Error: command "AUTH" not recognized') + return + if self.authenticated_user is not None: + self.push( + '503 Bad sequence of commands: already authenticated') + return + args = arg.split() + if len(args) not in [1, 2]: + self.push('501 Syntax: AUTH []') + return + auth_object_name = '_auth_%s' % args[0].lower().replace('-', '_') + try: + self.auth_object = getattr(self, auth_object_name) + except AttributeError: + self.push('504 Command parameter not implemented: ' + 'unsupported authentication mechanism') + return + self.smtp_state = self.AUTH + try: + self.auth_object(args[1] if len(args) == 2 else None) + except SMTPResponseException as e: + self.smtp_state = self.COMMAND + self.push('%s %s' % (e.smtp_code, e.smtp_error)) + + def _authenticate(self, user, password): + """Translates the output of `_verify_user_credentials` to SMTP + responses, sets `self.user` on success and resets the internal + state.""" + if self.smtp_server.process_auth(user, password): + self.authenticated_user = user + self.push('235 Authentication Succeeded') + else: + self.push('535 Authentication credentials invalid') + self.smtp_state = self.COMMAND + + def _decode(self, string): + """Decode string containing base64 data to unicode literal.""" + try: + return base64.decodebytes(string.encode('ascii')).decode('utf-8') + except base64.binascii.Error as e: + raise SMTPResponseException( + 501, 'Encoding error: %s' % str(e)) + + def _auth_plain(self, arg=None): + """AUTH helper processing PLAIN authentication.""" + if arg is None: + self.push('334 ') + else: + try: + user, password = self._decode(arg).strip('\0').split('\0') + except ValueError as e: + self.push('535 Splitting input into user and password failed') + return + self._authenticate(user, password) + + def _auth_login(self, arg=None): + """AUTH helper processing LOGIN authentication.""" + if arg is None: + # base64 encoded 'Username:' + self.push('334 VXNlcm5hbWU6') + elif not hasattr(self, '_auth_login_user'): + self._auth_login_user = self._decode(arg) + # base64 encoded 'Password:' + self.push('334 UGFzc3dvcmQ6') + else: + password = self._decode(arg) + self._authenticate(self._auth_login_user, password) + del self._auth_login_user + def smtp_VRFY(self, arg): if arg: address, params = self._getaddr(arg) @@ -514,6 +606,8 @@ elif self.data_size_limit and int(size) > self.data_size_limit: self.push('552 Error: message size exceeds fixed maximum message size') return + # See RFC 4954 section 5 + self.auth_mailbox = params.pop('AUTH', self.authenticated_user) if len(params.keys()) > 0: self.push('555 MAIL FROM parameters not recognized or not implemented') return @@ -558,6 +652,21 @@ if not address: self.push('501 Syntax: RCPT TO:
') return + if self.smtp_server.enable_AUTH: + try: + if not self.smtp_server.accept_recipient( + self.authenticated_user, self.mailfrom, address): + self.push( + "530 Sending emails from '%(from)s' to '%(to)s' as " + "'%(user)s' is not allowed by this server" % { + 'from': self.mailfrom, + 'to': address, + 'user': self.authenticated_user or '' + }) + return + except SMTPResponseException as e: + self.push('%s %s' % (e.smtp_code, e.smtp_error)) + return self.rcpttos.append(address) print('recips:', self.rcpttos, file=DEBUGSTREAM) self.push('250 OK') @@ -598,10 +707,11 @@ def __init__(self, localaddr, remoteaddr, data_size_limit=DATA_SIZE_DEFAULT, map=None, - decode_data=None): + decode_data=None, enable_AUTH=False): self._localaddr = localaddr self._remoteaddr = remoteaddr self.data_size_limit = data_size_limit + self.enable_AUTH = enable_AUTH if decode_data is None: warn("The decode_data default of True will change to False in 3.6;" " specify an explicit value for this keyword", @@ -655,6 +765,21 @@ """ raise NotImplementedError + def process_auth(self, user, password): + """Overwrite this method to provide authentication. + This method gets `user` and `password` as strings and should return a + boolean. It may raise an smtpd.SMTPResponseException on errors instead. + SMTPResponseExceptions are sent to to the client as SMTP response.""" + raise NotImplementedError + + def accept_recipient(self, user, mailfrom, rcptto): + """Overwrite this method to provide authorization. If the `user` is + allowed to send a message from `mailfrom` to `rcptto` this method + should return `True` else `False`. Any smtpd.SMTPResponseException gets + caught and will be send to the client. + """ + raise NotImplementedError + class DebuggingServer(SMTPServer): # Do something with the gathered message diff -r c43362d35d8d Lib/test/test_smtpd.py --- a/Lib/test/test_smtpd.py Fri Jul 04 17:00:25 2014 -0700 +++ b/Lib/test/test_smtpd.py Wed Jul 16 20:23:15 2014 +0200 @@ -55,6 +55,38 @@ with self.assertWarns(DeprecationWarning): smtpd.SMTPServer((support.HOST, 0), ('b', 0)) + def test_process_auth_unimplemented(self): + server = smtpd.SMTPServer((support.HOST, 0), ('b', 0), + decode_data=True, enable_AUTH=True) + conn, addr = server.accept() + channel = smtpd.SMTPChannel(server, conn, addr, decode_data=True) + + def write_line(line): + channel.socket.queue_recv(line) + channel.handle_read() + write_line(b'EHLO example') + self.assertRaises( + NotImplementedError, + write_line, + b'AUTH PLAIN AGhhbGxvAGhhbGxv\r\n') + + def test_accept_recipient_unimplemented(self): + server = smtpd.SMTPServer((support.HOST, 0), ('b', 0), + decode_data=True, enable_AUTH=True) + server.process_auth = lambda u, p : True + conn, addr = server.accept() + channel = smtpd.SMTPChannel(server, conn, addr, decode_data=True) + + def write_line(line): + channel.socket.queue_recv(line) + channel.handle_read() + write_line(b'EHLO example') + write_line(b'MAIL From:eggs@example') + self.assertRaises( + NotImplementedError, + write_line, + b'rcpt to: test@example.com') + def tearDown(self): asyncore.close_all() asyncore.socket = smtpd.socket = socket @@ -86,6 +118,7 @@ self.old_debugstream = smtpd.DEBUGSTREAM self.debug = smtpd.DEBUGSTREAM = io.StringIO() self.server = DummyServer((support.HOST, 0), ('b', 0)) + self.server.process_auth = lambda user, password: True conn, addr = self.server.accept() self.channel = smtpd.SMTPChannel(self.server, conn, addr, decode_data=True) @@ -180,6 +213,70 @@ self.assertEqual(self.channel.socket.last, b'501 Syntax: MAIL FROM:
\r\n') + def test_AUTH_requires_greeting(self): + self.server.enable_AUTH = True + self.write_line(b'AUTH PLAIN') + self.assertEqual(self.channel.socket.last.decode('ascii')[:4], '503 ') + self.write_line(b'EHLO beispiel') + self.write_line(b'AUTH PLAIN AGhhbGxvAGhhbGxv') + self.assertEqual(self.channel.socket.last.decode('ascii')[:4], '235 ') + + def test_AUTH_can_be_accepted_conditionally(self): + self.server.process_auth = lambda u, p: u == p + self.server.enable_AUTH = True + self.write_line(b'EHLO beispiel') + # base64 encoded '\0no\0access' + self.write_line(b'AUTH PLAIN AG5vAGFjY2Vzcw==') + self.assertEqual(self.channel.socket.last.decode('ascii')[:4], '535 ') + # base64 encoded '\0hello\0hello' + self.write_line(b'AUTH PLAIN AGhhbGxvAGhhbGxv') + self.assertEqual(self.channel.socket.last.decode('ascii')[:4], '235 ') + self.assertEqual(self.channel.authenticated_user, 'hallo') + self.assertEqual(self.channel.smtp_state, self.channel.COMMAND) + + def test_AUTH_with_more_then_one_message(self): + self.server.enable_AUTH = True + self.write_line(b'EHLO beispiel') + self.write_line(b'AUTH PLAIN') + self.assertEqual(self.channel.socket.last, b'334 \r\n') + self.assertEqual(self.channel.smtp_state, self.channel.AUTH) + self.write_line(b'AGhhbGxvAGhhbGxv') + self.assertEqual(self.channel.socket.last.decode('ascii')[:4], '235 ') + + def test_AUTH_LOGIN(self): + self.server.enable_AUTH = True + self.write_line(b'EHLO beispiel') + self.write_line(b'AUTH LOGIN') + self.assertEqual(self.channel.socket.last, b'334 VXNlcm5hbWU6\r\n') + self.write_line(b'aGFsbG8=') + self.assertEqual(self.channel.socket.last, b'334 UGFzc3dvcmQ6\r\n') + self.write_line(b'aGFsbG8=') + self.assertEqual(self.channel.socket.last.decode('ascii')[:4], '235 ') + + def test_AUTH_exceptions(self): + self.server.enable_AUTH = True + def verify(user, password): + raise smtpd.SMTPResponseException(404, 'Code not found') + self.server.process_auth = verify + self.write_line(b'EHLO beispiel') + self.write_line(b'AUTH PLAIN hallo') + self.assertEqual(self.channel.socket.last.decode('ascii')[:4], '501 ') + self.write_line(b'AUTH PAIN AGhhbGxvAGhhbGxv') + self.assertEqual(self.channel.socket.last.decode('ascii')[:4], '504 ') + self.write_line(b'AUTH PLAIN AGhhbGxvAGhhbGxv') + self.assertEqual(self.channel.socket.last.decode('ascii')[:4], '404 ') + + def test_multiple_AUTH_denied(self): + self.server.enable_AUTH = True + self.server.accept_recipiant = lambda u, f, t: True + self.write_line(b'EHLO beispiel') + self.write_line(b'AUTH PLAIN AGhhbGxvAGhhbGxv') + self.assertEqual(self.channel.socket.last.decode('ascii')[:4], '235 ') + self.assertEqual(self.channel.authenticated_user, 'hallo') + self.assertEqual(self.channel.smtp_state, self.channel.COMMAND) + self.write_line(b'AUTH PLAIN AGhhbGxvAGhhbGxv') + self.assertEqual(self.channel.socket.last.decode('ascii')[:4], '503 ') + def test_MAIL_allows_space_after_colon(self): self.write_line(b'HELO example') self.write_line(b'MAIL from: ') @@ -234,19 +331,38 @@ b'500 Error: line too long\r\n') def test_MAIL_command_limit_extended_with_SIZE(self): + fill_len = self.channel.command_size_limit - len('MAIL from:<@example>') self.write_line(b'EHLO example') - fill_len = self.channel.command_size_limit - len('MAIL from:<@example>') + self.write_line(b'MAIL from:<' + + b'a' * (fill_len + 26) + + b'@example> SIZE=1234') + self.assertEqual(self.channel.socket.last, + b'500 Error: line too long\r\n') + self.write_line(b'MAIL from:<' + b'a' * fill_len + b'@example> SIZE=1234') self.assertEqual(self.channel.socket.last, b'250 OK\r\n') - self.write_line(b'MAIL from:<' + - b'a' * (fill_len + 26) + - b'@example> SIZE=1234') + def test_MAIL_command_limit_extended_with_AUTH(self): + self.server.enable_AUTH = True + self.write_line(b'EHLO example') + prefix = b'mail from: <' + suffix = b'@example.com>' + + self.write_line( + prefix + + b'a' * (512 + 26 + 500 - len(prefix) - len(suffix) + 1) + + suffix) self.assertEqual(self.channel.socket.last, b'500 Error: line too long\r\n') + self.write_line( + prefix + + b'a' * (512 + 26 + 500 - len(prefix) - len(suffix)) + + suffix) + self.assertEqual(self.channel.socket.last, b'250 OK\r\n') + def test_data_longer_than_default_data_size_limit(self): # Hack the default so we don't have to generate so much data. self.channel.data_size_limit = 1048 @@ -548,6 +664,7 @@ self.old_debugstream = smtpd.DEBUGSTREAM self.debug = smtpd.DEBUGSTREAM = io.StringIO() self.server = DummyServer((support.HOSTv6, 0), ('b', 0)) + self.server.process_auth = lambda user, password: True conn, addr = self.server.accept() self.channel = smtpd.SMTPChannel(self.server, conn, addr, decode_data=True) diff -r c43362d35d8d Lib/test/test_smtplib.py --- a/Lib/test/test_smtplib.py Fri Jul 04 17:00:25 2014 -0700 +++ b/Lib/test/test_smtplib.py Wed Jul 16 20:23:15 2014 +0200 @@ -623,12 +623,18 @@ rcpt_count = 0 rset_count = 0 disconnect = 0 + user_dict = {} def __init__(self, extra_features, *args, **kw): self._extrafeatures = ''.join( [ "250-{0}\r\n".format(x) for x in extra_features ]) super(SimSMTPChannel, self).__init__(*args, **kw) + def _authenticate(self, user, password): + super(SimSMTPChannel, self)._authenticate(user, password) + if self.smtp_server.process_auth(user, password): + self.smtp_server.sim_auth = (user, password) + def smtp_EHLO(self, arg): resp = ('250-testhost\r\n' '250-EXPN\r\n' @@ -662,17 +668,10 @@ def smtp_AUTH(self, arg): mech = arg.strip().lower() - if mech=='cram-md5': + if mech == 'cram-md5': self.push('334 {}'.format(sim_cram_md5_challenge)) - elif mech not in sim_auth_credentials: - self.push('504 auth type unimplemented') - return - elif mech=='plain': - self.push('334 ') - elif mech=='login': - self.push('334 ') else: - self.push('550 No access for you!') + super(SimSMTPChannel, self).smtp_AUTH(arg) def smtp_QUIT(self, arg): if self.quit_response is None: @@ -726,6 +725,14 @@ def process_message(self, peer, mailfrom, rcpttos, data): pass + def process_auth(self, user, password): + if (user, password) == sim_auth: + return True + return False + + def accept_recipient(self, user, mailfrom, rcptto): + return True + def add_feature(self, feature): self._extra_features.append(feature) @@ -744,7 +751,8 @@ self.serv_evt = threading.Event() self.client_evt = threading.Event() # Pick a random unused port by passing 0 for the port number - self.serv = SimSMTPServer((HOST, 0), ('nowhere', -1), decode_data=True) + self.serv = SimSMTPServer( + (HOST, 0), ('nowhere', -1), decode_data=True, enable_AUTH=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) @@ -829,17 +837,15 @@ 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.login(sim_auth[0], sim_auth[1]) + self.assertEqual(self.serv.sim_auth, sim_auth) smtp.close() def testAUTH_LOGIN(self): self.serv.add_feature("AUTH LOGIN") 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_login_user, str(err)) + smtp.login(sim_auth[0], sim_auth[1]) + self.assertEqual(self.serv.sim_auth, sim_auth) smtp.close() def testAUTH_CRAM_MD5(self): @@ -855,7 +861,9 @@ # Test that multiple authentication methods are tried. self.serv.add_feature("AUTH BOGUS PLAIN LOGIN CRAM-MD5") 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]) + self.assertEqual(self.serv.sim_auth, sim_auth) except smtplib.SMTPAuthenticationError as err: self.assertIn(sim_auth_login_user, str(err)) smtp.close() @@ -872,6 +880,8 @@ for mechanism, method in supported.items(): try: smtp.auth(mechanism, method) except smtplib.SMTPAuthenticationError as err: + if err.smtp_code == 503: + break self.assertIn(sim_auth_credentials[mechanism.lower()].upper(), str(err)) smtp.close()