Make our JWT subjects better and log using the info

Fixes #1039
This commit is contained in:
Joseph Schorr 2015-12-09 16:10:39 -05:00
parent 35437c9f55
commit 4a4eee5e05
10 changed files with 199 additions and 35 deletions

View file

@ -14,7 +14,7 @@ import scopes
from data import model from data import model
from app import app, authentication from app import app, authentication
from permissions import QuayDeferredPermissionUser 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) set_validated_oauth_token)
from util.http import abort 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) logger.debug('Successfully validated signed grant with data: %s', token_data)
loaded_identity = Identity(None, 'signed_grant') 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']) loaded_identity.provides.update(token_data['grants'])
identity_changed.send(app, identity=loaded_identity) identity_changed.send(app, identity=loaded_identity)

View file

@ -36,13 +36,13 @@ def set_authenticated_user(user_or_robot):
ctx.authenticated_user = user_or_robot ctx.authenticated_user = user_or_robot
def get_grant_user_context(): def get_grant_context():
return getattr(_request_ctx_stack.top, 'grant_user_context', None) 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 = _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): def set_authenticated_user_deferred(user_or_robot_db_uuid):

View file

@ -10,18 +10,20 @@ from cryptography.hazmat.backends import default_backend
from cachetools import lru_cache from cachetools import lru_cache
from app import app 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 .permissions import repository_read_grant, repository_write_grant
from util.names import parse_namespace_repository from util.names import parse_namespace_repository
from util.http import abort from util.http import abort
from util.security import strictjwt from util.security import strictjwt
from data import model
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
TOKEN_REGEX = re.compile(r'^Bearer (([a-zA-Z0-9+/]+\.)+[a-zA-Z0-9+-_/]+)$') TOKEN_REGEX = re.compile(r'^Bearer (([a-zA-Z0-9+/]+\.)+[a-zA-Z0-9+-_/]+)$')
ANONYMOUS_SUB = '(anonymous)'
CONTEXT_KINDS = ['user', 'token', 'oauth']
ACCESS_SCHEMA = { ACCESS_SCHEMA = {
'type': 'array', 'type': 'array',
@ -65,6 +67,91 @@ class InvalidJWTException(Exception):
pass 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): 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 """ 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 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: if not 'sub' in payload:
raise InvalidJWTException('Missing sub field in JWT') raise InvalidJWTException('Missing sub field in JWT')
username = payload['sub'] loaded_identity = Identity(payload['sub'], 'signed_jwt')
loaded_identity = Identity(username, 'signed_jwt')
# Process the grants from the payload # Process the grants from the payload
if 'access' in payload: if 'access' in payload:
try: try:
validate(payload['access'], ACCESS_SCHEMA) 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']: elif 'pull' in grant['actions']:
loaded_identity.provides.add(repository_read_grant(namespace, repo_name)) 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) @lru_cache(maxsize=1)
@ -135,9 +230,11 @@ def process_jwt_auth(func):
public_key = load_public_key(certificate_file_path) public_key = load_public_key(certificate_file_path)
try: 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) 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) logger.debug('Identity changed to %s', extracted_identity.id)
except InvalidJWTException as ije: except InvalidJWTException as ije:
abort(401, message=ije.message) abort(401, message=ije.message)

View file

@ -3,7 +3,7 @@
import features import features
from flask import abort from flask import abort
from auth.auth_context import (get_validated_oauth_token, get_authenticated_user, 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 from functools import wraps
@ -29,7 +29,7 @@ def check_anon_protection(func):
# Check for validated context. If none exists, fail with a 401. # Check for validated context. If none exists, fail with a 401.
if (get_authenticated_user() or get_validated_oauth_token() or get_validated_token() or if (get_authenticated_user() or get_validated_oauth_token() or get_validated_token() or
get_grant_user_context()): get_grant_context()):
return func(*args, **kwargs) return func(*args, **kwargs)
abort(401) abort(401)

View file

@ -4,8 +4,9 @@ import random
from app import analytics, app, userevents from app import analytics, app, userevents
from data import model from data import model
from flask import request from flask import request
from auth.jwt_auth import get_granted_entity
from auth.auth_context import (get_authenticated_user, get_validated_token, 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__) 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_oauth_token = get_validated_oauth_token()
authenticated_user = get_authenticated_user() authenticated_user = get_authenticated_user()
authenticated_token = get_validated_token() if not authenticated_user else None 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 not authenticated_user and not authenticated_token and not authenticated_oauth_token:
if granted_username is not None: entity = get_granted_entity()
authenticated_user = model.user.get_user(granted_username) 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) logger.debug('Logging the %s to Mixpanel and the log system', event_name)
if authenticated_oauth_token: if authenticated_oauth_token:

View file

@ -9,7 +9,8 @@ from time import time
from app import storage as store, image_replication_queue, app from app import storage as store, image_replication_queue, app
from auth.auth import process_auth, extract_namespace_repo_from_session 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 digest import checksums
from util.registry import changes from util.registry import changes
from util.http import abort, exact_abort 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) repo_image = model.image.get_repo_image_extended(namespace, repository, image_id)
if not repo_image: if not repo_image:
username = (get_authenticated_user() and get_authenticated_user().username or username = get_authenticated_user() and get_authenticated_user().username
get_grant_user_context()) if not username:
username = get_granted_username()
logger.debug('Image not found, creating image with initiating user context: %s', 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, {}, repo_image = model.image.find_create_or_link_image(image_id, repo, username, {},
store.preferred_locations[0]) store.preferred_locations[0])

View file

@ -10,7 +10,7 @@ from app import metric_queue
from endpoints.decorators import anon_protect, anon_allowed from endpoints.decorators import anon_protect, anon_allowed
from endpoints.v2.errors import V2RegistryException from endpoints.v2.errors import V2RegistryException
from auth.jwt_auth import process_jwt_auth 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, from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermission,
AdministerRepositoryPermission) AdministerRepositoryPermission)
from data import model from data import model
@ -80,7 +80,7 @@ def route_show_if(value):
def v2_support_enabled(): def v2_support_enabled():
response = make_response('true', 200) response = make_response('true', 200)
if get_grant_user_context() is None: if get_grant_context() is None:
response = make_response('true', 401) response = make_response('true', 401)
realm_auth_path = url_for('v2.generate_registry_jwt') realm_auth_path = url_for('v2.generate_registry_jwt')

View file

@ -9,7 +9,8 @@ from cachetools import lru_cache
from app import app from app import app
from data import model from data import model
from auth.auth import process_auth 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, from auth.permissions import (ModifyRepositoryPermission, ReadRepositoryPermission,
CreateRepositoryPermission) CreateRepositoryPermission)
from endpoints.v2 import v2_bp from endpoints.v2 import v2_bp
@ -24,8 +25,6 @@ TOKEN_VALIDITY_LIFETIME_S = 60 * 60 # 1 hour
SCOPE_REGEX = re.compile( SCOPE_REGEX = re.compile(
r'^repository:([\.a-zA-Z0-9_\-]+/[\.a-zA-Z0-9_\-]+):(((push|pull|\*),)*(push|pull|\*))$' r'^repository:([\.a-zA-Z0-9_\-]+/[\.a-zA-Z0-9_\-]+):(((push|pull|\*),)*(push|pull|\*))$'
) )
ANONYMOUS_SUB = '(anonymous)'
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
def load_certificate_bytes(certificate_file_path): def load_certificate_bytes(certificate_file_path):
@ -58,6 +57,10 @@ def generate_registry_jwt():
token = get_validated_token() token = get_validated_token()
logger.debug('Authenticated token: %s', token) logger.debug('Authenticated token: %s', token)
oauthtoken = get_validated_oauth_token()
logger.debug('Authenticated OAuth token: %s', oauthtoken)
access = [] access = []
if scope_param is not None: if scope_param is not None:
match = SCOPE_REGEX.match(scope_param) 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 # In this case, we are doing an auth flow, and it's not an anonymous pull
return abort(401) return abort(401)
context, subject = build_context_and_subject(user, token, oauthtoken)
token_data = { token_data = {
'iss': app.config['JWT_AUTH_TOKEN_ISSUER'], 'iss': app.config['JWT_AUTH_TOKEN_ISSUER'],
'aud': audience_param, 'aud': audience_param,
'nbf': int(time.time()), 'nbf': int(time.time()),
'iat': int(time.time()), 'iat': int(time.time()),
'exp': int(time.time() + TOKEN_VALIDITY_LIFETIME_S), 'exp': int(time.time() + TOKEN_VALIDITY_LIFETIME_S),
'sub': user.username if user else ANONYMOUS_SUB, 'sub': subject,
'access': access, 'access': access,
'context': context,
} }
certificate = load_certificate_bytes(app.config['JWT_AUTH_CERTIFICATE_PATH']) certificate = load_certificate_bytes(app.config['JWT_AUTH_CERTIFICATE_PATH'])

View file

@ -568,6 +568,54 @@ class RegistryTestsMixin(object):
self.assertEquals('public', logs[0]['performer']['name']) 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): def test_pull_publicrepo_anonymous(self):
# Add a new repository under the public user, so we have a real repository to pull. # Add a new repository under the public user, so we have a real repository to pull.
self.do_push('public', 'newrepo', 'public', 'password') self.do_push('public', 'newrepo', 'public', 'password')

View file

@ -6,9 +6,9 @@ from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric import rsa
from app import app from app import app
from endpoints.v2.v2auth import (TOKEN_VALIDITY_LIFETIME_S, load_certificate_bytes, from endpoints.v2.v2auth import TOKEN_VALIDITY_LIFETIME_S, load_certificate_bytes, load_private_key
load_private_key, ANONYMOUS_SUB) from auth.jwt_auth import (identity_from_bearer_token, load_public_key, InvalidJWTException,
from auth.jwt_auth import identity_from_bearer_token, load_public_key, InvalidJWTException build_context_and_subject, ANONYMOUS_SUB)
from util.morecollections import AttrDict 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, 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']): exp=None, nbf=None, iss=app.config['JWT_AUTH_TOKEN_ISSUER']):
_, subject = build_context_and_subject(user, None, None)
return { return {
'iss': iss, 'iss': iss,
'aud': audience, 'aud': audience,
'nbf': nbf if nbf is not None else int(time.time()), 'nbf': nbf if nbf is not None else int(time.time()),
'iat': iat if iat 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), '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, 'access': access,
} }
@ -50,7 +52,7 @@ class TestRegistryV2Auth(unittest.TestCase):
return 'Bearer {0}'.format(token_data) return 'Bearer {0}'.format(token_data)
def _parse_token(self, token): 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): def _generate_public_key(self):
key = rsa.generate_private_key( key = rsa.generate_private_key(