From c6897367b9e24e31dede1f7de47037f2fb7abde2 Mon Sep 17 00:00:00 2001 From: Thomas Steinacher Date: Wed, 30 Sep 2020 16:09:29 -0400 Subject: [PATCH] Use AuthAlligator client to obtain access token (#33) - Support to add/update AuthAlligator accounts via API. - Added back some Gmail auth code that we previously removed (we currently have a few accounts where auth fails retry forever), and also made it work for Microsoft accounts. - Propagate the token manager's `force_refresh` parameter to AuthAlligator's verify method. Example for adding an account: ``` curl -XPOST localhost:5555/accounts/ -d '{"type": "microsoft", "email_address":"the@email.com", "scopes": "the scopes", "authalligator": "{\"provider\": \"MICROSOFT\", \"accountKey\": \"abc123\", \"username\": \"00000000-0000-0000-xxxx-xxxxxxxxxxxx\"}", "client_id": "hello"}' ``` --- etc/config-dev.json | 4 +- etc/secrets-dev.yml | 1 + etc/secrets-test.yml | 1 + inbox/auth/oauth.py | 130 +++++++++++++++++++++++++++++++-- inbox/models/backends/oauth.py | 12 ++- requirements_frozen.txt | 1 + 6 files changed, 137 insertions(+), 12 deletions(-) diff --git a/etc/config-dev.json b/etc/config-dev.json index 6c26e9b3a..aeee75b83 100644 --- a/etc/config-dev.json +++ b/etc/config-dev.json @@ -82,6 +82,8 @@ "MAILGUN_API_KEY": null, "NOTIFICATIONS_MAILGUN_DOMAIN": null, -"NOTIFICATIONS_MAILGUN_API_KEY": null +"NOTIFICATIONS_MAILGUN_API_KEY": null, + +"AUTHALLIGATOR_SERVICE_URL": "https://authalligator-service" } diff --git a/etc/secrets-dev.yml b/etc/secrets-dev.yml index b89a7fda9..d31b35520 100644 --- a/etc/secrets-dev.yml +++ b/etc/secrets-dev.yml @@ -4,6 +4,7 @@ GOOGLE_OAUTH_CLIENT_SECRET: zgY9wgwML0kmQ6mmYHYJE05d MICROSOFT_OAUTH_CLIENT_ID: ms_oauth_client_id MICROSOFT_OAUTH_CLIENT_SECRET: ms_oauth_client_secret MICROSOFT_OAUTH_REDIRECT_URI: https://example.com/sandbox +AUTHALLIGATOR_AUTH_KEY: super-secret-authalligator-auth-key # Hexl-encoded static keys used to encrypt blocks in S3, secrets in database: BLOCK_ENCRYPTION_KEY: 43933ee4aff59913b7cd7204d87ee18cd5d0faea4df296cb7863f9f28525f7cd SECRET_ENCRYPTION_KEY: 5f2356f7e2dfc4ccc93458d27147f97b954a56cc0554273cb6fee070cbadd050 diff --git a/etc/secrets-test.yml b/etc/secrets-test.yml index 3c28e341b..5e806c2d6 100644 --- a/etc/secrets-test.yml +++ b/etc/secrets-test.yml @@ -4,6 +4,7 @@ GOOGLE_OAUTH_CLIENT_SECRET: zgY9wgwML0kmQ6mmYHYJE05d MICROSOFT_OAUTH_CLIENT_ID: ms_oauth_client_id MICROSOFT_OAUTH_CLIENT_SECRET: ms_oauth_client_secret MICROSOFT_OAUTH_REDIRECT_URI: https://example.com/sandbox +AUTHALLIGATOR_AUTH_KEY: super-secret-authalligator-auth-key # Hexl-encoded static keys used to encrypt blocks in S3, secrets in database: BLOCK_ENCRYPTION_KEY: 0ba4c7da83f474d2b33c8725416e444db632a1684705bc2fb7da5058e93668c9 diff --git a/inbox/auth/oauth.py b/inbox/auth/oauth.py index f99ed5bd6..1572821d0 100644 --- a/inbox/auth/oauth.py +++ b/inbox/auth/oauth.py @@ -1,11 +1,17 @@ +import datetime import json +import pytz import requests +from authalligator_client.client import Client as AuthAlligatorApiClient +from authalligator_client.enums import AccountErrorCode, ProviderType +from authalligator_client.exceptions import AccountError from imapclient import IMAPClient from nylas.logging import get_logger from six.moves import urllib -from inbox.basicauth import ConnectionError, OAuthError +from inbox.basicauth import ConnectionError, ImapSupportDisabledError, OAuthError +from inbox.config import config from inbox.models.backends.oauth import token_manager from inbox.models.secret import SecretType @@ -18,6 +24,9 @@ class OAuthAuthHandler(AuthHandler): # Defined by subclasses OAUTH_ACCESS_TOKEN_URL = None + AUTHALLIGATOR_AUTH_KEY = config.get("AUTHALLIGATOR_AUTH_KEY") + AUTHALLIGATOR_SERVICE_URL = config.get("AUTHALLIGATOR_SERVICE_URL") + def _new_access_token_from_refresh_token(self, account): refresh_token = account.refresh_token if not refresh_token: @@ -67,16 +76,75 @@ def _new_access_token_from_refresh_token(self, account): return session_dict["access_token"], session_dict["expires_in"] - def _access_token_from_authalligator(self, account): + def _new_access_token_from_authalligator(self, account, force_refresh): + """ + Return the access token based on an account created in AuthAlligator. + """ assert account.secret.type == SecretType.AuthAlligator.value - # aa_data = json.loads(account.secret.secret) - # TODO: get verified token - raise NotImplementedError("Not implemented yet.") + assert self.AUTHALLIGATOR_AUTH_KEY + assert self.AUTHALLIGATOR_SERVICE_URL + + aa_client = AuthAlligatorApiClient( + token=self.AUTHALLIGATOR_AUTH_KEY, + service_url=self.AUTHALLIGATOR_SERVICE_URL, + ) + aa_data = json.loads(account.secret.secret) + provider = ProviderType(aa_data["provider"]) + username = aa_data["username"] + account_key = aa_data["account_key"] + + try: + if force_refresh: + aa_response = aa_client.verify_account( + provider=provider, username=username, account_key=account_key, + ) + aa_account = aa_response.account + else: + aa_response = aa_client.query_account( + provider=provider, username=username, account_key=account_key, + ) + aa_account = aa_response + except AccountError as exc: + log.warn( + "AccountError during AuthAlligator account query", + account_id=account.id, + error_code=exc.code and exc.code.value, + error_message=exc.message, + retry_in=exc.retry_in, + ) + if exc.code in ( + AccountErrorCode.AUTHORIZATION_ERROR, + AccountErrorCode.CONFIGURATION_ERROR, + AccountErrorCode.DOES_NOT_EXIST, + ): + raise OAuthError("Could not obtain access token from AuthAlligator") + else: + raise ConnectionError( + "Temporary error while obtaining access token from AuthAlligator" + ) + else: + now = datetime.datetime.now(pytz.UTC) + expires_in = int((aa_account.access_token_expires_at - now).total_seconds()) + assert expires_in > 0 + return (aa_account.access_token, expires_in) + + def acquire_access_token(self, account, force_refresh=False): + """ + Acquire a new access token for the given account. - def acquire_access_token(self, account): + Args: + force_refresh (bool): Whether a token refresh should be forced when + requesting it from an external token service (AuthAlligator) + + Raises: + OAuthError: If the token is no longer valid and syncing should stop. + ConnectionError: If there was a temporary/connection error renewing + the auth token. + """ if account.secret.type == SecretType.AuthAlligator.value: - return self._new_access_token_from_authalligator(account) + return self._new_access_token_from_authalligator(account, force_refresh) elif account.secret.type == SecretType.Token.value: + # Any token requested from the refresh token is refreshed already. return self._new_access_token_from_refresh_token(account) else: raise OAuthError("No supported secret found.") @@ -86,10 +154,38 @@ def authenticate_imap_connection(self, account, conn): try: conn.oauth2_login(account.email_address, token) except IMAPClient.Error as exc: + exc = _process_imap_exception(exc) + + # Raise all IMAP disabled errors except authentication_failed + # error, which we handle differently. + if ( + isinstance(exc, ImapSupportDisabledError) + and exc.reason != "authentication_failed" + ): + raise exc + log.error( "Error during IMAP XOAUTH2 login", account_id=account.id, error=exc, ) - raise + if not isinstance(exc, ImapSupportDisabledError): + raise # Unknown IMAPClient error, reraise + + # If we got an AUTHENTICATIONFAILED response, force a token refresh + # and try again. If IMAP auth still fails, it's likely that IMAP + # access is disabled, so propagate that errror. + token = token_manager.get_token(account, force_refresh=True) + try: + conn.oauth2_login(account.email_address, token) + except IMAPClient.Error as exc: + exc = _process_imap_exception(exc) + if ( + not isinstance(exc, ImapSupportDisabledError) + or exc.reason != "authentication_failed" + ): + raise exc + else: + # Instead of authentication_failed, report imap disabled + raise ImapSupportDisabledError("imap_disabled_for_account") def _get_user_info(self, session_dict): access_token = session_dict["access_token"] @@ -150,3 +246,21 @@ def __init__(self, token): def __call__(self, r): r.headers["Authorization"] = "Bearer {}".format(self.token) return r + + +def _process_imap_exception(exc): + if "Lookup failed" in exc.message: + # Gmail is disabled for this apps account + return ImapSupportDisabledError("gmail_disabled_for_domain") + elif "IMAP access is disabled for your domain." in exc.message: + # IMAP is disabled for this domain + return ImapSupportDisabledError("imap_disabled_for_domain") + elif exc.message.startswith("[AUTHENTICATIONFAILED] Invalid credentials (Failure)"): + # Google + return ImapSupportDisabledError("authentication_failed") + elif exc.message.startswith("AUTHENTICATE failed."): + # Microsoft + return ImapSupportDisabledError("authentication_failed") + else: + # Unknown IMAPClient error + return exc diff --git a/inbox/models/backends/oauth.py b/inbox/models/backends/oauth.py index 50179d8d5..ae8b55dad 100644 --- a/inbox/models/backends/oauth.py +++ b/inbox/models/backends/oauth.py @@ -25,7 +25,7 @@ def get_token(self, account, force_refresh=False): if not force_refresh and expiration > datetime.utcnow(): return token - new_token, expires_in = account.new_token() + new_token, expires_in = account.new_token(force_refresh=force_refresh) self.cache_token(account, new_token, expires_in) return new_token @@ -91,10 +91,13 @@ def get_client_info(self): else: raise OAuthError("No valid tokens.") - def new_token(self): + def new_token(self, force_refresh=False): """ Retrieves a new access token. + Args: + force_refresh (bool): Whether a token refresh should be forced when + requesting it from an external token service (AuthAlligator) Returns: A tuple with the new access token and its expiration. @@ -102,10 +105,13 @@ def new_token(self): OAuthError: If no token could be obtained. """ try: - return self.auth_handler.acquire_access_token(self) + return self.auth_handler.acquire_access_token( + self, force_refresh=force_refresh + ) except Exception as e: log.error( "Error while getting access token: {}".format(e), + force_refresh=force_refresh, account_id=self.id, exc_info=True, ) diff --git a/requirements_frozen.txt b/requirements_frozen.txt index 873998150..723f09ef4 100644 --- a/requirements_frozen.txt +++ b/requirements_frozen.txt @@ -5,6 +5,7 @@ arrow==0.5.4 asn1crypto==0.24.0 astroid==1.6.1 attrs==20.2.0 +git+https://github.com/closeio/authalligator-client.git@58310ba34c639f4504dff8d9c64c0247d27100f8#egg=authalligator_client backports.functools-lru-cache==1.4 backports.ssl==0.0.9 boto==2.10.0