diff --git a/auth/auth.py b/auth/auth.py index 21c28d420..2d77d6094 100644 --- a/auth/auth.py +++ b/auth/auth.py @@ -32,19 +32,34 @@ def process_basic_auth(auth): credentials = b64decode(normalized[1]).split(':') if len(credentials) != 2: - logger.debug('Invalid basic auth credential formet.') + logger.debug('Invalid basic auth credential format.') - authenticated = model.verify_user(credentials[0], credentials[1]) + if credentials[0] == '$token': + # Use as token auth + try: + token = model.load_token_data(credentials[1]) + logger.debug('Successfully validated token: %s' % credentials[1]) + ctx = _request_ctx_stack.top + ctx.validated_token = token - if authenticated: - logger.debug('Successfully validated user: %s' % authenticated.username) - ctx = _request_ctx_stack.top - ctx.authenticated_user = authenticated + identity_changed.send(app, identity=Identity(token.code, 'token')) + return - new_identity = QuayDeferredPermissionUser(authenticated.username, - 'username') - identity_changed.send(app, identity=new_identity) - return + except model.DataModelException: + logger.debug('Invalid token: %s' % credentials[1]) + + else: + authenticated = model.verify_user(credentials[0], credentials[1]) + + if authenticated: + logger.debug('Successfully validated user: %s' % authenticated.username) + ctx = _request_ctx_stack.top + ctx.authenticated_user = authenticated + + new_identity = QuayDeferredPermissionUser(authenticated.username, + 'username') + identity_changed.send(app, identity=new_identity) + return # We weren't able to authenticate via basic auth. logger.debug('Basic auth present but could not be validated.') @@ -54,42 +69,37 @@ def process_basic_auth(auth): def process_token(auth): normalized = [part.strip() for part in auth.split(' ') if part] if normalized[0].lower() != 'token' or len(normalized) != 2: - logger.debug('Invalid token format.') + logger.debug('Not an auth token: %s' % auth) return token_details = normalized[1].split(',') - if len(token_details) != 2: - logger.debug('Invalid token format.') - return + if len(token_details) != 1: + logger.warning('Invalid token format: %s' % auth) + abort(401) token_vals = {val[0]: val[1] for val in (detail.split('=') for detail in token_details)} - if ('signature' not in token_vals or 'repository' not in token_vals): - logger.debug('Invalid token components.') - return + if 'signature' not in token_vals: + logger.warning('Token does not contain signature: %s' % auth) + abort(401) - unquoted = token_vals['repository'][1:-1] - namespace, repository = parse_namespace_repository(unquoted) - logger.debug('Validing signature: %s' % token_vals['signature']) - validated = model.verify_token(token_vals['signature'], namespace, - repository) + try: + token_data = model.load_token_data(token_vals['signature']) - if validated: - session['repository'] = repository - session['namespace'] = namespace + except model.InvalidTokenException: + logger.warning('Token could not be validated: %s' % + token_vals['signature']) + abort(401) - logger.debug('Successfully validated token: %s' % validated.code) - ctx = _request_ctx_stack.top - ctx.validated_token = validated + session['repository'] = token_data.repository.name + session['namespace'] = token_data.repository.namespace - identity_changed.send(app, identity=Identity(validated.code, 'token')) + logger.debug('Successfully validated token: %s' % token_data.code) + ctx = _request_ctx_stack.top + ctx.validated_token = token_data - return - - # WE weren't able to authenticate the token - logger.debug('Token present but could not be validated.') - abort(401) + identity_changed.send(app, identity=Identity(token_data.code, 'token')) def process_auth(f): diff --git a/auth/permissions.py b/auth/permissions.py index 7ce41d7c9..c0661bbf2 100644 --- a/auth/permissions.py +++ b/auth/permissions.py @@ -80,19 +80,14 @@ def on_identity_loaded(sender, identity): identity_changed.send(app, identity=switch_to_deferred) elif identity.auth_type == 'token': - logger.debug('Computing permissions for token: %s' % identity.id) + logger.debug('Loading permissions for token: %s' % identity.id) + token_data = model.load_token_data(identity.id) - token = model.get_token(identity.id) - - if token.user: - query = model.get_user_repo_permissions(token.user, token.repository) - for permission in query: - t_grant = _RepositoryNeed(token.repository.namespace, - token.repository.name, permission.role.name) - logger.debug('Token added permission: {0}'.format(t_grant)) - identity.provides.add(t_grant) - else: - logger.debug('Token was anonymous.') + repo_grant = _RepositoryNeed(token_data.repository.namespace, + token_data.repository.name, + token_data.role.name) + logger.debug('Delegate token added permission: {0}'.format(repo_grant)) + identity.provides.add(repo_grant) else: logger.error('Unknown identity auth type: %s' % identity.auth_type) diff --git a/data/database.py b/data/database.py index 8d2ad291f..04fd33b8a 100644 --- a/data/database.py +++ b/data/database.py @@ -99,10 +99,13 @@ def random_string_generator(length=16): class AccessToken(BaseModel): - code = CharField(default=random_string_generator(), unique=True, index=True) - user = ForeignKeyField(User, null=True) + friendly_name = CharField(null=True) + code = CharField(default=random_string_generator(length=64), unique=True, + index=True) repository = ForeignKeyField(Repository) created = DateTimeField(default=datetime.now) + role = ForeignKeyField(Role) + temporary = BooleanField(default=True) class EmailConfirmation(BaseModel): diff --git a/data/model.py b/data/model.py index 8148cf6b1..5a1a3ff57 100644 --- a/data/model.py +++ b/data/model.py @@ -26,6 +26,10 @@ class InvalidPasswordException(DataModelException): pass +class InvalidTokenException(DataModelException): + pass + + def create_user(username, password, email): if not validate_email(email): raise InvalidEmailAddressException('Invalid email address: %s' % email) @@ -159,25 +163,6 @@ def verify_user(username, password): return None -def create_access_token(user, repository): - new_token = AccessToken.create(user=user, repository=repository) - return new_token - - -def verify_token(code, namespace_name, repository_name): - joined = AccessToken.select(AccessToken, Repository).join(Repository) - tokens = list(joined.where(AccessToken.code == code, - Repository.namespace == namespace_name, - Repository.name == repository_name)) - if tokens: - return tokens[0] - return None - - -def get_token(code): - return AccessToken.get(AccessToken.code == code) - - def get_visible_repositories(username=None, include_public=True, limit=None, sort=False): if not username and not include_public: @@ -485,3 +470,69 @@ def get_private_repo_count(username): joined = Repository.select().join(Visibility) return joined.where(Repository.namespace == username, Visibility.name == 'private').count() + + +def create_access_token(repository, role): + role = Role.get(Role.name == role) + new_token = AccessToken.create(repository=repository, temporary=True, + role=role) + return new_token + + +def create_delegate_token(namespace_name, repository_name, friendly_name): + read_only = Role.get(name='read') + repo = Repository.get(Repository.name == repository_name, + Repository.namespace == namespace_name) + new_token = AccessToken.create(repository=repo, role=read_only, + friendly_name=friendly_name, temporary=False) + return new_token + + +def get_repository_delegate_tokens(namespace_name, repository_name): + selected = AccessToken.select(AccessToken, Role) + with_repo = selected.join(Repository) + with_role = with_repo.switch(AccessToken).join(Role) + return with_role.where(Repository.name == repository_name, + Repository.namespace == namespace_name, + AccessToken.temporary == False) + + +def get_repo_delegate_token(namespace_name, repository_name, code): + repo_query = get_repository_delegate_tokens(namespace_name, repository_name) + found = list(repo_query.where(AccessToken.code == code)) + + if found: + return found[0] + else: + raise InvalidTokenException('Unable to find token with code: %s' % code) + + +def set_repo_delegate_token_role(namespace_name, repository_name, code, role): + token = get_repo_delegate_token(namespace_name, repository_name, code) + + if role != 'read' and role != 'write': + raise DataModelException('Invalid role for delegate token: %s' % role) + + new_role = Role.get(Role.name == role) + token.role = new_role + token.save() + + return token + + +def delete_delegate_token(namespace_name, repository_name, code): + token = get_repo_delegate_token(namespace_name, repository_name, code) + token.delete_instance() + + +def load_token_data(code): + """ Load the permissions for any token by code. """ + selected = AccessToken.select(AccessToken, Repository, Role) + with_role = selected.join(Role) + with_repo = with_role.switch(AccessToken).join(Repository) + fetched = list(with_repo.where(AccessToken.code == code)) + + if fetched: + return fetched[0] + else: + raise InvalidTokenException('Invalid delegate token code: %s' % code) diff --git a/endpoints/api.py b/endpoints/api.py index 0a2ccf9a0..08344e38b 100644 --- a/endpoints/api.py +++ b/endpoints/api.py @@ -456,6 +456,92 @@ def delete_permissions(namespace, repository, username): abort(403) # Permission denied +def token_view(token_obj): + return { + 'friendlyName': token_obj.friendly_name, + 'code': token_obj.code, + 'role': token_obj.role.name, + } + + +@app.route('/api/repository//tokens/', methods=['GET']) +@api_login_required +@parse_repository_name +def list_repo_tokens(namespace, repository): + permission = AdministerRepositoryPermission(namespace, repository) + if permission.can(): + tokens = model.get_repository_delegate_tokens(namespace, repository) + + return jsonify({ + 'tokens': {token.code: token_view(token) for token in tokens} + }) + + abort(403) # Permission denied + + +@app.route('/api/repository//tokens/', methods=['GET']) +@api_login_required +@parse_repository_name +def get_tokens(namespace, repository, code): + permission = AdministerRepositoryPermission(namespace, repository) + if permission.can(): + perm = model.get_repo_delegate_token(namespace, repository, code) + return jsonify(token_view(perm)) + + abort(403) # Permission denied + + +@app.route('/api/repository//tokens/', methods=['POST']) +@api_login_required +@parse_repository_name +def create_token(namespace, repository): + permission = AdministerRepositoryPermission(namespace, repository) + if permission.can(): + token_params = request.get_json() + + token = model.create_delegate_token(namespace, repository, + token_params['friendlyName']) + + resp = jsonify(token_view(token)) + resp.status_code = 201 + return resp + + abort(403) # Permission denied + + +@app.route('/api/repository//tokens/', methods=['PUT']) +@api_login_required +@parse_repository_name +def change_token(namespace, repository, code): + permission = AdministerRepositoryPermission(namespace, repository) + if permission.can(): + new_permission = request.get_json() + + logger.debug('Setting permission to: %s for code %s' % + (new_permission['role'], code)) + + token = model.set_repo_delegate_token_role(namespace, repository, code, + new_permission['role']) + + resp = jsonify(token_view(token)) + return resp + + abort(403) # Permission denied + + +@app.route('/api/repository//tokens/', + methods=['DELETE']) +@api_login_required +@parse_repository_name +def delete_token(namespace, repository, code): + permission = AdministerRepositoryPermission(namespace, repository) + if permission.can(): + model.delete_delegate_token(namespace, repository, code) + return make_response('Deleted', 204) + + abort(403) # Permission denied + + def subscription_view(stripe_subscription, used_repos): return { 'currentPeriodStart': stripe_subscription.current_period_start, diff --git a/endpoints/index.py b/endpoints/index.py index ecf27d70e..375fa94b4 100644 --- a/endpoints/index.py +++ b/endpoints/index.py @@ -19,25 +19,26 @@ from auth.permissions import (ModifyRepositoryPermission, logger = logging.getLogger(__name__) -def generate_headers(f): - @wraps(f) - def wrapper(namespace, repository, *args, **kwargs): - response = f(namespace, repository, *args, **kwargs) +def generate_headers(role='read'): + def decorator_method(f): + @wraps(f) + def wrapper(namespace, repository, *args, **kwargs): + response = f(namespace, repository, *args, **kwargs) - response.headers['X-Docker-Endpoints'] = app.config['REGISTRY_SERVER'] + response.headers['X-Docker-Endpoints'] = app.config['REGISTRY_SERVER'] - has_token_request = request.headers.get('X-Docker-Token', '') + has_token_request = request.headers.get('X-Docker-Token', '') - if has_token_request: - repo = model.get_repository(namespace, repository) - token = model.create_access_token(get_authenticated_user(), repo) - token_str = 'signature=%s,repository="%s/%s"' % (token.code, namespace, - repository) - response.headers['WWW-Authenticate'] = token_str - response.headers['X-Docker-Token'] = token_str + if has_token_request: + repo = model.get_repository(namespace, repository) + token = model.create_access_token(repo, role) + token_str = 'signature=%s' % token.code + response.headers['WWW-Authenticate'] = token_str + response.headers['X-Docker-Token'] = token_str - return response - return wrapper + return response + return wrapper + return decorator_method @app.route('/v1/users', methods=['POST']) @@ -47,6 +48,13 @@ def create_user(): username = user_data['username'] password = user_data['password'] + if username == '$token': + try: + token = model.load_token_data(password) + return make_response('Verified', 201) + except model.InvalidTokenException: + abort(401) + existing_user = model.get_user(username) if existing_user: verified = model.verify_user(username, password) @@ -100,13 +108,17 @@ def update_user(username): @app.route('/v1/repositories/', methods=['PUT']) @process_auth @parse_repository_name -@generate_headers +@generate_headers(role='write') def create_repository(namespace, repository): image_descriptions = json.loads(request.data) repo = model.get_repository(namespace, repository) - if repo: + if not repo and get_authenticated_user() is None: + logger.debug('Attempt to create new repository with token auth.') + abort(400) + + elif repo: permission = ModifyRepositoryPermission(namespace, repository) if not permission.can(): abort(403) @@ -135,7 +147,10 @@ def create_repository(namespace, repository): response = make_response('Created', 201) - mixpanel.track(get_authenticated_user().username, 'push_repo') + if get_authenticated_user(): + mixpanel.track(get_authenticated_user().username, 'push_repo') + else: + mixpanel.track(get_validated_token().code, 'push_repo') return response @@ -143,7 +158,7 @@ def create_repository(namespace, repository): @app.route('/v1/repositories//images', methods=['PUT']) @process_auth @parse_repository_name -@generate_headers +@generate_headers(role='write') def update_images(namespace, repository): permission = ModifyRepositoryPermission(namespace, repository) @@ -164,7 +179,7 @@ def update_images(namespace, repository): @app.route('/v1/repositories//images', methods=['GET']) @process_auth @parse_repository_name -@generate_headers +@generate_headers(role='read') def get_repository_images(namespace, repository): permission = ReadRepositoryPermission(namespace, repository) @@ -196,7 +211,7 @@ def get_repository_images(namespace, repository): @app.route('/v1/repositories//images', methods=['DELETE']) @process_auth @parse_repository_name -@generate_headers +@generate_headers(role='write') def delete_repository_images(namespace, repository): pass diff --git a/static/js/controllers.js b/static/js/controllers.js index 534ce6020..d6765a309 100644 --- a/static/js/controllers.js +++ b/static/js/controllers.js @@ -469,6 +469,40 @@ function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) { }); }; + $scope.createToken = function() { + var friendlyName = { + 'friendlyName': $scope.newToken.friendlyName + }; + + var permissionPost = Restangular.one('repository/' + namespace + '/' + name + '/tokens/'); + permissionPost.customPOST(friendlyName).then(function(newToken) { + $scope.tokens[newToken.code] = newToken; + }); + }; + + $scope.deleteToken = function(tokenCode) { + var deleteAction = Restangular.one('repository/' + namespace + '/' + name + '/tokens/' + tokenCode); + deleteAction.customDELETE().then(function() { + delete $scope.tokens[tokenCode]; + }); + }; + + $scope.changeTokenAccess = function(tokenCode, newAccess) { + var role = { + 'role': newAccess + }; + + var deleteAction = Restangular.one('repository/' + namespace + '/' + name + '/tokens/' + tokenCode); + deleteAction.customPUT(role).then(function(updated) { + $scope.tokens[updated.code] = updated; + }); + }; + + $scope.showToken = function(tokenCode) { + $scope.shownToken = $scope.tokens[tokenCode]; + $('#tokenmodal').modal({}); + }; + $scope.askChangeAccess = function(newAccess) { $('#make' + newAccess + 'Modal').modal({}); }; @@ -512,7 +546,7 @@ function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) { var repositoryFetch = Restangular.one('repository/' + namespace + '/' + name); repositoryFetch.get().then(function(repo) { $scope.repo = repo; - $scope.loading = !($scope.permissions && $scope.repo); + $scope.loading = !($scope.permissions && $scope.repo && $scope.tokens); }, function() { $scope.permissions = null; $rootScope.title = 'Unknown Repository'; @@ -524,12 +558,23 @@ function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) { permissionsFetch.get().then(function(resp) { $rootScope.title = 'Settings - ' + namespace + '/' + name; $scope.permissions = resp.permissions; - $scope.loading = !($scope.permissions && $scope.repo); + $scope.loading = !($scope.permissions && $scope.repo && $scope.tokens); }, function() { $scope.permissions = null; $rootScope.title = 'Unknown Repository'; $scope.loading = false; }); + + // Fetch the tokens. + var tokensFetch = Restangular.one('repository/' + namespace + '/' + name + '/tokens/'); + tokensFetch.get().then(function(resp) { + $scope.tokens = resp.tokens; + $scope.loading = !($scope.permissions && $scope.repo && $scope.tokens); + }, function() { + $scope.tokens = null; + $scope.loading = false; + }); + } function UserAdminCtrl($scope, $timeout, Restangular, PlanService, UserService, KeyService, $routeParams) { diff --git a/static/partials/repo-admin.html b/static/partials/repo-admin.html index 10ebaaee9..967a36892 100644 --- a/static/partials/repo-admin.html +++ b/static/partials/repo-admin.html @@ -56,7 +56,53 @@ -
+ + +
+
Access Token Permissions
+
+ + + + + + + + + + + + + + + + + + + + + + +
TokenPermissions
+ + {{ token.friendlyName }} + +
+ + +
+
+ + + + +
+ + + +
+
+
@@ -113,6 +159,24 @@
+ + + diff --git a/test.db b/test.db index 179b19de4..70f675eb8 100644 Binary files a/test.db and b/test.db differ