Handle empty scopes and always send the WWW-Authenticate header, as per spec

Fixes #1045
This commit is contained in:
Joseph Schorr 2015-12-09 15:07:37 -05:00
parent c8f43ed08e
commit ca7d36bf14
10 changed files with 47 additions and 41 deletions

View file

@ -3,13 +3,13 @@ import re
from jsonschema import validate, ValidationError from jsonschema import validate, ValidationError
from functools import wraps from functools import wraps
from flask import request from flask import request, url_for
from flask.ext.principal import identity_changed, Identity from flask.ext.principal import identity_changed, Identity
from cryptography.x509 import load_pem_x509_certificate from cryptography.x509 import load_pem_x509_certificate
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
from cachetools import lru_cache from cachetools import lru_cache
from app import app from app import app, get_app_url
from .auth_context import set_grant_context, get_grant_context from .auth_context import set_grant_context, get_grant_context
from .permissions import repository_read_grant, repository_write_grant from .permissions import repository_read_grant, repository_write_grant
from util.names import parse_namespace_repository from util.names import parse_namespace_repository
@ -152,6 +152,18 @@ def build_context_and_subject(user, token, oauthtoken):
return (context, ANONYMOUS_SUB) return (context, ANONYMOUS_SUB)
def get_auth_headers():
""" Returns a dictionary of headers for auth responses. """
headers = {}
realm_auth_path = url_for('v2.generate_registry_jwt')
authenticate = 'Bearer realm="{0}{1}",service="{2}"'.format(get_app_url(),
realm_auth_path,
app.config['SERVER_HOSTNAME'])
headers['WWW-Authenticate'] = authenticate
headers['Docker-Distribution-API-Version'] = 'registry/2.0'
return headers
def identity_from_bearer_token(bearer_token, max_signed_s, public_key): def identity_from_bearer_token(bearer_token, max_signed_s, public_key):
""" Process a bearer token and return the loaded identity, or raise InvalidJWTException if an """ Process a bearer token and return the loaded identity, or raise InvalidJWTException if an
identity could not be loaded. Expects tokens and grants in the format of the Docker registry identity could not be loaded. Expects tokens and grants in the format of the Docker registry
@ -219,7 +231,7 @@ def load_public_key(certificate_file_path):
return cert_obj.public_key() return cert_obj.public_key()
def process_jwt_auth(func): def process_registry_jwt_auth(func):
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
logger.debug('Called with params: %s, %s', args, kwargs) logger.debug('Called with params: %s, %s', args, kwargs)
@ -237,8 +249,7 @@ def process_jwt_auth(func):
set_grant_context(context) set_grant_context(context)
logger.debug('Identity changed to %s', extracted_identity.id) logger.debug('Identity changed to %s', extracted_identity.id)
except InvalidJWTException as ije: except InvalidJWTException as ije:
abort(401, message=ije.message) abort(401, message=ije.message, headers=get_auth_headers())
else: else:
logger.debug('No auth header.') logger.debug('No auth header.')

View file

@ -4,7 +4,7 @@ import random
from app import analytics, app, userevents from app import analytics, app, userevents
from data import model from data import model
from flask import request from flask import request
from auth.jwt_auth import get_granted_entity from auth.registry_jwt_auth import get_granted_entity
from auth.auth_context import (get_authenticated_user, get_validated_token, from auth.auth_context import (get_authenticated_user, get_validated_token,
get_validated_oauth_token) get_validated_oauth_token)

View file

@ -10,7 +10,7 @@ from time import time
from app import storage as store, image_replication_queue, app from app import storage as store, image_replication_queue, app
from auth.auth import process_auth, extract_namespace_repo_from_session from auth.auth import process_auth, extract_namespace_repo_from_session
from auth.auth_context import get_authenticated_user from auth.auth_context import get_authenticated_user
from auth.jwt_auth import get_granted_username from auth.registry_jwt_auth import get_granted_username
from digest import checksums from digest import checksums
from util.registry import changes from util.registry import changes
from util.http import abort, exact_abort from util.http import abort, exact_abort

