dcos-4134 integrate with acs AuthN flow

This commit is contained in:
Tamar Ben-Shachar
2016-01-04 16:57:49 -08:00
parent df0f87ad67
commit 36071ff4a7
2 changed files with 159 additions and 52 deletions

View File

@@ -6,28 +6,38 @@ from requests.auth import HTTPBasicAuth
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'")
@@ -35,39 +45,50 @@ def test_get_http_auth_credentials_not_supported(mock):
@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_credentials(m)
assert returned_auth == auth
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(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
@@ -78,8 +99,25 @@ 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_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(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_auth_basic(mock, req_mock, auth_mock):
mock.url = 'http://domain.com'
mock.headers = {'www-authenticate': 'Basic realm="Restricted"'}
mock.status_code = 401
@@ -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

View File

@@ -6,8 +6,9 @@ import threading
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__)
@@ -16,7 +17,7 @@ lock = threading.Lock()
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):
@@ -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]
@@ -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:
@@ -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