Merge pull request #2967 from coreos-inc/joseph.schorr/QS-111/auth-refactor

Refactor auth code to be cleaner and more extensible
This commit is contained in:
josephschorr 2018-02-15 16:02:22 -05:00 committed by GitHub
commit 7cd2c00d4d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 822 additions and 436 deletions

5
app.py
View file

@ -6,12 +6,11 @@ import os
from functools import partial from functools import partial
from Crypto.PublicKey import RSA from Crypto.PublicKey import RSA
from flask import Flask, request, Request, _request_ctx_stack from flask import Flask, request, Request
from flask_login import LoginManager, UserMixin from flask_login import LoginManager
from flask_mail import Mail from flask_mail import Mail
from flask_principal import Principal from flask_principal import Principal
from jwkest.jwk import RSAKey from jwkest.jwk import RSAKey
from werkzeug.routing import BaseConverter
import features import features
from _init import CONF_DIR from _init import CONF_DIR

View file

@ -1,78 +1,21 @@
import logging
from flask import _request_ctx_stack 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(): def get_authenticated_user():
user = getattr(_request_ctx_stack.top, 'authenticated_user', None) """ Returns the authenticated user, if any, or None if none. """
if not user: context = get_authenticated_context()
user_uuid = getattr(_request_ctx_stack.top, 'authenticated_user_uuid', None) return context.authed_user if context else 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
def get_validated_oauth_token(): 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_authenticated_context(auth_context):
def set_validated_oauth_token(token): """ Sets the auth context for the current request context to that given. """
ctx = _request_ctx_stack.top ctx = _request_ctx_stack.top
ctx.validated_oauth_token = token ctx.authenticated_context = auth_context
return auth_context
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

413
auth/auth_context_type.py Normal file
View file

@ -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

View file

@ -15,7 +15,7 @@ def has_basic_auth(username):
""" """
auth_header = request.headers.get('authorization', '') auth_header = request.headers.get('authorization', '')
result = validate_basic_auth(auth_header) 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): def validate_basic_auth(auth_header):

203
auth/context_entity.py Normal file
View file

@ -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,
}

View file

@ -0,0 +1,3 @@
ACCESS_TOKEN_USERNAME = '$token'
OAUTH_TOKEN_USERNAME = '$oauthtoken'
APP_SPECIFIC_TOKEN_USERNAME = '$app'

View file

@ -7,15 +7,13 @@ import features
from app import authentication from app import authentication
from auth.oauth import validate_oauth_token from auth.oauth import validate_oauth_token
from auth.validateresult import ValidateResult, AuthKind 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 data import model
from util.names import parse_robot_username from util.names import parse_robot_username
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ACCESS_TOKEN_USERNAME = '$token'
OAUTH_TOKEN_USERNAME = '$oauthtoken'
APP_SPECIFIC_TOKEN_USERNAME = '$app'
class CredentialKind(Enum): class CredentialKind(Enum):
user = 'user' user = 'user'

View file

@ -69,7 +69,7 @@ def require_session_login(func):
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
result = validate_session_cookie() result = validate_session_cookie()
if result.has_user: if result.has_nonrobot_user:
result.apply_to_context() result.apply_to_context()
metric_queue.authentication_count.Inc(labelvalues=[result.kind, True]) metric_queue.authentication_count.Inc(labelvalues=[result.kind, True])
return func(*args, **kwargs) return func(*args, **kwargs)

View file

