Add support for an external JWT-based authentication system
This authentication system hits two HTTP endpoints to check and verify the existence of users: Existance endpoint: GET http://endpoint/ with Authorization: Basic (username:) => Returns 200 if the username/email exists, 4** otherwise Verification endpoint: GET http://endpoint/ with Authorization: Basic (username:password) => Returns 200 and a signed JWT with the user's username and email address if the username+password validates, 4** otherwise with the body containing an optional error message The JWT produced by the endpoint must be issued with an issuer matching that configured in the config.yaml, and the audience must be "quay.io/jwtauthn". The JWT is signed using a private key and then validated on the Quay.io side with the associated public key, found as "jwt-authn.cert" in the conf/stack directory.
This commit is contained in:
parent
42da017d69
commit
8aac3fd86e
10 changed files with 417 additions and 38 deletions
143
data/users.py
143
data/users.py
|
@ -5,11 +5,14 @@ import itertools
|
|||
import uuid
|
||||
import struct
|
||||
import os
|
||||
import urllib
|
||||
import jwt
|
||||
|
||||
from util.aes import AESCipher
|
||||
from util.validation import generate_valid_usernames
|
||||
from data import model
|
||||
from collections import namedtuple
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if os.environ.get('LDAP_DEBUG') == '1':
|
||||
|
@ -20,6 +23,115 @@ if os.environ.get('LDAP_DEBUG') == '1':
|
|||
|
||||
logger.addHandler(ch)
|
||||
|
||||
|
||||
def _get_federated_user(username, email, federated_service, create_new_user):
|
||||
db_user = model.verify_federated_login(federated_service, username)
|
||||
if not db_user:
|
||||
if not create_new_user:
|
||||
return (None, 'Invalid user')
|
||||
|
||||
# We must create the user in our db
|
||||
valid_username = None
|
||||
for valid_username in generate_valid_usernames(username):
|
||||
if model.is_username_unique(valid_username):
|
||||
break
|
||||
|
||||
if not valid_username:
|
||||
logger.error('Unable to pick a username for user: %s', username)
|
||||
return (None, 'Unable to pick a username. Please report this to your administrator.')
|
||||
|
||||
db_user = model.create_federated_user(valid_username, email, federated_service, username,
|
||||
set_password_notification=False)
|
||||
else:
|
||||
# Update the db attributes from ldap
|
||||
db_user.email = email
|
||||
db_user.save()
|
||||
|
||||
return (db_user, None)
|
||||
|
||||
|
||||
class JWTAuthUsers(object):
|
||||
""" Delegates authentication to a REST endpoint that returns JWTs. """
|
||||
PUBLIC_KEY_FILENAME = 'jwt-authn.cert'
|
||||
|
||||
def __init__(self, exists_url, verify_url, issuer, public_key_path=None):
|
||||
from app import OVERRIDE_CONFIG_DIRECTORY
|
||||
|
||||
self.verify_url = verify_url
|
||||
self.exists_url = exists_url
|
||||
self.issuer = issuer
|
||||
|
||||
default_key_path = os.path.join(OVERRIDE_CONFIG_DIRECTORY, JWTAuthUsers.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_DIRECTORY))
|
||||
|
||||
raise Exception(error_message)
|
||||
|
||||
with open(public_key_path) as public_key_file:
|
||||
self.public_key = public_key_file.read()
|
||||
|
||||
def verify_user(self, username_or_email, password, create_new_user=True):
|
||||
from app import app
|
||||
client = app.config['HTTPCLIENT']
|
||||
result = client.get(self.verify_url, timeout=2, auth=(username_or_email, password))
|
||||
|
||||
if result.status_code != 200:
|
||||
return (None, result.text or 'Invalid username or password')
|
||||
|
||||
try:
|
||||
result_data = json.loads(result.text)
|
||||
except ValueError:
|
||||
raise Exception('Returned JWT Authentication body does not contain JSON')
|
||||
|
||||
# Load the JWT returned.
|
||||
encoded = result_data.get('token', '')
|
||||
try:
|
||||
payload = jwt.decode(encoded, self.public_key, algorithms=['RS256'],
|
||||
audience='quay.io/jwtauthn', issuer=self.issuer)
|
||||
except jwt.InvalidTokenError:
|
||||
logger.exception('Exception when decoding returned JWT')
|
||||
return (None, 'Invalid username or password')
|
||||
|
||||
if not 'sub' in payload:
|
||||
raise Exception('Missing username field in JWT')
|
||||
|
||||
if not 'email' in payload:
|
||||
raise Exception('Missing email 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')
|
||||
|
||||
# Parse out the username and email.
|
||||
return _get_federated_user(payload['sub'], payload['email'], 'jwtauthn', create_new_user)
|
||||
|
||||
def user_exists(self, username):
|
||||
from app import app
|
||||
client = app.config['HTTPCLIENT']
|
||||
result = client.get(self.exists_url, auth=(username, ''), timeout=2)
|
||||
if result.status_code / 500 >= 1:
|
||||
raise Exception('Internal Error when trying to check if user exists: %s' % result.text)
|
||||
|
||||
return result.status_code == 200
|
||||
|
||||
def confirm_existing_user(self, username, password):
|
||||
db_user = model.get_user(username)
|
||||
if not db_user:
|
||||
return (None, 'Invalid user')
|
||||
|
||||
federated_login = model.lookup_federated_login(db_user, 'jwtauthn')
|
||||
if not federated_login:
|
||||
return (None, 'Invalid user')
|
||||
|
||||
return self.verify_user(federated_login.service_ident, password, create_new_user=False)
|
||||
|
||||
|
||||
class DatabaseUsers(object):
|
||||
def verify_user(self, username_or_email, password):
|
||||
""" Simply delegate to the model implementation. """
|
||||
|
@ -189,30 +301,7 @@ class LDAPUsers(object):
|
|||
|
||||
username = found_response[self._uid_attr][0].decode('utf-8')
|
||||
email = found_response[self._email_attr][0]
|
||||
db_user = model.verify_federated_login('ldap', username)
|
||||
|
||||
if not db_user:
|
||||
if not create_new_user:
|
||||
return (None, 'Invalid user')
|
||||
|
||||
# We must create the user in our db
|
||||
valid_username = None
|
||||
for valid_username in generate_valid_usernames(username):
|
||||
if model.is_username_unique(valid_username):
|
||||
break
|
||||
|
||||
if not valid_username:
|
||||
logger.error('Unable to pick a username for user: %s', username)
|
||||
return (None, 'Unable to pick a username. Please report this to your administrator.')
|
||||
|
||||
db_user = model.create_federated_user(valid_username, email, 'ldap', username,
|
||||
set_password_notification=False)
|
||||
else:
|
||||
# Update the db attributes from ldap
|
||||
db_user.email = email
|
||||
db_user.save()
|
||||
|
||||
return (db_user, None)
|
||||
return _get_federated_user(username, email, 'ldap', create_new_user)
|
||||
|
||||
def user_exists(self, username):
|
||||
found_user = self._ldap_user_search(username)
|
||||
|
@ -243,7 +332,11 @@ class UserAuthentication(object):
|
|||
email_attr = app.config.get('LDAP_EMAIL_ATTR', 'mail')
|
||||
|
||||
users = LDAPUsers(ldap_uri, base_dn, admin_dn, admin_passwd, user_rdn, uid_attr, email_attr)
|
||||
|
||||
elif authentication_type == 'JWT':
|
||||
verify_url = app.config.get('JWT_VERIFY_ENDPOINT')
|
||||
exists_url = app.config.get('JWT_EXISTS_ENDPOINT')
|
||||
issuer = app.config.get('JWT_AUTH_ISSUER')
|
||||
users = JWTAuthUsers(exists_url, verify_url, issuer)
|
||||
else:
|
||||
raise RuntimeError('Unknown authentication type: %s' % authentication_type)
|
||||
|
||||
|
|
Reference in a new issue