diff --git a/emailproxy.py b/emailproxy.py index c14bec5..270f357 100644 --- a/emailproxy.py +++ b/emailproxy.py @@ -6,7 +6,7 @@ __author__ = 'Simon Robinson' __copyright__ = 'Copyright (c) 2023 Simon Robinson' __license__ = 'Apache 2.0' -__version__ = '2023-07-12' # ISO 8601 (YYYY-MM-DD) +__version__ = '2023-07-13' # ISO 8601 (YYYY-MM-DD) import abc import argparse @@ -539,8 +539,11 @@ def _save_cache(cache_store_identifier, output_config_parser): class OAuth2Helper: + class TokenRefreshError(Exception): + pass + @staticmethod - def get_oauth2_credentials(username, password, recurse_retries=True): + def get_oauth2_credentials(username, password, reload_remote_accounts=True): """Using the given username (i.e., email address) and password, reads account details from AppConfig and handles OAuth 2.0 token request and renewal, saving the updated details back to AppConfig (or removing them if invalid). Returns either (True, '[OAuth2 string for authentication]') or (False, '[Error message]')""" @@ -602,9 +605,9 @@ def get_account_with_catch_all_fallback(option): refresh_token = config.get(username, 'refresh_token', fallback=None) # try reloading remotely cached tokens if possible - if not access_token and CACHE_STORE != CONFIG_FILE_PATH and recurse_retries: + if not access_token and CACHE_STORE != CONFIG_FILE_PATH and reload_remote_accounts: AppConfig.reload() - return OAuth2Helper.get_oauth2_credentials(username, password, recurse_retries=False) + return OAuth2Helper.get_oauth2_credentials(username, password, reload_remote_accounts=False) # we hash locally-stored tokens with the given password if not token_salt: @@ -697,35 +700,34 @@ def get_account_with_catch_all_fallback(option): oauth2_string = OAuth2Helper.construct_oauth2_string(username, access_token) return True, oauth2_string - except InvalidToken as e: - # we always remove the access token - we can easily request another using the refresh token + except OAuth2Helper.TokenRefreshError as e: + # always clear access tokens - can easily request another via the refresh token (with no user interaction) has_access_token = True if config.get(username, 'access_token', fallback=None) else False config.remove_option(username, 'access_token') config.remove_option(username, 'access_token_expiry') - if has_access_token: - AppConfig.save() - # if invalid details are the reason for failure we remove our cached version and re-authenticate - this can - # be disabled by a configuration setting, but note that we always remove credentials on 400 Bad Request - if e.args == (400, APP_PACKAGE) or AppConfig.globals().getboolean('delete_account_token_on_password_error', - fallback=True): - # try authentication again with no cached details - note that if we have just removed an invalid access - # token this will trigger an unnecessary reload from the cache store, but it is worth doing this to - # avoid an unnecessary re-authentication request - recurse_retries = True - - # if this is already a second attempt, remove the refresh token as well, and force re-authentication - if not has_access_token: - config.remove_option(username, 'token_salt') - config.remove_option(username, 'refresh_token') - AppConfig.save() - else: - recurse_retries = has_access_token # no need to recurse if we are trying the same credentials again + if not has_access_token: + # if this is already a second failure, remove the refresh token as well, and force re-authentication + config.remove_option(username, 'token_salt') + config.remove_option(username, 'refresh_token') + + AppConfig.save() + + Log.info('Retrying login due to exception while refreshing OAuth 2.0 tokens for', username, + '(attempt %d):' % (1 if has_access_token else 2), Log.error_string(e)) + return OAuth2Helper.get_oauth2_credentials(username, password, reload_remote_accounts=False) + + except InvalidToken as e: + if AppConfig.globals().getboolean('delete_account_token_on_password_error', fallback=True): + config.remove_option(username, 'access_token') + config.remove_option(username, 'access_token_expiry') + config.remove_option(username, 'token_salt') + config.remove_option(username, 'refresh_token') + AppConfig.save() - if recurse_retries: - Log.info('Retrying login due to exception while requesting OAuth 2.0 credentials for %s:' % username, - Log.error_string(e)) - return OAuth2Helper.get_oauth2_credentials(username, password, recurse_retries=has_access_token) + Log.info('Retrying login due to exception while decrypting OAuth 2.0 credentials for', username, + '(invalid password):', Log.error_string(e)) + return OAuth2Helper.get_oauth2_credentials(username, password, reload_remote_accounts=False) Log.error('Invalid password to decrypt', username, 'credentials - aborting login:', Log.error_string(e)) return False, '%s: Login failed - the password for account %s is incorrect' % (APP_NAME, username) @@ -930,8 +932,8 @@ def refresh_oauth2_access_token(token_url, client_id, client_secret, refresh_tok except urllib.error.HTTPError as e: e.message = json.loads(e.read()) Log.debug('Error refreshing access token - received invalid response:', e.message) - if e.code == 400: # 400 Bad Request typically means re-authentication is required (refresh token expired) - raise InvalidToken(e.code, APP_PACKAGE) from e + if e.code == 400: # 400 Bad Request typically means re-authentication is required (token expired) + raise OAuth2Helper.TokenRefreshError from e raise e @staticmethod