Skip to content

Commit c317456

Browse files
authored
Allow retrying local (but not remote) authentication failures (#230)
1 parent d253f64 commit c317456

File tree

1 file changed

+49
-15
lines changed

1 file changed

+49
-15
lines changed

emailproxy.py

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
__author__ = 'Simon Robinson'
77
__copyright__ = 'Copyright (c) 2024 Simon Robinson'
88
__license__ = 'Apache 2.0'
9-
__version__ = '2024-01-20' # ISO 8601 (YYYY-MM-DD)
9+
__version__ = '2024-02-15' # ISO 8601 (YYYY-MM-DD)
1010
__package_version__ = '.'.join([str(int(i)) for i in __version__.split('-')]) # for pyproject.toml usage only
1111

1212
import abc
@@ -1416,11 +1416,17 @@ class IMAPOAuth2ClientConnection(OAuth2ClientConnection):
14161416

14171417
def __init__(self, connection_socket, socket_map, proxy_parent, custom_configuration):
14181418
super().__init__('IMAP', connection_socket, socket_map, proxy_parent, custom_configuration)
1419+
(self.authentication_tag, self.authentication_command, self.awaiting_credentials,
1420+
self.login_literal_length_awaited, self.login_literal_username) = self.reset_login_state()
1421+
1422+
def reset_login_state(self):
14191423
self.authentication_tag = None
14201424
self.authentication_command = None
14211425
self.awaiting_credentials = False
14221426
self.login_literal_length_awaited = 0
14231427
self.login_literal_username = None
1428+
return (self.authentication_tag, self.authentication_command, self.awaiting_credentials,
1429+
self.login_literal_length_awaited, self.login_literal_username) # avoid defining outside init complaint
14241430

14251431
def process_data(self, byte_data, censor_server_log=False):
14261432
str_data = byte_data.decode('utf-8', 'replace').rstrip('\r\n')
@@ -1525,11 +1531,11 @@ def authenticate_connection(self, username, password, command='login'):
15251531
if self.server_connection:
15261532
self.server_connection.authenticated_username = username
15271533

1528-
else:
1529-
error_message = '%s NO %s %s\r\n' % (self.authentication_tag, command.upper(), result)
1534+
error_authentication_tag = self.authentication_tag
1535+
self.reset_login_state()
1536+
if not success:
1537+
error_message = '%s NO %s %s\r\n' % (error_authentication_tag, command.upper(), result)
15301538
self.send(error_message.encode('utf-8'))
1531-
self.send(b'* BYE Autologout; authentication failed\r\n')
1532-
self.close()
15331539

15341540

15351541
class POPOAuth2ClientConnection(OAuth2ClientConnection):
@@ -1605,8 +1611,10 @@ def send_authentication_request(self):
16051611
self.connection_state = self.STATE.XOAUTH2_AWAITING_CONFIRMATION
16061612
super().process_data(b'AUTH XOAUTH2\r\n')
16071613
else:
1614+
self.server_connection.username = None
1615+
self.server_connection.password = None
1616+
self.connection_state = self.STATE.PENDING
16081617
self.send(b'-ERR Authentication failed.\r\n')
1609-
self.close()
16101618

16111619

16121620
class SMTPOAuth2ClientConnection(OAuth2ClientConnection):
@@ -1690,8 +1698,10 @@ def send_authentication_request(self):
16901698
self.connection_state = self.STATE.XOAUTH2_AWAITING_CONFIRMATION
16911699
super().process_data(b'AUTH XOAUTH2\r\n')
16921700
else:
1701+
self.server_connection.username = None
1702+
self.server_connection.password = None
1703+
self.connection_state = self.STATE.PENDING
16931704
self.send(b'535 5.7.8 Authentication credentials invalid.\r\n')
1694-
self.close()
16951705

16961706

16971707
class OAuth2ServerConnection(SSLAsyncoreDispatcher):
@@ -1890,6 +1900,7 @@ def __init__(self, connection_socket, socket_map, proxy_parent, custom_configura
18901900
self.capa = []
18911901
self.username = None
18921902
self.password = None
1903+
self.auth_error_result = None
18931904

18941905
def process_data(self, byte_data):
18951906
# note: there is no reason why POP STARTTLS (https://tools.ietf.org/html/rfc2595) couldn't be supported here
@@ -1914,6 +1925,7 @@ def process_data(self, byte_data):
19141925
if capa_lower == 'user':
19151926
has_user = True
19161927
super().process_data(b'%s\r\n' % capa.encode('utf-8'))
1928+
self.capa = []
19171929

19181930
if not has_sasl:
19191931
super().process_data(b'SASL PLAIN\r\n')
@@ -1930,16 +1942,25 @@ def process_data(self, byte_data):
19301942
if str_data.startswith('+') and self.username and self.password: # '+ ' = 'please send credentials'
19311943
success, result = OAuth2Helper.get_oauth2_credentials(self.username, self.password)
19321944
if success:
1933-
self.client_connection.connection_state = POPOAuth2ClientConnection.STATE.XOAUTH2_CREDENTIALS_SENT
1945+
# because get_oauth2_credentials blocks, the client could have disconnected, and may no-longer exist
1946+
if self.client_connection:
1947+
self.client_connection.connection_state = (
1948+
POPOAuth2ClientConnection.STATE.XOAUTH2_CREDENTIALS_SENT)
19341949
self.send(b'%s\r\n' % OAuth2Helper.encode_oauth2_string(result), censor_log=True)
19351950
self.authenticated_username = self.username
19361951

19371952
self.username = None
19381953
self.password = None
19391954
if not success:
1940-
# a local authentication error occurred - send details to the client and exit
1941-
super().process_data(b'-ERR Authentication failed. %s\r\n' % result.encode('utf-8'))
1942-
self.close()
1955+
# a local authentication error occurred - cancel then (on confirmation) send details to the client
1956+
self.send(b'*\r\n') # RFC 5034, Section 4
1957+
self.auth_error_result = result
1958+
1959+
elif str_data.startswith('-ERR') and not self.username and not self.password:
1960+
self.client_connection.connection_state = POPOAuth2ClientConnection.STATE.PENDING
1961+
error_message = self.auth_error_result if self.auth_error_result else ''
1962+
self.auth_error_result = None
1963+
super().process_data(b'-ERR Authentication failed. %s\r\n' % error_message.encode('utf-8'))
19431964

19441965
else:
19451966
super().process_data(byte_data) # an error occurred - just send to the client and exit
@@ -1980,6 +2001,7 @@ def __init__(self, connection_socket, socket_map, proxy_parent, custom_configura
19802001

19812002
self.username = None
19822003
self.password = None
2004+
self.auth_error_result = None
19832005

19842006
def process_data(self, byte_data):
19852007
# SMTP setup/authentication involves a little more back-and-forth than IMAP/POP as the default is STARTTLS...
@@ -2017,6 +2039,7 @@ def process_data(self, byte_data):
20172039
're-sending greeting ]')
20182040
self.client_connection.connection_state = SMTPOAuth2ClientConnection.STATE.EHLO_AWAITING_RESPONSE
20192041
self.send(self.ehlo) # re-send original EHLO/HELO to server (includes domain, so can't just be generic)
2042+
self.ehlo = None
20202043
else:
20212044
super().process_data(byte_data) # an error occurred - just send to the client and exit
20222045
self.close()
@@ -2026,17 +2049,28 @@ def process_data(self, byte_data):
20262049
if str_data.startswith('334') and self.username and self.password: # '334 ' = 'please send credentials'
20272050
success, result = OAuth2Helper.get_oauth2_credentials(self.username, self.password)
20282051
if success:
2029-
self.client_connection.connection_state = SMTPOAuth2ClientConnection.STATE.XOAUTH2_CREDENTIALS_SENT
2052+
# because get_oauth2_credentials blocks, the client could have disconnected, and may no-longer exist
2053+
if self.client_connection:
2054+
self.client_connection.connection_state = (
2055+
SMTPOAuth2ClientConnection.STATE.XOAUTH2_CREDENTIALS_SENT)
20302056
self.authenticated_username = self.username
20312057
self.send(b'%s\r\n' % OAuth2Helper.encode_oauth2_string(result), censor_log=True)
20322058

20332059
self.username = None
20342060
self.password = None
20352061
if not success:
2036-
# a local authentication error occurred - send details to the client and exit
2062+
# a local authentication error occurred - cancel then (on confirmation) send details to the client
2063+
self.send(b'*\r\n') # RFC 4954, Section 4
2064+
self.auth_error_result = result
2065+
2066+
# note that RFC 4954 says that the server must respond with '501', but some (e.g., Office 365) return '535'
2067+
elif str_data.startswith('5') and not self.username and not self.password:
2068+
if len(str_data) >= 4 and str_data[3] == ' ': # responses may be multiline - wait for last part
2069+
self.client_connection.connection_state = SMTPOAuth2ClientConnection.STATE.PENDING
2070+
error_message = self.auth_error_result if self.auth_error_result else ''
2071+
self.auth_error_result = None
20372072
super().process_data(
2038-
b'535 5.7.8 Authentication credentials invalid. %s\r\n' % result.encode('utf-8'))
2039-
self.close()
2073+
b'535 5.7.8 Authentication credentials invalid. %s\r\n' % error_message.encode('utf-8'))
20402074

20412075
else:
20422076
super().process_data(byte_data) # an error occurred - just send to the client and exit

0 commit comments

Comments
 (0)