diff --git a/auth/auth.py b/auth/auth.py index 558b50f73..80e211607 100644 --- a/auth/auth.py +++ b/auth/auth.py @@ -14,7 +14,7 @@ import scopes from data import model from app import app, authentication from permissions import QuayDeferredPermissionUser -from auth_context import (set_authenticated_user, set_validated_token, set_grant_user_context, +from auth_context import (set_authenticated_user, set_validated_token, set_grant_context, set_validated_oauth_token) from util.http import abort @@ -173,7 +173,13 @@ def _process_signed_grant(auth): logger.debug('Successfully validated signed grant with data: %s', token_data) loaded_identity = Identity(None, 'signed_grant') - set_grant_user_context(token_data['user_context']) + + if token_data['user_context']: + set_grant_context({ + 'user': token_data['user_context'], + 'kind': 'user', + }) + loaded_identity.provides.update(token_data['grants']) identity_changed.send(app, identity=loaded_identity) diff --git a/auth/auth_context.py b/auth/auth_context.py index 58b2c7b2f..f4a1206aa 100644 --- a/auth/auth_context.py +++ b/auth/auth_context.py @@ -36,13 +36,13 @@ def set_authenticated_user(user_or_robot): ctx.authenticated_user = user_or_robot -def get_grant_user_context(): - return getattr(_request_ctx_stack.top, 'grant_user_context', None) +def get_grant_context(): + return getattr(_request_ctx_stack.top, 'grant_context', None) -def set_grant_user_context(username_or_robotname): +def set_grant_context(grant_context): ctx = _request_ctx_stack.top - ctx.grant_user_context = username_or_robotname + ctx.grant_context = grant_context def set_authenticated_user_deferred(user_or_robot_db_uuid): diff --git a/auth/jwt_auth.py b/auth/jwt_auth.py index e4d1b15a5..a253c9b72 100644 --- a/auth/jwt_auth.py +++ b/auth/jwt_auth.py @@ -10,18 +10,20 @@ from cryptography.hazmat.backends import default_backend from cachetools import lru_cache from app import app -from .auth_context import set_grant_user_context +from .auth_context import set_grant_context, get_grant_context from .permissions import repository_read_grant, repository_write_grant from util.names import parse_namespace_repository from util.http import abort from util.security import strictjwt +from data import model logger = logging.getLogger(__name__) TOKEN_REGEX = re.compile(r'^Bearer (([a-zA-Z0-9+/]+\.)+[a-zA-Z0-9+-_/]+)$') - +ANONYMOUS_SUB = '(anonymous)' +CONTEXT_KINDS = ['user', 'token', 'oauth'] ACCESS_SCHEMA = { 'type': 'array', @@ -65,6 +67,91 @@ class InvalidJWTException(Exception): pass +class GrantedEntity(object): + def __init__(self, user=None, token=None, oauth=None): + self.user = user + self.token = token + self.oauth = oauth + + +def get_granted_entity(): + """ Returns the entity granted in the current context, if any. Returns the GrantedEntity or None + if none. + """ + context = get_grant_context() + if not context: + return None + + kind = context.get('kind', 'anonymous') + + if not kind in CONTEXT_KINDS: + return None + + if kind == 'user': + user = model.user.get_user(context.get('user', '')) + if not user: + return None + + return GrantedEntity(user=user) + + if kind == 'token': + return GrantedEntity(token=context.get('token')) + + if kind == 'oauth': + user = model.user.get_user(context.get('user', '')) + if not user: + return None + + oauthtoken = model.oauth.lookup_access_token_for_user(user, context.get('oauth', '')) + if not oauthtoken: + return None + + return GrantedEntity(oauth=oauthtoken, user=user) + + return None + + +def get_granted_username(): + """ Returns the username inside the grant, if any. """ + granted = get_granted_entity() + if not granted or not granted.user: + return None + + return granted.user.username + + +def build_context_and_subject(user, token, oauthtoken): + """ Builds the custom context field for the JWT signed token and returns it, + along with the subject for the JWT signed token. """ + if oauthtoken: + context = { + 'kind': 'oauth', + 'user': user.username, + 'oauth': oauthtoken.uuid, + } + + return (context, user.username) + + if user: + context = { + 'kind': 'user', + 'user': user.username, + } + return (context, user.username) + + if token: + context = { + 'kind': 'token', + 'token': token, + } + return (context, None) + + context = { + 'kind': 'anonymous', + } + return (context, ANONYMOUS_SUB) + + def identity_from_bearer_token(bearer_token, max_signed_s, public_key): """ Process a bearer token 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 @@ -94,11 +181,9 @@ def identity_from_bearer_token(bearer_token, max_signed_s, public_key): if not 'sub' in payload: raise InvalidJWTException('Missing sub field in JWT') - username = payload['sub'] - loaded_identity = Identity(username, 'signed_jwt') + loaded_identity = Identity(payload['sub'], 'signed_jwt') # Process the grants from the payload - if 'access' in payload: try: validate(payload['access'], ACCESS_SCHEMA) @@ -114,7 +199,17 @@ def identity_from_bearer_token(bearer_token, max_signed_s, public_key): elif 'pull' in grant['actions']: loaded_identity.provides.add(repository_read_grant(namespace, repo_name)) - return loaded_identity + default_context = { + 'kind': 'anonymous' + } + + if payload['sub'] != ANONYMOUS_SUB: + default_context = { + 'kind': 'user', + 'user': payload['sub'], + } + + return loaded_identity, payload.get('context', default_context) @lru_cache(maxsize=1) @@ -135,9 +230,11 @@ def process_jwt_auth(func): public_key = load_public_key(certificate_file_path) try: - extracted_identity = identity_from_bearer_token(auth, max_signature_seconds, public_key) + extracted_identity, context = identity_from_bearer_token(auth, max_signature_seconds, + public_key) + identity_changed.send(app, identity=extracted_identity) - set_grant_user_context(extracted_identity.id) + set_grant_context(context) logger.debug('Identity changed to %s', extracted_identity.id) except InvalidJWTException as ije: abort(401, message=ije.message) diff --git a/endpoints/decorators.py b/endpoints/decorators.py index 421edd041..b032b624a 100644 --- a/endpoints/decorators.py +++ b/endpoints/decorators.py @@ -3,7 +3,7 @@ import features from flask import abort from auth.auth_context import (get_validated_oauth_token, get_authenticated_user, - get_validated_token, get_grant_user_context) + get_validated_token, get_grant_context) from functools import wraps @@ -29,7 +29,7 @@ def check_anon_protection(func): # Check for validated context. If none exists, fail with a 401. if (get_authenticated_user() or get_validated_oauth_token() or get_validated_token() or - get_grant_user_context()): + get_grant_context()): return func(*args, **kwargs) abort(401) diff --git a/endpoints/trackhelper.py b/endpoints/trackhelper.py index cf0fad65d..e0961628e 100644 --- a/endpoints/trackhelper.py +++ b/endpoints/trackhelper.py @@ -4,8 +4,9 @@ import random from app import analytics, app, userevents from data import model from flask import request +from auth.jwt_auth import get_granted_entity from auth.auth_context import (get_authenticated_user, get_validated_token, - get_validated_oauth_token, get_grant_user_context) + get_validated_oauth_token) logger = logging.getLogger(__name__) @@ -23,11 +24,13 @@ def track_and_log(event_name, repo, analytics_name=None, analytics_sample=1, **k authenticated_oauth_token = get_validated_oauth_token() authenticated_user = get_authenticated_user() authenticated_token = get_validated_token() if not authenticated_user else None - granted_username = get_grant_user_context() - # TODO: Fix this to support OAuth tokens as well. - if granted_username is not None: - authenticated_user = model.user.get_user(granted_username) + if not authenticated_user and not authenticated_token and not authenticated_oauth_token: + entity = get_granted_entity() + if entity: + authenticated_user = entity.user + authenticated_token = entity.token + authenticated_oauth_token = entity.oauth logger.debug('Logging the %s to Mixpanel and the log system', event_name) if authenticated_oauth_token: diff --git a/endpoints/v1/registry.py b/endpoints/v1/registry.py index d62cada5b..f2f64a9f8 100644 --- a/endpoints/v1/registry.py +++ b/endpoints/v1/registry.py @@ -9,7 +9,8 @@ from time import time from app import storage as store, image_replication_queue, app from auth.auth import process_auth, extract_namespace_repo_from_session -from auth.auth_context import get_authenticated_user, get_grant_user_context +from auth.auth_context import get_authenticated_user +from auth.jwt_auth import get_granted_username from digest import checksums from util.registry import changes from util.http import abort, exact_abort @@ -436,8 +437,10 @@ def put_image_json(namespace, repository, image_id): repo_image = model.image.get_repo_image_extended(namespace, repository, image_id) if not repo_image: - username = (get_authenticated_user() and get_authenticated_user().username or - get_grant_user_context()) + username = get_authenticated_user() and get_authenticated_user().username + if not username: + username = get_granted_username() + logger.debug('Image not found, creating image with initiating user context: %s', username) repo_image = model.image.find_create_or_link_image(image_id, repo, username, {}, store.preferred_locations[0]) diff --git a/endpoints/v2/__init__.py b/endpoints/v2/__init__.py index 846a304e7..f10e2fc19 100644 --- a/endpoints/v2/__init__.py +++ b/endpoints/v2/__init__.py @@ -10,7 +10,7 @@ from app import metric_queue from endpoints.decorators import anon_protect, anon_allowed from endpoints.v2.errors import V2RegistryException from auth.jwt_auth import process_jwt_auth -from auth.auth_context import get_grant_user_context +from auth.auth_context import get_grant_context from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermission, AdministerRepositoryPermission) from data import model @@ -80,7 +80,7 @@ def route_show_if(value): def v2_support_enabled(): response = make_response('true', 200) - if get_grant_user_context() is None: + if get_grant_context() is None: response = make_response('true', 401) realm_auth_path = url_for('v2.generate_registry_jwt') diff --git a/endpoints/v2/v2auth.py b/endpoints/v2/v2auth.py index e339aaed3..80eecf5a0 100644 --- a/endpoints/v2/v2auth.py +++ b/endpoints/v2/v2auth.py @@ -9,7 +9,8 @@ from cachetools import lru_cache from app import app from data import model from auth.auth import process_auth -from auth.auth_context import get_authenticated_user, get_validated_token +from auth.jwt_auth import build_context_and_subject +from auth.auth_context import get_authenticated_user, get_validated_token, get_validated_oauth_token from auth.permissions import (ModifyRepositoryPermission, ReadRepositoryPermission, CreateRepositoryPermission) from endpoints.v2 import v2_bp @@ -24,8 +25,6 @@ TOKEN_VALIDITY_LIFETIME_S = 60 * 60 # 1 hour SCOPE_REGEX = re.compile( r'^repository:([\.a-zA-Z0-9_\-]+/[\.a-zA-Z0-9_\-]+):(((push|pull|\*),)*(push|pull|\*))$' ) -ANONYMOUS_SUB = '(anonymous)' - @lru_cache(maxsize=1) def load_certificate_bytes(certificate_file_path): @@ -58,6 +57,10 @@ def generate_registry_jwt(): token = get_validated_token() logger.debug('Authenticated token: %s', token) + + oauthtoken = get_validated_oauth_token() + logger.debug('Authenticated OAuth token: %s', oauthtoken) + access = [] if scope_param is not None: match = SCOPE_REGEX.match(scope_param) @@ -123,14 +126,16 @@ def generate_registry_jwt(): # In this case, we are doing an auth flow, and it's not an anonymous pull return abort(401) + context, subject = build_context_and_subject(user, token, oauthtoken) token_data = { 'iss': app.config['JWT_AUTH_TOKEN_ISSUER'], 'aud': audience_param, 'nbf': int(time.time()), 'iat': int(time.time()), 'exp': int(time.time() + TOKEN_VALIDITY_LIFETIME_S), - 'sub': user.username if user else ANONYMOUS_SUB, + 'sub': subject, 'access': access, + 'context': context, } certificate = load_certificate_bytes(app.config['JWT_AUTH_CERTIFICATE_PATH']) diff --git a/test/registry_tests.py b/test/registry_tests.py index c0796f1c1..9c983d31a 100644 --- a/test/registry_tests.py +++ b/test/registry_tests.py @@ -568,6 +568,54 @@ class RegistryTestsMixin(object): self.assertEquals('public', logs[0]['performer']['name']) + def test_push_pull_logging_byrobot(self): + # Lookup the robot's password. + self.conduct_api_login('devtable', 'password') + resp = self.conduct('GET', '/api/v1/organization/buynlarge/robots/ownerbot') + robot_token = json.loads(resp.text)['token'] + + # Push a new repository. + self.do_push('buynlarge', 'newrepo', 'buynlarge+ownerbot', robot_token) + + # Retrieve the logs and ensure the push was added. + result = self.conduct('GET', '/api/v1/repository/buynlarge/newrepo/logs') + logs = result.json()['logs'] + + self.assertEquals(1, len(logs)) + self.assertEquals('push_repo', logs[0]['kind']) + self.assertEquals('buynlarge+ownerbot', logs[0]['performer']['name']) + + # Pull the repository. + self.do_pull('buynlarge', 'newrepo', 'buynlarge+ownerbot', robot_token) + + # Retrieve the logs and ensure the pull was added. + result = self.conduct('GET', '/api/v1/repository/buynlarge/newrepo/logs') + logs = result.json()['logs'] + + self.assertEquals(2, len(logs)) + self.assertEquals('pull_repo', logs[0]['kind']) + self.assertEquals('buynlarge+ownerbot', logs[0]['performer']['name']) + + + def test_push_pull_logging_byoauth(self): + # Push the repository. + self.do_push('devtable', 'newrepo', 'devtable', 'password') + + # Pull the repository. + self.do_pull('devtable', 'newrepo', '$oauthtoken', 'test') + + # Retrieve the logs and ensure the pull was added. + self.conduct_api_login('devtable', 'password') + result = self.conduct('GET', '/api/v1/repository/devtable/newrepo/logs') + logs = result.json()['logs'] + + self.assertEquals(2, len(logs)) + self.assertEquals('pull_repo', logs[0]['kind']) + + self.assertEquals('devtable', logs[0]['performer']['name']) + self.assertEquals(1, logs[0]['metadata']['oauth_token_id']) + + def test_pull_publicrepo_anonymous(self): # Add a new repository under the public user, so we have a real repository to pull. self.do_push('public', 'newrepo', 'public', 'password') diff --git a/test/test_registry_v2_auth.py b/test/test_registry_v2_auth.py index f449935f3..dcd4a6bbd 100644 --- a/test/test_registry_v2_auth.py +++ b/test/test_registry_v2_auth.py @@ -6,9 +6,9 @@ from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.asymmetric import rsa from app import app -from endpoints.v2.v2auth import (TOKEN_VALIDITY_LIFETIME_S, load_certificate_bytes, - load_private_key, ANONYMOUS_SUB) -from auth.jwt_auth import identity_from_bearer_token, load_public_key, InvalidJWTException +from endpoints.v2.v2auth import TOKEN_VALIDITY_LIFETIME_S, load_certificate_bytes, load_private_key +from auth.jwt_auth import (identity_from_bearer_token, load_public_key, InvalidJWTException, + build_context_and_subject, ANONYMOUS_SUB) from util.morecollections import AttrDict @@ -27,13 +27,15 @@ class TestRegistryV2Auth(unittest.TestCase): def _generate_token_data(self, access=[], audience=TEST_AUDIENCE, user=TEST_USER, iat=None, exp=None, nbf=None, iss=app.config['JWT_AUTH_TOKEN_ISSUER']): + + _, subject = build_context_and_subject(user, None, None) return { 'iss': iss, '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': user.username if user else ANONYMOUS_SUB, + 'sub': subject, 'access': access, } @@ -50,7 +52,7 @@ class TestRegistryV2Auth(unittest.TestCase): return 'Bearer {0}'.format(token_data) def _parse_token(self, token): - return identity_from_bearer_token(token, MAX_SIGNED_S, self.public_key) + return identity_from_bearer_token(token, MAX_SIGNED_S, self.public_key)[0] def _generate_public_key(self): key = rsa.generate_private_key(