initial import for Open Source 🎉
This commit is contained in:
parent
1898c361f3
commit
9c0dd3b722
2048 changed files with 218743 additions and 0 deletions
263
data/users/__init__.py
Normal file
263
data/users/__init__.py
Normal 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
67
data/users/apptoken.py
Normal 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
66
data/users/database.py
Normal 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
128
data/users/externaljwt.py
Normal 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
413
data/users/externalldap.py
Normal 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
154
data/users/federated.py
Normal 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
300
data/users/keystone.py
Normal 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
30
data/users/shared.py
Normal 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
136
data/users/teamsync.py
Normal 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
|
55
data/users/test/test_shared.py
Normal file
55
data/users/test/test_shared.py
Normal 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
|
332
data/users/test/test_teamsync.py
Normal file
332
data/users/test/test_teamsync.py
Normal 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
|
99
data/users/test/test_users.py
Normal file
99
data/users/test/test_users.py
Normal 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
|
Reference in a new issue