initial import for Open Source 🎉

This commit is contained in:
Jimmy Zelinskie 2019-11-12 11:09:47 -05:00
parent 1898c361f3
commit 9c0dd3b722
2048 changed files with 218743 additions and 0 deletions

263
data/users/__init__.py Normal file
View file

@ -0,0 +1,263 @@
import logging
import itertools
import json
import uuid
import features
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 get_keystone_users
from data.users.apptoken import AppTokenInternalAuth
from util.security.aes import AESCipher
from util.security.secret import convert_secret_key
logger = logging.getLogger(__name__)
def get_federated_service_name(authentication_type):
if authentication_type == 'LDAP':
return 'ldap'
if authentication_type == 'JWT':
return 'jwtauthn'
if authentication_type == 'Keystone':
return 'keystone'
if authentication_type == 'AppToken':
return None
if authentication_type == 'Database':
return None
raise Exception('Unknown auth type: %s' % authentication_type)
LDAP_CERT_FILENAME = 'ldap.crt'
def get_users_handler(config, _, override_config_dir):
""" Returns a users handler for the authentication configured in the given config object. """
authentication_type = config.get('AUTHENTICATION_TYPE', 'Database')
if authentication_type == 'Database':
return DatabaseUsers()
if authentication_type == 'LDAP':
ldap_uri = config.get('LDAP_URI', 'ldap://localhost')
base_dn = config.get('LDAP_BASE_DN')
admin_dn = config.get('LDAP_ADMIN_DN')
admin_passwd = config.get('LDAP_ADMIN_PASSWD')
user_rdn = config.get('LDAP_USER_RDN', [])
uid_attr = config.get('LDAP_UID_ATTR', 'uid')
email_attr = config.get('LDAP_EMAIL_ATTR', 'mail')
secondary_user_rdns = config.get('LDAP_SECONDARY_USER_RDNS', [])
timeout = config.get('LDAP_TIMEOUT')
network_timeout = config.get('LDAP_NETWORK_TIMEOUT')
allow_tls_fallback = config.get('LDAP_ALLOW_INSECURE_FALLBACK', False)
return LDAPUsers(ldap_uri, base_dn, admin_dn, admin_passwd, user_rdn, uid_attr, email_attr,
allow_tls_fallback, secondary_user_rdns=secondary_user_rdns,
requires_email=features.MAILING, timeout=timeout,
network_timeout=network_timeout)
if authentication_type == 'JWT':
verify_url = config.get('JWT_VERIFY_ENDPOINT')
issuer = config.get('JWT_AUTH_ISSUER')
max_fresh_s = config.get('JWT_AUTH_MAX_FRESH_S', 300)
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,
requires_email=features.MAILING)
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 get_keystone_users(auth_version, auth_url, keystone_admin_username,
keystone_admin_password, keystone_admin_tenant, timeout,
requires_email=features.MAILING)
if authentication_type == 'AppToken':
if features.DIRECT_LOGIN:
raise Exception('Direct login feature must be disabled to use AppToken internal auth')
if not features.APP_SPECIFIC_TOKENS:
raise Exception('AppToken internal auth requires app specific token support to be enabled')
return AppTokenInternalAuth()
raise RuntimeError('Unknown authentication type: %s' % authentication_type)
class UserAuthentication(object):
def __init__(self, app=None, config_provider=None, override_config_dir=None):
self.secret_key = None
self.app = app
if app is not None:
self.state = self.init_app(app, config_provider, override_config_dir)
else:
self.state = None
def init_app(self, app, config_provider, override_config_dir):
self.secret_key = convert_secret_key(app.config['SECRET_KEY'])
users = get_users_handler(app.config, config_provider, override_config_dir)
# register extension with app
app.extensions = getattr(app, 'extensions', {})
app.extensions['authentication'] = users
return users
def encrypt_user_password(self, password):
""" Returns an encrypted version of the user's password. """
data = {
'password': password
}
message = json.dumps(data)
cipher = AESCipher(self.secret_key)
return cipher.encrypt(message)
def _decrypt_user_password(self, encrypted):
""" Attempts to decrypt the given password and returns it. """
cipher = AESCipher(self.secret_key)
try:
message = cipher.decrypt(encrypted)
except ValueError:
return None
except TypeError:
return None
try:
data = json.loads(message)
except ValueError:
return None
return data.get('password', encrypted)
def ping(self):
""" Returns whether the authentication engine is reachable and working. """
return self.state.ping()
@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
@property
def requires_distinct_cli_password(self):
""" Returns whether this auth system requires a distinct CLI password to be created,
in-system, before the CLI can be used. """
return self.state.requires_distinct_cli_password
@property
def supports_encrypted_credentials(self):
""" Returns whether this auth system supports using encrypted credentials. """
return self.state.supports_encrypted_credentials
def has_password_set(self, username):
""" Returns whether the user has a password set in the auth system. """
return self.state.has_password_set(username)
@property
def supports_fresh_login(self):
""" Returns whether this auth system supports the fresh login check. """
return self.state.supports_fresh_login
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 get_and_link_federated_user_info(self, user_info, internal_create=False):
""" Returns a tuple containing the database user record linked to the given UserInformation
pair and any error that occurred when trying to link the user.
If `internal_create` is True, the caller is an internal user creation process (such
as team syncing), and the "can a user be created" check will be bypassed.
"""
return self.state.get_and_link_federated_user_info(user_info, internal_create=internal_create)
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
(where applicable).
"""
return self.state.confirm_existing_user(username, password)
def verify_credentials(self, username_or_email, password):
""" Verifies that the given username and password credentials are valid. """
return self.state.verify_credentials(username_or_email, password)
def check_group_lookup_args(self, group_lookup_args):
""" Verifies that the given group lookup args point to a valid group. Returns a tuple consisting
of a boolean status and an error message (if any).
"""
return self.state.check_group_lookup_args(group_lookup_args)
def service_metadata(self):
""" Returns a dictionary of extra metadata to present to *superusers* about this auth engine.
For example, LDAP returns the base DN so we can display to the user during sync setup.
"""
return self.state.service_metadata()
def iterate_group_members(self, group_lookup_args, page_size=None, disable_pagination=False):
""" Returns a tuple of an iterator over all the members of the group matching the given lookup
args dictionary, or the error that occurred if the initial call failed or is unsupported.
The format of the lookup args dictionary is specific to the implementation.
Each result in the iterator is a tuple of (UserInformation, error_message), and only
one will be not-None.
"""
return self.state.iterate_group_members(group_lookup_args, page_size=page_size,
disable_pagination=disable_pagination)
def verify_and_link_user(self, username_or_email, password, basic_auth=False):
""" Verifies that the given username and password credentials are valid and, if so,
creates or links the database user to the federated identity. """
# First try to decode the password as a signed token.
if basic_auth:
decrypted = self._decrypt_user_password(password)
if decrypted is None:
# This is a normal password.
if features.REQUIRE_ENCRYPTED_BASIC_AUTH:
msg = ('Client login with unencrypted passwords is disabled. Please generate an ' +
'encrypted password in the user admin panel for use here.')
return (None, msg)
else:
password = decrypted
(result, err_msg) = self.state.verify_and_link_user(username_or_email, password)
if not result:
return (result, err_msg)
if not result.enabled:
return (None, 'This user has been disabled. Please contact your administrator.')
return (result, err_msg)
def __getattr__(self, name):
return getattr(self.state, name, None)

67
data/users/apptoken.py Normal file
View file

@ -0,0 +1,67 @@
import logging
from data import model
from oauth.loginmanager import OAuthLoginManager
from oauth.oidc import PublicKeyLoadException
from util.security.jwtutil import InvalidTokenError
logger = logging.getLogger(__name__)
class AppTokenInternalAuth(object):
""" Forces all internal credential login to go through an app token, by disabling all other
access.
"""
@property
def supports_fresh_login(self):
# Since there is no password.
return False
@property
def federated_service(self):
return None
@property
def requires_distinct_cli_password(self):
# Since there is no supported "password".
return False
def has_password_set(self, username):
# Since there is no supported "password".
return False
@property
def supports_encrypted_credentials(self):
# Since there is no supported "password".
return False
def verify_credentials(self, username_or_email, id_token):
return (None, 'An application specific token is required to login')
def verify_and_link_user(self, username_or_email, password):
return self.verify_credentials(username_or_email, password)
def confirm_existing_user(self, username, password):
return self.verify_credentials(username, password)
def link_user(self, username_or_email):
return (None, 'Unsupported for this authentication system')
def get_and_link_federated_user_info(self, user_info):
return (None, 'Unsupported for this authentication system')
def query_users(self, query, limit):
return (None, '', '')
def check_group_lookup_args(self, group_lookup_args):
return (False, 'Not supported')
def iterate_group_members(self, group_lookup_args, page_size=None, disable_pagination=False):
return (None, 'Not supported')
def service_metadata(self):
return {}
def ping(self):
""" Always assumed to be working. If the DB is broken, other checks will handle it. """
return (True, None)

66
data/users/database.py Normal file
View file

@ -0,0 +1,66 @@
from data import model
class DatabaseUsers(object):
@property
def federated_service(self):
return None
@property
def supports_fresh_login(self):
return True
def ping(self):
""" Always assumed to be working. If the DB is broken, other checks will handle it. """
return (True, None)
@property
def supports_encrypted_credentials(self):
return True
def has_password_set(self, username):
user = model.user.get_user(username)
return user and user.password_hash is not None
@property
def requires_distinct_cli_password(self):
# Since the database stores its own password.
return True
def verify_credentials(self, username_or_email, password):
""" Simply delegate to the model implementation. """
result = model.user.verify_user(username_or_email, password)
if not result:
return (None, 'Invalid Username or Password')
return (result, None)
def verify_and_link_user(self, username_or_email, password):
""" Simply delegate to the model implementation. """
return self.verify_credentials(username_or_email, password)
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 get_and_link_federated_user_info(self, user_info, internal_create=False):
""" 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, '', '')
def check_group_lookup_args(self, group_lookup_args):
""" Never used since all groups, by definition, are in the database. """
return (False, 'Not supported')
def iterate_group_members(self, group_lookup_args, page_size=None, disable_pagination=False):
""" Never used since all groups, by definition, are in the database. """
return (None, 'Not supported')
def service_metadata(self):
""" Never used since database has no metadata """
return {}

