initial import for Open Source 🎉
This commit is contained in:
parent
1898c361f3
commit
9c0dd3b722
2048 changed files with 218743 additions and 0 deletions
0
auth/__init__.py
Normal file
0
auth/__init__.py
Normal file
21
auth/auth_context.py
Normal file
21
auth/auth_context.py
Normal file
|
@ -0,0 +1,21 @@
|
|||
from flask import _request_ctx_stack
|
||||
|
||||
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():
|
||||
""" 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():
|
||||
""" 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):
|
||||
""" Sets the auth context for the current request context to that given. """
|
||||
ctx = _request_ctx_stack.top
|
||||
ctx.authenticated_context = auth_context
|
||||
return auth_context
|
437
auth/auth_context_type.py
Normal file
437
auth/auth_context_type.py
Normal file
|
@ -0,0 +1,437 @@
|
|||
import logging
|
||||
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from cachetools.func import lru_cache
|
||||
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
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def unique_key(self):
|
||||
""" Returns a key that is unique to this auth context type and its data. For example, an
|
||||
instance of the auth context type for the user might be a string of the form
|
||||
`user-{user-uuid}`. Callers should treat this key as opaque and not rely on the contents
|
||||
for anything besides uniqueness. This is typically used by callers when they'd like to
|
||||
check cache but not hit the database to get a fully validated auth context.
|
||||
"""
|
||||
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.get_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)
|
||||
|
||||
@property
|
||||
def unique_key(self):
|
||||
signed_dict = self.to_signed_dict()
|
||||
return '%s-%s' % (signed_dict['entity_kind'], signed_dict.get('entity_reference', '(anon)'))
|
||||
|
||||
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: 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
|
||||
|
||||
@property
|
||||
def unique_key(self):
|
||||
if self.v1_dict_format:
|
||||
# Since V1 data format is verbose, just use the validated version to get the key.
|
||||
return self._get_validated().unique_key
|
||||
|
||||
signed_dict = self.signed_data
|
||||
return '%s-%s' % (signed_dict['entity_kind'], signed_dict.get('entity_reference', '(anon)'))
|
||||
|
||||
@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: 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: 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
|
58
auth/basic.py
Normal file
58
auth/basic.py
Normal file
|
@ -0,0 +1,58 @@
|
|||
import logging
|
||||
|
||||
from base64 import b64decode
|
||||
from flask import request
|
||||
|
||||
from auth.credentials import validate_credentials
|
||||
from auth.validateresult import ValidateResult, AuthKind
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def has_basic_auth(username):
|
||||
""" Returns true if a basic auth header exists with a username and password pair that validates
|
||||
against the internal authentication system. Returns True on full success and False on any
|
||||
failure (missing header, invalid header, invalid credentials, etc).
|
||||
"""
|
||||
auth_header = request.headers.get('authorization', '')
|
||||
result = validate_basic_auth(auth_header)
|
||||
return result.has_nonrobot_user and result.context.user.username == username
|
||||
|
||||
|
||||
def validate_basic_auth(auth_header):
|
||||
""" Validates the specified basic auth header, returning whether its credentials point
|
||||
to a valid user or token.
|
||||
"""
|
||||
if not auth_header:
|
||||
return ValidateResult(AuthKind.basic, missing=True)
|
||||
|
||||
logger.debug('Attempt to process basic auth header')
|
||||
|
||||
# Parse the basic auth header.
|
||||
assert isinstance(auth_header, basestring)
|
||||
credentials, err = _parse_basic_auth_header(auth_header)
|
||||
if err is not None:
|
||||
logger.debug('Got invalid basic auth header: %s', auth_header)
|
||||
return ValidateResult(AuthKind.basic, missing=True)
|
||||
|
||||
auth_username, auth_password_or_token = credentials
|
||||
result, _ = validate_credentials(auth_username, auth_password_or_token)
|
||||
return result.with_kind(AuthKind.basic)
|
||||
|
||||
|
||||
def _parse_basic_auth_header(auth):
|
||||
""" Parses the given basic auth header, returning the credentials found inside.
|
||||
"""
|
||||
normalized = [part.strip() for part in auth.split(' ') if part]
|
||||
if normalized[0].lower() != 'basic' or len(normalized) != 2:
|
||||
return None, 'Invalid basic auth header'
|
||||
|
||||
try:
|
||||
credentials = [part.decode('utf-8') for part in b64decode(normalized[1]).split(':', 1)]
|
||||
except (TypeError, UnicodeDecodeError, ValueError):
|
||||
logger.exception('Exception when parsing basic auth header: %s', auth)
|
||||
return None, 'Could not parse basic auth header'
|
||||
|
||||
if len(credentials) != 2:
|
||||
return None, 'Unexpected number of credentials found in basic auth header'
|
||||
|
||||
return credentials, None
|
203
auth/context_entity.py
Normal file
203
auth/context_entity.py
Normal 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.get_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,
|
||||
}
|
37
auth/cookie.py
Normal file
37
auth/cookie.py
Normal file
|
@ -0,0 +1,37 @@
|
|||
import logging
|
||||
|
||||
from uuid import UUID
|
||||
from flask_login import current_user
|
||||
|
||||
from auth.validateresult import AuthKind, ValidateResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def validate_session_cookie(auth_header_unusued=None):
|
||||
""" Attempts to load a user from a session cookie. """
|
||||
if current_user.is_anonymous:
|
||||
return ValidateResult(AuthKind.cookie, missing=True)
|
||||
|
||||
try:
|
||||
# Attempt to parse the user uuid to make sure the cookie has the right value type
|
||||
UUID(current_user.get_id())
|
||||
except ValueError:
|
||||
logger.debug('Got non-UUID for session cookie user: %s', current_user.get_id())
|
||||
return ValidateResult(AuthKind.cookie, error_message='Invalid session cookie format')
|
||||
|
||||
logger.debug('Loading user from cookie: %s', current_user.get_id())
|
||||
db_user = current_user.db_user()
|
||||
if db_user is None:
|
||||
return ValidateResult(AuthKind.cookie, error_message='Could not find matching user')
|
||||
|
||||
# Don't allow disabled users to login.
|
||||
if not db_user.enabled:
|
||||
logger.debug('User %s in session cookie is disabled', db_user.username)
|
||||
return ValidateResult(AuthKind.cookie, error_message='User account is disabled')
|
||||
|
||||
# Don't allow organizations to "login".
|
||||
if db_user.organization:
|
||||
logger.debug('User %s in session cookie is in-fact organization', db_user.username)
|
||||
return ValidateResult(AuthKind.cookie, error_message='Cannot login to organization')
|
||||
|
||||
return ValidateResult(AuthKind.cookie, user=db_user)
|
3
auth/credential_consts.py
Normal file
3
auth/credential_consts.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
ACCESS_TOKEN_USERNAME = '$token'
|
||||
OAUTH_TOKEN_USERNAME = '$oauthtoken'
|
||||
APP_SPECIFIC_TOKEN_USERNAME = '$app'
|
85
auth/credentials.py
Normal file
85
auth/credentials.py
Normal file
|
@ -0,0 +1,85 @@
|
|||
import logging
|
||||
|
||||
from enum import Enum
|
||||
|
||||
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__)
|
||||
|
||||
|
||||
class CredentialKind(Enum):
|
||||
user = 'user'
|
||||
robot = 'robot'
|
||||
token = ACCESS_TOKEN_USERNAME
|
||||
oauth_token = OAUTH_TOKEN_USERNAME
|
||||
app_specific_token = APP_SPECIFIC_TOKEN_USERNAME
|
||||
|
||||
|
||||
def validate_credentials(auth_username, auth_password_or_token):
|
||||
""" Validates a pair of auth username and password/token credentials. """
|
||||
# Check for access tokens.
|
||||
if auth_username == ACCESS_TOKEN_USERNAME:
|
||||
logger.debug('Found credentials for access token')
|
||||
try:
|
||||
token = model.token.load_token_data(auth_password_or_token)
|
||||
logger.debug('Successfully validated credentials for access token %s', token.id)
|
||||
return ValidateResult(AuthKind.credentials, token=token), CredentialKind.token
|
||||
except model.DataModelException:
|
||||
logger.warning('Failed to validate credentials for access token %s', auth_password_or_token)
|
||||
return (ValidateResult(AuthKind.credentials, error_message='Invalid access token'),
|
||||
CredentialKind.token)
|
||||
|
||||
# Check for App Specific tokens.
|
||||
if features.APP_SPECIFIC_TOKENS and auth_username == APP_SPECIFIC_TOKEN_USERNAME:
|
||||
logger.debug('Found credentials for app specific auth token')
|
||||
token = model.appspecifictoken.access_valid_token(auth_password_or_token)
|
||||
if token is None:
|
||||
logger.debug('Failed to validate credentials for app specific token: %s',
|
||||
auth_password_or_token)
|
||||
return (ValidateResult(AuthKind.credentials, error_message='Invalid token'),
|
||||
CredentialKind.app_specific_token)
|
||||
|
||||
if not token.user.enabled:
|
||||
logger.debug('Tried to use an app specific token for a disabled user: %s',
|
||||
token.uuid)
|
||||
return (ValidateResult(AuthKind.credentials,
|
||||
error_message='This user has been disabled. Please contact your administrator.'),
|
||||
CredentialKind.app_specific_token)
|
||||
|
||||
logger.debug('Successfully validated credentials for app specific token %s', token.id)
|
||||
return (ValidateResult(AuthKind.credentials, appspecifictoken=token),
|
||||
CredentialKind.app_specific_token)
|
||||
|
||||
# Check for OAuth tokens.
|
||||
if auth_username == OAUTH_TOKEN_USERNAME:
|
||||
return validate_oauth_token(auth_password_or_token), CredentialKind.oauth_token
|
||||
|
||||
# Check for robots and users.
|
||||
is_robot = parse_robot_username(auth_username)
|
||||
if is_robot:
|
||||
logger.debug('Found credentials header for robot %s', auth_username)
|
||||
try:
|
||||
robot = model.user.verify_robot(auth_username, auth_password_or_token)
|
||||
logger.debug('Successfully validated credentials for robot %s', auth_username)
|
||||
return ValidateResult(AuthKind.credentials, robot=robot), CredentialKind.robot
|
||||
except model.InvalidRobotException as ire:
|
||||
logger.warning('Failed to validate credentials for robot %s: %s', auth_username, ire)
|
||||
return ValidateResult(AuthKind.credentials, error_message=str(ire)), CredentialKind.robot
|
||||
|
||||
# Otherwise, treat as a standard user.
|
||||
(authenticated, err) = authentication.verify_and_link_user(auth_username, auth_password_or_token,
|
||||
basic_auth=True)
|
||||
if authenticated:
|
||||
logger.debug('Successfully validated credentials for user %s', authenticated.username)
|
||||
return ValidateResult(AuthKind.credentials, user=authenticated), CredentialKind.user
|
||||
else:
|
||||
logger.warning('Failed to validate credentials for user %s: %s', auth_username, err)
|
||||
return ValidateResult(AuthKind.credentials, error_message=err), CredentialKind.user
|
96
auth/decorators.py
Normal file
96
auth/decorators.py
Normal file
|
@ -0,0 +1,96 @@
|
|||
import logging
|
||||
|
||||
from functools import wraps
|
||||
from flask import request, session
|
||||
|
||||
from app import metric_queue
|
||||
from auth.basic import validate_basic_auth
|
||||
from auth.oauth import validate_bearer_auth
|
||||
from auth.cookie import validate_session_cookie
|
||||
from auth.signedgrant import validate_signed_grant
|
||||
|
||||
from util.http import abort
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _auth_decorator(pass_result=False, handlers=None):
|
||||
""" Builds an auth decorator that runs the given handlers and, if any return successfully,
|
||||
sets up the auth context. The wrapped function will be invoked *regardless of success or
|
||||
failure of the auth handler(s)*
|
||||
"""
|
||||
def processor(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
auth_header = request.headers.get('authorization', '')
|
||||
result = None
|
||||
|
||||
for handler in handlers:
|
||||
result = handler(auth_header)
|
||||
# If the handler was missing the necessary information, skip it and try the next one.
|
||||
if result.missing:
|
||||
continue
|
||||
|
||||
# Check for a valid result.
|
||||
if result.auth_valid:
|
||||
logger.debug('Found valid auth result: %s', result.tuple())
|
||||
|
||||
# Set the various pieces of the auth context.
|
||||
result.apply_to_context()
|
||||
|
||||
# Log the metric.
|
||||
metric_queue.authentication_count.Inc(labelvalues=[result.kind, True])
|
||||
break
|
||||
|
||||
# Otherwise, report the error.
|
||||
if result.error_message is not None:
|
||||
# Log the failure.
|
||||
metric_queue.authentication_count.Inc(labelvalues=[result.kind, False])
|
||||
break
|
||||
|
||||
if pass_result:
|
||||
kwargs['auth_result'] = result
|
||||
|
||||
return func(*args, **kwargs)
|
||||
return wrapper
|
||||
return processor
|
||||
|
||||
|
||||
process_oauth = _auth_decorator(handlers=[validate_bearer_auth, validate_session_cookie])
|
||||
process_auth = _auth_decorator(handlers=[validate_signed_grant, validate_basic_auth])
|
||||
process_auth_or_cookie = _auth_decorator(handlers=[validate_basic_auth, validate_session_cookie])
|
||||
process_basic_auth = _auth_decorator(handlers=[validate_basic_auth], pass_result=True)
|
||||
process_basic_auth_no_pass = _auth_decorator(handlers=[validate_basic_auth])
|
||||
|
||||
|
||||
def require_session_login(func):
|
||||
""" Decorates a function and ensures that a valid session cookie exists or a 401 is raised. If
|
||||
a valid session cookie does exist, the authenticated user and identity are also set.
|
||||
"""
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
result = validate_session_cookie()
|
||||
if result.has_nonrobot_user:
|
||||
result.apply_to_context()
|
||||
metric_queue.authentication_count.Inc(labelvalues=[result.kind, True])
|
||||
return func(*args, **kwargs)
|
||||
elif not result.missing:
|
||||
metric_queue.authentication_count.Inc(labelvalues=[result.kind, False])
|
||||
|
||||
abort(401, message='Method requires login and no valid login could be loaded.')
|
||||
return wrapper
|
||||
|
||||
|
||||
def extract_namespace_repo_from_session(func):
|
||||
""" Extracts the namespace and repository name from the current session (which must exist)
|
||||
and passes them into the decorated function as the first and second arguments. If the
|
||||
session doesn't exist or does not contain these arugments, a 400 error is raised.
|
||||
"""
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
if 'namespace' not in session or 'repository' not in session:
|
||||
logger.error('Unable to load namespace or repository from session: %s', session)
|
||||
abort(400, message='Missing namespace in request')
|
||||
|
||||
return func(session['namespace'], session['repository'], *args, **kwargs)
|
||||
return wrapper
|
48
auth/oauth.py
Normal file
48
auth/oauth.py
Normal file
|
@ -0,0 +1,48 @@
|
|||
import logging
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from auth.scopes import scopes_from_scope_string
|
||||
from auth.validateresult import AuthKind, ValidateResult
|
||||
from data import model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def validate_bearer_auth(auth_header):
|
||||
""" Validates an OAuth token found inside a basic auth `Bearer` token, returning whether it
|
||||
points to a valid OAuth token.
|
||||
"""
|
||||
if not auth_header:
|
||||
return ValidateResult(AuthKind.oauth, missing=True)
|
||||
|
||||
normalized = [part.strip() for part in auth_header.split(' ') if part]
|
||||
if normalized[0].lower() != 'bearer' or len(normalized) != 2:
|
||||
logger.debug('Got invalid bearer token format: %s', auth_header)
|
||||
return ValidateResult(AuthKind.oauth, missing=True)
|
||||
|
||||
(_, oauth_token) = normalized
|
||||
return validate_oauth_token(oauth_token)
|
||||
|
||||
|
||||
def validate_oauth_token(token):
|
||||
""" Validates the specified OAuth token, returning whether it points to a valid OAuth token.
|
||||
"""
|
||||
validated = model.oauth.validate_access_token(token)
|
||||
if not validated:
|
||||
logger.warning('OAuth access token could not be validated: %s', token)
|
||||
return ValidateResult(AuthKind.oauth,
|
||||
error_message='OAuth access token could not be validated')
|
||||
|
||||
if validated.expires_at <= datetime.utcnow():
|
||||
logger.warning('OAuth access with an expired token: %s', token)
|
||||
return ValidateResult(AuthKind.oauth, error_message='OAuth access token has expired')
|
||||
|
||||
# Don't allow disabled users to login.
|
||||
if not validated.authorized_user.enabled:
|
||||
return ValidateResult(AuthKind.oauth,
|
||||
error_message='Granter of the oauth access token is disabled')
|
||||
|
||||
# We have a valid token
|
||||
scope_set = scopes_from_scope_string(validated.scope)
|
||||
logger.debug('Successfully validated oauth access token with scope: %s', scope_set)
|
||||
return ValidateResult(AuthKind.oauth, oauthtoken=validated)
|
364
auth/permissions.py
Normal file
364
auth/permissions.py
Normal file
|
@ -0,0 +1,364 @@
|
|||
import logging
|
||||
|
||||
from collections import namedtuple, defaultdict
|
||||
from functools import partial
|
||||
|
||||
from flask_principal import identity_loaded, Permission, Identity, identity_changed
|
||||
|
||||
|
||||
from app import app, superusers
|
||||
from auth import scopes
|
||||
from data import model
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_ResourceNeed = namedtuple('resource', ['type', 'namespace', 'name', 'role'])
|
||||
_RepositoryNeed = partial(_ResourceNeed, 'repository')
|
||||
_NamespaceWideNeed = namedtuple('namespacewide', ['type', 'namespace', 'role'])
|
||||
_OrganizationNeed = partial(_NamespaceWideNeed, 'organization')
|
||||
_OrganizationRepoNeed = partial(_NamespaceWideNeed, 'organizationrepo')
|
||||
_TeamTypeNeed = namedtuple('teamwideneed', ['type', 'orgname', 'teamname', 'role'])
|
||||
_TeamNeed = partial(_TeamTypeNeed, 'orgteam')
|
||||
_UserTypeNeed = namedtuple('userspecificneed', ['type', 'username', 'role'])
|
||||
_UserNeed = partial(_UserTypeNeed, 'user')
|
||||
_SuperUserNeed = partial(namedtuple('superuserneed', ['type']), 'superuser')
|
||||
|
||||
|
||||
REPO_ROLES = [None, 'read', 'write', 'admin']
|
||||
TEAM_ROLES = [None, 'member', 'creator', 'admin']
|
||||
USER_ROLES = [None, 'read', 'admin']
|
||||
|
||||
TEAM_ORGWIDE_REPO_ROLES = {
|
||||
'admin': 'admin',
|
||||
'creator': None,
|
||||
'member': None,
|
||||
}
|
||||
|
||||
SCOPE_MAX_REPO_ROLES = defaultdict(lambda: None)
|
||||
SCOPE_MAX_REPO_ROLES.update({
|
||||
scopes.READ_REPO: 'read',
|
||||
scopes.WRITE_REPO: 'write',
|
||||
scopes.ADMIN_REPO: 'admin',
|
||||
scopes.DIRECT_LOGIN: 'admin',
|
||||
})
|
||||
|
||||
SCOPE_MAX_TEAM_ROLES = defaultdict(lambda: None)
|
||||
SCOPE_MAX_TEAM_ROLES.update({
|
||||
scopes.CREATE_REPO: 'creator',
|
||||
scopes.DIRECT_LOGIN: 'admin',
|
||||
scopes.ORG_ADMIN: 'admin',
|
||||
})
|
||||
|
||||
SCOPE_MAX_USER_ROLES = defaultdict(lambda: None)
|
||||
SCOPE_MAX_USER_ROLES.update({
|
||||
scopes.READ_USER: 'read',
|
||||
scopes.DIRECT_LOGIN: 'admin',
|
||||
scopes.ADMIN_USER: 'admin',
|
||||
})
|
||||
|
||||
def repository_read_grant(namespace, repository):
|
||||
return _RepositoryNeed(namespace, repository, 'read')
|
||||
|
||||
|
||||
def repository_write_grant(namespace, repository):
|
||||
return _RepositoryNeed(namespace, repository, 'write')
|
||||
|
||||
|
||||
def repository_admin_grant(namespace, repository):
|
||||
return _RepositoryNeed(namespace, repository, 'admin')
|
||||
|
||||
|
||||
class QuayDeferredPermissionUser(Identity):
|
||||
def __init__(self, uuid, auth_type, auth_scopes, user=None):
|
||||
super(QuayDeferredPermissionUser, self).__init__(uuid, auth_type)
|
||||
|
||||
self._namespace_wide_loaded = set()
|
||||
self._repositories_loaded = set()
|
||||
self._personal_loaded = False
|
||||
|
||||
self._scope_set = auth_scopes
|
||||
self._user_object = user
|
||||
|
||||
@staticmethod
|
||||
def for_id(uuid, auth_scopes=None):
|
||||
auth_scopes = auth_scopes if auth_scopes is not None else {scopes.DIRECT_LOGIN}
|
||||
return QuayDeferredPermissionUser(uuid, 'user_uuid', auth_scopes)
|
||||
|
||||
@staticmethod
|
||||
def for_user(user, auth_scopes=None):
|
||||
auth_scopes = auth_scopes if auth_scopes is not None else {scopes.DIRECT_LOGIN}
|
||||
return QuayDeferredPermissionUser(user.uuid, 'user_uuid', auth_scopes, user=user)
|
||||
|
||||
def _translate_role_for_scopes(self, cardinality, max_roles, role):
|
||||
if self._scope_set is None:
|
||||
return role
|
||||
|
||||
max_for_scopes = max({cardinality.index(max_roles[scope]) for scope in self._scope_set})
|
||||
|
||||
if max_for_scopes < cardinality.index(role):
|
||||
logger.debug('Translated permission %s -> %s', role, cardinality[max_for_scopes])
|
||||
return cardinality[max_for_scopes]
|
||||
else:
|
||||
return role
|
||||
|
||||
def _team_role_for_scopes(self, role):
|
||||
return self._translate_role_for_scopes(TEAM_ROLES, SCOPE_MAX_TEAM_ROLES, role)
|
||||
|
||||
def _repo_role_for_scopes(self, role):
|
||||
return self._translate_role_for_scopes(REPO_ROLES, SCOPE_MAX_REPO_ROLES, role)
|
||||
|
||||
def _user_role_for_scopes(self, role):
|
||||
return self._translate_role_for_scopes(USER_ROLES, SCOPE_MAX_USER_ROLES, role)
|
||||
|
||||
def _populate_user_provides(self, user_object):
|
||||
""" Populates the provides that naturally apply to a user, such as being the admin of
|
||||
their own namespace.
|
||||
"""
|
||||
|
||||
# Add the user specific permissions, only for non-oauth permission
|
||||
user_grant = _UserNeed(user_object.username, self._user_role_for_scopes('admin'))
|
||||
logger.debug('User permission: {0}'.format(user_grant))
|
||||
self.provides.add(user_grant)
|
||||
|
||||
# Every user is the admin of their own 'org'
|
||||
user_namespace = _OrganizationNeed(user_object.username, self._team_role_for_scopes('admin'))
|
||||
logger.debug('User namespace permission: {0}'.format(user_namespace))
|
||||
self.provides.add(user_namespace)
|
||||
|
||||
# Org repo roles can differ for scopes
|
||||
user_repos = _OrganizationRepoNeed(user_object.username, self._repo_role_for_scopes('admin'))
|
||||
logger.debug('User namespace repo permission: {0}'.format(user_repos))
|
||||
self.provides.add(user_repos)
|
||||
|
||||
if ((scopes.SUPERUSER in self._scope_set or scopes.DIRECT_LOGIN in self._scope_set) and
|
||||
superusers.is_superuser(user_object.username)):
|
||||
logger.debug('Adding superuser to user: %s', user_object.username)
|
||||
self.provides.add(_SuperUserNeed())
|
||||
|
||||
def _populate_namespace_wide_provides(self, user_object, namespace_filter):
|
||||
""" Populates the namespace-wide provides for a particular user under a particular namespace.
|
||||
This method does *not* add any provides for specific repositories.
|
||||
"""
|
||||
|
||||
for team in model.permission.get_org_wide_permissions(user_object, org_filter=namespace_filter):
|
||||
team_org_grant = _OrganizationNeed(team.organization.username,
|
||||
self._team_role_for_scopes(team.role.name))
|
||||
logger.debug('Organization team added permission: {0}'.format(team_org_grant))
|
||||
self.provides.add(team_org_grant)
|
||||
|
||||
team_repo_role = TEAM_ORGWIDE_REPO_ROLES[team.role.name]
|
||||
org_repo_grant = _OrganizationRepoNeed(team.organization.username,
|
||||
self._repo_role_for_scopes(team_repo_role))
|
||||
logger.debug('Organization team added repo permission: {0}'.format(org_repo_grant))
|
||||
self.provides.add(org_repo_grant)
|
||||
|
||||
team_grant = _TeamNeed(team.organization.username, team.name,
|
||||
self._team_role_for_scopes(team.role.name))
|
||||
logger.debug('Team added permission: {0}'.format(team_grant))
|
||||
self.provides.add(team_grant)
|
||||
|
||||
def _populate_repository_provides(self, user_object, namespace_filter, repository_name):
|
||||
""" Populates the repository-specific provides for a particular user and repository. """
|
||||
|
||||
if namespace_filter and repository_name:
|
||||
permissions = model.permission.get_user_repository_permissions(user_object, namespace_filter,
|
||||
repository_name)
|
||||
else:
|
||||
permissions = model.permission.get_all_user_repository_permissions(user_object)
|
||||
|
||||
for perm in permissions:
|
||||
repo_grant = _RepositoryNeed(perm.repository.namespace_user.username, perm.repository.name,
|
||||
self._repo_role_for_scopes(perm.role.name))
|
||||
logger.debug('User added permission: {0}'.format(repo_grant))
|
||||
self.provides.add(repo_grant)
|
||||
|
||||
def can(self, permission):
|
||||
logger.debug('Loading user permissions after deferring for: %s', self.id)
|
||||
user_object = self._user_object or model.user.get_user_by_uuid(self.id)
|
||||
if user_object is None:
|
||||
return super(QuayDeferredPermissionUser, self).can(permission)
|
||||
|
||||
# Add the user-specific provides.
|
||||
if not self._personal_loaded:
|
||||
self._populate_user_provides(user_object)
|
||||
self._personal_loaded = True
|
||||
|
||||
# If we now have permission, no need to load any more permissions.
|
||||
if super(QuayDeferredPermissionUser, self).can(permission):
|
||||
return super(QuayDeferredPermissionUser, self).can(permission)
|
||||
|
||||
# Check for namespace and/or repository permissions.
|
||||
perm_namespace = permission.namespace
|
||||
perm_repo_name = permission.repo_name
|
||||
perm_repository = None
|
||||
|
||||
if perm_namespace and perm_repo_name:
|
||||
perm_repository = '%s/%s' % (perm_namespace, perm_repo_name)
|
||||
|
||||
if not perm_namespace and not perm_repo_name:
|
||||
# Nothing more to load, so just check directly.
|
||||
return super(QuayDeferredPermissionUser, self).can(permission)
|
||||
|
||||
# Lazy-load the repository-specific permissions.
|
||||
if perm_repository and perm_repository not in self._repositories_loaded:
|
||||
self._populate_repository_provides(user_object, perm_namespace, perm_repo_name)
|
||||
self._repositories_loaded.add(perm_repository)
|
||||
|
||||
# If we now have permission, no need to load any more permissions.
|
||||
if super(QuayDeferredPermissionUser, self).can(permission):
|
||||
return super(QuayDeferredPermissionUser, self).can(permission)
|
||||
|
||||
# Lazy-load the namespace-wide-only permissions.
|
||||
if perm_namespace and perm_namespace not in self._namespace_wide_loaded:
|
||||
self._populate_namespace_wide_provides(user_object, perm_namespace)
|
||||
self._namespace_wide_loaded.add(perm_namespace)
|
||||
|
||||
return super(QuayDeferredPermissionUser, self).can(permission)
|
||||
|
||||
|
||||
class QuayPermission(Permission):
|
||||
""" Base for all permissions in Quay. """
|
||||
namespace = None
|
||||
repo_name = None
|
||||
|
||||
|
||||
class ModifyRepositoryPermission(QuayPermission):
|
||||
def __init__(self, namespace, name):
|
||||
admin_need = _RepositoryNeed(namespace, name, 'admin')
|
||||
write_need = _RepositoryNeed(namespace, name, 'write')
|
||||
org_admin_need = _OrganizationRepoNeed(namespace, 'admin')
|
||||
org_write_need = _OrganizationRepoNeed(namespace, 'write')
|
||||
|
||||
self.namespace = namespace
|
||||
self.repo_name = name
|
||||
|
||||
super(ModifyRepositoryPermission, self).__init__(admin_need, write_need, org_admin_need,
|
||||
org_write_need)
|
||||
|
||||
|
||||
class ReadRepositoryPermission(QuayPermission):
|
||||
def __init__(self, namespace, name):
|
||||
admin_need = _RepositoryNeed(namespace, name, 'admin')
|
||||
write_need = _RepositoryNeed(namespace, name, 'write')
|
||||
read_need = _RepositoryNeed(namespace, name, 'read')
|
||||
org_admin_need = _OrganizationRepoNeed(namespace, 'admin')
|
||||
org_write_need = _OrganizationRepoNeed(namespace, 'write')
|
||||
org_read_need = _OrganizationRepoNeed(namespace, 'read')
|
||||
|
||||
self.namespace = namespace
|
||||
self.repo_name = name
|
||||
|
||||
super(ReadRepositoryPermission, self).__init__(admin_need, write_need, read_need,
|
||||
org_admin_need, org_read_need, org_write_need)
|
||||
|
||||
|
||||
class AdministerRepositoryPermission(QuayPermission):
|
||||
def __init__(self, namespace, name):
|
||||
admin_need = _RepositoryNeed(namespace, name, 'admin')
|
||||
org_admin_need = _OrganizationRepoNeed(namespace, 'admin')
|
||||
|
||||
self.namespace = namespace
|
||||
self.repo_name = name
|
||||
|
||||
super(AdministerRepositoryPermission, self).__init__(admin_need,
|
||||
org_admin_need)
|
||||
|
||||
|
||||
class CreateRepositoryPermission(QuayPermission):
|
||||
def __init__(self, namespace):
|
||||
admin_org = _OrganizationNeed(namespace, 'admin')
|
||||
create_repo_org = _OrganizationNeed(namespace, 'creator')
|
||||
|
||||
self.namespace = namespace
|
||||
|
||||
super(CreateRepositoryPermission, self).__init__(admin_org,
|
||||
create_repo_org)
|
||||
|
||||
class SuperUserPermission(QuayPermission):
|
||||
def __init__(self):
|
||||
need = _SuperUserNeed()
|
||||
super(SuperUserPermission, self).__init__(need)
|
||||
|
||||
|
||||
class UserAdminPermission(QuayPermission):
|
||||
def __init__(self, username):
|
||||
user_admin = _UserNeed(username, 'admin')
|
||||
super(UserAdminPermission, self).__init__(user_admin)
|
||||
|
||||
|
||||
class UserReadPermission(QuayPermission):
|
||||
def __init__(self, username):
|
||||
user_admin = _UserNeed(username, 'admin')
|
||||
user_read = _UserNeed(username, 'read')
|
||||
super(UserReadPermission, self).__init__(user_read, user_admin)
|
||||
|
||||
|
||||
class AdministerOrganizationPermission(QuayPermission):
|
||||
def __init__(self, org_name):
|
||||
admin_org = _OrganizationNeed(org_name, 'admin')
|
||||
|
||||
self.namespace = org_name
|
||||
|
||||
super(AdministerOrganizationPermission, self).__init__(admin_org)
|
||||
|
||||
|
||||
class OrganizationMemberPermission(QuayPermission):
|
||||
def __init__(self, org_name):
|
||||
admin_org = _OrganizationNeed(org_name, 'admin')
|
||||
repo_creator_org = _OrganizationNeed(org_name, 'creator')
|
||||
org_member = _OrganizationNeed(org_name, 'member')
|
||||
|
||||
self.namespace = org_name
|
||||
|
||||
super(OrganizationMemberPermission, self).__init__(admin_org, org_member,
|
||||
repo_creator_org)
|
||||
|
||||
|
||||
class ViewTeamPermission(QuayPermission):
|
||||
def __init__(self, org_name, team_name):
|
||||
team_admin = _TeamNeed(org_name, team_name, 'admin')
|
||||
team_creator = _TeamNeed(org_name, team_name, 'creator')
|
||||
team_member = _TeamNeed(org_name, team_name, 'member')
|
||||
admin_org = _OrganizationNeed(org_name, 'admin')
|
||||
|
||||
self.namespace = org_name
|
||||
|
||||
super(ViewTeamPermission, self).__init__(team_admin, team_creator,
|
||||
team_member, admin_org)
|
||||
|
||||
|
||||
class AlwaysFailPermission(QuayPermission):
|
||||
def can(self):
|
||||
return False
|
||||
|
||||
|
||||
@identity_loaded.connect_via(app)
|
||||
def on_identity_loaded(sender, identity):
|
||||
logger.debug('Identity loaded: %s' % identity)
|
||||
# We have verified an identity, load in all of the permissions
|
||||
|
||||
if isinstance(identity, QuayDeferredPermissionUser):
|
||||
logger.debug('Deferring permissions for user with uuid: %s', identity.id)
|
||||
|
||||
elif identity.auth_type == 'user_uuid':
|
||||
logger.debug('Switching username permission to deferred object with uuid: %s', identity.id)
|
||||
switch_to_deferred = QuayDeferredPermissionUser.for_id(identity.id)
|
||||
identity_changed.send(app, identity=switch_to_deferred)
|
||||
|
||||
elif identity.auth_type == 'token':
|
||||
logger.debug('Loading permissions for token: %s', identity.id)
|
||||
token_data = model.token.load_token_data(identity.id)
|
||||
|
||||
repo_grant = _RepositoryNeed(token_data.repository.namespace_user.username,
|
||||
token_data.repository.name,
|
||||
token_data.role.name)
|
||||
logger.debug('Delegate token added permission: %s', repo_grant)
|
||||
identity.provides.add(repo_grant)
|
||||
|
||||
elif identity.auth_type == 'signed_grant' or identity.auth_type == 'signed_jwt':
|
||||
logger.debug('Loaded %s identity for: %s', identity.auth_type, identity.id)
|
||||
|
||||
else:
|
||||
logger.error('Unknown identity auth type: %s', identity.auth_type)
|
164
auth/registry_jwt_auth.py
Normal file
164
auth/registry_jwt_auth.py
Normal file
|
@ -0,0 +1,164 @@
|
|||
import logging
|
||||
|
||||
from functools import wraps
|
||||
|
||||
from jsonschema import validate, ValidationError
|
||||
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_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)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
ACCESS_SCHEMA = {
|
||||
'type': 'array',
|
||||
'description': 'List of access granted to the subject',
|
||||
'items': {
|
||||
'type': 'object',
|
||||
'required': [
|
||||
'type',
|
||||
'name',
|
||||
'actions',
|
||||
],
|
||||
'properties': {
|
||||
'type': {
|
||||
'type': 'string',
|
||||
'description': 'We only allow repository permissions',
|
||||
'enum': [
|
||||
'repository',
|
||||
],
|
||||
},
|
||||
'name': {
|
||||
'type': 'string',
|
||||
'description': 'The name of the repository for which we are receiving access'
|
||||
},
|
||||
'actions': {
|
||||
'type': 'array',
|
||||
'description': 'List of specific verbs which can be performed against repository',
|
||||
'items': {
|
||||
'type': 'string',
|
||||
'enum': [
|
||||
'push',
|
||||
'pull',
|
||||
'*',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class InvalidJWTException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def get_auth_headers(repository=None, scopes=None):
|
||||
""" Returns a dictionary of headers for auth responses. """
|
||||
headers = {}
|
||||
realm_auth_path = url_for('v2.generate_registry_jwt')
|
||||
authenticate = 'Bearer realm="{0}{1}",service="{2}"'.format(get_app_url(),
|
||||
realm_auth_path,
|
||||
app.config['SERVER_HOSTNAME'])
|
||||
if repository:
|
||||
scopes_string = "repository:{0}".format(repository)
|
||||
if scopes:
|
||||
scopes_string += ':' + ','.join(scopes)
|
||||
|
||||
authenticate += ',scope="{0}"'.format(scopes_string)
|
||||
|
||||
headers['WWW-Authenticate'] = authenticate
|
||||
headers['Docker-Distribution-API-Version'] = 'registry/2.0'
|
||||
return headers
|
||||
|
||||
|
||||
def identity_from_bearer_token(bearer_header):
|
||||
""" Process a bearer header and return the loaded identity, or raise InvalidJWTException if an
|
||||
identity could not be loaded. Expects tokens and grants in the format of the Docker registry
|
||||
v2 auth spec: https://docs.docker.com/registry/spec/auth/token/
|
||||
"""
|
||||
logger.debug('Validating auth header: %s', bearer_header)
|
||||
|
||||
try:
|
||||
payload = decode_bearer_header(bearer_header, instance_keys, app.config,
|
||||
metric_queue=metric_queue)
|
||||
except InvalidBearerTokenException as bte:
|
||||
logger.exception('Invalid bearer token: %s', bte)
|
||||
raise InvalidJWTException(bte)
|
||||
|
||||
loaded_identity = Identity(payload['sub'], 'signed_jwt')
|
||||
|
||||
# Process the grants from the payload
|
||||
if 'access' in payload:
|
||||
try:
|
||||
validate(payload['access'], ACCESS_SCHEMA)
|
||||
except ValidationError:
|
||||
logger.exception('We should not be minting invalid credentials')
|
||||
raise InvalidJWTException('Token contained invalid or malformed access grants')
|
||||
|
||||
lib_namespace = app.config['LIBRARY_NAMESPACE']
|
||||
for grant in payload['access']:
|
||||
namespace, repo_name = parse_namespace_repository(grant['name'], lib_namespace)
|
||||
|
||||
if '*' in grant['actions']:
|
||||
loaded_identity.provides.add(repository_admin_grant(namespace, repo_name))
|
||||
elif 'push' in grant['actions']:
|
||||
loaded_identity.provides.add(repository_write_grant(namespace, repo_name))
|
||||
elif 'pull' in grant['actions']:
|
||||
loaded_identity.provides.add(repository_read_grant(namespace, repo_name))
|
||||
|
||||
default_context = {
|
||||
'kind': 'anonymous'
|
||||
}
|
||||
|
||||
if payload['sub'] != ANONYMOUS_SUB:
|
||||
default_context = {
|
||||
'kind': 'user',
|
||||
'user': payload['sub'],
|
||||
}
|
||||
|
||||
return loaded_identity, payload.get('context', default_context)
|
||||
|
||||
|
||||
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):
|
||||
logger.debug('Called with params: %s, %s', args, kwargs)
|
||||
auth = request.headers.get('authorization', '').strip()
|
||||
if auth:
|
||||
try:
|
||||
extracted_identity, context_dict = identity_from_bearer_token(auth)
|
||||
identity_changed.send(app, identity=extracted_identity)
|
||||
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:
|
||||
repository = kwargs['namespace_name'] + '/' + kwargs['repo_name']
|
||||
|
||||
abort(401, message=ije.message, headers=get_auth_headers(repository=repository,
|
||||
scopes=scopes))
|
||||
else:
|
||||
logger.debug('No auth header.')
|
||||
|
||||
return func(*args, **kwargs)
|
||||
return wrapper
|
||||
return inner
|
146
auth/scopes.py
Normal file
146
auth/scopes.py
Normal file
|
@ -0,0 +1,146 @@
|
|||
from collections import namedtuple
|
||||
import features
|
||||
import re
|
||||
|
||||
Scope = namedtuple('scope', ['scope', 'icon', 'dangerous', 'title', 'description'])
|
||||
|
||||
|
||||
READ_REPO = Scope(scope='repo:read',
|
||||
icon='fa-hdd-o',
|
||||
dangerous=False,
|
||||
title='View all visible repositories',
|
||||
description=('This application will be able to view and pull all repositories '
|
||||
'visible to the granting user or robot account'))
|
||||
|
||||
WRITE_REPO = Scope(scope='repo:write',
|
||||
icon='fa-hdd-o',
|
||||
dangerous=False,
|
||||
title='Read/Write to any accessible repositories',
|
||||
description=('This application will be able to view, push and pull to all '
|
||||
'repositories to which the granting user or robot account has '
|
||||
'write access'))
|
||||
|
||||
ADMIN_REPO = Scope(scope='repo:admin',
|
||||
icon='fa-hdd-o',
|
||||
dangerous=False,
|
||||
title='Administer Repositories',
|
||||
description=('This application will have administrator access to all '
|
||||
'repositories to which the granting user or robot account has '
|
||||
'access'))
|
||||
|
||||
CREATE_REPO = Scope(scope='repo:create',
|
||||
icon='fa-plus',
|
||||
dangerous=False,
|
||||
title='Create Repositories',
|
||||
description=('This application will be able to create repositories in to any '
|
||||
'namespaces that the granting user or robot account is allowed '
|
||||
'to create repositories'))
|
||||
|
||||
READ_USER = Scope(scope= 'user:read',
|
||||
icon='fa-user',
|
||||
dangerous=False,
|
||||
title='Read User Information',
|
||||
description=('This application will be able to read user information such as '
|
||||
'username and email address.'))
|
||||
|
||||
ADMIN_USER = Scope(scope= 'user:admin',
|
||||
icon='fa-gear',
|
||||
dangerous=True,
|
||||
title='Administer User',
|
||||
description=('This application will be able to administer your account '
|
||||
'including creating robots and granting them permissions '
|
||||
'to your repositories. You should have absolute trust in the '
|
||||
'requesting application before granting this permission.'))
|
||||
|
||||
ORG_ADMIN = Scope(scope='org:admin',
|
||||
icon='fa-gear',
|
||||
dangerous=True,
|
||||
title='Administer Organization',
|
||||
description=('This application will be able to administer your organizations '
|
||||
'including creating robots, creating teams, adjusting team '
|
||||
'membership, and changing billing settings. You should have '
|
||||
'absolute trust in the requesting application before granting this '
|
||||
'permission.'))
|
||||
|
||||
DIRECT_LOGIN = Scope(scope='direct_user_login',
|
||||
icon='fa-exclamation-triangle',
|
||||
dangerous=True,
|
||||
title='Full Access',
|
||||
description=('This scope should not be available to OAuth applications. '
|
||||
'Never approve a request for this scope!'))
|
||||
|
||||
SUPERUSER = Scope(scope='super:user',
|
||||
icon='fa-street-view',
|
||||
dangerous=True,
|
||||
title='Super User Access',
|
||||
description=('This application will be able to administer your installation '
|
||||
'including managing users, managing organizations and other '
|
||||
'features found in the superuser panel. You should have '
|
||||
'absolute trust in the requesting application before granting this '
|
||||
'permission.'))
|
||||
|
||||
ALL_SCOPES = {scope.scope: scope for scope in (READ_REPO, WRITE_REPO, ADMIN_REPO, CREATE_REPO,
|
||||
READ_USER, ORG_ADMIN, SUPERUSER, ADMIN_USER)}
|
||||
|
||||
IMPLIED_SCOPES = {
|
||||
ADMIN_REPO: {ADMIN_REPO, WRITE_REPO, READ_REPO},
|
||||
WRITE_REPO: {WRITE_REPO, READ_REPO},
|
||||
READ_REPO: {READ_REPO},
|
||||
CREATE_REPO: {CREATE_REPO},
|
||||
READ_USER: {READ_USER},
|
||||
ORG_ADMIN: {ORG_ADMIN},
|
||||
SUPERUSER: {SUPERUSER},
|
||||
ADMIN_USER: {ADMIN_USER},
|
||||
None: set(),
|
||||
}
|
||||
|
||||
|
||||
def app_scopes(app_config):
|
||||
scopes_from_config = dict(ALL_SCOPES)
|
||||
if not app_config.get('FEATURE_SUPER_USERS', False):
|
||||
del scopes_from_config[SUPERUSER.scope]
|
||||
return scopes_from_config
|
||||
|
||||
|
||||
def scopes_from_scope_string(scopes):
|
||||
if not scopes:
|
||||
scopes = ''
|
||||
|
||||
# Note: The scopes string should be space seperated according to the spec:
|
||||
# https://tools.ietf.org/html/rfc6749#section-3.3
|
||||
# However, we also support commas for backwards compatibility with existing callers to our code.
|
||||
scope_set = {ALL_SCOPES.get(scope, None) for scope in re.split(' |,', scopes)}
|
||||
return scope_set if not None in scope_set else set()
|
||||
|
||||
|
||||
def validate_scope_string(scopes):
|
||||
decoded = scopes_from_scope_string(scopes)
|
||||
return len(decoded) > 0
|
||||
|
||||
|
||||
def is_subset_string(full_string, expected_string):
|
||||
""" Returns true if the scopes found in expected_string are also found
|
||||
in full_string.
|
||||
"""
|
||||
full_scopes = scopes_from_scope_string(full_string)
|
||||
if not full_scopes:
|
||||
return False
|
||||
|
||||
full_implied_scopes = set.union(*[IMPLIED_SCOPES[scope] for scope in full_scopes])
|
||||
expected_scopes = scopes_from_scope_string(expected_string)
|
||||
return expected_scopes.issubset(full_implied_scopes)
|
||||
|
||||
|
||||
def get_scope_information(scopes_string):
|
||||
scopes = scopes_from_scope_string(scopes_string)
|
||||
scope_info = []
|
||||
for scope in scopes:
|
||||
scope_info.append({
|
||||
'title': scope.title,
|
||||
'scope': scope.scope,
|
||||
'description': scope.description,
|
||||
'icon': scope.icon,
|
||||
'dangerous': scope.dangerous,
|
||||
})
|
||||
|
||||
return scope_info
|
55
auth/signedgrant.py
Normal file
55
auth/signedgrant.py
Normal file
|
@ -0,0 +1,55 @@
|
|||
import logging
|
||||
|
||||
from flask.sessions import SecureCookieSessionInterface, BadSignature
|
||||
|
||||
from app import app
|
||||
from auth.validateresult import AuthKind, ValidateResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The prefix for all signatures of signed granted.
|
||||
SIGNATURE_PREFIX = 'sigv2='
|
||||
|
||||
def generate_signed_token(grants, user_context):
|
||||
""" Generates a signed session token with the given grants and user context. """
|
||||
ser = SecureCookieSessionInterface().get_signing_serializer(app)
|
||||
data_to_sign = {
|
||||
'grants': grants,
|
||||
'user_context': user_context,
|
||||
}
|
||||
|
||||
encrypted = ser.dumps(data_to_sign)
|
||||
return '{0}{1}'.format(SIGNATURE_PREFIX, encrypted)
|
||||
|
||||
|
||||
def validate_signed_grant(auth_header):
|
||||
""" Validates a signed grant as found inside an auth header and returns whether it points to
|
||||
a valid grant.
|
||||
"""
|
||||
if not auth_header:
|
||||
return ValidateResult(AuthKind.signed_grant, missing=True)
|
||||
|
||||
# Try to parse the token from the header.
|
||||
normalized = [part.strip() for part in auth_header.split(' ') if part]
|
||||
if normalized[0].lower() != 'token' or len(normalized) != 2:
|
||||
logger.debug('Not a token: %s', auth_header)
|
||||
return ValidateResult(AuthKind.signed_grant, missing=True)
|
||||
|
||||
# Check that it starts with the expected prefix.
|
||||
if not normalized[1].startswith(SIGNATURE_PREFIX):
|
||||
logger.debug('Not a signed grant token: %s', auth_header)
|
||||
return ValidateResult(AuthKind.signed_grant, missing=True)
|
||||
|
||||
# Decrypt the grant.
|
||||
encrypted = normalized[1][len(SIGNATURE_PREFIX):]
|
||||
ser = SecureCookieSessionInterface().get_signing_serializer(app)
|
||||
|
||||
try:
|
||||
token_data = ser.loads(encrypted, max_age=app.config['SIGNED_GRANT_EXPIRATION_SEC'])
|
||||
except BadSignature:
|
||||
logger.warning('Signed grant could not be validated: %s', encrypted)
|
||||
return ValidateResult(AuthKind.signed_grant,
|
||||
error_message='Signed grant could not be validated')
|
||||
|
||||
logger.debug('Successfully validated signed grant with data: %s', token_data)
|
||||
return ValidateResult(AuthKind.signed_grant, signed_data=token_data)
|
51
auth/test/test_auth_context_type.py
Normal file
51
auth/test/test_auth_context_type.py
Normal file
|
@ -0,0 +1,51 @@
|
|||
import pytest
|
||||
|
||||
from auth.auth_context_type import SignedAuthContext, ValidatedAuthContext, ContextEntityKind
|
||||
from data import model, database
|
||||
|
||||
from test.fixtures import *
|
||||
|
||||
def get_oauth_token(_):
|
||||
return database.OAuthAccessToken.get()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('kind, entity_reference, loader', [
|
||||
(ContextEntityKind.anonymous, None, None),
|
||||
(ContextEntityKind.appspecifictoken, '%s%s' % ('a' * 60, 'b' * 60),
|
||||
model.appspecifictoken.access_valid_token),
|
||||
(ContextEntityKind.oauthtoken, None, get_oauth_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
|
||||
assert validated.unique_key
|
||||
|
||||
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.unique_key == validated.unique_key
|
||||
|
||||
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()
|
98
auth/test/test_basic.py
Normal file
98
auth/test/test_basic.py
Normal file
|
@ -0,0 +1,98 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
import pytest
|
||||
|
||||
from base64 import b64encode
|
||||
|
||||
from auth.basic import validate_basic_auth
|
||||
from auth.credentials import (ACCESS_TOKEN_USERNAME, OAUTH_TOKEN_USERNAME,
|
||||
APP_SPECIFIC_TOKEN_USERNAME)
|
||||
from auth.validateresult import AuthKind, ValidateResult
|
||||
from data import model
|
||||
|
||||
from test.fixtures import *
|
||||
|
||||
|
||||
def _token(username, password):
|
||||
assert isinstance(username, basestring)
|
||||
assert isinstance(password, basestring)
|
||||
return 'basic ' + b64encode('%s:%s' % (username, password))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('token, expected_result', [
|
||||
('', ValidateResult(AuthKind.basic, missing=True)),
|
||||
('someinvalidtoken', ValidateResult(AuthKind.basic, missing=True)),
|
||||
('somefoobartoken', ValidateResult(AuthKind.basic, missing=True)),
|
||||
('basic ', ValidateResult(AuthKind.basic, missing=True)),
|
||||
('basic some token', ValidateResult(AuthKind.basic, missing=True)),
|
||||
('basic sometoken', ValidateResult(AuthKind.basic, missing=True)),
|
||||
(_token(APP_SPECIFIC_TOKEN_USERNAME, 'invalid'), ValidateResult(AuthKind.basic,
|
||||
error_message='Invalid token')),
|
||||
(_token(ACCESS_TOKEN_USERNAME, 'invalid'), ValidateResult(AuthKind.basic,
|
||||
error_message='Invalid access token')),
|
||||
(_token(OAUTH_TOKEN_USERNAME, 'invalid'),
|
||||
ValidateResult(AuthKind.basic, error_message='OAuth access token could not be validated')),
|
||||
(_token('devtable', 'invalid'), ValidateResult(AuthKind.basic,
|
||||
error_message='Invalid Username or Password')),
|
||||
(_token('devtable+somebot', 'invalid'), ValidateResult(
|
||||
AuthKind.basic, error_message='Could not find robot with username: devtable+somebot')),
|
||||
(_token('disabled', 'password'), ValidateResult(
|
||||
AuthKind.basic,
|
||||
error_message='This user has been disabled. Please contact your administrator.')),])
|
||||
def test_validate_basic_auth_token(token, expected_result, app):
|
||||
result = validate_basic_auth(token)
|
||||
assert result == expected_result
|
||||
|
||||
|
||||
def test_valid_user(app):
|
||||
token = _token('devtable', 'password')
|
||||
result = validate_basic_auth(token)
|
||||
assert result == ValidateResult(AuthKind.basic, user=model.user.get_user('devtable'))
|
||||
|
||||
|
||||
def test_valid_robot(app):
|
||||
robot, password = model.user.create_robot('somerobot', model.user.get_user('devtable'))
|
||||
token = _token(robot.username, password)
|
||||
result = validate_basic_auth(token)
|
||||
assert result == ValidateResult(AuthKind.basic, robot=robot)
|
||||
|
||||
|
||||
def test_valid_token(app):
|
||||
access_token = model.token.create_delegate_token('devtable', 'simple', 'sometoken')
|
||||
token = _token(ACCESS_TOKEN_USERNAME, access_token.get_code())
|
||||
result = validate_basic_auth(token)
|
||||
assert result == ValidateResult(AuthKind.basic, token=access_token)
|
||||
|
||||
|
||||
def test_valid_oauth(app):
|
||||
user = model.user.get_user('devtable')
|
||||
app = model.oauth.list_applications_for_org(model.user.get_user_or_org('buynlarge'))[0]
|
||||
oauth_token, code = model.oauth.create_access_token_for_testing(user, app.client_id, 'repo:read')
|
||||
token = _token(OAUTH_TOKEN_USERNAME, code)
|
||||
result = validate_basic_auth(token)
|
||||
assert result == ValidateResult(AuthKind.basic, oauthtoken=oauth_token)
|
||||
|
||||
|
||||
def test_valid_app_specific_token(app):
|
||||
user = model.user.get_user('devtable')
|
||||
app_specific_token = model.appspecifictoken.create_token(user, 'some token')
|
||||
full_token = model.appspecifictoken.get_full_token_string(app_specific_token)
|
||||
token = _token(APP_SPECIFIC_TOKEN_USERNAME, full_token)
|
||||
result = validate_basic_auth(token)
|
||||
assert result == ValidateResult(AuthKind.basic, appspecifictoken=app_specific_token)
|
||||
|
||||
|
||||
def test_invalid_unicode(app):
|
||||
token = '\xebOH'
|
||||
header = 'basic ' + b64encode(token)
|
||||
result = validate_basic_auth(header)
|
||||
assert result == ValidateResult(AuthKind.basic, missing=True)
|
||||
|
||||
|
||||
def test_invalid_unicode_2(app):
|
||||
token = '“4JPCOLIVMAY32Q3XGVPHC4CBF8SKII5FWNYMASOFDIVSXTC5I5NBU”'
|
||||
header = 'basic ' + b64encode('devtable+somerobot:%s' % token)
|
||||
result = validate_basic_auth(header)
|
||||
assert result == ValidateResult(
|
||||
AuthKind.basic,
|
||||
error_message='Could not find robot with username: devtable+somerobot and supplied password.')
|
66
auth/test/test_cookie.py
Normal file
66
auth/test/test_cookie.py
Normal file
|
@ -0,0 +1,66 @@
|
|||
import uuid
|
||||
|
||||
from flask_login import login_user
|
||||
|
||||
from app import LoginWrappedDBUser
|
||||
from data import model
|
||||
from auth.cookie import validate_session_cookie
|
||||
from test.fixtures import *
|
||||
|
||||
|
||||
def test_anonymous_cookie(app):
|
||||
assert validate_session_cookie().missing
|
||||
|
||||
|
||||
def test_invalidformatted_cookie(app):
|
||||
# "Login" with a non-UUID reference.
|
||||
someuser = model.user.get_user('devtable')
|
||||
login_user(LoginWrappedDBUser('somenonuuid', someuser))
|
||||
|
||||
# Ensure we get an invalid session cookie format error.
|
||||
result = validate_session_cookie()
|
||||
assert result.authed_user is None
|
||||
assert result.context.identity is None
|
||||
assert not result.has_nonrobot_user
|
||||
assert result.error_message == 'Invalid session cookie format'
|
||||
|
||||
|
||||
def test_disabled_user(app):
|
||||
# "Login" with a disabled user.
|
||||
someuser = model.user.get_user('disabled')
|
||||
login_user(LoginWrappedDBUser(someuser.uuid, someuser))
|
||||
|
||||
# Ensure we get an invalid session cookie format error.
|
||||
result = validate_session_cookie()
|
||||
assert result.authed_user is None
|
||||
assert result.context.identity is None
|
||||
assert not result.has_nonrobot_user
|
||||
assert result.error_message == 'User account is disabled'
|
||||
|
||||
|
||||
def test_valid_user(app):
|
||||
# Login with a valid user.
|
||||
someuser = model.user.get_user('devtable')
|
||||
login_user(LoginWrappedDBUser(someuser.uuid, someuser))
|
||||
|
||||
result = validate_session_cookie()
|
||||
assert result.authed_user == someuser
|
||||
assert result.context.identity is not None
|
||||
assert result.has_nonrobot_user
|
||||
assert result.error_message is None
|
||||
|
||||
|
||||
def test_valid_organization(app):
|
||||
# "Login" with a valid organization.
|
||||
someorg = model.user.get_namespace_user('buynlarge')
|
||||
someorg.uuid = str(uuid.uuid4())
|
||||
someorg.verified = True
|
||||
someorg.save()
|
||||
|
||||
login_user(LoginWrappedDBUser(someorg.uuid, someorg))
|
||||
|
||||
result = validate_session_cookie()
|
||||
assert result.authed_user is None
|
||||
assert result.context.identity is None
|
||||
assert not result.has_nonrobot_user
|
||||
assert result.error_message == 'Cannot login to organization'
|
147
auth/test/test_credentials.py
Normal file
147
auth/test/test_credentials.py
Normal file
|
@ -0,0 +1,147 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
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
|
||||
|
||||
from test.fixtures import *
|
||||
|
||||
def test_valid_user(app):
|
||||
result, kind = validate_credentials('devtable', 'password')
|
||||
assert kind == CredentialKind.user
|
||||
assert result == ValidateResult(AuthKind.credentials, user=model.user.get_user('devtable'))
|
||||
|
||||
def test_valid_robot(app):
|
||||
robot, password = model.user.create_robot('somerobot', model.user.get_user('devtable'))
|
||||
result, kind = validate_credentials(robot.username, password)
|
||||
assert kind == CredentialKind.robot
|
||||
assert result == ValidateResult(AuthKind.credentials, robot=robot)
|
||||
|
||||
def test_valid_robot_for_disabled_user(app):
|
||||
user = model.user.get_user('devtable')
|
||||
user.enabled = False
|
||||
user.save()
|
||||
|
||||
robot, password = model.user.create_robot('somerobot', user)
|
||||
result, kind = validate_credentials(robot.username, password)
|
||||
assert kind == CredentialKind.robot
|
||||
|
||||
err = 'This user has been disabled. Please contact your administrator.'
|
||||
assert result == ValidateResult(AuthKind.credentials, error_message=err)
|
||||
|
||||
def test_valid_token(app):
|
||||
access_token = model.token.create_delegate_token('devtable', 'simple', 'sometoken')
|
||||
result, kind = validate_credentials(ACCESS_TOKEN_USERNAME, access_token.get_code())
|
||||
assert kind == CredentialKind.token
|
||||
assert result == ValidateResult(AuthKind.credentials, token=access_token)
|
||||
|
||||
def test_valid_oauth(app):
|
||||
user = model.user.get_user('devtable')
|
||||
app = model.oauth.list_applications_for_org(model.user.get_user_or_org('buynlarge'))[0]
|
||||
oauth_token, code = model.oauth.create_access_token_for_testing(user, app.client_id, 'repo:read')
|
||||
result, kind = validate_credentials(OAUTH_TOKEN_USERNAME, code)
|
||||
assert kind == CredentialKind.oauth_token
|
||||
assert result == ValidateResult(AuthKind.oauth, oauthtoken=oauth_token)
|
||||
|
||||
def test_invalid_user(app):
|
||||
result, kind = validate_credentials('devtable', 'somepassword')
|
||||
assert kind == CredentialKind.user
|
||||
assert result == ValidateResult(AuthKind.credentials,
|
||||
error_message='Invalid Username or Password')
|
||||
|
||||
def test_valid_app_specific_token(app):
|
||||
user = model.user.get_user('devtable')
|
||||
app_specific_token = model.appspecifictoken.create_token(user, 'some token')
|
||||
full_token = model.appspecifictoken.get_full_token_string(app_specific_token)
|
||||
result, kind = validate_credentials(APP_SPECIFIC_TOKEN_USERNAME, full_token)
|
||||
assert kind == CredentialKind.app_specific_token
|
||||
assert result == ValidateResult(AuthKind.credentials, appspecifictoken=app_specific_token)
|
||||
|
||||
def test_valid_app_specific_token_for_disabled_user(app):
|
||||
user = model.user.get_user('devtable')
|
||||
user.enabled = False
|
||||
user.save()
|
||||
|
||||
app_specific_token = model.appspecifictoken.create_token(user, 'some token')
|
||||
full_token = model.appspecifictoken.get_full_token_string(app_specific_token)
|
||||
result, kind = validate_credentials(APP_SPECIFIC_TOKEN_USERNAME, full_token)
|
||||
assert kind == CredentialKind.app_specific_token
|
||||
|
||||
err = 'This user has been disabled. Please contact your administrator.'
|
||||
assert result == ValidateResult(AuthKind.credentials, error_message=err)
|
||||
|
||||
def test_invalid_app_specific_token(app):
|
||||
result, kind = validate_credentials(APP_SPECIFIC_TOKEN_USERNAME, 'somecode')
|
||||
assert kind == CredentialKind.app_specific_token
|
||||
assert result == ValidateResult(AuthKind.credentials, error_message='Invalid token')
|
||||
|
||||
def test_invalid_app_specific_token_code(app):
|
||||
user = model.user.get_user('devtable')
|
||||
app_specific_token = model.appspecifictoken.create_token(user, 'some token')
|
||||
full_token = app_specific_token.token_name + 'something'
|
||||
result, kind = validate_credentials(APP_SPECIFIC_TOKEN_USERNAME, full_token)
|
||||
assert kind == CredentialKind.app_specific_token
|
||||
assert result == ValidateResult(AuthKind.credentials, error_message='Invalid token')
|
||||
|
||||
def test_unicode(app):
|
||||
result, kind = validate_credentials('someusername', 'some₪code')
|
||||
assert kind == CredentialKind.user
|
||||
assert not result.auth_valid
|
||||
assert result == ValidateResult(AuthKind.credentials,
|
||||
error_message='Invalid Username or Password')
|
||||
|
||||
def test_unicode_robot(app):
|
||||
robot, _ = model.user.create_robot('somerobot', model.user.get_user('devtable'))
|
||||
result, kind = validate_credentials(robot.username, 'some₪code')
|
||||
|
||||
assert kind == CredentialKind.robot
|
||||
assert not result.auth_valid
|
||||
|
||||
msg = 'Could not find robot with username: devtable+somerobot and supplied password.'
|
||||
assert result == ValidateResult(AuthKind.credentials, error_message=msg)
|
||||
|
||||
def test_invalid_user(app):
|
||||
result, kind = validate_credentials('someinvaliduser', 'password')
|
||||
assert kind == CredentialKind.user
|
||||
assert not result.authed_user
|
||||
assert not result.auth_valid
|
||||
|
||||
def test_invalid_user_password(app):
|
||||
result, kind = validate_credentials('devtable', 'somepassword')
|
||||
assert kind == CredentialKind.user
|
||||
assert not result.authed_user
|
||||
assert not result.auth_valid
|
||||
|
||||
def test_invalid_robot(app):
|
||||
result, kind = validate_credentials('devtable+doesnotexist', 'password')
|
||||
assert kind == CredentialKind.robot
|
||||
assert not result.authed_user
|
||||
assert not result.auth_valid
|
||||
|
||||
def test_invalid_robot_token(app):
|
||||
robot, _ = model.user.create_robot('somerobot', model.user.get_user('devtable'))
|
||||
result, kind = validate_credentials(robot.username, 'invalidpassword')
|
||||
assert kind == CredentialKind.robot
|
||||
assert not result.authed_user
|
||||
assert not result.auth_valid
|
||||
|
||||
def test_invalid_unicode_robot(app):
|
||||
token = '“4JPCOLIVMAY32Q3XGVPHC4CBF8SKII5FWNYMASOFDIVSXTC5I5NBU”'
|
||||
result, kind = validate_credentials('devtable+somerobot', token)
|
||||
assert kind == CredentialKind.robot
|
||||
assert not result.auth_valid
|
||||
msg = 'Could not find robot with username: devtable+somerobot'
|
||||
assert result == ValidateResult(AuthKind.credentials, error_message=msg)
|
||||
|
||||
def test_invalid_unicode_robot_2(app):
|
||||
user = model.user.get_user('devtable')
|
||||
robot, password = model.user.create_robot('somerobot', user)
|
||||
|
||||
token = '“4JPCOLIVMAY32Q3XGVPHC4CBF8SKII5FWNYMASOFDIVSXTC5I5NBU”'
|
||||
result, kind = validate_credentials('devtable+somerobot', token)
|
||||
assert kind == CredentialKind.robot
|
||||
assert not result.auth_valid
|
||||
msg = 'Could not find robot with username: devtable+somerobot and supplied password.'
|
||||
assert result == ValidateResult(AuthKind.credentials, error_message=msg)
|
105
auth/test/test_decorators.py
Normal file
105
auth/test/test_decorators.py
Normal file
|
@ -0,0 +1,105 @@
|
|||
import pytest
|
||||
|
||||
from flask import session
|
||||
from flask_login import login_user
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
from app import LoginWrappedDBUser
|
||||
from auth.auth_context import get_authenticated_user
|
||||
from auth.decorators import (
|
||||
extract_namespace_repo_from_session, require_session_login, process_auth_or_cookie)
|
||||
from data import model
|
||||
from test.fixtures import *
|
||||
|
||||
|
||||
def test_extract_namespace_repo_from_session_missing(app):
|
||||
def emptyfunc():
|
||||
pass
|
||||
|
||||
session.clear()
|
||||
with pytest.raises(HTTPException):
|
||||
extract_namespace_repo_from_session(emptyfunc)()
|
||||
|
||||
|
||||
def test_extract_namespace_repo_from_session_present(app):
|
||||
encountered = []
|
||||
|
||||
def somefunc(namespace, repository):
|
||||
encountered.append(namespace)
|
||||
encountered.append(repository)
|
||||
|
||||
# Add the namespace and repository to the session.
|
||||
session.clear()
|
||||
session['namespace'] = 'foo'
|
||||
session['repository'] = 'bar'
|
||||
|
||||
# Call the decorated method.
|
||||
extract_namespace_repo_from_session(somefunc)()
|
||||
|
||||
assert encountered[0] == 'foo'
|
||||
assert encountered[1] == 'bar'
|
||||
|
||||
|
||||
def test_require_session_login_missing(app):
|
||||
def emptyfunc():
|
||||
pass
|
||||
|
||||
with pytest.raises(HTTPException):
|
||||
require_session_login(emptyfunc)()
|
||||
|
||||
|
||||
def test_require_session_login_valid_user(app):
|
||||
def emptyfunc():
|
||||
pass
|
||||
|
||||
# Login as a valid user.
|
||||
someuser = model.user.get_user('devtable')
|
||||
login_user(LoginWrappedDBUser(someuser.uuid, someuser))
|
||||
|
||||
# Call the function.
|
||||
require_session_login(emptyfunc)()
|
||||
|
||||
# Ensure the authenticated user was updated.
|
||||
assert get_authenticated_user() == someuser
|
||||
|
||||
|
||||
def test_require_session_login_invalid_user(app):
|
||||
def emptyfunc():
|
||||
pass
|
||||
|
||||
# "Login" as a disabled user.
|
||||
someuser = model.user.get_user('disabled')
|
||||
login_user(LoginWrappedDBUser(someuser.uuid, someuser))
|
||||
|
||||
# Call the function.
|
||||
with pytest.raises(HTTPException):
|
||||
require_session_login(emptyfunc)()
|
||||
|
||||
# Ensure the authenticated user was not updated.
|
||||
assert get_authenticated_user() is None
|
||||
|
||||
|
||||
def test_process_auth_or_cookie_invalid_user(app):
|
||||
def emptyfunc():
|
||||
pass
|
||||
|
||||
# Call the function.
|
||||
process_auth_or_cookie(emptyfunc)()
|
||||
|
||||
# Ensure the authenticated user was not updated.
|
||||
assert get_authenticated_user() is None
|
||||
|
||||
|
||||
def test_process_auth_or_cookie_valid_user(app):
|
||||
def emptyfunc():
|
||||
pass
|
||||
|
||||
# Login as a valid user.
|
||||
someuser = model.user.get_user('devtable')
|
||||
login_user(LoginWrappedDBUser(someuser.uuid, someuser))
|
||||
|
||||
# Call the function.
|
||||
process_auth_or_cookie(emptyfunc)()
|
||||
|
||||
# Ensure the authenticated user was updated.
|
||||
assert get_authenticated_user() == someuser
|
55
auth/test/test_oauth.py
Normal file
55
auth/test/test_oauth.py
Normal file
|
@ -0,0 +1,55 @@
|
|||
import pytest
|
||||
|
||||
from auth.oauth import validate_bearer_auth, validate_oauth_token
|
||||
from auth.validateresult import AuthKind, ValidateResult
|
||||
from data import model
|
||||
from test.fixtures import *
|
||||
|
||||
|
||||
@pytest.mark.parametrize('header, expected_result', [
|
||||
('', ValidateResult(AuthKind.oauth, missing=True)),
|
||||
('somerandomtoken', ValidateResult(AuthKind.oauth, missing=True)),
|
||||
('bearer some random token', ValidateResult(AuthKind.oauth, missing=True)),
|
||||
('bearer invalidtoken',
|
||||
ValidateResult(AuthKind.oauth, error_message='OAuth access token could not be validated')),])
|
||||
def test_bearer(header, expected_result, app):
|
||||
assert validate_bearer_auth(header) == expected_result
|
||||
|
||||
|
||||
def test_valid_oauth(app):
|
||||
user = model.user.get_user('devtable')
|
||||
app = model.oauth.list_applications_for_org(model.user.get_user_or_org('buynlarge'))[0]
|
||||
token_string = '%s%s' % ('a' * 20, 'b' * 20)
|
||||
oauth_token, _ = model.oauth.create_access_token_for_testing(user, app.client_id, 'repo:read',
|
||||
access_token=token_string)
|
||||
result = validate_bearer_auth('bearer ' + token_string)
|
||||
assert result.context.oauthtoken == oauth_token
|
||||
assert result.authed_user == user
|
||||
assert result.auth_valid
|
||||
|
||||
|
||||
def test_disabled_user_oauth(app):
|
||||
user = model.user.get_user('disabled')
|
||||
token_string = '%s%s' % ('a' * 20, 'b' * 20)
|
||||
oauth_token, _ = model.oauth.create_access_token_for_testing(user, 'deadbeef', 'repo:admin',
|
||||
access_token=token_string)
|
||||
|
||||
result = validate_bearer_auth('bearer ' + token_string)
|
||||
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'
|
||||
|
||||
|
||||
def test_expired_token(app):
|
||||
user = model.user.get_user('devtable')
|
||||
token_string = '%s%s' % ('a' * 20, 'b' * 20)
|
||||
oauth_token, _ = model.oauth.create_access_token_for_testing(user, 'deadbeef', 'repo:admin',
|
||||
access_token=token_string,
|
||||
expires_in=-1000)
|
||||
|
||||
result = validate_bearer_auth('bearer ' + token_string)
|
||||
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'
|
37
auth/test/test_permissions.py
Normal file
37
auth/test/test_permissions.py
Normal file
|
@ -0,0 +1,37 @@
|
|||
import pytest
|
||||
|
||||
from auth import scopes
|
||||
from auth.permissions import SuperUserPermission, QuayDeferredPermissionUser
|
||||
from data import model
|
||||
|
||||
from test.fixtures import *
|
||||
|
||||
SUPER_USERNAME = 'devtable'
|
||||
UNSUPER_USERNAME = 'freshuser'
|
||||
|
||||
@pytest.fixture()
|
||||
def superuser(initialized_db):
|
||||
return model.user.get_user(SUPER_USERNAME)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def normie(initialized_db):
|
||||
return model.user.get_user(UNSUPER_USERNAME)
|
||||
|
||||
|
||||
def test_superuser_matrix(superuser, normie):
|
||||
test_cases = [
|
||||
(superuser, {scopes.SUPERUSER}, True),
|
||||
(superuser, {scopes.DIRECT_LOGIN}, True),
|
||||
(superuser, {scopes.READ_USER, scopes.SUPERUSER}, True),
|
||||
(superuser, {scopes.READ_USER}, False),
|
||||
(normie, {scopes.SUPERUSER}, False),
|
||||
(normie, {scopes.DIRECT_LOGIN}, False),
|
||||
(normie, {scopes.READ_USER, scopes.SUPERUSER}, False),
|
||||
(normie, {scopes.READ_USER}, False),
|
||||
]
|
||||
|
||||
for user_obj, scope_set, expected in test_cases:
|
||||
perm_user = QuayDeferredPermissionUser.for_user(user_obj, scope_set)
|
||||
has_su = perm_user.can(SuperUserPermission())
|
||||
assert has_su == expected
|
203
auth/test/test_registry_jwt.py
Normal file
203
auth/test/test_registry_jwt.py
Normal file
|
@ -0,0 +1,203 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
import time
|
||||
|
||||
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: remove this after service keys are decoupled
|
||||
from data.database import ServiceKeyApprovalType
|
||||
from initdb import setup_database_for_testing, finished_database_for_testing
|
||||
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', 'uuid': 'foobar', 'enabled': True})
|
||||
MAX_SIGNED_S = 3660
|
||||
TOKEN_VALIDITY_LIFETIME_S = 60 * 60 # 1 hour
|
||||
ANONYMOUS_SUB = '(anonymous)'
|
||||
SERVICE_NAME = 'quay'
|
||||
|
||||
# This import has to come below any references to "app".
|
||||
from test.fixtures import *
|
||||
|
||||
|
||||
def _access(typ='repository', name='somens/somerepo', actions=None):
|
||||
actions = [] if actions is None else actions
|
||||
return [{
|
||||
'type': typ,
|
||||
'name': name,
|
||||
'actions': actions,
|
||||
}]
|
||||
|
||||
|
||||
def _delete_field(token_data, field_name):
|
||||
token_data.pop(field_name)
|
||||
return token_data
|
||||
|
||||
|
||||
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(ValidatedAuthContext(user=user))
|
||||
return {
|
||||
'iss': iss or instance_keys.service_name,
|
||||
'aud': audience,
|
||||
'nbf': nbf if nbf is not None else int(time.time()),
|
||||
'iat': iat if iat is not None else int(time.time()),
|
||||
'exp': exp if exp is not None else int(time.time() + TOKEN_VALIDITY_LIFETIME_S),
|
||||
'sub': subject,
|
||||
'access': access,
|
||||
'context': context,
|
||||
}
|
||||
|
||||
|
||||
def _token(token_data, key_id=None, private_key=None, skip_header=False, alg=None):
|
||||
key_id = key_id or instance_keys.local_key_id
|
||||
private_key = private_key or instance_keys.local_private_key
|
||||
|
||||
if alg == "none":
|
||||
private_key = None
|
||||
|
||||
token_headers = {'kid': key_id}
|
||||
|
||||
if skip_header:
|
||||
token_headers = {}
|
||||
|
||||
token_data = jwt.encode(token_data, private_key, alg or 'RS256', headers=token_headers)
|
||||
return 'Bearer {0}'.format(token_data)
|
||||
|
||||
|
||||
def _parse_token(token):
|
||||
return identity_from_bearer_token(token)[0]
|
||||
|
||||
|
||||
def test_accepted_token(initialized_db):
|
||||
token = _token(_token_data())
|
||||
identity = _parse_token(token)
|
||||
assert identity.id == TEST_USER.username, 'should be %s, but was %s' % (TEST_USER.username,
|
||||
identity.id)
|
||||
assert len(identity.provides) == 0
|
||||
|
||||
anon_token = _token(_token_data(user=None))
|
||||
anon_identity = _parse_token(anon_token)
|
||||
assert anon_identity.id == ANONYMOUS_SUB, 'should be %s, but was %s' % (ANONYMOUS_SUB,
|
||||
anon_identity.id)
|
||||
assert len(identity.provides) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize('access', [
|
||||
(_access(actions=['pull', 'push'])),
|
||||
(_access(actions=['pull', '*'])),
|
||||
(_access(actions=['*', 'push'])),
|
||||
(_access(actions=['*'])),
|
||||
(_access(actions=['pull', '*', 'push'])),])
|
||||
def test_token_with_access(access, initialized_db):
|
||||
token = _token(_token_data(access=access))
|
||||
identity = _parse_token(token)
|
||||
assert identity.id == TEST_USER.username, 'should be %s, but was %s' % (TEST_USER.username,
|
||||
identity.id)
|
||||
assert len(identity.provides) == 1
|
||||
|
||||
role = list(identity.provides)[0][3]
|
||||
if "*" in access[0]['actions']:
|
||||
assert role == 'admin'
|
||||
elif "push" in access[0]['actions']:
|
||||
assert role == 'write'
|
||||
elif "pull" in access[0]['actions']:
|
||||
assert role == 'read'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('token', [
|
||||
pytest.param(_token(
|
||||
_token_data(access=[{
|
||||
'toipe': 'repository',
|
||||
'namesies': 'somens/somerepo',
|
||||
'akshuns': ['pull', 'push', '*']}])), id='bad access'),
|
||||
pytest.param(_token(_token_data(audience='someotherapp')), id='bad aud'),
|
||||
pytest.param(_token(_delete_field(_token_data(), 'aud')), id='no aud'),
|
||||
pytest.param(_token(_token_data(nbf=int(time.time()) + 600)), id='future nbf'),
|
||||
pytest.param(_token(_delete_field(_token_data(), 'nbf')), id='no nbf'),
|
||||
pytest.param(_token(_token_data(iat=int(time.time()) + 600)), id='future iat'),
|
||||
pytest.param(_token(_delete_field(_token_data(), 'iat')), id='no iat'),
|
||||
pytest.param(_token(_token_data(exp=int(time.time()) + MAX_SIGNED_S * 2)), id='exp too long'),
|
||||
pytest.param(_token(_token_data(exp=int(time.time()) - 60)), id='expired'),
|
||||
pytest.param(_token(_delete_field(_token_data(), 'exp')), id='no exp'),
|
||||
pytest.param(_token(_delete_field(_token_data(), 'sub')), id='no sub'),
|
||||
pytest.param(_token(_token_data(iss='badissuer')), id='bad iss'),
|
||||
pytest.param(_token(_delete_field(_token_data(), 'iss')), id='no iss'),
|
||||
pytest.param(_token(_token_data(), skip_header=True), id='no header'),
|
||||
pytest.param(_token(_token_data(), key_id='someunknownkey'), id='bad key'),
|
||||
pytest.param(_token(_token_data(), key_id='kid7'), id='bad key :: kid7'),
|
||||
pytest.param(_token(_token_data(), alg='none', private_key=None), id='none alg'),
|
||||
pytest.param('some random token', id='random token'),
|
||||
pytest.param('Bearer: sometokenhere', id='extra bearer'),
|
||||
pytest.param('\nBearer: dGVzdA', id='leading newline'),
|
||||
])
|
||||
def test_invalid_jwt(token, initialized_db):
|
||||
with pytest.raises(InvalidJWTException):
|
||||
_parse_token(token)
|
||||
|
||||
|
||||
def test_mixing_keys_e2e(initialized_db):
|
||||
token_data = _token_data()
|
||||
|
||||
# Create a new key for testing.
|
||||
p, key = model.service_keys.generate_service_key(instance_keys.service_name, None, kid='newkey',
|
||||
name='newkey', metadata={})
|
||||
private_key = p.exportKey('PEM')
|
||||
|
||||
# Test first with the new valid, but unapproved key.
|
||||
unapproved_key_token = _token(token_data, key_id='newkey', private_key=private_key)
|
||||
with pytest.raises(InvalidJWTException):
|
||||
_parse_token(unapproved_key_token)
|
||||
|
||||
# Approve the key and try again.
|
||||
admin_user = model.user.get_user('devtable')
|
||||
model.service_keys.approve_service_key(key.kid, ServiceKeyApprovalType.SUPERUSER, approver=admin_user)
|
||||
|
||||
valid_token = _token(token_data, key_id='newkey', private_key=private_key)
|
||||
|
||||
identity = _parse_token(valid_token)
|
||||
assert identity.id == TEST_USER.username
|
||||
assert len(identity.provides) == 0
|
||||
|
||||
# Try using a different private key with the existing key ID.
|
||||
bad_private_token = _token(token_data, key_id='newkey',
|
||||
private_key=instance_keys.local_private_key)
|
||||
with pytest.raises(InvalidJWTException):
|
||||
_parse_token(bad_private_token)
|
||||
|
||||
# Try using a different key ID with the existing private key.
|
||||
kid_mismatch_token = _token(token_data, key_id=instance_keys.local_key_id,
|
||||
private_key=private_key)
|
||||
with pytest.raises(InvalidJWTException):
|
||||
_parse_token(kid_mismatch_token)
|
||||
|
||||
# Delete the new key.
|
||||
key.delete_instance(recursive=True)
|
||||
|
||||
# Ensure it still works (via the cache.)
|
||||
deleted_key_token = _token(token_data, key_id='newkey', private_key=private_key)
|
||||
identity = _parse_token(deleted_key_token)
|
||||
assert identity.id == TEST_USER.username
|
||||
assert len(identity.provides) == 0
|
||||
|
||||
# Break the cache.
|
||||
instance_keys.clear_cache()
|
||||
|
||||
# Ensure the key no longer works.
|
||||
with pytest.raises(InvalidJWTException):
|
||||
_parse_token(deleted_key_token)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('token', [
|
||||
u'someunicodetoken✡',
|
||||
u'\xc9\xad\xbd',
|
||||
])
|
||||
def test_unicode_token(token):
|
||||
with pytest.raises(InvalidJWTException):
|
||||
_parse_token(token)
|
50
auth/test/test_scopes.py
Normal file
50
auth/test/test_scopes.py
Normal file
|
@ -0,0 +1,50 @@
|
|||
import pytest
|
||||
|
||||
from auth.scopes import (
|
||||
scopes_from_scope_string, validate_scope_string, ALL_SCOPES, is_subset_string)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'scopes_string, expected',
|
||||
[
|
||||
# Valid single scopes.
|
||||
('repo:read', ['repo:read']),
|
||||
('repo:admin', ['repo:admin']),
|
||||
|
||||
# Invalid scopes.
|
||||
('not:valid', []),
|
||||
('repo:admins', []),
|
||||
|
||||
# Valid scope strings.
|
||||
('repo:read repo:admin', ['repo:read', 'repo:admin']),
|
||||
('repo:read,repo:admin', ['repo:read', 'repo:admin']),
|
||||
('repo:read,repo:admin repo:write', ['repo:read', 'repo:admin', 'repo:write']),
|
||||
|
||||
# Partially invalid scopes.
|
||||
('repo:read,not:valid', []),
|
||||
('repo:read repo:admins', []),
|
||||
|
||||
# Invalid scope strings.
|
||||
('repo:read|repo:admin', []),
|
||||
|
||||
# Mixture of delimiters.
|
||||
('repo:read, repo:admin', []),])
|
||||
def test_parsing(scopes_string, expected):
|
||||
expected_scope_set = {ALL_SCOPES[scope_name] for scope_name in expected}
|
||||
parsed_scope_set = scopes_from_scope_string(scopes_string)
|
||||
assert parsed_scope_set == expected_scope_set
|
||||
assert validate_scope_string(scopes_string) == bool(expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('superset, subset, result', [
|
||||
('repo:read', 'repo:read', True),
|
||||
('repo:read repo:admin', 'repo:read', True),
|
||||
('repo:read,repo:admin', 'repo:read', True),
|
||||
('repo:read,repo:admin', 'repo:admin', True),
|
||||
('repo:read,repo:admin', 'repo:admin repo:read', True),
|
||||
('', 'repo:read', False),
|
||||
('unknown:tag', 'repo:read', False),
|
||||
('repo:read unknown:tag', 'repo:read', False),
|
||||
('repo:read,unknown:tag', 'repo:read', False),])
|
||||
def test_subset_string(superset, subset, result):
|
||||
assert is_subset_string(superset, subset) == result
|
32
auth/test/test_signedgrant.py
Normal file
32
auth/test/test_signedgrant.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
import pytest
|
||||
|
||||
from auth.signedgrant import validate_signed_grant, generate_signed_token, SIGNATURE_PREFIX
|
||||
from auth.validateresult import AuthKind, ValidateResult
|
||||
|
||||
|
||||
@pytest.mark.parametrize('header, expected_result', [
|
||||
pytest.param('', ValidateResult(AuthKind.signed_grant, missing=True), id='Missing'),
|
||||
pytest.param('somerandomtoken', ValidateResult(AuthKind.signed_grant, missing=True),
|
||||
id='Invalid header'),
|
||||
pytest.param('token somerandomtoken', ValidateResult(AuthKind.signed_grant, missing=True),
|
||||
id='Random Token'),
|
||||
pytest.param('token ' + SIGNATURE_PREFIX + 'foo',
|
||||
ValidateResult(AuthKind.signed_grant,
|
||||
error_message='Signed grant could not be validated'),
|
||||
id='Invalid token'),
|
||||
])
|
||||
def test_token(header, expected_result):
|
||||
assert validate_signed_grant(header) == expected_result
|
||||
|
||||
|
||||
def test_valid_grant():
|
||||
header = 'token ' + generate_signed_token({'a': 'b'}, {'c': 'd'})
|
||||
expected = ValidateResult(AuthKind.signed_grant, signed_data={
|
||||
'grants': {
|
||||
'a': 'b',
|
||||
},
|
||||
'user_context': {
|
||||
'c': 'd'
|
||||
},
|
||||
})
|
||||
assert validate_signed_grant(header) == expected
|
63
auth/test/test_validateresult.py
Normal file
63
auth/test/test_validateresult.py
Normal file
|
@ -0,0 +1,63 @@
|
|||
import pytest
|
||||
|
||||
from auth.auth_context import get_authenticated_context
|
||||
from auth.validateresult import AuthKind, ValidateResult
|
||||
from data import model
|
||||
from data.database import AppSpecificAuthToken
|
||||
from test.fixtures import *
|
||||
|
||||
def get_user():
|
||||
return model.user.get_user('devtable')
|
||||
|
||||
def get_app_specific_token():
|
||||
return AppSpecificAuthToken.get()
|
||||
|
||||
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_app_specific_token, 'appspecifictoken'),
|
||||
])
|
||||
def test_apply_context(get_entity, entity_kind, app):
|
||||
assert get_authenticated_context() is None
|
||||
|
||||
entity = get_entity()
|
||||
args = {}
|
||||
args[entity_kind] = entity
|
||||
|
||||
result = ValidateResult(AuthKind.basic, **args)
|
||||
result.apply_to_context()
|
||||
|
||||
expected_user = entity if entity_kind == 'user' or entity_kind == 'robot' else None
|
||||
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
|
||||
|
||||
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
|
56
auth/validateresult.py
Normal file
56
auth/validateresult.py
Normal file
|
@ -0,0 +1,56 @@
|
|||
from enum import Enum
|
||||
from auth.auth_context_type import ValidatedAuthContext, ContextEntityKind
|
||||
|
||||
|
||||
class AuthKind(Enum):
|
||||
cookie = 'cookie'
|
||||
basic = 'basic'
|
||||
oauth = 'oauth'
|
||||
signed_grant = 'signed_grant'
|
||||
credentials = 'credentials'
|
||||
|
||||
|
||||
class ValidateResult(object):
|
||||
""" A result of validating auth in one form or another. """
|
||||
def __init__(self, kind, missing=False, user=None, token=None, oauthtoken=None,
|
||||
robot=None, appspecifictoken=None, signed_data=None, error_message=None):
|
||||
self.kind = kind
|
||||
self.missing = missing
|
||||
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.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. """
|
||||
self.context.apply_to_request_context()
|
||||
|
||||
def with_kind(self, kind):
|
||||
""" Returns a copy of this result, but with the kind replaced. """
|
||||
result = ValidateResult(kind, missing=self.missing, error_message=self.error_message)
|
||||
result.context = self.context
|
||||
return result
|
||||
|
||||
def __repr__(self):
|
||||
return 'ValidateResult: %s (missing: %s, error: %s)' % (self.kind, self.missing,
|
||||
self.error_message)
|
||||
|
||||
@property
|
||||
def authed_user(self):
|
||||
""" Returns the authenticated user, whether directly, or via an OAuth token. """
|
||||
return self.context.authed_user
|
||||
|
||||
@property
|
||||
def has_nonrobot_user(self):
|
||||
""" Returns whether a user (not a robot) was authenticated successfully. """
|
||||
return self.context.has_nonrobot_user
|
||||
|
||||
@property
|
||||
def auth_valid(self):
|
||||
""" Returns whether authentication successfully occurred. """
|
||||
return self.context.entity_kind != ContextEntityKind.anonymous
|
Reference in a new issue