From e8f43b8412abb453a355300974cd5c1d42ef5caa Mon Sep 17 00:00:00 2001 From: Alex Golec Date: Mon, 18 Jan 2021 12:40:32 -0500 Subject: [PATCH] Add manual login flow (#139) * Added manual login flow * Ran make fix --- docs/auth.rst | 27 ++++++++++ tda/auth.py | 131 ++++++++++++++++++++++++++++++++++++--------- tests/test_auth.py | 131 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 262 insertions(+), 27 deletions(-) diff --git a/docs/auth.rst b/docs/auth.rst index a7a7aa5..06e563c 100644 --- a/docs/auth.rst +++ b/docs/auth.rst @@ -109,6 +109,14 @@ browsers/>`__. .. autofunction:: tda.auth.client_from_login_flow +.. _manual_login: + +If for some reason you cannot open a web browser, such as when running in a +cloud environment, the following function will guide you through the process of +manually creating a token by copy-pasting relevant URLs. + +.. autofunction:: tda.auth.client_from_manual_flow + Once you have a token written on disk, you can reuse it without going through the login flow again. @@ -239,3 +247,22 @@ you're confident is valid, please `file a ticket `__. Just remember, **never share your token file, not even with** ``tda-api`` **developers**. Sharing the token file is as dangerous as sharing your TD Ameritrade username and password. + + +++++++++++++++++++++++++++++++ +What If I Can't Use a Browser? +++++++++++++++++++++++++++++++ + +Unfortunately, there is no way to create a token without a browser: one way or +another, you have to open up the login URL to enter your username and password +on TDAmeritrade's website. (Some OAuth APIs support `alternate login flows +`__, but TDAmeritrade does not.) Still, there are +many situations in which the machine on which you're running cannot perform this +login flow, such as in a cloud or headless server setting, or when your browser +is somehow misconfigured. + +Fortunately, you don't have to run the login flow on the same machine as +``tda-api``. The library provides a helpful :ref:`manual login flow` which will guide +you through the process of performing the login. It requires a little more care +than the easy selenium-based flow, but it should help you log in if you're +stuck otherwise. diff --git a/tda/auth.py b/tda/auth.py index 09be6b8..4b58382 100644 --- a/tda/auth.py +++ b/tda/auth.py @@ -81,6 +81,40 @@ class RedirectTimeoutError(Exception): pass +def __fetch_and_register_token_from_redirect( + oauth, redirected_url, api_key, token_path, token_write_func, asyncio): + token = oauth.fetch_token( + 'https://api.tdameritrade.com/v1/oauth2/token', + authorization_response=redirected_url, + access_type='offline', + client_id=api_key, + include_client_id=True) + + # Don't emit token details in debug logs + __register_token_redactions(token) + + # Record the token + update_token = ( + __update_token(token_path) if token_write_func is None + else token_write_func) + update_token(token) + + if asyncio: + session_class = AsyncOAuth2Client + client_class = AsyncClient + else: + session_class = OAuth2Client + client_class = Client + + # Return a new session configured to refresh credentials + return client_class( + api_key, + session_class(api_key, token=token, + auto_refresh_url='https://api.tdameritrade.com/v1/oauth2/token', + auto_refresh_kwargs={'client_id': api_key}, + update_token=update_token)) + + def client_from_login_flow(webdriver, api_key, redirect_url, token_path, redirect_wait_time_seconds=0.1, max_waits=3000, asyncio=False, token_write_func=None): @@ -146,36 +180,81 @@ def client_from_login_flow(webdriver, api_key, redirect_url, token_path, time.sleep(redirect_wait_time_seconds) num_waits += 1 - token = oauth.fetch_token( - 'https://api.tdameritrade.com/v1/oauth2/token', - authorization_response=current_url, - access_type='offline', - client_id=api_key, - include_client_id=True) + return __fetch_and_register_token_from_redirect( + oauth, current_url, api_key, token_path, token_write_func, + asyncio) - # Don't emit token details in debug logs - __register_token_redactions(token) - # Record the token - update_token = ( - __update_token(token_path) if token_write_func is None - else token_write_func) - update_token(token) +def client_from_manual_flow(api_key, redirect_url, token_path, + asyncio=False, token_write_func=None): + ''' + Walks the user through performing an OAuth login flow by manually + copy-pasting URLs, and returns a client wrapped around the resulting token. + The client will be configured to refresh the token as necessary, writing + each updated version to ``token_path``. - if asyncio: - session_class = AsyncOAuth2Client - client_class = AsyncClient - else: - session_class = OAuth2Client - client_class = Client + Note this method is more complicated and error prone, and should be avoided + in favor of :func:`client_from_login_flow` wherever possible. - # Return a new session configured to refresh credentials - return client_class( - api_key, - session_class(api_key, token=token, - auto_refresh_url='https://api.tdameritrade.com/v1/oauth2/token', - auto_refresh_kwargs={'client_id': api_key}, - update_token=update_token)) + :param api_key: Your TD Ameritrade application's API key, also known as the + client ID. + :param redirect_url: Your TD Ameritrade application's redirect URL. Note + this must *exactly* match the value you've entered in + your application configuration, otherwise login will + fail with a security error. + :param token_path: Path to which the new token will be written. If the token + file already exists, it will be overwritten with a new + one. Updated tokens will be written to this path as well. + ''' + get_logger().info(('Creating new token with redirect URL \'{}\' ' + + 'and token path \'{}\'').format(redirect_url, token_path)) + + api_key = __normalize_api_key(api_key) + + oauth = OAuth2Client(api_key, redirect_uri=redirect_url) + authorization_url, state = oauth.create_authorization_url( + 'https://auth.tdameritrade.com/auth') + + print('\n**************************************************************\n') + print('This is the manual login and token creation flow for tda-api.') + print('Please follow these instructions exactly:') + print() + print(' 1. Open the following link by copy-pasting it into the browser') + print(' of your choice:') + print() + print(' ' + authorization_url) + print() + print(' 2. Log in with your account credentials. You may be asked to') + print(' perform two-factor authentication using text messaging or') + print(' another method, as well as whether to trust the browser.') + print() + print(' 3. When asked whether to allow your app access to your account,') + print(' select "Allow".') + print() + print(' 4. Your browser should be redirected to your redirect URI. Copy') + print(' the ENTIRE address, paste it into the following prompt, and press') + print(' Enter/Return.') + print() + print('If you encounter any issues, see here for troubleshooting:') + print('https://tda-api.readthedocs.io/en/stable/auth.html') + print('#troubleshooting') + print('\n**************************************************************\n') + + if redirect_url.startswith('http://'): + print(('WARNING: Your redirect URL ({}) will transmit data over HTTP, ' + + 'which is a potentially severe security vulnerability. ' + + 'Please go to your app\'s configuration with TDAmeritrade ' + + 'and update your redirect URL to begin with \'https\' ' + + 'to stop seeing this message.').format(redirect_url)) + + # Workaround for Mac OS freezing on reading input + import readline + + redirected_url = input('Redirect URL> ').strip() + + return __fetch_and_register_token_from_redirect( + oauth, redirected_url, api_key, token_path, token_write_func, + asyncio) def easy_client(api_key, redirect_uri, token_path, webdriver_func=None, diff --git a/tests/test_auth.py b/tests/test_auth.py index 8ed2e28..6ff2908 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -162,6 +162,14 @@ def test_success_no_write_func(self, session, client): REDIRECT_URL = 'https://redirect.url.com' +class AnyStringWith(str): + ''' + Utility for checking whether a function was called with the given string as + a substring. + ''' + def __eq__(self, other): + return self in other + class ClientFromLoginFlow(unittest.TestCase): def setUp(self): @@ -198,7 +206,8 @@ def test_no_token_file_https(self, session_constructor, client): @no_duplicates @patch('tda.auth.Client') @patch('tda.auth.OAuth2Client') - def test_no_token_file_http(self, session_constructor, client): + @patch('builtins.print') + def test_no_token_file_http(self, print_func, session_constructor, client): AUTH_URL = 'https://auth.url.com' redirect_url = 'http://redirect.url.com' @@ -222,6 +231,8 @@ def test_no_token_file_http(self, session_constructor, client): with open(self.json_path, 'r') as f: self.assertEqual(self.token, json.load(f)) + print_func.assert_any_call(AnyStringWith('will transmit data over HTTP')) + @no_duplicates @patch('tda.auth.Client') @patch('tda.auth.OAuth2Client') @@ -333,6 +344,124 @@ def dummy_token_write_func(*args, **kwargs): update_token=dummy_token_write_func) +class ClientFromManualFlow(unittest.TestCase): + + def setUp(self): + self.tmp_dir = tempfile.TemporaryDirectory() + self.json_path = os.path.join(self.tmp_dir.name, 'token.json') + self.token = {'token': 'yes'} + + + @no_duplicates + @patch('tda.auth.Client') + @patch('tda.auth.OAuth2Client') + @patch('tda.auth.input') + def test_no_token_file(self, input_func, session_constructor, client): + AUTH_URL = 'https://auth.url.com' + + session = MagicMock() + session_constructor.return_value = session + session.create_authorization_url.return_value = AUTH_URL, None + session.fetch_token.return_value = self.token + + client.return_value = 'returned client' + input_func.return_value = 'http://redirect.url.com/?data' + + self.assertEqual('returned client', + auth.client_from_manual_flow( + API_KEY, REDIRECT_URL, self.json_path)) + + with open(self.json_path, 'r') as f: + self.assertEqual(self.token, json.load(f)) + + @no_duplicates + @patch('tda.auth.Client') + @patch('tda.auth.OAuth2Client') + @patch('tda.auth.input') + def test_normalize_api_key(self, input_func, session_constructor, client): + AUTH_URL = 'https://auth.url.com' + + session = MagicMock() + session_constructor.return_value = session + session.create_authorization_url.return_value = AUTH_URL, None + session.fetch_token.return_value = self.token + + webdriver = MagicMock() + webdriver.current_url = REDIRECT_URL + '/token_params' + + client.return_value = 'returned client' + input_func.return_value = 'http://redirect.url.com/?data' + + self.assertEqual('returned client', + auth.client_from_manual_flow( + 'API_KEY', REDIRECT_URL, self.json_path)) + + self.assertEqual( + 'API_KEY@AMER.OAUTHAP', + session_constructor.call_args[0][0]) + + + @no_duplicates + @patch('tda.auth.Client') + @patch('tda.auth.OAuth2Client') + @patch('tda.auth.input') + def test_custom_token_write_func(self, input_func, session_constructor, client): + AUTH_URL = 'https://auth.url.com' + + session = MagicMock() + session_constructor.return_value = session + session.create_authorization_url.return_value = AUTH_URL, None + session.fetch_token.return_value = self.token + + webdriver = MagicMock() + webdriver.current_url = REDIRECT_URL + '/token_params' + + client.return_value = 'returned client' + input_func.return_value = 'http://redirect.url.com/?data' + + def dummy_token_write_func(*args, **kwargs): + pass + + self.assertEqual('returned client', + auth.client_from_manual_flow( + API_KEY, REDIRECT_URL, + self.json_path, + token_write_func=dummy_token_write_func)) + + session_constructor.assert_called_with( + _, token=_, auto_refresh_url=_, auto_refresh_kwargs=_, + update_token=dummy_token_write_func) + + + @no_duplicates + @patch('tda.auth.Client') + @patch('tda.auth.OAuth2Client') + @patch('tda.auth.input') + @patch('builtins.print') + def test_print_warning_on_http_redirect_uri( + self, print_func, input_func, session_constructor, client): + AUTH_URL = 'https://auth.url.com' + + redirect_url = 'http://redirect.url.com' + + session = MagicMock() + session_constructor.return_value = session + session.create_authorization_url.return_value = AUTH_URL, None + session.fetch_token.return_value = self.token + + client.return_value = 'returned client' + input_func.return_value = 'http://redirect.url.com/?data' + + self.assertEqual('returned client', + auth.client_from_manual_flow( + API_KEY, redirect_url, self.json_path)) + + with open(self.json_path, 'r') as f: + self.assertEqual(self.token, json.load(f)) + + print_func.assert_any_call(AnyStringWith('will transmit data over HTTP')) + + class EasyClientTest(unittest.TestCase): def setUp(self):