parent
35437c9f55
commit
4a4eee5e05
10 changed files with 199 additions and 35 deletions
10
auth/auth.py
10
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)
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
113
auth/jwt_auth.py
113
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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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])
|
||||
|
|
|
@ -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')
|
||||
|
||||
|
|
|
@ -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'])
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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(
|
||||
|
|
Reference in a new issue