Skip to content

Commit

Permalink
Merge pull request #423 from mesosphere/dcos-4134
Browse files Browse the repository at this point in the history
dcos-4134 integrate with acs AuthN flow
  • Loading branch information
tamarrow committed Jan 27, 2016
2 parents df0f87a + 36071ff commit 76a31d4
Show file tree
Hide file tree
Showing 2 changed files with 159 additions and 52 deletions.
99 changes: 78 additions & 21 deletions cli/tests/integrations/test_http_auth.py
Expand Up @@ -6,68 +6,106 @@

import pytest
from mock import Mock, patch
from six.moves.urllib.parse import urlparse


def test_get_realm_good_request():
def test_get_auth_scheme_basic():
with patch('requests.Response') as mock:
mock.headers = {'www-authenticate': 'Basic realm="Restricted"'}
res = http._get_realm(mock)
assert res == "restricted"
auth_scheme, realm = http.get_auth_scheme(mock)
assert auth_scheme == "basic"
assert realm == "restricted"


def test_get_realm_bad_request():
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_bad_request():
with patch('requests.Response') as mock:
mock.headers = {'www-authenticate': ''}
res = http._get_realm(mock)
res = http.get_auth_scheme(mock)
assert res is None


@patch('requests.Response')
def test_get_http_auth_credentials_not_supported(mock):
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_credentials(mock)
http._get_http_auth(mock, url=urlparse(''), auth_scheme='foo')

msg = ("Server responded with an HTTP 'www-authenticate' field of "
"'test', DCOS only supports 'Basic'")
assert e.exconly().split(':')[1].strip() == msg


@patch('requests.Response')
def test_get_http_auth_credentials_bad_response(mock):
def test_get_http_auth_bad_response(mock):
mock.headers = {}
mock.url = ''
with pytest.raises(DCOSException) as e:
http._get_http_auth_credentials(mock)
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_basic_auth_credentials')
def test_get_http_auth_credentials_good_reponse(auth_mock):
@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 = HTTPBasicAuth("username", "password")
auth_mock.return_value = auth
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')
def test_get_http_auth_credentials_acl(auth_mock):
m = Mock()
m.url = 'http://domain.com'
m.headers = {'www-authenticate': 'acsjwt"'}
auth_mock.return_value = ("username", "password")

returned_auth = http._get_http_auth_credentials(m)
assert returned_auth == auth
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_basic_auth_credentials')
def test_request_with_bad_auth(mock, req_mock, auth_mock):
@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 = HTTPBasicAuth("username", "password")
auth_mock.return_value = auth
auth_mock.return_value = HTTPBasicAuth("username", "password")

req_mock.return_value = mock

with pytest.raises(DCOSException) as e:
http._request_with_auth(mock, "method", mock.url)
assert e.exconly().split(':')[1].strip() == "Authentication failed"


@patch('requests.Response')
@patch('dcos.http._request')
@patch('dcos.http._get_http_auth')
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

Expand All @@ -78,8 +116,8 @@ def test_request_with_bad_auth(mock, req_mock, auth_mock):

@patch('requests.Response')
@patch('dcos.http._request')
@patch('dcos.http._get_basic_auth_credentials')
def test_request_with_auth(mock, req_mock, auth_mock):
@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
Expand All @@ -93,3 +131,22 @@ def test_request_with_auth(mock, req_mock, auth_mock):

response = http._request_with_auth(mock, "method", mock.url)
assert response.status_code == 200


@patch('requests.Response')
@patch('dcos.http._request')
@patch('dcos.http._get_http_auth')
def test_request_with_auth_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(mock, "method", mock.url)
assert response.status_code == 200
112 changes: 81 additions & 31 deletions dcos/http.py
Expand Up @@ -6,8 +6,9 @@
import requests
from dcos import constants, util
from dcos.errors import DCOSException, DCOSHTTPException
from requests.auth import HTTPBasicAuth
from requests.auth import AuthBase, HTTPBasicAuth

from six.moves import urllib
from six.moves.urllib.parse import urlparse

logger = util.get_logger(__name__)
Expand All @@ -16,7 +17,7 @@
DEFAULT_TIMEOUT = 5