@ -7,21 +7,18 @@ from flask import request, url_for
from flask_principal import identity_changed, Identity from flask_principal import identity_changed, Identity
from app import app, get_app_url, instance_keys, metric_queue 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 auth.permissions import repository_read_grant, repository_write_grant, repository_admin_grant
from util.http import abort from util.http import abort
from util.names import parse_namespace_repository from util.names import parse_namespace_repository
from util.security.registry_jwt import (ANONYMOUS_SUB, decode_bearer_header, from util.security.registry_jwt import (ANONYMOUS_SUB, decode_bearer_header,
InvalidBearerTokenException) InvalidBearerTokenException)
from data import model
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
CONTEXT_KINDS = ['user', 'token', 'oauth', 'app_specific_token']
ACCESS_SCHEMA = { ACCESS_SCHEMA = {
'type': 'array', 'type': 'array',
'description': 'List of access granted to the subject', 'description': 'List of access granted to the subject',
@ -65,71 +62,6 @@ class InvalidJWTException(Exception):
pass 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): def get_auth_headers(repository=None, scopes=None):
""" Returns a dictionary of headers for auth responses. """ """ Returns a dictionary of headers for auth responses. """
headers = {} headers = {}
@ -198,6 +130,9 @@ def identity_from_bearer_token(bearer_header):
def process_registry_jwt_auth(scopes=None): 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): def inner(func):
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
@ -205,10 +140,15 @@ def process_registry_jwt_auth(scopes=None):
auth = request.headers.get('authorization', '').strip() auth = request.headers.get('authorization', '').strip()
if auth: if auth:
try: 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) identity_changed.send(app, identity=extracted_identity)
set_grant_context(context)
logger.debug('Identity changed to %s', extracted_identity.id) 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: except InvalidJWTException as ije:
repository = None repository = None
if 'namespace_name' in kwargs and 'repo_name' in kwargs: if 'namespace_name' in kwargs and 'repo_name' in kwargs:

View file

@ -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()

View file

@ -20,8 +20,8 @@ def test_invalidformatted_cookie(app):
# Ensure we get an invalid session cookie format error. # Ensure we get an invalid session cookie format error.
result = validate_session_cookie() result = validate_session_cookie()
assert result.authed_user is None assert result.authed_user is None
assert result.identity is None assert result.context.identity is None
assert not result.has_user assert not result.has_nonrobot_user
assert result.error_message == 'Invalid session cookie format' 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. # Ensure we get an invalid session cookie format error.
result = validate_session_cookie() result = validate_session_cookie()
assert result.authed_user is None assert result.authed_user is None
assert result.identity is None assert result.context.identity is None
assert not result.has_user assert not result.has_nonrobot_user
assert result.error_message == 'User account is disabled' assert result.error_message == 'User account is disabled'
@ -45,8 +45,8 @@ def test_valid_user(app):
result = validate_session_cookie() result = validate_session_cookie()
assert result.authed_user == someuser assert result.authed_user == someuser
assert result.identity is not None assert result.context.identity is not None
assert result.has_user assert result.has_nonrobot_user
assert result.error_message is None assert result.error_message is None
@ -61,6 +61,6 @@ def test_valid_organization(app):
result = validate_session_cookie() result = validate_session_cookie()
assert result.authed_user is None assert result.authed_user is None
assert result.identity is None assert result.context.identity is None
assert not result.has_user assert not result.has_nonrobot_user
assert result.error_message == 'Cannot login to organization' assert result.error_message == 'Cannot login to organization'

View file

@ -1,5 +1,6 @@
from auth.credentials import (ACCESS_TOKEN_USERNAME, OAUTH_TOKEN_USERNAME, validate_credentials, from auth.credentials import validate_credentials, CredentialKind
CredentialKind, APP_SPECIFIC_TOKEN_USERNAME) from auth.credential_consts import (ACCESS_TOKEN_USERNAME, OAUTH_TOKEN_USERNAME,
APP_SPECIFIC_TOKEN_USERNAME)
from auth.validateresult import AuthKind, ValidateResult from auth.validateresult import AuthKind, ValidateResult
from data import model from data import model

View file

@ -21,7 +21,7 @@ def test_valid_oauth(app):
token = list(model.oauth.list_access_tokens_for_user(user))[0] token = list(model.oauth.list_access_tokens_for_user(user))[0]
result = validate_bearer_auth('bearer ' + token.access_token) result = validate_bearer_auth('bearer ' + token.access_token)
assert result.oauthtoken == token assert result.context.oauthtoken == token
assert result.authed_user == user assert result.authed_user == user
assert result.auth_valid assert result.auth_valid
@ -32,7 +32,7 @@ def test_disabled_user_oauth(app):
access_token='foo') access_token='foo')
result = validate_bearer_auth('bearer ' + token.access_token) 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 result.authed_user is None
assert not result.auth_valid assert not result.auth_valid
assert result.error_message == 'Granter of the oauth access token is disabled' 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) access_token='bar', expires_in=-1000)
result = validate_bearer_auth('bearer ' + token.access_token) 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 result.authed_user is None
assert not result.auth_valid assert not result.auth_valid
assert result.error_message == 'OAuth access token has expired' assert result.error_message == 'OAuth access token has expired'

