auth: new flow allowing auth from CLI for all configured providers (#837)
This commit is contained in:
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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=[],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
17
cli/tests/fixtures/auth_provider.py
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
2
cli/tests/unit/data/auth_provider.txt
Normal file
2
cli/tests/unit/data/auth_provider.txt
Normal 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
107
cli/tests/unit/test_auth.py
Normal 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
|
||||
@@ -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
|
||||
@@ -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
379
dcos/auth.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
|
||||
236
dcos/http.py
236
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):
|
||||
|
||||
42
dcos/util.py
42
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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user