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:
commit
934cdecbd6
16 changed files with 817 additions and 100 deletions
|
@ -65,6 +65,9 @@ class BaseAvatar(object):
|
|||
def get_data_for_org(self, 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'):
|
||||
""" Computes and returns the full data block for the avatar:
|
||||
{
|
||||
|
|
|
@ -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)
|
||||
|
||||
def get_matching_users(username_prefix, robot_namespace=None,
|
||||
organization=None):
|
||||
def get_matching_users(username_prefix, robot_namespace=None, organization=None, limit=20):
|
||||
user_search = _basequery.prefix_search(User.username, username_prefix)
|
||||
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:
|
||||
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(Team, JOIN_LEFT_OUTER, on=((Team.id == TeamMember.team) &
|
||||
(Team.organization == organization))))
|
||||
|
||||
class MatchingUserResult(object):
|
||||
def __init__(self, *args):
|
||||
self.username = args[0]
|
||||
self.email = args[1]
|
||||
self.robot = args[2]
|
||||
self.id = args[0]
|
||||
self.username = args[1]
|
||||
self.email = args[2]
|
||||
self.robot = args[3]
|
||||
|
||||
if organization:
|
||||
self.is_org_member = (args[3] != None)
|
||||
else:
|
||||
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):
|
||||
|
@ -749,3 +749,16 @@ def get_region_locations(user):
|
|||
""" Returns the locations defined as preferred storage for the given user. """
|
||||
query = UserRegion.select().join(ImageStorageLocation).where(UserRegion.user == user)
|
||||
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))
|
||||
|
|
|
@ -9,7 +9,7 @@ from data import model
|
|||
from data.users.database import DatabaseUsers
|
||||
from data.users.externalldap import LDAPUsers
|
||||
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
|
||||
|
||||
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')
|
||||
issuer = config.get('JWT_AUTH_ISSUER')
|
||||
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':
|
||||
auth_url = config.get('KEYSTONE_AUTH_URL')
|
||||
auth_version = int(config.get('KEYSTONE_AUTH_VERSION', 2))
|
||||
timeout = config.get('KEYSTONE_AUTH_TIMEOUT')
|
||||
keystone_admin_username = config.get('KEYSTONE_ADMIN_USERNAME')
|
||||
keystone_admin_password = config.get('KEYSTONE_ADMIN_PASSWORD')
|
||||
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)
|
||||
|
||||
|
@ -139,6 +145,31 @@ class UserAuthentication(object):
|
|||
|
||||
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):
|
||||
""" 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
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
from data import model
|
||||
|
||||
class DatabaseUsers(object):
|
||||
@property
|
||||
def federated_service(self):
|
||||
return None
|
||||
|
||||
def verify_credentials(self, username_or_email, password):
|
||||
""" Simply delegate to the model implementation. """
|
||||
result = model.user.verify_user(username_or_email, password)
|
||||
|
@ -16,3 +20,11 @@ class DatabaseUsers(object):
|
|||
def confirm_existing_user(self, 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, '', '')
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@ import logging
|
|||
import json
|
||||
import os
|
||||
|
||||
from data.users.federated import FederatedUsers, VerifiedCredentials
|
||||
from data.users.federated import FederatedUsers, UserInformation
|
||||
from util.security import jwtutil
|
||||
|
||||
|
||||
|
@ -13,10 +13,13 @@ class ExternalJWTAuthN(FederatedUsers):
|
|||
""" Delegates authentication to a REST endpoint that returns JWTs. """
|
||||
PUBLIC_KEY_FILENAME = 'jwt-authn.cert'
|
||||
|
||||
def __init__(self, verify_url, issuer, override_config_dir, http_client, max_fresh_s,
|
||||
public_key_path=None):
|
||||
def __init__(self, verify_url, query_url, getuser_url, issuer, override_config_dir, http_client,
|
||||
max_fresh_s, public_key_path=None):
|
||||
super(ExternalJWTAuthN, self).__init__('jwtauthn')
|
||||
self.verify_url = verify_url
|
||||
self.query_url = query_url
|
||||
self.getuser_url = getuser_url
|
||||
|
||||
self.issuer = issuer
|
||||
self.client = http_client
|
||||
self.max_fresh_s = max_fresh_s
|
||||
|
@ -32,34 +35,80 @@ class ExternalJWTAuthN(FederatedUsers):
|
|||
with open(public_key_path) as public_key_file:
|
||||
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:
|
||||
return (None, result.text or 'Invalid username or password')
|
||||
return (None, result.text or 'Could not make JWT auth call')
|
||||
|
||||
try:
|
||||
result_data = json.loads(result.text)
|
||||
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.
|
||||
encoded = result_data.get('token', '')
|
||||
exp_limit_options = jwtutil.exp_max_s_option(self.max_fresh_s)
|
||||
try:
|
||||
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)
|
||||
return (payload, None)
|
||||
except jwtutil.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')
|
||||
|
||||
# Parse out the username and email.
|
||||
return (VerifiedCredentials(username=payload['sub'], email=payload['email']), None)
|
||||
|
||||
logger.exception('Exception when decoding returned JWT for url %s', url)
|
||||
return (None, 'Exception when decoding returned JWT')
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import ldap
|
||||
import logging
|
||||
import os
|
||||
import itertools
|
||||
|
||||
from collections import namedtuple
|
||||
from data.users.federated import FederatedUsers, VerifiedCredentials
|
||||
from data.users.federated import FederatedUsers, UserInformation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -25,11 +26,11 @@ class LDAPConnection(object):
|
|||
self._user_dn = user_dn
|
||||
self._user_pw = user_pw
|
||||
self._allow_tls_fallback = allow_tls_fallback
|
||||
|
||||
self._conn = None
|
||||
|
||||
def __enter__(self):
|
||||
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.set_option(ldap.OPT_REFERRALS, 1)
|
||||
|
||||
|
@ -43,6 +44,10 @@ class LDAPConnection(object):
|
|||
def __exit__(self, exc_type, value, tb):
|
||||
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):
|
||||
_LDAPResult = namedtuple('LDAPResult', ['dn', 'attrs'])
|
||||
|
@ -51,6 +56,7 @@ class LDAPUsers(FederatedUsers):
|
|||
allow_tls_fallback=False, secondary_user_rdns=None):
|
||||
|
||||
super(LDAPUsers, self).__init__('ldap')
|
||||
|
||||
self._ldap = LDAPConnectionBuilder(ldap_uri, admin_dn, admin_passwd, allow_tls_fallback)
|
||||
self._ldap_uri = ldap_uri
|
||||
self._uid_attr = uid_attr
|
||||
|
@ -109,7 +115,10 @@ class LDAPUsers(FederatedUsers):
|
|||
logger.exception('LDAP search exception')
|
||||
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
|
||||
# the entire block in the INVALID CREDENTIALS check.
|
||||
try:
|
||||
|
@ -130,22 +139,72 @@ class LDAPUsers(FederatedUsers):
|
|||
return (None, err_msg)
|
||||
|
||||
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
|
||||
# pairs.
|
||||
# Filter out pairs without DNs. Some LDAP impls will return such pairs.
|
||||
with_dns = [result for result in results if result.dn]
|
||||
if len(with_dns) < 1:
|
||||
return (None, 'Username not found')
|
||||
return (with_dns, None)
|
||||
|
||||
# If we have found a single pair, then return it.
|
||||
if len(with_dns) == 1:
|
||||
return (with_dns[0], None)
|
||||
def _ldap_single_user_search(self, username_or_email):
|
||||
with_dns, err_msg = self._ldap_user_search(username_or_email)
|
||||
if err_msg is not None:
|
||||
return (None, err_msg)
|
||||
|
||||
# Otherwise, there are multiple pairs with DNs, so find the one with the mail
|
||||
# attribute (if any).
|
||||
with_mail = [result for result in results if result.attrs.get(self._email_attr)]
|
||||
return (with_mail[0] if with_mail else with_dns[0], None)
|
||||
# Make sure we have at least one result.
|
||||
if len(with_dns) < 1:
|
||||
return (None, 'Username not found')
|
||||
|
||||
# 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):
|
||||
""" Verify the credentials with LDAP. """
|
||||
|
@ -153,7 +212,7 @@ class LDAPUsers(FederatedUsers):
|
|||
if not password:
|
||||
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:
|
||||
return (None, err_msg)
|
||||
|
||||
|
@ -183,14 +242,4 @@ class LDAPUsers(FederatedUsers):
|
|||
logger.exception('Invalid LDAP credentials')
|
||||
return (None, 'Invalid password')
|
||||
|
||||
# Now check if we have a federated login for this user
|
||||
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)
|
||||
|
||||
return self._credential_for_user(found_response)
|
||||
|
|
|
@ -7,7 +7,7 @@ from util.validation import generate_valid_usernames
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VerifiedCredentials = namedtuple('VerifiedCredentials', ['username', 'email'])
|
||||
UserInformation = namedtuple('UserInformation', ['username', 'email', 'id'])
|
||||
|
||||
class FederatedUsers(object):
|
||||
""" Base class for all federated users systems. """
|
||||
|
@ -15,11 +15,26 @@ class FederatedUsers(object):
|
|||
def __init__(self, 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):
|
||||
""" 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
|
||||
|
||||
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):
|
||||
db_user = model.user.verify_federated_login(self._federated_service, username)
|
||||
if not db_user:
|
||||
|
@ -34,7 +49,8 @@ class FederatedUsers(object):
|
|||
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,
|
||||
username, set_password_notification=False)
|
||||
username,
|
||||
set_password_notification=False)
|
||||
else:
|
||||
# Update the db attributes from the federated service.
|
||||
db_user.email = email
|
||||
|
@ -42,6 +58,13 @@ class FederatedUsers(object):
|
|||
|
||||
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):
|
||||
""" Verifies the given credentials and, if valid, creates/links a database user to the
|
||||
associated federated service.
|
||||
|
|
|
@ -1,19 +1,34 @@
|
|||
import logging
|
||||
import os
|
||||
import itertools
|
||||
|
||||
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 Unauthorized as KeystoneUnauthorized
|
||||
from data.users.federated import FederatedUsers, VerifiedCredentials
|
||||
from data.users.federated import FederatedUsers, UserInformation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_TIMEOUT = 10 # seconds
|
||||
|
||||
class KeystoneUsers(FederatedUsers):
|
||||
""" Delegates authentication to OpenStack Keystone. """
|
||||
def _take(n, iterable):
|
||||
"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):
|
||||
super(KeystoneUsers, self).__init__('keystone')
|
||||
super(KeystoneV2Users, self).__init__('keystone')
|
||||
self.auth_url = auth_url
|
||||
self.admin_username = admin_username
|
||||
self.admin_password = admin_password
|
||||
|
@ -43,4 +58,75 @@ class KeystoneUsers(FederatedUsers):
|
|||
logger.exception('Keystone unauthorized admin')
|
||||
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')
|
||||
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
""" Conduct searches against all registry context. """
|
||||
|
||||
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 auth.permissions import (OrganizationMemberPermission, ReadRepositoryPermission,
|
||||
UserAdminPermission, AdministerOrganizationPermission,
|
||||
ReadRepositoryPermission)
|
||||
from auth.auth_context import get_authenticated_user
|
||||
from auth import scopes
|
||||
from app import avatar
|
||||
from app import avatar, authentication
|
||||
from operator import itemgetter
|
||||
from stringscore import liquidmetal
|
||||
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 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>')
|
||||
class EntitySearch(ApiResource):
|
||||
""" Resource for searching entities. """
|
||||
|
@ -69,7 +96,22 @@ class EntitySearch(ApiResource):
|
|||
if admin_permission.can():
|
||||
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):
|
||||
result = {
|
||||
|
@ -93,11 +135,20 @@ class EntitySearch(ApiResource):
|
|||
|
||||
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]
|
||||
user_data = [user_view(user) for user in users]
|
||||
external_data = [external_view(user) for user in filtered_external_users]
|
||||
|
||||
return {
|
||||
'results': team_data + user_data + org_data
|
||||
'results': team_data + user_data + org_data + external_data
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -523,6 +523,15 @@
|
|||
|
||||
<!-- Keystone Authentication -->
|
||||
<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>
|
||||
<td>Keystone Authentication URL:</td>
|
||||
<td>
|
||||
|
@ -573,20 +582,6 @@
|
|||
</div>
|
||||
|
||||
<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>
|
||||
<td>Authentication Issuer:</td>
|
||||
<td>
|
||||
|
@ -606,6 +601,50 @@
|
|||
</div
|
||||
</td>
|
||||
</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>
|
||||
|
||||
<!-- LDAP Authentication -->
|
||||
|
|
|
@ -148,6 +148,18 @@ angular.module('quay').directive('entitySearch', function () {
|
|||
};
|
||||
|
||||
$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) {
|
||||
$(input).typeahead('val', $scope.autoClear ? '' : entity.name);
|
||||
} else {
|
||||
|
@ -193,7 +205,7 @@ angular.module('quay').directive('entitySearch', function () {
|
|||
var entity = data.results[i];
|
||||
|
||||
var found = 'user';
|
||||
if (entity.kind == 'user') {
|
||||
if (entity.kind == 'user' || entity.kind == 'external') {
|
||||
found = entity.is_robot ? 'robot' : 'user';
|
||||
} else if (entity.kind == 'team') {
|
||||
found = 'team';
|
||||
|
@ -276,6 +288,8 @@ angular.module('quay').directive('entitySearch', function () {
|
|||
template = '<div class="entity-mini-listing">';
|
||||
if (datum.entity.kind == 'user' && !datum.entity.is_robot) {
|
||||
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) {
|
||||
template += '<i class="fa ci-robot fa-lg"></i>';
|
||||
} else if (datum.entity.kind == 'team') {
|
||||
|
|
Binary file not shown.
|
@ -53,6 +53,58 @@ class JWTAuthTestCase(LiveServerTestCase):
|
|||
data = base64.b64decode(request.headers['Authorization'][len('Basic '):])
|
||||
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'])
|
||||
def verify_user():
|
||||
username, password = _get_basic_auth()
|
||||
|
@ -80,7 +132,7 @@ class JWTAuthTestCase(LiveServerTestCase):
|
|||
'token': encoded
|
||||
})
|
||||
|
||||
return make_response('', 404)
|
||||
return make_response('Invalid username or password', 404)
|
||||
|
||||
jwt_app.config['TESTING'] = True
|
||||
return jwt_app
|
||||
|
@ -94,7 +146,11 @@ class JWTAuthTestCase(LiveServerTestCase):
|
|||
|
||||
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)
|
||||
|
||||
def tearDown(self):
|
||||
|
@ -142,11 +198,78 @@ class JWTAuthTestCase(LiveServerTestCase):
|
|||
self.assertIsNotNone(result)
|
||||
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')
|
||||
self.assertIsNone(result)
|
||||
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__':
|
||||
unittest.main()
|
||||
|
|
|
@ -4,16 +4,15 @@ import unittest
|
|||
|
||||
import requests
|
||||
|
||||
from flask import Flask, request, abort
|
||||
from flask import Flask, request, abort, make_response
|
||||
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
|
||||
|
||||
|
||||
class KeystoneAuthTests(LiveServerTestCase):
|
||||
class KeystoneAuthTestsMixin():
|
||||
maxDiff = None
|
||||
|
||||
def create_app(self):
|
||||
|
@ -44,6 +43,106 @@ class KeystoneAuthTests(LiveServerTestCase):
|
|||
|
||||
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'])
|
||||
def tokens():
|
||||
|
@ -89,9 +188,15 @@ class KeystoneAuthTests(LiveServerTestCase):
|
|||
return ks_app
|
||||
|
||||
def setUp(self):
|
||||
setup_database_for_testing(self)
|
||||
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):
|
||||
(user, _) = self.keystone.verify_credentials('unknownuser', 'password')
|
||||
|
@ -111,6 +216,48 @@ class KeystoneAuthTests(LiveServerTestCase):
|
|||
self.assertEquals(user.username, 'some.neat.user')
|
||||
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__':
|
||||
unittest.main()
|
||||
|
|
|
@ -4,6 +4,7 @@ from app import app
|
|||
from initdb import setup_database_for_testing, finished_database_for_testing
|
||||
from data.users import LDAPUsers
|
||||
from mockldap import MockLdap
|
||||
from mock import patch
|
||||
|
||||
class TestLDAP(unittest.TestCase):
|
||||
def setUp(self):
|
||||
|
@ -198,6 +199,60 @@ class TestLDAP(unittest.TestCase):
|
|||
(response, _) = self.ldap.confirm_existing_user('someuser', 'somepass')
|
||||
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__':
|
||||
unittest.main()
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@ from data.database import validate_database_url
|
|||
from data.users import LDAP_CERT_FILENAME
|
||||
from data.users.externaljwt import ExternalJWTAuthN
|
||||
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 util.config.oauth import GoogleOAuthConfig, GithubOAuthConfig, GitLabOAuthConfig
|
||||
from util.secscan.api import SecurityScannerAPI
|
||||
|
@ -372,6 +372,9 @@ def _validate_jwt(config, password):
|
|||
return
|
||||
|
||||
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')
|
||||
|
||||
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
|
||||
# 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.get('JWT_AUTH_MAX_FRESH_S', 300))
|
||||
|
||||
|
@ -392,7 +396,24 @@ def _validate_jwt(config, password):
|
|||
if not result:
|
||||
raise Exception(('Verification of superuser %s failed: %s. \n\nThe user either does not ' +
|
||||
'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):
|
||||
|
@ -401,6 +422,7 @@ def _validate_keystone(config, password):
|
|||
return
|
||||
|
||||
auth_url = config.get('KEYSTONE_AUTH_URL')
|
||||
auth_version = int(config.get('KEYSTONE_AUTH_VERSION', 2))
|
||||
admin_username = config.get('KEYSTONE_ADMIN_USERNAME')
|
||||
admin_password = config.get('KEYSTONE_ADMIN_PASSWORD')
|
||||
admin_tenant = config.get('KEYSTONE_ADMIN_TENANT')
|
||||
|
@ -417,7 +439,7 @@ def _validate_keystone(config, password):
|
|||
if not 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.
|
||||
username = get_authenticated_user().username
|
||||
|
|
Reference in a new issue