This repository has been archived by the owner on Dec 7, 2022. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 53
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Make requests to docker registry with bearer tokens
closes #1144 closes #1596
- Loading branch information
Showing
5 changed files
with
264 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,92 @@ | ||
from cStringIO import StringIO | ||
import json | ||
import logging | ||
import urllib | ||
import urlparse | ||
|
||
from nectar.request import DownloadRequest | ||
|
||
|
||
_logger = logging.getLogger(__name__) | ||
|
||
|
||
def add_auth_header(request, token): | ||
""" | ||
Adds the token into the request's headers as specified in the Docker v2 API documentation. | ||
https://docs.docker.com/registry/spec/auth/token/#using-the-bearer-token | ||
:param request: a download request | ||
:type request: nectar.request.DownloadRequest | ||
:param token: a Bearer token to be inserted into the Authorization header | ||
:type token: basestring | ||
""" | ||
if request.headers is None: | ||
request.headers = {} | ||
request.headers['Authorization'] = 'Bearer %s' % token | ||
|
||
|
||
def request_token(downloader, request, response_headers): | ||
""" | ||
Attempts to retrieve the correct token based on the 401 response header. | ||
According to the Docker API v2 documentation, the token be retrieved by issuing a GET | ||
request to the url specified by the `realm` within the `WWW-Authenticate` header. This | ||
request should add the following query parameters: | ||
service: the name of the service that hosts the desired resource | ||
scope: the specific resource and permissions requested | ||
https://docs.docker.com/registry/spec/auth/token/#requesting-a-token | ||
:param downloader: Nectar downloader that will be used to issue a download request | ||
:type downloader: nectar.downloaders.threaded.HTTPThreadedDownloader | ||
:param request: a download request | ||
:type request: nectar.request.DownloadRequest | ||
:param response_headers: headers from the 401 response | ||
:type response_headers: basestring | ||
:return: Bearer token for requested resource | ||
:rtype: str | ||
""" | ||
auth_info = parse_401_response_headers(response_headers) | ||
try: | ||
token_url = auth_info.pop('realm') | ||
except KeyError: | ||
raise IOError("No realm specified for token auth challenge.") | ||
|
||
parse_result = urlparse.urlparse(token_url) | ||
query_dict = urlparse.parse_qs(parse_result.query) | ||
query_dict.update(auth_info) | ||
url_pieces = list(parse_result) | ||
url_pieces[4] = urllib.urlencode(query_dict) | ||
token_url = urlparse.urlunparse(url_pieces) | ||
|
||
token_data = StringIO() | ||
token_request = DownloadRequest(token_url, token_data) | ||
_logger.debug("Requesting token from {url}".format(url=token_url)) | ||
report = downloader.download_one(token_request) | ||
if report.state == report.DOWNLOAD_FAILED: | ||
raise IOError(report.error_msg) | ||
|
||
return json.loads(token_data.getvalue())['token'] | ||
|
||
|
||
def parse_401_response_headers(response_headers): | ||
""" | ||
Parse the headers from a 401 response into a dictionary that contains the information | ||
necessary to retrieve a token. | ||
:param response_headers: headers returned in a 401 response | ||
:type response_headers: requests.structures.CaseInsensitiveDict | ||
""" | ||
auth_header = response_headers.get('www-authenticate') | ||
if auth_header is None: | ||
raise IOError("401 responses are expected to conatin authentication information") | ||
auth_header = auth_header[len("Bearer "):] | ||
|
||
# The remaining string consists of comma seperated key=value pairs | ||
auth_dict = {} | ||
for key, value in (item.split('=') for item in auth_header.split(',')): | ||
# The value is a string within a string, ex: '"value"' | ||
auth_dict[key] = json.loads(value) | ||
return auth_dict |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,93 @@ | ||
from pulp.common.compat import unittest | ||
import mock | ||
|
||
from pulp_docker.plugins import token_util | ||
|
||
|
||
class TestAddAuthHeader(unittest.TestCase): | ||
""" | ||
Tests for adding a bearer token to a request header. | ||
""" | ||
|
||
def test_no_headers(self): | ||
""" | ||
Test that when there are no existing headers, it is added. | ||
""" | ||
mock_req = mock.MagicMock() | ||
mock_req.headers = None | ||
|
||
token_util.add_auth_header(mock_req, "mock token") | ||
self.assertDictEqual(mock_req.headers, {"Authorization": "Bearer mock token"}) | ||
|
||
def test_with_headers(self): | ||
""" | ||
Test that when the headers exists, the auth token is added to it. | ||
""" | ||
mock_req = mock.MagicMock() | ||
mock_req.headers = {"mock": "header"} | ||
|
||
token_util.add_auth_header(mock_req, "mock token") | ||
self.assertDictEqual(mock_req.headers, {"Authorization": "Bearer mock token", | ||
"mock": "header"}) | ||
|
||
|
||
class TestRequestToken(unittest.TestCase): | ||
""" | ||
Tests for the utility to request a token from the response headers of a 401. | ||
""" | ||
@mock.patch('pulp_docker.plugins.token_util.parse_401_response_headers') | ||
def test_no_realm(self, mock_parse): | ||
""" | ||
When the realm is not specified, raise. | ||
""" | ||
m_downloader = mock.MagicMock() | ||
m_req = mock.MagicMock() | ||
m_headers = mock.MagicMock() | ||
resp_headers = {'missing': 'realm'} | ||
mock_parse.return_value = resp_headers | ||
self.assertRaises(IOError, token_util.request_token, m_downloader, m_req, m_headers) | ||
mock_parse.assert_called_once_with(m_headers) | ||
|
||
@mock.patch('pulp_docker.plugins.token_util.StringIO') | ||
@mock.patch('pulp_docker.plugins.token_util.DownloadRequest') | ||
@mock.patch('pulp_docker.plugins.token_util.urllib.urlencode') | ||
@mock.patch('pulp_docker.plugins.token_util.parse_401_response_headers') | ||
def test_as_expected(self, mock_parse, mock_encode, m_dl_req, m_string_io): | ||
""" | ||
Test that a request is created with correct query parameters to retrieve a bearer token. | ||
""" | ||
m_downloader = mock.MagicMock() | ||
m_req = mock.MagicMock() | ||
m_headers = mock.MagicMock() | ||
m_string_io.return_value.getvalue.return_value = '{"token": "Hey, its a token!"}' | ||
mock_parse.return_value = {'realm': 'url', 'other_info': 'stuff'} | ||
mock_encode.return_value = 'other_info=stuff' | ||
token_util.request_token(m_downloader, m_req, m_headers) | ||
|
||
mock_encode.assert_called_once_with({'other_info': 'stuff'}) | ||
m_dl_req.assert_called_once_with('url?other_info=stuff', m_string_io.return_value) | ||
mock_parse.assert_called_once_with(m_headers) | ||
m_downloader.download_one.assert_called_once_with(m_dl_req.return_value) | ||
|
||
|
||
class TestParse401ResponseHeaders(unittest.TestCase): | ||
""" | ||
Tests for parsing 401 headers. | ||
""" | ||
|
||
def test_missing_header(self): | ||
""" | ||
Raise if 401 does not include the header with authentication information. | ||
""" | ||
headers = {'missing-www-auth': 'should fail'} | ||
self.assertRaises(IOError, token_util.parse_401_response_headers, headers) | ||
|
||
def test_dict_created(self): | ||
""" | ||
Ensure that the www-authenticate header is correctly parsed into a dict. | ||
""" | ||
headers = {'www-authenticate': | ||
'Bearer realm="https://auth.docker.io/token",service="registry.docker.io"'} | ||
ret = token_util.parse_401_response_headers(headers) | ||
self.assertDictEqual(ret, {"realm": "https://auth.docker.io/token", | ||
"service": "registry.docker.io"}) |