View file

@ -4,6 +4,7 @@ import jwt
import pytest import pytest
from app import app, instance_keys 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 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 import model # TODO(jzelinskie): remove this after service keys are decoupled
from data.database import ServiceKeyApprovalType 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 from util.security.registry_jwt import ANONYMOUS_SUB, build_context_and_subject
TEST_AUDIENCE = app.config['SERVER_HOSTNAME'] TEST_AUDIENCE = app.config['SERVER_HOSTNAME']
TEST_USER = AttrDict({'username': 'joeuser'}) TEST_USER = AttrDict({'username': 'joeuser', 'uuid': 'foobar', 'enabled': True})
MAX_SIGNED_S = 3660 MAX_SIGNED_S = 3660
TOKEN_VALIDITY_LIFETIME_S = 60 * 60 # 1 hour TOKEN_VALIDITY_LIFETIME_S = 60 * 60 # 1 hour
ANONYMOUS_SUB = '(anonymous)' ANONYMOUS_SUB = '(anonymous)'
@ -27,7 +28,8 @@ def _access(typ='repository', name='somens/somerepo', actions=None):
return [{ return [{
'type': typ, 'type': typ,
'name': name, 'name': name,
'actions': actions,}] 'actions': actions,
}]
def _delete_field(token_data, field_name): 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, def _token_data(access=[], context=None, audience=TEST_AUDIENCE, user=TEST_USER, iat=None,
exp=None, nbf=None, iss=None, subject=None): exp=None, nbf=None, iss=None, subject=None):
if subject is None: if subject is None:
_, subject = build_context_and_subject(user=user) _, subject = build_context_and_subject(ValidatedAuthContext(user=user))
return { return {
'iss': iss or instance_keys.service_name, 'iss': iss or instance_keys.service_name,
'aud': audience, '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), 'exp': exp if exp is not None else int(time.time() + TOKEN_VALIDITY_LIFETIME_S),
'sub': subject, 'sub': subject,
'access': access, 'access': access,
'context': context,} 'context': context,
}
def _token(token_data, key_id=None, private_key=None, skip_header=False, alg=None): def _token(token_data, key_id=None, private_key=None, skip_header=False, alg=None):

View file

