dcos-4134 integrate with acs AuthN flow
This commit is contained in:
@@ -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
|
||||
|
||||
112
dcos/http.py
112
dcos/http.py
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user