From dc8fb08226c7ecad6a474b01b6e7fb3afef999cf Mon Sep 17 00:00:00 2001 From: DiablosOffens Date: Fri, 24 Jan 2025 23:45:42 +0100 Subject: [PATCH 1/2] Use SSLContext at all places where SSL connection could be opened Without it connection will fail, at least for url https://login.microsoftonline.com/common/oauth2/v2.0/token and with Python 3.13.1 on Windows. The following error was logged: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:1018) --- emailproxy.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/emailproxy.py b/emailproxy.py index a61843d..5d7a4df 100644 --- a/emailproxy.py +++ b/emailproxy.py @@ -1060,9 +1060,11 @@ def construct_oauth2_permission_url(permission_url, redirect_uri, client_id, sco def start_device_authorisation_grant(permission_url): """Requests the device authorisation grant flow URI and user code - see https://tools.ietf.org/html/rfc8628""" try: + ssl_context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH) + ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2 # GitHub CodeQL issue 2 response = urllib.request.urlopen( urllib.request.Request(permission_url, headers={'User-Agent': APP_NAME}), - timeout=AUTHENTICATION_TIMEOUT).read() + timeout=AUTHENTICATION_TIMEOUT, context=ssl_context).read() parsed_result = json.loads(response) verification_uri = parsed_result.get('verification_uri_complete', parsed_result['verification_uri']) user_code = parsed_result['user_code'] @@ -1187,10 +1189,14 @@ def get_oauth2_authorisation_tokens(token_url, redirect_uri, client_id, client_s expires_at = time.time() + expires_in while time.time() < expires_at and not EXITING: try: + ssl_context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH) + ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2 # GitHub CodeQL issue 2 + # in all flows except DAG, we make one attempt only response = urllib.request.urlopen( urllib.request.Request(token_url, data=urllib.parse.urlencode(params).encode('utf-8'), - headers={'User-Agent': APP_NAME}), timeout=AUTHENTICATION_TIMEOUT).read() + headers={'User-Agent': APP_NAME}), timeout=AUTHENTICATION_TIMEOUT, + context=ssl_context).read() return json.loads(response) except urllib.error.HTTPError as e: @@ -1262,9 +1268,12 @@ def refresh_oauth2_access_token(token_url, client_id, client_secret, jwt_client_ params['client_assertion'] = jwt_client_assertion try: + ssl_context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH) + ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2 # GitHub CodeQL issue 2 response = urllib.request.urlopen( urllib.request.Request(token_url, data=urllib.parse.urlencode(params).encode('utf-8'), - headers={'User-Agent': APP_NAME}), timeout=AUTHENTICATION_TIMEOUT).read() + headers={'User-Agent': APP_NAME}), timeout=AUTHENTICATION_TIMEOUT, + context=ssl_context).read() token = json.loads(response) if 'expires_in' in token: # some servers return integer values as strings - fix expiry values (GitHub #237) token['expires_in'] = int(token['expires_in']) From dd44ecacde391468adba193935905f8e9506636d Mon Sep 17 00:00:00 2001 From: DiablosOffens Date: Sat, 25 Jan 2025 00:02:20 +0100 Subject: [PATCH 2/2] Let IMAP authentication immediately fail when no password was given Some clients (old Outlook) try IMAP authentication on first connection without a password, when it was not saved in the client beforehand. So to support scenarios where the client tries to login with IMAP on first connection to email server and OAuth 2.0 authorisation didn't happen yet, the authorisation will be delayed until the correct password is entered in the client. As this password will be used for token encryption/decryption in config file, other protocols, like SMTP, which also needs a password to work, can now use the same password to decrypt the authorisation token. --- emailproxy.py | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/emailproxy.py b/emailproxy.py index 5d7a4df..0298650 100644 --- a/emailproxy.py +++ b/emailproxy.py @@ -1687,22 +1687,32 @@ def process_data(self, byte_data, censor_server_log=False): super().process_data(byte_data) def authenticate_connection(self, username, password, command='login'): - success, result = OAuth2Helper.get_oauth2_credentials(username, password) - if success: - # send authentication command to server (response checked in ServerConnection) - # note: we only support single-trip authentication (SASL) without checking server capabilities - improve? - super().process_data(b'%s AUTHENTICATE XOAUTH2 ' % self.authentication_tag.encode('utf-8')) - super().process_data(b'%s\r\n' % OAuth2Helper.encode_oauth2_string(result), censor_server_log=True) - - # because get_oauth2_credentials blocks, the server could have disconnected, and may no-longer exist - if self.server_connection: - self.server_connection.authenticated_username = username - - self.reset_login_state() - if not success: + if not password: + # we can't actually check credentials here, but if password is missing, it's possible that + # the client want to ask for credentials and so tried without password first, in the hope + # that the server will handle it, which it won't for XOAUTH2 - intercept that here and mimic old behavior + self.reset_login_state() + result = '%s: Login failed - the password for account %s is incorrect' % (APP_NAME, username) error_message = '%s NO %s %s\r\n' % (self.authentication_tag, command.upper(), result) self.authentication_tag = None self.send(error_message.encode('utf-8')) + else: + success, result = OAuth2Helper.get_oauth2_credentials(username, password) + if success: + # send authentication command to server (response checked in ServerConnection) + # note: we only support single-trip authentication (SASL) without checking server capabilities - improve? + super().process_data(b'%s AUTHENTICATE XOAUTH2 ' % self.authentication_tag.encode('utf-8')) + super().process_data(b'%s\r\n' % OAuth2Helper.encode_oauth2_string(result), censor_server_log=True) + + # because get_oauth2_credentials blocks, the server could have disconnected, and may no-longer exist + if self.server_connection: + self.server_connection.authenticated_username = username + + self.reset_login_state() + if not success: + error_message = '%s NO %s %s\r\n' % (self.authentication_tag, command.upper(), result) + self.authentication_tag = None + self.send(error_message.encode('utf-8')) class POPOAuth2ClientConnection(OAuth2ClientConnection):