128
data/users/externaljwt.py Normal file
View file

@ -0,0 +1,128 @@
import logging
import json
import os
from data.users.federated import FederatedUsers, UserInformation
from util.security import jwtutil
logger = logging.getLogger(__name__)
class ExternalJWTAuthN(FederatedUsers):
""" Delegates authentication to a REST endpoint that returns JWTs. """
PUBLIC_KEY_FILENAME = 'jwt-authn.cert'
def __init__(self, verify_url, query_url, getuser_url, issuer, override_config_dir, http_client,
max_fresh_s, public_key_path=None, requires_email=True):
super(ExternalJWTAuthN, self).__init__('jwtauthn', requires_email)
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
self.requires_email = requires_email
default_key_path = os.path.join(override_config_dir, ExternalJWTAuthN.PUBLIC_KEY_FILENAME)
public_key_path = public_key_path or default_key_path
if not os.path.exists(public_key_path):
error_message = ('JWT Authentication public key file "%s" not found' % public_key_path)
raise Exception(error_message)
self.public_key_path = public_key_path
with open(public_key_path) as public_key_file:
self.public_key = public_key_file.read()
def has_password_set(self, username):
return True
def ping(self):
result = self.client.get(self.getuser_url, timeout=2)
# We expect a 401 or 403 of some kind, since we explicitly don't send an auth header
if result.status_code // 100 != 4:
return (False, result.text or 'Could not reach JWT authn endpoint')
return (True, None)
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 self.requires_email and 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.get('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.get('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 self.requires_email and not 'email' in payload:
raise Exception('Missing email field in JWT')
user_info = UserInformation(username=payload['sub'], email=payload.get('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 'Could not make JWT auth call')
try:
result_data = json.loads(result.text)
except ValueError:
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=aud, issuer=self.issuer,
options=exp_limit_options)
return (payload, None)
except jwtutil.InvalidTokenError:
logger.exception('Exception when decoding returned JWT for url %s', url)
return (None, 'Exception when decoding returned JWT')

413
data/users/externalldap.py Normal file
View file

@ -0,0 +1,413 @@
import ldap
import logging
import os
from ldap.controls import SimplePagedResultsControl
from ldap.filter import filter_format, escape_filter_chars
from collections import namedtuple
from data.users.federated import FederatedUsers, UserInformation
from util.itertoolrecipes import take
logger = logging.getLogger(__name__)
_DEFAULT_NETWORK_TIMEOUT = 10.0 # seconds
_DEFAULT_TIMEOUT = 10.0 # seconds
_DEFAULT_PAGE_SIZE = 1000
class LDAPConnectionBuilder(object):
def __init__(self, ldap_uri, user_dn, user_pw, allow_tls_fallback=False,
timeout=None, network_timeout=None):
self._ldap_uri = ldap_uri
self._user_dn = user_dn
self._user_pw = user_pw
self._allow_tls_fallback = allow_tls_fallback
self._timeout = timeout
self._network_timeout = network_timeout
def get_connection(self):
return LDAPConnection(self._ldap_uri, self._user_dn, self._user_pw, self._allow_tls_fallback,
self._timeout, self._network_timeout)
class LDAPConnection(object):
def __init__(self, ldap_uri, user_dn, user_pw, allow_tls_fallback=False,
timeout=None, network_timeout=None):
self._ldap_uri = ldap_uri
self._user_dn = user_dn
self._user_pw = user_pw
self._allow_tls_fallback = allow_tls_fallback
self._timeout = timeout
self._network_timeout = network_timeout
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)
self._conn.set_option(ldap.OPT_NETWORK_TIMEOUT,
self._network_timeout or _DEFAULT_NETWORK_TIMEOUT)
self._conn.set_option(ldap.OPT_TIMEOUT, self._timeout or _DEFAULT_TIMEOUT)
if self._allow_tls_fallback:
logger.debug('TLS Fallback enabled in LDAP')
self._conn.set_option(ldap.OPT_X_TLS_TRY, 1)
self._conn.simple_bind_s(self._user_dn, self._user_pw)
return self._conn
def __exit__(self, exc_type, value, tb):
self._conn.unbind_s()
class LDAPUsers(FederatedUsers):
_LDAPResult = namedtuple('LDAPResult', ['dn', 'attrs'])
def __init__(self, ldap_uri, base_dn, admin_dn, admin_passwd, user_rdn, uid_attr, email_attr,
allow_tls_fallback=False, secondary_user_rdns=None, requires_email=True,
timeout=None, network_timeout=None, force_no_pagination=False):
super(LDAPUsers, self).__init__('ldap', requires_email)
self._ldap = LDAPConnectionBuilder(ldap_uri, admin_dn, admin_passwd, allow_tls_fallback,
timeout, network_timeout)
self._ldap_uri = ldap_uri
self._uid_attr = uid_attr
self._email_attr = email_attr
self._allow_tls_fallback = allow_tls_fallback
self._requires_email = requires_email
self._force_no_pagination = force_no_pagination
# Note: user_rdn is a list of RDN pieces (for historical reasons), and secondary_user_rds
# is a list of RDN strings.
relative_user_dns = [','.join(user_rdn)] + (secondary_user_rdns or [])
def get_full_rdn(relative_dn):
prefix = relative_dn.split(',') if relative_dn else []
return ','.join(prefix + base_dn)
# Create the set of full DN paths.
self._user_dns = [get_full_rdn(relative_dn) for relative_dn in relative_user_dns]
self._base_dn = ','.join(base_dn)
def _get_ldap_referral_dn(self, referral_exception):
logger.debug('Got referral: %s', referral_exception.args[0])
if not referral_exception.args[0] or not referral_exception.args[0].get('info'):
logger.debug('LDAP referral missing info block')
return None
referral_info = referral_exception.args[0]['info']
if not referral_info.startswith('Referral:\n'):
logger.debug('LDAP referral missing Referral header')
return None
referral_uri = referral_info[len('Referral:\n'):]
if not referral_uri.startswith('ldap:///'):
logger.debug('LDAP referral URI does not start with ldap:///')
return None
referral_dn = referral_uri[len('ldap:///'):]
return referral_dn
def _ldap_user_search_with_rdn(self, conn, username_or_email, user_search_dn, suffix=''):
query = u'(|({0}={2}{3})({1}={2}{3}))'.format(self._uid_attr, self._email_attr,
escape_filter_chars(username_or_email),
suffix)
logger.debug('Conducting user search: %s under %s', query, user_search_dn)
try:
return (conn.search_s(user_search_dn, ldap.SCOPE_SUBTREE, query.encode('utf-8')), None)
except ldap.REFERRAL as re:
referral_dn = self._get_ldap_referral_dn(re)
if not referral_dn:
return (None, 'Failed to follow referral when looking up username')
try:
subquery = u'(%s=%s)' % (self._uid_attr, username_or_email)
return (conn.search_s(referral_dn, ldap.SCOPE_BASE, subquery), None)
except ldap.LDAPError:
logger.debug('LDAP referral search exception')
return (None, 'Username not found')
except ldap.LDAPError:
logger.debug('LDAP search exception')
return (None, 'Username not found')
def _ldap_user_search(self, username_or_email, limit=20, suffix=''):
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:
with self._ldap.get_connection():
pass
except ldap.INVALID_CREDENTIALS:
return (None, 'LDAP Admin dn or password is invalid')
with self._ldap.get_connection() as conn:
logger.debug('Incoming username or email param: %s', username_or_email.__repr__())
for user_search_dn in self._user_dns:
(pairs, err_msg) = self._ldap_user_search_with_rdn(conn, username_or_email, user_search_dn,
suffix=suffix)
if pairs is not None and len(pairs) > 0:
break
if err_msg is not None:
return (None, err_msg)
logger.debug('Found matching pairs: %s', pairs)
results = [LDAPUsers._LDAPResult(*pair) for pair in take(limit, pairs)]
# Filter out pairs without DNs. Some LDAP impls will return such pairs.
with_dns = [result for result in results if result.dn]
return (with_dns, 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)
# 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 _build_user_information(self, response):
if not response.get(self._uid_attr):
return (None, 'Missing uid field "%s" in user record' % self._uid_attr)
if self._requires_email and 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.get(self._email_attr, [None])[0]
return (UserInformation(username=username, email=email, id=username), None)
def ping(self):
try:
with self._ldap.get_connection():
pass
except ldap.INVALID_CREDENTIALS:
return (False, 'LDAP Admin dn or password is invalid')
except ldap.LDAPError as lde:
logger.exception('Exception when trying to health check LDAP')
return (False, lde.message)
return (True, None)
def at_least_one_user_exists(self):
logger.debug('Checking if any users exist in LDAP')
try:
with self._ldap.get_connection():
pass
except ldap.INVALID_CREDENTIALS:
return (None, 'LDAP Admin dn or password is invalid')
has_pagination = not self._force_no_pagination
with self._ldap.get_connection() as conn:
for user_search_dn in self._user_dns:
lc = ldap.controls.libldap.SimplePagedResultsControl(criticality=True, size=1, cookie='')
try:
if has_pagination:
msgid = conn.search_ext(user_search_dn, ldap.SCOPE_SUBTREE, serverctrls=[lc])
_, rdata, _, serverctrls = conn.result3(msgid)
else:
msgid = conn.search(user_search_dn, ldap.SCOPE_SUBTREE)
_, rdata = conn.result(msgid)
for entry in rdata: # Handles both lists and iterators.
return (True, None)
except ldap.LDAPError as lde:
return (False, str(lde) or 'Could not find DN %s' % user_search_dn)
return (False, 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._build_user_information(found_response)
def query_users(self, query, limit=20):
""" Queries LDAP for matching users. """
if not query:
return (None, self.federated_service, 'Empty query')
logger.debug('Got query %s with limit %s', query, limit)
(results, err_msg) = self._ldap_user_search(query, limit=limit, suffix='*')
if err_msg is not None:
return (None, self.federated_service, err_msg)
final_results = []
for result in results[0:limit]:
credentials, err_msg = self._build_user_information(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, self.federated_service, None)
def verify_credentials(self, username_or_email, password):
""" Verify the credentials with LDAP. """
# Make sure that even if the server supports anonymous binds, we don't allow it
if not password:
return (None, 'Anonymous binding not allowed')
(found_user, err_msg) = self._ldap_single_user_search(username_or_email)
if found_user is None:
return (None, err_msg)
found_dn, found_response = found_user
logger.debug('Found user for LDAP username %s; validating password', username_or_email)
logger.debug('DN %s found: %s', found_dn, found_response)
# First validate the password by binding as the user
try:
with LDAPConnection(self._ldap_uri, found_dn, password.encode('utf-8'),
self._allow_tls_fallback):
pass
except ldap.REFERRAL as re:
referral_dn = self._get_ldap_referral_dn(re)
if not referral_dn:
return (None, 'Invalid username')
try:
with LDAPConnection(self._ldap_uri, referral_dn, password.encode('utf-8'),
self._allow_tls_fallback):
pass
except ldap.INVALID_CREDENTIALS:
logger.debug('Invalid LDAP credentials')
return (None, 'Invalid password')
except ldap.INVALID_CREDENTIALS:
logger.debug('Invalid LDAP credentials')
return (None, 'Invalid password')
return self._build_user_information(found_response)
def service_metadata(self):
return {
'base_dn': self._base_dn,
}
def check_group_lookup_args(self, group_lookup_args, disable_pagination=False):
if not group_lookup_args.get('group_dn'):
return (False, 'Missing group_dn')
(it, err) = self.iterate_group_members(group_lookup_args, page_size=1,
disable_pagination=disable_pagination)
if err is not None:
return (False, err)
if not next(it, False):
return (False, 'Group does not exist or is empty')
return (True, None)
def iterate_group_members(self, group_lookup_args, page_size=None, disable_pagination=False):
try:
with self._ldap.get_connection():
pass
except ldap.INVALID_CREDENTIALS:
return (None, 'LDAP Admin dn or password is invalid')
group_dn = group_lookup_args['group_dn']
page_size = page_size or _DEFAULT_PAGE_SIZE
return (self._iterate_members(group_dn, page_size, disable_pagination), None)
def _iterate_members(self, group_dn, page_size, disable_pagination):
has_pagination = not(self._force_no_pagination or disable_pagination)
with self._ldap.get_connection() as conn:
search_flt = filter_format('(memberOf=%s,%s)', (group_dn, self._base_dn))
attributes = [self._uid_attr, self._email_attr]
for user_search_dn in self._user_dns:
lc = ldap.controls.libldap.SimplePagedResultsControl(criticality=True, size=page_size,
cookie='')
# Conduct the initial search for users that are a member of the group.
logger.debug('Conducting LDAP search of DN: %s and filter %s', user_search_dn, search_flt)
try:
if has_pagination:
msgid = conn.search_ext(user_search_dn, ldap.SCOPE_SUBTREE, search_flt,
serverctrls=[lc], attrlist=attributes)
else:
msgid = conn.search(user_search_dn, ldap.SCOPE_SUBTREE, search_flt, attrlist=attributes)
except ldap.LDAPError as lde:
logger.exception('Got error when trying to search %s with filter %s: %s',
user_search_dn, search_flt, lde.message)
break
while True:
try:
if has_pagination:
_, rdata, _, serverctrls = conn.result3(msgid)
else:
_, rdata = conn.result(msgid)
# Yield any users found.
found_results = 0
for userdata in rdata:
found_results = found_results + 1
yield self._build_user_information(userdata[1])
logger.debug('Found %s users in group %s; %s', found_results, user_search_dn,
search_flt)
except ldap.NO_SUCH_OBJECT as nsoe:
logger.debug('NSO when trying to lookup results of search %s with filter %s: %s',
user_search_dn, search_flt, nsoe.message)
except ldap.LDAPError as lde:
logger.exception('Error when trying to lookup results of search %s with filter %s: %s',
user_search_dn, search_flt, lde.message)
break
# If no additional results, nothing more to do.
if not found_results:
break
# If pagination is disabled, nothing more to do.
if not has_pagination:
logger.debug('Pagination is disabled, no further queries')
break
# Filter down the controls with which the server responded, looking for the paging
# control type. If not found, then the server does not support pagination and we already
# got all of the results.
pctrls = [control for control in serverctrls
if control.controlType == ldap.controls.SimplePagedResultsControl.controlType]
if pctrls:
# Server supports pagination. Update the cookie so the next search finds the next page,
# then conduct the next search.
cookie = lc.cookie = pctrls[0].cookie
if cookie:
logger.debug('Pagination is supported for this LDAP server; trying next page')
msgid = conn.search_ext(user_search_dn, ldap.SCOPE_SUBTREE, search_flt,
serverctrls=[lc], attrlist=attributes)
continue
else:
# No additional results.
logger.debug('Pagination is supported for this LDAP server but on last page')
break
else:
# Pagination is not supported.
logger.debug('Pagination is not supported for this LDAP server')
break

154
data/users/federated.py Normal file
View file

@ -0,0 +1,154 @@
import logging
import features
from collections import namedtuple
from data import model
from data.users.shared import can_create_user
from util.validation import generate_valid_usernames
logger = logging.getLogger(__name__)
UserInformation = namedtuple('UserInformation', ['username', 'email', 'id'])
DISABLED_MESSAGE = 'User creation is disabled. Please contact your administrator to gain access.'
class FederatedUsers(object):
""" Base class for all federated users systems. """
def __init__(self, federated_service, requires_email):
self._federated_service = federated_service
self._requires_email = requires_email
@property
def federated_service(self):
return self._federated_service
@property
def supports_fresh_login(self):
return True
@property
def supports_encrypted_credentials(self):
return True
def has_password_set(self, username):
return True
@property
def requires_distinct_cli_password(self):
# Since the federated auth provides a password which works on the CLI.
return False
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 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 link_user(self, username_or_email):
(user_info, err_msg) = self.get_user(username_or_email)
if user_info is None:
return (None, err_msg)
return self.get_and_link_federated_user_info(user_info)
def get_and_link_federated_user_info(self, user_info, internal_create=False):
return self._get_and_link_federated_user_info(user_info.username, user_info.email,
internal_create=internal_create)
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.
"""
(credentials, err_msg) = self.verify_credentials(username_or_email, password)
if credentials is None:
return (None, err_msg)
return self._get_and_link_federated_user_info(credentials.username, credentials.email)
def confirm_existing_user(self, username, password):
""" Confirms that the given *database* username and service password are valid for the linked
service. This method is used when the federated service's username is not known.
"""
db_user = model.user.get_user(username)
if not db_user:
return (None, 'Invalid user')
federated_login = model.user.lookup_federated_login(db_user, self._federated_service)
if not federated_login:
return (None, 'Invalid user')
(credentials, err_msg) = self.verify_credentials(federated_login.service_ident, password)
if credentials is None:
return (None, err_msg)
return (db_user, None)
def service_metadata(self):
""" Returns a dictionary of extra metadata to present to *superusers* about this auth engine.
For example, LDAP returns the base DN so we can display to the user during sync setup.
"""
return {}
def check_group_lookup_args(self, group_lookup_args):
""" Verifies that the given group lookup args point to a valid group. Returns a tuple consisting
of a boolean status and an error message (if any).
"""
return (False, 'Not supported')
def iterate_group_members(self, group_lookup_args, page_size=None, disable_pagination=False):
""" Returns an iterator over all the members of the group matching the given lookup args
dictionary. The format of the lookup args dictionary is specific to the implementation.
"""
return (None, 'Not supported')
def _get_and_link_federated_user_info(self, username, email, internal_create=False):
db_user = model.user.verify_federated_login(self._federated_service, username)
if not db_user:
# Fetch list of blacklisted domains
blacklisted_domains = model.config.app_config.get('BLACKLISTED_EMAIL_DOMAINS')
# We must create the user in our db. Check to see if this is allowed (except for internal
# creation, which is always allowed).
if not internal_create and not can_create_user(email, blacklisted_domains):
return (None, DISABLED_MESSAGE)
valid_username = None
for valid_username in generate_valid_usernames(username):
if model.user.is_username_unique(valid_username):
break
if not valid_username:
logger.error('Unable to pick a username for user: %s', username)
return (None, 'Unable to pick a username. Please report this to your administrator.')
prompts = model.user.get_default_user_prompts(features)
try:
db_user = model.user.create_federated_user(valid_username, email, self._federated_service,
username,
set_password_notification=False,
email_required=self._requires_email,
confirm_username=features.USERNAME_CONFIRMATION,
prompts=prompts)
except model.InvalidEmailAddressException as iae:
return (None, str(iae))
else:
# Update the db attributes from the federated service.
if email and db_user.email != email:
db_user.email = email
db_user.save()
return (db_user, None)

300
data/users/keystone.py Normal file
View file

@ -0,0 +1,300 @@
import logging
import os
from keystoneauth1.identity import v2 as keystone_v2_auth
from keystoneauth1.identity import v3 as keystone_v3_auth
from keystoneauth1 import session
from keystoneauth1.exceptions import ClientException
from keystoneclient.v2_0 import client as client_v2
from keystoneclient.v3 import client as client_v3
from keystoneclient.exceptions import AuthorizationFailure as KeystoneAuthorizationFailure
from keystoneclient.exceptions import Unauthorized as KeystoneUnauthorized
from keystoneclient.exceptions import NotFound as KeystoneNotFound
from data.users.federated import FederatedUsers, UserInformation
from util.itertoolrecipes import take
logger = logging.getLogger(__name__)
DEFAULT_TIMEOUT = 10 # seconds
def get_keystone_users(auth_version, auth_url, admin_username, admin_password, admin_tenant,
timeout=None, requires_email=True):
if auth_version == 3:
return KeystoneV3Users(auth_url, admin_username, admin_password, admin_tenant, timeout,
requires_email)
else:
return KeystoneV2Users(auth_url, admin_username, admin_password, admin_tenant, timeout,
requires_email)
class KeystoneV2Users(FederatedUsers):
""" Delegates authentication to OpenStack Keystone V2. """
def __init__(self, auth_url, admin_username, admin_password, admin_tenant, timeout=None,
requires_email=True):
super(KeystoneV2Users, self).__init__('keystone', requires_email)
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'
self.requires_email = requires_email
def _get_client(self, username, password, tenant_name=None):
if tenant_name:
auth = keystone_v2_auth.Password(auth_url=self.auth_url,
username=username,
password=password,
tenant_name=tenant_name)
else:
auth = keystone_v2_auth.Password(auth_url=self.auth_url,
username=username,
password=password)
sess = session.Session(auth=auth)
client = client_v2.Client(session=sess,
timeout=self.timeout,
debug=self.debug)
return client, sess
def ping(self):
try:
_, sess = self._get_client(self.admin_username, self.admin_password, self.admin_tenant)
assert sess.get_user_id() # Make sure we loaded a valid user.
except KeystoneUnauthorized as kut:
logger.exception('Keystone unauthorized admin')
return (False, 'Keystone admin credentials are invalid: %s' % kut.message)
except ClientException as e:
logger.exception('Keystone unauthorized admin')
return (False, 'Keystone ping check failed: %s' % e.message)
return (True, None)
def at_least_one_user_exists(self):
logger.debug('Checking if any users exist in Keystone')
try:
keystone_client, _ = self._get_client(self.admin_username, self.admin_password,
self.admin_tenant)
user_list = keystone_client.users.list(tenant_id=self.admin_tenant, limit=1)
if len(user_list) < 1:
return (False, None)
return (True, None)
except ClientException as e:
# Catch exceptions to give the user our custom error message
logger.exception('Unable to list users in Keystone')
return (False, e.message)
def verify_credentials(self, username_or_email, password):
try:
_, sess = self._get_client(username_or_email, password)
user_id = sess.get_user_id()
except KeystoneAuthorizationFailure as kaf:
logger.exception('Keystone auth failure for user: %s', username_or_email)
return (None, 'Invalid username or password')
except KeystoneUnauthorized as kut:
logger.exception('Keystone unauthorized for user: %s', username_or_email)
return (None, 'Invalid username or password')
except ClientException as ex:
logger.exception('Keystone unauthorized for user: %s', username_or_email)
return (None, 'Invalid username or password')
if user_id is None:
return (None, 'Invalid username or password')
try:
admin_client, _ = self._get_client(self.admin_username, self.admin_password,
self.admin_tenant)
user = admin_client.users.get(user_id)
except KeystoneUnauthorized as kut:
logger.exception('Keystone unauthorized admin')
return (None, 'Keystone admin credentials are invalid: %s' % kut.message)
if self.requires_email and not hasattr(user, 'email'):
return (None, 'Missing email field for user %s' % user_id)
email = user.email if hasattr(user, 'email') else None
return (UserInformation(username=username_or_email, email=email, id=user_id), None)
def query_users(self, query, limit=20):
return (None, self.federated_service, '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,
requires_email=True, project_domain_id='default', user_domain_id='default'):
super(KeystoneV3Users, self).__init__('keystone', requires_email)
self.auth_url = auth_url
self.admin_username = admin_username
self.admin_password = admin_password
self.admin_tenant = admin_tenant
self.project_domain_id = project_domain_id
self.user_domain_id = user_domain_id
self.timeout = timeout or DEFAULT_TIMEOUT
self.debug = os.environ.get('USERS_DEBUG') == '1'
self.requires_email = requires_email
def _get_client(self, username, password, project_name=None):
if project_name:
auth = keystone_v3_auth.Password(auth_url=self.auth_url,
username=username,
password=password,
project_name=project_name,
project_domain_id=self.project_domain_id,
user_domain_id=self.user_domain_id)
else:
auth = keystone_v3_auth.Password(auth_url=self.auth_url,
username=username,
password=password,
user_domain_id=self.user_domain_id)
sess = session.Session(auth=auth)
client = client_v3.Client(session=sess,
timeout=self.timeout,
debug=self.debug)
return client, sess
def ping(self):
try:
_, sess = self._get_client(self.admin_username, self.admin_password)
assert sess.get_user_id() # Make sure we loaded a valid user.
except KeystoneUnauthorized as kut:
logger.exception('Keystone unauthorized admin')
return (False, 'Keystone admin credentials are invalid: %s' % kut.message)
except ClientException as cle:
logger.exception('Keystone unauthorized admin')
return (False, 'Keystone ping check failed: %s' % cle.message)
return (True, None)
def at_least_one_user_exists(self):
logger.debug('Checking if any users exist in admin tenant in Keystone')
try:
# Just make sure the admin can connect to the project.
self._get_client(self.admin_username, self.admin_password, self.admin_tenant)
return (True, None)
except ClientException as cle:
# Catch exceptions to give the user our custom error message
logger.exception('Unable to list users in Keystone')
return (False, cle.message)
def verify_credentials(self, username_or_email, password):
try:
keystone_client, sess = self._get_client(username_or_email, password)
user_id = sess.get_user_id()
assert user_id
keystone_client, sess = self._get_client(self.admin_username, self.admin_password,
self.admin_tenant)
user = keystone_client.users.get(user_id)
if self.requires_email and not hasattr(user, 'email'):
return (None, 'Missing email field for user %s' % 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, 'Invalid username or password')
except KeystoneUnauthorized as kut:
logger.exception('Keystone unauthorized for user: %s', username_or_email)
return (None, 'Invalid username or password')
except ClientException as cle:
logger.exception('Keystone unauthorized for user: %s', username_or_email)
return (None, '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')
user = users_found[0]
if self.requires_email and not user.email:
return (None, 'Missing email field for user %s' % user.id)
return (user, None)
def check_group_lookup_args(self, group_lookup_args):
if not group_lookup_args.get('group_id'):
return (False, 'Missing group_id')
group_id = group_lookup_args['group_id']
return self._check_group(group_id)
def _check_group(self, group_id):
try:
admin_client, _ = self._get_client(self.admin_username, self.admin_password,
self.admin_tenant)
return (bool(admin_client.groups.get(group_id)), None)
except KeystoneNotFound:
return (False, 'Group not found')
except KeystoneAuthorizationFailure as kaf:
logger.exception('Keystone auth failure for admin user for group lookup %s', group_id)
return (False, kaf.message or 'Invalid admin username or password')
except KeystoneUnauthorized as kut:
logger.exception('Keystone unauthorized for admin user for group lookup %s', group_id)
return (False, kut.message or 'Invalid admin username or password')
except ClientException as cle:
logger.exception('Keystone unauthorized for admin user for group lookup %s', group_id)
return (False, cle.message or 'Invalid admin username or password')
def iterate_group_members(self, group_lookup_args, page_size=None, disable_pagination=False):
group_id = group_lookup_args['group_id']
(status, err) = self._check_group(group_id)
if not status:
return (None, err)
try:
admin_client, _ = self._get_client(self.admin_username, self.admin_password,
self.admin_tenant)
user_info_iterator = admin_client.users.list(group=group_id)
def iterator():
for user in user_info_iterator:
yield (self._user_info(user), None)
return (iterator(), None)
except KeystoneAuthorizationFailure as kaf:
logger.exception('Keystone auth failure for admin user for group lookup %s', group_id)
return (False, kaf.message or 'Invalid admin username or password')
except KeystoneUnauthorized as kut:
logger.exception('Keystone unauthorized for admin user for group lookup %s', group_id)
return (False, kut.message or 'Invalid admin username or password')
except ClientException as cle:
logger.exception('Keystone unauthorized for admin user for group lookup %s', group_id)
return (False, cle.message or 'Invalid admin username or password')
@staticmethod
def _user_info(user):
email = user.email if hasattr(user, 'email') else None
return UserInformation(user.name, email, user.id)
def query_users(self, query, limit=20):
if len(query) < 3:
return ([], self.federated_service, None)
try:
admin_client, _ = self._get_client(self.admin_username, self.admin_password,
self.admin_tenant)
found_users = list(take(limit, admin_client.users.list(name=query)))
logger.debug('For Keystone query %s found users: %s', query, found_users)
if not found_users:
return ([], self.federated_service, None)
return ([self._user_info(user) for user in found_users], self.federated_service, None)
except KeystoneAuthorizationFailure as kaf:
logger.exception('Keystone auth failure for admin user for query %s', query)
return (None, self.federated_service, 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, self.federated_service, kut.message or 'Invalid admin username or password')
except ClientException as cle:
logger.exception('Keystone unauthorized for admin user for query %s', query)
return (None, self.federated_service, cle.message or 'Invalid admin username or password')

30
data/users/shared.py Normal file
View file

@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
import tldextract
import features
from data import model
def can_create_user(email_address, blacklisted_domains=None):
""" Returns true if a user with the specified e-mail address can be created. """
if features.BLACKLISTED_EMAILS and email_address and '@' in email_address:
blacklisted_domains = blacklisted_domains or []
_, email_domain = email_address.split('@', 1)
extracted = tldextract.extract(email_domain)
if extracted.registered_domain.lower() in blacklisted_domains:
return False
if not features.USER_CREATION:
return False
if features.INVITE_ONLY_USER_CREATION:
if not email_address:
return False
# Check to see that there is an invite for the e-mail address.
return bool(model.team.lookup_team_invites_by_email(email_address))
# Otherwise the user can be created (assuming it doesn't already exist, of course)
return True

136
data/users/teamsync.py Normal file
View file

@ -0,0 +1,136 @@
import logging
import json
from data import model
logger = logging.getLogger(__name__)
MAX_TEAMS_PER_ITERATION = 500
def sync_teams_to_groups(authentication, stale_cutoff):
""" Performs team syncing by looking up any stale team(s) found, and performing the sync
operation on them.
"""
logger.debug('Looking up teams to sync to groups')
sync_team_tried = set()
while len(sync_team_tried) < MAX_TEAMS_PER_ITERATION:
# Find a stale team.
stale_team_sync = model.team.get_stale_team(stale_cutoff)
if not stale_team_sync:
logger.debug('No additional stale team found; sleeping')
return
# Make sure we don't try to reprocess a team on this iteration.
if stale_team_sync.id in sync_team_tried:
break
sync_team_tried.add(stale_team_sync.id)
# Sync the team.
sync_successful = sync_team(authentication, stale_team_sync)
if not sync_successful:
return
def sync_team(authentication, stale_team_sync):
""" Performs synchronization of a team (as referenced by the TeamSync stale_team_sync).
Returns True on success and False otherwise.
"""
sync_config = json.loads(stale_team_sync.config)
logger.info('Syncing team `%s` under organization %s via %s (#%s)', stale_team_sync.team.name,
stale_team_sync.team.organization.username, sync_config, stale_team_sync.team_id,
extra={'team': stale_team_sync.team_id, 'sync_config': sync_config})
# Load all the existing members of the team in Quay that are bound to the auth service.
existing_users = model.team.get_federated_team_member_mapping(stale_team_sync.team,
authentication.federated_service)
logger.debug('Existing membership of %s for team `%s` under organization %s via %s (#%s)',
len(existing_users), stale_team_sync.team.name,
stale_team_sync.team.organization.username, sync_config, stale_team_sync.team_id,
extra={'team': stale_team_sync.team_id, 'sync_config': sync_config,
'existing_member_count': len(existing_users)})
# Load all the members of the team from the authenication system.
(member_iterator, err) = authentication.iterate_group_members(sync_config)
if err is not None:
logger.error('Got error when trying to iterate group members with config %s: %s',
sync_config, err)
return False
# Collect all the members currently found in the group, adding them to the team as we go
# along.
group_membership = set()
for (member_info, err) in member_iterator:
if err is not None:
logger.error('Got error when trying to construct a member: %s', err)
continue
# If the member is already in the team, nothing more to do.
if member_info.username in existing_users:
logger.debug('Member %s already in team `%s` under organization %s via %s (#%s)',
member_info.username, stale_team_sync.team.name,
stale_team_sync.team.organization.username, sync_config,
stale_team_sync.team_id,
extra={'team': stale_team_sync.team_id, 'sync_config': sync_config,
'member': member_info.username})
group_membership.add(existing_users[member_info.username])
continue
# Retrieve the Quay user associated with the member info.
(quay_user, err) = authentication.get_and_link_federated_user_info(member_info,
internal_create=True)
if err is not None:
logger.error('Could not link external user %s to an internal user: %s',
member_info.username, err,
extra={'team': stale_team_sync.team_id, 'sync_config': sync_config,
'member': member_info.username, 'error': err})
continue
# Add the user to the membership set.
group_membership.add(quay_user.id)
# Add the user to the team.
try:
logger.info('Adding member %s to team `%s` under organization %s via %s (#%s)',
quay_user.username, stale_team_sync.team.name,
stale_team_sync.team.organization.username, sync_config,
stale_team_sync.team_id,
extra={'team': stale_team_sync.team_id, 'sync_config': sync_config,
'member': quay_user.username})
model.team.add_user_to_team(quay_user, stale_team_sync.team)
except model.UserAlreadyInTeam:
# If the user is already present, nothing more to do for them.
pass
# Update the transaction and last_updated time of the team sync. Only if it matches
# the current value will we then perform the deletion step.
got_transaction_handle = model.team.update_sync_status(stale_team_sync)
if not got_transaction_handle:
# Another worker updated this team. Nothing more to do.
logger.debug('Another worker synced team `%s` under organization %s via %s (#%s)',
stale_team_sync.team.name,
stale_team_sync.team.organization.username, sync_config,
stale_team_sync.team_id,
extra={'team': stale_team_sync.team_id, 'sync_config': sync_config})
return True
# Delete any team members not found in the backing auth system.
logger.debug('Deleting stale members for team `%s` under organization %s via %s (#%s)',
stale_team_sync.team.name, stale_team_sync.team.organization.username,
sync_config, stale_team_sync.team_id,
extra={'team': stale_team_sync.team_id, 'sync_config': sync_config})
deleted = model.team.delete_members_not_present(stale_team_sync.team, group_membership)
# Done!
logger.info('Finishing sync for team `%s` under organization %s via %s (#%s): %s deleted',
stale_team_sync.team.name, stale_team_sync.team.organization.username,
sync_config, stale_team_sync.team_id, deleted,
extra={'team': stale_team_sync.team_id, 'sync_config': sync_config})
return True

View file

@ -0,0 +1,55 @@
import pytest
from mock import patch
from data.database import model
from data.users.shared import can_create_user
from test.fixtures import *
@pytest.mark.parametrize('open_creation, invite_only, email, has_invite, can_create', [
# Open user creation => always allowed.
(True, False, None, False, True),
# Open user creation => always allowed.
(True, False, 'foo@example.com', False, True),
# Invite only user creation + no invite => disallowed.
(True, True, None, False, False),
# Invite only user creation + no invite => disallowed.
(True, True, 'foo@example.com', False, False),
# Invite only user creation + invite => allowed.
(True, True, 'foo@example.com', True, True),
# No open creation => Disallowed.
(False, True, 'foo@example.com', False, False),
(False, True, 'foo@example.com', True, False),
# Blacklisted emails => Disallowed.
(True, False, 'foo@blacklisted.com', False, False),
(True, False, 'foo@blacklisted.org', False, False),
(True, False, 'foo@BlAcKlIsTeD.CoM', False, False), # Verify Capitalization
(True, False, u'foo@mail.bLacklisted.Com', False, False), # Verify unicode
(True, False, 'foo@blacklisted.net', False, True), # Avoid False Positives
(True, False, 'foo@myblacklisted.com', False, True), # Avoid partial domain matches
(True, False, 'fooATblacklisted.com', False, True), # Ignore invalid email addresses
])
@pytest.mark.parametrize('blacklisting_enabled', [True, False])
def test_can_create_user(open_creation, invite_only, email, has_invite, can_create, blacklisting_enabled, app):
# Mock list of blacklisted domains
blacklisted_domains = ['blacklisted.com', 'blacklisted.org']
if has_invite:
inviter = model.user.get_user('devtable')
team = model.team.get_organization_team('buynlarge', 'owners')
model.team.add_or_invite_to_team(inviter, team, email=email)
with patch('features.USER_CREATION', open_creation):
with patch('features.INVITE_ONLY_USER_CREATION', invite_only):
with patch('features.BLACKLISTED_EMAILS', blacklisting_enabled):
if email and any(domain in email.lower() for domain in blacklisted_domains) and not blacklisting_enabled:
can_create = True # blacklisted domains can be used, if blacklisting is disabled
assert can_create_user(email, blacklisted_domains) == can_create

View file

@ -0,0 +1,332 @@
import os
from datetime import datetime, timedelta
import pytest
from mock import patch
from data import model, database
from data.users.federated import FederatedUsers, UserInformation
from data.users.teamsync import sync_team, sync_teams_to_groups
from test.test_ldap import mock_ldap
from test.test_keystone_auth import fake_keystone
from util.names import parse_robot_username
from test.fixtures import *
_FAKE_AUTH = 'fake'
class FakeUsers(FederatedUsers):
def __init__(self, group_members):
super(FakeUsers, self).__init__(_FAKE_AUTH, False)
self.group_tuples = [(m, None) for m in group_members]
def iterate_group_members(self, group_lookup_args, page_size=None, disable_pagination=False):
return (self.group_tuples, None)
@pytest.fixture(params=[True, False])
def user_creation(request):
with patch('features.USER_CREATION', request.param):
yield
@pytest.fixture(params=[True, False])
def invite_only_user_creation(request):
with patch('features.INVITE_ONLY_USER_CREATION', request.param):
yield
@pytest.fixture(params=[True, False])
def blacklisted_emails(request):
mock_blacklisted_domains = {'BLACKLISTED_EMAIL_DOMAINS': ['blacklisted.com', 'blacklisted.net']}
with patch('features.BLACKLISTED_EMAILS', request.param):
with patch.dict('data.model.config.app_config', mock_blacklisted_domains):
yield
@pytest.mark.skipif(os.environ.get('TEST_DATABASE_URI', '').find('postgres') >= 0,
reason="Postgres fails when existing members are added under the savepoint")
@pytest.mark.parametrize('starting_membership,group_membership,expected_membership', [
# Empty team + single member in group => Single member in team.
([],
[
UserInformation('someuser', 'someuser', 'someuser@devtable.com'),
],
['someuser']),
# Team with a Quay user + empty group => empty team.
([('someuser', None)],
[],
[]),
# Team with an existing external user + user is in the group => no changes.
([
('someuser', 'someuser'),
],
[
UserInformation('someuser', 'someuser', 'someuser@devtable.com'),
],
['someuser']),
# Team with an existing external user (with a different Quay username) + user is in the group.
# => no changes
([
('anotherquayname', 'someuser'),
],
[
UserInformation('someuser', 'someuser', 'someuser@devtable.com'),
],
['someuser']),
# Team missing a few members that are in the group => members added.
([('someuser', 'someuser')],
[
UserInformation('anotheruser', 'anotheruser', 'anotheruser@devtable.com'),
UserInformation('someuser', 'someuser', 'someuser@devtable.com'),
UserInformation('thirduser', 'thirduser', 'thirduser@devtable.com'),
],
['anotheruser', 'someuser', 'thirduser']),
# Team has a few extra members no longer in the group => members removed.
([
('anotheruser', 'anotheruser'),
('someuser', 'someuser'),
('thirduser', 'thirduser'),
('nontestuser', None),
],
[
UserInformation('thirduser', 'thirduser', 'thirduser@devtable.com'),
],
['thirduser']),
# Team has different membership than the group => members added and removed.
([
('anotheruser', 'anotheruser'),
('someuser', 'someuser'),
('nontestuser', None),
],
[
UserInformation('anotheruser', 'anotheruser', 'anotheruser@devtable.com'),
UserInformation('missinguser', 'missinguser', 'missinguser@devtable.com'),
],
['anotheruser', 'missinguser']),
# Team has same membership but some robots => robots remain and no other changes.
([
('someuser', 'someuser'),
('buynlarge+anotherbot', None),
('buynlarge+somerobot', None),
],
[
UserInformation('someuser', 'someuser', 'someuser@devtable.com'),
],
['someuser', 'buynlarge+somerobot', 'buynlarge+anotherbot']),
# Team has an extra member and some robots => member removed and robots remain.
([
('someuser', 'someuser'),
('buynlarge+anotherbot', None),
('buynlarge+somerobot', None),
],
[
# No members.
],
['buynlarge+somerobot', 'buynlarge+anotherbot']),
# Team has a different member and some robots => member changed and robots remain.
([
('someuser', 'someuser'),
('buynlarge+anotherbot', None),
('buynlarge+somerobot', None),
],
[
UserInformation('anotheruser', 'anotheruser', 'anotheruser@devtable.com'),
],
['anotheruser', 'buynlarge+somerobot', 'buynlarge+anotherbot']),
# Team with an existing external user (with a different Quay username) + user is in the group.
# => no changes and robots remain.
([
('anotherquayname', 'someuser'),
('buynlarge+anotherbot', None),
],
[
UserInformation('someuser', 'someuser', 'someuser@devtable.com'),
],
['someuser', 'buynlarge+anotherbot']),
# Team which returns the same member twice, as pagination in some engines (like LDAP) is not
# stable.
([],
[
UserInformation('someuser', 'someuser', 'someuser@devtable.com'),
UserInformation('anotheruser', 'anotheruser', 'anotheruser@devtable.com'),
UserInformation('someuser', 'someuser', 'someuser@devtable.com'),
],
['anotheruser', 'someuser']),
])
def test_syncing(user_creation, invite_only_user_creation, starting_membership, group_membership,
expected_membership, blacklisted_emails, app):
org = model.organization.get_organization('buynlarge')
# Necessary for the fake auth entries to be created in FederatedLogin.
database.LoginService.create(name=_FAKE_AUTH)
# Assert the team is empty, so we have a clean slate.
sync_team_info = model.team.get_team_sync_information('buynlarge', 'synced')
assert len(list(model.team.list_team_users(sync_team_info.team))) == 0
# Add the existing starting members to the team.
for starting_member in starting_membership:
(quay_username, fakeauth_username) = starting_member
if '+' in quay_username:
# Add a robot.
(_, shortname) = parse_robot_username(quay_username)
robot, _ = model.user.create_robot(shortname, org)
model.team.add_user_to_team(robot, sync_team_info.team)
else:
email = quay_username + '@devtable.com'
if fakeauth_username is None:
quay_user = model.user.create_user_noverify(quay_username, email)
else:
quay_user = model.user.create_federated_user(quay_username, email, _FAKE_AUTH,
fakeauth_username, False)
model.team.add_user_to_team(quay_user, sync_team_info.team)
# Call syncing on the team.
fake_auth = FakeUsers(group_membership)
assert sync_team(fake_auth, sync_team_info)
# Ensure the last updated time and transaction_id's have changed.
updated_sync_info = model.team.get_team_sync_information('buynlarge', 'synced')
assert updated_sync_info.last_updated is not None
assert updated_sync_info.transaction_id != sync_team_info.transaction_id
users_expected = set([name for name in expected_membership if '+' not in name])
robots_expected = set([name for name in expected_membership if '+' in name])
assert len(users_expected) + len(robots_expected) == len(expected_membership)
# Check that the team's users match those expected.
service_user_map = model.team.get_federated_team_member_mapping(sync_team_info.team,
_FAKE_AUTH)
assert set(service_user_map.keys()) == users_expected
quay_users = model.team.list_team_users(sync_team_info.team)
assert len(quay_users) == len(users_expected)
for quay_user in quay_users:
fakeauth_record = model.user.lookup_federated_login(quay_user, _FAKE_AUTH)
assert fakeauth_record is not None
assert fakeauth_record.service_ident in users_expected
assert service_user_map[fakeauth_record.service_ident] == quay_user.id
# Check that the team's robots match those expected.
robots_found = set([r.username for r in model.team.list_team_robots(sync_team_info.team)])
assert robots_expected == robots_found
def test_sync_teams_to_groups(user_creation, invite_only_user_creation, blacklisted_emails, app):
# Necessary for the fake auth entries to be created in FederatedLogin.
database.LoginService.create(name=_FAKE_AUTH)
# Assert the team has not yet been updated.
sync_team_info = model.team.get_team_sync_information('buynlarge', 'synced')
assert sync_team_info.last_updated is None
# Call to sync all teams.
fake_auth = FakeUsers([])
sync_teams_to_groups(fake_auth, timedelta(seconds=1))
# Ensure the team was synced.
updated_sync_info = model.team.get_team_sync_information('buynlarge', 'synced')
assert updated_sync_info.last_updated is not None
assert updated_sync_info.transaction_id != sync_team_info.transaction_id
# Set the stale threshold to a high amount and ensure the team is not resynced.
current_info = model.team.get_team_sync_information('buynlarge', 'synced')
current_info.last_updated = datetime.now() - timedelta(seconds=2)
current_info.save()
sync_teams_to_groups(fake_auth, timedelta(seconds=120))
third_sync_info = model.team.get_team_sync_information('buynlarge', 'synced')
assert third_sync_info.transaction_id == updated_sync_info.transaction_id
# Set the stale threshold to 10 seconds, and ensure the team is resynced, after making it
# "updated" 20s ago.
current_info = model.team.get_team_sync_information('buynlarge', 'synced')
current_info.last_updated = datetime.now() - timedelta(seconds=20)
current_info.save()
sync_teams_to_groups(fake_auth, timedelta(seconds=10))
fourth_sync_info = model.team.get_team_sync_information('buynlarge', 'synced')
assert fourth_sync_info.transaction_id != updated_sync_info.transaction_id
@pytest.mark.parametrize('auth_system_builder,config', [
(mock_ldap, {'group_dn': 'cn=AwesomeFolk'}),
(fake_keystone, {'group_id': 'somegroupid'}),
])
def test_teamsync_end_to_end(user_creation, invite_only_user_creation, auth_system_builder, config,
blacklisted_emails, app):
with auth_system_builder() as auth:
# Create an new team to sync.
org = model.organization.get_organization('buynlarge')
new_synced_team = model.team.create_team('synced2', org, 'member', 'Some synced team.')
sync_team_info = model.team.set_team_syncing(new_synced_team, auth.federated_service, config)
# Sync the team.
assert sync_team(auth, sync_team_info)
# Ensure we now have members.
msg = 'Auth system: %s' % auth.federated_service
sync_team_info = model.team.get_team_sync_information('buynlarge', 'synced2')
team_members = list(model.team.list_team_users(sync_team_info.team))
assert len(team_members) > 1, msg
it, _ = auth.iterate_group_members(config)
assert len(team_members) == len(list(it)), msg
sync_team_info.last_updated = datetime.now() - timedelta(hours=6)
sync_team_info.save()
# Remove one of the members and force a sync again to ensure we re-link the correct users.
first_member = team_members[0]
model.team.remove_user_from_team('buynlarge', 'synced2', first_member.username, 'devtable')
team_members2 = list(model.team.list_team_users(sync_team_info.team))
assert len(team_members2) == 1, msg
assert sync_team(auth, sync_team_info)
team_members3 = list(model.team.list_team_users(sync_team_info.team))
assert len(team_members3) > 1, msg
assert set([m.id for m in team_members]) == set([m.id for m in team_members3])
@pytest.mark.parametrize('auth_system_builder,config', [
(mock_ldap, {'group_dn': 'cn=AwesomeFolk'}),
(fake_keystone, {'group_id': 'somegroupid'}),
])
def test_teamsync_existing_email(user_creation, invite_only_user_creation, auth_system_builder,
blacklisted_emails, config, app):
with auth_system_builder() as auth:
# Create an new team to sync.
org = model.organization.get_organization('buynlarge')
new_synced_team = model.team.create_team('synced2', org, 'member', 'Some synced team.')
sync_team_info = model.team.set_team_syncing(new_synced_team, auth.federated_service, config)
# Add a new *unlinked* user with the same email address as one of the team members.
it, _ = auth.iterate_group_members(config)
members = list(it)
model.user.create_user_noverify('someusername', members[0][0].email)
# Sync the team and ensure it doesn't fail.
assert sync_team(auth, sync_team_info)
team_members = list(model.team.list_team_users(sync_team_info.team))
assert len(team_members) > 0

View file

@ -0,0 +1,99 @@
import pytest
from contextlib import contextmanager
from mock import patch
from data.database import model
from data.users.federated import DISABLED_MESSAGE
from test.test_ldap import mock_ldap
from test.test_keystone_auth import fake_keystone
from test.test_external_jwt_authn import fake_jwt
from test.fixtures import *
@pytest.mark.parametrize('auth_system_builder, user1, user2', [
(mock_ldap, ('someuser', 'somepass'), ('testy', 'password')),
(fake_keystone, ('cool.user', 'password'), ('some.neat.user', 'foobar')),
])
def test_auth_createuser(auth_system_builder, user1, user2, config, app):
with auth_system_builder() as auth:
# Login as a user and ensure a row in the database is created for them.
user, err = auth.verify_and_link_user(*user1)
assert err is None
assert user
federated_info = model.user.lookup_federated_login(user, auth.federated_service)
assert federated_info is not None
# Disable user creation.
with patch('features.USER_CREATION', False):
# Ensure that the existing user can login.
user_again, err = auth.verify_and_link_user(*user1)
assert err is None
assert user_again.id == user.id
# Ensure that a new user cannot.
new_user, err = auth.verify_and_link_user(*user2)
assert new_user is None
assert err == DISABLED_MESSAGE
@pytest.mark.parametrize(
'email, blacklisting_enabled, can_create',
[
# Blacklisting Enabled, Blacklisted Domain => Blocked
('foo@blacklisted.net', True, False),
('foo@blacklisted.com', True, False),
# Blacklisting Enabled, similar to blacklisted domain => Allowed
('foo@notblacklisted.com', True, True),
('foo@blacklisted.org', True, True),
# Blacklisting *Disabled*, Blacklisted Domain => Allowed
('foo@blacklisted.com', False, True),
('foo@blacklisted.net', False, True),
]
)
@pytest.mark.parametrize('auth_system_builder', [mock_ldap, fake_keystone, fake_jwt])
def test_createuser_with_blacklist(auth_system_builder, email, blacklisting_enabled, can_create, config, app):
"""Verify email blacklisting with User Creation"""
MOCK_CONFIG = {'BLACKLISTED_EMAIL_DOMAINS': ['blacklisted.com', 'blacklisted.net']}
MOCK_PASSWORD = 'somepass'
with auth_system_builder() as auth:
with patch('features.BLACKLISTED_EMAILS', blacklisting_enabled):
with patch.dict('data.model.config.app_config', MOCK_CONFIG):
with patch('features.USER_CREATION', True):
new_user, err = auth.verify_and_link_user(email, MOCK_PASSWORD)
if can_create:
assert err is None
assert new_user
else:
assert err
assert new_user is None
@pytest.mark.parametrize('auth_system_builder,auth_kwargs', [
(mock_ldap, {}),
(fake_keystone, {'version': 3}),
(fake_keystone, {'version': 2}),
(fake_jwt, {}),
])
def test_ping(auth_system_builder, auth_kwargs, app):
with auth_system_builder(**auth_kwargs) as auth:
status, err = auth.ping()
assert status
assert err is None
@pytest.mark.parametrize('auth_system_builder,auth_kwargs', [
(mock_ldap, {}),
(fake_keystone, {'version': 3}),
(fake_keystone, {'version': 2}),
])
def test_at_least_one_user_exists(auth_system_builder, auth_kwargs, app):
with auth_system_builder(**auth_kwargs) as auth:
status, err = auth.at_least_one_user_exists()
assert status
assert err is None