View file

@ -9,16 +9,13 @@ import features
from app import metric_queue from app import metric_queue
from endpoints.decorators import anon_protect, anon_allowed from endpoints.decorators import anon_protect, anon_allowed
from endpoints.v2.errors import V2RegistryException from endpoints.v2.errors import V2RegistryException
from auth.jwt_auth import process_jwt_auth
from auth.auth_context import get_grant_context from auth.auth_context import get_grant_context
from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermission, from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermission,
AdministerRepositoryPermission) AdministerRepositoryPermission)
from data import model from data import model
from util.http import abort from util.http import abort
from util.saas.metricqueue import time_blueprint from util.saas.metricqueue import time_blueprint
from util import get_app_url from auth.registry_jwt_auth import process_registry_jwt_auth, get_auth_headers
from app import app
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
v2_bp = Blueprint('v2', __name__) v2_bp = Blueprint('v2', __name__)
@ -75,21 +72,15 @@ def route_show_if(value):
@v2_bp.route('/') @v2_bp.route('/')
@route_show_if(features.ADVERTISE_V2) @route_show_if(features.ADVERTISE_V2)
@process_jwt_auth @process_registry_jwt_auth
@anon_allowed @anon_allowed
def v2_support_enabled(): def v2_support_enabled():
response = make_response('true', 200) response = make_response('true', 200)
if get_grant_context() is None: if get_grant_context() is None:
response = make_response('true', 401) response = make_response('true', 401)
realm_auth_path = url_for('v2.generate_registry_jwt')
authenticate = 'Bearer realm="{0}{1}",service="{2}"'.format(get_app_url(app.config), response.headers.extend(get_auth_headers())
realm_auth_path,
app.config['SERVER_HOSTNAME'])
response.headers['WWW-Authenticate'] = authenticate
response.headers['Docker-Distribution-API-Version'] = 'registry/2.0'
return response return response

View file

