11import asyncore
2+ import base64
23import email .mime .text
34from email .message import EmailMessage
45from email .base64mime import body_encode as encode_base64
56import email .utils
7+ import hmac
68import socket
79import smtpd
810import smtplib
@@ -623,20 +625,12 @@ def testLineTooLong(self):
623625sim_auth = (
'[email protected] ' ,
'somepassword' )
624626sim_cram_md5_challenge = ('PENCeUxFREJoU0NnbmhNWitOMjNGNn'
625627 'dAZWx3b29kLmlubm9zb2Z0LmNvbT4=' )
626- sim_auth_credentials = {
627- 'login' : 'TXIuQUBzb21ld2hlcmUuY29t' ,
628- 'plain' : 'AE1yLkFAc29tZXdoZXJlLmNvbQBzb21lcGFzc3dvcmQ=' ,
629- 'cram-md5' : ('TXIUQUBZB21LD2HLCMUUY29TIDG4OWQ0MJ'
630- 'KWZGQ4ODNMNDA4NTGXMDRLZWMYZJDMODG1' ),
631- }
632- sim_auth_login_user = 'TXIUQUBZB21LD2HLCMUUY29T'
633- sim_auth_plain = 'AE1YLKFAC29TZXDOZXJLLMNVBQBZB21LCGFZC3DVCMQ='
634-
635628636629637630 }
638631
639632# Simulated SMTP channel & server
633+ class ResponseException (Exception ): pass
640634class SimSMTPChannel (smtpd .SMTPChannel ):
641635
642636 quit_response = None
@@ -646,12 +640,109 @@ class SimSMTPChannel(smtpd.SMTPChannel):
646640 rcpt_count = 0
647641 rset_count = 0
648642 disconnect = 0
643+ AUTH = 99 # Add protocol state to enable auth testing.
644+ authenticated_user = None
649645
650646 def __init__ (self , extra_features , * args , ** kw ):
651647 self ._extrafeatures = '' .join (
652648 [ "250-{0}\r \n " .format (x ) for x in extra_features ])
653649 super (SimSMTPChannel , self ).__init__ (* args , ** kw )
654650
651+ # AUTH related stuff. It would be nice if support for this were in smtpd.
652+ def found_terminator (self ):
653+ if self .smtp_state == self .AUTH :
654+ line = self ._emptystring .join (self .received_lines )
655+ print ('Data:' , repr (line ), file = smtpd .DEBUGSTREAM )
656+ self .received_lines = []
657+ try :
658+ self .auth_object (line )
659+ except ResponseException as e :
660+ self .smtp_state = self .COMMAND
661+ self .push ('%s %s' % (e .smtp_code , e .smtp_error ))
662+ return
663+ super ().found_terminator ()
664+
665+
666+ def smtp_AUTH (self , arg ):
667+ if not self .seen_greeting :
668+ self .push ('503 Error: send EHLO first' )
669+ return
670+ if not self .extended_smtp or 'AUTH' not in self ._extrafeatures :
671+ self .push ('500 Error: command "AUTH" not recognized' )
672+ return
673+ if self .authenticated_user is not None :
674+ self .push (
675+ '503 Bad sequence of commands: already authenticated' )
676+ return
677+ args = arg .split ()
678+ if len (args ) not in [1 , 2 ]:
679+ self .push ('501 Syntax: AUTH <mechanism> [initial-response]' )
680+ return
681+ auth_object_name = '_auth_%s' % args [0 ].lower ().replace ('-' , '_' )
682+ try :
683+ self .auth_object = getattr (self , auth_object_name )
684+ except AttributeError :
685+ self .push ('504 Command parameter not implemented: unsupported '
686+ ' authentication mechanism {!r}' .format (auth_object_name ))
687+ return
688+ self .smtp_state = self .AUTH
689+ self .auth_object (args [1 ] if len (args ) == 2 else None )
690+
691+ def _authenticated (self , user , valid ):
692+ if valid :
693+ self .authenticated_user = user
694+ self .push ('235 Authentication Succeeded' )
695+ else :
696+ self .push ('535 Authentication credentials invalid' )
697+ self .smtp_state = self .COMMAND
698+
699+ def _decode_base64 (self , string ):
700+ return base64 .decodebytes (string .encode ('ascii' )).decode ('utf-8' )
701+
702+ def _auth_plain (self , arg = None ):
703+ if arg is None :
704+ self .push ('334 ' )
705+ else :
706+ logpass = self ._decode_base64 (arg )
707+ try :
708+ * _ , user , password = logpass .split ('\0 ' )
709+ except ValueError as e :
710+ self .push ('535 Splitting response {!r} into user and password'
711+ ' failed: {}' .format (logpass , e ))
712+ return
713+ self ._authenticated (user , password == sim_auth [1 ])
714+
715+ def _auth_login (self , arg = None ):
716+ if arg is None :
717+ # base64 encoded 'Username:'
718+ self .push ('334 VXNlcm5hbWU6' )
719+ elif not hasattr (self , '_auth_login_user' ):
720+ self ._auth_login_user = self ._decode_base64 (arg )
721+ # base64 encoded 'Password:'
722+ self .push ('334 UGFzc3dvcmQ6' )
723+ else :
724+ password = self ._decode_base64 (arg )
725+ self ._authenticated (self ._auth_login_user , password == sim_auth [1 ])
726+ del self ._auth_login_user
727+
728+ def _auth_cram_md5 (self , arg = None ):
729+ if arg is None :
730+ self .push ('334 {}' .format (sim_cram_md5_challenge ))
731+ else :
732+ logpass = self ._decode_base64 (arg )
733+ try :
734+ user , hashed_pass = logpass .split ()
735+ except ValueError as e :
736+ self .push ('535 Splitting response {!r} into user and password'
737+ 'failed: {}' .format (logpass , e ))
738+ return False
739+ valid_hashed_pass = hmac .HMAC (
740+ sim_auth [1 ].encode ('ascii' ),
741+ self ._decode_base64 (sim_cram_md5_challenge ).encode ('ascii' ),
742+ 'md5' ).hexdigest ()
743+ self ._authenticated (user , hashed_pass == valid_hashed_pass )
744+ # end AUTH related stuff.
745+
655746 def smtp_EHLO (self , arg ):
656747 resp = ('250-testhost\r \n '
657748 '250-EXPN\r \n '
@@ -683,20 +774,6 @@ def smtp_EXPN(self, arg):
683774 else :
684775 self .push ('550 No access for you!' )
685776
686- def smtp_AUTH (self , arg ):
687- mech = arg .strip ().lower ()
688- if mech == 'cram-md5' :
689- self .push ('334 {}' .format (sim_cram_md5_challenge ))
690- elif mech not in sim_auth_credentials :
691- self .push ('504 auth type unimplemented' )
692- return
693- elif mech == 'plain' :
694- self .push ('334 ' )
695- elif mech == 'login' :
696- self .push ('334 ' )
697- else :
698- self .push ('550 No access for you!' )
699-
700777 def smtp_QUIT (self , arg ):
701778 if self .quit_response is None :
702779 super (SimSMTPChannel , self ).smtp_QUIT (arg )
@@ -841,63 +918,49 @@ def testEXPN(self):
841918 self .assertEqual (smtp .expn (u ), expected_unknown )
842919 smtp .quit ()
843920
844- # SimSMTPChannel doesn't fully support AUTH because it requires a
845- # synchronous read to obtain the credentials...so instead smtpd
846- # sees the credential sent by smtplib's login method as an unknown command,
847- # which results in smtplib raising an auth error. Fortunately the error
848- # message contains the encoded credential, so we can partially check that it
849- # was generated correctly (partially, because the 'word' is uppercased in
850- # the error message).
851-
852921 def testAUTH_PLAIN (self ):
853922 self .serv .add_feature ("AUTH PLAIN" )
854923 smtp = smtplib .SMTP (HOST , self .port , local_hostname = 'localhost' , timeout = 15 )
855- try : smtp .login (sim_auth [0 ], sim_auth [1 ], initial_response_ok = False )
856- except smtplib .SMTPAuthenticationError as err :
857- self .assertIn (sim_auth_plain , str (err ))
924+ resp = smtp .login (sim_auth [0 ], sim_auth [1 ])
925+ self .assertEqual (resp , (235 , b'Authentication Succeeded' ))
858926 smtp .close ()
859927
860928 def testAUTH_LOGIN (self ):
861929 self .serv .add_feature ("AUTH LOGIN" )
862930 smtp = smtplib .SMTP (HOST , self .port , local_hostname = 'localhost' , timeout = 15 )
863- try : smtp .login (sim_auth [0 ], sim_auth [1 ])
864- except smtplib .SMTPAuthenticationError as err :
865- self .assertIn (sim_auth_login_user , str (err ))
931+ resp = smtp .login (sim_auth [0 ], sim_auth [1 ])
932+ self .assertEqual (resp , (235 , b'Authentication Succeeded' ))
866933 smtp .close ()
867934
868935 def testAUTH_CRAM_MD5 (self ):
869936 self .serv .add_feature ("AUTH CRAM-MD5" )
870937 smtp = smtplib .SMTP (HOST , self .port , local_hostname = 'localhost' , timeout = 15 )
871-
872- try : smtp .login (sim_auth [0 ], sim_auth [1 ])
873- except smtplib .SMTPAuthenticationError as err :
874- self .assertIn (sim_auth_credentials ['cram-md5' ], str (err ))
938+ resp = smtp .login (sim_auth [0 ], sim_auth [1 ])
939+ self .assertEqual (resp , (235 , b'Authentication Succeeded' ))
875940 smtp .close ()
876941
877942 def testAUTH_multiple (self ):
878943 # Test that multiple authentication methods are tried.
879944 self .serv .add_feature ("AUTH BOGUS PLAIN LOGIN CRAM-MD5" )
880945 smtp = smtplib .SMTP (HOST , self .port , local_hostname = 'localhost' , timeout = 15 )
881- try : smtp .login (sim_auth [0 ], sim_auth [1 ])
882- except smtplib .SMTPAuthenticationError as err :
883- self .assertIn (sim_auth_login_user , str (err ))
946+ resp = smtp .login (sim_auth [0 ], sim_auth [1 ])
947+ self .assertEqual (resp , (235 , b'Authentication Succeeded' ))
884948 smtp .close ()
885949
886950 def test_auth_function (self ):
887- smtp = smtplib .SMTP (HOST , self .port ,
888- local_hostname = 'localhost' , timeout = 15 )
889- self .serv .add_feature ("AUTH CRAM-MD5" )
890- smtp .user , smtp .password = sim_auth [0 ], sim_auth [1 ]
891- supported = {'CRAM-MD5' : smtp .auth_cram_md5 ,
892- 'PLAIN' : smtp .auth_plain ,
893- 'LOGIN' : smtp .auth_login ,
894- }
895- for mechanism , method in supported .items ():
896- try : smtp .auth (mechanism , method , initial_response_ok = False )
897- except smtplib .SMTPAuthenticationError as err :
898- self .assertIn (sim_auth_credentials [mechanism .lower ()].upper (),
899- str (err ))
900- smtp .close ()
951+ supported = {'CRAM-MD5' , 'PLAIN' , 'LOGIN' }
952+ for mechanism in supported :
953+ self .serv .add_feature ("AUTH {}" .format (mechanism ))
954+ for mechanism in supported :
955+ with self .subTest (mechanism = mechanism ):
956+ smtp = smtplib .SMTP (HOST , self .port ,
957+ local_hostname = 'localhost' , timeout = 15 )
958+ smtp .ehlo ('foo' )
959+ smtp .user , smtp .password = sim_auth [0 ], sim_auth [1 ]
960+ method = 'auth_' + mechanism .lower ().replace ('-' , '_' )
961+ resp = smtp .auth (mechanism , getattr (smtp , method ))
962+ self .assertEqual (resp , (235 , b'Authentication Succeeded' ))
963+ smtp .close ()
901964
902965 def test_quit_resets_greeting (self ):
903966 smtp = smtplib .SMTP (HOST , self .port ,
0 commit comments