diff --git a/Dockerfile.test b/Dockerfile.test index df153a4..82d0677 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -7,6 +7,11 @@ RUN apt-get update && apt-get install -y \ httpie \ jq \ make \ + build-essential \ + libssl-dev \ + libffi-dev \ + python-dev \ + python3-pip \ python3-venv \ openssh-client \ git \ diff --git a/cli/binary/Dockerfile.linux-binary b/cli/binary/Dockerfile.linux-binary index f3f5936..c99232a 100644 --- a/cli/binary/Dockerfile.linux-binary +++ b/cli/binary/Dockerfile.linux-binary @@ -11,7 +11,7 @@ RUN apt-get update && apt-get install -y \ git \ sudo \ && sudo apt-get update --fix-missing \ -&& sudo apt-get install -y python-dev build-essential python3-pip python3-venv \ +&& sudo apt-get install -y build-essential libssl-dev libffi-dev python-dev python3-pip python3-venv \ && pip3 install pip --upgrade \ && python3 -m pip install pyinstaller==3.1.1 diff --git a/cli/binary/binary.spec b/cli/binary/binary.spec index 5439a54..939104f 100644 --- a/cli/binary/binary.spec +++ b/cli/binary/binary.spec @@ -14,7 +14,7 @@ a = Analysis(['../dcoscli/main.py'], ('../../dcos/data/config-schema/*', 'dcos/data/config-schema'), ('../../dcos/data/marathon/*', 'dcos/data/marathon') ], - hiddenimports=[], + hiddenimports=['_cffi_backend'], hookspath=[], runtime_hooks=[], excludes=[], diff --git a/cli/dcoscli/auth/main.py b/cli/dcoscli/auth/main.py index 517c251..daeeeb9 100644 --- a/cli/dcoscli/auth/main.py +++ b/cli/dcoscli/auth/main.py @@ -1,9 +1,11 @@ +import os + import docopt -from six.moves import urllib import dcoscli -from dcos import cmds, config, emitting, http, util -from dcos.errors import DCOSAuthorizationException, DCOSException +from dcos import auth, cmds, config, emitting, http, util +from dcos.errors import DCOSException, DefaultError +from dcoscli import tables from dcoscli.subcommand import default_command_info, default_doc from dcoscli.util import decorate_docopt_usage @@ -39,9 +41,15 @@ def _cmds(): """ return [ + cmds.Command( + hierarchy=['auth', 'list-providers'], + arg_keys=['--json'], + function=_list_providers), + cmds.Command( hierarchy=['auth', 'login'], - arg_keys=[], + arg_keys=['--password', '--password-env', '--password-file', + '--provider', '--username', '--private-key'], function=_login), cmds.Command( @@ -68,34 +76,151 @@ def _info(info): return 0 -def _login(): +def _list_providers(json_): """ - :returns: process status + :returns: providers available for configured cluster + :rtype: dict + """ + + providers = auth.get_providers() + if providers or json_: + emitting.publish_table( + emitter, providers, tables.auth_provider_table, json_) + else: + raise DCOSException("No providers configured for your cluster") + + +def _get_password(password_str, password_env, password_file): + """ + Get password for authentication + + :param password_str: password + :type password_str: str + :param password_env: name of environment variable with password + :type password_env: str + :param password_file: path to file with password + :type password_file: bool + :returns: password or None if no password specified + :rtype: str | None + """ + + password = None + if password_str: + password = password_str + elif password_env: + password = os.environ.get(password_env) + if password is None: + msg = "Environment variable specified [{}] does not exist" + raise DCOSException(msg.format(password_env)) + elif password_file: + password = util.read_file_secure(password_file) + return password + + +def _login(password_str, password_env, password_file, + provider, username, key_path): + """ + :param password_str: password + :type password_str: str + :param password_env: name of environment variable with password + :type password_env: str + :param password_file: path to file with password + :type password_file: bool + :param provider: name of provider to authentication with + :type provider: str + :param username: username + :type username: str + :param key_path: path to file with private key + :type param: str :rtype: int """ - # every call to login will generate a new token if applicable - _logout() dcos_url = config.get_config_val("core.dcos_url") if dcos_url is None: msg = ("Please provide the url to your DC/OS cluster: " "`dcos config set core.dcos_url`") raise DCOSException(msg) - # hit protected endpoint which will prompt for auth if cluster has auth - try: - endpoint = '/pkgpanda/active.buildinfo.full.json' - url = urllib.parse.urljoin(dcos_url, endpoint) - http.request_with_auth('HEAD', url) - # if the user is authenticated, they have effectively "logged in" even if - # they are not authorized for this endpoint - except DCOSAuthorizationException: - pass + # every call to login will generate a new token if applicable + _logout() + + password = _get_password(password_str, password_env, password_file) + if provider is None: + if username and password: + auth.dcos_uid_password_auth(dcos_url, username, password) + elif username and key_path: + auth.servicecred_auth(dcos_url, username, key_path) + else: + try: + providers = auth.get_providers() + # Let users know if they have non-default providers configured + # This is a weak check, we should check default versions per + # DC/OS version since defaults will change. jj + if len(providers) > 2: + msg = ("\nYour cluster has several authentication " + "providers enabled. Run `dcos auth " + "list-providers` to see all providers and `dcos " + "auth login --provider ` to " + "authenticate with a specific provider\n") + emitter.publish(DefaultError(msg)) + except DCOSException: + pass + finally: + auth.header_challenge_auth(dcos_url) + else: + providers = auth.get_providers() + if providers.get(provider): + _trigger_client_method( + provider, providers[provider], username, password, key_path) + else: + msg = "Provider [{}] not configured on your cluster" + raise DCOSException(msg.format(provider)) emitter.publish("Login successful!") return 0 +def _trigger_client_method( + provider, provider_info, username=None, password=None, key_path=None): + """ + Trigger client method for authentication type user requested + + :param provider: provider_id requested by user + :type provider: str + :param provider_info: info about auth type defined by provider + :param provider_info: dict + :param username: username + :type username: str + :param password: password + :type password: str + :param key_path: path to file with service key + :type param: str + :rtype: None + """ + + client_method = provider_info.get("client-method") + dcos_url = config.get_config_val("core.dcos_url") + + if client_method == "browser-prompt-authtoken": + auth.browser_prompt_auth(dcos_url, provider_info) + elif client_method == "browser-prompt-oidcidtoken-get-authtoken": + auth.oidc_implicit_flow_auth(dcos_url) + elif client_method == "dcos-usercredential-post-receive-authtoken" or \ + client_method == "dcos-credential-post-receive-authtoken": + if not username or not password: + msg = "Please specify username and password for provider [{}]" + raise DCOSException(msg.format(provider)) + auth.dcos_uid_password_auth(dcos_url, username, password) + elif client_method == "dcos-servicecredential-post-receive-authtoken": + if not username or not key_path: + msg = "Please specify username and service key for provider [{}]" + raise DCOSException(msg.format(provider)) + auth.servicecred_auth(dcos_url, username, key_path) + else: + msg = "Authentication by provider [{}] is not supported by this CLI" + raise DCOSException(msg.format(provider)) + + def _logout(): """ Logout the user from dcos acs auth or oauth diff --git a/cli/dcoscli/data/help/auth.txt b/cli/dcoscli/data/help/auth.txt index 21b2546..5171f59 100644 --- a/cli/dcoscli/data/help/auth.txt +++ b/cli/dcoscli/data/help/auth.txt @@ -4,19 +4,37 @@ Description: Usage: dcos auth --help dcos auth --info + dcos auth list-providers [--json] dcos auth login + [--provider=] [--username=] + [--password= | --password-file= + | --password-env= | --private-key=] dcos auth logout Commands: + list-providers + List configured authentication providers for your DC/OS cluster. login - Login to your DC/OS Cluster. + Login to your DC/OS cluster. logout - Logout of your DC/OS Cluster. + Logout of your DC/OS cluster. Options: -h, --help Print usage. --info Print a short description of this subcommand. + --password= + Specify password on the command line (insecure). + --password-env= + Specify environment variable name that contains the password. + --password-file= + Specify path to a file that contains the password. + --provider= + Specify authentication provider to use for login. + --private-key= + Specify path to file that contains the private key. + --username= + Specify username for login. --version Print version information. diff --git a/cli/dcoscli/tables.py b/cli/dcoscli/tables.py index 32eab25..d82b25e 100644 --- a/cli/dcoscli/tables.py +++ b/cli/dcoscli/tables.py @@ -5,7 +5,7 @@ from collections import OrderedDict import prettytable -from dcos import marathon, mesos, util +from dcos import auth, marathon, mesos, util EMPTY_ENTRY = '---' @@ -814,6 +814,28 @@ def package_search_table(search_results): return tb +def auth_provider_table(providers): + """Returns a PrettyTable representation of the auth providers for cluster + + :param providers: auth providers available + :type providers: dict + :rtype: PrettyTable + + """ + + fields = OrderedDict([ + ('PROVIDER ID', lambda p: p), + ('AUTHENTICATION TYPE', lambda p: auth.auth_type_description( + providers[p])), + ]) + + tb = table(fields, providers, sortby="PROVIDER ID") + tb.align['PROVIDER ID'] = 'l' + tb.align['AUTHENTICATION TYPE'] = 'l' + + return tb + + def slave_table(slaves): """Returns a PrettyTable representation of the provided DC/OS slaves diff --git a/cli/setup.py b/cli/setup.py index b8cb99c..f8815ac 100644 --- a/cli/setup.py +++ b/cli/setup.py @@ -66,9 +66,11 @@ setup( install_requires=[ 'dcos=={}'.format(dcoscli.version), 'docopt>=0.6, <1.0', + 'PyJWT==1.4.2', 'pkginfo==1.2.1', 'toml>=0.9, <1.0', 'virtualenv>=13.0, <16.0', + 'cryptography==1.6' ], # If there are data files included in your packages that need to be diff --git a/cli/tests/data/help/auth.txt b/cli/tests/data/help/auth.txt index 21b2546..5171f59 100644 --- a/cli/tests/data/help/auth.txt +++ b/cli/tests/data/help/auth.txt @@ -4,19 +4,37 @@ Description: Usage: dcos auth --help dcos auth --info + dcos auth list-providers [--json] dcos auth login + [--provider=] [--username=] + [--password= | --password-file= + | --password-env= | --private-key=] dcos auth logout Commands: + list-providers + List configured authentication providers for your DC/OS cluster. login - Login to your DC/OS Cluster. + Login to your DC/OS cluster. logout - Logout of your DC/OS Cluster. + Logout of your DC/OS cluster. Options: -h, --help Print usage. --info Print a short description of this subcommand. + --password= + Specify password on the command line (insecure). + --password-env= + Specify environment variable name that contains the password. + --password-file= + Specify path to a file that contains the password. + --provider= + Specify authentication provider to use for login. + --private-key= + Specify path to file that contains the private key. + --username= + Specify username for login. --version Print version information. diff --git a/cli/tests/fixtures/auth_provider.py b/cli/tests/fixtures/auth_provider.py new file mode 100644 index 0000000..d0f4bef --- /dev/null +++ b/cli/tests/fixtures/auth_provider.py @@ -0,0 +1,17 @@ + +def auth_provider_fixture(): + """Auth provider fixture + + :rtype: dict + """ + + return { + "dcos-users": { + "authentication-type": "dcos-uid-password", + "client-method": "dcos-usercredential-post-receive-authtoken", + "config": { + "start_flow_url": "/acs/api/v1/auth/login" + }, + "description": "Default DC/OS authenticator" + } + } diff --git a/cli/tests/unit/data/auth_provider.txt b/cli/tests/unit/data/auth_provider.txt new file mode 100644 index 0000000..c8d60cd --- /dev/null +++ b/cli/tests/unit/data/auth_provider.txt @@ -0,0 +1,2 @@ +PROVIDER ID AUTHENTICATION TYPE +dcos-users Authenticate with a regular user account (using username and password) \ No newline at end of file diff --git a/cli/tests/unit/test_auth.py b/cli/tests/unit/test_auth.py new file mode 100644 index 0000000..538d175 --- /dev/null +++ b/cli/tests/unit/test_auth.py @@ -0,0 +1,107 @@ +import pytest +import requests +from mock import create_autospec, patch + +from dcos import auth +from dcos.errors import DCOSException + + +def test_get_auth_scheme(): + _get_auth_scheme({'www-authenticate': 'acsjwt'}, scheme='acsjwt') + _get_auth_scheme({'www-authenticate': 'oauthjwt'}, scheme='oauthjwt') + + msg = ("Server responded with an HTTP 'www-authenticate' field of " + "'foobar', DC/OS only supports ['oauthjwt', 'acsjwt']") + _get_auth_scheme_exception({'www-authenticate': 'foobar'}, msg) + + msg = ("Invalid HTTP response: server returned an HTTP 401 response " + "with no 'www-authenticate' field") + _get_auth_scheme_exception({}, msg) + + +def _get_auth_scheme(header, scheme): + with patch('requests.Response') as mock: + mock.headers = header + auth_scheme = auth._get_auth_scheme(mock) + assert auth_scheme == scheme + + +def _get_auth_scheme_exception(header, err_msg): + with patch('requests.Response') as mock: + mock.headers = header + with pytest.raises(DCOSException) as e: + auth._get_auth_scheme(mock) + + assert str(e.value) == err_msg + + +@patch('dcos.http._request') +@patch('dcos.config.set_val') +def test_get_dcostoken_by_post_with_creds(config, req): + creds = {"foobar"} + resp = create_autospec(requests.Response) + resp.status_code = 200 + resp.json.return_value = {"token": "foo"} + req.return_value = resp + + auth._get_dcostoken_by_post_with_creds("http://url", creds) + req.assert_called_with( + "post", "http://url/acs/api/v1/auth/login", json=creds) + config.assert_called_with("core.dcos_acs_token", "foo") + + +@patch('dcos.http._request') +@patch('dcos.auth._get_dcostoken_by_oidc_implicit_flow') +@patch('dcos.auth._get_dcostoken_by_dcos_uid_password_auth') +def test_header_challenge_auth(cred_auth, oidc_auth, req): + resp = create_autospec(requests.Response) + resp.status_code = 401 + resp.headers = {"www-authenticate": "oauthjwt"} + req.return_value = resp + + auth.header_challenge_auth("url") + oidc_auth.assert_called_once() + + resp2 = create_autospec(requests.Response) + resp2.status_code = 401 + resp2.headers = {"www-authenticate": "acsjwt"} + req.return_value = resp2 + + auth.header_challenge_auth("url") + cred_auth.assert_called_once() + + +@patch('dcos.http.get') +@patch('dcos.config.get_config_val') +def test_get_providers(config, req_mock): + resp = create_autospec(requests.Response) + resp.return_value = {} + req_mock.return_value = resp + config.return_value = "http://localhost" + + auth.get_providers() + req_mock.assert_called_with( + "http://localhost/acs/api/v1/auth/providers") + + # test url construction valid with trailing slash + config.return_value = "http://localhost/" + + auth.get_providers() + req_mock.assert_called_with( + "http://localhost/acs/api/v1/auth/providers") + + +@patch('dcos.http._request') +@patch('dcos.config.get_config_val') +def test_get_providers_errors(config, req): + config.return_value = "http://localhost" + + resp = create_autospec(requests.Response) + resp.status_code = 404 + req.return_value = resp + + with pytest.raises(DCOSException) as e: + auth.get_providers() + + err_msg = "This command is not supported for your cluster" + assert str(e.value) == err_msg diff --git a/cli/tests/unit/test_http_auth.py b/cli/tests/unit/test_http_auth.py deleted file mode 100644 index 26c0013..0000000 --- a/cli/tests/unit/test_http_auth.py +++ /dev/null @@ -1,201 +0,0 @@ -import copy - -import pytest -from mock import Mock, patch -from requests.auth import HTTPBasicAuth -from six.moves.urllib.parse import urlparse - -from dcos import http -from dcos.errors import DCOSException - - -def test_get_auth_scheme_basic(): - with patch('requests.Response') as mock: - mock.headers = {'www-authenticate': 'Basic realm="Restricted"'} - auth_scheme, realm = http.get_auth_scheme(mock) - assert auth_scheme == "basic" - assert realm == "restricted" - - -def test_get_auth_scheme_acs(): - with patch('requests.Response') as mock: - mock.headers = {'www-authenticate': 'acsjwt'} - auth_scheme, realm = http.get_auth_scheme(mock) - assert auth_scheme == "acsjwt" - 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': ''} - res = http.get_auth_scheme(mock) - assert res == (None, None) - - -@patch('requests.Response') -def test_get_http_auth_not_supported(mock): - mock.headers = {'www-authenticate': 'test'} - mock.url = '' - with pytest.raises(DCOSException) as e: - http._get_http_auth(mock, url=urlparse(''), auth_scheme='foo') - - msg = ("Server responded with an HTTP 'www-authenticate' field of " - "'test', DC/OS only supports 'Basic'") - assert e.exconly().split(':')[1].strip() == msg - - -@patch('requests.Response') -def test_get_http_auth_bad_response(mock): - mock.headers = {} - mock.url = '' - with pytest.raises(DCOSException) as e: - http._get_http_auth(mock, url=urlparse(''), auth_scheme='') - - msg = ("Invalid HTTP response: server returned an HTTP 401 response " - "with no 'www-authenticate' field") - assert e.exconly().split(':', 1)[1].strip() == msg - - -@patch('dcos.http._get_auth_credentials') -def test_get_http_auth_credentials_basic(auth_mock): - m = Mock() - m.url = 'http://domain.com' - m.headers = {'www-authenticate': 'Basic realm="Restricted"'} - auth_mock.return_value = ("username", "password") - - returned_auth = http._get_http_auth(m, urlparse(m.url), "basic") - assert type(returned_auth) == HTTPBasicAuth - assert returned_auth.username == "username" - assert returned_auth.password == "password" - - -@patch('dcos.http._get_auth_credentials') -@patch('dcos.http._request') -def test_get_http_auth_credentials_acl(req_mock, auth_mock): - m = Mock() - m.url = 'http://domain.com' - m.headers = {'www-authenticate': 'acsjwt"'} - auth_mock.return_value = ("username", "password") - req_mock.status_code = 404 - - returned_auth = http._get_http_auth(m, urlparse(m.url), "acsjwt") - assert type(returned_auth) == http.DCOSAcsAuth - - -@patch('requests.Response') -@patch('dcos.http._request') -@patch('dcos.http._get_http_auth') -def test_request_with_bad_auth_basic(mock, req_mock, auth_mock): - mock.url = 'http://domain.com' - mock.headers = {'www-authenticate': 'Basic realm="Restricted"'} - mock.status_code = 401 - - auth_mock.return_value = HTTPBasicAuth("username", "password") - - req_mock.return_value = mock - - with pytest.raises(DCOSException) as e: - http.request_with_auth("method", mock.url) - msg = "Authentication failed. Please run `dcos auth login`" - assert e.exconly().split(':')[1].strip() == msg - - -@patch('requests.Response') -@patch('dcos.http._request') -@patch('dcos.http._get_http_auth') -def test_request_with_bad_auth_acl(mock, req_mock, auth_mock): - mock.url = 'http://domain.com' - mock.headers = {'www-authenticate': 'acsjwt'} - 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("method", mock.url) - msg = "Your core.dcos_acs_token is invalid. Please run: `dcos auth login`" - assert e.exconly().split(':', 1)[1].strip() == msg - - -@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("method", mock.url) - msg = "Your core.dcos_acs_token is invalid. Please run: `dcos auth login`" - assert e.exconly().split(':', 1)[1].strip() == msg - - -@patch('requests.Response') -@patch('dcos.http._request') -@patch('dcos.http._get_http_auth') -def test_request_with_auth_basic(mock, req_mock, auth_mock): - mock.url = 'http://domain.com' - mock.headers = {'www-authenticate': 'Basic realm="Restricted"'} - mock.status_code = 401 - - auth = HTTPBasicAuth("username", "password") - auth_mock.return_value = auth - - mock2 = copy.deepcopy(mock) - mock2.status_code = 200 - req_mock.return_value = mock2 - - response = http.request_with_auth("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_acl(mock, req_mock, auth_mock): - mock.url = 'http://domain.com' - mock.headers = {'www-authenticate': 'acsjwt'} - 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("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("method", mock.url) - assert response.status_code == 200 diff --git a/cli/tests/unit/test_tables.py b/cli/tests/unit/test_tables.py index d10b91a..0e6d498 100644 --- a/cli/tests/unit/test_tables.py +++ b/cli/tests/unit/test_tables.py @@ -6,6 +6,7 @@ import pytz from dcos.mesos import Slave from dcoscli import tables +from ..fixtures.auth_provider import auth_provider_fixture from ..fixtures.marathon import (app_fixture, app_task_fixture, deployment_fixture_app_post_pods, deployment_fixture_app_pre_pods, @@ -73,6 +74,12 @@ def test_group_table(): 'tests/unit/data/group.txt') +def test_auth_providers_table(): + _test_table(tables.auth_provider_table, + auth_provider_fixture(), + 'tests/unit/data/auth_provider.txt') + + def test_pod_table(): _test_table(tables.pod_table, pod_list_fixture(), diff --git a/dcos/auth.py b/dcos/auth.py new file mode 100644 index 0000000..60e68fd --- /dev/null +++ b/dcos/auth.py @@ -0,0 +1,379 @@ +import getpass + +import sys + +import time + +import jwt + +from six.moves import urllib +from six.moves.urllib.parse import urlparse + +from dcos import config, http, util +from dcos.errors import (DCOSAuthenticationException, DCOSException, + DCOSHTTPException) + + +def _get_auth_scheme(response): + """Return authentication scheme requested by server for + 'acsjwt' (DC/OS acs auth) or 'oauthjwt' (DC/OS acs oauth) type + + :param response: requests.response + :type response: requests.Response + :returns: auth_scheme + :rtype: str + """ + + if 'www-authenticate' in response.headers: + auths = response.headers['www-authenticate'].split(',') + scheme = next((auth_type.rstrip().lower() for auth_type in auths + if auth_type.rstrip().lower().startswith("acsjwt") or + auth_type.rstrip().lower().startswith("oauthjwt")), + None) + if scheme: + scheme_info = scheme.split("=") + auth_scheme = scheme_info[0].split(" ")[0].lower() + return auth_scheme + else: + msg = ("Server responded with an HTTP 'www-authenticate' field of " + "'{}', DC/OS only supports ['oauthjwt', 'acsjwt']".format( + response.headers['www-authenticate'])) + raise DCOSException(msg) + else: + msg = ("Invalid HTTP response: server returned an HTTP 401 response " + "with no 'www-authenticate' field") + raise DCOSException(msg) + + +def _prompt_user_for_token(url, token_type): + """Get Token from user + + :param url: url for user to go to + :type url: str + :param token_type: type of token to be received + :type token_type: str + :returns: token show to user by browser + :rtype: str + """ + + msg = "\n{}\n\n {}\n\nEnter {}:".format( + "Please go to the following link in your browser:", + url, + token_type) + sys.stderr.write(msg) + sys.stderr.flush() + token = sys.stdin.readline().strip() + return token + + +def _get_dcostoken_by_post_with_creds(dcos_url, creds): + """ + Get DC/OS Authentication token by POST to `acs/api/v1/auth/login` + with specific credentials. Credentials can be uid/password for + username/password authentication, OIDC ID token for implicit OIDC flow + (used for open DC/OS), or uid/token for service accounts (where token is a + jwt token encoded with private key). + + :param dcos_url: url to cluster + :type dcos_url: str + :param creds: credentials to login endpoint + :type creds: {} + :returns: DC/OS Authentication Token + :rtype: str + """ + + url = dcos_url.rstrip('/') + '/acs/api/v1/auth/login' + response = http._request('post', url, json=creds) + + token = None + if response.status_code == 200: + token = response.json()['token'] + config.set_val("core.dcos_acs_token", token) + + return token + + +def _prompt_for_uid_password(username, hostname): + """Get username/password for auth + + :param username: username user for authentication + :type username: str + :param hostname: hostname for credentials + :type hostname: str + :returns: username, password + :rtype: str, str + """ + + if username is None: + sys.stdout.write("{}'s username: ".format(hostname)) + sys.stdout.flush() + username = sys.stdin.readline().strip() + + password = getpass.getpass("{}@{}'s password: ".format(username, hostname)) + + return username, password + + +def _get_dcostoken_by_dcos_uid_password_auth( + dcos_url, username=None, password=None): + """ + Get DC/OS Authentication Token by DC/OS uid password auth + + :param dcos_url: url to cluster + :type dcos_url: str + :param username: username to auth with + :type username: str + :param password: password to auth with + :type password: str + :returns: DC/OS Authentication Token if successful login + :rtype: str + """ + + url = urlparse(dcos_url) + hostname = url.hostname + username = username or url.username + password = password or url.password + + if password is None: + username, password = _prompt_for_uid_password(username, hostname) + + creds = {"uid": username, "password": password} + return _get_dcostoken_by_post_with_creds(dcos_url, creds) + + +def dcos_uid_password_auth(dcos_url, username=None, password=None): + """ + Authenticate user using DC/OS uid password auth + + Raises exception if authentication fails. + + :param dcos_url: url to cluster + :type dcos_url: str + :param username: username to auth with + :type username: str + :param password: password to auth with + :type password: str + :rtype: None + """ + + dcos_token = _get_dcostoken_by_dcos_uid_password_auth( + dcos_url, username, password) + if not dcos_token: + raise DCOSException("Authentication failed") + else: + return + + +def dcos_cred_auth(dcos_url, username=None, password=None): + """ + Authenticate user using DC/OS credentials + + Raises exception if authentication fails. + + :param dcos_url: url to cluster + :type dcos_url: str + :param username: username to auth with + :type username: str + :param password: password to auth with + :type password: str + :rtype: None + """ + + dcos_token = _get_dcostoken_by_dcos_uid_password_auth( + dcos_url, username, password) + if not dcos_token: + raise DCOSException("Authentication failed") + else: + return + + +def oidc_implicit_flow_auth(dcos_url): + """ + Authenticate user using OIDC implict flow + + Raises exception if authentication fails. + + :param dcos_url: url to cluster + :type dcos_url: str + :rtype: None + """ + + dcos_auth_token = _get_dcostoken_by_oidc_implicit_flow(dcos_url) + if not dcos_auth_token: + raise DCOSException("Authentication failed") + else: + return + + +def _get_dcostoken_by_oidc_implicit_flow(dcos_url): + """ + Get DC/OS Authentication Token by OIDC implicit flow + + :param dcos_url: url to cluster + :type dcos_url: str + :returns: DC/OS Authentication Token + :rtype: str + """ + + oauth_login = '/login?redirect_uri=urn:ietf:wg:oauth:2.0:oob' + url = dcos_url.rstrip('/') + oauth_login + oidc_token = _prompt_user_for_token(url, "OpenID Connect ID Token") + creds = {"token": oidc_token} + return _get_dcostoken_by_post_with_creds(dcos_url, creds) + + +def servicecred_auth(dcos_url, username, key_path): + """ + Get DC/OS Authentication token by browser prompt + + :param dcos_url: url to cluster + :type dcos_url: str + :param username: username user for authentication + :type username: str + :param key_path: path to service key + :param key_path: str + :rtype: None + """ + + # 'token' below contains a short lived service login token. This requires + # the local machine to be in sync with DC/OS nodes enough that the 5min + # padding here is enough time to validate the token. + creds = { + 'uid': username, + 'token': jwt.encode( + { + 'exp': int(time.time()+5*60), + 'uid': username + }, + util.read_file_secure(key_path), + algorithm='RS256') + .decode('ascii') + } + + dcos_token = _get_dcostoken_by_post_with_creds(dcos_url, creds) + if not dcos_token: + raise DCOSException("Authentication failed") + else: + return + + +def browser_prompt_auth(dcos_url, provider_info): + """ + Get DC/OS Authentication token by browser prompt + + :param dcos_url: url to cluster + :type dcos_url: str + :param provider_info: info about provider to auth with + :param provider_info: str + :rtype: None + """ + + start_flow_url = provider_info["config"]["start_flow_url"].lstrip('/') + if not urlparse(start_flow_url).netloc: + start_flow_url = dcos_url.rstrip('/') + start_flow_url + + dcos_token = _prompt_user_for_token( + start_flow_url, "DC/OS Authentication Token") + + # verify token + endpoint = '/pkgpanda/active.buildinfo.full.json' + url = urllib.parse.urljoin(dcos_url, endpoint) + response = http._request('HEAD', url, auth=http.DCOSAcsAuth(dcos_token)) + if response.status_code in [200, 403]: + config.set_val("core.dcos_acs_token", dcos_token) + else: + raise DCOSException("Authentication failed") + + +def header_challenge_auth(dcos_url): + """ + Triggers authentication using scheme specified in www-authenticate header. + + Raises exception if authentication fails. + + :param dcos_url: url to cluster + :type dcos_url: str + :rtype: None + """ + + # hit protected endpoint which will prompt for auth if cluster has auth + endpoint = '/pkgpanda/active.buildinfo.full.json' + url = urllib.parse.urljoin(dcos_url, endpoint) + response = http._request('HEAD', url) + auth_scheme = _get_auth_scheme(response) + + for _ in range(3): + if response.status_code == 401: + # this header claims the cluster is open DC/OS 1.7, 1.8 or 1.9 + # and supports OIDC implicit auth + if auth_scheme == "oauthjwt": + token = _get_dcostoken_by_oidc_implicit_flow(dcos_url) + # auth_scheme == "acsjwt" + # this header claims the cluster is enterprise DC/OS 1.7, 1.8 or + # 1.9 and supports username/pawword auth + else: + token = _get_dcostoken_by_dcos_uid_password_auth(dcos_url) + + if token is not None: + response.status_code = 200 + break + else: + raise DCOSAuthenticationException(response) + + +def get_providers(): + """ + Returns dict of providers configured on cluster + + :returns: configured providers + :rtype: {} + """ + + dcos_url = config.get_config_val("core.dcos_url").rstrip('/') + endpoint = '/acs/api/v1/auth/providers' + url = urllib.parse.urljoin(dcos_url, endpoint) + try: + providers = http.get(url) + return providers.json() + # this endpoint should only have authentication in DC/OS 1.8 + except DCOSAuthenticationException: + msg = "This command is not supported for your cluster" + raise DCOSException(msg) + except DCOSHTTPException as e: + if e.response.status_code == 404: + msg = "This command is not supported for your cluster" + raise DCOSException(msg) + + return {} + + +def auth_type_description(provider_info): + """ + Returns human readable description of auth type + + :param provider_info: info about auth provider + :type provider_info: dict + :returns: human readable description of auth type + :rtype: str + """ + + auth_type = provider_info.get("authentication-type") + if auth_type == "dcos-uid-password": + msg = ("Authenticate with a regular user account " + "(using username and password)") + elif auth_type == "dcos-uid-servicekey": + msg = ("Authenticate with a service user account " + "(using username and private key)") + elif auth_type == "dcos-uid-password-ldap": + msg = ("Authenticate with a LDAP user account " + "(using username and password)") + elif auth_type == "saml-sp-initiated": + msg = "Authenticate via SAML 2.0 ({})".format( + provider_info["description"]) + elif auth_type in ["oidc-authorization-code-flow", "oidc-implicit-flow"]: + msg = "Authenticate via OpenID Connect ({})".format( + provider_info["description"]) + else: + raise DCOSException("Unknown authentication type") + + return msg diff --git a/dcos/config.py b/dcos/config.py index 1c2cc3b..a847331 100644 --- a/dcos/config.py +++ b/dcos/config.py @@ -2,8 +2,6 @@ import collections import copy import json import os -import stat -import sys import pkg_resources import toml @@ -155,28 +153,6 @@ def set_val(name, value): return toml_config, msg -def _enforce_config_permissions(path): - """Enfore 600 permissions on config file - - :param path: Path to the TOML file - :type path: str - :rtype: None - """ - - # Unix permissions are incompatible with windows - # TODO: https://github.com/dcos/dcos-cli/issues/662 - if sys.platform == 'win32': - return - else: - permissions = oct(stat.S_IMODE(os.lstat(path).st_mode)) - if permissions not in ['0o600', '0600']: - msg = ( - "Permissions '{}' for configuration file '{}' are too open. " - "File must only be accessible by owner. " - "Aborting...".format(permissions, path)) - raise DCOSException(msg) - - def load_from_path(path, mutable=False): """Loads a TOML file from the path @@ -189,7 +165,7 @@ def load_from_path(path, mutable=False): """ util.ensure_file_exists(path) - _enforce_config_permissions(path) + util.enforce_file_permissions(path) with util.open_file(path, 'r') as config_file: try: toml_obj = toml.loads(config_file.read()) @@ -207,7 +183,9 @@ def save(toml_config): serial = toml.dumps(toml_config._dictionary) path = get_config_path() - _enforce_config_permissions(path) + + util.ensure_file_exists(path) + util.enforce_file_permissions(path) with util.open_file(path, 'w') as config_file: config_file.write(serial) diff --git a/dcos/http.py b/dcos/http.py index d2c918d..672c01a 100644 --- a/dcos/http.py +++ b/dcos/http.py @@ -1,11 +1,5 @@ -import getpass -import sys -import threading - import requests -from requests.auth import AuthBase, HTTPBasicAuth -from six.moves import urllib -from six.moves.urllib.parse import urlparse +from requests.auth import AuthBase from dcos import config, util from dcos.errors import (DCOSAuthenticationException, @@ -14,13 +8,9 @@ from dcos.errors import (DCOSAuthenticationException, DCOSUnprocessableException) logger = util.get_logger(__name__) -lock = threading.Lock() DEFAULT_TIMEOUT = 5 -# only accessed from request_with_auth -AUTH_CREDS = {} # (hostname, auth_scheme, realm) -> AuthBase() - def _default_is_success(status_code): """Returns true if the success status is between [200, 300). @@ -129,70 +119,6 @@ def _request(method, return response -def request_with_auth(method, - url, - is_success=_default_is_success, - timeout=None, - verify=None, - **kwargs): - """Try request (3 times) with credentials if 401 returned from server - - :param method: method for the new Request object - :type method: str - :param url: URL for the new Request object - :type url: str - :param is_success: Defines successful status codes for the request - :type is_success: Function from int to bool - :param timeout: request timeout - :type timeout: int - :param verify: whether to verify SSL certs or path to cert(s) - :type verify: bool | str - :param kwargs: Additional arguments to requests.request - (see http://docs.python-requests.org/en/latest/api/#requests.request) - :type kwargs: dict - :rtype: requests.Response - """ - - response = _request(method, url, is_success, timeout, verify, **kwargs) - - i = 0 - while i < 3 and response.status_code == 401: - parsed_url = urlparse(url) - hostname = parsed_url.hostname - auth_scheme, realm = get_auth_scheme(response) - creds = (hostname, auth_scheme, realm) - - with lock: - if creds not in AUTH_CREDS: - auth = _get_http_auth(response, parsed_url, auth_scheme) - else: - auth = AUTH_CREDS[creds] - - # try request again, with auth - response = _request(method, url, is_success, timeout, auth, - verify, **kwargs) - - # only store credentials if they're valid - with lock: - 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 in ["acsjwt", "oauthjwt"]: - - if config.get_config_val("core.dcos_acs_token") is not None: - msg = ("Your core.dcos_acs_token is invalid. " - "Please run: `dcos auth login`") - raise DCOSException(msg) - - i += 1 - - if response.status_code == 401: - raise DCOSAuthenticationException(response) - - return response - - def request(method, url, is_success=_default_is_success, @@ -343,166 +269,6 @@ def silence_requests_warnings(): requests.packages.urllib3.disable_warnings() -def _get_auth_credentials(username, hostname): - """Get username/password for auth - - :param username: username user for authentication - :type username: str - :param hostname: hostname for credentials - :type hostname: str - :returns: username, password - :rtype: str, str - """ - - if username is None: - sys.stdout.write("{}'s username: ".format(hostname)) - sys.stdout.flush() - username = sys.stdin.readline().strip() - - password = getpass.getpass("{}@{}'s password: ".format(username, hostname)) - - return username, password - - -def get_auth_scheme(response): - """Return authentication scheme and realm requested by server for 'Basic' - or 'acsjwt' (DC/OS acs auth) or 'oauthjwt' (DC/OS acs oauth) type - - :param response: requests.response - :type response: requests.Response - :returns: auth_scheme, realm - :rtype: (str, str) - """ - - if 'www-authenticate' in response.headers: - 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") or - auth_type.rstrip().lower().startswith("oauthjwt")), - None) - if scheme: - scheme_info = scheme.split("=") - auth_scheme = scheme_info[0].split(" ")[0].lower() - realm = scheme_info[-1].strip(' \'\"').lower() - return auth_scheme, realm - else: - return None, None - else: - return None, None - - -def _get_http_auth(response, url, auth_scheme): - """Get authentication mechanism required by server - - :param response: requests.response - :type response: requests.Response - :param url: parsed request url - :type url: str - :param auth_scheme: str - :type auth_scheme: str - :returns: AuthBase - :rtype: AuthBase - """ - - hostname = url.hostname - username = url.username - password = url.password - - if 'www-authenticate' in response.headers: - if auth_scheme not in ['basic', 'acsjwt', 'oauthjwt']: - msg = ("Server responded with an HTTP 'www-authenticate' field of " - "'{}', DC/OS only supports 'Basic'".format( - response.headers['www-authenticate'])) - raise DCOSException(msg) - - if auth_scheme == 'basic': - # for basic auth if username + password was present, - # 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_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_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 OpenID Connect ID 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 - :type password: str - :param hostname: hostname for credentials - :type hostname: str - :returns: DCOSAcsAuth - :rtype: AuthBase - """ - - toml_config = config.get_config() - token = config.get_config_val("core.dcos_acs_token", toml_config) - if token is None: - dcos_url = config.get_config_val("core.dcos_url", toml_config) - if auth_scheme == "acsjwt": - creds = _get_dcos_acs_auth_creds(username, password, hostname) - else: - creds = _get_dcos_oauth_creds(dcos_url) - - url = urllib.parse.urljoin(dcos_url, 'acs/api/v1/auth/login') - response = _request('post', url, json=creds) - - if response.status_code == 200: - token = response.json()['token'] - config.set_val("core.dcos_acs_token", token) - - return DCOSAcsAuth(token) - - class DCOSAcsAuth(AuthBase): """Invokes DCOS Authentication flow for given Request object.""" def __init__(self, token): diff --git a/dcos/util.py b/dcos/util.py index 153f61e..2d4a7b4 100644 --- a/dcos/util.py +++ b/dcos/util.py @@ -8,6 +8,7 @@ import os import platform import re import shutil +import stat import sys import tempfile import time @@ -157,9 +158,50 @@ def read_file(path): :returns: contents of file :rtype: str """ + if not os.path.isfile(path): + raise DCOSException('path [{}] is not a file'.format(path)) + + with open_file(path) as file_: + return file_.read() + + +def enforce_file_permissions(path): + """Enfore 600 permissions on file + + :param path: Path to the TOML file + :type path: str + :rtype: None + """ + if not os.path.isfile(path): raise DCOSException('Path [{}] is not a file'.format(path)) + # Unix permissions are incompatible with windows + # TODO: https://github.com/dcos/dcos-cli/issues/662 + if sys.platform == 'win32': + return + else: + permissions = oct(stat.S_IMODE(os.lstat(path).st_mode)) + if permissions not in ['0o600', '0600']: + msg = ( + "Permissions '{}' for configuration file '{}' are too open. " + "File must only be accessible by owner. " + "Aborting...".format(permissions, path)) + raise DCOSException(msg) + + +def read_file_secure(path): + """ + Enfore 600 permissions when reading file + + :param path: path to file + :type path: str + :returns: contents of file + :rtype: str + """ + + enforce_file_permissions(path) + with open_file(path) as file_: return file_.read()