auth: new flow allowing auth from CLI for all configured providers (#837)

This commit is contained in:
tamarrow
2016-12-12 14:23:03 -08:00
committed by GitHub
parent 234cd3b620
commit 25824dcd6c
17 changed files with 773 additions and 486 deletions

View File

@@ -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 \

View File

@@ -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

View File

@@ -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=[],

View File

@@ -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 <provider-id>` 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

View File

@@ -4,19 +4,37 @@ Description:
Usage:
dcos auth --help
dcos auth --info
dcos auth list-providers [--json]
dcos auth login
[--provider=<provider_id>] [--username=<username>]
[--password=<password> | --password-file=<password_file>
| --password-env=<password_env> | --private-key=<key_path>]
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=<password>
Specify password on the command line (insecure).
--password-env=<password_env>
Specify environment variable name that contains the password.
--password-file=<password_file>
Specify path to a file that contains the password.
--provider=<provider_id>
Specify authentication provider to use for login.
--private-key=<key_path>
Specify path to file that contains the private key.
--username=<username>
Specify username for login.
--version
Print version information.

View File

@@ -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

View File

@@ -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

View File

@@ -4,19 +4,37 @@ Description:
Usage:
dcos auth --help
dcos auth --info
dcos auth list-providers [--json]
dcos auth login
[--provider=<provider_id>] [--username=<username>]
[--password=<password> | --password-file=<password_file>
| --password-env=<password_env> | --private-key=<key_path>]
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=<password>
Specify password on the command line (insecure).
--password-env=<password_env>
Specify environment variable name that contains the password.
--password-file=<password_file>
Specify path to a file that contains the password.
--provider=<provider_id>
Specify authentication provider to use for login.
--private-key=<key_path>
Specify path to file that contains the private key.
--username=<username>
Specify username for login.
--version
Print version information.

17
cli/tests/fixtures/auth_provider.py vendored Normal file
View File

@@ -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"
}
}

View File

@@ -0,0 +1,2 @@
PROVIDER ID AUTHENTICATION TYPE
dcos-users Authenticate with a regular user account (using username and password)

107
cli/tests/unit/test_auth.py Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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(),

379
dcos/auth.py Normal file
View File

@@ -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

View File

@@ -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)

View File

@@ -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):

View File

@@ -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()