@ -1,45 +1,40 @@
import pytest import pytest
from auth.auth_context import ( from auth.auth_context import get_authenticated_context
get_authenticated_user, get_grant_context, get_validated_token, get_validated_oauth_token)
from auth.validateresult import AuthKind, ValidateResult from auth.validateresult import AuthKind, ValidateResult
from data import model from data import model
from test.fixtures import * from test.fixtures import *
def get_user(): def get_user():
return model.user.get_user('devtable') return model.user.get_user('devtable')
def get_app_specific_token():
return model.appspecifictoken.access_valid_token('test')
def get_robot(): def get_robot():
robot, _ = model.user.create_robot('somebot', get_user()) robot, _ = model.user.create_robot('somebot', get_user())
return robot return robot
def get_token(): def get_token():
return model.token.create_delegate_token('devtable', 'simple', 'sometoken') return model.token.create_delegate_token('devtable', 'simple', 'sometoken')
def get_oauthtoken(): def get_oauthtoken():
user = model.user.get_user('devtable') user = model.user.get_user('devtable')
return list(model.oauth.list_access_tokens_for_user(user))[0] return list(model.oauth.list_access_tokens_for_user(user))[0]
def get_signeddata(): def get_signeddata():
return {'grants': {'a': 'b'}, 'user_context': {'c': 'd'}} return {'grants': {'a': 'b'}, 'user_context': {'c': 'd'}}
@pytest.mark.parametrize('get_entity,entity_kind', [ @pytest.mark.parametrize('get_entity,entity_kind', [
(get_user, 'user'), (get_user, 'user'),
(get_robot, 'robot'), (get_robot, 'robot'),
(get_token, 'token'), (get_token, 'token'),
(get_oauthtoken, 'oauthtoken'), (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): def test_apply_context(get_entity, entity_kind, app):
assert get_authenticated_user() is None assert get_authenticated_context() is None
assert get_validated_token() is None
assert get_validated_oauth_token() is None
assert get_grant_context() is None
entity = get_entity() entity = get_entity()
args = {} args = {}
@ -52,16 +47,16 @@ def test_apply_context(get_entity, entity_kind, app):
if entity_kind == 'oauthtoken': if entity_kind == 'oauthtoken':
expected_user = entity.authorized_user expected_user = entity.authorized_user
if entity_kind == 'appspecifictoken':
expected_user = entity.user
expected_token = entity if entity_kind == 'token' else None expected_token = entity if entity_kind == 'token' else None
expected_oauth = entity if entity_kind == 'oauthtoken' 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 = { assert get_authenticated_context().authed_user == expected_user
'user': { assert get_authenticated_context().token == expected_token
'c': 'd'}, assert get_authenticated_context().oauthtoken == expected_oauth
'kind': 'user',} assert get_authenticated_context().appspecifictoken == expected_appspecifictoken
expected_grant = fake_grant if entity_kind == 'signed_data' else None assert get_authenticated_context().signed_data == expected_grant
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

View file

@ -1,11 +1,5 @@
from enum import Enum from enum import Enum
from flask_principal import Identity, identity_changed from auth.auth_context_type import ValidatedAuthContext, ContextEntityKind
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
class AuthKind(Enum): class AuthKind(Enum):
@ -22,94 +16,37 @@ class ValidateResult(object):
robot=None, appspecifictoken=None, signed_data=None, error_message=None): robot=None, appspecifictoken=None, signed_data=None, error_message=None):
self.kind = kind self.kind = kind
self.missing = missing 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.error_message = error_message
self.context = ValidatedAuthContext(user=user, token=token, oauthtoken=oauthtoken, robot=robot,
appspecifictoken=appspecifictoken, signed_data=signed_data)
def tuple(self): def tuple(self):
return (self.kind, self.missing, self.user, self.token, self.oauthtoken, self.robot, return (self.kind, self.missing, self.error_message, self.context.tuple())
self.appspecifictoken, self.signed_data, self.error_message)
def __eq__(self, other): def __eq__(self, other):
return self.tuple() == other.tuple() return self.tuple() == other.tuple()
def apply_to_context(self): def apply_to_context(self):
""" Applies this auth result to the auth context and Flask-Principal. """ """ Applies this auth result to the auth context and Flask-Principal. """
# Set the various pieces of the auth context. self.context.apply_to_request_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)
def with_kind(self, kind): def with_kind(self, kind):
""" Returns a copy of this result, but with the kind replaced. """ """ Returns a copy of this result, but with the kind replaced. """
return ValidateResult(kind, self.missing, self.user, self.token, self.oauthtoken, self.robot, result = ValidateResult(kind, missing=self.missing, error_message=self.error_message)
self.appspecifictoken, self.signed_data, self.error_message) result.context = self.context
return result
@property @property
def authed_user(self): def authed_user(self):
""" Returns the authenticated user, whether directly, or via an OAuth token. """ """ Returns the authenticated user, whether directly, or via an OAuth token. """
if not self.auth_valid: return self.context.authed_user
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
@property @property
def identity(self): def has_nonrobot_user(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):
""" Returns whether a user (not a robot) was authenticated successfully. """ """ Returns whether a user (not a robot) was authenticated successfully. """
return bool(self.user) return self.context.has_nonrobot_user
@property @property
def auth_valid(self): def auth_valid(self):
""" Returns whether authentication successfully occurred. """ """ Returns whether authentication successfully occurred. """
return (self.user or self.token or self.oauthtoken or self.appspecifictoken or self.robot or return self.context.entity_kind != ContextEntityKind.anonymous
self.signed_data)

View file

@ -255,6 +255,13 @@ def delete_application(org, client_id):
return application 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): def lookup_access_token_for_user(user_obj, token_uuid):
try: try:
return OAuthAccessToken.get(OAuthAccessToken.authorized_user == user_obj, return OAuthAccessToken.get(OAuthAccessToken.authorized_user == user_obj,

View file

@ -15,7 +15,8 @@ from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermissi
AdministerRepositoryPermission, UserReadPermission, AdministerRepositoryPermission, UserReadPermission,
UserAdminPermission) UserAdminPermission)
from auth import scopes 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 auth.decorators import process_oauth
from endpoints.csrf import csrf_protect from endpoints.csrf import csrf_protect
from endpoints.exception import (Unauthorized, InvalidRequest, InvalidResponse, from endpoints.exception import (Unauthorized, InvalidRequest, InvalidResponse,
@ -291,8 +292,7 @@ def require_fresh_login(func):
if not user: if not user:
raise Unauthorized() raise Unauthorized()
oauth_token = get_validated_oauth_token() if get_validated_oauth_token():
if oauth_token:
return func(*args, **kwargs) return func(*args, **kwargs)
logger.debug('Checking fresh login for user %s', user.username) logger.debug('Checking fresh login for user %s', user.username)

View file

@ -46,8 +46,7 @@ def csrf_protect(session_token_name=_QUAY_CSRF_TOKEN_NAME,
def inner(func): def inner(func):
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
oauth_token = get_validated_oauth_token() if get_validated_oauth_token() is None:
if oauth_token is None:
if all_methods or (request.method != "GET" and request.method != "HEAD"): if all_methods or (request.method != "GET" and request.method != "HEAD"):
verify_csrf(session_token_name, request_token_name) verify_csrf(session_token_name, request_token_name)

View file

@ -6,8 +6,7 @@ from flask import abort, request, make_response
import features import features
from app import app from app import app
from auth.auth_context import ( from auth.auth_context import get_authenticated_context
get_validated_oauth_token, get_authenticated_user, get_validated_token, get_grant_context)
from util.names import parse_namespace_repository from util.names import parse_namespace_repository
@ -73,8 +72,7 @@ def check_anon_protection(func):
return func(*args, **kwargs) return func(*args, **kwargs)
# Check for validated context. If none exists, fail with a 401. # 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 if get_authenticated_context() and not get_authenticated_context().is_anonymous:
get_grant_context()):
return func(*args, **kwargs) return func(*args, **kwargs)
abort(401) abort(401)

View file

@ -7,8 +7,7 @@ from functools import wraps
from flask import request, make_response, jsonify, session from flask import request, make_response, jsonify, session
from app import userevents, metric_queue from app import userevents, metric_queue
from auth.auth_context import (get_authenticated_user, get_validated_token, from auth.auth_context import get_authenticated_context, get_authenticated_user
get_validated_oauth_token, get_validated_app_specific_token)
from auth.credentials import validate_credentials, CredentialKind from auth.credentials import validate_credentials, CredentialKind
from auth.decorators import process_auth from auth.decorators import process_auth
from auth.permissions import ( from auth.permissions import (
@ -106,7 +105,7 @@ def create_user():
# Default case: Just fail. # Default case: Just fail.
abort(400, result.error_message, issue='login-failure') abort(400, result.error_message, issue='login-failure')
if result.has_user: if result.has_nonrobot_user:
# Mark that the user was logged in. # Mark that the user was logged in.
event = userevents.get_event(username) event = userevents.get_event(username)
event.publish_event_data('docker-cli', {'action': 'login'}) event.publish_event_data('docker-cli', {'action': 'login'})
@ -119,27 +118,14 @@ def create_user():
@process_auth @process_auth
@anon_allowed @anon_allowed
def get_user(): def get_user():
if get_validated_oauth_token(): context = get_authenticated_context()
return jsonify({ if not context or context.is_anonymous:
'username': '$oauthtoken', abort(404)
'email': None,
}) return jsonify({
elif get_validated_app_specific_token(): 'username': context.credential_username,
return jsonify({ 'email': None,
'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)
@v1_bp.route('/users/<username>/', methods=['PUT']) @v1_bp.route('/users/<username>/', methods=['PUT'])

View file

@ -11,7 +11,6 @@ from app import storage as store, app, metric_queue
from auth.auth_context import get_authenticated_user from auth.auth_context import get_authenticated_user
from auth.decorators import extract_namespace_repo_from_session, process_auth from auth.decorators import extract_namespace_repo_from_session, process_auth
from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermission) from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermission)
from auth.registry_jwt_auth import get_granted_username
from data import model, database from data import model, database
from digest import checksums from digest import checksums
from endpoints.v1 import v1_bp 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) v1_metadata = model.docker_v1_metadata(namespace, repository, image_id)
if v1_metadata is None: if v1_metadata is None:
username = get_authenticated_user() and get_authenticated_user().username 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', logger.debug('Image not found, creating or linking image with initiating user context: %s',
username) username)
location_pref = store.preferred_locations[0] location_pref = store.preferred_locations[0]

View file

@ -11,7 +11,7 @@ from semantic_version import Spec
import features import features
from app import app, metric_queue, get_app_url, license_validator 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 ( from auth.permissions import (
ReadRepositoryPermission, ModifyRepositoryPermission, AdministerRepositoryPermission) ReadRepositoryPermission, ModifyRepositoryPermission, AdministerRepositoryPermission)
from auth.registry_jwt_auth import process_registry_jwt_auth, get_auth_headers 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) response = make_response('true', 200)
if get_grant_context() is None: if get_authenticated_context() is None:
response = make_response('true', 401) response = make_response('true', 401)
response.headers.extend(get_auth_headers()) response.headers.extend(get_auth_headers())

View file

@ -2,7 +2,8 @@ import features
from flask import jsonify 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.decorators import anon_protect
from endpoints.v2 import v2_bp, paginate from endpoints.v2 import v2_bp, paginate
from endpoints.v2.models_pre_oci import data_model as model 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 @anon_protect
@paginate() @paginate()
def catalog_search(limit, offset, pagination_callback): def catalog_search(limit, offset, pagination_callback):
username = None username = get_authenticated_user().username if get_authenticated_user() else None
entity = get_granted_entity()
if entity:
username = entity.user.username
include_public = bool(features.PUBLIC_CATALOG) include_public = bool(features.PUBLIC_CATALOG)
visible_repositories = model.get_visible_repositories(username, limit + 1, offset, visible_repositories = model.get_visible_repositories(username, limit + 1, offset,
include_public=include_public) include_public=include_public)

View file

@ -6,6 +6,7 @@ from flask import url_for
from playhouse.test_utils import assert_query_count from playhouse.test_utils import assert_query_count
from app import instance_keys, app as realapp from app import instance_keys, app as realapp
from auth.auth_context_type import ValidatedAuthContext
from data import model from data import model
from data.cache import InMemoryDataModelCache from data.cache import InMemoryDataModelCache
from data.database import ImageStorageLocation from data.database import ImageStorageLocation
@ -34,7 +35,7 @@ def test_blob_caching(method, endpoint, client, app):
'actions': ['pull'], '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, token = generate_bearer_token(realapp.config['SERVER_HOSTNAME'], subject, context, access, 600,
instance_keys) instance_keys)

View file

@ -8,6 +8,7 @@ from flask import url_for
from playhouse.test_utils import count_queries from playhouse.test_utils import count_queries
from app import instance_keys, app as realapp from app import instance_keys, app as realapp
from auth.auth_context_type import ValidatedAuthContext
from data import model from data import model
from endpoints.test.shared import conduct_call from endpoints.test.shared import conduct_call
from util.security.registry_jwt import generate_bearer_token, build_context_and_subject 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'], '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, token = generate_bearer_token(realapp.config['SERVER_HOSTNAME'], subject, context, access, 600,
instance_keys) instance_keys)

