diff --git a/app.py b/app.py index 7ca66c53d..9db5c8819 100644 --- a/app.py +++ b/app.py @@ -6,12 +6,11 @@ import os from functools import partial from Crypto.PublicKey import RSA -from flask import Flask, request, Request, _request_ctx_stack -from flask_login import LoginManager, UserMixin +from flask import Flask, request, Request +from flask_login import LoginManager from flask_mail import Mail from flask_principal import Principal from jwkest.jwk import RSAKey -from werkzeug.routing import BaseConverter import features from _init import CONF_DIR diff --git a/auth/auth_context.py b/auth/auth_context.py index e2c24e624..8cb57f691 100644 --- a/auth/auth_context.py +++ b/auth/auth_context.py @@ -1,78 +1,21 @@ -import logging - from flask import _request_ctx_stack -from data import model - - -logger = logging.getLogger(__name__) +def get_authenticated_context(): + """ Returns the auth context for the current request context, if any. """ + return getattr(_request_ctx_stack.top, 'authenticated_context', None) def get_authenticated_user(): - user = getattr(_request_ctx_stack.top, 'authenticated_user', None) - if not user: - user_uuid = getattr(_request_ctx_stack.top, 'authenticated_user_uuid', None) - if not user_uuid: - logger.debug('No authenticated user or deferred user uuid.') - return None - - logger.debug('Loading deferred authenticated user.') - loaded = model.user.get_user_by_uuid(user_uuid) - if not loaded.enabled: - return None - - set_authenticated_user(loaded) - user = loaded - - if user: - logger.debug('Returning authenticated user: %s', user.username) - return user - - -def set_authenticated_user(user_or_robot): - if not user_or_robot.enabled: - raise Exception('Attempt to authenticate a disabled user/robot: %s' % user_or_robot.username) - - ctx = _request_ctx_stack.top - ctx.authenticated_user = user_or_robot - - -def get_grant_context(): - return getattr(_request_ctx_stack.top, 'grant_context', None) - - -def set_grant_context(grant_context): - ctx = _request_ctx_stack.top - ctx.grant_context = grant_context - - -def set_authenticated_user_deferred(user_or_robot_db_uuid): - logger.debug('Deferring loading of authenticated user object with uuid: %s', user_or_robot_db_uuid) - ctx = _request_ctx_stack.top - ctx.authenticated_user_uuid = user_or_robot_db_uuid - + """ Returns the authenticated user, if any, or None if none. """ + context = get_authenticated_context() + return context.authed_user if context else None def get_validated_oauth_token(): - return getattr(_request_ctx_stack.top, 'validated_oauth_token', None) + """ Returns the authenticated and validated OAuth access token, if any, or None if none. """ + context = get_authenticated_context() + return context.authed_oauth_token if context else None - -def set_validated_oauth_token(token): +def set_authenticated_context(auth_context): + """ Sets the auth context for the current request context to that given. """ ctx = _request_ctx_stack.top - ctx.validated_oauth_token = token - - -def get_validated_app_specific_token(): - return getattr(_request_ctx_stack.top, 'validated_app_specific_token', None) - - -def set_validated_app_specific_token(token): - ctx = _request_ctx_stack.top - ctx.validated_app_specific_token = token - - -def get_validated_token(): - return getattr(_request_ctx_stack.top, 'validated_token', None) - - -def set_validated_token(token): - ctx = _request_ctx_stack.top - ctx.validated_token = token + ctx.authenticated_context = auth_context + return auth_context diff --git a/auth/auth_context_type.py b/auth/auth_context_type.py new file mode 100644 index 000000000..dbd13c174 --- /dev/null +++ b/auth/auth_context_type.py @@ -0,0 +1,413 @@ +import logging + +from cachetools import lru_cache + +from abc import ABCMeta, abstractmethod +from six import add_metaclass + +from app import app +from data import model + +from flask_principal import Identity, identity_changed + +from auth.auth_context import set_authenticated_context +from auth.context_entity import ContextEntityKind, CONTEXT_ENTITY_HANDLERS +from auth.permissions import QuayDeferredPermissionUser +from auth.scopes import scopes_from_scope_string + +logger = logging.getLogger(__name__) + +@add_metaclass(ABCMeta) +class AuthContext(object): + """ + Interface that represents the current context of authentication. + """ + + @property + @abstractmethod + def entity_kind(self): + """ Returns the kind of the entity in this auth context. """ + pass + + @property + @abstractmethod + def is_anonymous(self): + """ Returns true if this is an anonymous context. """ + pass + + @property + @abstractmethod + def authed_oauth_token(self): + """ Returns the authenticated OAuth token, if any. """ + pass + + @property + @abstractmethod + def authed_user(self): + """ Returns the authenticated user, whether directly, or via an OAuth or access token. Note that + this property will also return robot accounts. + """ + pass + + @property + @abstractmethod + def has_nonrobot_user(self): + """ Returns whether a user (not a robot) was authenticated successfully. """ + pass + + @property + @abstractmethod + def identity(self): + """ Returns the identity for the auth context. """ + pass + + @property + @abstractmethod + def description(self): + """ Returns a human-readable and *public* description of the current auth context. """ + pass + + @property + @abstractmethod + def credential_username(self): + """ Returns the username to create credentials for this context's entity, if any. """ + pass + + @abstractmethod + def analytics_id_and_public_metadata(self): + """ Returns the analytics ID and public log metadata for this auth context. """ + pass + + @abstractmethod + def apply_to_request_context(self): + """ Applies this auth result to the auth context and Flask-Principal. """ + pass + + @abstractmethod + def to_signed_dict(self): + """ Serializes the auth context into a dictionary suitable for inclusion in a JWT or other + form of signed serialization. + """ + pass + + +class ValidatedAuthContext(AuthContext): + """ ValidatedAuthContext represents the loaded, authenticated and validated auth information + for the current request context. + """ + def __init__(self, user=None, token=None, oauthtoken=None, robot=None, appspecifictoken=None, + signed_data=None): + # Note: These field names *MUST* match the string values of the kinds defined in + # ContextEntityKind. + self.user = user + self.robot = robot + self.token = token + self.oauthtoken = oauthtoken + self.appspecifictoken = appspecifictoken + self.signed_data = signed_data + + def tuple(self): + return vars(self).values() + + def __eq__(self, other): + return self.tuple() == other.tuple() + + @property + def entity_kind(self): + """ Returns the kind of the entity in this auth context. """ + for kind in ContextEntityKind: + if hasattr(self, kind.value) and getattr(self, kind.value): + return kind + + return ContextEntityKind.anonymous + + @property + def authed_user(self): + """ Returns the authenticated user, whether directly, or via an OAuth token. Note that this + will also return robot accounts. + """ + authed_user = self._authed_user() + if authed_user is not None and not authed_user.enabled: + logger.warning('Attempt to reference a disabled user/robot: %s', authed_user.username) + return None + + return authed_user + + @property + def authed_oauth_token(self): + return self.oauthtoken + + def _authed_user(self): + if self.oauthtoken: + return self.oauthtoken.authorized_user + + if self.appspecifictoken: + return self.appspecifictoken.user + + if self.signed_data: + return model.user.get_user(self.signed_data['user_context']) + + return self.user if self.user else self.robot + + @property + def is_anonymous(self): + """ Returns true if this is an anonymous context. """ + return not self.authed_user and not self.token and not self.signed_data + + @property + def has_nonrobot_user(self): + """ Returns whether a user (not a robot) was authenticated successfully. """ + return bool(self.authed_user and not self.robot) + + @property + def identity(self): + """ Returns the identity for the auth context. """ + if self.oauthtoken: + scope_set = scopes_from_scope_string(self.oauthtoken.scope) + return QuayDeferredPermissionUser.for_user(self.oauthtoken.authorized_user, scope_set) + + if self.authed_user: + return QuayDeferredPermissionUser.for_user(self.authed_user) + + if self.token: + return Identity(self.token.code, 'token') + + if self.signed_data: + identity = Identity(None, 'signed_grant') + identity.provides.update(self.signed_data['grants']) + return identity + + return None + + @property + def entity_reference(self): + """ Returns the DB object reference for this context's entity. """ + if self.entity_kind == ContextEntityKind.anonymous: + return None + + return getattr(self, self.entity_kind.value) + + @property + def description(self): + """ Returns a human-readable and *public* description of the current auth context. """ + handler = CONTEXT_ENTITY_HANDLERS[self.entity_kind]() + return handler.description(self.entity_reference) + + @property + def credential_username(self): + """ Returns the username to create credentials for this context's entity, if any. """ + handler = CONTEXT_ENTITY_HANDLERS[self.entity_kind]() + return handler.credential_username(self.entity_reference) + + def analytics_id_and_public_metadata(self): + """ Returns the analytics ID and public log metadata for this auth context. """ + handler = CONTEXT_ENTITY_HANDLERS[self.entity_kind]() + return handler.analytics_id_and_public_metadata(self.entity_reference) + + def apply_to_request_context(self): + """ Applies this auth result to the auth context and Flask-Principal. """ + # Save to the request context. + set_authenticated_context(self) + + # Set the identity for Flask-Principal. + if self.identity: + identity_changed.send(app, identity=self.identity) + + def to_signed_dict(self): + """ Serializes the auth context into a dictionary suitable for inclusion in a JWT or other + form of signed serialization. + """ + dict_data = { + 'version': 2, + 'entity_kind': self.entity_kind.value, + } + + if self.entity_kind != ContextEntityKind.anonymous: + handler = CONTEXT_ENTITY_HANDLERS[self.entity_kind]() + dict_data.update({ + 'entity_reference': handler.get_serialized_entity_reference(self.entity_reference), + }) + + # Add legacy information. + # TODO(jschorr): Remove this all once the new code is fully deployed. + if self.token: + dict_data.update({ + 'kind': 'token', + 'token': self.token.code, + }) + + if self.oauthtoken: + dict_data.update({ + 'kind': 'oauth', + 'oauth': self.oauthtoken.uuid, + 'user': self.authed_user.username, + }) + + if self.user or self.robot: + dict_data.update({ + 'kind': 'user', + 'user': self.authed_user.username, + }) + + if self.appspecifictoken: + dict_data.update({ + 'kind': 'user', + 'user': self.authed_user.username, + }) + + if self.is_anonymous: + dict_data.update({ + 'kind': 'anonymous', + }) + + # End of legacy information. + return dict_data + +class SignedAuthContext(AuthContext): + """ SignedAuthContext represents an auth context loaded from a signed token of some kind, + such as a JWT. Unlike ValidatedAuthContext, SignedAuthContext operates lazily, only loading + the actual {user, robot, token, etc} when requested. This allows registry operations that + only need to check if *some* entity is present to do so, without hitting the database. + """ + def __init__(self, kind, signed_data, v1_dict_format): + self.kind = kind + self.signed_data = signed_data + self.v1_dict_format = v1_dict_format + + @classmethod + def build_from_signed_dict(cls, dict_data, v1_dict_format=False): + if not v1_dict_format: + entity_kind = ContextEntityKind(dict_data.get('entity_kind', 'anonymous')) + return SignedAuthContext(entity_kind, dict_data, v1_dict_format) + + # Legacy handling. + # TODO(jschorr): Remove this all once the new code is fully deployed. + kind_string = dict_data.get('kind', 'anonymous') + if kind_string == 'oauth': + kind_string = 'oauthtoken' + + kind = ContextEntityKind(kind_string) + return SignedAuthContext(kind, dict_data, v1_dict_format) + + @lru_cache(maxsize=1) + def _get_validated(self): + """ Returns a ValidatedAuthContext for this signed context, resolving all the necessary + references. + """ + if not self.v1_dict_format: + if self.kind == ContextEntityKind.anonymous: + return ValidatedAuthContext() + + serialized_entity_reference = self.signed_data['entity_reference'] + handler = CONTEXT_ENTITY_HANDLERS[self.kind]() + entity_reference = handler.deserialize_entity_reference(serialized_entity_reference) + if entity_reference is None: + logger.debug('Could not deserialize entity reference `%s` under kind `%s`', + serialized_entity_reference, self.kind) + return ValidatedAuthContext() + + return ValidatedAuthContext(**{self.kind.value: entity_reference}) + + # Legacy handling. + # TODO(jschorr): Remove this all once the new code is fully deployed. + kind_string = self.signed_data.get('kind', 'anonymous') + if kind_string == 'oauth': + kind_string = 'oauthtoken' + + kind = ContextEntityKind(kind_string) + if kind == ContextEntityKind.anonymous: + return ValidatedAuthContext() + + if kind == ContextEntityKind.user or kind == ContextEntityKind.robot: + user = model.user.get_user(self.signed_data.get('user', '')) + if not user: + return None + + return ValidatedAuthContext(robot=user) if user.robot else ValidatedAuthContext(user=user) + + if kind == ContextEntityKind.token: + token = model.token.load_token_data(self.signed_data.get('token')) + if not token: + return None + + return ValidatedAuthContext(token=token) + + if kind == ContextEntityKind.oauthtoken: + user = model.user.get_user(self.signed_data.get('user', '')) + if not user: + return None + + token_uuid = self.signed_data.get('oauth', '') + oauthtoken = model.oauth.lookup_access_token_for_user(user, token_uuid) + if not oauthtoken: + return None + + return ValidatedAuthContext(oauthtoken=oauthtoken) + + raise Exception('Unknown auth context kind `%s` when deserializing %s' % (kind, + self.signed_data)) + # End of legacy handling. + + @property + def entity_kind(self): + """ Returns the kind of the entity in this auth context. """ + return self.kind + + @property + def is_anonymous(self): + """ Returns true if this is an anonymous context. """ + return self.kind == ContextEntityKind.anonymous + + @property + def authed_user(self): + """ Returns the authenticated user, whether directly, or via an OAuth or access token. Note that + this property will also return robot accounts. + """ + if self.kind == ContextEntityKind.anonymous: + return None + + return self._get_validated().authed_user + + @property + def authed_oauth_token(self): + if self.kind == ContextEntityKind.anonymous: + return None + + return self._get_validated().authed_oauth_token + + @property + def has_nonrobot_user(self): + """ Returns whether a user (not a robot) was authenticated successfully. """ + if self.kind == ContextEntityKind.anonymous: + return False + + return self._get_validated().has_nonrobot_user + + @property + def identity(self): + """ Returns the identity for the auth context. """ + return self._get_validated().identity + + @property + def description(self): + """ Returns a human-readable and *public* description of the current auth context. """ + return self._get_validated().description + + @property + def credential_username(self): + """ Returns the username to create credentials for this context's entity, if any. """ + return self._get_validated().credential_username + + def analytics_id_and_public_metadata(self): + """ Returns the analytics ID and public log metadata for this auth context. """ + return self._get_validated().analytics_id_and_public_metadata() + + def apply_to_request_context(self): + """ Applies this auth result to the auth context and Flask-Principal. """ + return self._get_validated().apply_to_request_context() + + def to_signed_dict(self): + """ Serializes the auth context into a dictionary suitable for inclusion in a JWT or other + form of signed serialization. + """ + return self.signed_data diff --git a/auth/basic.py b/auth/basic.py index dfb945acf..fd3817b93 100644 --- a/auth/basic.py +++ b/auth/basic.py @@ -15,7 +15,7 @@ def has_basic_auth(username): """ auth_header = request.headers.get('authorization', '') result = validate_basic_auth(auth_header) - return result.has_user and result.user.username == username + return result.has_nonrobot_user and result.context.user.username == username def validate_basic_auth(auth_header): diff --git a/auth/context_entity.py b/auth/context_entity.py new file mode 100644 index 000000000..e86c110b6 --- /dev/null +++ b/auth/context_entity.py @@ -0,0 +1,203 @@ +from abc import ABCMeta, abstractmethod +from six import add_metaclass +from enum import Enum + +from data import model + +from auth.credential_consts import (ACCESS_TOKEN_USERNAME, OAUTH_TOKEN_USERNAME, + APP_SPECIFIC_TOKEN_USERNAME) + +class ContextEntityKind(Enum): + """ Defines the various kinds of entities in an auth context. Note that the string values of + these fields *must* match the names of the fields in the ValidatedAuthContext class, as + we fill them in directly based on the string names here. + """ + anonymous = 'anonymous' + user = 'user' + robot = 'robot' + token = 'token' + oauthtoken = 'oauthtoken' + appspecifictoken = 'appspecifictoken' + signed_data = 'signed_data' + + +@add_metaclass(ABCMeta) +class ContextEntityHandler(object): + """ + Interface that represents handling specific kinds of entities under an auth context. + """ + + @abstractmethod + def credential_username(self, entity_reference): + """ Returns the username to create credentials for this entity, if any. """ + pass + + @abstractmethod + def get_serialized_entity_reference(self, entity_reference): + """ Returns the entity reference for this kind of auth context, serialized into a form that can + be placed into a JSON object and put into a JWT. This is typically a DB UUID or another + unique identifier for the object in the DB. + """ + pass + + @abstractmethod + def deserialize_entity_reference(self, serialized_entity_reference): + """ Returns the deserialized reference to the entity in the database, or None if none. """ + pass + + @abstractmethod + def description(self, entity_reference): + """ Returns a human-readable and *public* description of the current entity. """ + pass + + @abstractmethod + def analytics_id_and_public_metadata(self, entity_reference): + """ Returns the analyitics ID and a dict of public metadata for the current entity. """ + pass + + +class AnonymousEntityHandler(ContextEntityHandler): + def credential_username(self, entity_reference): + return None + + def get_serialized_entity_reference(self, entity_reference): + return None + + def deserialize_entity_reference(self, serialized_entity_reference): + return None + + def description(self, entity_reference): + return "anonymous" + + def analytics_id_and_public_metadata(self, entity_reference): + return "anonymous", {} + + +class UserEntityHandler(ContextEntityHandler): + def credential_username(self, entity_reference): + return entity_reference.username + + def get_serialized_entity_reference(self, entity_reference): + return entity_reference.uuid + + def deserialize_entity_reference(self, serialized_entity_reference): + return model.user.get_user_by_uuid(serialized_entity_reference) + + def description(self, entity_reference): + return "user %s" % entity_reference.username + + def analytics_id_and_public_metadata(self, entity_reference): + return entity_reference.username, { + 'username': entity_reference.username, + } + + +class RobotEntityHandler(ContextEntityHandler): + def credential_username(self, entity_reference): + return entity_reference.username + + def get_serialized_entity_reference(self, entity_reference): + return entity_reference.username + + def deserialize_entity_reference(self, serialized_entity_reference): + return model.user.lookup_robot(serialized_entity_reference) + + def description(self, entity_reference): + return "robot %s" % entity_reference.username + + def analytics_id_and_public_metadata(self, entity_reference): + return entity_reference.username, { + 'username': entity_reference.username, + 'is_robot': True, + } + + +class TokenEntityHandler(ContextEntityHandler): + def credential_username(self, entity_reference): + return ACCESS_TOKEN_USERNAME + + def get_serialized_entity_reference(self, entity_reference): + return entity_reference.code + + def deserialize_entity_reference(self, serialized_entity_reference): + return model.token.load_token_data(serialized_entity_reference) + + def description(self, entity_reference): + return "token %s" % entity_reference.friendly_name + + def analytics_id_and_public_metadata(self, entity_reference): + return 'token:%s' % entity_reference.id, { + 'token': entity_reference.friendly_name, + } + + +class OAuthTokenEntityHandler(ContextEntityHandler): + def credential_username(self, entity_reference): + return OAUTH_TOKEN_USERNAME + + def get_serialized_entity_reference(self, entity_reference): + return entity_reference.uuid + + def deserialize_entity_reference(self, serialized_entity_reference): + return model.oauth.lookup_access_token_by_uuid(serialized_entity_reference) + + def description(self, entity_reference): + return "oauthtoken for user %s" % entity_reference.authorized_user.username + + def analytics_id_and_public_metadata(self, entity_reference): + return 'oauthtoken:%s' % entity_reference.id, { + 'oauth_token_id': entity_reference.id, + 'oauth_token_application_id': entity_reference.application.client_id, + 'oauth_token_application': entity_reference.application.name, + 'username': entity_reference.authorized_user.username, + } + + +class AppSpecificTokenEntityHandler(ContextEntityHandler): + def credential_username(self, entity_reference): + return APP_SPECIFIC_TOKEN_USERNAME + + def get_serialized_entity_reference(self, entity_reference): + return entity_reference.uuid + + def deserialize_entity_reference(self, serialized_entity_reference): + return model.appspecifictoken.get_token_by_uuid(serialized_entity_reference) + + def description(self, entity_reference): + tpl = (entity_reference.title, entity_reference.user.username) + return "app specific token %s for user %s" % tpl + + def analytics_id_and_public_metadata(self, entity_reference): + return 'appspecifictoken:%s' % entity_reference.id, { + 'app_specific_token': entity_reference.uuid, + 'app_specific_token_title': entity_reference.title, + 'username': entity_reference.user.username, + } + + +class SignedDataEntityHandler(ContextEntityHandler): + def credential_username(self, entity_reference): + return None + + def get_serialized_entity_reference(self, entity_reference): + raise NotImplementedError + + def deserialize_entity_reference(self, serialized_entity_reference): + raise NotImplementedError + + def description(self, entity_reference): + return "signed" + + def analytics_id_and_public_metadata(self, entity_reference): + return 'signed', {'signed': entity_reference} + + +CONTEXT_ENTITY_HANDLERS = { + ContextEntityKind.anonymous: AnonymousEntityHandler, + ContextEntityKind.user: UserEntityHandler, + ContextEntityKind.robot: RobotEntityHandler, + ContextEntityKind.token: TokenEntityHandler, + ContextEntityKind.oauthtoken: OAuthTokenEntityHandler, + ContextEntityKind.appspecifictoken: AppSpecificTokenEntityHandler, + ContextEntityKind.signed_data: SignedDataEntityHandler, +} diff --git a/auth/credential_consts.py b/auth/credential_consts.py new file mode 100644 index 000000000..dda9834d1 --- /dev/null +++ b/auth/credential_consts.py @@ -0,0 +1,3 @@ +ACCESS_TOKEN_USERNAME = '$token' +OAUTH_TOKEN_USERNAME = '$oauthtoken' +APP_SPECIFIC_TOKEN_USERNAME = '$app' diff --git a/auth/credentials.py b/auth/credentials.py index f03f697ec..a97155639 100644 --- a/auth/credentials.py +++ b/auth/credentials.py @@ -7,15 +7,13 @@ import features from app import authentication from auth.oauth import validate_oauth_token from auth.validateresult import ValidateResult, AuthKind +from auth.credential_consts import (ACCESS_TOKEN_USERNAME, OAUTH_TOKEN_USERNAME, + APP_SPECIFIC_TOKEN_USERNAME) from data import model from util.names import parse_robot_username logger = logging.getLogger(__name__) -ACCESS_TOKEN_USERNAME = '$token' -OAUTH_TOKEN_USERNAME = '$oauthtoken' -APP_SPECIFIC_TOKEN_USERNAME = '$app' - class CredentialKind(Enum): user = 'user' diff --git a/auth/decorators.py b/auth/decorators.py index 19b2671d5..2c61c8aa6 100644 --- a/auth/decorators.py +++ b/auth/decorators.py @@ -69,7 +69,7 @@ def require_session_login(func): @wraps(func) def wrapper(*args, **kwargs): result = validate_session_cookie() - if result.has_user: + if result.has_nonrobot_user: result.apply_to_context() metric_queue.authentication_count.Inc(labelvalues=[result.kind, True]) return func(*args, **kwargs) diff --git a/auth/registry_jwt_auth.py b/auth/registry_jwt_auth.py index 2b6c2f2c4..75be63d73 100644 --- a/auth/registry_jwt_auth.py +++ b/auth/registry_jwt_auth.py @@ -7,21 +7,18 @@ from flask import request, url_for from flask_principal import identity_changed, Identity from app import app, get_app_url, instance_keys, metric_queue -from auth.auth_context import (set_grant_context, get_grant_context) +from auth.auth_context import set_authenticated_context +from auth.auth_context_type import SignedAuthContext from auth.permissions import repository_read_grant, repository_write_grant, repository_admin_grant from util.http import abort from util.names import parse_namespace_repository from util.security.registry_jwt import (ANONYMOUS_SUB, decode_bearer_header, InvalidBearerTokenException) -from data import model logger = logging.getLogger(__name__) -CONTEXT_KINDS = ['user', 'token', 'oauth', 'app_specific_token'] - - ACCESS_SCHEMA = { 'type': 'array', 'description': 'List of access granted to the subject', @@ -65,71 +62,6 @@ class InvalidJWTException(Exception): pass -class GrantedEntity(object): - def __init__(self, user=None, token=None, oauth=None, app_specific_token=None): - self.user = user - self.token = token - self.oauth = oauth - self.app_specific_token = app_specific_token - - -def get_granted_entity(): - """ Returns the entity granted in the current context, if any. Returns the GrantedEntity or None - if none. - """ - context = get_grant_context() - if not context: - return None - - kind = context.get('kind', 'anonymous') - - if not kind in CONTEXT_KINDS: - return None - - if kind == 'app_specific_token': - app_specific_token = model.appspecifictoken.get_token_by_uuid(context.get('ast', '')) - if app_specific_token is None: - return None - - return GrantedEntity(app_specific_token=app_specific_token, user=app_specific_token.user) - - if kind == 'user': - user = model.user.get_user(context.get('user', '')) - if not user: - return None - - return GrantedEntity(user=user) - - if kind == 'token': - token = model.token.load_token_data(context.get('token')) - if not token: - return None - - return GrantedEntity(token=token) - - if kind == 'oauth': - user = model.user.get_user(context.get('user', '')) - if not user: - return None - - oauthtoken = model.oauth.lookup_access_token_for_user(user, context.get('oauth', '')) - if not oauthtoken: - return None - - return GrantedEntity(oauth=oauthtoken, user=user) - - return None - - -def get_granted_username(): - """ Returns the username inside the grant, if any. """ - granted = get_granted_entity() - if not granted or not granted.user: - return None - - return granted.user.username - - def get_auth_headers(repository=None, scopes=None): """ Returns a dictionary of headers for auth responses. """ headers = {} @@ -198,6 +130,9 @@ def identity_from_bearer_token(bearer_header): def process_registry_jwt_auth(scopes=None): + """ Processes the registry JWT auth token found in the authorization header. If none found, + no error is returned. If an invalid token is found, raises a 401. + """ def inner(func): @wraps(func) def wrapper(*args, **kwargs): @@ -205,10 +140,15 @@ def process_registry_jwt_auth(scopes=None): auth = request.headers.get('authorization', '').strip() if auth: try: - extracted_identity, context = identity_from_bearer_token(auth) + extracted_identity, context_dict = identity_from_bearer_token(auth) identity_changed.send(app, identity=extracted_identity) - set_grant_context(context) logger.debug('Identity changed to %s', extracted_identity.id) + + auth_context = SignedAuthContext.build_from_signed_dict(context_dict) + if auth_context is not None: + logger.debug('Auth context set to %s', auth_context.signed_data) + set_authenticated_context(auth_context) + except InvalidJWTException as ije: repository = None if 'namespace_name' in kwargs and 'repo_name' in kwargs: diff --git a/auth/test/test_auth_context_type.py b/auth/test/test_auth_context_type.py new file mode 100644 index 000000000..c3c3c8443 --- /dev/null +++ b/auth/test/test_auth_context_type.py @@ -0,0 +1,44 @@ +import pytest + +from auth.auth_context_type import SignedAuthContext, ValidatedAuthContext, ContextEntityKind +from data import model + +from test.fixtures import * + +@pytest.mark.parametrize('kind, entity_reference, loader', [ + (ContextEntityKind.anonymous, None, None), + (ContextEntityKind.appspecifictoken, 'test', model.appspecifictoken.access_valid_token), + (ContextEntityKind.oauthtoken, 'test', model.oauth.validate_access_token), + (ContextEntityKind.robot, 'devtable+dtrobot', model.user.lookup_robot), + (ContextEntityKind.user, 'devtable', model.user.get_user), +]) +@pytest.mark.parametrize('v1_dict_format', [ + (True), + (False), +]) +def test_signed_auth_context(kind, entity_reference, loader, v1_dict_format, initialized_db): + if kind == ContextEntityKind.anonymous: + validated = ValidatedAuthContext() + assert validated.is_anonymous + else: + ref = loader(entity_reference) + validated = ValidatedAuthContext(**{kind.value: ref}) + assert not validated.is_anonymous + + assert validated.entity_kind == kind + + signed = SignedAuthContext.build_from_signed_dict(validated.to_signed_dict(), + v1_dict_format=v1_dict_format) + + if not v1_dict_format: + # Under legacy V1 format, we don't track the app specific token, merely its associated user. + assert signed.entity_kind == kind + assert signed.description == validated.description + assert signed.credential_username == validated.credential_username + assert signed.analytics_id_and_public_metadata() == validated.analytics_id_and_public_metadata() + + assert signed.is_anonymous == validated.is_anonymous + assert signed.authed_user == validated.authed_user + assert signed.has_nonrobot_user == validated.has_nonrobot_user + + assert signed.to_signed_dict() == validated.to_signed_dict() diff --git a/auth/test/test_cookie.py b/auth/test/test_cookie.py index 022b220cf..8c212d709 100644 --- a/auth/test/test_cookie.py +++ b/auth/test/test_cookie.py @@ -20,8 +20,8 @@ def test_invalidformatted_cookie(app): # Ensure we get an invalid session cookie format error. result = validate_session_cookie() assert result.authed_user is None - assert result.identity is None - assert not result.has_user + assert result.context.identity is None + assert not result.has_nonrobot_user assert result.error_message == 'Invalid session cookie format' @@ -33,8 +33,8 @@ def test_disabled_user(app): # Ensure we get an invalid session cookie format error. result = validate_session_cookie() assert result.authed_user is None - assert result.identity is None - assert not result.has_user + assert result.context.identity is None + assert not result.has_nonrobot_user assert result.error_message == 'User account is disabled' @@ -45,8 +45,8 @@ def test_valid_user(app): result = validate_session_cookie() assert result.authed_user == someuser - assert result.identity is not None - assert result.has_user + assert result.context.identity is not None + assert result.has_nonrobot_user assert result.error_message is None @@ -61,6 +61,6 @@ def test_valid_organization(app): result = validate_session_cookie() assert result.authed_user is None - assert result.identity is None - assert not result.has_user + assert result.context.identity is None + assert not result.has_nonrobot_user assert result.error_message == 'Cannot login to organization' diff --git a/auth/test/test_credentials.py b/auth/test/test_credentials.py index 1000258c5..00e1c1df4 100644 --- a/auth/test/test_credentials.py +++ b/auth/test/test_credentials.py @@ -1,5 +1,6 @@ -from auth.credentials import (ACCESS_TOKEN_USERNAME, OAUTH_TOKEN_USERNAME, validate_credentials, - CredentialKind, APP_SPECIFIC_TOKEN_USERNAME) +from auth.credentials import validate_credentials, CredentialKind +from auth.credential_consts import (ACCESS_TOKEN_USERNAME, OAUTH_TOKEN_USERNAME, + APP_SPECIFIC_TOKEN_USERNAME) from auth.validateresult import AuthKind, ValidateResult from data import model diff --git a/auth/test/test_oauth.py b/auth/test/test_oauth.py index 35959c12c..e35df33ec 100644 --- a/auth/test/test_oauth.py +++ b/auth/test/test_oauth.py @@ -21,7 +21,7 @@ def test_valid_oauth(app): token = list(model.oauth.list_access_tokens_for_user(user))[0] result = validate_bearer_auth('bearer ' + token.access_token) - assert result.oauthtoken == token + assert result.context.oauthtoken == token assert result.authed_user == user assert result.auth_valid @@ -32,7 +32,7 @@ def test_disabled_user_oauth(app): access_token='foo') result = validate_bearer_auth('bearer ' + token.access_token) - assert result.oauthtoken is None + assert result.context.oauthtoken is None assert result.authed_user is None assert not result.auth_valid assert result.error_message == 'Granter of the oauth access token is disabled' @@ -44,7 +44,7 @@ def test_expired_token(app): access_token='bar', expires_in=-1000) result = validate_bearer_auth('bearer ' + token.access_token) - assert result.oauthtoken is None + assert result.context.oauthtoken is None assert result.authed_user is None assert not result.auth_valid assert result.error_message == 'OAuth access token has expired' diff --git a/auth/test/test_registry_jwt.py b/auth/test/test_registry_jwt.py index 796417b32..12000a95a 100644 --- a/auth/test/test_registry_jwt.py +++ b/auth/test/test_registry_jwt.py @@ -4,6 +4,7 @@ import jwt import pytest from app import app, instance_keys +from auth.auth_context_type import ValidatedAuthContext from auth.registry_jwt_auth import identity_from_bearer_token, InvalidJWTException from data import model # TODO(jzelinskie): remove this after service keys are decoupled from data.database import ServiceKeyApprovalType @@ -12,7 +13,7 @@ from util.morecollections import AttrDict from util.security.registry_jwt import ANONYMOUS_SUB, build_context_and_subject TEST_AUDIENCE = app.config['SERVER_HOSTNAME'] -TEST_USER = AttrDict({'username': 'joeuser'}) +TEST_USER = AttrDict({'username': 'joeuser', 'uuid': 'foobar', 'enabled': True}) MAX_SIGNED_S = 3660 TOKEN_VALIDITY_LIFETIME_S = 60 * 60 # 1 hour ANONYMOUS_SUB = '(anonymous)' @@ -27,7 +28,8 @@ def _access(typ='repository', name='somens/somerepo', actions=None): return [{ 'type': typ, 'name': name, - 'actions': actions,}] + 'actions': actions, + }] def _delete_field(token_data, field_name): @@ -38,7 +40,7 @@ def _delete_field(token_data, field_name): def _token_data(access=[], context=None, audience=TEST_AUDIENCE, user=TEST_USER, iat=None, exp=None, nbf=None, iss=None, subject=None): if subject is None: - _, subject = build_context_and_subject(user=user) + _, subject = build_context_and_subject(ValidatedAuthContext(user=user)) return { 'iss': iss or instance_keys.service_name, 'aud': audience, @@ -47,7 +49,8 @@ def _token_data(access=[], context=None, audience=TEST_AUDIENCE, user=TEST_USER, 'exp': exp if exp is not None else int(time.time() + TOKEN_VALIDITY_LIFETIME_S), 'sub': subject, 'access': access, - 'context': context,} + 'context': context, + } def _token(token_data, key_id=None, private_key=None, skip_header=False, alg=None): diff --git a/auth/test/test_validateresult.py b/auth/test/test_validateresult.py index 4b7d66c8f..38ce0e2dd 100644 --- a/auth/test/test_validateresult.py +++ b/auth/test/test_validateresult.py @@ -1,45 +1,40 @@ import pytest -from auth.auth_context import ( - get_authenticated_user, get_grant_context, get_validated_token, get_validated_oauth_token) +from auth.auth_context import get_authenticated_context from auth.validateresult import AuthKind, ValidateResult from data import model from test.fixtures import * - def get_user(): return model.user.get_user('devtable') +def get_app_specific_token(): + return model.appspecifictoken.access_valid_token('test') def get_robot(): robot, _ = model.user.create_robot('somebot', get_user()) return robot - def get_token(): return model.token.create_delegate_token('devtable', 'simple', 'sometoken') - def get_oauthtoken(): user = model.user.get_user('devtable') return list(model.oauth.list_access_tokens_for_user(user))[0] - def get_signeddata(): return {'grants': {'a': 'b'}, 'user_context': {'c': 'd'}} - @pytest.mark.parametrize('get_entity,entity_kind', [ (get_user, 'user'), (get_robot, 'robot'), (get_token, 'token'), (get_oauthtoken, 'oauthtoken'), - (get_signeddata, 'signed_data'),]) + (get_signeddata, 'signed_data'), + (get_app_specific_token, 'appspecifictoken'), +]) def test_apply_context(get_entity, entity_kind, app): - assert get_authenticated_user() is None - assert get_validated_token() is None - assert get_validated_oauth_token() is None - assert get_grant_context() is None + assert get_authenticated_context() is None entity = get_entity() args = {} @@ -52,16 +47,16 @@ def test_apply_context(get_entity, entity_kind, app): if entity_kind == 'oauthtoken': expected_user = entity.authorized_user + if entity_kind == 'appspecifictoken': + expected_user = entity.user + expected_token = entity if entity_kind == 'token' else None expected_oauth = entity if entity_kind == 'oauthtoken' else None + expected_appspecifictoken = entity if entity_kind == 'appspecifictoken' else None + expected_grant = entity if entity_kind == 'signed_data' else None - fake_grant = { - 'user': { - 'c': 'd'}, - 'kind': 'user',} - expected_grant = fake_grant if entity_kind == 'signed_data' else None - - assert get_authenticated_user() == expected_user - assert get_validated_token() == expected_token - assert get_validated_oauth_token() == expected_oauth - assert get_grant_context() == expected_grant + assert get_authenticated_context().authed_user == expected_user + assert get_authenticated_context().token == expected_token + assert get_authenticated_context().oauthtoken == expected_oauth + assert get_authenticated_context().appspecifictoken == expected_appspecifictoken + assert get_authenticated_context().signed_data == expected_grant diff --git a/auth/validateresult.py b/auth/validateresult.py index 3b5d74dca..a49b6218b 100644 --- a/auth/validateresult.py +++ b/auth/validateresult.py @@ -1,11 +1,5 @@ from enum import Enum -from flask_principal import Identity, identity_changed - -from app import app -from auth.auth_context import (set_authenticated_user, set_validated_token, set_grant_context, - set_validated_oauth_token, set_validated_app_specific_token) -from auth.scopes import scopes_from_scope_string -from auth.permissions import QuayDeferredPermissionUser +from auth.auth_context_type import ValidatedAuthContext, ContextEntityKind class AuthKind(Enum): @@ -22,94 +16,37 @@ class ValidateResult(object): robot=None, appspecifictoken=None, signed_data=None, error_message=None): self.kind = kind self.missing = missing - self.user = user - self.robot = robot - self.token = token - self.oauthtoken = oauthtoken - self.appspecifictoken = appspecifictoken - self.signed_data = signed_data self.error_message = error_message + self.context = ValidatedAuthContext(user=user, token=token, oauthtoken=oauthtoken, robot=robot, + appspecifictoken=appspecifictoken, signed_data=signed_data) def tuple(self): - return (self.kind, self.missing, self.user, self.token, self.oauthtoken, self.robot, - self.appspecifictoken, self.signed_data, self.error_message) + return (self.kind, self.missing, self.error_message, self.context.tuple()) def __eq__(self, other): return self.tuple() == other.tuple() def apply_to_context(self): """ Applies this auth result to the auth context and Flask-Principal. """ - # Set the various pieces of the auth context. - if self.oauthtoken: - set_authenticated_user(self.authed_user) - set_validated_oauth_token(self.oauthtoken) - elif self.appspecifictoken: - set_authenticated_user(self.authed_user) - set_validated_app_specific_token(self.appspecifictoken) - elif self.authed_user: - set_authenticated_user(self.authed_user) - elif self.token: - set_validated_token(self.token) - elif self.signed_data: - if self.signed_data['user_context']: - set_grant_context({ - 'user': self.signed_data['user_context'], - 'kind': 'user', - }) - - # Set the identity for Flask-Principal. - if self.identity: - identity_changed.send(app, identity=self.identity) + self.context.apply_to_request_context() def with_kind(self, kind): """ Returns a copy of this result, but with the kind replaced. """ - return ValidateResult(kind, self.missing, self.user, self.token, self.oauthtoken, self.robot, - self.appspecifictoken, self.signed_data, self.error_message) + result = ValidateResult(kind, missing=self.missing, error_message=self.error_message) + result.context = self.context + return result @property def authed_user(self): """ Returns the authenticated user, whether directly, or via an OAuth token. """ - if not self.auth_valid: - return None - - if self.oauthtoken: - return self.oauthtoken.authorized_user - - if self.appspecifictoken: - return self.appspecifictoken.user - - return self.user if self.user else self.robot + return self.context.authed_user @property - def identity(self): - """ Returns the identity for the auth result. """ - if not self.auth_valid: - return None - - if self.oauthtoken: - scope_set = scopes_from_scope_string(self.oauthtoken.scope) - return QuayDeferredPermissionUser.for_user(self.oauthtoken.authorized_user, scope_set) - - if self.authed_user: - return QuayDeferredPermissionUser.for_user(self.authed_user) - - if self.token: - return Identity(self.token.code, 'token') - - if self.signed_data: - identity = Identity(None, 'signed_grant') - identity.provides.update(self.signed_data['grants']) - return identity - - return None - - @property - def has_user(self): + def has_nonrobot_user(self): """ Returns whether a user (not a robot) was authenticated successfully. """ - return bool(self.user) + return self.context.has_nonrobot_user @property def auth_valid(self): """ Returns whether authentication successfully occurred. """ - return (self.user or self.token or self.oauthtoken or self.appspecifictoken or self.robot or - self.signed_data) + return self.context.entity_kind != ContextEntityKind.anonymous diff --git a/data/model/oauth.py b/data/model/oauth.py index 003332acb..8f072ed4d 100644 --- a/data/model/oauth.py +++ b/data/model/oauth.py @@ -255,6 +255,13 @@ def delete_application(org, client_id): return application +def lookup_access_token_by_uuid(token_uuid): + try: + return OAuthAccessToken.get(OAuthAccessToken.uuid == token_uuid) + except OAuthAccessToken.DoesNotExist: + return None + + def lookup_access_token_for_user(user_obj, token_uuid): try: return OAuthAccessToken.get(OAuthAccessToken.authorized_user == user_obj, diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py index 209b2d013..e79263cf8 100644 --- a/endpoints/api/__init__.py +++ b/endpoints/api/__init__.py @@ -15,7 +15,8 @@ from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermissi AdministerRepositoryPermission, UserReadPermission, UserAdminPermission) from auth import scopes -from auth.auth_context import get_authenticated_user, get_validated_oauth_token +from auth.auth_context import (get_authenticated_context, get_authenticated_user, + get_validated_oauth_token) from auth.decorators import process_oauth from endpoints.csrf import csrf_protect from endpoints.exception import (Unauthorized, InvalidRequest, InvalidResponse, @@ -291,8 +292,7 @@ def require_fresh_login(func): if not user: raise Unauthorized() - oauth_token = get_validated_oauth_token() - if oauth_token: + if get_validated_oauth_token(): return func(*args, **kwargs) logger.debug('Checking fresh login for user %s', user.username) diff --git a/endpoints/csrf.py b/endpoints/csrf.py index b2dbfcff1..708011425 100644 --- a/endpoints/csrf.py +++ b/endpoints/csrf.py @@ -46,8 +46,7 @@ def csrf_protect(session_token_name=_QUAY_CSRF_TOKEN_NAME, def inner(func): @wraps(func) def wrapper(*args, **kwargs): - oauth_token = get_validated_oauth_token() - if oauth_token is None: + if get_validated_oauth_token() is None: if all_methods or (request.method != "GET" and request.method != "HEAD"): verify_csrf(session_token_name, request_token_name) diff --git a/endpoints/decorators.py b/endpoints/decorators.py index 8337da812..725dcf316 100644 --- a/endpoints/decorators.py +++ b/endpoints/decorators.py @@ -6,8 +6,7 @@ from flask import abort, request, make_response import features from app import app -from auth.auth_context import ( - get_validated_oauth_token, get_authenticated_user, get_validated_token, get_grant_context) +from auth.auth_context import get_authenticated_context from util.names import parse_namespace_repository @@ -73,8 +72,7 @@ def check_anon_protection(func): return func(*args, **kwargs) # Check for validated context. If none exists, fail with a 401. - if (get_authenticated_user() or get_validated_oauth_token() or get_validated_token() or - get_grant_context()): + if get_authenticated_context() and not get_authenticated_context().is_anonymous: return func(*args, **kwargs) abort(401) diff --git a/endpoints/v1/index.py b/endpoints/v1/index.py index dcf68e4d7..1f34055da 100644 --- a/endpoints/v1/index.py +++ b/endpoints/v1/index.py @@ -7,8 +7,7 @@ from functools import wraps from flask import request, make_response, jsonify, session from app import userevents, metric_queue -from auth.auth_context import (get_authenticated_user, get_validated_token, - get_validated_oauth_token, get_validated_app_specific_token) +from auth.auth_context import get_authenticated_context, get_authenticated_user from auth.credentials import validate_credentials, CredentialKind from auth.decorators import process_auth from auth.permissions import ( @@ -106,7 +105,7 @@ def create_user(): # Default case: Just fail. abort(400, result.error_message, issue='login-failure') - if result.has_user: + if result.has_nonrobot_user: # Mark that the user was logged in. event = userevents.get_event(username) event.publish_event_data('docker-cli', {'action': 'login'}) @@ -119,27 +118,14 @@ def create_user(): @process_auth @anon_allowed def get_user(): - if get_validated_oauth_token(): - return jsonify({ - 'username': '$oauthtoken', - 'email': None, - }) - elif get_validated_app_specific_token(): - return jsonify({ - 'username': "$app", - 'email': None, - }) - elif get_authenticated_user(): - return jsonify({ - 'username': get_authenticated_user().username, - 'email': get_authenticated_user().email, - }) - elif get_validated_token(): - return jsonify({ - 'username': '$token', - 'email': None, - }) - abort(404) + context = get_authenticated_context() + if not context or context.is_anonymous: + abort(404) + + return jsonify({ + 'username': context.credential_username, + 'email': None, + }) @v1_bp.route('/users//', methods=['PUT']) diff --git a/endpoints/v1/registry.py b/endpoints/v1/registry.py index 366bf1afa..0bb187fef 100644 --- a/endpoints/v1/registry.py +++ b/endpoints/v1/registry.py @@ -11,7 +11,6 @@ from app import storage as store, app, metric_queue from auth.auth_context import get_authenticated_user from auth.decorators import extract_namespace_repo_from_session, process_auth from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermission) -from auth.registry_jwt_auth import get_granted_username from data import model, database from digest import checksums from endpoints.v1 import v1_bp @@ -433,9 +432,6 @@ def put_image_json(namespace, repository, image_id): v1_metadata = model.docker_v1_metadata(namespace, repository, image_id) if v1_metadata is None: username = get_authenticated_user() and get_authenticated_user().username - if not username: - username = get_granted_username() - logger.debug('Image not found, creating or linking image with initiating user context: %s', username) location_pref = store.preferred_locations[0] diff --git a/endpoints/v2/__init__.py b/endpoints/v2/__init__.py index 9b2b8cf67..2acfd5a42 100644 --- a/endpoints/v2/__init__.py +++ b/endpoints/v2/__init__.py @@ -11,7 +11,7 @@ from semantic_version import Spec import features from app import app, metric_queue, get_app_url, license_validator -from auth.auth_context import get_grant_context +from auth.auth_context import get_authenticated_context from auth.permissions import ( ReadRepositoryPermission, ModifyRepositoryPermission, AdministerRepositoryPermission) from auth.registry_jwt_auth import process_registry_jwt_auth, get_auth_headers @@ -146,7 +146,7 @@ def v2_support_enabled(): response = make_response('true', 200) - if get_grant_context() is None: + if get_authenticated_context() is None: response = make_response('true', 401) response.headers.extend(get_auth_headers()) diff --git a/endpoints/v2/catalog.py b/endpoints/v2/catalog.py index 096dd1d15..5d29cfffc 100644 --- a/endpoints/v2/catalog.py +++ b/endpoints/v2/catalog.py @@ -2,7 +2,8 @@ import features from flask import jsonify -from auth.registry_jwt_auth import process_registry_jwt_auth, get_granted_entity +from auth.auth_context import get_authenticated_user +from auth.registry_jwt_auth import process_registry_jwt_auth from endpoints.decorators import anon_protect from endpoints.v2 import v2_bp, paginate from endpoints.v2.models_pre_oci import data_model as model @@ -13,11 +14,7 @@ from endpoints.v2.models_pre_oci import data_model as model @anon_protect @paginate() def catalog_search(limit, offset, pagination_callback): - username = None - entity = get_granted_entity() - if entity: - username = entity.user.username - + username = get_authenticated_user().username if get_authenticated_user() else None include_public = bool(features.PUBLIC_CATALOG) visible_repositories = model.get_visible_repositories(username, limit + 1, offset, include_public=include_public) diff --git a/endpoints/v2/test/test_blob.py b/endpoints/v2/test/test_blob.py index 0ec205949..5747ac5c3 100644 --- a/endpoints/v2/test/test_blob.py +++ b/endpoints/v2/test/test_blob.py @@ -6,6 +6,7 @@ from flask import url_for from playhouse.test_utils import assert_query_count from app import instance_keys, app as realapp +from auth.auth_context_type import ValidatedAuthContext from data import model from data.cache import InMemoryDataModelCache from data.database import ImageStorageLocation @@ -34,7 +35,7 @@ def test_blob_caching(method, endpoint, client, app): 'actions': ['pull'], }] - context, subject = build_context_and_subject(user=user) + context, subject = build_context_and_subject(ValidatedAuthContext(user=user)) token = generate_bearer_token(realapp.config['SERVER_HOSTNAME'], subject, context, access, 600, instance_keys) diff --git a/endpoints/v2/test/test_manifest.py b/endpoints/v2/test/test_manifest.py index f78dac206..8f50fa190 100644 --- a/endpoints/v2/test/test_manifest.py +++ b/endpoints/v2/test/test_manifest.py @@ -8,6 +8,7 @@ from flask import url_for from playhouse.test_utils import count_queries from app import instance_keys, app as realapp +from auth.auth_context_type import ValidatedAuthContext from data import model from endpoints.test.shared import conduct_call from util.security.registry_jwt import generate_bearer_token, build_context_and_subject @@ -28,7 +29,7 @@ def test_e2e_query_count_manifest_norewrite(client, app): 'actions': ['pull', 'push'], }] - context, subject = build_context_and_subject(user=user) + context, subject = build_context_and_subject(ValidatedAuthContext(user=user)) token = generate_bearer_token(realapp.config['SERVER_HOSTNAME'], subject, context, access, 600, instance_keys) diff --git a/endpoints/v2/v2auth.py b/endpoints/v2/v2auth.py index 6e92e9171..532745d96 100644 --- a/endpoints/v2/v2auth.py +++ b/endpoints/v2/v2auth.py @@ -2,12 +2,11 @@ import logging import re from cachetools import lru_cache -from flask import request, jsonify, abort +from flask import request, jsonify import features from app import app, userevents, instance_keys -from auth.auth_context import (get_authenticated_user, get_validated_token, - get_validated_oauth_token, get_validated_app_specific_token) +from auth.auth_context import get_authenticated_context, get_authenticated_user from auth.decorators import process_basic_auth from auth.permissions import (ModifyRepositoryPermission, ReadRepositoryPermission, CreateRepositoryPermission, AdministerRepositoryPermission) @@ -48,21 +47,14 @@ def generate_registry_jwt(auth_result): 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) - - appspecifictoken = get_validated_app_specific_token() - logger.debug('Authenticated app specific token: %s', appspecifictoken) - auth_header = request.headers.get('authorization', '') auth_credentials_sent = bool(auth_header) - if auth_credentials_sent and not user and not token: + + has_valid_auth_context = False + if get_authenticated_context(): + has_valid_auth_context = not get_authenticated_context().is_anonymous + + if auth_credentials_sent and not has_valid_auth_context: # The auth credentials sent for the user are invalid. raise InvalidLogin(auth_result.error_message) @@ -109,9 +101,9 @@ def generate_registry_jwt(auth_result): '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 + # Check if there is a valid user or token, as otherwise the repository cannot be # accessed. - if user is not None or token is not None: + if has_valid_auth_context: # Lookup the repository. If it exists, make sure the entity has modify # permission. Otherwise, make sure the entity has create permission. if repo: @@ -123,6 +115,7 @@ def generate_registry_jwt(auth_result): else: logger.debug('No permission to modify repository %s/%s', namespace, reponame) else: + user = get_authenticated_user() if CreateRepositoryPermission(namespace).can() and user is not None: logger.debug('Creating repository: %s/%s', namespace, reponame) model.create_repository(namespace, reponame, user) @@ -172,19 +165,18 @@ def generate_registry_jwt(auth_result): } tuf_root = get_tuf_root(repo, namespace, reponame) - elif user is None and token is None: + elif not has_valid_auth_context: # 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') raise Unauthorized() # Send the user event. - if user is not None: - event = userevents.get_event(user.username) + if get_authenticated_user() is not None: + event = userevents.get_event(get_authenticated_user().username) event.publish_event_data('docker-cli', user_event_data) - # Build the signed JWT. - context, subject = build_context_and_subject(user=user, token=token, oauthtoken=oauthtoken, - appspecifictoken=appspecifictoken, tuf_root=tuf_root) + # Build the signed JWT. + context, subject = build_context_and_subject(get_authenticated_context(), tuf_root=tuf_root) token = generate_bearer_token(audience_param, subject, context, access, TOKEN_VALIDITY_LIFETIME_S, instance_keys) return jsonify({'token': token}) diff --git a/util/audit.py b/util/audit.py index 8de8a4329..1d2cc4909 100644 --- a/util/audit.py +++ b/util/audit.py @@ -7,9 +7,7 @@ from flask import request from app import analytics, userevents, ip_resolver from data import model -from auth.registry_jwt_auth import get_granted_entity -from auth.auth_context import (get_authenticated_user, get_validated_token, - get_validated_oauth_token, get_validated_app_specific_token) +from auth.auth_context import get_authenticated_context, get_authenticated_user logger = logging.getLogger(__name__) @@ -22,51 +20,16 @@ def track_and_log(event_name, repo_obj, analytics_name=None, analytics_sample=1, } metadata.update(kwargs) + # Add auth context metadata. analytics_id = 'anonymous' - - authenticated_oauth_token = get_validated_oauth_token() - authenticated_user = get_authenticated_user() - authenticated_token = get_validated_token() if not authenticated_user else None - app_specific_token = get_validated_app_specific_token() - - if (not authenticated_user and not authenticated_token and not authenticated_oauth_token and - not app_specific_token): - entity = get_granted_entity() - if entity: - authenticated_user = entity.user - authenticated_token = entity.token - authenticated_oauth_token = entity.oauth - app_specific_token = entity.app_specific_token - - logger.debug('Logging the %s to Mixpanel and the log system', event_name) - if authenticated_oauth_token: - metadata['oauth_token_id'] = authenticated_oauth_token.id - metadata['oauth_token_application_id'] = authenticated_oauth_token.application.client_id - metadata['oauth_token_application'] = authenticated_oauth_token.application.name - metadata['username'] = authenticated_user.username - analytics_id = 'oauth:{0}'.format(authenticated_oauth_token.id) - elif app_specific_token: - metadata['app_specific_token'] = app_specific_token.uuid - metadata['username'] = authenticated_user.username - analytics_id = 'appspecifictoken:{0}'.format(app_specific_token.uuid) - elif authenticated_user: - metadata['username'] = authenticated_user.username - analytics_id = authenticated_user.username - elif authenticated_token: - metadata['token'] = authenticated_token.friendly_name - metadata['token_code'] = authenticated_token.code - - if authenticated_token.kind: - metadata['token_type'] = authenticated_token.kind.name - - analytics_id = 'token:{0}'.format(authenticated_token.code) - else: - metadata['public'] = True - analytics_id = 'anonymous' + auth_context = get_authenticated_context() + if auth_context is not None: + analytics_id, context_metadata = auth_context.analytics_id_and_public_metadata() + metadata.update(context_metadata) # Publish the user event (if applicable) logger.debug('Checking publishing %s to the user events system', event_name) - if authenticated_user and not authenticated_user.robot: + if auth_context and auth_context.has_nonrobot_user: logger.debug('Publishing %s to the user events system', event_name) user_event_data = { 'action': event_name, @@ -74,7 +37,7 @@ def track_and_log(event_name, repo_obj, analytics_name=None, analytics_sample=1, 'namespace': namespace_name, } - event = userevents.get_event(authenticated_user.username) + event = userevents.get_event(auth_context.authed_user.username) event.publish_event_data('docker-cli', user_event_data) # Save the action to mixpanel. @@ -100,6 +63,6 @@ def track_and_log(event_name, repo_obj, analytics_name=None, analytics_sample=1, # Log the action to the database. logger.debug('Logging the %s to logs system', event_name) - model.log.log_action(event_name, namespace_name, performer=authenticated_user, + model.log.log_action(event_name, namespace_name, performer=get_authenticated_user(), ip=request.remote_addr, metadata=metadata, repository=repo_obj) logger.debug('Track and log of %s complete', event_name) diff --git a/util/http.py b/util/http.py index 836a737dd..3ef44b235 100644 --- a/util/http.py +++ b/util/http.py @@ -5,7 +5,7 @@ from flask import request, make_response, current_app from werkzeug.exceptions import HTTPException from app import analytics -from auth.auth_context import get_authenticated_user, get_validated_token +from auth.auth_context import get_authenticated_context logger = logging.getLogger(__name__) @@ -58,15 +58,9 @@ def abort(status_code, message=None, issue=None, headers=None, **kwargs): params['message'] = message # Add the user information. - auth_user = get_authenticated_user() - auth_token = get_validated_token() - if auth_user: - analytics.track(auth_user.username, 'http_error', params) - message = '%s (user: %s)' % (message, auth_user.username) - elif auth_token: - analytics.track(auth_token.code, 'http_error', params) - message = '%s (token: %s)' % (message, - auth_token.friendly_name or auth_token.code) + auth_context = get_authenticated_context() + if auth_context is not None: + message = '%s (authorized: %s)' % (message, auth_context.description) # Log the abort. logger.error('Error %s: %s; Arguments: %s' % (status_code, message, params)) diff --git a/util/security/registry_jwt.py b/util/security/registry_jwt.py index ff5cbb9a2..a8d8083be 100644 --- a/util/security/registry_jwt.py +++ b/util/security/registry_jwt.py @@ -106,50 +106,21 @@ def _generate_jwt_object(audience, subject, context, access, lifetime_s, issuer, return jwt.encode(token_data, private_key, ALGORITHM, headers=token_headers) -def build_context_and_subject(user=None, token=None, oauthtoken=None, appspecifictoken=None, - tuf_root=None): +def build_context_and_subject(auth_context=None, tuf_root=None): """ Builds the custom context field for the JWT signed token and returns it, along with the subject for the JWT signed token. """ - + # Serialize to a dictionary. + context = auth_context.to_signed_dict() if auth_context else {} + # Default to quay root if not explicitly granted permission to see signer root if not tuf_root: tuf_root = QUAY_TUF_ROOT - - context = { - CLAIM_TUF_ROOT: tuf_root - } - - if oauthtoken: - context.update({ - 'kind': 'oauth', - 'user': user.username, - 'oauth': oauthtoken.uuid, - }) - return (context, user.username) - - if appspecifictoken: - context.update({ - 'kind': 'app_specific_token', - 'user': user.username, - 'ast': appspecifictoken.uuid, - }) - return (context, user.username) - - if user: - context.update({ - 'kind': 'user', - 'user': user.username, - }) - return (context, user.username) - - if token: - context.update({ - 'kind': 'token', - 'token': token.code, - }) - return (context, None) context.update({ - 'kind': 'anonymous', + CLAIM_TUF_ROOT: tuf_root }) - return (context, ANONYMOUS_SUB) + + if not auth_context or auth_context.is_anonymous: + return (context, ANONYMOUS_SUB) + + return (context, auth_context.authed_user.username if auth_context.authed_user else None) diff --git a/util/tufmetadata/api.py b/util/tufmetadata/api.py index 410509cef..f2dda0930 100644 --- a/util/tufmetadata/api.py +++ b/util/tufmetadata/api.py @@ -1,9 +1,10 @@ import logging +from urlparse import urljoin +from posixpath import join + from abc import ABCMeta, abstractmethod from six import add_metaclass -from urlparse import urljoin -from posixpath import join import requests @@ -11,7 +12,8 @@ from data.database import CloseForLongOperation from util.abchelpers import nooper from util.failover import failover, FailoverException from util.security.instancekeys import InstanceKeys -from util.security.registry_jwt import build_context_and_subject, generate_bearer_token, QUAY_TUF_ROOT, SIGNER_TUF_ROOT +from util.security.registry_jwt import (build_context_and_subject, generate_bearer_token, + SIGNER_TUF_ROOT) DEFAULT_HTTP_HEADERS = {'Connection': 'close'} @@ -223,7 +225,7 @@ class ImplementedTUFMetadataAPI(TUFMetadataAPIInterface): except Non200ResponseException as ex: logger.exception('Failed request for %s: %s', gun, str(ex)) except InvalidMetadataException as ex: - logger.exception('Failed to parse targets from metadata', str(ex)) + logger.exception('Failed to parse targets from metadata: %s', str(ex)) return None def _parse_signed(self, json_response): @@ -240,7 +242,7 @@ class ImplementedTUFMetadataAPI(TUFMetadataAPIInterface): 'name': gun, 'actions': actions, }] - context, subject = build_context_and_subject(user=None, token=None, oauthtoken=None, tuf_root=SIGNER_TUF_ROOT) + context, subject = build_context_and_subject(auth_context=None, tuf_root=SIGNER_TUF_ROOT) token = generate_bearer_token(self._config["SERVER_HOSTNAME"], subject, context, access, TOKEN_VALIDITY_LIFETIME_S, self._instance_keys) return {'Authorization': 'Bearer %s' % token}