import logging import re from datetime import datetime, timedelta from functools import wraps from flask import request from flask.ext.principal import identity_changed, Identity from cryptography.x509 import load_pem_x509_certificate 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 .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 logger = logging.getLogger(__name__) TOKEN_REGEX = re.compile(r'Bearer (([a-zA-Z0-9+/]+\.)+[a-zA-Z0-9+-_/]+)') class InvalidJWTException(Exception): pass 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 v2 auth spec: https://docs.docker.com/registry/spec/auth/token/ """ logger.debug('Validating auth header: %s', bearer_token) # Extract the jwt token from the header match = TOKEN_REGEX.match(bearer_token) if match is None or match.end() != len(bearer_token): raise InvalidJWTException('Invalid bearer token format') encoded = match.group(1) logger.debug('encoded JWT: %s', encoded) # Load the JWT returned. try: payload = strictjwt.decode(encoded, public_key, algorithms=['RS256'], audience='quay', issuer='token-issuer') except strictjwt.InvalidTokenError: raise InvalidJWTException('Invalid token') if not 'sub' in payload: raise InvalidJWTException('Missing sub field in JWT') # Verify that the expiration is no more than 300 seconds in the future. if datetime.fromtimestamp(payload['exp']) > datetime.utcnow() + timedelta(seconds=max_signed_s): raise InvalidJWTException('Token was signed for more than %s seconds' % max_signed_s) username = payload['sub'] loaded_identity = Identity(username, 'signed_jwt') # Process the grants from the payload if 'access' in payload: for grant in payload['access']: if grant['type'] != 'repository': continue namespace, repo_name = parse_namespace_repository(grant['name']) if '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)) return loaded_identity @lru_cache(maxsize=1) def load_public_key(certificate_file_path): with open(certificate_file_path) as cert_file: cert_obj = load_pem_x509_certificate(cert_file.read(), default_backend()) return cert_obj.public_key() def process_jwt_auth(func): @wraps(func) def wrapper(*args, **kwargs): logger.debug('Called with params: %s, %s', args, kwargs) auth = request.headers.get('authorization', '').strip() if auth: max_signature_seconds = app.config.get('JWT_AUTH_MAX_FRESH_S', 300) certificate_file_path = app.config['JWT_AUTH_CERTIFICATE_PATH'] public_key = load_public_key(certificate_file_path) try: extracted_identity = identity_from_bearer_token(auth, max_signature_seconds, public_key) identity_changed.send(app, identity=extracted_identity) set_grant_user_context(extracted_identity.id) logger.debug('Identity changed to %s', extracted_identity.id) except InvalidJWTException as ije: abort(401, message=ije.message) else: logger.debug('No auth header.') return func(*args, **kwargs) return wrapper