View file

@ -2,12 +2,11 @@ import logging
import re import re
from cachetools import lru_cache from cachetools import lru_cache
from flask import request, jsonify, abort from flask import request, jsonify
import features import features
from app import app, userevents, instance_keys from app import app, userevents, instance_keys
from auth.auth_context import (get_authenticated_user, get_validated_token, from auth.auth_context import get_authenticated_context, get_authenticated_user
get_validated_oauth_token, get_validated_app_specific_token)
from auth.decorators import process_basic_auth from auth.decorators import process_basic_auth
from auth.permissions import (ModifyRepositoryPermission, ReadRepositoryPermission, from auth.permissions import (ModifyRepositoryPermission, ReadRepositoryPermission,
CreateRepositoryPermission, AdministerRepositoryPermission) CreateRepositoryPermission, AdministerRepositoryPermission)
@ -48,21 +47,14 @@ def generate_registry_jwt(auth_result):
scope_param = request.args.get('scope') or '' 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()
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_header = request.headers.get('authorization', '')
auth_credentials_sent = bool(auth_header) 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. # The auth credentials sent for the user are invalid.
raise InvalidLogin(auth_result.error_message) 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) 'This repository is for managing %s resources ' + 'and not container images.') % repo.kind)
if 'push' in actions: 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. # 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 # Lookup the repository. If it exists, make sure the entity has modify
# permission. Otherwise, make sure the entity has create permission. # permission. Otherwise, make sure the entity has create permission.
if repo: if repo:
@ -123,6 +115,7 @@ def generate_registry_jwt(auth_result):
else: else:
logger.debug('No permission to modify repository %s/%s', namespace, reponame) logger.debug('No permission to modify repository %s/%s', namespace, reponame)
else: else:
user = get_authenticated_user()
if CreateRepositoryPermission(namespace).can() and user is not None: if CreateRepositoryPermission(namespace).can() and user is not None:
logger.debug('Creating repository: %s/%s', namespace, reponame) logger.debug('Creating repository: %s/%s', namespace, reponame)
model.create_repository(namespace, reponame, user) model.create_repository(namespace, reponame, user)
@ -172,19 +165,18 @@ def generate_registry_jwt(auth_result):
} }
tuf_root = get_tuf_root(repo, namespace, reponame) 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 # 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') logger.debug('No user and no token sent for empty scope list')
raise Unauthorized() raise Unauthorized()
# Send the user event. # Send the user event.
if user is not None: if get_authenticated_user() is not None:
event = userevents.get_event(user.username) event = userevents.get_event(get_authenticated_user().username)
event.publish_event_data('docker-cli', user_event_data) event.publish_event_data('docker-cli', user_event_data)
# Build the signed JWT. # Build the signed JWT.
context, subject = build_context_and_subject(user=user, token=token, oauthtoken=oauthtoken, context, subject = build_context_and_subject(get_authenticated_context(), tuf_root=tuf_root)
appspecifictoken=appspecifictoken, tuf_root=tuf_root)
token = generate_bearer_token(audience_param, subject, context, access, token = generate_bearer_token(audience_param, subject, context, access,
TOKEN_VALIDITY_LIFETIME_S, instance_keys) TOKEN_VALIDITY_LIFETIME_S, instance_keys)
return jsonify({'token': token}) return jsonify({'token': token})