@ -4,12 +4,12 @@ import re
from flask import make_response, url_for, request, redirect, Response, abort as flask_abort from flask import make_response, url_for, request, redirect, Response, abort as flask_abort
from app import storage, app from app import storage, app
from auth.registry_jwt_auth import process_registry_jwt_auth
from data import model, database from data import model, database
from digest import digest_tools from digest import digest_tools
from endpoints.v2 import v2_bp, require_repo_read, require_repo_write, get_input_stream from endpoints.v2 import v2_bp, require_repo_read, require_repo_write, get_input_stream
from endpoints.v2.errors import (BlobUnknown, BlobUploadInvalid, BlobUploadUnknown, Unsupported, from endpoints.v2.errors import (BlobUnknown, BlobUploadInvalid, BlobUploadUnknown, Unsupported,
NameUnknown) NameUnknown)
from auth.jwt_auth import process_jwt_auth
from endpoints.decorators import anon_protect from endpoints.decorators import anon_protect
from util.cache import cache_control from util.cache import cache_control
from util.registry.filelike import wrap_with_handler, StreamSlice from util.registry.filelike import wrap_with_handler, StreamSlice
@ -53,7 +53,7 @@ def _base_blob_fetch(namespace, repo_name, digest):
@v2_bp.route(BLOB_DIGEST_ROUTE, methods=['HEAD']) @v2_bp.route(BLOB_DIGEST_ROUTE, methods=['HEAD'])
@process_jwt_auth @process_registry_jwt_auth
@require_repo_read @require_repo_read
@anon_protect @anon_protect
@cache_control(max_age=31436000) @cache_control(max_age=31436000)
@ -68,7 +68,7 @@ def check_blob_exists(namespace, repo_name, digest):
@v2_bp.route(BLOB_DIGEST_ROUTE, methods=['GET']) @v2_bp.route(BLOB_DIGEST_ROUTE, methods=['GET'])
@process_jwt_auth @process_registry_jwt_auth
@require_repo_read @require_repo_read
@anon_protect @anon_protect
@cache_control(max_age=31536000) @cache_control(max_age=31536000)
@ -101,7 +101,7 @@ def _render_range(num_uploaded_bytes, with_bytes_prefix=True):
@v2_bp.route('/<namespace>/<repo_name>/blobs/uploads/', methods=['POST']) @v2_bp.route('/<namespace>/<repo_name>/blobs/uploads/', methods=['POST'])
@process_jwt_auth @process_registry_jwt_auth
@require_repo_write @require_repo_write
@anon_protect @anon_protect
def start_blob_upload(namespace, repo_name): def start_blob_upload(namespace, repo_name):
@ -134,7 +134,7 @@ def start_blob_upload(namespace, repo_name):
@v2_bp.route('/<namespace>/<repo_name>/blobs/uploads/<upload_uuid>', methods=['GET']) @v2_bp.route('/<namespace>/<repo_name>/blobs/uploads/<upload_uuid>', methods=['GET'])
@process_jwt_auth @process_registry_jwt_auth
@require_repo_write @require_repo_write
@anon_protect @anon_protect
def fetch_existing_upload(namespace, repo_name, upload_uuid): def fetch_existing_upload(namespace, repo_name, upload_uuid):
@ -290,7 +290,7 @@ def _finish_upload(namespace, repo_name, upload_obj, expected_digest):
@v2_bp.route('/<namespace>/<repo_name>/blobs/uploads/<upload_uuid>', methods=['PATCH']) @v2_bp.route('/<namespace>/<repo_name>/blobs/uploads/<upload_uuid>', methods=['PATCH'])
@process_jwt_auth @process_registry_jwt_auth
@require_repo_write @require_repo_write
@anon_protect @anon_protect
def upload_chunk(namespace, repo_name, upload_uuid): def upload_chunk(namespace, repo_name, upload_uuid):
@ -308,7 +308,7 @@ def upload_chunk(namespace, repo_name, upload_uuid):
@v2_bp.route('/<namespace>/<repo_name>/blobs/uploads/<upload_uuid>', methods=['PUT']) @v2_bp.route('/<namespace>/<repo_name>/blobs/uploads/<upload_uuid>', methods=['PUT'])
@process_jwt_auth @process_registry_jwt_auth
@require_repo_write @require_repo_write
@anon_protect @anon_protect
def monolithic_upload_or_last_chunk(namespace, repo_name, upload_uuid): def monolithic_upload_or_last_chunk(namespace, repo_name, upload_uuid):
@ -326,7 +326,7 @@ def monolithic_upload_or_last_chunk(namespace, repo_name, upload_uuid):
@v2_bp.route('/<namespace>/<repo_name>/blobs/uploads/<upload_uuid>', methods=['DELETE']) @v2_bp.route('/<namespace>/<repo_name>/blobs/uploads/<upload_uuid>', methods=['DELETE'])
@process_jwt_auth @process_registry_jwt_auth
@require_repo_write @require_repo_write
@anon_protect @anon_protect
def cancel_upload(namespace, repo_name, upload_uuid): def cancel_upload(namespace, repo_name, upload_uuid):
@ -345,7 +345,7 @@ def cancel_upload(namespace, repo_name, upload_uuid):
@v2_bp.route('/<namespace>/<repo_name>/blobs/<digest>', methods=['DELETE']) @v2_bp.route('/<namespace>/<repo_name>/blobs/<digest>', methods=['DELETE'])
@process_jwt_auth @process_registry_jwt_auth
@require_repo_write @require_repo_write
@anon_protect @anon_protect
def delete_digest(namespace, repo_name, upload_uuid): def delete_digest(namespace, repo_name, upload_uuid):

View file

