95e1cf6673
If login fails, we now call validate again to get the reason for the failure, and then surface it to the user of the CLI. This allows for more actionable responses, such as: $ docker login 10.0.2.2:5000 Username (devtable): devtable Password: Error response from daemon: Get http://10.0.2.2:5000/v2/: unauthorized: Client login with unencrypted passwords is disabled. Please generate an encrypted password in the user admin panel for use here.
201 lines
6.7 KiB
Python
201 lines
6.7 KiB
Python
import logging
|
|
import re
|
|
|
|
from cachetools import lru_cache
|
|
from flask import request, jsonify, abort
|
|
|
|
from app import app, userevents, instance_keys
|
|
from auth.auth_context import get_authenticated_user, get_validated_token, get_validated_oauth_token
|
|
from auth.decorators import process_basic_auth
|
|
from auth.permissions import (ModifyRepositoryPermission, ReadRepositoryPermission,
|
|
CreateRepositoryPermission, AdministerRepositoryPermission)
|
|
from endpoints.decorators import anon_protect
|
|
from endpoints.v2 import v2_bp
|
|
from endpoints.v2.errors import InvalidLogin
|
|
from data.interfaces.v2 import pre_oci_model as model
|
|
from util.cache import no_cache
|
|
from util.names import parse_namespace_repository, REPOSITORY_NAME_REGEX
|
|
from util.security.registry_jwt import generate_bearer_token, build_context_and_subject
|
|
|
|
CLAIM_APOSTILLE_ROOT = 'com.apostille.root'
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
TOKEN_VALIDITY_LIFETIME_S = 60 * 60 # 1 hour
|
|
SCOPE_REGEX_TEMPLATE = r'^repository:((?:{}\/)?((?:[\.a-zA-Z0-9_\-]+\/)?[\.a-zA-Z0-9_\-]+)):((?:push|pull|\*)(?:,(?:push|pull|\*))*)$'
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def get_scope_regex():
|
|
hostname = re.escape(app.config['SERVER_HOSTNAME'])
|
|
scope_regex_string = SCOPE_REGEX_TEMPLATE.format(hostname)
|
|
return re.compile(scope_regex_string)
|
|
|
|
|
|
@v2_bp.route('/auth')
|
|
@process_basic_auth
|
|
@no_cache
|
|
@anon_protect
|
|
def generate_registry_jwt(auth_result):
|
|
"""
|
|
This endpoint will generate a JWT conforming to the Docker Registry v2 Auth Spec:
|
|
https://docs.docker.com/registry/spec/auth/token/
|
|
"""
|
|
audience_param = request.args.get('service')
|
|
logger.debug('Request audience: %s', audience_param)
|
|
|
|
scope_param = request.args.get('scope') or ''
|
|
logger.debug('Scope request: %s', scope_param)
|
|
|
|
user = get_authenticated_user()
|
|
logger.debug('Authenticated user: %s', user)
|
|
|
|
token = get_validated_token()
|
|
logger.debug('Authenticated token: %s', token)
|
|
|
|
oauthtoken = get_validated_oauth_token()
|
|
logger.debug('Authenticated OAuth token: %s', oauthtoken)
|
|
|
|
auth_header = request.headers.get('authorization', '')
|
|
auth_credentials_sent = bool(auth_header)
|
|
if auth_credentials_sent and not user and not token:
|
|
# The auth credentials sent for the user are invalid.
|
|
raise InvalidLogin(auth_result.error_message)
|
|
|
|
access = []
|
|
user_event_data = {
|
|
'action': 'login',
|
|
}
|
|
|
|
if len(scope_param) > 0:
|
|
match = get_scope_regex().match(scope_param)
|
|
if match is None:
|
|
logger.debug('Match: %s', match)
|
|
logger.debug('len: %s', len(scope_param))
|
|
logger.warning('Unable to decode repository and actions: %s', scope_param)
|
|
abort(400)
|
|
|
|
logger.debug('Match: %s', match.groups())
|
|
|
|
registry_and_repo = match.group(1)
|
|
namespace_and_repo = match.group(2)
|
|
actions = match.group(3).split(',')
|
|
|
|
lib_namespace = app.config['LIBRARY_NAMESPACE']
|
|
namespace, reponame = parse_namespace_repository(namespace_and_repo, lib_namespace)
|
|
|
|
# Ensure that we are never creating an invalid repository.
|
|
if not REPOSITORY_NAME_REGEX.match(reponame):
|
|
logger.debug('Found invalid repository name in auth flow: %s', reponame)
|
|
abort(400)
|
|
|
|
final_actions = []
|
|
|
|
repo = model.get_repository(namespace, reponame)
|
|
|
|
repo_is_public = repo is not None and repo.is_public
|
|
invalid_repo_message = ''
|
|
if repo is not None and repo.kind != 'image':
|
|
invalid_repo_message = (('This repository is for managing %s resources ' +
|
|
'and not container images.') % repo.kind)
|
|
|
|
if 'push' in actions:
|
|
# If there is no valid user or token, then the repository cannot be
|
|
# accessed.
|
|
if user is not None or token is not None:
|
|
# Lookup the repository. If it exists, make sure the entity has modify
|
|
# permission. Otherwise, make sure the entity has create permission.
|
|
if repo:
|
|
if ModifyRepositoryPermission(namespace, reponame).can():
|
|
if repo.kind != 'image':
|
|
abort(405, invalid_repo_message)
|
|
|
|
final_actions.append('push')
|
|
else:
|
|
logger.debug('No permission to modify repository %s/%s', namespace, reponame)
|
|
else:
|
|
if CreateRepositoryPermission(namespace).can() and user is not None:
|
|
logger.debug('Creating repository: %s/%s', namespace, reponame)
|
|
model.create_repository(namespace, reponame, user)
|
|
final_actions.append('push')
|
|
else:
|
|
logger.debug('No permission to create repository %s/%s', namespace, reponame)
|
|
|
|
if 'pull' in actions:
|
|
# Grant pull if the user can read the repo or it is public.
|
|
if ReadRepositoryPermission(namespace, reponame).can() or repo_is_public:
|
|
if repo is not None and repo.kind != 'image':
|
|
abort(405, invalid_repo_message)
|
|
|
|
final_actions.append('pull')
|
|
else:
|
|
logger.debug('No permission to pull repository %s/%s', namespace, reponame)
|
|
|
|
if '*' in actions:
|
|
# Grant * user is admin
|
|
if AdministerRepositoryPermission(namespace, reponame).can():
|
|
if repo is not None and repo.kind != 'image':
|
|
abort(405, invalid_repo_message)
|
|
|
|
final_actions.append('*')
|
|
else:
|
|
logger.debug("No permission to administer repository %s/%s", namespace, reponame)
|
|
|
|
# Add the access for the JWT.
|
|
access.append({
|
|
'type': 'repository',
|
|
'name': registry_and_repo,
|
|
'actions': final_actions,
|
|
})
|
|
|
|
# Set the user event data for the auth.
|
|
if 'push' in final_actions:
|
|
user_action = 'push_start'
|
|
elif 'pull' in final_actions:
|
|
user_action = 'pull_start'
|
|
else:
|
|
user_action = 'login'
|
|
|
|
user_event_data = {
|
|
'action': user_action,
|
|
'repository': reponame,
|
|
'namespace': namespace,
|
|
}
|
|
|
|
elif user is None and token is None:
|
|
# In this case, we are doing an auth flow, and it's not an anonymous pull
|
|
logger.debug('No user and no token sent for empty scope list')
|
|
abort(401)
|
|
|
|
# Send the user event.
|
|
if user is not None:
|
|
event = userevents.get_event(user.username)
|
|
event.publish_event_data('docker-cli', user_event_data)
|
|
|
|
# Build the signed JWT.
|
|
context, subject = build_context_and_subject(user, token, oauthtoken)
|
|
context = attach_metadata_root_name(context, access)
|
|
token = generate_bearer_token(audience_param, subject, context, access,
|
|
TOKEN_VALIDITY_LIFETIME_S, instance_keys)
|
|
return jsonify({'token': token})
|
|
|
|
|
|
def attach_metadata_root_name(context, access):
|
|
"""
|
|
Adds in metadata_root_name into JWT context when appropriate
|
|
"""
|
|
try:
|
|
actions = access[0]["actions"]
|
|
except(TypeError, IndexError, KeyError):
|
|
return context
|
|
|
|
if not actions:
|
|
return context
|
|
|
|
if "push" in actions:
|
|
context[CLAIM_APOSTILLE_ROOT] = 'signer'
|
|
else:
|
|
context[CLAIM_APOSTILLE_ROOT] = 'quay'
|
|
|
|
return context
|