Use the instance service key for registry JWT signing
This commit is contained in:
parent
a4aa5cc02a
commit
8887f09ba8
26 changed files with 457 additions and 278 deletions
3
app.py
3
app.py
|
@ -32,6 +32,7 @@ from util.config.oauth import (GoogleOAuthConfig, GithubOAuthConfig, GitLabOAuth
|
||||||
DexOAuthConfig)
|
DexOAuthConfig)
|
||||||
|
|
||||||
from util.security.signing import Signer
|
from util.security.signing import Signer
|
||||||
|
from util.security.instancekeys import InstanceKeys
|
||||||
from util.saas.cloudwatch import start_cloudwatch_sender
|
from util.saas.cloudwatch import start_cloudwatch_sender
|
||||||
from util.saas.metricqueue import MetricQueue
|
from util.saas.metricqueue import MetricQueue
|
||||||
from util.config.provider import get_config_provider
|
from util.config.provider import get_config_provider
|
||||||
|
@ -178,6 +179,8 @@ authentication = UserAuthentication(app, config_provider, OVERRIDE_CONFIG_DIRECT
|
||||||
userevents = UserEventsBuilderModule(app)
|
userevents = UserEventsBuilderModule(app)
|
||||||
superusers = SuperUserManager(app)
|
superusers = SuperUserManager(app)
|
||||||
signer = Signer(app, config_provider)
|
signer = Signer(app, config_provider)
|
||||||
|
instance_keys = InstanceKeys(app)
|
||||||
|
|
||||||
start_cloudwatch_sender(metric_queue, app)
|
start_cloudwatch_sender(metric_queue, app)
|
||||||
|
|
||||||
tf = app.config['DB_TRANSACTION_FACTORY']
|
tf = app.config['DB_TRANSACTION_FACTORY']
|
||||||
|
|
|
@ -1,29 +1,23 @@
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from jsonschema import validate, ValidationError
|
from jsonschema import validate, ValidationError
|
||||||
from flask import request, url_for
|
from flask import request, url_for
|
||||||
from flask.ext.principal import identity_changed, Identity
|
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, get_app_url
|
from app import app, get_app_url, instance_keys
|
||||||
from .auth_context import set_grant_context, get_grant_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.registry_jwt import (ANONYMOUS_SUB, decode_bearer_token,
|
||||||
from util.security.registry_jwt import ANONYMOUS_SUB
|
InvalidBearerTokenException)
|
||||||
from data import model
|
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+-_/]+)$')
|
|
||||||
CONTEXT_KINDS = ['user', 'token', 'oauth']
|
CONTEXT_KINDS = ['user', 'token', 'oauth']
|
||||||
|
|
||||||
ACCESS_SCHEMA = {
|
ACCESS_SCHEMA = {
|
||||||
|
@ -142,34 +136,18 @@ def get_auth_headers(repository=None, scopes=None):
|
||||||
return headers
|
return headers
|
||||||
|
|
||||||
|
|
||||||
def identity_from_bearer_token(bearer_token, max_signed_s, public_key):
|
def identity_from_bearer_token(bearer_token):
|
||||||
""" 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
|
||||||
v2 auth spec: https://docs.docker.com/registry/spec/auth/token/
|
v2 auth spec: https://docs.docker.com/registry/spec/auth/token/
|
||||||
"""
|
"""
|
||||||
logger.debug('Validating auth header: %s', bearer_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:
|
|
||||||
raise InvalidJWTException('Invalid bearer token format')
|
|
||||||
|
|
||||||
encoded = match.group(1)
|
|
||||||
logger.debug('encoded JWT: %s', encoded)
|
|
||||||
|
|
||||||
# Load the JWT returned.
|
|
||||||
try:
|
try:
|
||||||
expected_issuer = app.config['JWT_AUTH_TOKEN_ISSUER']
|
payload = decode_bearer_token(bearer_token, instance_keys)
|
||||||
audience = app.config['SERVER_HOSTNAME']
|
except InvalidBearerTokenException as bte:
|
||||||
max_exp = strictjwt.exp_max_s_option(max_signed_s)
|
logger.exception('Invalid bearer token: %s', bte)
|
||||||
payload = strictjwt.decode(encoded, public_key, algorithms=['RS256'], audience=audience,
|
raise InvalidJWTException(bte)
|
||||||
issuer=expected_issuer, options=max_exp)
|
|
||||||
except strictjwt.InvalidTokenError:
|
|
||||||
logger.exception('Invalid token reason')
|
|
||||||
raise InvalidJWTException('Invalid token')
|
|
||||||
|
|
||||||
if not 'sub' in payload:
|
|
||||||
raise InvalidJWTException('Missing sub field in JWT')
|
|
||||||
|
|
||||||
loaded_identity = Identity(payload['sub'], 'signed_jwt')
|
loaded_identity = Identity(payload['sub'], 'signed_jwt')
|
||||||
|
|
||||||
|
@ -203,13 +181,6 @@ def identity_from_bearer_token(bearer_token, max_signed_s, public_key):
|
||||||
return loaded_identity, payload.get('context', default_context)
|
return loaded_identity, payload.get('context', default_context)
|
||||||
|
|
||||||
|
|
||||||
@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_registry_jwt_auth(scopes=None):
|
def process_registry_jwt_auth(scopes=None):
|
||||||
def inner(func):
|
def inner(func):
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
|
@ -217,14 +188,8 @@ def process_registry_jwt_auth(scopes=None):
|
||||||
logger.debug('Called with params: %s, %s', args, kwargs)
|
logger.debug('Called with params: %s, %s', args, kwargs)
|
||||||
auth = request.headers.get('authorization', '').strip()
|
auth = request.headers.get('authorization', '').strip()
|
||||||
if auth:
|
if auth:
|
||||||
max_signature_seconds = app.config.get('JWT_AUTH_MAX_FRESH_S', 3660)
|
|
||||||
certificate_file_path = app.config['JWT_AUTH_CERTIFICATE_PATH']
|
|
||||||
public_key = load_public_key(certificate_file_path)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
extracted_identity, context = identity_from_bearer_token(auth, max_signature_seconds,
|
extracted_identity, context = identity_from_bearer_token(auth)
|
||||||
public_key)
|
|
||||||
|
|
||||||
identity_changed.send(app, identity=extracted_identity)
|
identity_changed.send(app, identity=extracted_identity)
|
||||||
set_grant_context(context)
|
set_grant_context(context)
|
||||||
logger.debug('Identity changed to %s', extracted_identity.id)
|
logger.debug('Identity changed to %s', extracted_identity.id)
|
||||||
|
|
9
boot.py
9
boot.py
|
@ -47,15 +47,16 @@ def setup_jwt_proxy():
|
||||||
return
|
return
|
||||||
|
|
||||||
# Generate the key for this Quay instance to use.
|
# Generate the key for this Quay instance to use.
|
||||||
minutes_until_expiration = app.config.get('QUAY_SERVICE_KEY_EXPIRATION', 120)
|
minutes_until_expiration = app.config.get('INSTANCE_SERVICE_KEY_EXPIRATION', 120)
|
||||||
expiration = datetime.now() + timedelta(minutes=minutes_until_expiration)
|
expiration = datetime.now() + timedelta(minutes=minutes_until_expiration)
|
||||||
quay_key, quay_key_id = generate_key('quay', get_audience(), expiration_date=expiration)
|
quay_key, quay_key_id = generate_key(app.config['INSTANCE_SERVICE_KEY_SERVICE'],
|
||||||
|
get_audience(), expiration_date=expiration)
|
||||||
|
|
||||||
with open('conf/quay.kid', mode='w') as f:
|
with open(app.config['INSTANCE_SERVICE_KEY_KID_LOCATION'], mode='w') as f:
|
||||||
f.truncate(0)
|
f.truncate(0)
|
||||||
f.write(quay_key_id)
|
f.write(quay_key_id)
|
||||||
|
|
||||||
with open('conf/quay.pem', mode='w') as f:
|
with open(app.config['INSTANCE_SERVICE_KEY_LOCATION'], mode='w') as f:
|
||||||
f.truncate(0)
|
f.truncate(0)
|
||||||
f.write(quay_key.exportKey())
|
f.write(quay_key.exportKey())
|
||||||
|
|
||||||
|
|
22
config.py
22
config.py
|
@ -260,10 +260,7 @@ class DefaultConfig(object):
|
||||||
SIGNED_GRANT_EXPIRATION_SEC = 60 * 60 * 24 # One day to complete a push/pull
|
SIGNED_GRANT_EXPIRATION_SEC = 60 * 60 * 24 # One day to complete a push/pull
|
||||||
|
|
||||||
# Registry v2 JWT Auth config
|
# Registry v2 JWT Auth config
|
||||||
JWT_AUTH_MAX_FRESH_S = 60 * 60 + 60 # At most signed for one hour, accounting for clock skew
|
REGISTRY_JWT_AUTH_MAX_FRESH_S = 60 * 60 + 60 # At most signed one hour, accounting for clock skew
|
||||||
JWT_AUTH_TOKEN_ISSUER = 'quay-test-issuer'
|
|
||||||
JWT_AUTH_CERTIFICATE_PATH = None
|
|
||||||
JWT_AUTH_PRIVATE_KEY_PATH = None
|
|
||||||
|
|
||||||
# The URL endpoint to which we redirect OAuth when generating a token locally.
|
# The URL endpoint to which we redirect OAuth when generating a token locally.
|
||||||
LOCAL_OAUTH_HANDLER = '/oauth/localapp'
|
LOCAL_OAUTH_HANDLER = '/oauth/localapp'
|
||||||
|
@ -340,14 +337,23 @@ class DefaultConfig(object):
|
||||||
# lowest user in the database will be used.
|
# lowest user in the database will be used.
|
||||||
SERVICE_LOG_ACCOUNT_ID = None
|
SERVICE_LOG_ACCOUNT_ID = None
|
||||||
|
|
||||||
# The location of the private key generated for this instance
|
# The service key ID for the instance service.
|
||||||
|
# NOTE: If changed, jwtproxy_conf.yaml.jnj must also be updated.
|
||||||
|
INSTANCE_SERVICE_KEY_SERVICE = 'quay'
|
||||||
|
|
||||||
|
# The location of the key ID file generated for this instance.
|
||||||
|
INSTANCE_SERVICE_KEY_KID_LOCATION = 'conf/quay.kid'
|
||||||
|
|
||||||
|
# The location of the private key generated for this instance.
|
||||||
|
# NOTE: If changed, jwtproxy_conf.yaml.jnj must also be updated.
|
||||||
INSTANCE_SERVICE_KEY_LOCATION = 'conf/quay.pem'
|
INSTANCE_SERVICE_KEY_LOCATION = 'conf/quay.pem'
|
||||||
|
|
||||||
# This instance's service key expiration in minutes
|
# This instance's service key expiration in minutes.
|
||||||
INSTANCE_SERVICE_KEY_EXPIRATION = 120
|
INSTANCE_SERVICE_KEY_EXPIRATION = 120
|
||||||
|
|
||||||
# Number of minutes between expiration refresh in minutes
|
# Number of minutes between expiration refresh in minutes. Should be the expiration / 2 minus
|
||||||
INSTANCE_SERVICE_KEY_REFRESH = 60
|
# some additional window time.
|
||||||
|
INSTANCE_SERVICE_KEY_REFRESH = 55
|
||||||
|
|
||||||
# The whitelist of client IDs for OAuth applications that allow for direct login.
|
# The whitelist of client IDs for OAuth applications that allow for direct login.
|
||||||
DIRECT_OAUTH_CLIENTID_WHITELIST = []
|
DIRECT_OAUTH_CLIENTID_WHITELIST = []
|
||||||
|
|
|
@ -3,7 +3,7 @@ import json
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from data.users.federated import FederatedUsers, VerifiedCredentials
|
from data.users.federated import FederatedUsers, VerifiedCredentials
|
||||||
from util.security import strictjwt
|
from util.security import jwtutil
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -45,12 +45,12 @@ class ExternalJWTAuthN(FederatedUsers):
|
||||||
|
|
||||||
# Load the JWT returned.
|
# Load the JWT returned.
|
||||||
encoded = result_data.get('token', '')
|
encoded = result_data.get('token', '')
|
||||||
exp_limit_options = strictjwt.exp_max_s_option(self.max_fresh_s)
|
exp_limit_options = jwtutil.exp_max_s_option(self.max_fresh_s)
|
||||||
try:
|
try:
|
||||||
payload = strictjwt.decode(encoded, self.public_key, algorithms=['RS256'],
|
payload = jwtutil.decode(encoded, self.public_key, algorithms=['RS256'],
|
||||||
audience='quay.io/jwtauthn', issuer=self.issuer,
|
audience='quay.io/jwtauthn', issuer=self.issuer,
|
||||||
options=exp_limit_options)
|
options=exp_limit_options)
|
||||||
except strictjwt.InvalidTokenError:
|
except jwtutil.InvalidTokenError:
|
||||||
logger.exception('Exception when decoding returned JWT')
|
logger.exception('Exception when decoding returned JWT')
|
||||||
return (None, 'Invalid username or password')
|
return (None, 'Invalid username or password')
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,7 @@
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from cryptography.hazmat.backends import default_backend
|
|
||||||
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicNumbers
|
|
||||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
|
|
||||||
from flask import Blueprint, jsonify, abort, request, make_response
|
from flask import Blueprint, jsonify, abort, request, make_response
|
||||||
from jwkest.jwk import keyrep, RSAKey, ECKey
|
|
||||||
from jwt import get_unverified_header
|
from jwt import get_unverified_header
|
||||||
|
|
||||||
import data.model
|
import data.model
|
||||||
|
@ -14,8 +9,7 @@ import data.model.service_keys
|
||||||
from data.model.log import log_action
|
from data.model.log import log_action
|
||||||
|
|
||||||
from app import app
|
from app import app
|
||||||
from auth.registry_jwt_auth import TOKEN_REGEX
|
from util.security import jwtutil
|
||||||
from util.security import strictjwt
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -39,23 +33,13 @@ def _validate_jwk(jwk):
|
||||||
abort(400)
|
abort(400)
|
||||||
|
|
||||||
|
|
||||||
def _jwk_dict_to_public_key(jwk):
|
|
||||||
jwkest_key = keyrep(jwk)
|
|
||||||
if isinstance(jwkest_key, RSAKey):
|
|
||||||
pycrypto_key = jwkest_key.key
|
|
||||||
return RSAPublicNumbers(e=pycrypto_key.e, n=pycrypto_key.n).public_key(default_backend())
|
|
||||||
elif isinstance(jwkest_key, ECKey):
|
|
||||||
x, y = jwkest_key.get_key()
|
|
||||||
return EllipticCurvePublicNumbers(x, y, jwkest_key.curve).public_key(default_backend())
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_jwt(encoded_jwt, jwk, service):
|
def _validate_jwt(encoded_jwt, jwk, service):
|
||||||
public_key = _jwk_dict_to_public_key(jwk)
|
public_key = jwtutil.jwk_dict_to_public_key(jwk)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
strictjwt.decode(encoded_jwt, public_key, algorithms=['RS256'],
|
jwtutil.decode(encoded_jwt, public_key, algorithms=['RS256'],
|
||||||
audience=JWT_AUDIENCE, issuer=service)
|
audience=JWT_AUDIENCE, issuer=service)
|
||||||
except strictjwt.InvalidTokenError:
|
except jwtutil.InvalidTokenError:
|
||||||
logger.exception('JWT validation failure')
|
logger.exception('JWT validation failure')
|
||||||
abort(400)
|
abort(400)
|
||||||
|
|
||||||
|
@ -122,7 +106,7 @@ def put_service_key(service, kid):
|
||||||
abort(400)
|
abort(400)
|
||||||
|
|
||||||
jwt_header = request.headers.get(JWT_HEADER_NAME, '')
|
jwt_header = request.headers.get(JWT_HEADER_NAME, '')
|
||||||
match = TOKEN_REGEX.match(jwt_header)
|
match = jwtutil.TOKEN_REGEX.match(jwt_header)
|
||||||
if match is None:
|
if match is None:
|
||||||
logger.error('Could not find matching bearer token')
|
logger.error('Could not find matching bearer token')
|
||||||
abort(400)
|
abort(400)
|
||||||
|
@ -180,7 +164,7 @@ def put_service_key(service, kid):
|
||||||
@key_server.route('/services/<service>/keys/<kid>', methods=['DELETE'])
|
@key_server.route('/services/<service>/keys/<kid>', methods=['DELETE'])
|
||||||
def delete_service_key(service, kid):
|
def delete_service_key(service, kid):
|
||||||
jwt_header = request.headers.get(JWT_HEADER_NAME, '')
|
jwt_header = request.headers.get(JWT_HEADER_NAME, '')
|
||||||
match = TOKEN_REGEX.match(jwt_header)
|
match = jwtutil.TOKEN_REGEX.match(jwt_header)
|
||||||
if match is None:
|
if match is None:
|
||||||
abort(400)
|
abort(400)
|
||||||
|
|
||||||
|
|
|
@ -12,7 +12,7 @@ from auth.auth import require_session_login
|
||||||
from data import model
|
from data import model
|
||||||
from endpoints.common import common_login, route_show_if
|
from endpoints.common import common_login, route_show_if
|
||||||
from endpoints.web import render_page_template_with_routedata
|
from endpoints.web import render_page_template_with_routedata
|
||||||
from util.security.strictjwt import decode, InvalidTokenError
|
from util.security.jwtutil import decode, InvalidTokenError
|
||||||
from util.validation import generate_valid_usernames
|
from util.validation import generate_valid_usernames
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ import re
|
||||||
|
|
||||||
from flask import request, jsonify, abort
|
from flask import request, jsonify, abort
|
||||||
|
|
||||||
from app import app, userevents
|
from app import app, userevents, instance_keys
|
||||||
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, get_validated_oauth_token
|
from auth.auth_context import get_authenticated_user, get_validated_token, get_validated_oauth_token
|
||||||
|
@ -14,7 +14,7 @@ from endpoints.v2 import v2_bp
|
||||||
from endpoints.decorators import anon_protect
|
from endpoints.decorators import anon_protect
|
||||||
from util.cache import no_cache
|
from util.cache import no_cache
|
||||||
from util.names import parse_namespace_repository, REPOSITORY_NAME_REGEX
|
from util.names import parse_namespace_repository, REPOSITORY_NAME_REGEX
|
||||||
from util.security.registry_jwt import generate_jwt_object, build_context_and_subject
|
from util.security.registry_jwt import generate_bearer_token, build_context_and_subject
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -152,8 +152,6 @@ def generate_registry_jwt():
|
||||||
|
|
||||||
# Build the signed JWT.
|
# Build the signed JWT.
|
||||||
context, subject = build_context_and_subject(user, token, oauthtoken)
|
context, subject = build_context_and_subject(user, token, oauthtoken)
|
||||||
|
token = generate_bearer_token(audience_param, subject, context, access,
|
||||||
jwt_obj = generate_jwt_object(audience_param, subject, context, access, TOKEN_VALIDITY_LIFETIME_S,
|
TOKEN_VALIDITY_LIFETIME_S, instance_keys)
|
||||||
app.config)
|
return jsonify({'token': token})
|
||||||
|
|
||||||
return jsonify({'token': jwt_obj})
|
|
||||||
|
|
|
@ -53,7 +53,6 @@ logging.captureWarnings(True)
|
||||||
web = Blueprint('web', __name__)
|
web = Blueprint('web', __name__)
|
||||||
|
|
||||||
STATUS_TAGS = app.config['STATUS_TAGS']
|
STATUS_TAGS = app.config['STATUS_TAGS']
|
||||||
JWT_ISSUER = app.config.get('JWT_AUTH_TOKEN_ISSUER')
|
|
||||||
|
|
||||||
|
|
||||||
@web.route('/', methods=['GET'], defaults={'path': ''})
|
@web.route('/', methods=['GET'], defaults={'path': ''})
|
||||||
|
|
14
initdb.py
14
initdb.py
|
@ -673,6 +673,20 @@ def populate_database(minimal=False, with_storage=False):
|
||||||
__generate_service_key('kid7', 'somewayexpiredkey', new_user_1, week_ago,
|
__generate_service_key('kid7', 'somewayexpiredkey', new_user_1, week_ago,
|
||||||
ServiceKeyApprovalType.SUPERUSER, today - timedelta(days=30))
|
ServiceKeyApprovalType.SUPERUSER, today - timedelta(days=30))
|
||||||
|
|
||||||
|
# Add the test pull key as pre-approved for local and unittest registry testing.
|
||||||
|
# Note: this must match the private key found in the local/test config.
|
||||||
|
_TEST_JWK = {
|
||||||
|
'e': 'AQAB',
|
||||||
|
'kty': 'RSA',
|
||||||
|
'n': 'yqdQgnelhAPMSeyH0kr3UGePK9oFOmNfwD0Ymnh7YYXr21VHWwyM2eVW3cnLd9KXywDFtGSe9oFDbnOuMCdUowdkBcaHju-isbv5KEbNSoy_T2Rip-6L0cY63YzcMJzv1nEYztYXS8wz76pSK81BKBCLapqOCmcPeCvV9yaoFZYvZEsXCl5jjXN3iujSzSF5Z6PpNFlJWTErMT2Z4QfbDKX2Nw6vJN6JnGpTNHZvgvcyNX8vkSgVpQ8DFnFkBEx54PvRV5KpHAq6AsJxKONMo11idQS2PfCNpa2hvz9O6UZe-eIX8jPo5NW8TuGZJumbdPT_nxTDLfCqfiZboeI0Pw'
|
||||||
|
}
|
||||||
|
|
||||||
|
key = model.service_keys.create_service_key('test_service_key', 'test_service_key', 'quay',
|
||||||
|
_TEST_JWK, {}, None)
|
||||||
|
|
||||||
|
model.service_keys.approve_service_key(key.kid, new_user_1, ServiceKeyApprovalType.SUPERUSER,
|
||||||
|
notes='Test service key for local/test registry testing')
|
||||||
|
|
||||||
model.log.log_action('org_create_team', org.username, performer=new_user_1,
|
model.log.log_action('org_create_team', org.username, performer=new_user_1,
|
||||||
timestamp=week_ago, metadata={'team': 'readers'})
|
timestamp=week_ago, metadata={'team': 'readers'})
|
||||||
|
|
||||||
|
|
|
@ -1,20 +0,0 @@
|
||||||
-----BEGIN CERTIFICATE-----
|
|
||||||
MIIDVDCCAjwCCQDNYtlT1+tGbzANBgkqhkiG9w0BAQUFADBsMQswCQYDVQQGEwJV
|
|
||||||
UzERMA8GA1UECBMITmV3IFlvcmsxETAPBgNVBAcTCE5ldyBZb3JrMRQwEgYDVQQK
|
|
||||||
EwtDb3JlT1MsIEluYzENMAsGA1UECxMEUXVheTESMBAGA1UEAxMJMTI3LjAuMC4x
|
|
||||||
MB4XDTE2MDUyMzE1MjUxOVoXDTI2MDUyMTE1MjUxOVowbDELMAkGA1UEBhMCVVMx
|
|
||||||
ETAPBgNVBAgTCE5ldyBZb3JrMREwDwYDVQQHEwhOZXcgWW9yazEUMBIGA1UEChML
|
|
||||||
Q29yZU9TLCBJbmMxDTALBgNVBAsTBFF1YXkxEjAQBgNVBAMTCTEyNy4wLjAuMTCC
|
|
||||||
ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKRvOt/XGNIovlr1BWxl2oqs
|
|
||||||
KDlgnESj6bFENDjs9+YLrB3mSWX6w4Dk2IdNU0EKHeVnnsAuBs83jaFsIVJxrC99
|
|
||||||
ndv0PaejBovUbWyYN3zCMur8iNGse/FT4WRqks2m0Wr0jmEAX5piX/eWo/7OQdea
|
|
||||||
wNAGyH7wE0voMpyVSZMBmxRw07zWnwWBihvhOiiCnXZh32GQMplq0wxk4DkBf3hC
|
|
||||||
SEaAqsFHKfEFPxVXfdPGeiKKK+P2SAh+uN4miJpGf7Xkuj/Mmzxr1ajNczhPT6OM
|
|
||||||
pw0R3h/mok1S8zcp8lN/eDdKwjMeP4Rx+Lc0cRluZNa8otq9qYPNSCIkvsSz5b8C
|
|
||||||
AwEAATANBgkqhkiG9w0BAQUFAAOCAQEAZaaD8fLWEh4RGZ7X38IM/ocwDKaXWpDp
|
|
||||||
0EC3KMEuar1MET3MtVIXy/k/BLr0HmLRQ2KSV3wFfyOInseVeCvIcKZZo/JF28gR
|
|
||||||
LJVBcjExSIr6X8RoPgmKt7AdjlUjPV5XpRzDpfYcMaqpjJa75x6RoxC2ybh5Apyk
|
|
||||||
EzL3Naysk6TVPi5ckUYMLfw3JEbCeaEY4KNwVgsNcs447EcBxwGHTBqGOYtpIfku
|
|
||||||
SMas81oniMo9LMKv19Bn1oOforaqh8P2c57yregDsCDmP6j0gqkYjhJFCj5JNAKK
|
|
||||||
KT35QIfTbVFeCXAoLw0+o9Ma1Q+j7LfwdxnikUHNVZmlmjQmTBMwqg==
|
|
||||||
-----END CERTIFICATE-----
|
|
|
@ -1,27 +0,0 @@
|
||||||
-----BEGIN RSA PRIVATE KEY-----
|
|
||||||
MIIEowIBAAKCAQEApG8639cY0ii+WvUFbGXaiqwoOWCcRKPpsUQ0OOz35gusHeZJ
|
|
||||||
ZfrDgOTYh01TQQod5WeewC4GzzeNoWwhUnGsL32d2/Q9p6MGi9RtbJg3fMIy6vyI
|
|
||||||
0ax78VPhZGqSzabRavSOYQBfmmJf95aj/s5B15rA0AbIfvATS+gynJVJkwGbFHDT
|
|
||||||
vNafBYGKG+E6KIKddmHfYZAymWrTDGTgOQF/eEJIRoCqwUcp8QU/FVd908Z6Ioor
|
|
||||||
4/ZICH643iaImkZ/teS6P8ybPGvVqM1zOE9Po4ynDRHeH+aiTVLzNynyU394N0rC
|
|
||||||
Mx4/hHH4tzRxGW5k1ryi2r2pg81IIiS+xLPlvwIDAQABAoIBAANdV0oPP63FMafw
|
|
||||||
zYybRO6DeUs7Q9dPt09uQtdLWgM2B+6QsL3KdMelZxzVozd4eoYgKaprBq6kx1wf
|
|
||||||
N0tVkh1ip6FBjSVp+49O6HJJZxFBdANE6ZPIwLx+Z+VDHP/iQvS6TlODy3EARFBv
|
|
||||||
n6luFQDRZNKc4OtgBDUQakCz+U5tuJLqoR8wk/WGQP4FJiZlVwJqNPXMA1A2Mrri
|
|
||||||
n6WkhfpB30Z5dl9zsR+zJRbwRBjgJCYN37YC7zdHRfIhBPBvDT+8ApR50BGvPGN3
|
|
||||||
sLQuH2FsskbgPsIrWMfCxtWr2xbw028GOe7TSjEG63EG7oGAT0O2eQmAcuPc4Dqj
|
|
||||||
Urn8saECgYEA2LkCe6MysmOtattC/gi3B/rIoOCd+4l9yTnW7S7nk/hdeOzxyqX1
|
|
||||||
P7OgVeoYLLk3UJy3qTrNDnc0eGTJz0XyPhLlX0f9lduiSMH92XpNsBG7ngnyMCQF
|
|
||||||
eAZz8ZlDZC39I8y9CzdcHSLxuHKmQ9jhgUm+EIuf8OlrkjchPdE06i8CgYEAwjxG
|
|
||||||
cDA5X1hKYgQTObq245vR3txkvETmLVB7hWkjWLzR//a4hXHJT1fg2LxD5EMtCKZ2
|
|
||||||
WXKhcy3tbja+c/IEI1L1wA2v/aWlEvi9n354EQ1QzkvCBDFP5enLnItAUzJQ0IgE
|
|
||||||
dtSUskK+li8aY2LB0EPt0eJmYU0cZUJXbl/ZKXECgYAAtttjPO512A5CQ+a8n5q6
|
|
||||||
1ADFRvg+U/2uJBqpPXZV7oOgWmeRm2prg1QL9HGP9CxSf7G7RQ5X9dyeaPahUEG0
|
|
||||||
IqvO3JXhYI/wXXNQvC51XhmYM8AwmG3ML3lCWpb2RZCIBay51Lzg+7SAPyB9KMHV
|
|
||||||
g0C1HUCxspNAMB5T7dSW0QKBgGkxRaCarWeypE4jENpyAXyRNf8xcyj3U4V1EgB1
|
|
||||||
qVv0nvK2BsbWkgTzfeVDSK2FqA0IQg49Y6zCUdUfttOKXa1Xz5ocj5SaMiVtKx0G
|
|
||||||
3DW39WxUYRXuMuw8SzZTwBmOpW/aSjik9ob4WMlzZyIuKPMG5vSFXZcSsO8yF7HC
|
|
||||||
HRUxAoGBAKtCRLT9I5Ap37gWT8W6AAZygoUqhlYO9qygQrBDaJsHj0ZSHM0TO3ig
|
|
||||||
Bwq/UxDHBKFV3hmqx5Zmpoa9ZrURb4cBw/+TLq2ppXPLEU+XmEVmqL2323Vyr/Ih
|
|
||||||
CAIVWFsY3EGQL7TArOfag+v0Nxq3pypOhjweqIWEMDg+gV2+GHhQ
|
|
||||||
-----END RSA PRIVATE KEY-----
|
|
Binary file not shown.
1
test/data/test.kid
Normal file
1
test/data/test.kid
Normal file
|
@ -0,0 +1 @@
|
||||||
|
test_service_key
|
|
@ -1,27 +1,27 @@
|
||||||
-----BEGIN RSA PRIVATE KEY-----
|
-----BEGIN RSA PRIVATE KEY-----
|
||||||
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
|
||||||
-----END RSA PRIVATE KEY-----
|
-----END RSA PRIVATE KEY-----
|
|
@ -1 +0,0 @@
|
||||||
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC/PL+l5ASc28h5sn7dAa2vLp7zA86UMF9DlXliH2vTuKBQTlQa1Qzxij/ZF7h206gXB6w88W/DC7rlCnsvVdl1rcqU6dwJiZDT8AcQqXIDTsEFslVi8n3KD6b/3hbR/NSAVNknXxSeNSKnstPgw+QYA2syll4jWEZWKE4cgeMZH+Skx11bzraAJHB0WbgfD5Zu3RTBfOGmvfUIZj8zN/qbCHMSTpDRCNALtHjO+P06LpDWvUKKk+l1ktgMX6OhLED1koeIsIMfjaRC7zxYM1x1dK+Dluzr7gvRK9bBSkYsc8M4bkkQ0zdy6O/f89vHlAhCdY06KwyItCy51a1/lXWx jzelinskie@hanazawa
|
|
|
@ -15,10 +15,8 @@ from cachetools import lru_cache
|
||||||
from flask import request, jsonify, abort
|
from flask import request, jsonify, abort
|
||||||
from flask.blueprints import Blueprint
|
from flask.blueprints import Blueprint
|
||||||
from flask.ext.testing import LiveServerTestCase
|
from flask.ext.testing import LiveServerTestCase
|
||||||
from cryptography.x509 import load_pem_x509_certificate
|
|
||||||
from cryptography.hazmat.backends import default_backend
|
|
||||||
|
|
||||||
from app import app, storage
|
from app import app, storage, instance_keys
|
||||||
from data.database import close_db_filter, configure, DerivedStorageForImage, QueueItem, Image
|
from data.database import close_db_filter, configure, DerivedStorageForImage, QueueItem, Image
|
||||||
from data import model
|
from data import model
|
||||||
from endpoints.v1 import v1_bp
|
from endpoints.v1 import v1_bp
|
||||||
|
@ -30,7 +28,7 @@ from initdb import wipe_database, initialize_database, populate_database
|
||||||
from endpoints.csrf import generate_csrf_token
|
from endpoints.csrf import generate_csrf_token
|
||||||
from tempfile import NamedTemporaryFile
|
from tempfile import NamedTemporaryFile
|
||||||
from jsonschema import validate as validate_schema
|
from jsonschema import validate as validate_schema
|
||||||
from util.security import strictjwt
|
from util.security.registry_jwt import decode_bearer_token
|
||||||
|
|
||||||
import endpoints.decorated
|
import endpoints.decorated
|
||||||
import json
|
import json
|
||||||
|
@ -1824,36 +1822,19 @@ class V1LoginTests(V1RegistryLoginMixin, LoginTests, RegistryTestCaseMixin, Base
|
||||||
|
|
||||||
class V2LoginTests(V2RegistryLoginMixin, LoginTests, RegistryTestCaseMixin, BaseRegistryMixin, LiveServerTestCase):
|
class V2LoginTests(V2RegistryLoginMixin, LoginTests, RegistryTestCaseMixin, BaseRegistryMixin, LiveServerTestCase):
|
||||||
""" Tests for V2 login. """
|
""" Tests for V2 login. """
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@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 do_logincheck(self, username, password, scope, expected_actions=[], expect_success=True,
|
def do_logincheck(self, username, password, scope, expected_actions=[], expect_success=True,
|
||||||
**kwargs):
|
**kwargs):
|
||||||
|
# Perform login to get an auth token.
|
||||||
response = self.do_login(username, password, scope, expect_success=expect_success, **kwargs)
|
response = self.do_login(username, password, scope, expect_success=expect_success, **kwargs)
|
||||||
|
|
||||||
if not expect_success:
|
if not expect_success:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Validate the returned JWT.
|
# Validate the returned token.
|
||||||
encoded = response.json()['token']
|
encoded = response.json()['token']
|
||||||
|
token = 'Bearer ' + encoded
|
||||||
|
|
||||||
expected_issuer = app.config['JWT_AUTH_TOKEN_ISSUER']
|
payload = decode_bearer_token(token, instance_keys)
|
||||||
audience = app.config['SERVER_HOSTNAME']
|
self.assertIsNotNone(payload)
|
||||||
|
|
||||||
max_signed_s = app.config.get('JWT_AUTH_MAX_FRESH_S', 3660)
|
|
||||||
certificate_file_path = app.config['JWT_AUTH_CERTIFICATE_PATH']
|
|
||||||
|
|
||||||
public_key = V2LoginTests.load_public_key(certificate_file_path)
|
|
||||||
|
|
||||||
max_exp = strictjwt.exp_max_s_option(max_signed_s)
|
|
||||||
payload = strictjwt.decode(encoded, public_key, algorithms=['RS256'], audience=audience,
|
|
||||||
issuer=expected_issuer, options=max_exp)
|
|
||||||
|
|
||||||
if scope is None:
|
if scope is None:
|
||||||
self.assertEquals(0, len(payload['access']))
|
self.assertEquals(0, len(payload['access']))
|
||||||
|
|
|
@ -2,15 +2,15 @@ import unittest
|
||||||
import time
|
import time
|
||||||
import jwt
|
import jwt
|
||||||
|
|
||||||
from cryptography.hazmat.backends import default_backend
|
from app import app, instance_keys
|
||||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
from data import model
|
||||||
|
from data.database import ServiceKeyApprovalType
|
||||||
from app import app
|
from initdb import setup_database_for_testing, finished_database_for_testing
|
||||||
from endpoints.v2.v2auth import TOKEN_VALIDITY_LIFETIME_S
|
from endpoints.v2.v2auth import TOKEN_VALIDITY_LIFETIME_S
|
||||||
from auth.registry_jwt_auth import identity_from_bearer_token, load_public_key, InvalidJWTException
|
from auth.registry_jwt_auth import identity_from_bearer_token, InvalidJWTException
|
||||||
from util.morecollections import AttrDict
|
from util.morecollections import AttrDict
|
||||||
from util.security.registry_jwt import (_load_certificate_bytes, _load_private_key, ANONYMOUS_SUB,
|
from util.security.registry_jwt import (ANONYMOUS_SUB, build_context_and_subject,
|
||||||
build_context_and_subject)
|
decode_bearer_token, generate_bearer_token)
|
||||||
|
|
||||||
|
|
||||||
TEST_AUDIENCE = app.config['SERVER_HOSTNAME']
|
TEST_AUDIENCE = app.config['SERVER_HOSTNAME']
|
||||||
|
@ -19,20 +19,18 @@ MAX_SIGNED_S = 3660
|
||||||
|
|
||||||
|
|
||||||
class TestRegistryV2Auth(unittest.TestCase):
|
class TestRegistryV2Auth(unittest.TestCase):
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super(TestRegistryV2Auth, self).__init__(*args, **kwargs)
|
|
||||||
self.public_key = None
|
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
certificate_file_path = app.config['JWT_AUTH_CERTIFICATE_PATH']
|
setup_database_for_testing(self)
|
||||||
self.public_key = load_public_key(certificate_file_path)
|
|
||||||
|
def tearDown(self):
|
||||||
|
finished_database_for_testing(self)
|
||||||
|
|
||||||
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=None):
|
||||||
|
|
||||||
_, subject = build_context_and_subject(user, None, None)
|
_, subject = build_context_and_subject(user, None, None)
|
||||||
return {
|
return {
|
||||||
'iss': iss,
|
'iss': iss or instance_keys.service_name,
|
||||||
'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()),
|
||||||
|
@ -41,28 +39,25 @@ class TestRegistryV2Auth(unittest.TestCase):
|
||||||
'access': access,
|
'access': access,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _generate_token(self, token_data):
|
def _generate_token(self, token_data, key_id=None, private_key=None, skip_header=False, alg=None):
|
||||||
|
key_id = key_id or instance_keys.local_key_id
|
||||||
|
private_key = private_key or instance_keys.local_private_key
|
||||||
|
|
||||||
certificate = _load_certificate_bytes(app.config['JWT_AUTH_CERTIFICATE_PATH'])
|
if alg == "none":
|
||||||
|
private_key = None
|
||||||
|
|
||||||
token_headers = {
|
token_headers = {
|
||||||
'x5c': [certificate],
|
'kid': key_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
private_key = _load_private_key(app.config['JWT_AUTH_PRIVATE_KEY_PATH'])
|
if skip_header:
|
||||||
token_data = jwt.encode(token_data, private_key, 'RS256', headers=token_headers)
|
token_headers = {}
|
||||||
|
|
||||||
|
token_data = jwt.encode(token_data, private_key, alg or 'RS256', headers=token_headers)
|
||||||
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)[0]
|
return identity_from_bearer_token(token)[0]
|
||||||
|
|
||||||
def _generate_public_key(self):
|
|
||||||
key = rsa.generate_private_key(
|
|
||||||
public_exponent=65537,
|
|
||||||
key_size=1024,
|
|
||||||
backend=default_backend()
|
|
||||||
)
|
|
||||||
return key.public_key()
|
|
||||||
|
|
||||||
def test_accepted_token(self):
|
def test_accepted_token(self):
|
||||||
token = self._generate_token(self._generate_token_data())
|
token = self._generate_token(self._generate_token_data())
|
||||||
|
@ -100,12 +95,6 @@ class TestRegistryV2Auth(unittest.TestCase):
|
||||||
with self.assertRaises(InvalidJWTException):
|
with self.assertRaises(InvalidJWTException):
|
||||||
self._parse_token(token)
|
self._parse_token(token)
|
||||||
|
|
||||||
def test_bad_signature(self):
|
|
||||||
token = self._generate_token(self._generate_token_data())
|
|
||||||
other_public_key = self._generate_public_key()
|
|
||||||
with self.assertRaises(InvalidJWTException):
|
|
||||||
identity_from_bearer_token(token, MAX_SIGNED_S, other_public_key)
|
|
||||||
|
|
||||||
def test_audience(self):
|
def test_audience(self):
|
||||||
token_data = self._generate_token_data(audience='someotherapp')
|
token_data = self._generate_token_data(audience='someotherapp')
|
||||||
token = self._generate_token(token_data)
|
token = self._generate_token(token_data)
|
||||||
|
@ -171,7 +160,6 @@ class TestRegistryV2Auth(unittest.TestCase):
|
||||||
|
|
||||||
def test_iss(self):
|
def test_iss(self):
|
||||||
token_data = self._generate_token_data(iss='badissuer')
|
token_data = self._generate_token_data(iss='badissuer')
|
||||||
|
|
||||||
token = self._generate_token(token_data)
|
token = self._generate_token(token_data)
|
||||||
with self.assertRaises(InvalidJWTException):
|
with self.assertRaises(InvalidJWTException):
|
||||||
self._parse_token(token)
|
self._parse_token(token)
|
||||||
|
@ -181,9 +169,94 @@ class TestRegistryV2Auth(unittest.TestCase):
|
||||||
with self.assertRaises(InvalidJWTException):
|
with self.assertRaises(InvalidJWTException):
|
||||||
self._parse_token(no_iss_token)
|
self._parse_token(no_iss_token)
|
||||||
|
|
||||||
|
def test_missing_header(self):
|
||||||
|
token_data = self._generate_token_data()
|
||||||
|
missing_header_token = self._generate_token(token_data, skip_header=True)
|
||||||
|
with self.assertRaises(InvalidJWTException):
|
||||||
|
self._parse_token(missing_header_token)
|
||||||
|
|
||||||
|
def test_invalid_key(self):
|
||||||
|
token_data = self._generate_token_data()
|
||||||
|
invalid_key_token = self._generate_token(token_data, key_id='someunknownkey')
|
||||||
|
with self.assertRaises(InvalidJWTException):
|
||||||
|
self._parse_token(invalid_key_token)
|
||||||
|
|
||||||
|
def test_expired_key(self):
|
||||||
|
token_data = self._generate_token_data()
|
||||||
|
expired_key_token = self._generate_token(token_data, key_id='kid7')
|
||||||
|
with self.assertRaises(InvalidJWTException):
|
||||||
|
self._parse_token(expired_key_token)
|
||||||
|
|
||||||
|
def test_mixing_keys(self):
|
||||||
|
token_data = self._generate_token_data()
|
||||||
|
|
||||||
|
# Create a new key for testing.
|
||||||
|
p, key = model.service_keys.generate_service_key(instance_keys.service_name, None, kid='newkey',
|
||||||
|
name='newkey', metadata={})
|
||||||
|
|
||||||
|
private_key = p.exportKey('PEM')
|
||||||
|
|
||||||
|
# Test first with the new valid, but unapproved key.
|
||||||
|
unapproved_key_token = self._generate_token(token_data, key_id='newkey', private_key=private_key)
|
||||||
|
with self.assertRaises(InvalidJWTException):
|
||||||
|
self._parse_token(unapproved_key_token)
|
||||||
|
|
||||||
|
# Approve the key and try again.
|
||||||
|
admin_user = model.user.get_user('devtable')
|
||||||
|
model.service_keys.approve_service_key(key.kid, admin_user, ServiceKeyApprovalType.SUPERUSER)
|
||||||
|
|
||||||
|
valid_token = self._generate_token(token_data, key_id='newkey', private_key=private_key)
|
||||||
|
|
||||||
|
identity = self._parse_token(valid_token)
|
||||||
|
self.assertEqual(identity.id, TEST_USER.username)
|
||||||
|
self.assertEqual(0, len(identity.provides))
|
||||||
|
|
||||||
|
# Try using a different private key with the existing key ID.
|
||||||
|
bad_private_token = self._generate_token(token_data, key_id='newkey', private_key=instance_keys.local_private_key)
|
||||||
|
with self.assertRaises(InvalidJWTException):
|
||||||
|
self._parse_token(bad_private_token)
|
||||||
|
|
||||||
|
# Try using a different key ID with the existing private key.
|
||||||
|
kid_mismatch_token = self._generate_token(token_data, key_id=instance_keys.local_key_id, private_key=private_key)
|
||||||
|
with self.assertRaises(InvalidJWTException):
|
||||||
|
self._parse_token(kid_mismatch_token)
|
||||||
|
|
||||||
|
# Delete the new key.
|
||||||
|
key.delete_instance(recursive=True)
|
||||||
|
|
||||||
|
# Ensure it still works (via the cache.)
|
||||||
|
deleted_key_token = self._generate_token(token_data, key_id='newkey', private_key=private_key)
|
||||||
|
identity = self._parse_token(deleted_key_token)
|
||||||
|
self.assertEqual(identity.id, TEST_USER.username)
|
||||||
|
self.assertEqual(0, len(identity.provides))
|
||||||
|
|
||||||
|
# Break the cache.
|
||||||
|
instance_keys.clear_cache()
|
||||||
|
|
||||||
|
# Ensure the key no longer works.
|
||||||
|
with self.assertRaises(InvalidJWTException):
|
||||||
|
self._parse_token(deleted_key_token)
|
||||||
|
|
||||||
|
def test_bad_token(self):
|
||||||
|
with self.assertRaises(InvalidJWTException):
|
||||||
|
self._parse_token("some random token here")
|
||||||
|
|
||||||
|
def test_bad_bearer_token(self):
|
||||||
|
with self.assertRaises(InvalidJWTException):
|
||||||
|
self._parse_token("Bearer: sometokenhere")
|
||||||
|
|
||||||
|
def test_bad_bearer_newline_token(self):
|
||||||
|
with self.assertRaises(InvalidJWTException):
|
||||||
|
self._parse_token("\nBearer: dGVzdA")
|
||||||
|
|
||||||
|
def test_ensure_no_none(self):
|
||||||
|
token_data = self._generate_token_data()
|
||||||
|
none_token = self._generate_token(token_data, alg='none', private_key=None)
|
||||||
|
|
||||||
|
with self.assertRaises(InvalidJWTException):
|
||||||
|
self._parse_token(none_token)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
import logging
|
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|
|
@ -56,8 +56,6 @@ class TestConfig(DefaultConfig):
|
||||||
FEATURE_BITTORRENT = True
|
FEATURE_BITTORRENT = True
|
||||||
FEATURE_ACI_CONVERSION = True
|
FEATURE_ACI_CONVERSION = True
|
||||||
|
|
||||||
INSTANCE_SERVICE_KEY_LOCATION = 'test/data/test.pem'
|
|
||||||
|
|
||||||
CLOUDWATCH_NAMESPACE = None
|
CLOUDWATCH_NAMESPACE = None
|
||||||
|
|
||||||
FEATURE_SECURITY_SCANNER = True
|
FEATURE_SECURITY_SCANNER = True
|
||||||
|
@ -73,5 +71,5 @@ class TestConfig(DefaultConfig):
|
||||||
GPG2_PRIVATE_KEY_FILENAME = 'test/data/signing-private.gpg'
|
GPG2_PRIVATE_KEY_FILENAME = 'test/data/signing-private.gpg'
|
||||||
GPG2_PUBLIC_KEY_FILENAME = 'test/data/signing-public.gpg'
|
GPG2_PUBLIC_KEY_FILENAME = 'test/data/signing-public.gpg'
|
||||||
|
|
||||||
JWT_AUTH_CERTIFICATE_PATH = 'test/data/registry_v2_auth.crt'
|
INSTANCE_SERVICE_KEY_KID_LOCATION = 'test/data/test.kid'
|
||||||
JWT_AUTH_PRIVATE_KEY_PATH = 'test/data/registry_v2_auth_private.key'
|
INSTANCE_SERVICE_KEY_LOCATION = 'test/data/test.pem'
|
||||||
|
|
55
util/expiresdict.py
Normal file
55
util/expiresdict.py
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class ExpiresEntry(object):
|
||||||
|
""" A single entry under a ExpiresDict. """
|
||||||
|
def __init__(self, value, expires=None):
|
||||||
|
self.value = value
|
||||||
|
self._expiration = expires
|
||||||
|
|
||||||
|
@property
|
||||||
|
def expired(self):
|
||||||
|
if self._expiration is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return datetime.now() >= self._expiration
|
||||||
|
|
||||||
|
|
||||||
|
class ExpiresDict(object):
|
||||||
|
""" ExpiresDict defines a dictionary-like class whose keys have expiration. The rebuilder is
|
||||||
|
a function that returns the full contents of the cached dictionary as a dict of the keys
|
||||||
|
and whose values are TTLEntry's.
|
||||||
|
"""
|
||||||
|
def __init__(self, rebuilder):
|
||||||
|
self._rebuilder = rebuilder
|
||||||
|
self._items = {}
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
found = self.get(key)
|
||||||
|
if found is None:
|
||||||
|
raise KeyError
|
||||||
|
|
||||||
|
return found
|
||||||
|
|
||||||
|
def get(self, key, default_value=None):
|
||||||
|
# Check the cache first. If the key is found and it has not yet expired,
|
||||||
|
# return it.
|
||||||
|
found = self._items.get(key)
|
||||||
|
if found is not None and not found.expired:
|
||||||
|
return found.value
|
||||||
|
|
||||||
|
# Otherwise the key has expired or was not found. Rebuild the cache and check it again.
|
||||||
|
self._rebuild()
|
||||||
|
found = self._items.get(key)
|
||||||
|
if found is None:
|
||||||
|
return default_value
|
||||||
|
|
||||||
|
return found.value
|
||||||
|
|
||||||
|
def __contains__(self, key):
|
||||||
|
return self.get(key) is not None
|
||||||
|
|
||||||
|
def _rebuild(self):
|
||||||
|
self._items = self._rebuilder()
|
||||||
|
|
||||||
|
def set(self, key, value, expires=None):
|
||||||
|
self._items[key] = ExpiresEntry(value, expires=expires)
|
|
@ -7,15 +7,12 @@ import urllib
|
||||||
|
|
||||||
from cachetools import lru_cache
|
from cachetools import lru_cache
|
||||||
|
|
||||||
from app import app
|
from app import app, instance_keys
|
||||||
|
|
||||||
|
|
||||||
ANNOUNCE_URL = app.config.get('BITTORRENT_ANNOUNCE_URL')
|
ANNOUNCE_URL = app.config['BITTORRENT_ANNOUNCE_URL']
|
||||||
PRIVATE_KEY_LOCATION = app.config.get('INSTANCE_SERVICE_KEY_LOCATION')
|
FILENAME_PEPPER = app.config['BITTORRENT_FILENAME_PEPPER']
|
||||||
FILENAME_PEPPER = app.config.get('BITTORRENT_FILENAME_PEPPER')
|
REGISTRY_TITLE = app.config['REGISTRY_TITLE']
|
||||||
REGISTRY_TITLE = app.config.get('REGISTRY_TITLE')
|
|
||||||
JWT_ISSUER = app.config.get('JWT_AUTH_TOKEN_ISSUER')
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def _load_private_key(private_key_file_path):
|
def _load_private_key(private_key_file_path):
|
||||||
|
@ -24,13 +21,12 @@ def _load_private_key(private_key_file_path):
|
||||||
|
|
||||||
def _torrent_jwt(info_dict):
|
def _torrent_jwt(info_dict):
|
||||||
token_data = {
|
token_data = {
|
||||||
'iss': JWT_ISSUER,
|
'iss': instance_keys.service_name,
|
||||||
'aud': ANNOUNCE_URL,
|
'aud': ANNOUNCE_URL,
|
||||||
'infohash': _infohash(info_dict),
|
'infohash': _infohash(info_dict),
|
||||||
}
|
}
|
||||||
|
|
||||||
private_key = _load_private_key(PRIVATE_KEY_LOCATION)
|
return jwt.encode(token_data, instance_keys.local_private_key, 'RS256')
|
||||||
return jwt.encode(token_data, private_key, 'RS256')
|
|
||||||
|
|
||||||
def _infohash(infodict):
|
def _infohash(infodict):
|
||||||
digest = hashlib.sha1()
|
digest = hashlib.sha1()
|
||||||
|
|
|
@ -8,7 +8,8 @@ from data.database import CloseForLongOperation
|
||||||
from data import model
|
from data import model
|
||||||
from data.model.storage import get_storage_locations
|
from data.model.storage import get_storage_locations
|
||||||
from util.secscan.validator import SecurityConfigValidator
|
from util.secscan.validator import SecurityConfigValidator
|
||||||
from util.security.registry_jwt import generate_jwt_object, build_context_and_subject
|
from util.security.instancekeys import InstanceKeys
|
||||||
|
from util.security.registry_jwt import generate_bearer_token, build_context_and_subject
|
||||||
from util import get_app_url
|
from util import get_app_url
|
||||||
|
|
||||||
|
|
||||||
|
@ -43,6 +44,7 @@ class SecurityScannerAPI(object):
|
||||||
|
|
||||||
self._app = app
|
self._app = app
|
||||||
self._config = config
|
self._config = config
|
||||||
|
self._instance_keys = InstanceKeys(app)
|
||||||
self._client = client or config['HTTPCLIENT']
|
self._client = client or config['HTTPCLIENT']
|
||||||
self._storage = storage
|
self._storage = storage
|
||||||
self._default_storage_locations = config['DISTRIBUTED_STORAGE_PREFERENCE']
|
self._default_storage_locations = config['DISTRIBUTED_STORAGE_PREFERENCE']
|
||||||
|
@ -80,9 +82,10 @@ class SecurityScannerAPI(object):
|
||||||
'name': repository_and_namespace,
|
'name': repository_and_namespace,
|
||||||
'actions': ['pull'],
|
'actions': ['pull'],
|
||||||
}]
|
}]
|
||||||
auth_jwt = generate_jwt_object(audience, subject, context, access, TOKEN_VALIDITY_LIFETIME_S,
|
|
||||||
self._config)
|
auth_token = generate_bearer_token(audience, subject, context, access,
|
||||||
auth_header = 'Bearer {}'.format(auth_jwt)
|
TOKEN_VALIDITY_LIFETIME_S, self._instance_keys)
|
||||||
|
auth_header = 'Bearer ' + auth_token
|
||||||
|
|
||||||
with self._app.test_request_context('/'):
|
with self._app.test_request_context('/'):
|
||||||
relative_layer_url = url_for('v2.download_blob', repository=repository_and_namespace,
|
relative_layer_url = url_for('v2.download_blob', repository=repository_and_namespace,
|
||||||
|
|
81
util/security/instancekeys.py
Normal file
81
util/security/instancekeys.py
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
from cachetools import lru_cache
|
||||||
|
from data import model
|
||||||
|
from util.expiresdict import ExpiresDict, ExpiresEntry
|
||||||
|
from util.security import jwtutil
|
||||||
|
|
||||||
|
|
||||||
|
class InstanceKeys(object):
|
||||||
|
""" InstanceKeys defines a helper class for interacting with the Quay instance service keys
|
||||||
|
used for JWT signing of registry tokens as well as requests from Quay to other services
|
||||||
|
such as Clair. Each container will have a single registered instance key.
|
||||||
|
"""
|
||||||
|
def __init__(self, app):
|
||||||
|
self.app = app
|
||||||
|
self.instance_keys = ExpiresDict(self._load_instance_keys)
|
||||||
|
self.public_keys = {}
|
||||||
|
|
||||||
|
def clear_cache(self):
|
||||||
|
""" Clears the cache of instance keys. """
|
||||||
|
self.instance_keys = ExpiresDict(self._load_instance_keys)
|
||||||
|
self.public_keys = {}
|
||||||
|
|
||||||
|
def _load_instance_keys(self):
|
||||||
|
# Load all the instance keys.
|
||||||
|
keys = {}
|
||||||
|
for key in model.service_keys.list_service_keys(self.service_name):
|
||||||
|
keys[key.kid] = ExpiresEntry(key, key.expiration_date)
|
||||||
|
|
||||||
|
# Remove any expired or deleted keys from the public keys cache.
|
||||||
|
for key in self.public_keys:
|
||||||
|
if key not in keys:
|
||||||
|
self.public_keys.pop(key)
|
||||||
|
|
||||||
|
return keys
|
||||||
|
|
||||||
|
@property
|
||||||
|
def service_name(self):
|
||||||
|
""" Returns the name of the instance key's service (i.e. 'quay'). """
|
||||||
|
return self.app.config['INSTANCE_SERVICE_KEY_SERVICE']
|
||||||
|
|
||||||
|
@property
|
||||||
|
def service_key_expiration(self):
|
||||||
|
""" Returns the defined expiration for instance service keys, in minutes. """
|
||||||
|
return self.app.config.get('INSTANCE_SERVICE_KEY_EXPIRATION', 120)
|
||||||
|
|
||||||
|
@property
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def local_key_id(self):
|
||||||
|
""" Returns the ID of the local instance service key. """
|
||||||
|
return _load_file_contents(self.app.config['INSTANCE_SERVICE_KEY_KID_LOCATION'])
|
||||||
|
|
||||||
|
@property
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def local_private_key(self):
|
||||||
|
""" Returns the private key of the local instance service key. """
|
||||||
|
return _load_file_contents(self.app.config['INSTANCE_SERVICE_KEY_LOCATION'])
|
||||||
|
|
||||||
|
def get_service_key_public_key(self, kid):
|
||||||
|
""" Returns the public key associated with the given instance service key or None if none. """
|
||||||
|
|
||||||
|
# Note: We do the lookup via instance_keys *first* to ensure that if a key has expired, we
|
||||||
|
# don't use the entry in the public key cache.
|
||||||
|
service_key = self.instance_keys.get(kid)
|
||||||
|
if service_key is None:
|
||||||
|
# Remove the kid from the cache just to be sure.
|
||||||
|
self.public_keys.pop(kid, None)
|
||||||
|
return None
|
||||||
|
|
||||||
|
public_key = self.public_keys.get(kid)
|
||||||
|
if public_key is not None:
|
||||||
|
return public_key
|
||||||
|
|
||||||
|
# Convert the JWK into a public key and cache it (since the conversion can take > 200ms).
|
||||||
|
public_key = jwtutil.jwk_dict_to_public_key(service_key.jwk)
|
||||||
|
self.public_keys[kid] = public_key
|
||||||
|
return public_key
|
||||||
|
|
||||||
|
|
||||||
|
def _load_file_contents(path):
|
||||||
|
""" Returns the contents of the specified file path. """
|
||||||
|
with open(path) as f:
|
||||||
|
return f.read()
|
|
@ -1,3 +1,5 @@
|
||||||
|
import re
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from jwt import PyJWT
|
from jwt import PyJWT
|
||||||
from jwt.exceptions import (
|
from jwt.exceptions import (
|
||||||
|
@ -5,8 +7,19 @@ from jwt.exceptions import (
|
||||||
ImmatureSignatureError, InvalidIssuedAtError, InvalidIssuerError, MissingRequiredClaimError
|
ImmatureSignatureError, InvalidIssuedAtError, InvalidIssuerError, MissingRequiredClaimError
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicNumbers
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
|
||||||
|
from jwkest.jwk import keyrep, RSAKey, ECKey
|
||||||
|
|
||||||
|
|
||||||
|
# TOKEN_REGEX defines a regular expression for matching JWT bearer tokens.
|
||||||
|
TOKEN_REGEX = re.compile(r'\ABearer (([a-zA-Z0-9+\-_/]+\.)+[a-zA-Z0-9+\-_/]+)\Z')
|
||||||
|
|
||||||
|
|
||||||
class StrictJWT(PyJWT):
|
class StrictJWT(PyJWT):
|
||||||
|
""" StrictJWT defines a JWT decoder with extra checks. """
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_default_options():
|
def _get_default_options():
|
||||||
# Weird syntax to call super on a staticmethod
|
# Weird syntax to call super on a staticmethod
|
||||||
|
@ -53,3 +66,16 @@ def exp_max_s_option(max_exp_s):
|
||||||
|
|
||||||
|
|
||||||
decode = StrictJWT().decode
|
decode = StrictJWT().decode
|
||||||
|
|
||||||
|
|
||||||
|
def jwk_dict_to_public_key(jwk):
|
||||||
|
""" Converts the specified JWK into a public key. """
|
||||||
|
jwkest_key = keyrep(jwk)
|
||||||
|
if isinstance(jwkest_key, RSAKey):
|
||||||
|
pycrypto_key = jwkest_key.key
|
||||||
|
return RSAPublicNumbers(e=pycrypto_key.e, n=pycrypto_key.n).public_key(default_backend())
|
||||||
|
elif isinstance(jwkest_key, ECKey):
|
||||||
|
x, y = jwkest_key.get_key()
|
||||||
|
return EllipticCurvePublicNumbers(x, y, jwkest_key.curve).public_key(default_backend())
|
||||||
|
|
||||||
|
raise Exception('Unsupported kind of JWK: %s', str(type(jwkest_key)))
|
|
@ -1,17 +1,80 @@
|
||||||
import time
|
import time
|
||||||
import jwt
|
import jwt
|
||||||
|
import logging
|
||||||
|
|
||||||
from cachetools import lru_cache
|
from util.security import jwtutil
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
ANONYMOUS_SUB = '(anonymous)'
|
ANONYMOUS_SUB = '(anonymous)'
|
||||||
|
ALGORITHM = 'RS256'
|
||||||
|
|
||||||
|
|
||||||
def generate_jwt_object(audience, subject, context, access, lifetime_s, app_config):
|
class InvalidBearerTokenException(Exception):
|
||||||
""" Generates a compact encoded JWT with the values specified.
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def decode_bearer_token(bearer_token, instance_keys):
|
||||||
|
""" decode_bearer_token decodes the given bearer token that contains both a Key ID as well as the
|
||||||
|
encoded JWT and returns the decoded and validated JWT. On any error, raises an
|
||||||
|
InvalidBearerTokenException with the reason for failure.
|
||||||
"""
|
"""
|
||||||
|
app_config = instance_keys.app.config
|
||||||
|
|
||||||
|
# Extract the jwt token from the header
|
||||||
|
match = jwtutil.TOKEN_REGEX.match(bearer_token)
|
||||||
|
if match is None:
|
||||||
|
raise InvalidBearerTokenException('Invalid bearer token format')
|
||||||
|
|
||||||
|
encoded_jwt = match.group(1)
|
||||||
|
logger.debug('encoded JWT: %s', encoded_jwt)
|
||||||
|
|
||||||
|
# Decode the key ID.
|
||||||
|
headers = jwt.get_unverified_header(encoded_jwt)
|
||||||
|
kid = headers.get('kid', None)
|
||||||
|
if kid is None:
|
||||||
|
logger.error('Missing kid header on encoded JWT: %s', encoded_jwt)
|
||||||
|
raise InvalidBearerTokenException('Missing kid header')
|
||||||
|
|
||||||
|
# Find the matching public key.
|
||||||
|
public_key = instance_keys.get_service_key_public_key(kid)
|
||||||
|
if public_key is None:
|
||||||
|
logger.error('Could not find requested service key %s', kid)
|
||||||
|
raise InvalidBearerTokenException('Unknown service key')
|
||||||
|
|
||||||
|
# Load the JWT returned.
|
||||||
|
try:
|
||||||
|
expected_issuer = instance_keys.service_name
|
||||||
|
audience = app_config['SERVER_HOSTNAME']
|
||||||
|
max_signed_s = app_config.get('REGISTRY_JWT_AUTH_MAX_FRESH_S', 3660)
|
||||||
|
|
||||||
|
max_exp = jwtutil.exp_max_s_option(max_signed_s)
|
||||||
|
payload = jwtutil.decode(encoded_jwt, public_key, algorithms=[ALGORITHM], audience=audience,
|
||||||
|
issuer=expected_issuer, options=max_exp)
|
||||||
|
except jwtutil.InvalidTokenError as ite:
|
||||||
|
logger.exception('Invalid token reason: %s', ite)
|
||||||
|
raise InvalidBearerTokenException(ite)
|
||||||
|
|
||||||
|
if not 'sub' in payload:
|
||||||
|
raise InvalidBearerTokenException('Missing sub field in JWT')
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def generate_bearer_token(audience, subject, context, access, lifetime_s, instance_keys):
|
||||||
|
""" Generates a registry bearer token (without the 'Bearer ' portion) based on the given
|
||||||
|
information.
|
||||||
|
"""
|
||||||
|
return _generate_jwt_object(audience, subject, context, access, lifetime_s,
|
||||||
|
instance_keys.service_name, instance_keys.local_key_id,
|
||||||
|
instance_keys.local_private_key)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_jwt_object(audience, subject, context, access, lifetime_s, issuer, key_id,
|
||||||
|
private_key):
|
||||||
|
""" Generates a compact encoded JWT with the values specified. """
|
||||||
token_data = {
|
token_data = {
|
||||||
'iss': app_config['JWT_AUTH_TOKEN_ISSUER'],
|
'iss': issuer,
|
||||||
'aud': audience,
|
'aud': audience,
|
||||||
'nbf': int(time.time()),
|
'nbf': int(time.time()),
|
||||||
'iat': int(time.time()),
|
'iat': int(time.time()),
|
||||||
|
@ -21,15 +84,11 @@ def generate_jwt_object(audience, subject, context, access, lifetime_s, app_conf
|
||||||
'context': context,
|
'context': context,
|
||||||
}
|
}
|
||||||
|
|
||||||
certificate = _load_certificate_bytes(app_config['JWT_AUTH_CERTIFICATE_PATH'])
|
|
||||||
|
|
||||||
token_headers = {
|
token_headers = {
|
||||||
'x5c': [certificate],
|
'kid': key_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
private_key = _load_private_key(app_config['JWT_AUTH_PRIVATE_KEY_PATH'])
|
return jwt.encode(token_data, private_key, ALGORITHM, headers=token_headers)
|
||||||
|
|
||||||
return jwt.encode(token_data, private_key, 'RS256', headers=token_headers)
|
|
||||||
|
|
||||||
|
|
||||||
def build_context_and_subject(user, token, oauthtoken):
|
def build_context_and_subject(user, token, oauthtoken):
|
||||||
|
@ -64,14 +123,3 @@ def build_context_and_subject(user, token, oauthtoken):
|
||||||
return (context, ANONYMOUS_SUB)
|
return (context, ANONYMOUS_SUB)
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
|
||||||
def _load_certificate_bytes(certificate_file_path):
|
|
||||||
with open(certificate_file_path) as cert_file:
|
|
||||||
cert_lines = cert_file.readlines()[1:-1]
|
|
||||||
return ''.join([cert_line.rstrip('\n') for cert_line in cert_lines])
|
|
||||||
|
|
||||||
|
|
||||||
@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()
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from app import app
|
from app import app, instance_keys
|
||||||
from data.model.service_keys import set_key_expiration
|
from data.model.service_keys import set_key_expiration
|
||||||
from workers.worker import Worker
|
from workers.worker import Worker
|
||||||
|
|
||||||
|
@ -17,14 +17,9 @@ class ServiceKeyWorker(Worker):
|
||||||
"""
|
"""
|
||||||
Refreshes active service keys so they don't get garbage collected.
|
Refreshes active service keys so they don't get garbage collected.
|
||||||
"""
|
"""
|
||||||
with open("/conf/quay.kid") as f:
|
expiration = timedelta(minutes=instance_keys.service_key_expiration)
|
||||||
kid = f.read()
|
|
||||||
|
|
||||||
minutes_until_expiration = app.config.get('INSTANCE_SERVICE_KEY_EXPIRATION', 120)
|
|
||||||
expiration = timedelta(minutes=minutes_until_expiration)
|
|
||||||
|
|
||||||
logger.debug('Starting refresh of automatic service keys')
|
logger.debug('Starting refresh of automatic service keys')
|
||||||
set_key_expiration(kid, datetime.now() + expiration)
|
set_key_expiration(instance_keys.local_key_id, datetime.now() + expiration)
|
||||||
logger.debug('Finished refresh of automatic service keys')
|
logger.debug('Finished refresh of automatic service keys')
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
Reference in a new issue