# only accessed from _request_with_auth
AUTH_CREDS = {} # (hostname, realm) -> AuthBase()
AUTH_CREDS = {} # (hostname, auth_scheme, realm) -> AuthBase()


def _default_is_success(status_code):
Expand Down Expand Up @@ -118,12 +119,14 @@ def _request_with_auth(response,
"""
i = 0
while i < 3 and response.status_code == 401:
hostname = urlparse(response.url).hostname
creds = (hostname, _get_realm(response))
parsed_url = urlparse(response.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_credentials(response)
auth = _get_http_auth(response, parsed_url, auth_scheme)
else:
auth = AUTH_CREDS[creds]

Expand Down Expand Up @@ -293,15 +296,15 @@ def silence_requests_warnings():
requests.packages.urllib3.disable_warnings()


def _get_basic_auth_credentials(username, hostname):
"""Get username/password for basic auth
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: HTTPBasicAuth
:rtype: requests.auth.HTTPBasicAuth
:returns: username, password
:rtype: str, str
"""

if username is None:
Expand All @@ -311,55 +314,102 @@ def _get_basic_auth_credentials(username, hostname):

password = getpass.getpass("{}@{}'s password: ".format(username, hostname))

return HTTPBasicAuth(username, password)
return username, password


def _get_realm(response):
"""Return authentication realm requested by server for 'Basic' type or None
def get_auth_scheme(response):
"""Return authentication scheme and realm requested by server for 'Basic'
or 'acsjwt' (DCOS acs auth) type or None
:param response: requests.response
:type response: requests.Response
:returns: realm
:rtype: str | None
:returns: auth_scheme, realm
:rtype: (str, str) | None
"""

if 'www-authenticate' in response.headers:
auths = response.headers['www-authenticate'].split(',')
basic_realm = next((auth_type for auth_type in auths
if auth_type.rstrip().lower().startswith("basic")),
None)
if basic_realm:
realm = basic_realm.split('=')[-1].strip(' \'\"').lower()
return realm
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")),
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
else:
return None


def _get_http_auth_credentials(response):
"""Get authentication credentials required by server
def _get_http_auth(response, url, auth_scheme):
"""Get authentication mechanism required by server
:param response: requests.response
:type response: requests.Response
:returns: HTTPBasicAuth
:rtype: HTTPBasicAuth
:param url: parsed request url
:type url: str
:param auth_scheme: str
:type auth_scheme: str
:returns: AuthBase
:rtype: AuthBase
"""

parsed_url = urlparse(response.url)
hostname = parsed_url.hostname
user = parsed_url.username
hostname = url.hostname
username = url.username

if 'www-authenticate' in response.headers:
realm = _get_realm(response)
if realm:
return _get_basic_auth_credentials(user, hostname)
else:
if auth_scheme not in ['basic', 'acsjwt']:
msg = ("Server responded with an HTTP 'www-authenticate' field of "
"'{}', DCOS only supports 'Basic'".format(
response.headers['www-authenticate']))
raise DCOSException(msg)

username, password = _get_auth_credentials(username, hostname)
if auth_scheme == 'basic':
return HTTPBasicAuth(username, password)
else:
return _get_dcos_acs_auth(username, password)
else:
msg = ("Invalid HTTP response: server returned an HTTP 401 response "
"with no 'www-authenticate' field")
raise DCOSException(msg)


def _get_dcos_acs_auth(uid, password):
"""Get authentication flow for dcos acs auth
:param uid: uid
:type uid: str
:param password: password
:type password: str
:returns: DCOSAcsAuth
:rtype: AuthBase
"""

dcos_url = util.get_config_vals(
['core.dcos_url'], util.get_config())[0]
url = urllib.parse.urljoin(dcos_url, 'acs/api/v1/auth/login')
creds = {"uid": uid, "password": password}

# using private method here, so we don't retry on this request
# error here will be bubbled up to _request_with_auth
response = _request('post', url, json=creds)

token = None
if response.status_code == 200:
token = response.json()['token']

return DCOSAcsAuth(token)


class DCOSAcsAuth(AuthBase):
"""Invokes DCOS Authentication flow for given Request object."""
def __init__(self, token):
self.token = token

def __call__(self, r):
r.headers['Authorization'] = "token={}".format(self.token)
return r

0 comments on commit 76a31d4

Please sign in to comment.