Merge pull request #1905 from coreos-inc/external-auth-search

Add support for entity search against external auth users not yet linked
This commit is contained in:
josephschorr 2016-10-27 16:06:42 -04:00 committed by GitHub
commit 934cdecbd6
16 changed files with 817 additions and 100 deletions

View file

@ -65,6 +65,9 @@ class BaseAvatar(object):
def get_data_for_org(self, org): def get_data_for_org(self, org):
return self.get_data(org.username, org.email, 'org') return self.get_data(org.username, org.email, 'org')
def get_data_for_external_user(self, external_user):
return self.get_data(external_user.username, external_user.email, 'user')
def get_data(self, name, email_or_id, kind='user'): def get_data(self, name, email_or_id, kind='user'):
""" Computes and returns the full data block for the avatar: """ Computes and returns the full data block for the avatar:
{ {

View file

@ -499,8 +499,7 @@ def get_matching_user_namespaces(namespace_prefix, username, limit=10):
return _basequery.filter_to_repos_for_user(base_query, username).limit(limit) return _basequery.filter_to_repos_for_user(base_query, username).limit(limit)
def get_matching_users(username_prefix, robot_namespace=None, def get_matching_users(username_prefix, robot_namespace=None, organization=None, limit=20):
organization=None):
user_search = _basequery.prefix_search(User.username, username_prefix) user_search = _basequery.prefix_search(User.username, username_prefix)
direct_user_query = (user_search & (User.organization == False) & (User.robot == False)) direct_user_query = (user_search & (User.organization == False) & (User.robot == False))
@ -516,23 +515,24 @@ def get_matching_users(username_prefix, robot_namespace=None,
if organization: if organization:
query = (query query = (query
.select(User.username, User.email, User.robot, fn.Sum(Team.id)) .select(User.id, User.username, User.email, User.robot, fn.Sum(Team.id))
.join(TeamMember, JOIN_LEFT_OUTER) .join(TeamMember, JOIN_LEFT_OUTER)
.join(Team, JOIN_LEFT_OUTER, on=((Team.id == TeamMember.team) & .join(Team, JOIN_LEFT_OUTER, on=((Team.id == TeamMember.team) &
(Team.organization == organization)))) (Team.organization == organization))))
class MatchingUserResult(object): class MatchingUserResult(object):
def __init__(self, *args): def __init__(self, *args):
self.username = args[0] self.id = args[0]
self.email = args[1] self.username = args[1]
self.robot = args[2] self.email = args[2]
self.robot = args[3]
if organization: if organization:
self.is_org_member = (args[3] != None) self.is_org_member = (args[3] != None)
else: else:
self.is_org_member = None self.is_org_member = None
return (MatchingUserResult(*args) for args in query.tuples().limit(10)) return (MatchingUserResult(*args) for args in query.tuples().limit(limit))
def verify_user(username_or_email, password): def verify_user(username_or_email, password):
@ -749,3 +749,16 @@ def get_region_locations(user):
""" Returns the locations defined as preferred storage for the given user. """ """ Returns the locations defined as preferred storage for the given user. """
query = UserRegion.select().join(ImageStorageLocation).where(UserRegion.user == user) query = UserRegion.select().join(ImageStorageLocation).where(UserRegion.user == user)
return set([region.location.name for region in query]) return set([region.location.name for region in query])
def get_federated_logins(user_ids, service_name):
""" Returns all federated logins for the given user ids under the given external service. """
if not user_ids:
return []
return (FederatedLogin
.select()
.join(User)
.switch(FederatedLogin)
.join(LoginService)
.where(FederatedLogin.user << user_ids,
LoginService.name == service_name))

View file

@ -9,7 +9,7 @@ from data import model
from data.users.database import DatabaseUsers from data.users.database import DatabaseUsers
from data.users.externalldap import LDAPUsers from data.users.externalldap import LDAPUsers
from data.users.externaljwt import ExternalJWTAuthN from data.users.externaljwt import ExternalJWTAuthN
from data.users.keystone import KeystoneUsers from data.users.keystone import get_keystone_users
from util.security.aes import AESCipher from util.security.aes import AESCipher
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -54,17 +54,23 @@ def get_users_handler(config, config_provider, override_config_dir):
verify_url = config.get('JWT_VERIFY_ENDPOINT') verify_url = config.get('JWT_VERIFY_ENDPOINT')
issuer = config.get('JWT_AUTH_ISSUER') issuer = config.get('JWT_AUTH_ISSUER')
max_fresh_s = config.get('JWT_AUTH_MAX_FRESH_S', 300) max_fresh_s = config.get('JWT_AUTH_MAX_FRESH_S', 300)
return ExternalJWTAuthN(verify_url, issuer, override_config_dir, config['HTTPCLIENT'],
max_fresh_s) query_url = config.get('JWT_QUERY_ENDPOINT', None)
getuser_url = config.get('JWT_GETUSER_ENDPOINT', None)
return ExternalJWTAuthN(verify_url, query_url, getuser_url, issuer, override_config_dir,
config['HTTPCLIENT'], max_fresh_s)
if authentication_type == 'Keystone': if authentication_type == 'Keystone':
auth_url = config.get('KEYSTONE_AUTH_URL') auth_url = config.get('KEYSTONE_AUTH_URL')
auth_version = int(config.get('KEYSTONE_AUTH_VERSION', 2))
timeout = config.get('KEYSTONE_AUTH_TIMEOUT') timeout = config.get('KEYSTONE_AUTH_TIMEOUT')
keystone_admin_username = config.get('KEYSTONE_ADMIN_USERNAME') keystone_admin_username = config.get('KEYSTONE_ADMIN_USERNAME')
keystone_admin_password = config.get('KEYSTONE_ADMIN_PASSWORD') keystone_admin_password = config.get('KEYSTONE_ADMIN_PASSWORD')
keystone_admin_tenant = config.get('KEYSTONE_ADMIN_TENANT') keystone_admin_tenant = config.get('KEYSTONE_ADMIN_TENANT')
return KeystoneUsers(auth_url, keystone_admin_username, keystone_admin_password,
keystone_admin_tenant, timeout) return get_keystone_users(auth_version, auth_url, keystone_admin_username,
keystone_admin_password, keystone_admin_tenant, timeout)
raise RuntimeError('Unknown authentication type: %s' % authentication_type) raise RuntimeError('Unknown authentication type: %s' % authentication_type)
@ -139,6 +145,31 @@ class UserAuthentication(object):
return data.get('password', encrypted) return data.get('password', encrypted)
@property
def federated_service(self):
""" Returns the name of the federated service for the auth system. If none, should return None.
"""
return self.state.federated_service
def query_users(self, query, limit=20):
""" Performs a lookup against the user system for the specified query. The returned tuple
will be of the form (results, federated_login_id, err_msg). If the method is unsupported,
the results portion of the tuple will be None instead of empty list.
Note that this method can and will return results for users not yet found within the
database; it is the responsibility of the caller to call link_user if they need the
database row for the user system record.
Results will be in the form of objects's with username and email fields.
"""
return self.state.query_users(query, limit)
def link_user(self, username_or_email):
""" Returns a tuple containing the database user record linked to the given username/email
and any error that occurred when trying to link the user.
"""
return self.state.link_user(username_or_email)
def confirm_existing_user(self, username, password): def confirm_existing_user(self, username, password):
""" Verifies that the given password matches to the given DB username. Unlike """ Verifies that the given password matches to the given DB username. Unlike
verify_credentials, this call first translates the DB user via the FederatedLogin table verify_credentials, this call first translates the DB user via the FederatedLogin table

View file

@ -1,6 +1,10 @@
from data import model from data import model
class DatabaseUsers(object): class DatabaseUsers(object):
@property
def federated_service(self):
return None
def verify_credentials(self, username_or_email, password): def verify_credentials(self, username_or_email, password):
""" Simply delegate to the model implementation. """ """ Simply delegate to the model implementation. """
result = model.user.verify_user(username_or_email, password) result = model.user.verify_user(username_or_email, password)
@ -16,3 +20,11 @@ class DatabaseUsers(object):
def confirm_existing_user(self, username, password): def confirm_existing_user(self, username, password):
return self.verify_credentials(username, password) return self.verify_credentials(username, password)
def link_user(self, username_or_email):
""" Never used since all users being added are already, by definition, in the database. """
return (None, 'Unsupported for this authentication system')
def query_users(self, query, limit):
""" No need to implement, as we already query for users directly in the database. """
return (None, '', '')

View file

@ -2,7 +2,7 @@ import logging
import json import json
import os import os
from data.users.federated import FederatedUsers, VerifiedCredentials from data.users.federated import FederatedUsers, UserInformation
from util.security import jwtutil from util.security import jwtutil
@ -13,10 +13,13 @@ class ExternalJWTAuthN(FederatedUsers):
""" Delegates authentication to a REST endpoint that returns JWTs. """ """ Delegates authentication to a REST endpoint that returns JWTs. """
PUBLIC_KEY_FILENAME = 'jwt-authn.cert' PUBLIC_KEY_FILENAME = 'jwt-authn.cert'
def __init__(self, verify_url, issuer, override_config_dir, http_client, max_fresh_s, def __init__(self, verify_url, query_url, getuser_url, issuer, override_config_dir, http_client,
public_key_path=None): max_fresh_s, public_key_path=None):
super(ExternalJWTAuthN, self).__init__('jwtauthn') super(ExternalJWTAuthN, self).__init__('jwtauthn')
self.verify_url = verify_url self.verify_url = verify_url
self.query_url = query_url
self.getuser_url = getuser_url
self.issuer = issuer self.issuer = issuer
self.client = http_client self.client = http_client
self.max_fresh_s = max_fresh_s self.max_fresh_s = max_fresh_s
@ -32,34 +35,80 @@ class ExternalJWTAuthN(FederatedUsers):
with open(public_key_path) as public_key_file: with open(public_key_path) as public_key_file:
self.public_key = public_key_file.read() self.public_key = public_key_file.read()
def verify_credentials(self, username_or_email, password):
result = self.client.get(self.verify_url, timeout=2, auth=(username_or_email, password))
def get_user(self, username_or_email):
if self.getuser_url is None:
return (None, 'No endpoint defined for retrieving user')
(payload, err_msg) = self._execute_call(self.getuser_url, 'quay.io/jwtauthn/getuser',
params=dict(username=username_or_email))
if err_msg is not None:
return (None, err_msg)
if not 'sub' in payload:
raise Exception('Missing sub field in JWT')
if not 'email' in payload:
raise Exception('Missing email field in JWT')
# Parse out the username and email.
user_info = UserInformation(username=payload['sub'], email=payload['email'], id=payload['sub'])
return (user_info, None)
def query_users(self, query, limit=20):
if self.query_url is None:
return (None, self.federated_service, 'No endpoint defined for querying users')
(payload, err_msg) = self._execute_call(self.query_url, 'quay.io/jwtauthn/query',
params=dict(query=query, limit=limit))
if err_msg is not None:
return (None, self.federated_service, err_msg)
query_results = []
for result in payload['results'][0:limit]:
user_info = UserInformation(username=result['username'], email=result['email'],
id=result['username'])
query_results.append(user_info)
return (query_results, self.federated_service, None)
def verify_credentials(self, username_or_email, password):
(payload, err_msg) = self._execute_call(self.verify_url, 'quay.io/jwtauthn',
auth=(username_or_email, password))
if err_msg is not None:
return (None, err_msg)
if not 'sub' in payload:
raise Exception('Missing sub field in JWT')
if not 'email' in payload:
raise Exception('Missing email field in JWT')
user_info = UserInformation(username=payload['sub'], email=payload['email'], id=payload['sub'])
return (user_info, None)
def _execute_call(self, url, aud, auth=None, params=None):
""" Executes a call to the external JWT auth provider. """
result = self.client.get(url, timeout=2, auth=auth, params=params)
if result.status_code != 200: if result.status_code != 200:
return (None, result.text or 'Invalid username or password') return (None, result.text or 'Could not make JWT auth call')
try: try:
result_data = json.loads(result.text) result_data = json.loads(result.text)
except ValueError: except ValueError:
raise Exception('Returned JWT Authentication body does not contain JSON') raise Exception('Returned JWT body for url %s does not contain JSON', url)
# Load the JWT returned. # Load the JWT returned.
encoded = result_data.get('token', '') encoded = result_data.get('token', '')
exp_limit_options = jwtutil.exp_max_s_option(self.max_fresh_s) exp_limit_options = jwtutil.exp_max_s_option(self.max_fresh_s)
try: try:
payload = jwtutil.decode(encoded, self.public_key, algorithms=['RS256'], payload = jwtutil.decode(encoded, self.public_key, algorithms=['RS256'],
audience='quay.io/jwtauthn', issuer=self.issuer, audience=aud, issuer=self.issuer,
options=exp_limit_options) options=exp_limit_options)
return (payload, None)
except jwtutil.InvalidTokenError: except jwtutil.InvalidTokenError:
logger.exception('Exception when decoding returned JWT') logger.exception('Exception when decoding returned JWT for url %s', url)
return (None, 'Invalid username or password') return (None, 'Exception when decoding returned JWT')
if not 'sub' in payload:
raise Exception('Missing username field in JWT')
if not 'email' in payload:
raise Exception('Missing email field in JWT')
# Parse out the username and email.
return (VerifiedCredentials(username=payload['sub'], email=payload['email']), None)

View file

@ -1,9 +1,10 @@
import ldap import ldap
import logging import logging
import os import os
import itertools
from collections import namedtuple from collections import namedtuple
from data.users.federated import FederatedUsers, VerifiedCredentials from data.users.federated import FederatedUsers, UserInformation
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -25,11 +26,11 @@ class LDAPConnection(object):
self._user_dn = user_dn self._user_dn = user_dn
self._user_pw = user_pw self._user_pw = user_pw
self._allow_tls_fallback = allow_tls_fallback self._allow_tls_fallback = allow_tls_fallback
self._conn = None self._conn = None
def __enter__(self): def __enter__(self):
trace_level = 2 if os.environ.get('USERS_DEBUG') == '1' else 0 trace_level = 2 if os.environ.get('USERS_DEBUG') == '1' else 0
self._conn = ldap.initialize(self._ldap_uri, trace_level=trace_level) self._conn = ldap.initialize(self._ldap_uri, trace_level=trace_level)
self._conn.set_option(ldap.OPT_REFERRALS, 1) self._conn.set_option(ldap.OPT_REFERRALS, 1)
@ -43,6 +44,10 @@ class LDAPConnection(object):
def __exit__(self, exc_type, value, tb): def __exit__(self, exc_type, value, tb):
self._conn.unbind_s() self._conn.unbind_s()
def _take(n, iterable):
"Return first n items of the iterable as a list"
return list(itertools.islice(iterable, n))
class LDAPUsers(FederatedUsers): class LDAPUsers(FederatedUsers):
_LDAPResult = namedtuple('LDAPResult', ['dn', 'attrs']) _LDAPResult = namedtuple('LDAPResult', ['dn', 'attrs'])
@ -51,6 +56,7 @@ class LDAPUsers(FederatedUsers):
allow_tls_fallback=False, secondary_user_rdns=None): allow_tls_fallback=False, secondary_user_rdns=None):
super(LDAPUsers, self).__init__('ldap') super(LDAPUsers, self).__init__('ldap')
self._ldap = LDAPConnectionBuilder(ldap_uri, admin_dn, admin_passwd, allow_tls_fallback) self._ldap = LDAPConnectionBuilder(ldap_uri, admin_dn, admin_passwd, allow_tls_fallback)
self._ldap_uri = ldap_uri self._ldap_uri = ldap_uri
self._uid_attr = uid_attr self._uid_attr = uid_attr
@ -109,7 +115,10 @@ class LDAPUsers(FederatedUsers):
logger.exception('LDAP search exception') logger.exception('LDAP search exception')
return (None, 'Username not found') return (None, 'Username not found')
def _ldap_user_search(self, username_or_email): def _ldap_user_search(self, username_or_email, limit=20):
if not username_or_email:
return (None, 'Empty username/email')
# Verify the admin connection works first. We do this here to avoid wrapping # Verify the admin connection works first. We do this here to avoid wrapping
# the entire block in the INVALID CREDENTIALS check. # the entire block in the INVALID CREDENTIALS check.
try: try:
@ -130,22 +139,72 @@ class LDAPUsers(FederatedUsers):
return (None, err_msg) return (None, err_msg)
logger.debug('Found matching pairs: %s', pairs) logger.debug('Found matching pairs: %s', pairs)
results = [LDAPUsers._LDAPResult(*pair) for pair in pairs] results = [LDAPUsers._LDAPResult(*pair) for pair in _take(limit, pairs)]
# Filter out pairs without DNs. Some LDAP impls will return such # Filter out pairs without DNs. Some LDAP impls will return such pairs.
# pairs.
with_dns = [result for result in results if result.dn] with_dns = [result for result in results if result.dn]
if len(with_dns) < 1: return (with_dns, None)
return (None, 'Username not found')
# If we have found a single pair, then return it. def _ldap_single_user_search(self, username_or_email):
if len(with_dns) == 1: with_dns, err_msg = self._ldap_user_search(username_or_email)
return (with_dns[0], None) if err_msg is not None:
return (None, err_msg)
# Otherwise, there are multiple pairs with DNs, so find the one with the mail # Make sure we have at least one result.
# attribute (if any). if len(with_dns) < 1:
with_mail = [result for result in results if result.attrs.get(self._email_attr)] return (None, 'Username not found')
return (with_mail[0] if with_mail else with_dns[0], None)
# If we have found a single pair, then return it.
if len(with_dns) == 1:
return (with_dns[0], None)
# Otherwise, there are multiple pairs with DNs, so find the one with the mail
# attribute (if any).
with_mail = [result for result in with_dns if result.attrs.get(self._email_attr)]
return (with_mail[0] if with_mail else with_dns[0], None)
def _credential_for_user(self, response):
if not response.get(self._uid_attr):
return (None, 'Missing uid field "%s" in user record' % self._uid_attr)
if not response.get(self._email_attr):
return (None, 'Missing mail field "%s" in user record' % self._email_attr)
username = response[self._uid_attr][0].decode('utf-8')
email = response[self._email_attr][0]
return (UserInformation(username=username, email=email, id=username), None)
def get_user(self, username_or_email):
""" Looks up a username or email in LDAP. """
logger.debug('Looking up LDAP username or email %s', username_or_email)
(found_user, err_msg) = self._ldap_single_user_search(username_or_email)
if err_msg is not None:
return (None, err_msg)
logger.debug('Found user for LDAP username or email %s', username_or_email)
_, found_response = found_user
return self._credential_for_user(found_response)
def query_users(self, query, limit=20):
""" Queries LDAP for matching users. """
if not query:
return (None, 'Empty query')
logger.debug('Got query %s with limit %s', query, limit)
(results, err_msg) = self._ldap_user_search(query + '*', limit=limit)
if err_msg is not None:
return (None, err_msg)
final_results = []
for result in results[0:limit]:
credentials, err_msg = self._credential_for_user(result.attrs)
if err_msg is not None:
continue
final_results.append(credentials)
logger.debug('For query %s found results %s', query, final_results)
return (final_results, None)
def verify_credentials(self, username_or_email, password): def verify_credentials(self, username_or_email, password):
""" Verify the credentials with LDAP. """ """ Verify the credentials with LDAP. """
@ -153,7 +212,7 @@ class LDAPUsers(FederatedUsers):
if not password: if not password:
return (None, 'Anonymous binding not allowed') return (None, 'Anonymous binding not allowed')
(found_user, err_msg) = self._ldap_user_search(username_or_email) (found_user, err_msg) = self._ldap_single_user_search(username_or_email)
if found_user is None: if found_user is None:
return (None, err_msg) return (None, err_msg)
@ -183,14 +242,4 @@ class LDAPUsers(FederatedUsers):
logger.exception('Invalid LDAP credentials') logger.exception('Invalid LDAP credentials')
return (None, 'Invalid password') return (None, 'Invalid password')
# Now check if we have a federated login for this user return self._credential_for_user(found_response)
if not found_response.get(self._uid_attr):
return (None, 'Missing uid field "%s" in user record' % self._uid_attr)
if not found_response.get(self._email_attr):
return (None, 'Missing mail field "%s" in user record' % self._email_attr)
username = found_response[self._uid_attr][0].decode('utf-8')
email = found_response[self._email_attr][0]
return (VerifiedCredentials(username=username, email=email), None)

View file

@ -7,7 +7,7 @@ from util.validation import generate_valid_usernames
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
VerifiedCredentials = namedtuple('VerifiedCredentials', ['username', 'email']) UserInformation = namedtuple('UserInformation', ['username', 'email', 'id'])
class FederatedUsers(object): class FederatedUsers(object):
""" Base class for all federated users systems. """ """ Base class for all federated users systems. """
@ -15,11 +15,26 @@ class FederatedUsers(object):
def __init__(self, federated_service): def __init__(self, federated_service):
self._federated_service = federated_service self._federated_service = federated_service
@property
def federated_service(self):
return self._federated_service
def get_user(self, username_or_email):
""" Retrieves the user with the given username or email, returning a tuple containing
a UserInformation (if success) and the error message (on failure).
"""
raise NotImplementedError
def verify_credentials(self, username_or_email, password): def verify_credentials(self, username_or_email, password):
""" Verifies the given credentials against the backing federated service, returning """ Verifies the given credentials against the backing federated service, returning
a tuple containing a VerifiedCredentials (if success) and the error message (if failed). """ a tuple containing a UserInformation (on success) and the error message (on failure).
"""
raise NotImplementedError raise NotImplementedError
def query_users(self, query, limit=20):
""" If implemented, get_user must be implemented as well. """
return (None, 'Not supported')
def _get_federated_user(self, username, email): def _get_federated_user(self, username, email):
db_user = model.user.verify_federated_login(self._federated_service, username) db_user = model.user.verify_federated_login(self._federated_service, username)
if not db_user: if not db_user:
@ -34,7 +49,8 @@ class FederatedUsers(object):
return (None, 'Unable to pick a username. Please report this to your administrator.') return (None, 'Unable to pick a username. Please report this to your administrator.')
db_user = model.user.create_federated_user(valid_username, email, self._federated_service, db_user = model.user.create_federated_user(valid_username, email, self._federated_service,
username, set_password_notification=False) username,
set_password_notification=False)
else: else:
# Update the db attributes from the federated service. # Update the db attributes from the federated service.
db_user.email = email db_user.email = email
@ -42,6 +58,13 @@ class FederatedUsers(object):
return (db_user, None) return (db_user, None)
def link_user(self, username_or_email):
(credentials, err_msg) = self.get_user(username_or_email)
if credentials is None:
return (None, err_msg)
return self._get_federated_user(credentials.username, credentials.email)
def verify_and_link_user(self, username_or_email, password): def verify_and_link_user(self, username_or_email, password):
""" Verifies the given credentials and, if valid, creates/links a database user to the """ Verifies the given credentials and, if valid, creates/links a database user to the
associated federated service. associated federated service.

View file

@ -1,19 +1,34 @@
import logging import logging
import os import os
import itertools
from keystoneclient.v2_0 import client as kclient from keystoneclient.v2_0 import client as kclient
from keystoneclient.v3 import client as kv3client
from keystoneclient.exceptions import AuthorizationFailure as KeystoneAuthorizationFailure from keystoneclient.exceptions import AuthorizationFailure as KeystoneAuthorizationFailure
from keystoneclient.exceptions import Unauthorized as KeystoneUnauthorized from keystoneclient.exceptions import Unauthorized as KeystoneUnauthorized
from data.users.federated import FederatedUsers, VerifiedCredentials from data.users.federated import FederatedUsers, UserInformation
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_TIMEOUT = 10 # seconds DEFAULT_TIMEOUT = 10 # seconds
class KeystoneUsers(FederatedUsers): def _take(n, iterable):
""" Delegates authentication to OpenStack Keystone. """ "Return first n items of the iterable as a list"
return list(itertools.islice(iterable, n))
def get_keystone_users(auth_version, auth_url, admin_username, admin_password, admin_tenant,
timeout=None):
if auth_version == 3:
return KeystoneV3Users(auth_url, admin_username, admin_password, admin_tenant, timeout)
else:
return KeystoneV2Users(auth_url, admin_username, admin_password, admin_tenant, timeout)
class KeystoneV2Users(FederatedUsers):
""" Delegates authentication to OpenStack Keystone V2. """
def __init__(self, auth_url, admin_username, admin_password, admin_tenant, timeout=None): def __init__(self, auth_url, admin_username, admin_password, admin_tenant, timeout=None):
super(KeystoneUsers, self).__init__('keystone') super(KeystoneV2Users, self).__init__('keystone')
self.auth_url = auth_url self.auth_url = auth_url
self.admin_username = admin_username self.admin_username = admin_username
self.admin_password = admin_password self.admin_password = admin_password
@ -43,4 +58,75 @@ class KeystoneUsers(FederatedUsers):
logger.exception('Keystone unauthorized admin') logger.exception('Keystone unauthorized admin')
return (None, 'Keystone admin credentials are invalid: %s' % kut.message) return (None, 'Keystone admin credentials are invalid: %s' % kut.message)
return (VerifiedCredentials(username=username_or_email, email=user.email), None) return (UserInformation(username=username_or_email, email=user.email, id=user_id), None)
def query_users(self, query, limit=20):
return (None, 'Unsupported in Keystone V2')
def get_user(self, username_or_email):
return (None, 'Unsupported in Keystone V2')
class KeystoneV3Users(FederatedUsers):
""" Delegates authentication to OpenStack Keystone V3. """
def __init__(self, auth_url, admin_username, admin_password, admin_tenant, timeout=None):
super(KeystoneV3Users, self).__init__('keystone')
self.auth_url = auth_url
self.admin_username = admin_username
self.admin_password = admin_password
self.admin_tenant = admin_tenant
self.timeout = timeout or DEFAULT_TIMEOUT
self.debug = os.environ.get('USERS_DEBUG') == '1'
def verify_credentials(self, username_or_email, password):
try:
keystone_client = kv3client.Client(username=username_or_email, password=password,
auth_url=self.auth_url, timeout=self.timeout,
debug=self.debug)
user_id = keystone_client.user_id
user = keystone_client.users.get(user_id)
return (self._user_info(user), None)
except KeystoneAuthorizationFailure as kaf:
logger.exception('Keystone auth failure for user: %s', username_or_email)
return (None, kaf.message or 'Invalid username or password')
except KeystoneUnauthorized as kut:
logger.exception('Keystone unauthorized for user: %s', username_or_email)
return (None, kut.message or 'Invalid username or password')
def get_user(self, username_or_email):
users_found, err_msg = self.query_users(username_or_email)
if err_msg is not None:
return (None, err_msg)
if len(users_found) != 1:
return (None, 'Single user not found')
return (users_found[0], None)
@staticmethod
def _user_info(user):
# Because Keystone uses defined attributes...
email = user.email if hasattr(user, 'email') else ''
return UserInformation(user.name, email, user.id)
def query_users(self, query, limit=20):
if len(query) < 3:
return ([], None)
try:
keystone_client = kv3client.Client(username=self.admin_username, password=self.admin_password,
tenant_name=self.admin_tenant, auth_url=self.auth_url,
timeout=self.timeout, debug=self.debug)
found_users = list(_take(limit, keystone_client.users.list(name=query)))
logger.debug('For Keystone query %s found users: %s', query, found_users)
if not found_users:
return ([], None)
return ([self._user_info(user) for user in found_users], None)
except KeystoneAuthorizationFailure as kaf:
logger.exception('Keystone auth failure for admin user for query %s', query)
return (None, kaf.message or 'Invalid admin username or password')
except KeystoneUnauthorized as kut:
logger.exception('Keystone unauthorized for admin user for query %s', query)
return (None, kut.message or 'Invalid admin username or password')

View file

@ -1,14 +1,15 @@
""" Conduct searches against all registry context. """ """ Conduct searches against all registry context. """
from endpoints.api import (ApiResource, parse_args, query_param, truthy_bool, nickname, resource, from endpoints.api import (ApiResource, parse_args, query_param, truthy_bool, nickname, resource,
require_scope, path_param) require_scope, path_param, internal_only, Unauthorized, InvalidRequest,
show_if)
from data import model from data import model
from auth.permissions import (OrganizationMemberPermission, ReadRepositoryPermission, from auth.permissions import (OrganizationMemberPermission, ReadRepositoryPermission,
UserAdminPermission, AdministerOrganizationPermission, UserAdminPermission, AdministerOrganizationPermission,
ReadRepositoryPermission) ReadRepositoryPermission)
from auth.auth_context import get_authenticated_user from auth.auth_context import get_authenticated_user
from auth import scopes from auth import scopes
from app import avatar from app import avatar, authentication
from operator import itemgetter from operator import itemgetter
from stringscore import liquidmetal from stringscore import liquidmetal
from util.names import parse_robot_username from util.names import parse_robot_username
@ -16,6 +17,32 @@ from util.names import parse_robot_username
import anunidecode # Don't listen to pylint's lies. This import is required. import anunidecode # Don't listen to pylint's lies. This import is required.
import math import math
@show_if(authentication.federated_service) # Only enabled for non-DB auth.
@resource('/v1/entities/link/<username>')
@internal_only
class LinkExternalEntity(ApiResource):
""" Resource for linking external entities to internal users. """
@nickname('linkExternalUser')
def post(self, username):
# Only allowed if there is a logged in user.
if not get_authenticated_user():
raise Unauthorized()
# Try to link the user with the given *external* username, to an internal record.
(user, err_msg) = authentication.link_user(username)
if user is None:
raise InvalidRequest(err_msg, payload={'username': username})
return {
'entity': {
'name': user.username,
'kind': 'user',
'is_robot': False,
'avatar': avatar.get_data_for_user(user)
}
}
@resource('/v1/entities/<prefix>') @resource('/v1/entities/<prefix>')
class EntitySearch(ApiResource): class EntitySearch(ApiResource):
""" Resource for searching entities. """ """ Resource for searching entities. """
@ -69,7 +96,22 @@ class EntitySearch(ApiResource):
if admin_permission.can(): if admin_permission.can():
robot_namespace = namespace_name robot_namespace = namespace_name
users = model.user.get_matching_users(prefix, robot_namespace, organization) # Lookup users in the database for the prefix query.
users = model.user.get_matching_users(prefix, robot_namespace, organization, limit=10)
# Lookup users via the user system for the prefix query. We'll filter out any users that
# already exist in the database.
external_users, federated_id, _ = authentication.query_users(prefix, limit=10)
filtered_external_users = []
if external_users and federated_id is not None:
users = list(users)
user_ids = [user.id for user in users]
# Filter the users if any are already found via the database. We do so by looking up all
# the found users in the federated user system.
federated_query = model.user.get_federated_logins(user_ids, federated_id)
found = {result.service_ident for result in federated_query}
filtered_external_users = [user for user in external_users if not user.username in found]
def entity_team_view(team): def entity_team_view(team):
result = { result = {
@ -93,11 +135,20 @@ class EntitySearch(ApiResource):
return user_json return user_json
def external_view(user):
result = {
'name': user.username,
'kind': 'external',
'avatar': avatar.get_data_for_external_user(user)
}
return result
team_data = [entity_team_view(team) for team in teams] team_data = [entity_team_view(team) for team in teams]
user_data = [user_view(user) for user in users] user_data = [user_view(user) for user in users]
external_data = [external_view(user) for user in filtered_external_users]
return { return {
'results': team_data + user_data + org_data 'results': team_data + user_data + org_data + external_data
} }

View file

@ -523,6 +523,15 @@
<!-- Keystone Authentication --> <!-- Keystone Authentication -->
<table class="config-table" ng-if="config.AUTHENTICATION_TYPE == 'Keystone'"> <table class="config-table" ng-if="config.AUTHENTICATION_TYPE == 'Keystone'">
<tr>
<td>Keystone API Version:</td>
<td>
<select ng-model="config.KEYSTONE_AUTH_VERSION">
<option value="2">2.0</option>
<option value="3">V3</option>
</select>
</td>
</tr>
<tr> <tr>
<td>Keystone Authentication URL:</td> <td>Keystone Authentication URL:</td>
<td> <td>
@ -573,20 +582,6 @@
</div> </div>
<table class="config-table" ng-if="config.AUTHENTICATION_TYPE == 'JWT'"> <table class="config-table" ng-if="config.AUTHENTICATION_TYPE == 'JWT'">
<tr>
<td>User Verification Endpoint:</td>
<td>
<span class="config-string-field" binding="config.JWT_VERIFY_ENDPOINT"
pattern="http(s)?://.+"></span>
<div class="help-text">
The URL (starting with http or https) on the JWT authentication server for verifying username and password credentials.
</div>
<div class="help-text" style="margin-top: 6px;">
Credentials will be sent in the <code>Authorization</code> header as Basic Auth, and this endpoint should return <code>200 OK</code> on success (or a <code>4**</code> otherwise).
</div>
</td>
</tr>
<tr> <tr>
<td>Authentication Issuer:</td> <td>Authentication Issuer:</td>
<td> <td>
@ -606,6 +601,50 @@
</div </div
</td> </td>
</tr> </tr>
<tr>
<td>User Verification Endpoint:</td>
<td>
<span class="config-string-field" binding="config.JWT_VERIFY_ENDPOINT"
pattern="http(s)?://.+"></span>
<div class="help-text">
The URL (starting with http or https) on the JWT authentication server for verifying username and password credentials.
</div>
<div class="help-text" style="margin-top: 6px;">
Credentials will be sent in the <code>Authorization</code> header as Basic Auth, and this endpoint should return <code>200 OK</code> on success (or a <code>4**</code> otherwise).
</div>
</td>
</tr>
<tr>
<td>User Query Endpoint:</td>
<td>
<span class="config-string-field" binding="config.JWT_QUERY_ENDPOINT"
pattern="http(s)?://.+" is-optional="true"></span>
<div class="help-text">
The URL (starting with http or https) on the JWT authentication server for looking up
users based on a prefix query. This is optional.
</div>
<div class="help-text" style="margin-top: 6px;">
The prefix query will be sent as a query parameter with name <code>query</code>.
</div>
</td>
</tr>
<tr>
<td>User Lookup Endpoint:</td>
<td>
<span class="config-string-field" binding="config.JWT_GETUSER_ENDPOINT"
pattern="http(s)?://.+" is-optional="true"></span>
<div class="help-text">
The URL (starting with http or https) on the JWT authentication server for looking up
a user by username or email address.
</div>
<div class="help-text" style="margin-top: 6px;">
The username or email address will be sent as a query parameter with name <code>username</code>.
</div>
</td>
</tr>
</table> </table>
<!-- LDAP Authentication --> <!-- LDAP Authentication -->

View file

@ -148,6 +148,18 @@ angular.module('quay').directive('entitySearch', function () {
}; };
$scope.setEntityInternal = function(entity, updateTypeahead) { $scope.setEntityInternal = function(entity, updateTypeahead) {
// If the entity is an external entity, convert it to a known user via an API call.
if (entity.kind == 'external') {
var params = {
'username': entity.name
};
ApiService.linkExternalUser(null, params).then(function(resp) {
$scope.setEntityInternal(resp['entity'], updateTypeahead);
}, ApiService.errorDisplay('Could not link external user'));
return;
}
if (updateTypeahead) { if (updateTypeahead) {
$(input).typeahead('val', $scope.autoClear ? '' : entity.name); $(input).typeahead('val', $scope.autoClear ? '' : entity.name);
} else { } else {
@ -193,7 +205,7 @@ angular.module('quay').directive('entitySearch', function () {
var entity = data.results[i]; var entity = data.results[i];
var found = 'user'; var found = 'user';
if (entity.kind == 'user') { if (entity.kind == 'user' || entity.kind == 'external') {
found = entity.is_robot ? 'robot' : 'user'; found = entity.is_robot ? 'robot' : 'user';
} else if (entity.kind == 'team') { } else if (entity.kind == 'team') {
found = 'team'; found = 'team';
@ -276,6 +288,8 @@ angular.module('quay').directive('entitySearch', function () {
template = '<div class="entity-mini-listing">'; template = '<div class="entity-mini-listing">';
if (datum.entity.kind == 'user' && !datum.entity.is_robot) { if (datum.entity.kind == 'user' && !datum.entity.is_robot) {
template += '<i class="fa fa-user fa-lg"></i>'; template += '<i class="fa fa-user fa-lg"></i>';
} else if (datum.entity.kind == 'external') {
template += '<i class="fa fa-user fa-lg"></i>';
} else if (datum.entity.kind == 'user' && datum.entity.is_robot) { } else if (datum.entity.kind == 'user' && datum.entity.is_robot) {
template += '<i class="fa ci-robot fa-lg"></i>'; template += '<i class="fa ci-robot fa-lg"></i>';
} else if (datum.entity.kind == 'team') { } else if (datum.entity.kind == 'team') {

Binary file not shown.

View file

@ -53,6 +53,58 @@ class JWTAuthTestCase(LiveServerTestCase):
data = base64.b64decode(request.headers['Authorization'][len('Basic '):]) data = base64.b64decode(request.headers['Authorization'][len('Basic '):])
return data.split(':', 1) return data.split(':', 1)
@jwt_app.route('/user/query', methods=['GET'])
def query_users():
query = request.args.get('query')
results = []
for user in users:
if user['name'].startswith(query):
results.append({
'username': user['name'],
'email': user['email'],
})
token_data = {
'iss': 'authy',
'aud': 'quay.io/jwtauthn/query',
'nbf': datetime.utcnow(),
'iat': datetime.utcnow(),
'exp': datetime.utcnow() + timedelta(seconds=60),
'results': results,
}
encoded = jwt.encode(token_data, private_key, 'RS256')
return jsonify({
'token': encoded
})
@jwt_app.route('/user/get', methods=['GET'])
def get_user():
username = request.args.get('username')
if username == 'disabled':
return make_response('User is currently disabled', 401)
for user in users:
if user['name'] == username or user['email'] == username:
token_data = {
'iss': 'authy',
'aud': 'quay.io/jwtauthn/getuser',
'nbf': datetime.utcnow(),
'iat': datetime.utcnow(),
'exp': datetime.utcnow() + timedelta(seconds=60),
'sub': user['name'],
'email': user['email']
}
encoded = jwt.encode(token_data, private_key, 'RS256')
return jsonify({
'token': encoded
})
return make_response('Invalid username or password', 404)
@jwt_app.route('/user/verify', methods=['GET']) @jwt_app.route('/user/verify', methods=['GET'])
def verify_user(): def verify_user():
username, password = _get_basic_auth() username, password = _get_basic_auth()
@ -80,7 +132,7 @@ class JWTAuthTestCase(LiveServerTestCase):
'token': encoded 'token': encoded
}) })
return make_response('', 404) return make_response('Invalid username or password', 404)
jwt_app.config['TESTING'] = True jwt_app.config['TESTING'] = True
return jwt_app return jwt_app
@ -94,7 +146,11 @@ class JWTAuthTestCase(LiveServerTestCase):
self.session = requests.Session() self.session = requests.Session()
self.jwt_auth = ExternalJWTAuthN(self.get_server_url() + '/user/verify', 'authy', '', verify_url = self.get_server_url() + '/user/verify'
query_url = self.get_server_url() + '/user/query'
getuser_url = self.get_server_url() + '/user/get'
self.jwt_auth = ExternalJWTAuthN(verify_url, query_url, getuser_url, 'authy', '',
app.config['HTTPCLIENT'], 300, JWTAuthTestCase.public_key.name) app.config['HTTPCLIENT'], 300, JWTAuthTestCase.public_key.name)
def tearDown(self): def tearDown(self):
@ -142,11 +198,78 @@ class JWTAuthTestCase(LiveServerTestCase):
self.assertIsNotNone(result) self.assertIsNotNone(result)
self.assertEquals('some_neat_user', result.username) self.assertEquals('some_neat_user', result.username)
def test_disabled_user_custom_erorr(self): def test_disabled_user_custom_error(self):
result, error_message = self.jwt_auth.verify_and_link_user('disabled', 'password') result, error_message = self.jwt_auth.verify_and_link_user('disabled', 'password')
self.assertIsNone(result) self.assertIsNone(result)
self.assertEquals('User is currently disabled', error_message) self.assertEquals('User is currently disabled', error_message)
def test_query(self):
# Lookup `cool`.
results, identifier, error_message = self.jwt_auth.query_users('cool')
self.assertIsNone(error_message)
self.assertEquals('jwtauthn', identifier)
self.assertEquals(1, len(results))
self.assertEquals('cooluser', results[0].username)
self.assertEquals('user@domain.com', results[0].email)
# Lookup `some`.
results, identifier, error_message = self.jwt_auth.query_users('some')
self.assertIsNone(error_message)
self.assertEquals('jwtauthn', identifier)
self.assertEquals(1, len(results))
self.assertEquals('some.neat.user', results[0].username)
self.assertEquals('neat@domain.com', results[0].email)
# Lookup `unknown`.
results, identifier, error_message = self.jwt_auth.query_users('unknown')
self.assertIsNone(error_message)
self.assertEquals('jwtauthn', identifier)
self.assertEquals(0, len(results))
def test_get_user(self):
# Lookup cooluser.
result, error_message = self.jwt_auth.get_user('cooluser')
self.assertIsNone(error_message)
self.assertIsNotNone(result)
self.assertEquals('cooluser', result.username)
self.assertEquals('user@domain.com', result.email)
# Lookup some.neat.user.
result, error_message = self.jwt_auth.get_user('some.neat.user')
self.assertIsNone(error_message)
self.assertIsNotNone(result)
self.assertEquals('some.neat.user', result.username)
self.assertEquals('neat@domain.com', result.email)
# Lookup unknown user.
result, error_message = self.jwt_auth.get_user('unknownuser')
self.assertIsNone(result)
def test_link_user(self):
# Link cooluser.
user, error_message = self.jwt_auth.link_user('cooluser')
self.assertIsNone(error_message)
self.assertIsNotNone(user)
self.assertEquals('cooluser', user.username)
# Link again. Should return the same user record.
user_again, _ = self.jwt_auth.link_user('cooluser')
self.assertEquals(user_again.id, user.id)
# Confirm cooluser.
result, _ = self.jwt_auth.confirm_existing_user('cooluser', 'password')
self.assertIsNotNone(result)
self.assertEquals('cooluser', result.username)
def test_link_invalid_user(self):
user, error_message = self.jwt_auth.link_user('invaliduser')
self.assertIsNotNone(error_message)
self.assertIsNone(user)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()

View file

@ -4,16 +4,15 @@ import unittest
import requests import requests
from flask import Flask, request, abort from flask import Flask, request, abort, make_response
from flask_testing import LiveServerTestCase from flask_testing import LiveServerTestCase
from data.users.keystone import KeystoneUsers from data.users.keystone import get_keystone_users
from initdb import setup_database_for_testing, finished_database_for_testing
_PORT_NUMBER = 5001 _PORT_NUMBER = 5001
class KeystoneAuthTestsMixin():
class KeystoneAuthTests(LiveServerTestCase):
maxDiff = None maxDiff = None
def create_app(self): def create_app(self):
@ -44,6 +43,106 @@ class KeystoneAuthTests(LiveServerTestCase):
abort(404) abort(404)
@ks_app.route('/v3/identity/users/<userid>', methods=['GET'])
def getv3user(userid):
for user in users:
if user['username'] == userid:
return json.dumps({
'user': {
"domain_id": "default",
"enabled": True,
"id": user['username'],
"links": {},
"name": user['username'],
"email": user['username'] + '@example.com',
}
})
abort(404)
@ks_app.route('/v3/identity/users', methods=['GET'])
def v3identity():
returned = []
for user in users:
if not request.args.get('name') or user['username'].startswith(request.args.get('name')):
returned.append({
"domain_id": "default",
"enabled": True,
"id": user['username'],
"links": {},
"name": user['username'],
"email": user['username'] + '@example.com',
})
return json.dumps({"users": returned})
@ks_app.route('/v3/auth/tokens', methods=['POST'])
def v3tokens():
creds = request.json['auth']['identity']['password']['user']
for user in users:
if creds['name'] == user['username'] and creds['password'] == user['password']:
data = json.dumps({
"token": {
"methods": [
"password"
],
"roles": [
{
"id": "9fe2ff9ee4384b1894a90878d3e92bab",
"name": "_member_"
},
{
"id": "c703057be878458588961ce9a0ce686b",
"name": "admin"
}
],
"project": {
"domain": {
"id": "default",
"name": "Default"
},
"id": "8538a3f13f9541b28c2620eb19065e45",
"name": "admin"
},
"catalog": [
{
"endpoints": [
{
"url": self.get_server_url() + '/v3/identity',
"region": "RegionOne",
"interface": "admin",
"id": "29beb2f1567642eb810b042b6719ea88"
},
],
"type": "identity",
"id": "bd73972c0e14fb69bae8ff76e112a90",
"name": "keystone"
}
],
"extras": {
},
"user": {
"domain": {
"id": "default",
"name": "Default"
},
"id": user['username'],
"name": "admin"
},
"audit_ids": [
"yRt0UrxJSs6-WYJgwEMMmg"
],
"issued_at": "2014-06-16T22:24:26.089380",
"expires_at": "2020-06-16T23:24:26Z",
}
})
response = make_response(data, 200)
response.headers['X-Subject-Token'] = 'sometoken'
return response
abort(403)
@ks_app.route('/v2.0/auth/tokens', methods=['POST']) @ks_app.route('/v2.0/auth/tokens', methods=['POST'])
def tokens(): def tokens():
@ -89,9 +188,15 @@ class KeystoneAuthTests(LiveServerTestCase):
return ks_app return ks_app
def setUp(self): def setUp(self):
setup_database_for_testing(self)
self.session = requests.Session() self.session = requests.Session()
self.keystone = KeystoneUsers(self.get_server_url() + '/v2.0/auth', 'adminuser', 'adminpass',
'admintenant') def tearDown(self):
finished_database_for_testing(self)
@property
def keystone(self):
raise NotImplementedError
def test_invalid_user(self): def test_invalid_user(self):
(user, _) = self.keystone.verify_credentials('unknownuser', 'password') (user, _) = self.keystone.verify_credentials('unknownuser', 'password')
@ -111,6 +216,48 @@ class KeystoneAuthTests(LiveServerTestCase):
self.assertEquals(user.username, 'some.neat.user') self.assertEquals(user.username, 'some.neat.user')
self.assertEquals(user.email, 'some.neat.user@example.com') self.assertEquals(user.email, 'some.neat.user@example.com')
class KeystoneV2AuthTests(KeystoneAuthTestsMixin, LiveServerTestCase):
@property
def keystone(self):
return get_keystone_users(2, self.get_server_url() + '/v2.0/auth',
'adminuser', 'adminpass', 'admintenant')
class KeystoneV3AuthTests(KeystoneAuthTestsMixin, LiveServerTestCase):
@property
def keystone(self):
return get_keystone_users(3, self.get_server_url() + '/v3',
'adminuser', 'adminpass', 'admintenant')
def test_query(self):
# Lookup cool.
(response, error_message) = self.keystone.query_users('cool')
self.assertIsNone(error_message)
self.assertEquals(1, len(response))
user_info = response[0]
self.assertEquals("cooluser", user_info.username)
# Lookup unknown.
(response, error_message) = self.keystone.query_users('unknown')
self.assertIsNone(error_message)
self.assertEquals(0, len(response))
def test_link_user(self):
# Link someuser.
user, error_message = self.keystone.link_user('cooluser')
self.assertIsNone(error_message)
self.assertIsNotNone(user)
self.assertEquals('cooluser', user.username)
self.assertEquals('cooluser@example.com', user.email)
# Link again. Should return the same user record.
user_again, _ = self.keystone.link_user('cooluser')
self.assertEquals(user_again.id, user.id)
# Confirm someuser.
result, _ = self.keystone.confirm_existing_user('cooluser', 'password')
self.assertIsNotNone(result)
self.assertEquals('cooluser', result.username)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()

View file

@ -4,6 +4,7 @@ from app import app
from initdb import setup_database_for_testing, finished_database_for_testing from initdb import setup_database_for_testing, finished_database_for_testing
from data.users import LDAPUsers from data.users import LDAPUsers
from mockldap import MockLdap from mockldap import MockLdap
from mock import patch
class TestLDAP(unittest.TestCase): class TestLDAP(unittest.TestCase):
def setUp(self): def setUp(self):
@ -198,6 +199,60 @@ class TestLDAP(unittest.TestCase):
(response, _) = self.ldap.confirm_existing_user('someuser', 'somepass') (response, _) = self.ldap.confirm_existing_user('someuser', 'somepass')
self.assertEquals(response.username, 'someuser') self.assertEquals(response.username, 'someuser')
def test_link_user(self):
# Link someuser.
user, error_message = self.ldap.link_user('someuser')
self.assertIsNone(error_message)
self.assertIsNotNone(user)
self.assertEquals('someuser', user.username)
# Link again. Should return the same user record.
user_again, _ = self.ldap.link_user('someuser')
self.assertEquals(user_again.id, user.id)
# Confirm someuser.
result, _ = self.ldap.confirm_existing_user('someuser', 'somepass')
self.assertIsNotNone(result)
self.assertEquals('someuser', result.username)
def test_query(self):
def initializer(uri, trace_level=0):
obj = self.mockldap[uri]
# Seed to "support" wildcard queries, which MockLDAP does not support natively.
obj.search_s.seed('ou=employees,dc=quay,dc=io', 2, '(|(uid=cool*)(mail=cool*))')([
('uid=cool.user,ou=employees,dc=quay,dc=io', {
'dc': ['quay', 'io'],
'ou': 'employees',
'uid': ['cool.user', 'referred'],
'userPassword': ['somepass'],
'mail': ['foo@bar.com']
})
])
obj.search_s.seed('ou=otheremployees,dc=quay,dc=io', 2, '(|(uid=cool*)(mail=cool*))')([])
obj.search_s.seed('ou=employees,dc=quay,dc=io', 2, '(|(uid=unknown*)(mail=unknown*))')([])
obj.search_s.seed('ou=otheremployees,dc=quay,dc=io', 2,
'(|(uid=unknown*)(mail=unknown*))')([])
return obj
with patch('ldap.initialize', new=initializer):
# Lookup cool.
(response, error_message) = self.ldap.query_users('cool')
self.assertIsNone(error_message)
self.assertEquals(1, len(response))
user_info = response[0]
self.assertEquals("cool.user", user_info.username)
self.assertEquals("foo@bar.com", user_info.email)
# Lookup unknown.
(response, error_message) = self.ldap.query_users('unknown')
self.assertIsNone(error_message)
self.assertEquals(0, len(response))
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()

View file

@ -21,7 +21,7 @@ from data.database import validate_database_url
from data.users import LDAP_CERT_FILENAME from data.users import LDAP_CERT_FILENAME
from data.users.externaljwt import ExternalJWTAuthN from data.users.externaljwt import ExternalJWTAuthN
from data.users.externalldap import LDAPConnection, LDAPUsers from data.users.externalldap import LDAPConnection, LDAPUsers
from data.users.keystone import KeystoneUsers from data.users.keystone import get_keystone_users
from storage import get_storage_driver from storage import get_storage_driver
from util.config.oauth import GoogleOAuthConfig, GithubOAuthConfig, GitLabOAuthConfig from util.config.oauth import GoogleOAuthConfig, GithubOAuthConfig, GitLabOAuthConfig
from util.secscan.api import SecurityScannerAPI from util.secscan.api import SecurityScannerAPI
@ -372,6 +372,9 @@ def _validate_jwt(config, password):
return return
verify_endpoint = config.get('JWT_VERIFY_ENDPOINT') verify_endpoint = config.get('JWT_VERIFY_ENDPOINT')
query_endpoint = config.get('JWT_QUERY_ENDPOINT', None)
getuser_endpoint = config.get('JWT_GETUSER_ENDPOINT', None)
issuer = config.get('JWT_AUTH_ISSUER') issuer = config.get('JWT_AUTH_ISSUER')
if not verify_endpoint: if not verify_endpoint:
@ -382,7 +385,8 @@ def _validate_jwt(config, password):
# Try to instatiate the JWT authentication mechanism. This will raise an exception if # Try to instatiate the JWT authentication mechanism. This will raise an exception if
# the key cannot be found. # the key cannot be found.
users = ExternalJWTAuthN(verify_endpoint, issuer, OVERRIDE_CONFIG_DIRECTORY, users = ExternalJWTAuthN(verify_endpoint, query_endpoint, getuser_endpoint, issuer,
OVERRIDE_CONFIG_DIRECTORY,
app.config['HTTPCLIENT'], app.config['HTTPCLIENT'],
app.config.get('JWT_AUTH_MAX_FRESH_S', 300)) app.config.get('JWT_AUTH_MAX_FRESH_S', 300))
@ -392,7 +396,24 @@ def _validate_jwt(config, password):
if not result: if not result:
raise Exception(('Verification of superuser %s failed: %s. \n\nThe user either does not ' + raise Exception(('Verification of superuser %s failed: %s. \n\nThe user either does not ' +
'exist in the remote authentication system ' + 'exist in the remote authentication system ' +
'OR JWT auth is misconfigured.') % (username, err_msg)) 'OR JWT auth is misconfigured') % (username, err_msg))
# If the query endpoint exists, ensure we can query to find the current user and that we can
# look up users directly.
if query_endpoint:
(results, err_msg) = users.query_users(username)
if not results:
err_msg = err_msg or ('Could not find users matching query: %s' % username)
raise Exception('Query endpoint is misconfigured or not returning proper users: %s' % err_msg)
# Make sure the get user endpoint is also configured.
if not getuser_endpoint:
raise Exception('The lookup user endpoint must be configured if the query endpoint is set')
(result, err_msg) = users.get_user(username)
if not result:
err_msg = err_msg or ('Could not find user %s' % username)
raise Exception('Lookup endpoint is misconfigured or not returning properly: %s' % err_msg)
def _validate_keystone(config, password): def _validate_keystone(config, password):
@ -401,6 +422,7 @@ def _validate_keystone(config, password):
return return
auth_url = config.get('KEYSTONE_AUTH_URL') auth_url = config.get('KEYSTONE_AUTH_URL')
auth_version = int(config.get('KEYSTONE_AUTH_VERSION', 2))
admin_username = config.get('KEYSTONE_ADMIN_USERNAME') admin_username = config.get('KEYSTONE_ADMIN_USERNAME')
admin_password = config.get('KEYSTONE_ADMIN_PASSWORD') admin_password = config.get('KEYSTONE_ADMIN_PASSWORD')
admin_tenant = config.get('KEYSTONE_ADMIN_TENANT') admin_tenant = config.get('KEYSTONE_ADMIN_TENANT')
@ -417,7 +439,7 @@ def _validate_keystone(config, password):
if not admin_tenant: if not admin_tenant:
raise Exception('Missing admin tenant') raise Exception('Missing admin tenant')
users = KeystoneUsers(auth_url, admin_username, admin_password, admin_tenant) users = get_keystone_users(auth_version, auth_url, admin_username, admin_password, admin_tenant)
# Verify that the superuser exists. If not, raise an exception. # Verify that the superuser exists. If not, raise an exception.
username = get_authenticated_user().username username = get_authenticated_user().username