View file

@ -7,9 +7,7 @@ from flask import request
from app import analytics, userevents, ip_resolver from app import analytics, userevents, ip_resolver
from data import model from data import model
from auth.registry_jwt_auth import get_granted_entity from auth.auth_context import get_authenticated_context, get_authenticated_user
from auth.auth_context import (get_authenticated_user, get_validated_token,
get_validated_oauth_token, get_validated_app_specific_token)
logger = logging.getLogger(__name__) 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) metadata.update(kwargs)
# Add auth context metadata.
analytics_id = 'anonymous' analytics_id = 'anonymous'
auth_context = get_authenticated_context()
authenticated_oauth_token = get_validated_oauth_token() if auth_context is not None:
authenticated_user = get_authenticated_user() analytics_id, context_metadata = auth_context.analytics_id_and_public_metadata()
authenticated_token = get_validated_token() if not authenticated_user else None metadata.update(context_metadata)
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'
# Publish the user event (if applicable) # Publish the user event (if applicable)
logger.debug('Checking publishing %s to the user events system', event_name) 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) logger.debug('Publishing %s to the user events system', event_name)
user_event_data = { user_event_data = {
'action': event_name, 'action': event_name,
@ -74,7 +37,7 @@ def track_and_log(event_name, repo_obj, analytics_name=None, analytics_sample=1,
'namespace': namespace_name, '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) event.publish_event_data('docker-cli', user_event_data)
# Save the action to mixpanel. # 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. # Log the action to the database.
logger.debug('Logging the %s to logs system', event_name) 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) ip=request.remote_addr, metadata=metadata, repository=repo_obj)
logger.debug('Track and log of %s complete', event_name) logger.debug('Track and log of %s complete', event_name)

View file

@ -5,7 +5,7 @@ from flask import request, make_response, current_app
from werkzeug.exceptions import HTTPException from werkzeug.exceptions import HTTPException
from app import analytics 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__) logger = logging.getLogger(__name__)
@ -58,15 +58,9 @@ def abort(status_code, message=None, issue=None, headers=None, **kwargs):
params['message'] = message params['message'] = message
# Add the user information. # Add the user information.
auth_user = get_authenticated_user() auth_context = get_authenticated_context()
auth_token = get_validated_token() if auth_context is not None:
if auth_user: message = '%s (authorized: %s)' % (message, auth_context.description)
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)
# Log the abort. # Log the abort.
logger.error('Error %s: %s; Arguments: %s' % (status_code, message, params)) logger.error('Error %s: %s; Arguments: %s' % (status_code, message, params))