@ -9,7 +9,7 @@ from jwkest.jws import SIGNER_ALGS, keyrep
from datetime import datetime from datetime import datetime
from app import docker_v2_signing_key from app import docker_v2_signing_key
from auth.jwt_auth import process_jwt_auth from auth.registry_jwt_auth import process_registry_jwt_auth
from endpoints.decorators import anon_protect from endpoints.decorators import anon_protect
from endpoints.v2 import v2_bp, require_repo_read, require_repo_write from endpoints.v2 import v2_bp, require_repo_read, require_repo_write
from endpoints.v2.errors import (BlobUnknown, ManifestInvalid, ManifestUnverified, from endpoints.v2.errors import (BlobUnknown, ManifestInvalid, ManifestUnverified,
@ -212,7 +212,7 @@ class SignedManifestBuilder(object):
@v2_bp.route(MANIFEST_TAGNAME_ROUTE, methods=['GET']) @v2_bp.route(MANIFEST_TAGNAME_ROUTE, methods=['GET'])
@process_jwt_auth @process_registry_jwt_auth
@require_repo_read @require_repo_read
@anon_protect @anon_protect
def fetch_manifest_by_tagname(namespace, repo_name, manifest_ref): def fetch_manifest_by_tagname(namespace, repo_name, manifest_ref):
@ -241,7 +241,7 @@ def fetch_manifest_by_tagname(namespace, repo_name, manifest_ref):
@v2_bp.route(MANIFEST_DIGEST_ROUTE, methods=['GET']) @v2_bp.route(MANIFEST_DIGEST_ROUTE, methods=['GET'])
@process_jwt_auth @process_registry_jwt_auth
@require_repo_read @require_repo_read
@anon_protect @anon_protect
def fetch_manifest_by_digest(namespace, repo_name, manifest_ref): def fetch_manifest_by_digest(namespace, repo_name, manifest_ref):
@ -261,7 +261,7 @@ def fetch_manifest_by_digest(namespace, repo_name, manifest_ref):
@v2_bp.route(MANIFEST_TAGNAME_ROUTE, methods=['PUT']) @v2_bp.route(MANIFEST_TAGNAME_ROUTE, methods=['PUT'])
@process_jwt_auth @process_registry_jwt_auth
@require_repo_write @require_repo_write
@anon_protect @anon_protect
def write_manifest_by_tagname(namespace, repo_name, manifest_ref): def write_manifest_by_tagname(namespace, repo_name, manifest_ref):
@ -277,7 +277,7 @@ def write_manifest_by_tagname(namespace, repo_name, manifest_ref):
@v2_bp.route(MANIFEST_DIGEST_ROUTE, methods=['PUT']) @v2_bp.route(MANIFEST_DIGEST_ROUTE, methods=['PUT'])
@process_jwt_auth @process_registry_jwt_auth
@require_repo_write @require_repo_write
@anon_protect @anon_protect
def write_manifest_by_digest(namespace, repo_name, manifest_ref): def write_manifest_by_digest(namespace, repo_name, manifest_ref):
@ -375,7 +375,7 @@ def _write_manifest(namespace, repo_name, manifest):
@v2_bp.route(MANIFEST_DIGEST_ROUTE, methods=['DELETE']) @v2_bp.route(MANIFEST_DIGEST_ROUTE, methods=['DELETE'])
@process_jwt_auth @process_registry_jwt_auth
@require_repo_write @require_repo_write
@anon_protect @anon_protect
def delete_manifest_by_digest(namespace, repo_name, manifest_ref): def delete_manifest_by_digest(namespace, repo_name, manifest_ref):

View file

@ -1,14 +1,14 @@
from flask import jsonify, url_for from flask import jsonify, url_for
from auth.registry_jwt_auth import process_registry_jwt_auth
from endpoints.v2 import v2_bp, require_repo_read from endpoints.v2 import v2_bp, require_repo_read
from endpoints.v2.errors import NameUnknown from endpoints.v2.errors import NameUnknown
from endpoints.v2.v2util import add_pagination from endpoints.v2.v2util import add_pagination
from auth.jwt_auth import process_jwt_auth
from endpoints.decorators import anon_protect from endpoints.decorators import anon_protect
from data import model from data import model
@v2_bp.route('/<namespace>/<repo_name>/tags/list', methods=['GET']) @v2_bp.route('/<namespace>/<repo_name>/tags/list', methods=['GET'])
@process_jwt_auth @process_registry_jwt_auth
@require_repo_read @require_repo_read
@anon_protect @anon_protect
def list_all_tags(namespace, repo_name): def list_all_tags(namespace, repo_name):

View file

@ -9,7 +9,7 @@ from cachetools import lru_cache
from app import app from app import app
from data import model from data import model
from auth.auth import process_auth from auth.auth import process_auth
from auth.jwt_auth import build_context_and_subject from auth.registry_jwt_auth import build_context_and_subject
from auth.auth_context import get_authenticated_user, get_validated_token, get_validated_oauth_token from auth.auth_context import get_authenticated_user, get_validated_token, get_validated_oauth_token
from auth.permissions import (ModifyRepositoryPermission, ReadRepositoryPermission, from auth.permissions import (ModifyRepositoryPermission, ReadRepositoryPermission,
CreateRepositoryPermission) CreateRepositoryPermission)
@ -49,7 +49,7 @@ def generate_registry_jwt():
audience_param = request.args.get('service') audience_param = request.args.get('service')
logger.debug('Request audience: %s', audience_param) logger.debug('Request audience: %s', audience_param)
scope_param = request.args.get('scope') scope_param = request.args.get('scope') or ''
logger.debug('Scope request: %s', scope_param) logger.debug('Scope request: %s', scope_param)
user = get_authenticated_user() user = get_authenticated_user()
@ -62,7 +62,8 @@ def generate_registry_jwt():
logger.debug('Authenticated OAuth token: %s', oauthtoken) logger.debug('Authenticated OAuth token: %s', oauthtoken)
access = [] access = []
if scope_param is not None:
if len(scope_param) > 0:
match = SCOPE_REGEX.match(scope_param) match = SCOPE_REGEX.match(scope_param)
if match is None: if match is None:
logger.debug('Match: %s', match) logger.debug('Match: %s', match)

View file

@ -1193,6 +1193,9 @@ class V1LoginTests(V1RegistryLoginMixin, LoginTests, RegistryTestCaseMixin, Base
class V2LoginTests(V2RegistryLoginMixin, LoginTests, RegistryTestCaseMixin, BaseRegistryMixin, LiveServerTestCase): class V2LoginTests(V2RegistryLoginMixin, LoginTests, RegistryTestCaseMixin, BaseRegistryMixin, LiveServerTestCase):
""" Tests for V2 login. """ """ Tests for V2 login. """
def test_nouser_noscope(self):
self.do_login('', '', expected_code=401, scope='')
def test_validuser_unknownrepo(self): def test_validuser_unknownrepo(self):
self.do_login('devtable', 'password', expect_success=False, self.do_login('devtable', 'password', expect_success=False,
scope='repository:invalidnamespace/simple:pull') scope='repository:invalidnamespace/simple:pull')

View file

@ -7,8 +7,8 @@ from cryptography.hazmat.primitives.asymmetric import rsa
from app import app from app import app
from endpoints.v2.v2auth import TOKEN_VALIDITY_LIFETIME_S, load_certificate_bytes, load_private_key from endpoints.v2.v2auth import TOKEN_VALIDITY_LIFETIME_S, load_certificate_bytes, load_private_key
from auth.jwt_auth import (identity_from_bearer_token, load_public_key, InvalidJWTException, from auth.registry_jwt_auth import (identity_from_bearer_token, load_public_key,
build_context_and_subject, ANONYMOUS_SUB) InvalidJWTException, build_context_and_subject, ANONYMOUS_SUB)
from util.morecollections import AttrDict from util.morecollections import AttrDict