Skip to content
This repository has been archived by the owner on Dec 7, 2022. It is now read-only.

Commit

Permalink
Make requests to docker registry with bearer tokens
Browse files Browse the repository at this point in the history
closes #1144
closes #1596
  • Loading branch information
asmacdo committed Feb 3, 2016
1 parent 02d5748 commit 2440e5f
Show file tree
Hide file tree
Showing 5 changed files with 264 additions and 4 deletions.
58 changes: 55 additions & 3 deletions plugins/pulp_docker/plugins/importers/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""
from gettext import gettext as _
import errno
import httplib
import itertools
import logging
import os
Expand All @@ -13,7 +14,7 @@
from pulp.server.exceptions import MissingValue, PulpCodedException

from pulp_docker.common import constants, error_codes
from pulp_docker.plugins import models, registry
from pulp_docker.plugins import models, registry, token_util
from pulp_docker.plugins.importers import v1_sync


Expand Down Expand Up @@ -68,7 +69,7 @@ def __init__(self, repo=None, conduit=None, config=None):

# determine which API versions are supported and add corresponding steps
v2_found = self.index_repository.api_version_check()
v1_enabled = config.get(constants.CONFIG_KEY_ENABLE_V1, default=True)
v1_enabled = config.get(constants.CONFIG_KEY_ENABLE_V1, default=False)
if not v1_enabled:
_logger.debug(_('v1 API skipped due to config'))
v1_found = v1_enabled and self.v1_index_repository.api_version_check()
Expand Down Expand Up @@ -104,7 +105,7 @@ def add_v2_steps(self, repo, conduit, config):
self.add_child(self.step_get_local_manifests)
self.add_child(self.step_get_local_blobs)
self.add_child(
publish_step.DownloadStep(
TokenAuthDownloadStep(
step_type=constants.SYNC_STEP_DOWNLOAD, downloads=self.generate_download_requests(),
repo=self.repo, config=self.config, description=_('Downloading remote files')))
self.add_child(SaveUnitsStep())
Expand Down Expand Up @@ -300,3 +301,54 @@ def process_main(self):
tag_name=tag, manifest_digest=manifest.digest)
if new_tag:
repository.associate_single_unit(self.get_repo().repo_obj, new_tag)


class TokenAuthDownloadStep(publish_step.DownloadStep):
"""
Download remote files. For v2, this may require a bearer token to be used. This step attempts
to download files, and if it fails due to a 401, it will retrieve the auth token and retry the
download.
"""

def __init__(self, step_type, downloads=None, repo=None, conduit=None, config=None,
working_dir=None, plugin_type=None, description=''):
"""
Initialize the step, setting its description.
"""

super(TokenAuthDownloadStep, self).__init__(
step_type, downloads=downloads, repo=repo, conduit=conduit, config=config,
working_dir=working_dir, plugin_type=plugin_type)
self.description = _('Downloading remote files')
self.token = None
self._requests_map = {}

def process_main(self, item=None):
"""
Overrides the parent method to get a new token and try again if response is a 401.
"""
# Allow the original request to be retrieved from the url.
for request in self.downloads:
self._requests_map[request.url] = request

for request in self.downloads:
if self.token:
token_util.add_auth_header(request, self.token)
self.downloader.download_one(request, events=True)

def download_failed(self, report):
"""
If the download fails due to a 401, attempt to retreive a token and try again.
:param report: download report
:type report: nectar.report.DownloadReport
"""
if report.error_report.get('response_code') == httplib.UNAUTHORIZED:
_logger.debug(_('Download unauthorized, attempting to retrieve a token.'))
request = self._requests_map[report.url]
token = token_util.request_token(self.downloader, request, report.headers)
token_util.add_auth_header(request, token)
_logger.debug("Trying download again with new bearer token.")
report = self.downloader.download_one(request)
if report.state == report.DOWNLOAD_FAILED:
super(TokenAuthDownloadStep, self).download_failed(report)
15 changes: 15 additions & 0 deletions plugins/pulp_docker/plugins/registry.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from cStringIO import StringIO
from gettext import gettext as _
import errno
import httplib
import json
import logging
import os
Expand All @@ -14,6 +15,7 @@

from pulp_docker.common import error_codes
from pulp_docker.plugins import models
from pulp_docker.plugins import token_util


_logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -292,6 +294,7 @@ def __init__(self, name, download_config, registry_url, working_dir):
self.registry_url = registry_url
self.downloader = HTTPThreadedDownloader(self.download_config, AggregatingEventListener())
self.working_dir = working_dir
self.token = None