View file

@ -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) return jwt.encode(token_data, private_key, ALGORITHM, headers=token_headers)
def build_context_and_subject(user=None, token=None, oauthtoken=None, appspecifictoken=None, def build_context_and_subject(auth_context=None, tuf_root=None):
tuf_root=None):
""" Builds the custom context field for the JWT signed token and returns it, """ Builds the custom context field for the JWT signed token and returns it,
along with the subject for the JWT signed token. """ 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 # Default to quay root if not explicitly granted permission to see signer root
if not tuf_root: if not tuf_root:
tuf_root = QUAY_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({ 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)

View file

@ -1,9 +1,10 @@
import logging import logging
from urlparse import urljoin
from posixpath import join
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
from six import add_metaclass from six import add_metaclass
from urlparse import urljoin
from posixpath import join
import requests import requests
@ -11,7 +12,8 @@ from data.database import CloseForLongOperation
from util.abchelpers import nooper from util.abchelpers import nooper
from util.failover import failover, FailoverException from util.failover import failover, FailoverException
from util.security.instancekeys import InstanceKeys 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'} DEFAULT_HTTP_HEADERS = {'Connection': 'close'}
@ -223,7 +225,7 @@ class ImplementedTUFMetadataAPI(TUFMetadataAPIInterface):
except Non200ResponseException as ex: except Non200ResponseException as ex:
logger.exception('Failed request for %s: %s', gun, str(ex)) logger.exception('Failed request for %s: %s', gun, str(ex))
except InvalidMetadataException as 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 return None
def _parse_signed(self, json_response): def _parse_signed(self, json_response):
@ -240,7 +242,7 @@ class ImplementedTUFMetadataAPI(TUFMetadataAPIInterface):
'name': gun, 'name': gun,
'actions': actions, '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 = generate_bearer_token(self._config["SERVER_HOSTNAME"], subject, context, access,
TOKEN_VALIDITY_LIFETIME_S, self._instance_keys) TOKEN_VALIDITY_LIFETIME_S, self._instance_keys)
return {'Authorization': 'Bearer %s' % token} return {'Authorization': 'Bearer %s' % token}