From 4d9e9cdbbd927fae6080edf28cc4c529d8f4fd2f Mon Sep 17 00:00:00 2001 From: Yura Date: Fri, 24 Apr 2015 13:37:40 -0700 Subject: [PATCH] add user auth flow [ADD] DCOS-831 Add login feature --- cli/dcoscli/main.py | 7 +- cli/setup.py | 3 +- .../data/analytics/dcos_no_reporting.toml | 1 + cli/tests/data/analytics/dcos_reporting.toml | 1 + .../data/auth/dcos_with_credentials.toml | 3 + .../data/auth/dcos_without_credentials.toml | 2 + cli/tests/data/dcos.toml | 13 +- cli/tests/integrations/cli/test_auth.py | 52 ++++++ cli/tests/integrations/cli/test_config.py | 3 +- dcos/api/auth.py | 152 ++++++++++++++++++ 10 files changed, 228 insertions(+), 9 deletions(-) create mode 100644 cli/tests/data/auth/dcos_with_credentials.toml create mode 100644 cli/tests/data/auth/dcos_without_credentials.toml create mode 100644 cli/tests/integrations/cli/test_auth.py create mode 100644 dcos/api/auth.py diff --git a/cli/dcoscli/main.py b/cli/dcoscli/main.py index 22cae7a..1bf2774 100644 --- a/cli/dcoscli/main.py +++ b/cli/dcoscli/main.py @@ -33,7 +33,7 @@ from subprocess import PIPE, Popen import dcoscli import docopt -from dcos.api import constants, emitting, errors, http, subcommand, util +from dcos.api import auth, constants, emitting, errors, http, subcommand, util from dcoscli import analytics emitter = emitting.FlatEmitter() @@ -45,6 +45,11 @@ def main(): if not _is_valid_configuration(): return 1 + if not auth.check_if_user_authenticated(): + auth_status = auth.force_auth() + if not auth_status: + return 1 + args = docopt.docopt( __doc__, version='dcos version {}'.format(dcoscli.version), diff --git a/cli/setup.py b/cli/setup.py index 536881a..8564c03 100644 --- a/cli/setup.py +++ b/cli/setup.py @@ -68,7 +68,8 @@ setup( 'toml', 'virtualenv', 'rollbar', - 'futures' + 'futures', + 'oauth2client' ], # If there are data files included in your packages that need to be diff --git a/cli/tests/data/analytics/dcos_no_reporting.toml b/cli/tests/data/analytics/dcos_no_reporting.toml index affd359..a10b2c7 100644 --- a/cli/tests/data/analytics/dcos_no_reporting.toml +++ b/cli/tests/data/analytics/dcos_no_reporting.toml @@ -1,2 +1,3 @@ [core] reporting = false +email = "test@mail.com" diff --git a/cli/tests/data/analytics/dcos_reporting.toml b/cli/tests/data/analytics/dcos_reporting.toml index c565116..590a344 100644 --- a/cli/tests/data/analytics/dcos_reporting.toml +++ b/cli/tests/data/analytics/dcos_reporting.toml @@ -1,2 +1,3 @@ [core] reporting = true +email = "test@mail.com" diff --git a/cli/tests/data/auth/dcos_with_credentials.toml b/cli/tests/data/auth/dcos_with_credentials.toml new file mode 100644 index 0000000..a10b2c7 --- /dev/null +++ b/cli/tests/data/auth/dcos_with_credentials.toml @@ -0,0 +1,3 @@ +[core] +reporting = false +email = "test@mail.com" diff --git a/cli/tests/data/auth/dcos_without_credentials.toml b/cli/tests/data/auth/dcos_without_credentials.toml new file mode 100644 index 0000000..affd359 --- /dev/null +++ b/cli/tests/data/auth/dcos_without_credentials.toml @@ -0,0 +1,2 @@ +[core] +reporting = false diff --git a/cli/tests/data/dcos.toml b/cli/tests/data/dcos.toml index 863aaf3..9d1d9d3 100644 --- a/cli/tests/data/dcos.toml +++ b/cli/tests/data/dcos.toml @@ -1,10 +1,11 @@ [subcommand] pip_find_links = "../dist" -[marathon] -port = 8080 -host = "localhost" -[package] -cache = "tmp/cache" -sources = [ "git://github.com/mesosphere/universe.git", "https://github.com/mesosphere/universe/archive/master.zip",] [core] +email = "test@mail.com" reporting = false +[marathon] +host = "localhost" +port = 8080 +[package] +sources = [ "git://github.com/mesosphere/universe.git", "https://github.com/mesosphere/universe/archive/master.zip",] +cache = "tmp/cache" diff --git a/cli/tests/integrations/cli/test_auth.py b/cli/tests/integrations/cli/test_auth.py new file mode 100644 index 0000000..736520f --- /dev/null +++ b/cli/tests/integrations/cli/test_auth.py @@ -0,0 +1,52 @@ +import os +import webbrowser + +from dcos.api import auth, constants, util +from dcoscli.main import main + +from mock import Mock, patch + + +def test_no_browser_auth(): + + webbrowser.get = Mock(side_effect=webbrowser.Error()) + with patch('webbrowser.open') as op: + _mock_dcos_run([util.which('dcos')], False) + assert op.call_count == 0 + + +def test_when_authenticated(): + with patch('dcos.api.auth.force_auth'): + + _mock_dcos_run([util.which('dcos')], True) + assert auth.force_auth.call_count == 0 + + +def test_anonymous_login(): + with patch('six.moves.input', + return_value=''), patch('uuid.uuid1', + return_value='anonymous@email'): + + assert _mock_dcos_run([util.which('dcos'), + 'config', 'show'], False) == 0 + assert _mock_dcos_run([util.which('dcos'), 'config', + 'show', 'core.email'], False) == 0 + assert _mock_dcos_run([util.which('dcos'), 'config', + 'unset', 'core.email'], False) == 0 + + +def _mock_dcos_run(args, authenticated=True): + env = _config_with_credentials() if authenticated else \ + _config_without_credentials() + with patch('sys.argv', args), patch.dict(os.environ, env): + return main() + + +def _config_with_credentials(): + return {constants.DCOS_CONFIG_ENV: os.path.join( + 'tests', 'data', 'auth', 'dcos_with_credentials.toml')} + + +def _config_without_credentials(): + return {constants.DCOS_CONFIG_ENV: os.path.join( + 'tests', 'data', 'auth', 'dcos_without_credentials.toml')} diff --git a/cli/tests/integrations/cli/test_config.py b/cli/tests/integrations/cli/test_config.py index 4197d5b..e4b2925 100644 --- a/cli/tests/integrations/cli/test_config.py +++ b/cli/tests/integrations/cli/test_config.py @@ -69,7 +69,8 @@ def test_list_property(env): env) assert returncode == 0 - assert stdout == b"""core.reporting=False + assert stdout == b"""core.email=test@mail.com +core.reporting=False marathon.host=localhost marathon.port=8080 package.cache=tmp/cache diff --git a/dcos/api/auth.py b/dcos/api/auth.py new file mode 100644 index 0000000..2e1a71f --- /dev/null +++ b/dcos/api/auth.py @@ -0,0 +1,152 @@ +import json +import os +import uuid + +import pkg_resources +import toml +from dcos.api import config, constants, emitting, errors, http, jsonitem +from six import iteritems, moves + +from oauth2client import client + +CLIENT_ID = '6a552732-ab9b-410d-9b7d-d8c6523b09a1' +CLIENT_SECRET = 'f56c1e2b-8599-40ca-b6a0-3aba3e702eae' +AUTH_URL = 'https://accounts.mesosphere.com/oauth/authorize' +TOKEN_URL = 'https://accounts.mesosphere.com/oauth/token' +USER_INFO_URL = 'https://accounts.mesosphere.com/api/v1/user.json' +CORE_TOKEN_KEY = 'token' +CORE_EMAIL_KEY = 'email' +emitter = emitting.FlatEmitter() + + +def _authorize(): + """Create OAuth flow and authorize user + + :return: Tuple of credentials dict end Error + :rtype: (dict, dcos.api.errors.Error) + """ + try: + flow = client.OAuth2WebServerFlow( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + scope='', + auth_uri=AUTH_URL, + token_uri=TOKEN_URL, + redirect_uri=client.OOB_CALLBACK_URN, + response_type='code' + ) + res = _run(flow) + return res, None + except: + err = errors.DefaultError('There was a problem with ' + 'web authentication.') + return None, err + + +def make_oauth_request(code, flow): + """Make request to auth server using auth_code. + + :param: code: auth_code read from cli + :param: flow: OAuth2 web server flow + :return: dict with the keys token and email + :rtype: dict + """ + credential = flow.step2_exchange(code) + token = credential.access_token + headers = {'Authorization': str('Bearer ' + token)} + data = http.requests.get(USER_INFO_URL, headers=headers).json() + mail = data['email'] + credentials = {CORE_TOKEN_KEY: credential.access_token, + CORE_EMAIL_KEY: mail} + return credentials + + +def _run(flow): + """Make authorization and retrieve access token and user email. + + :param flow: OAuth2 web server flow + :param launch_browser: if possible to run browser + :return: dict with the keys token and email + :rtype: dict + """ + + auth_url = flow.step1_get_authorize_url() + message = """Thank you for installing the Mesosphere DCOS CLI. +Please log in with your Mesosphere Account by pasting +the following URL into your browser to continue.""" + emitter.publish(errors.DefaultError( + '{message}\n\n {url}\n\n'.format(message=message, + url=auth_url,))) + + code = moves.input('Please enter Mesosphere verification code: ').strip() + if not code: + email = moves.input('Skipping authentication.' + ' Please enter email address:').strip() + if not email: + emitter.publish(errors.DefaultError('Skipping email input,' + ' using anonymous id:')) + email = str(uuid.uuid1()) + return {CORE_EMAIL_KEY: email} + + return make_oauth_request(code, flow) + + +def check_if_user_authenticated(): + """ check if user is authenticated already + + :returns user auth status + :rtype: boolean + """ + + dcos_config = config.load_from_path( + os.environ[constants.DCOS_CONFIG_ENV]) + return dcos_config.get('core.email', '') != '' + + +def force_auth(): + """ Make user authentication process + + :returns authentication process status + :rtype: boolean + """ + + credentials, error = _authorize() + if error is not None: + emitter.publish(error) + return False + else: + error = _save_auth_keys(credentials) + if error is not None: + emitter.publish(error) + return False + return True + + +def _save_auth_keys(key_dict): + """ + :param key_dict: auth parameters dict + :type key_dict: dict + :returns: Error value + :rtype: Error + """ + + config_path = os.environ[constants.DCOS_CONFIG_ENV] + toml_config = config.mutable_load_from_path(config_path) + + section = 'core' + config_schema = json.loads( + pkg_resources.resource_string( + 'dcoscli', + 'data/config-schema/core.json').decode('utf-8')) + for k, v in iteritems(key_dict): + python_value, err = jsonitem.parse_json_value(k, v, config_schema) + if err is not None: + return err + name = '{}.{}'.format(section, k) + toml_config[name] = python_value + + serial = toml.dumps(toml_config._dictionary) + with open(config_path, 'w') as config_file: + config_file.write(serial) + + return None