def api_version_check(self):
"""
Expand Down Expand Up @@ -392,8 +395,20 @@ def _get_path(self, path):
url = urlparse.urljoin(self.registry_url, path)
_logger.debug(_('Retrieving {0}'.format(url)))
request = DownloadRequest(url, StringIO())

if self.token:
token_util.add_auth_header(request, self.token)

report = self.downloader.download_one(request)

# If the download was unauthorized, attempt to get a token and try again
if report.state == report.DOWNLOAD_FAILED:
if report.error_report.get('response_code') == httplib.UNAUTHORIZED:
_logger.debug(_('Download unauthorized, attempting to retrieve a token.'))
self.token = token_util.request_token(self.downloader, request, report.headers)
token_util.add_auth_header(request, self.token)
report = self.downloader.download_one(request)

if report.state == report.DOWNLOAD_FAILED:
raise IOError(report.error_msg)

Expand Down
92 changes: 92 additions & 0 deletions plugins/pulp_docker/plugins/token_util.py
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
10 changes: 9 additions & 1 deletion plugins/test/unit/plugins/importers/test_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@ def test___init___with_v2_registry(self, v1_api_check, api_version_check, _valid
self.assertEqual(
[type(child) for child in step.children],
[sync.DownloadManifestsStep, publish_step.GetLocalUnitsStep,
publish_step.GetLocalUnitsStep, publish_step.DownloadStep, sync.SaveUnitsStep,
publish_step.GetLocalUnitsStep, sync.TokenAuthDownloadStep, sync.SaveUnitsStep,
sync.SaveTagsStep])
# Ensure the first step was initialized correctly
self.assertEqual(step.children[0].repo, repo)
Expand All @@ -400,6 +400,7 @@ def test___init___with_v2_registry(self, v1_api_check, api_version_check, _valid
def test_init_v1(self, mock_check_v1, mock_check_v2, mock_validate, _working_directory_path):
_working_directory_path.return_value = self.working_dir
# re-run this with the mock in place
self.config.override_config[constants.CONFIG_KEY_ENABLE_V1] = True
step = sync.SyncStep(self.repo, self.conduit, self.config)

self.assertEqual(step.step_id, constants.SYNC_STEP_MAIN)
Expand Down Expand Up @@ -501,6 +502,7 @@ def test_generate_download_requests(self, _working_directory_path):
@mock.patch('pulp.server.managers.repo._common._working_directory_path')
def test_v1_generate_download_requests(self, mock_working_dir, mock_v1_check, mock_v2_check):
mock_working_dir.return_value = tempfile.mkdtemp()
self.config.override_config[constants.CONFIG_KEY_ENABLE_V1] = True
step = sync.SyncStep(self.repo, self.conduit, self.config)
step.v1_step_get_local_units.units_to_download.append(models.Image(image_id='image1'))

Expand All @@ -522,6 +524,7 @@ def test_v1_generate_download_requests(self, mock_working_dir, mock_v1_check, mo
def test_generate_download_requests_correct_urls(self, mock_working_dir, mock_v1_check,
mock_v2_check):
mock_working_dir.return_value = tempfile.mkdtemp()
self.config.override_config[constants.CONFIG_KEY_ENABLE_V1] = True
step = sync.SyncStep(self.repo, self.conduit, self.config)
step.v1_step_get_local_units.units_to_download.append(models.Image(image_id='image1'))

Expand All @@ -542,6 +545,7 @@ def test_generate_download_requests_correct_urls(self, mock_working_dir, mock_v1
def test_generate_download_requests_correct_destinations(self, mock_working_dir,
mock_v1_check, mock_v2_check):
mock_working_dir.return_value = tempfile.mkdtemp()
self.config.override_config[constants.CONFIG_KEY_ENABLE_V1] = True
step = sync.SyncStep(self.repo, self.conduit, self.config)
step.v1_step_get_local_units.units_to_download.append(models.Image(image_id='image1'))

Expand All @@ -565,6 +569,7 @@ def test_generate_download_requests_correct_destinations(self, mock_working_dir,
def test_generate_download_reqs_creates_dir(self, mock_working_dir, mock_v1_check,
mock_v2_check):
mock_working_dir.return_value = tempfile.mkdtemp()
self.config.override_config[constants.CONFIG_KEY_ENABLE_V1] = True
step = sync.SyncStep(self.repo, self.conduit, self.config)
step.v1_step_get_local_units.units_to_download.append(models.Image(image_id='image1'))

Expand All @@ -582,6 +587,7 @@ def test_generate_download_reqs_creates_dir(self, mock_working_dir, mock_v1_chec
def test_generate_download_reqs_existing_dir(self, mock_working_dir, mock_v1_check,
mock_v2_check):
mock_working_dir.return_value = tempfile.mkdtemp()
self.config.override_config[constants.CONFIG_KEY_ENABLE_V1] = True
step = sync.SyncStep(self.repo, self.conduit, self.config)
step.v1_step_get_local_units.units_to_download.append(models.Image(image_id='image1'))
os.makedirs(os.path.join(step.working_dir, 'image1'))
Expand All @@ -598,6 +604,7 @@ def test_generate_download_reqs_existing_dir(self, mock_working_dir, mock_v1_che
def test_generate_download_reqs_perm_denied(self, mock_working_dir, mock_v1_check,
mock_v2_check):
mock_working_dir.return_value = tempfile.mkdtemp()
self.config.override_config[constants.CONFIG_KEY_ENABLE_V1] = True
try:
step = sync.SyncStep(self.repo, self.conduit, self.config)
step.v1_step_get_local_units.units_to_download.append(models.Image(image_id='image1'))
Expand All @@ -614,6 +621,7 @@ def test_generate_download_reqs_perm_denied(self, mock_working_dir, mock_v1_chec
def test_generate_download_reqs_ancestry_exists(self, mock_working_dir, mock_v1_check,
mock_v2_check):
mock_working_dir.return_value = tempfile.mkdtemp()
self.config.override_config[constants.CONFIG_KEY_ENABLE_V1] = True
step = sync.SyncStep(self.repo, self.conduit, self.config)
step.v1_step_get_local_units.units_to_download.append(models.Image(image_id='image1'))
os.makedirs(os.path.join(step.working_dir, 'image1'))
Expand Down
93 changes: 93 additions & 0 deletions plugins/test/unit/plugins/test_token_util.py
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"})

0 comments on commit 2440e5f

Please sign in to comment.