diff --git a/auth/jwt_auth.py b/auth/jwt_auth.py index 5072bdffa..cd1a6ca31 100644 --- a/auth/jwt_auth.py +++ b/auth/jwt_auth.py @@ -8,11 +8,13 @@ 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 logger = logging.getLogger(__name__) @@ -21,67 +23,88 @@ 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 = jwt.decode(encoded, public_key, algorithms=['RS256'], audience='quay', + issuer='token-issuer') + except jwt.InvalidTokenError: + raise InvalidJWTException('Invalid token') + + if not 'sub' in payload: + raise InvalidJWTException('Missing sub field in JWT') + + if not 'exp' in payload: + raise InvalidJWTException('Missing exp 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: - logger.debug('Validating auth header: %s', 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) - # Extract the jwt token from the header - match = TOKEN_REGEX.match(auth) - if match is None or match.end() != len(auth): - logger.debug('Not a valid bearer token: %s', auth) - return - - encoded = match.group(1) - logger.debug('encoded JWT: %s', encoded) - - # Load the JWT returned. try: - with open('/Users/jake/Projects/registry-v2/ca/quay.host.crt') as cert_file: - cert_obj = load_pem_x509_certificate(cert_file.read(), default_backend()) - public_key = cert_obj.public_key() - payload = jwt.decode(encoded, public_key, algorithms=['RS256'], audience='quay', - issuer='token-issuer') - except jwt.InvalidTokenError: - logger.exception('Exception when decoding returned JWT') - return (None, 'Invalid username or password') + 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) - if not 'sub' in payload: - raise Exception('Missing subject field in JWT') - - if not 'exp' in payload: - raise Exception('Missing exp 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=300): - logger.debug('Payload expiration is outside of the 300 second window: %s', payload['exp']) - return (None, 'Invalid username or password') - - 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)) - - identity_changed.send(app, identity=loaded_identity) - set_grant_user_context(username) - - logger.debug('Identity changed to %s', username) else: logger.debug('No auth header.') return func(*args, **kwargs) - return wrapper \ No newline at end of file + return wrapper diff --git a/conf/selfsigned/jwt.crt b/conf/selfsigned/jwt.crt new file mode 100644 index 000000000..131bdc9c0 --- /dev/null +++ b/conf/selfsigned/jwt.crt @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFXDCCA0agAwIBAgIBAjALBgkqhkiG9w0BAQswLTEMMAoGA1UEBhMDVVNBMRAw +DgYDVQQKEwdldGNkLWNhMQswCQYDVQQLEwJDQTAeFw0xNTA3MTYxOTQzMTdaFw0y +NTA3MTYxOTQzMTlaMEYxDDAKBgNVBAYTA1VTQTEQMA4GA1UEChMHZXRjZC1jYTEQ +MA4GA1UECxMHand0YXV0aDESMBAGA1UEAxMJMTI3LjAuMC4xMIICIjANBgkqhkiG +9w0BAQEFAAOCAg8AMIICCgKCAgEAs5RxPVfO7iPZnFIP0DPiiMMMykDEG0OV6O1x +QycVReI2ELIPiWqfDFVcn6XXI/0kpvNeLGr2dDXaQFZYz+rNVDYBjM3djvibFhwa +30URmfHI9iZM703zdMZwc07+TIteIj1Q4MYhbPB4f6oERtLO29RffN9KH2FQvtzx +CF/GFb6vcHOeCeKZEGjxbQ2vfhMJh+UiO6woBooAJULBaM9hxErszqWqu0QKcV2h +NaW6fSf6aVUbFTu9hhYfkujDBR5EmwVFcKxUF+AHDrAshR/VdTHb0SJ3OtKz0vGv +NCc844J8nhUg7SeeO6ONeAq6cDRN65eJ7nJC1Nhhq2DpOgNxu+j0Dz7F+EEtNWpE +ezGjbRjmM4Ekhvsa/SUdzubInrnyHFYcbMZZIZzbgAJfruZHVKWWXjbxyG74xix+ ++KzBs9jkCHSNNWnXTx3dev4dp4QltZ048crA1lioim8/W5GzYjvkfNwx6OohC4yD +5UoblQsY5vDdJ+S8g4feTmJMoNHdS/4ar/sVojUDX3KOF3bCZ6w4Ufx09EBXeUlQ +9gzs63xAvFhGk8anFSQbRoQgoKoivHpzlANquhWvRZCDtW5P4RLaHcOLjhq6nwe6 +WW+vtDgEEKzdSj1We6grDPoT1kTagJ0gvpX+jcesu5d0e8MHt+qu0WTJwvCxcI+r +8zhXX/MCAwEAAaNyMHAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMB0G +A1UdDgQWBBQqTEeoqfZjPwzZYdkktdV+3Pl6SDAfBgNVHSMEGDAWgBRXeieifbQ2 +jgPj8wwrrixidWU9tzAPBgNVHREECDAGhwR/AAABMAsGCSqGSIb3DQEBCwOCAgEA +KIFrN5mDOvbBEWYZSnyrGkF4bsXCbmLtg3FJkgdn5vJ916/2WtgceJMQKu1KE90V +giaRaSm4HEb63VBrCNdvoU0ZSRO/xQI2SqbERdFa36w31W71D16qDchRsFz+dEt4 +7jm1gIdl/UYCMqFye7t+6H/w4J8p1M90RlSXw8wHyFEPOjEfBOM5uSpO9xsXW04+ +DpfJvsLmvhaaE+OUrPft+VTtf0Wc7vV8jfS21D9nB/CJVaoS71m3FEHD8BlTZIqB +qcU67UJc7qhUJ3HyKbpJgFQcvEQ8GL+PJnsCO7Y/zCCbYLwjV1GffvHMGQ2JAJbB +2qnUxPqVmP87X3YDMXPVubW+CtoRPz7BIYsX2/HejlYOtlT25+SrHwpXRT5lcgbt +a9dcHhUmNNpfTgZpbPrPfdzqw+ze+HcbJAECWgm8v10quGbP5NZCnySM7LIJ8p7C +dLOGGuZnUaruqA3FRYS3147bdhGF1gLwGuM+BwzzvoppMf5kZuBWq6j6Feg1I68z +n1qhlEJSMoS1qUEq/8oXYgSs2ttvMAhZ4CqKPZztp3oZLPzZgL/eKb4JEjhpgitJ +TrgLFwAytHGZIWke/lR+Ca9qo/uMebduLu6akqZ5yrxl/DuHcBV8KGq+rXJIvxxj +O9hZBNQ+WDPvQlSN2z/An17zZePLgxspjZXIkkgSg1Y= +-----END CERTIFICATE----- diff --git a/conf/selfsigned/jwt.key.insecure b/conf/selfsigned/jwt.key.insecure new file mode 100644 index 000000000..00111a443 --- /dev/null +++ b/conf/selfsigned/jwt.key.insecure @@ -0,0 +1,51 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIJKQIBAAKCAgEAs5RxPVfO7iPZnFIP0DPiiMMMykDEG0OV6O1xQycVReI2ELIP +iWqfDFVcn6XXI/0kpvNeLGr2dDXaQFZYz+rNVDYBjM3djvibFhwa30URmfHI9iZM +703zdMZwc07+TIteIj1Q4MYhbPB4f6oERtLO29RffN9KH2FQvtzxCF/GFb6vcHOe +CeKZEGjxbQ2vfhMJh+UiO6woBooAJULBaM9hxErszqWqu0QKcV2hNaW6fSf6aVUb +FTu9hhYfkujDBR5EmwVFcKxUF+AHDrAshR/VdTHb0SJ3OtKz0vGvNCc844J8nhUg +7SeeO6ONeAq6cDRN65eJ7nJC1Nhhq2DpOgNxu+j0Dz7F+EEtNWpEezGjbRjmM4Ek +hvsa/SUdzubInrnyHFYcbMZZIZzbgAJfruZHVKWWXjbxyG74xix++KzBs9jkCHSN +NWnXTx3dev4dp4QltZ048crA1lioim8/W5GzYjvkfNwx6OohC4yD5UoblQsY5vDd +J+S8g4feTmJMoNHdS/4ar/sVojUDX3KOF3bCZ6w4Ufx09EBXeUlQ9gzs63xAvFhG +k8anFSQbRoQgoKoivHpzlANquhWvRZCDtW5P4RLaHcOLjhq6nwe6WW+vtDgEEKzd +Sj1We6grDPoT1kTagJ0gvpX+jcesu5d0e8MHt+qu0WTJwvCxcI+r8zhXX/MCAwEA +AQKCAgEAhhD5ZYGLhDARgumk0pwZsF5lyw0FGxGe9lFl8GtaL10NXfOBM+b8rHmB +99IYxs5zMYyZLvH/4oxdzxBnp3m1JvxWtebvVJB3P89lpG/tDw/6JwI7B6Ebc3++ +bed4ZG7brRY3rkdcpvb0DuM/5Bv3wRhQ3WnZ7Yl6fbN24viVaqB8W6iFQP4BpcWj +D/ZaoPXXdLP0lbYV/6PBLhAjUnsYkzIYjsIRr1LBtRbghqueiVdyVHbsDDMYb+VO +VyAckFKjh1QtHkwZT+W5fxa5df1pH+BEKmLfvnOVOpOiaH4ur+8319EQTtz3/bBB +qm/f9mqmDY+JsxFsoXiVmht0oxH1MsHV7jSpwxVj0nN6uV61zlgTgj/kXIASbuRO +swFM1o6+KNuFuqI4w5+Nkw5o+PbtP5UMTVTpUSQBQumUbM+xPClRP/k7LZeK0ikv +36BQ2xaLIzECKXyYgK6b1rypTnJv6hAqJcNozUHnKPcworCNK1xB+n+pycrVzPwZ +32WNXdLSquTeXNmc4vHZxVrFFjGzeWmWESYt6huFWn6xb9IdfhrzpuH5LS7rTIhj +kvZCAiN4n+cuRwjBPaxxkSg/Lh8IyFOchwI6CcWWucGFMxJZpqtCS14B27LNrrJt +bCdO/AQr9h3hvDR5vrvLnxOnNusumIZ3tpvfWeObIdOhkiFoPykCggEBAOtEnCIz +RH2M7bo9IE2C4zE6T40fJvTRrSRju78dFW7mpfe9F2nr/2/FVjbeAw6FJaEFDQLW +OSc3Amr0ACLOpSIuoHHb6uT3XzNnXl6xdsJ1swi1gZJgyge6FUYjMNFjko98qI4O +aqYBZzoDBw+K7bpUXEMwYPZcU0A6P/9+98wkJLHp3DfqqfBH7PiMtAJY6+ZQ2mfs +UFGI6ygVONwPhHQ9kWwtGvBfb+4AgUD0lu9UR3Yij07cze1aVJcVXQJopBvFnEnG +qEsm2oDwnWquG4A7ASCUpHJk+A1K4p7q6opM3Y1Lv8OYzR7dHsAEH/NN0mSn1tyE +dFBrzSAdDr9mI8UCggEBAMNnkXy2EuLlDHzrOiEn0l4ULbtHZjT0FtjCczg/K9Nn +ousAr5I09eNuQDXLheNqOe5LXrpWqc5psf5FsCj5WkkzMbJm/7GPwQN8ufThBneY +4oAO/xrOvwDZP9ihzIo/+chQQMXXA8Dysn6lIOHCGrdvEYF8nIvf95gCbaXfPR8G +Jecsxg3Nc0Pi1bGN5X5q/AwlJDUrd7JjIuTWYxEuhczPcoiEskgjGHGO96EWIjLX +cGB4xav6K8X4BJyxN6Ewek/HT4TjMqd1bIH6020JNZ0Z1rVFtr9DUXf5xkI3gbjI +7X3uNu0yjw31rEfVA6vokfFUZ9TogNsxUw2s/WTX2FcCggEBAIXphJF26vonmDzU +hCl6YcToctVZsPvSySGYXwoWDNgWEsvjZotU2A0PntYWIllGcjenv1MpClkG16p2 +/gjR5G6DabHFQntXTmnc4Xs2uelPwzsmzPy7eONTCL7mUugsLATeKLbK/+tDizUa ++g7fvha749QemmJABObfAQR1iag5vmVCPqXZPSdWWUzUEbXwVT3AMcDLYqA2NduX +0Mh5UKQ1UyvmtJmzSOuIgAmv7qWFLDPS0g1KYzBBpTpl3436b8abAS2BFNPJ5r9T +tdY+CctASpD36m5uiD5QrJNWFW/o9oZxYlJ8C+0QYWtcLa94UVQXsJXOEsKfyZ8I +yxcolR0CggEACrKs4GsHdayclmo6cH5BoizwpAQwsE18wrlCnZ23xIc0ADG1awfD +PoRWt5tA5UZ3jXhK42DDQy2+NPGTx2p/auqGmgHRleMM6if19lYriHiyTdiEVu9i +vaUnPbD+BcOi5TifkzVGW1XuN8jKmBGMbOaDytcLqwzD/WqEnkQukHhBsvpcjXzm +Bp1wnZvrKJSq3+9YoCCVGQscafLi0Zn+cUwaNScuq4xgVjdBj2wqyyXIXT+/cr7r +jpcZiYqaRRTmXV/IFrppl4lyO1uEH8AVU1iKzLnYW3hQCYV/OTjYvUki13YnQ600 +78q3d+dNoCfHdbLtTFa+V0HIDkOeS9sVWQKCAQBoZIeAkKePec19TL5xvqe0MSDC +dZwW/rVPfIraMuETFXlH1dsyUcZu578T7Uvuc/ZAOf7cSedKtxuEtbd0eJ8YtQJ3 +LWuL+JX5TsU0qsPvhQIKpLkznhTinH8/TVi8yxJzsOd56Ta2068U+ad9oRiI14Ne +pSzqQavGp5s1anSD769xKNNHKZkYPHYJ/5Te7hhdpBwQ3kn8AiUuemJ5MNfJO+8e +LCQL/LjuwgKAis0PQbWAHs2d9HJxQLlR62j754ooTDe6FfSoH2zKgdzSTteqHXue +ga/+6pwc/LoLS1TAAv9ChJFIERClNi6Bq/OpcECiVN6eFav6r5UR+w3+mCQk +-----END RSA PRIVATE KEY----- diff --git a/config.py b/config.py index a08e48a97..e99f32aca 100644 --- a/config.py +++ b/config.py @@ -216,6 +216,11 @@ class DefaultConfig(object): # Signed registry grant token expiration in seconds SIGNED_GRANT_EXPIRATION_SEC = 60 * 60 * 24 # One day to complete a push/pull + # Registry v2 JWT Auth config + JWT_AUTH_MAX_FRESH_S = 60 * 5 # At most the JWT can be signed for 300s in the future + JWT_AUTH_CERTIFICATE_PATH = 'conf/selfsigned/jwt.crt' + JWT_AUTH_PRIVATE_KEY_PATH = 'conf/selfsigned/jwt.key.insecure' + # The URL endpoint to which we redirect OAuth when generating a token locally. LOCAL_OAUTH_HANDLER = '/oauth/localapp' diff --git a/data/users.py b/data/users.py index 7ff9c1b36..4b917162e 100644 --- a/data/users.py +++ b/data/users.py @@ -53,20 +53,22 @@ def _get_federated_user(username, email, federated_service, create_new_user): return (db_user, None) -class JWTAuthUsers(object): +class ExternalJWTAuthN(object): """ Delegates authentication to a REST endpoint that returns JWTs. """ PUBLIC_KEY_FILENAME = 'jwt-authn.cert' - def __init__(self, verify_url, issuer, override_config_dir, http_client, public_key_path=None): + def __init__(self, verify_url, issuer, override_config_dir, http_client, max_fresh_s, + public_key_path=None): self.verify_url = verify_url self.issuer = issuer self.client = http_client + self.max_fresh_s = max_fresh_s - default_key_path = os.path.join(override_config_dir, JWTAuthUsers.PUBLIC_KEY_FILENAME) + default_key_path = os.path.join(override_config_dir, ExternalJWTAuthN.PUBLIC_KEY_FILENAME) public_key_path = public_key_path or default_key_path if not os.path.exists(public_key_path): error_message = ('JWT Authentication public key file "%s" not found in directory %s' % - (JWTAuthUsers.PUBLIC_KEY_FILENAME, override_config_dir)) + (ExternalJWTAuthN.PUBLIC_KEY_FILENAME, override_config_dir)) raise Exception(error_message) @@ -102,10 +104,11 @@ class JWTAuthUsers(object): if not 'exp' in payload: raise Exception('Missing exp field in JWT') - # Verify that the expiration is no more than 300 seconds in the future. + # Verify that the expiration is no more than self.max_fresh_s seconds in the future. expiration = datetime.utcfromtimestamp(payload['exp']) - if expiration > datetime.utcnow() + timedelta(seconds=300): - logger.debug('Payload expiration is outside of the 300 second window: %s', payload['exp']) + if expiration > datetime.utcnow() + timedelta(seconds=self.max_fresh_s): + logger.debug('Payload expiration is outside of the %s second window: %s', self.max_fresh_s, + payload['exp']) return (None, 'Invalid username or password') # Parse out the username and email. @@ -330,7 +333,9 @@ class UserAuthentication(object): elif authentication_type == 'JWT': verify_url = app.config.get('JWT_VERIFY_ENDPOINT') issuer = app.config.get('JWT_AUTH_ISSUER') - users = JWTAuthUsers(verify_url, issuer, override_config_dir, app.config['HTTPCLIENT']) + max_fresh_s = app.config.get('JWT_AUTH_MAX_FRESH_S', 300) + users = ExternalJWTAuthN(verify_url, issuer, override_config_dir, max_fresh_s, + app.config['HTTPCLIENT']) else: raise RuntimeError('Unknown authentication type: %s' % authentication_type) diff --git a/endpoints/v2/__init__.py b/endpoints/v2/__init__.py index 53de97ef6..c88252b94 100644 --- a/endpoints/v2/__init__.py +++ b/endpoints/v2/__init__.py @@ -1,3 +1,6 @@ +# XXX This code is not yet ready to be run in production, and should remain disabled until such +# XXX time as this notice is removed. + import logging from flask import Blueprint, make_response @@ -25,7 +28,7 @@ def _require_repo_permission(permission_class, allow_public=False): permission = permission_class(namespace, repo_name) if (permission.can() or (allow_public and - model.repository_is_public(namespace, repo_name))): + model.repository.repository_is_public(namespace, repo_name))): return func(namespace, repo_name, *args, **kwargs) raise abort(401) return wrapped diff --git a/endpoints/v2/blob.py b/endpoints/v2/blob.py index edeef2467..1c5639ab0 100644 --- a/endpoints/v2/blob.py +++ b/endpoints/v2/blob.py @@ -1,3 +1,6 @@ +# XXX This code is not yet ready to be run in production, and should remain disabled until such +# XXX time as this notice is removed. + import logging from flask import make_response, url_for, request @@ -14,8 +17,11 @@ from util.http import abort logger = logging.getLogger(__name__) -@v2_bp.route('///blobs/', - methods=['HEAD']) +BASE_BLOB_ROUTE = '///blobs/' +BLOB_DIGEST_ROUTE = BASE_BLOB_ROUTE.format(digest_tools.DIGEST_PATTERN) + + +@v2_bp.route(BLOB_DIGEST_ROUTE, methods=['HEAD']) @process_jwt_auth @require_repo_read @anon_protect @@ -29,12 +35,12 @@ def check_blob_existence(namespace, repo_name, digest): abort(404) -@v2_bp.route('///blobs/', - methods=['GET']) +@v2_bp.route(BLOB_DIGEST_ROUTE, methods=['GET']) @process_jwt_auth @require_repo_read @anon_protect def download_blob(namespace, repo_name, digest): + # TODO Implement this return make_response('') diff --git a/endpoints/v2/manifest.py b/endpoints/v2/manifest.py index a9feb3c96..10868f3c9 100644 --- a/endpoints/v2/manifest.py +++ b/endpoints/v2/manifest.py @@ -1,3 +1,6 @@ +# XXX This code is not yet ready to be run in production, and should remain disabled until such +# XXX time as this notice is removed. + import logging import re import jwt.utils diff --git a/endpoints/v2/v2auth.py b/endpoints/v2/v2auth.py index 1c13899b1..7c05e10a0 100644 --- a/endpoints/v2/v2auth.py +++ b/endpoints/v2/v2auth.py @@ -1,10 +1,15 @@ +# XXX This code is not yet ready to be run in production, and should remain disabled until such +# XXX time as this notice is removed. + import logging import re import time import jwt from flask import request, jsonify, abort +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 @@ -23,10 +28,25 @@ SCOPE_REGEX = re.compile( ) +@lru_cache(maxsize=1) +def load_certificate_bytes(certificate_file_path): + with open(certificate_file_path) as cert_file: + return ''.join(cert_file.readlines()[1:-1]).rstrip('\n') + + +@lru_cache(maxsize=1) +def load_private_key(private_key_file_path): + with open(private_key_file_path) as private_key_file: + return private_key_file.read() + + @v2_bp.route('/auth') @process_auth @no_cache def generate_registry_jwt(): + """ This endpoint will generate a JWT conforming to the Docker registry v2 auth spec: + https://docs.docker.com/registry/spec/auth/token/ + """ audience_param = request.args.get('service') logger.debug('Request audience: %s', audience_param) @@ -81,14 +101,12 @@ def generate_registry_jwt(): 'access': access, } - with open('/Users/jake/Projects/registry-v2/ca/quay.host.crt') as cert_file: - certificate = ''.join(cert_file.readlines()[1:-1]).rstrip('\n') + certificate = load_certificate_bytes(app.config['JWT_AUTH_CERTIFICATE_PATH']) token_headers = { 'x5c': [certificate], } - with open('/Users/jake/Projects/registry-v2/ca/quay.host.key.insecure') as private_key_file: - private_key = private_key_file.read() + private_key = load_private_key(app.config['JWT_AUTH_PRIVATE_KEY_PATH']) - return jsonify({'token':jwt.encode(token_data, private_key, 'RS256', headers=token_headers)}) \ No newline at end of file + return jsonify({'token':jwt.encode(token_data, private_key, 'RS256', headers=token_headers)}) diff --git a/test/test_jwt_auth.py b/test/test_external_jwt_authn.py similarity index 89% rename from test/test_jwt_auth.py rename to test/test_external_jwt_authn.py index 1cf6c845d..77e35c9dd 100644 --- a/test/test_jwt_auth.py +++ b/test/test_external_jwt_authn.py @@ -4,10 +4,10 @@ import jwt import base64 from app import app -from flask import Flask, abort, jsonify, request, make_response +from flask import Flask, jsonify, request, make_response from flask.ext.testing import LiveServerTestCase from initdb import setup_database_for_testing, finished_database_for_testing -from data.users import JWTAuthUsers +from data.users import ExternalJWTAuthN from tempfile import NamedTemporaryFile from Crypto.PublicKey import RSA from datetime import datetime, timedelta @@ -31,8 +31,8 @@ class JWTAuthTestCase(LiveServerTestCase): def create_app(self): users = [ - { 'name': 'cooluser', 'email': 'user@domain.com', 'password': 'password' }, - { 'name': 'some.neat.user', 'email': 'neat@domain.com', 'password': 'foobar'} + {'name': 'cooluser', 'email': 'user@domain.com', 'password': 'password'}, + {'name': 'some.neat.user', 'email': 'neat@domain.com', 'password': 'foobar'} ] jwt_app = Flask('testjwt') @@ -82,10 +82,8 @@ class JWTAuthTestCase(LiveServerTestCase): self.session = requests.Session() - self.jwt_auth = JWTAuthUsers( - self.get_server_url() + '/user/verify', - 'authy', '', app.config['HTTPCLIENT'], - JWTAuthTestCase.public_key.name) + self.jwt_auth = ExternalJWTAuthN(self.get_server_url() + '/user/verify', 'authy', '', + app.config['HTTPCLIENT'], 300, JWTAuthTestCase.public_key.name) def tearDown(self): finished_database_for_testing(self) diff --git a/util/config/validator.py b/util/config/validator.py index 9aba129fb..3181b2811 100644 --- a/util/config/validator.py +++ b/util/config/validator.py @@ -7,7 +7,7 @@ import OpenSSL import logging from fnmatch import fnmatch -from data.users import LDAPConnection, JWTAuthUsers, LDAPUsers +from data.users import LDAPConnection, ExternalJWTAuthN, LDAPUsers from flask import Flask from flask.ext.mail import Mail, Message from data.database import validate_database_url, User @@ -340,7 +340,8 @@ def _validate_jwt(config, password): # Try to instatiate the JWT authentication mechanism. This will raise an exception if # the key cannot be found. - users = JWTAuthUsers(verify_endpoint, issuer, OVERRIDE_CONFIG_DIRECTORY, app.config['HTTPCLIENT']) + users = ExternalJWTAuthN(verify_endpoint, issuer, OVERRIDE_CONFIG_DIRECTORY, + app.config['HTTPCLIENT']) # Verify that the superuser exists. If not, raise an exception. username = get_authenticated_user().username