diff --git a/bin/install/install-optout-dcos-cli.sh b/bin/install/install-optout-dcos-cli.sh index d4284b2..5efd68f 100755 --- a/bin/install/install-optout-dcos-cli.sh +++ b/bin/install/install-optout-dcos-cli.sh @@ -160,7 +160,6 @@ fi ENV_SETUP="$VIRTUAL_ENV_PATH/bin/env-setup" source "$ENV_SETUP" -dcos config set core.email anonymous-optout dcos config set core.reporting false dcos config set core.dcos_url $DCOS_URL dcos config set core.ssl_verify false diff --git a/cli/dcoscli/analytics.py b/cli/dcoscli/analytics.py index 41faa37..6c08128 100644 --- a/cli/dcoscli/analytics.py +++ b/cli/dcoscli/analytics.py @@ -45,30 +45,12 @@ def _segment_track(event, conf, properties): """ data = {'event': event, - 'properties': properties} - - if 'core.email' in conf: - data['userId'] = conf['core.email'] - else: - data['anonymousId'] = session_id + 'properties': properties, + 'anonymousId': session_id} _segment_request('track', data) -def segment_identify(conf): - """ - Send a segment.io 'identify' event - - :param conf: dcos config file - :type conf: Toml - :rtype: None - """ - - if 'core.email' in conf: - data = {'userId': conf.get('core.email')} - _segment_request('identify', data) - - def _segment_request(path, data): """ Send a segment.io HTTP request diff --git a/cli/dcoscli/auth/__init__.py b/cli/dcoscli/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/dcoscli/auth/main.py b/cli/dcoscli/auth/main.py new file mode 100644 index 0000000..ca5ea0f --- /dev/null +++ b/cli/dcoscli/auth/main.py @@ -0,0 +1,108 @@ +import dcoscli +import docopt +from dcos import cmds, config, emitting, http, util +from dcos.errors import DCOSAuthorizationException, DCOSException +from dcoscli.subcommand import default_command_info, default_doc +from dcoscli.util import decorate_docopt_usage + +from six.moves import urllib + +emitter = emitting.FlatEmitter() +logger = util.get_logger(__name__) + + +def main(argv): + try: + return _main(argv) + except DCOSException as e: + emitter.publish(e) + return 1 + + +@decorate_docopt_usage +def _main(argv): + args = docopt.docopt( + default_doc("auth"), + argv=argv, + version='dcos-auth version {}'.format(dcoscli.version)) + + http.silence_requests_warnings() + + return cmds.execute(_cmds(), args) + + +def _cmds(): + """ + :returns: all the supported commands + :rtype: list of dcos.cmds.Command + """ + + return [ + cmds.Command( + hierarchy=['auth', 'login'], + arg_keys=[], + function=_login), + + cmds.Command( + hierarchy=['auth', 'logout'], + arg_keys=[], + function=_logout), + + cmds.Command( + hierarchy=['auth'], + arg_keys=['--info'], + function=_info), + ] + + +def _info(info): + """ + :param info: Whether to output a description of this subcommand + :type info: boolean + :returns: process status + :rtype: int + """ + + emitter.publish(default_command_info("auth")) + return 0 + + +def _login(): + """ + :returns: process status + :rtype: int + """ + + # every call to login will generate a new token if applicable + _logout() + conf = util.get_config() + dcos_url = conf.get("core.dcos_url") + if dcos_url is None: + msg = ("Please provide the url to your DCOS cluster: " + "`dcos config set core.dcos_url`") + raise DCOSException(msg) + + # hit protected endpoint which will prompt for auth if cluster has auth + try: + url = urllib.parse.urljoin(dcos_url, 'exhibitor') + http.get(url) + # if the user is authenticated, they have effectively "logged in" even if + # they are not authorized for this endpoint + except DCOSAuthorizationException: + pass + + emitter.publish("Login successful!") + return 0 + + +def _logout(): + """ + Logout the user from dcos acs auth or oauth + + :returns: process status + :rtype: int + """ + + if util.get_config().get("core.dcos_acs_token") is not None: + config.unset("core.dcos_acs_token") + return 0 diff --git a/cli/dcoscli/config/main.py b/cli/dcoscli/config/main.py index 648d1e2..06c502d 100644 --- a/cli/dcoscli/config/main.py +++ b/cli/dcoscli/config/main.py @@ -4,7 +4,6 @@ import dcoscli import docopt from dcos import cmds, config, emitting, http, util from dcos.errors import DCOSException -from dcoscli import analytics from dcoscli.subcommand import default_command_info, default_doc from dcoscli.util import decorate_docopt_usage @@ -88,9 +87,11 @@ def _set(name, value): notice = ("This config property has been deprecated. " "Please add your repositories with `dcos package repo add`") return DCOSException(notice) - toml_config = config.set_val(name, value) - if (name == 'core.reporting' is True) or (name == 'core.email'): - analytics.segment_identify(toml_config) + if name == "core.email": + notice = "This config property has been deprecated." + return DCOSException(notice) + + config.set_val(name, value) return 0 diff --git a/cli/dcoscli/data/help/auth.txt b/cli/dcoscli/data/help/auth.txt new file mode 100644 index 0000000..1d95c8b --- /dev/null +++ b/cli/dcoscli/data/help/auth.txt @@ -0,0 +1,21 @@ +Description: + Authenticate to DCOS cluster + +Usage: + dcos auth --info + dcos auth login + dcos auth logout + +Commands: + login + Login to your DCOS Cluster. + logout + Logout of your DCOS Cluster. + +Options: + -h, --help + Print usage. + --info + Print a short description of this subcommand. + --version + Print version information. diff --git a/cli/dcoscli/main.py b/cli/dcoscli/main.py index 0932fc1..a3f43a3 100644 --- a/cli/dcoscli/main.py +++ b/cli/dcoscli/main.py @@ -5,8 +5,7 @@ from concurrent.futures import ThreadPoolExecutor import dcoscli import docopt -from dcos import (auth, constants, emitting, errors, http, mesos, subcommand, - util) +from dcos import constants, emitting, errors, http, mesos, subcommand, util from dcos.errors import DCOSAuthenticationException, DCOSException from dcoscli import analytics from dcoscli.subcommand import SubcommandMain, default_doc @@ -40,10 +39,6 @@ def _main(): util.configure_process_from_environ() - if args[''] != 'config' and \ - not auth.check_if_user_authenticated(): - auth.force_auth() - config = util.get_config() set_ssl_info_env_vars(config) diff --git a/cli/dcoscli/subcommand.py b/cli/dcoscli/subcommand.py index b37ef43..54643b9 100644 --- a/cli/dcoscli/subcommand.py +++ b/cli/dcoscli/subcommand.py @@ -11,6 +11,7 @@ def _default_modules(): """ # avoid circular imports + from dcoscli.auth import main as auth_main from dcoscli.config import main as config_main from dcoscli.help import main as help_main from dcoscli.marathon import main as marathon_main @@ -19,7 +20,8 @@ def _default_modules(): from dcoscli.service import main as service_main from dcoscli.task import main as task_main - return {'config': config_main, + return {'auth': auth_main, + 'config': config_main, 'help': help_main, 'marathon': marathon_main, 'node': node_main, diff --git a/cli/tests/data/analytics/dcos_no_reporting.toml b/cli/tests/data/analytics/dcos_no_reporting.toml index 9a2b2ac..c93fd88 100644 --- a/cli/tests/data/analytics/dcos_no_reporting.toml +++ b/cli/tests/data/analytics/dcos_no_reporting.toml @@ -1,4 +1,3 @@ [core] reporting = false -email = "test@mail.com" dcos_url = "http://dcos.snakeoil.mesosphere.com" diff --git a/cli/tests/data/analytics/dcos_reporting.toml b/cli/tests/data/analytics/dcos_reporting.toml index 238977e..e5f9c8f 100644 --- a/cli/tests/data/analytics/dcos_reporting.toml +++ b/cli/tests/data/analytics/dcos_reporting.toml @@ -1,5 +1,4 @@ [core] dcos_acs_token = "foobar" dcos_url = "https://dcos.snakeoil.mesosphere.com" -email = "test@mail.com" reporting = true diff --git a/cli/tests/data/analytics/dcos_reporting_with_url.toml b/cli/tests/data/analytics/dcos_reporting_with_url.toml index ef6fb3b..bf02038 100644 --- a/cli/tests/data/analytics/dcos_reporting_with_url.toml +++ b/cli/tests/data/analytics/dcos_reporting_with_url.toml @@ -1,4 +1,3 @@ [core] -email = "test@mail.com" reporting = true dcos_url = "http://dcos.snakeoil.mesosphere.com" diff --git a/cli/tests/data/auth/dcos_with_credentials.toml b/cli/tests/data/auth/dcos_with_credentials.toml index a10b2c7..affd359 100644 --- a/cli/tests/data/auth/dcos_with_credentials.toml +++ b/cli/tests/data/auth/dcos_with_credentials.toml @@ -1,3 +1,2 @@ [core] reporting = false -email = "test@mail.com" diff --git a/cli/tests/data/config/missing_params_dcos.toml b/cli/tests/data/config/missing_params_dcos.toml index 5e68b86..95e2f57 100644 --- a/cli/tests/data/config/missing_params_dcos.toml +++ b/cli/tests/data/config/missing_params_dcos.toml @@ -1,5 +1,4 @@ [core] reporting = false -email = "test@mail.com" [package] cosmos_url = "http://localhost:7070" diff --git a/cli/tests/data/dcos.toml b/cli/tests/data/dcos.toml index b1e9422..3318454 100644 --- a/cli/tests/data/dcos.toml +++ b/cli/tests/data/dcos.toml @@ -1,6 +1,5 @@ [core] reporting = false -email = "test@mail.com" timeout = 5 dcos_url = "http://dcos.snakeoil.mesosphere.com" ssl_verify = "false" diff --git a/cli/tests/data/help/auth.txt b/cli/tests/data/help/auth.txt new file mode 100644 index 0000000..1d95c8b --- /dev/null +++ b/cli/tests/data/help/auth.txt @@ -0,0 +1,21 @@ +Description: + Authenticate to DCOS cluster + +Usage: + dcos auth --info + dcos auth login + dcos auth logout + +Commands: + login + Login to your DCOS Cluster. + logout + Logout of your DCOS Cluster. + +Options: + -h, --help + Print usage. + --info + Print a short description of this subcommand. + --version + Print version information. diff --git a/cli/tests/data/help/default.txt b/cli/tests/data/help/default.txt index a255916..627a207 100644 --- a/cli/tests/data/help/default.txt +++ b/cli/tests/data/help/default.txt @@ -5,6 +5,7 @@ for easy management of a DCOS installation. Available DCOS commands: + auth Authenticate to DCOS cluster config Manage the DCOS configuration file help Display help information about DCOS marathon Deploy and manage applications to DCOS diff --git a/cli/tests/data/marathon/missing_marathon_params.toml b/cli/tests/data/marathon/missing_marathon_params.toml index 2e4a447..9b0f34f 100644 --- a/cli/tests/data/marathon/missing_marathon_params.toml +++ b/cli/tests/data/marathon/missing_marathon_params.toml @@ -1,6 +1,5 @@ [core] reporting = false -email = "test@mail.com" [marathon] [package] cosmos_url = "http://localhost:7070" diff --git a/cli/tests/data/ssl/ssl.toml b/cli/tests/data/ssl/ssl.toml index b076a22..06f84fe 100644 --- a/cli/tests/data/ssl/ssl.toml +++ b/cli/tests/data/ssl/ssl.toml @@ -3,5 +3,4 @@ cosmos_url = "http://localhost:7070" [core] timeout = 5 dcos_url = "https://dcos.snakeoil.mesosphere.com" -email = "test@mail.com" reporting = false diff --git a/cli/tests/integrations/common.py b/cli/tests/integrations/common.py index c0d4143..0497fd1 100644 --- a/cli/tests/integrations/common.py +++ b/cli/tests/integrations/common.py @@ -510,4 +510,4 @@ def config_unset(key, env=None): returncode, stdout, stderr = exec_command(cmd, env=env) assert returncode == 0 - assert stderr == b'' + assert stdout == b'' diff --git a/cli/tests/integrations/test_auth.py b/cli/tests/integrations/test_auth.py new file mode 100644 index 0000000..ae972d4 --- /dev/null +++ b/cli/tests/integrations/test_auth.py @@ -0,0 +1,53 @@ +import os + +from dcos import constants + +import pytest + +from .common import assert_command, config_set, exec_command + + +@pytest.fixture +def env(): + r = os.environ.copy() + r.update({ + constants.PATH_ENV: os.environ[constants.PATH_ENV], + constants.DCOS_CONFIG_ENV: os.path.join("tests", "data", "dcos.toml"), + }) + + return r + + +def test_info(): + stdout = b'Authenticate to DCOS cluster\n' + assert_command(['dcos', 'auth', '--info'], + stdout=stdout) + + +def test_version(): + stdout = b'dcos-auth version SNAPSHOT\n' + assert_command(['dcos', 'auth', '--version'], + stdout=stdout) + + +def test_logout_no_token(env): + exec_command(['dcos', 'config', 'unset', 'core.dcos_acs_token'], env=env) + + returncode, _, stderr = exec_command( + ['dcos', 'config', 'show', 'core.dcos_acs_token'], env=env) + assert returncode == 1 + assert stderr == b"Property 'core.dcos_acs_token' doesn't exist\n" + + +def test_logout_with_token(env): + config_set('core.dcos_acs_token', "foobar", env=env) + stderr = b"[core.dcos_acs_token]: changed\n" + assert_command( + ['dcos', 'config', 'set', 'core.dcos_acs_token', 'faketoken'], + stderr=stderr, + env=env) + + stderr = b'Removed [core.dcos_acs_token]\n' + assert_command(['dcos', 'auth', 'logout'], + stderr=stderr, + env=env) diff --git a/cli/tests/integrations/test_config.py b/cli/tests/integrations/test_config.py index e6edaf6..bfca8f3 100644 --- a/cli/tests/integrations/test_config.py +++ b/cli/tests/integrations/test_config.py @@ -51,7 +51,6 @@ def test_version(): def _test_list_property(env): stdout = b"""core.dcos_url=http://dcos.snakeoil.mesosphere.com -core.email=test@mail.com core.reporting=False core.ssl_verify=false core.timeout=5 @@ -89,7 +88,6 @@ def test_get_top_property(env): b"Property 'core' doesn't fully specify a value - " b"possible properties are:\n" b"core.dcos_url\n" - b"core.email\n" b"core.reporting\n" b"core.ssl_verify\n" b"core.timeout\n" @@ -108,6 +106,13 @@ def test_set_package_sources_property(env): returncode=1) +def test_set_core_email_property(env): + notice = (b"This config property has been deprecated.\n") + assert_command(['dcos', 'config', 'set', 'core.email', 'foo@bar.com'], + stderr=notice, + returncode=1) + + def test_set_existing_string_property(env): config_set('core.dcos_url', 'http://dcos.snakeoil.mesosphere.com:5081', env) @@ -175,7 +180,7 @@ def test_unset_missing_property(env): def test_unset_output(env): assert_command(['dcos', 'config', 'unset', 'core.reporting'], - stdout=b'Removed [core.reporting]\n', + stderr=b'Removed [core.reporting]\n', env=env) config_set('core.reporting', 'false', env) @@ -185,7 +190,6 @@ def test_unset_top_property(env): b"Property 'core' doesn't fully specify a value - " b"possible properties are:\n" b"core.dcos_url\n" - b"core.email\n" b"core.reporting\n" b"core.ssl_verify\n" b"core.timeout\n" diff --git a/cli/tests/integrations/test_help.py b/cli/tests/integrations/test_help.py index 10584b9..9ac9e31 100644 --- a/cli/tests/integrations/test_help.py +++ b/cli/tests/integrations/test_help.py @@ -26,6 +26,7 @@ for easy management of a DCOS installation. Available DCOS commands: +\tauth \tAuthenticate to DCOS cluster \tconfig \tManage the DCOS configuration file \thelp \tDisplay help information about DCOS \tmarathon \tDeploy and manage applications to DCOS @@ -75,3 +76,9 @@ def test_help_task(): with open('tests/data/help/task.txt') as content: assert_command(['dcos', 'help', 'task'], stdout=content.read().encode('utf-8')) + + +def test_help_auth(): + with open('tests/data/help/auth.txt') as content: + assert_command(['dcos', 'help', 'auth'], + stdout=content.read().encode('utf-8')) diff --git a/cli/tests/unit/test_analytics.py b/cli/tests/unit/test_analytics.py index 20b33c9..f49c197 100644 --- a/cli/tests/unit/test_analytics.py +++ b/cli/tests/unit/test_analytics.py @@ -4,14 +4,11 @@ from functools import wraps import dcoscli.analytics import rollbar -from dcos import constants, http -from dcoscli.constants import SEGMENT_URL +from dcos import constants from dcoscli.main import main -from dcoscli.subcommand import SubcommandMain from mock import patch -from .common import mock_called_some_args ANON_ID = 0 USER_ID = 'test@mail.com' @@ -32,22 +29,6 @@ def _mock(fn): return wrapper -@_mock -def test_config_set(): - argv = ['set', 'core.email', 'test@mail.com'] - - config_thread = SubcommandMain("config", argv) - exitcode, err = config_thread.run_and_capture() - assert exitcode == 0 - assert err is None - - # segment.io - assert mock_called_some_args(http.post, - '{}/identify'.format(SEGMENT_URL), - json={'userId': 'test@mail.com'}, - timeout=(1, 1)) - - @_mock def test_cluster_id_not_sent_on_config_call(): """Tests that cluster_id is not sent to segment.io on call to config diff --git a/cli/tests/unit/test_auth.py b/cli/tests/unit/test_auth.py deleted file mode 100644 index 01c3654..0000000 --- a/cli/tests/unit/test_auth.py +++ /dev/null @@ -1,56 +0,0 @@ -import os -import webbrowser - -from dcos 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_anonymous_login(): - with patch('sys.stdin.readline', return_value='\n'), \ - patch('uuid.uuid1', return_value='anonymous@email'): - - assert _mock_dcos_run(['dcos', 'help'], False) == 0 - assert _mock_dcos_run(['dcos', - 'config', 'show', 'core.email'], False) == 0 - assert _mock_dcos_run(['dcos', - 'config', 'unset', 'core.email'], False) == 0 - - -def _mock_dcos_run(args, authenticated=True): - if authenticated: - env = _config_with_credentials() - else: - env = _config_without_credentials() - - with patch('sys.argv', args), patch.dict(os.environ, env): - return main() - - -def test_when_authenticated(): - with patch('dcos.auth.force_auth'): - - _mock_dcos_run(['dcos'], True) - assert auth.force_auth.call_count == 0 - - -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/unit/test_http_auth.py b/cli/tests/unit/test_http_auth.py index 4b0f5a8..8c8da35 100644 --- a/cli/tests/unit/test_http_auth.py +++ b/cli/tests/unit/test_http_auth.py @@ -25,6 +25,14 @@ def test_get_auth_scheme_acs(): assert realm == "acsjwt" +def test_get_auth_scheme_oauth(): + with patch('requests.Response') as mock: + mock.headers = {'www-authenticate': 'oauthjwt'} + auth_scheme, realm = http.get_auth_scheme(mock) + assert auth_scheme == "oauthjwt" + assert realm == "oauthjwt" + + def test_get_auth_scheme_bad_request(): with patch('requests.Response') as mock: mock.headers = {'www-authenticate': ''} @@ -116,6 +124,23 @@ def test_request_with_bad_auth_acl(mock, req_mock, auth_mock): assert e.exconly().split(':')[1].strip() == "Authentication failed" +@patch('requests.Response') +@patch('dcos.http._request') +@patch('dcos.http._get_http_auth') +def test_request_with_bad_oauth(mock, req_mock, auth_mock): + mock.url = 'http://domain.com' + mock.headers = {'www-authenticate': 'oauthjwt'} + mock.status_code = 401 + + auth_mock.return_value = http.DCOSAcsAuth("token") + + req_mock.return_value = mock + + with pytest.raises(DCOSException) as e: + http._request_with_auth(mock, "method", mock.url) + assert e.exconly().split(':')[1].strip() == "Authentication failed" + + @patch('requests.Response') @patch('dcos.http._request') @patch('dcos.http._get_http_auth') @@ -152,3 +177,22 @@ def test_request_with_auth_acl(mock, req_mock, auth_mock): response = http._request_with_auth(mock, "method", mock.url) assert response.status_code == 200 + + +@patch('requests.Response') +@patch('dcos.http._request') +@patch('dcos.http._get_http_auth') +def test_request_with_auth_oauth(mock, req_mock, auth_mock): + mock.url = 'http://domain.com' + mock.headers = {'www-authenticate': 'oauthjwt'} + mock.status_code = 401 + + auth = http.DCOSAcsAuth("token") + auth_mock.return_value = auth + + mock2 = copy.deepcopy(mock) + mock2.status_code = 200 + req_mock.return_value = mock2 + + response = http._request_with_auth(mock, "method", mock.url) + assert response.status_code == 200 diff --git a/dcos/auth.py b/dcos/auth.py deleted file mode 100644 index 4327f9e..0000000 --- a/dcos/auth.py +++ /dev/null @@ -1,138 +0,0 @@ -import json -import sys -import uuid - -import pkg_resources -from dcos import config, emitting, errors, http, jsonitem, util -from dcos.errors import DCOSException -from six import iteritems - -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() -logger = util.get_logger(__name__) - - -def _authorize(): - """Create OAuth flow and authorize user - - :return: credentials dict - :rtype: dict - """ - 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' - ) - return _run(flow) - except: - logger.exception('Error during OAuth web flow') - raise DCOSException('There was a problem with ' - 'web authentication.') - - -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 - """ - - emitter.publish( - errors.DefaultError( - '\n\n\n{}\n\n {}\n\n'.format( - 'Go to the following link in your browser:', - flow.step1_get_authorize_url()))) - - sys.stderr.write('Enter verification code: ') - code = sys.stdin.readline().strip() - if not code: - sys.stderr.write('Skipping authentication.\nEnter email address: ') - - email = sys.stdin.readline().strip() - if not email: - emitter.publish( - errors.DefaultError( - 'Skipping email input.')) - 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 = util.get_config() - return dcos_config.get('core.email', '') != '' - - -def force_auth(): - """Make user authentication process - - :returns authentication process status - :rtype: boolean - """ - - credentials = _authorize() - _save_auth_keys(credentials) - - -def _save_auth_keys(key_dict): - """ - :param key_dict: auth parameters dict - :type key_dict: dict - :rtype: None - """ - - toml_config = util.get_config(True) - - section = 'core' - config_schema = json.loads( - pkg_resources.resource_string( - 'dcos', - 'data/config-schema/core.json').decode('utf-8')) - for k, v in iteritems(key_dict): - python_value = jsonitem.parse_json_value(k, v, config_schema) - name = '{}.{}'.format(section, k) - toml_config[name] = python_value - - config.save(toml_config) - return None diff --git a/dcos/config.py b/dcos/config.py index 784619f..bb25e93 100644 --- a/dcos/config.py +++ b/dcos/config.py @@ -130,7 +130,7 @@ def unset(name): elif isinstance(value, collections.Mapping): raise DCOSException(_generate_choice_msg(name, value)) else: - emitter.publish("Removed [{}]".format(name)) + emitter.publish(DefaultError("Removed [{}]".format(name))) save(toml_config) return diff --git a/dcos/data/config-schema/core.json b/dcos/data/config-schema/core.json index 99095ff..244eb47 100644 --- a/dcos/data/config-schema/core.json +++ b/dcos/data/config-schema/core.json @@ -13,22 +13,12 @@ "title": "DCOS ACS token", "type": "string" }, - "email": { - "description": "Your email address", - "title": "Your email address", - "type": "string" - }, "mesos_master_url": { "description": "Mesos master URL. Must be set in format: \"http://host:port\"", "format": "uri", "title": "Mesos Master URL", "type": "string" }, - "refresh_token": { - "description": "Your OAuth refresh token", - "title": "The OAuth refresh token", - "type": "string" - }, "reporting": { "default": true, "description": "Whether to report usage events to Mesosphere", @@ -42,11 +32,6 @@ "title": "Request timeout in seconds", "type": "integer" }, - "token": { - "description": "Your OAuth access token", - "title": "Your OAuth access token", - "type": "string" - }, "ssl_verify": { "type": "string", "default": "false", diff --git a/dcos/http.py b/dcos/http.py index 303f18d..8629845 100644 --- a/dcos/http.py +++ b/dcos/http.py @@ -161,9 +161,13 @@ def _request_with_auth(response, if creds not in AUTH_CREDS and response.status_code == 200: AUTH_CREDS[creds] = auth # acs invalid token - elif response.status_code == 401 and auth_scheme == "acsjwt": + elif response.status_code == 401 and \ + auth_scheme in ["acsjwt", "oauthjwt"]: + if util.get_config().get("core.dcos_acs_token") is not None: - config.unset("core.dcos_acs_token") + msg = ("Your core.dcos_acs_token is invalid. " + "Please run: `dcos auth login`") + raise DCOSException(msg) i += 1 @@ -342,7 +346,7 @@ def _get_auth_credentials(username, hostname): def get_auth_scheme(response): """Return authentication scheme and realm requested by server for 'Basic' - or 'acsjwt' (DCOS acs auth) type or None + or 'acsjwt' (DCOS acs auth) or 'oauthjwt' (DCOS acs oauth) type or None :param response: requests.response :type response: requests.Response @@ -354,7 +358,8 @@ def get_auth_scheme(response): auths = response.headers['www-authenticate'].split(',') scheme = next((auth_type.rstrip().lower() for auth_type in auths if auth_type.rstrip().lower().startswith("basic") or - auth_type.rstrip().lower().startswith("acsjwt")), + auth_type.rstrip().lower().startswith("acsjwt") or + auth_type.rstrip().lower().startswith("oauthjwt")), None) if scheme: scheme_info = scheme.split("=") @@ -385,7 +390,7 @@ def _get_http_auth(response, url, auth_scheme): password = url.password if 'www-authenticate' in response.headers: - if auth_scheme not in ['basic', 'acsjwt']: + if auth_scheme not in ['basic', 'acsjwt', 'oauthjwt']: msg = ("Server responded with an HTTP 'www-authenticate' field of " "'{}', DCOS only supports 'Basic'".format( response.headers['www-authenticate'])) @@ -396,17 +401,59 @@ def _get_http_auth(response, url, auth_scheme): # we'd already be authed by python requests module username, password = _get_auth_credentials(username, hostname) return HTTPBasicAuth(username, password) + # dcos auth (acs or oauth) else: - return _get_dcos_acs_auth(username, password, hostname) + return _get_dcos_auth(auth_scheme, username, password, hostname) else: msg = ("Invalid HTTP response: server returned an HTTP 401 response " "with no 'www-authenticate' field") raise DCOSException(msg) -def _get_dcos_acs_auth(username, password, hostname): - """Get authentication flow for dcos acs auth +def _get_dcos_oauth_creds(dcos_url): + """Get token credential for dcos oath + :param dcos_url: dcos cluster url + :type dcos_url: str + :returns: token from browser for oauth flow + :rtype: dict + """ + + oauth_login = 'login?redirect_uri=urn:ietf:wg:oauth:2.0:oob' + url = urllib.parse.urljoin(dcos_url, oauth_login) + msg = "\n{}\n\n {}\n\n{} ".format( + "Please go to the following link in your browser:", + url, + "Enter authentication token:") + sys.stderr.write(msg) + sys.stderr.flush() + token = sys.stdin.readline().strip() + return {"token": token} + + +def _get_dcos_acs_auth_creds(username, password, hostname): + """Get credentials for dcos acs auth + + :param username: username user for authentication + :type username: str + :param password: password for authentication + :type password: str + :param hostname: hostname for credentials + :type hostname: str + :returns: username/password credentials + :rtype: dict + """ + + if password is None: + username, password = _get_auth_credentials(username, hostname) + return {"uid": username, "password": password} + + +def _get_dcos_auth(auth_scheme, username, password, hostname): + """Get authentication flow for dcos acs auth and dcos oauth + + :param auth_scheme: authentication_scheme + :type auth_scheme: str :param username: username user for authentication :type username: str :param password: password for authentication @@ -421,16 +468,17 @@ def _get_dcos_acs_auth(username, password, hostname): token = toml_config.get("core.dcos_acs_token") if token is None: dcos_url = toml_config.get("core.dcos_url") - url = urllib.parse.urljoin(dcos_url, 'acs/api/v1/auth/login') - if password is None: - username, password = _get_auth_credentials(username, hostname) - creds = {"uid": username, "password": password} + if auth_scheme == "acsjwt": + creds = _get_dcos_acs_auth_creds(username, password, hostname) + else: + creds = _get_dcos_oauth_creds(dcos_url) verify = _verify_ssl() # Silence 'Unverified HTTPS request' and 'SecurityWarning' for bad cert if verify is not None: silence_requests_warnings() + url = urllib.parse.urljoin(dcos_url, 'acs/api/v1/auth/login') # using private method here, so we don't retry on this request # error here will be bubbled up to _request_with_auth response = _request('post', url, json=creds, verify=verify) diff --git a/dcos/subcommand.py b/dcos/subcommand.py index d25dcd5..f59f373 100644 --- a/dcos/subcommand.py +++ b/dcos/subcommand.py @@ -134,8 +134,8 @@ def default_subcommands(): :returns: list of all the default dcos cli subcommands :rtype: [str] """ - - return ["config", "help", "marathon", "node", "package", "service", "task"] + return ["auth", "config", "help", "marathon", + "node", "package", "service", "task"] def documentation(executable_path):