From 1763d373ebd6f70af0a96d67fe61cea3478e3297 Mon Sep 17 00:00:00 2001 From: Chris Church Date: Fri, 6 Sep 2013 17:11:38 -0400 Subject: [PATCH] AC-156. Add dependencies for LDAP support. --- awx/lib/site-packages/README | 1 + .../django_auth_ldap/__init__.py | 2 + .../site-packages/django_auth_ldap/backend.py | 859 ++++++++++ .../site-packages/django_auth_ldap/config.py | 522 ++++++ awx/lib/site-packages/django_auth_ldap/dn.py | 32 + .../site-packages/django_auth_ldap/models.py | 31 + .../site-packages/django_auth_ldap/tests.py | 1401 +++++++++++++++++ requirements/dev.txt | 2 + requirements/dev_local.txt | 2 + requirements/django-auth-ldap-1.1.4.tar.gz | Bin 0 -> 39252 bytes requirements/prod.txt | 2 + requirements/prod_local.txt | 2 + 12 files changed, 2856 insertions(+) create mode 100644 awx/lib/site-packages/django_auth_ldap/__init__.py create mode 100644 awx/lib/site-packages/django_auth_ldap/backend.py create mode 100644 awx/lib/site-packages/django_auth_ldap/config.py create mode 100644 awx/lib/site-packages/django_auth_ldap/dn.py create mode 100644 awx/lib/site-packages/django_auth_ldap/models.py create mode 100644 awx/lib/site-packages/django_auth_ldap/tests.py create mode 100644 requirements/django-auth-ldap-1.1.4.tar.gz diff --git a/awx/lib/site-packages/README b/awx/lib/site-packages/README index aa3205363d..137586591a 100644 --- a/awx/lib/site-packages/README +++ b/awx/lib/site-packages/README @@ -5,6 +5,7 @@ amqp-1.0.13 (amqp/*) anyjson-0.3.3 (anyjson/*) billiard-2.7.3.32 (billiard/*, funtests/*, excluded _billiard.so) celery-3.0.22 (celery/*, excluded bin/celery* and bin/camqadm) +django-auth-ldap-1.1.4 (django_auth_ldap/*) django-celery-3.0.21 (djcelery/*, excluded bin/djcelerymon) django-extensions-1.2.0 (django_extensions/*) django-jsonfield-0.9.10 (jsonfield/*) diff --git a/awx/lib/site-packages/django_auth_ldap/__init__.py b/awx/lib/site-packages/django_auth_ldap/__init__.py new file mode 100644 index 0000000000..88ef52114c --- /dev/null +++ b/awx/lib/site-packages/django_auth_ldap/__init__.py @@ -0,0 +1,2 @@ +version = (1, 1, 4) +version_string = "1.1.4" diff --git a/awx/lib/site-packages/django_auth_ldap/backend.py b/awx/lib/site-packages/django_auth_ldap/backend.py new file mode 100644 index 0000000000..3dd15a51ae --- /dev/null +++ b/awx/lib/site-packages/django_auth_ldap/backend.py @@ -0,0 +1,859 @@ +# Copyright (c) 2009, Peter Sagerson +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# - Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# - Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +""" +LDAP authentication backend + +Complete documentation can be found in docs/howto/auth-ldap.txt (or the thing it +compiles to). + +Use of this backend requires the python-ldap module. To support unit tests, we +import ldap in a single centralized place (config._LDAPConfig) so that the test +harness can insert a mock object. + +A few notes on naming conventions. If an identifier ends in _dn, it is a string +representation of a distinguished name. If it ends in _info, it is a 2-tuple +containing a DN and a dictionary of lists of attributes. ldap.search_s returns a +list of such structures. An identifier that ends in _attrs is the dictionary of +attributes from the _info structure. + +A connection is an LDAPObject that has been successfully bound with a DN and +password. The identifier 'user' always refers to a User model object; LDAP user +information will be user_dn or user_info. + +Additional classes can be found in the config module next to this one. +""" + +try: + set +except NameError: + from sets import Set as set # Python 2.3 fallback + +import sys +import traceback +import pprint +import copy + +import django.db +from django.contrib.auth.models import User, Group, Permission, SiteProfileNotAvailable +from django.core.cache import cache +from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist +import django.dispatch + +# Support Django 1.5's custom user models +try: + from django.contrib.auth import get_user_model + get_user_username = lambda u: u.get_username() +except ImportError: + get_user_model = lambda: User + get_user_username = lambda u: u.username + + +from django_auth_ldap.config import _LDAPConfig, LDAPSearch + + +logger = _LDAPConfig.get_logger() + + +# Signals for populating user objects. +populate_user = django.dispatch.Signal(providing_args=["user", "ldap_user"]) +populate_user_profile = django.dispatch.Signal(providing_args=["profile", "ldap_user"]) + + +class LDAPBackend(object): + """ + The main backend class. This implements the auth backend API, although it + actually delegates most of its work to _LDAPUser, which is defined next. + """ + supports_anonymous_user = False + supports_object_permissions = True + supports_inactive_user = False + + _settings = None + _ldap = None # The cached ldap module (or mock object) + + # This is prepended to our internal setting names to produce the names we + # expect in Django's settings file. Subclasses can change this in order to + # support multiple collections of settings. + settings_prefix = 'AUTH_LDAP_' + + def __getstate__(self): + """ + Exclude certain cached properties from pickling. + """ + state = filter( + lambda (k, v): k not in ['_settings', '_ldap'], + self.__dict__.iteritems() + ) + + return dict(state) + + def _get_settings(self): + if self._settings is None: + self._settings = LDAPSettings(self.settings_prefix) + + return self._settings + + def _set_settings(self, settings): + self._settings = settings + + settings = property(_get_settings, _set_settings) + + def _get_ldap(self): + if self._ldap is None: + from django.conf import settings + + options = getattr(settings, 'AUTH_LDAP_GLOBAL_OPTIONS', None) + + self._ldap = _LDAPConfig.get_ldap(options) + + return self._ldap + ldap = property(_get_ldap) + + # + # The Django auth backend API + # + + def authenticate(self, username, password): + if len(password) == 0 and not self.settings.PERMIT_EMPTY_PASSWORD: + logger.debug('Rejecting empty password for %s' % username) + return None + + ldap_user = _LDAPUser(self, username=username.strip()) + user = ldap_user.authenticate(password) + + return user + + def get_user(self, user_id): + user = None + + try: + user = get_user_model().objects.get(pk=user_id) + _LDAPUser(self, user=user) # This sets user.ldap_user + except ObjectDoesNotExist: + pass + + return user + + def has_perm(self, user, perm, obj=None): + return perm in self.get_all_permissions(user, obj) + + def has_module_perms(self, user, app_label): + for perm in self.get_all_permissions(user): + if perm[:perm.index('.')] == app_label: + return True + + return False + + def get_all_permissions(self, user, obj=None): + return self.get_group_permissions(user, obj) + + def get_group_permissions(self, user, obj=None): + if not hasattr(user, 'ldap_user') and self.settings.AUTHORIZE_ALL_USERS: + _LDAPUser(self, user=user) # This sets user.ldap_user + + if hasattr(user, 'ldap_user'): + return user.ldap_user.get_group_permissions() + else: + return set() + + # + # Bonus API: populate the Django user from LDAP without authenticating. + # + + def populate_user(self, username): + ldap_user = _LDAPUser(self, username=username) + user = ldap_user.populate_user() + + return user + + # + # Hooks for subclasses + # + + def get_or_create_user(self, username, ldap_user): + """ + This must return a (User, created) 2-tuple for the given LDAP user. + username is the Django-friendly username of the user. ldap_user.dn is + the user's DN and ldap_user.attrs contains all of their LDAP attributes. + """ + model = get_user_model() + username_field = getattr(model, 'USERNAME_FIELD', 'username') + + kwargs = { + username_field + '__iexact': username, + 'defaults': {username_field: username.lower()} + } + + return model.objects.get_or_create(**kwargs) + + def ldap_to_django_username(self, username): + return username + + def django_to_ldap_username(self, username): + return username + + +class _LDAPUser(object): + """ + Represents an LDAP user and ultimately fields all requests that the + backend receives. This class exists for two reasons. First, it's + convenient to have a separate object for each request so that we can use + object attributes without running into threading problems. Second, these + objects get attached to the User objects, which allows us to cache + expensive LDAP information, especially around groups and permissions. + + self.backend is a reference back to the LDAPBackend instance, which we need + to access the ldap module and any hooks that a subclass has overridden. + """ + class AuthenticationFailed(Exception): + pass + + # Defaults + _user = None + _user_dn = None + _user_attrs = None + _groups = None + _group_permissions = None + _connection = None + _connection_bound = False + + # + # Initialization + # + + def __init__(self, backend, username=None, user=None): + """ + A new LDAPUser must be initialized with either a username or an + authenticated User object. If a user is given, the username will be + ignored. + """ + self.backend = backend + self._username = username + + if user is not None: + self._set_authenticated_user(user) + + if username is None and user is None: + raise Exception("Internal error: _LDAPUser improperly initialized.") + + def __deepcopy__(self, memo): + obj = object.__new__(self.__class__) + obj.backend = self.backend + obj._user = copy.deepcopy(self._user, memo) + + # This is all just cached immutable data. There's no point copying it. + obj._username = self._username + obj._user_dn = self._user_dn + obj._user_attrs = self._user_attrs + obj._groups = self._groups + obj._group_permissions = self._group_permissions + + # The connection couldn't be copied even if we wanted to + obj._connection = self._connection + obj._connection_bound = self._connection_bound + + return obj + + def __getstate__(self): + """ + Most of our properties are cached from the LDAP server. We only want to + pickle a few crucial things. + """ + state = filter( + lambda (k, v): k in ['backend', '_username', '_user'], + self.__dict__.iteritems() + ) + + return dict(state) + + def _set_authenticated_user(self, user): + self._user = user + self._username = self.backend.django_to_ldap_username(get_user_username(user)) + + user.ldap_user = self + user.ldap_username = self._username + + def _get_ldap(self): + return self.backend.ldap + ldap = property(_get_ldap) + + def _get_settings(self): + return self.backend.settings + settings = property(_get_settings) + + # + # Entry points + # + + def authenticate(self, password): + """ + Authenticates against the LDAP directory and returns the corresponding + User object if successful. Returns None on failure. + """ + user = None + + try: + self._authenticate_user_dn(password) + self._check_requirements() + self._get_or_create_user() + + user = self._user + except self.AuthenticationFailed, e: + logger.debug(u"Authentication failed for %s" % self._username) + except self.ldap.LDAPError, e: + logger.warning(u"Caught LDAPError while authenticating %s: %s", + self._username, pprint.pformat(e)) + except Exception: + logger.exception(u"Caught Exception while authenticating %s", + self._username) + raise + + return user + + def get_group_permissions(self): + """ + If allowed by the configuration, this returns the set of permissions + defined by the user's LDAP group memberships. + """ + if self._group_permissions is None: + self._group_permissions = set() + + if self.settings.FIND_GROUP_PERMS: + try: + self._load_group_permissions() + except self.ldap.LDAPError, e: + logger.warning("Caught LDAPError loading group permissions: %s", + pprint.pformat(e)) + + return self._group_permissions + + def populate_user(self): + """ + Populates the Django user object using the default bind credentials. + """ + user = None + + try: + # self.attrs will only be non-None if we were able to load this user + # from the LDAP directory, so this filters out nonexistent users. + if self.attrs is not None: + self._get_or_create_user(force_populate=True) + + user = self._user + except self.ldap.LDAPError, e: + logger.warning(u"Caught LDAPError while authenticating %s: %s", + self._username, pprint.pformat(e)) + except Exception, e: + logger.error(u"Caught Exception while authenticating %s: %s", + self._username, pprint.pformat(e)) + logger.error(''.join(traceback.format_tb(sys.exc_info()[2]))) + raise + + return user + + # + # Public properties (callbacks). These are all lazy for performance reasons. + # + + def _get_user_dn(self): + if self._user_dn is None: + self._load_user_dn() + + return self._user_dn + dn = property(_get_user_dn) + + def _get_user_attrs(self): + if self._user_attrs is None: + self._load_user_attrs() + + return self._user_attrs + attrs = property(_get_user_attrs) + + def _get_group_dns(self): + return self._get_groups().get_group_dns() + group_dns = property(_get_group_dns) + + def _get_group_names(self): + return self._get_groups().get_group_names() + group_names = property(_get_group_names) + + def _get_bound_connection(self): + if not self._connection_bound: + self._bind() + + return self._get_connection() + connection = property(_get_bound_connection) + + # + # Authentication + # + + def _authenticate_user_dn(self, password): + """ + Binds to the LDAP server with the user's DN and password. Raises + AuthenticationFailed on failure. + """ + if self.dn is None: + raise self.AuthenticationFailed("Failed to map the username to a DN.") + + try: + sticky = self.settings.BIND_AS_AUTHENTICATING_USER + + self._bind_as(self.dn, password, sticky=sticky) + except self.ldap.INVALID_CREDENTIALS: + raise self.AuthenticationFailed("User DN/password rejected by LDAP server.") + + def _load_user_attrs(self): + if self.dn is not None: + search = LDAPSearch(self.dn, self.ldap.SCOPE_BASE) + results = search.execute(self.connection) + + if results is not None and len(results) > 0: + self._user_attrs = results[0][1] + + def _load_user_dn(self): + """ + Populates self._user_dn with the distinguished name of our user. This + will either construct the DN from a template in + AUTH_LDAP_USER_DN_TEMPLATE or connect to the server and search for it. + """ + if self._using_simple_bind_mode(): + self._construct_simple_user_dn() + else: + self._search_for_user_dn() + + def _using_simple_bind_mode(self): + return (self.settings.USER_DN_TEMPLATE is not None) + + def _construct_simple_user_dn(self): + template = self.settings.USER_DN_TEMPLATE + username = self.ldap.dn.escape_dn_chars(self._username) + + self._user_dn = template % {'user': username} + + def _search_for_user_dn(self): + """ + Searches the directory for a user matching AUTH_LDAP_USER_SEARCH. + Populates self._user_dn and self._user_attrs. + """ + search = self.settings.USER_SEARCH + if search is None: + raise ImproperlyConfigured('AUTH_LDAP_USER_SEARCH must be an LDAPSearch instance.') + + results = search.execute(self.connection, {'user': self._username}) + if results is not None and len(results) == 1: + (self._user_dn, self._user_attrs) = results[0] + + def _check_requirements(self): + """ + Checks all authentication requirements beyond credentials. Raises + AuthenticationFailed on failure. + """ + self._check_required_group() + self._check_denied_group() + + def _check_required_group(self): + """ + Returns True if the group requirement (AUTH_LDAP_REQUIRE_GROUP) is + met. Always returns True if AUTH_LDAP_REQUIRE_GROUP is None. + """ + required_group_dn = self.settings.REQUIRE_GROUP + + if required_group_dn is not None: + is_member = self._get_groups().is_member_of(required_group_dn) + if not is_member: + raise self.AuthenticationFailed("User is not a member of AUTH_LDAP_REQUIRE_GROUP") + + return True + + def _check_denied_group(self): + """ + Returns True if the negative group requirement (AUTH_LDAP_DENY_GROUP) + is met. Always returns True if AUTH_LDAP_DENY_GROUP is None. + """ + denied_group_dn = self.settings.DENY_GROUP + + if denied_group_dn is not None: + is_member = self._get_groups().is_member_of(denied_group_dn) + if is_member: + raise self.AuthenticationFailed("User is a member of AUTH_LDAP_DENY_GROUP") + + return True + + # + # User management + # + + def _get_or_create_user(self, force_populate=False): + """ + Loads the User model object from the database or creates it if it + doesn't exist. Also populates the fields, subject to + AUTH_LDAP_ALWAYS_UPDATE_USER. + """ + save_user = False + + username = self.backend.ldap_to_django_username(self._username) + + self._user, created = self.backend.get_or_create_user(username, self) + self._user.ldap_user = self + self._user.ldap_username = self._username + + should_populate = force_populate or self.settings.ALWAYS_UPDATE_USER or created + + if created: + logger.debug("Created Django user %s", username) + self._user.set_unusable_password() + save_user = True + + if should_populate: + logger.debug("Populating Django user %s", username) + self._populate_user() + save_user = True + + if self.settings.MIRROR_GROUPS: + self._mirror_groups() + + # Give the client a chance to finish populating the user just before + # saving. + if should_populate: + signal_responses = populate_user.send(self.backend.__class__, user=self._user, ldap_user=self) + if len(signal_responses) > 0: + save_user = True + + if save_user: + self._user.save() + + # We populate the profile after the user model is saved to give the + # client a chance to create the profile. Custom user models in Django + # 1.5 probably won't have a get_profile method. + if should_populate and hasattr(self._user, 'get_profile'): + self._populate_and_save_user_profile() + + def _populate_user(self): + """ + Populates our User object with information from the LDAP directory. + """ + self._populate_user_from_attributes() + self._populate_user_from_group_memberships() + + def _populate_user_from_attributes(self): + for field, attr in self.settings.USER_ATTR_MAP.iteritems(): + try: + setattr(self._user, field, self.attrs[attr][0]) + except StandardError: + logger.warning("%s does not have a value for the attribute %s", self.dn, attr) + + def _populate_user_from_group_memberships(self): + for field, group_dn in self.settings.USER_FLAGS_BY_GROUP.iteritems(): + value = self._get_groups().is_member_of(group_dn) + setattr(self._user, field, value) + + def _populate_and_save_user_profile(self): + """ + Populates a User profile object with fields from the LDAP directory. + """ + try: + profile = self._user.get_profile() + save_profile = False + + logger.debug("Populating Django user profile for %s", get_user_username(self._user)) + + save_profile = self._populate_profile_from_attributes(profile) or save_profile + save_profile = self._populate_profile_from_group_memberships(profile) or save_profile + + signal_responses = populate_user_profile.send(self.backend.__class__, profile=profile, ldap_user=self) + if len(signal_responses) > 0: + save_profile = True + + if save_profile: + profile.save() + except (SiteProfileNotAvailable, ObjectDoesNotExist): + logger.debug("Django user %s does not have a profile to populate", get_user_username(self._user)) + + def _populate_profile_from_attributes(self, profile): + """ + Populate the given profile object from AUTH_LDAP_PROFILE_ATTR_MAP. + Returns True if the profile was modified. + """ + save_profile = False + + for field, attr in self.settings.PROFILE_ATTR_MAP.iteritems(): + try: + # user_attrs is a hash of lists of attribute values + setattr(profile, field, self.attrs[attr][0]) + save_profile = True + except StandardError: + logger.warning("%s does not have a value for the attribute %s", self.dn, attr) + + return save_profile + + def _populate_profile_from_group_memberships(self, profile): + """ + Populate the given profile object from AUTH_LDAP_PROFILE_FLAGS_BY_GROUP. + Returns True if the profile was modified. + """ + save_profile = False + + for field, group_dn in self.settings.PROFILE_FLAGS_BY_GROUP.iteritems(): + value = self._get_groups().is_member_of(group_dn) + setattr(profile, field, value) + save_profile = True + + return save_profile + + def _mirror_groups(self): + """ + Mirrors the user's LDAP groups in the Django database and updates the + user's membership. + """ + group_names = self._get_groups().get_group_names() + groups = [Group.objects.get_or_create(name=group_name)[0] for group_name + in group_names] + + self._user.groups = groups + + # + # Group information + # + + def _load_group_permissions(self): + """ + Populates self._group_permissions based on LDAP group membership and + Django group permissions. + """ + group_names = self._get_groups().get_group_names() + + perms = Permission.objects.filter(group__name__in=group_names + ).values_list('content_type__app_label', 'codename' + ).order_by() + + self._group_permissions = set(["%s.%s" % (ct, name) for ct, name in perms]) + + def _get_groups(self): + """ + Returns an _LDAPUserGroups object, which can determine group + membership. + """ + if self._groups is None: + self._groups = _LDAPUserGroups(self) + + return self._groups + + # + # LDAP connection + # + + def _bind(self): + """ + Binds to the LDAP server with AUTH_LDAP_BIND_DN and + AUTH_LDAP_BIND_PASSWORD. + """ + self._bind_as(self.settings.BIND_DN, + self.settings.BIND_PASSWORD, + sticky=True) + + def _bind_as(self, bind_dn, bind_password, sticky=False): + """ + Binds to the LDAP server with the given credentials. This does not trap + exceptions. + + If sticky is True, then we will consider the connection to be bound for + the life of this object. If False, then the caller only wishes to test + the credentials, after which the connection will be considered unbound. + """ + self._get_connection().simple_bind_s(bind_dn.encode('utf-8'), + bind_password.encode('utf-8')) + + self._connection_bound = sticky + + def _get_connection(self): + """ + Returns our cached LDAPObject, which may or may not be bound. + """ + if self._connection is None: + self._connection = self.ldap.initialize(self.settings.SERVER_URI) + + for opt, value in self.settings.CONNECTION_OPTIONS.iteritems(): + self._connection.set_option(opt, value) + + if self.settings.START_TLS: + logger.debug("Initiating TLS") + self._connection.start_tls_s() + + return self._connection + + +class _LDAPUserGroups(object): + """ + Represents the set of groups that a user belongs to. + """ + def __init__(self, ldap_user): + self.settings = ldap_user.settings + self._ldap_user = ldap_user + self._group_type = None + self._group_search = None + self._group_infos = None + self._group_dns = None + self._group_names = None + + self._init_group_settings() + + def _init_group_settings(self): + """ + Loads the settings we need to deal with groups. Raises + ImproperlyConfigured if anything's not right. + """ + self._group_type = self.settings.GROUP_TYPE + if self._group_type is None: + raise ImproperlyConfigured("AUTH_LDAP_GROUP_TYPE must be an LDAPGroupType instance.") + + self._group_search = self.settings.GROUP_SEARCH + if self._group_search is None: + raise ImproperlyConfigured("AUTH_LDAP_GROUP_SEARCH must be an LDAPSearch instance.") + + def get_group_names(self): + """ + Returns the set of Django group names that this user belongs to by + virtue of LDAP group memberships. + """ + if self._group_names is None: + self._load_cached_attr("_group_names") + + if self._group_names is None: + group_infos = self._get_group_infos() + self._group_names = set([self._group_type.group_name_from_info(group_info) + for group_info in group_infos]) + self._cache_attr("_group_names") + + return self._group_names + + def is_member_of(self, group_dn): + """ + Returns true if our user is a member of the given group. + """ + is_member = None + + # Normalize the DN + group_dn = group_dn.lower() + + # If we have self._group_dns, we'll use it. Otherwise, we'll try to + # avoid the cost of loading it. + if self._group_dns is None: + is_member = self._group_type.is_member(self._ldap_user, group_dn) + + if is_member is None: + is_member = (group_dn in self.get_group_dns()) + + logger.debug("%s is%sa member of %s", self._ldap_user.dn, + is_member and " " or " not ", group_dn) + + return is_member + + def get_group_dns(self): + """ + Returns a (cached) set of the distinguished names in self._group_infos. + """ + if self._group_dns is None: + group_infos = self._get_group_infos() + self._group_dns = set([group_info[0] for group_info in group_infos]) + + return self._group_dns + + def _get_group_infos(self): + """ + Returns a (cached) list of group_info structures for the groups that our + user is a member of. + """ + if self._group_infos is None: + self._group_infos = self._group_type.user_groups(self._ldap_user, + self._group_search) + + return self._group_infos + + def _load_cached_attr(self, attr_name): + if self.settings.CACHE_GROUPS: + key = self._cache_key(attr_name) + value = cache.get(key) + setattr(self, attr_name, value) + + def _cache_attr(self, attr_name): + if self.settings.CACHE_GROUPS: + key = self._cache_key(attr_name) + value = getattr(self, attr_name, None) + cache.set(key, value, self.settings.GROUP_CACHE_TIMEOUT) + + def _cache_key(self, attr_name): + """ + Memcache keys can't have spaces in them, so we'll remove them from the + DN for maximum compatibility. + """ + dn = self._ldap_user.dn.replace(' ', '%20') + key = u'auth_ldap.%s.%s.%s' % (self.__class__.__name__, attr_name, dn) + + return key + + +class LDAPSettings(object): + """ + This is a simple class to take the place of the global settings object. An + instance will contain all of our settings as attributes, with default values + if they are not specified by the configuration. + """ + defaults = { + 'ALWAYS_UPDATE_USER': True, + 'AUTHORIZE_ALL_USERS': False, + 'BIND_AS_AUTHENTICATING_USER': False, + 'BIND_DN': '', + 'BIND_PASSWORD': '', + 'CACHE_GROUPS': False, + 'CONNECTION_OPTIONS': {}, + 'DENY_GROUP': None, + 'FIND_GROUP_PERMS': False, + 'GROUP_CACHE_TIMEOUT': None, + 'GROUP_SEARCH': None, + 'GROUP_TYPE': None, + 'MIRROR_GROUPS': False, + 'PERMIT_EMPTY_PASSWORD': False, + 'PROFILE_ATTR_MAP': {}, + 'PROFILE_FLAGS_BY_GROUP': {}, + 'REQUIRE_GROUP': None, + 'SERVER_URI': 'ldap://localhost', + 'START_TLS': False, + 'USER_ATTR_MAP': {}, + 'USER_DN_TEMPLATE': None, + 'USER_FLAGS_BY_GROUP': {}, + 'USER_SEARCH': None, + } + + def __init__(self, prefix='AUTH_LDAP_'): + """ + Loads our settings from django.conf.settings, applying defaults for any + that are omitted. + """ + from django.conf import settings + + for name, default in self.defaults.iteritems(): + value = getattr(settings, prefix + name, default) + setattr(self, name, value) diff --git a/awx/lib/site-packages/django_auth_ldap/config.py b/awx/lib/site-packages/django_auth_ldap/config.py new file mode 100644 index 0000000000..d01b7d09f7 --- /dev/null +++ b/awx/lib/site-packages/django_auth_ldap/config.py @@ -0,0 +1,522 @@ +# Copyright (c) 2009, Peter Sagerson +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# - Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# - Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +""" +This module contains classes that will be needed for configuration of LDAP +authentication. Unlike backend.py, this is safe to import into settings.py. +Please see the docstring on the backend module for more information, including +notes on naming conventions. +""" + +try: + set +except NameError: + from sets import Set as set # Python 2.3 fallback + +import logging +import pprint + + +class _LDAPConfig(object): + """ + A private class that loads and caches some global objects. + """ + ldap = None + logger = None + + _ldap_configured = False + + def get_ldap(cls, global_options=None): + """ + Returns the ldap module. The unit test harness will assign a mock object + to _LDAPConfig.ldap. It is imperative that the ldap module not be + imported anywhere else so that the unit tests will pass in the absence + of python-ldap. + """ + if cls.ldap is None: + import ldap + import ldap.filter + + # Support for python-ldap < 2.0.6 + try: + import ldap.dn + except ImportError: + from django_auth_ldap import dn + ldap.dn = dn + + cls.ldap = ldap + + # Apply global LDAP options once + if (not cls._ldap_configured) and (global_options is not None): + for opt, value in global_options.iteritems(): + cls.ldap.set_option(opt, value) + + cls._ldap_configured = True + + return cls.ldap + get_ldap = classmethod(get_ldap) + + def get_logger(cls): + """ + Initializes and returns our logger instance. + """ + if cls.logger is None: + class NullHandler(logging.Handler): + def emit(self, record): + pass + + cls.logger = logging.getLogger('django_auth_ldap') + cls.logger.addHandler(NullHandler()) + + return cls.logger + get_logger = classmethod(get_logger) + + +# Our global logger +logger = _LDAPConfig.get_logger() + + +class LDAPSearch(object): + """ + Public class that holds a set of LDAP search parameters. Objects of this + class should be considered immutable. Only the initialization method is + documented for configuration purposes. Internal clients may use the other + methods to refine and execute the search. + """ + def __init__(self, base_dn, scope, filterstr=u'(objectClass=*)'): + """ + These parameters are the same as the first three parameters to + ldap.search_s. + """ + self.base_dn = base_dn + self.scope = scope + self.filterstr = filterstr + self.ldap = _LDAPConfig.get_ldap() + + def search_with_additional_terms(self, term_dict, escape=True): + """ + Returns a new search object with additional search terms and-ed to the + filter string. term_dict maps attribute names to assertion values. If + you don't want the values escaped, pass escape=False. + """ + term_strings = [self.filterstr] + + for name, value in term_dict.iteritems(): + if escape: + value = self.ldap.filter.escape_filter_chars(value) + term_strings.append(u'(%s=%s)' % (name, value)) + + filterstr = u'(&%s)' % ''.join(term_strings) + + return self.__class__(self.base_dn, self.scope, filterstr) + + def search_with_additional_term_string(self, filterstr): + """ + Returns a new search object with filterstr and-ed to the original filter + string. The caller is responsible for passing in a properly escaped + string. + """ + filterstr = u'(&%s%s)' % (self.filterstr, filterstr) + + return self.__class__(self.base_dn, self.scope, filterstr) + + def execute(self, connection, filterargs=()): + """ + Executes the search on the given connection (an LDAPObject). filterargs + is an object that will be used for expansion of the filter string. + + The python-ldap library returns utf8-encoded strings. For the sake of + sanity, this method will decode all result strings and return them as + Unicode. + """ + try: + filterstr = self.filterstr % filterargs + results = connection.search_s(self.base_dn.encode('utf-8'), + self.scope, filterstr.encode('utf-8')) + except self.ldap.LDAPError, e: + results = [] + logger.error(u"search_s('%s', %d, '%s') raised %s" % + (self.base_dn, self.scope, filterstr, pprint.pformat(e))) + + return self._process_results(results) + + def _begin(self, connection, filterargs=()): + """ + Begins an asynchronous search and returns the message id to retrieve + the results. + """ + try: + filterstr = self.filterstr % filterargs + msgid = connection.search(self.base_dn.encode('utf-8'), + self.scope, filterstr.encode('utf-8')) + except self.ldap.LDAPError, e: + msgid = None + logger.error(u"search('%s', %d, '%s') raised %s" % + (self.base_dn, self.scope, filterstr, pprint.pformat(e))) + + return msgid + + def _results(self, connection, msgid): + """ + Returns the result of a previous asynchronous query. + """ + try: + kind, results = connection.result(msgid) + if kind != self.ldap.RES_SEARCH_RESULT: + results = [] + except self.ldap.LDAPError, e: + results = [] + logger.error(u"result(%d) raised %s" % (msgid, pprint.pformat(e))) + + return self._process_results(results) + + def _process_results(self, results): + """ + Returns a sanitized copy of raw LDAP results. This scrubs out + references, decodes utf8, normalizes DNs, etc. + """ + results = filter(lambda r: r[0] is not None, results) + results = _DeepStringCoder('utf-8').decode(results) + + # The normal form of a DN is lower case. + results = map(lambda r: (r[0].lower(), r[1]), results) + + result_dns = [result[0] for result in results] + logger.debug(u"search_s('%s', %d, '%s') returned %d objects: %s" % + (self.base_dn, self.scope, self.filterstr, len(result_dns), "; ".join(result_dns))) + + return results + + +class LDAPSearchUnion(object): + """ + A compound search object that returns the union of the results. Instantiate + it with one or more LDAPSearch objects. + """ + def __init__(self, *args): + self.searches = args + self.ldap = _LDAPConfig.get_ldap() + + def execute(self, connection, filterargs=()): + msgids = [search._begin(connection, filterargs) for search in self.searches] + results = {} + + for search, msgid in zip(self.searches, msgids): + result = search._results(connection, msgid) + results.update(dict(result)) + + return results.items() + + +class _DeepStringCoder(object): + """ + Encodes and decodes strings in a nested structure of lists, tuples, and + dicts. This is helpful when interacting with the Unicode-unaware + python-ldap. + """ + def __init__(self, encoding): + self.encoding = encoding + self.ldap = _LDAPConfig.get_ldap() + + def decode(self, value): + try: + if isinstance(value, str): + value = value.decode(self.encoding) + elif isinstance(value, list): + value = self._decode_list(value) + elif isinstance(value, tuple): + value = tuple(self._decode_list(value)) + elif isinstance(value, dict): + value = self._decode_dict(value) + except UnicodeDecodeError: + pass + + return value + + def _decode_list(self, value): + return [self.decode(v) for v in value] + + def _decode_dict(self, value): + # Attribute dictionaries should be case-insensitive. python-ldap + # defines this, although for some reason, it doesn't appear to use it + # for search results. + decoded = self.ldap.cidict.cidict() + + for k, v in value.iteritems(): + decoded[self.decode(k)] = self.decode(v) + + return decoded + + +class LDAPGroupType(object): + """ + This is an abstract base class for classes that determine LDAP group + membership. A group can mean many different things in LDAP, so we will need + a concrete subclass for each grouping mechanism. Clients may subclass this + if they have a group mechanism that is not handled by a built-in + implementation. + + name_attr is the name of the LDAP attribute from which we will take the + Django group name. + + Subclasses in this file must use self.ldap to access the python-ldap module. + This will be a mock object during unit tests. + """ + def __init__(self, name_attr="cn"): + self.name_attr = name_attr + self.ldap = _LDAPConfig.get_ldap() + + def user_groups(self, ldap_user, group_search): + """ + Returns a list of group_info structures, each one a group to which + ldap_user belongs. group_search is an LDAPSearch object that returns all + of the groups that the user might belong to. Typical implementations + will apply additional filters to group_search and return the results of + the search. ldap_user represents the user and has the following three + properties: + + dn: the distinguished name + attrs: a dictionary of LDAP attributes (with lists of values) + connection: an LDAPObject that has been bound with credentials + + This is the primitive method in the API and must be implemented. + """ + return [] + + def is_member(self, ldap_user, group_dn): + """ + This method is an optimization for determining group membership without + loading all of the user's groups. Subclasses that are able to do this + may return True or False. ldap_user is as above. group_dn is the + distinguished name of the group in question. + + The base implementation returns None, which means we don't have enough + information. The caller will have to call user_groups() instead and look + for group_dn in the results. + """ + return None + + def group_name_from_info(self, group_info): + """ + Given the (DN, attrs) 2-tuple of an LDAP group, this returns the name of + the Django group. This may return None to indicate that a particular + LDAP group has no corresponding Django group. + + The base implementation returns the value of the cn attribute, or + whichever attribute was given to __init__ in the name_attr + parameter. + """ + try: + name = group_info[1][self.name_attr][0] + except (KeyError, IndexError): + name = None + + return name + + +class PosixGroupType(LDAPGroupType): + """ + An LDAPGroupType subclass that handles groups of class posixGroup. + """ + def user_groups(self, ldap_user, group_search): + """ + Searches for any group that is either the user's primary or contains the + user as a member. + """ + groups = [] + + try: + user_uid = ldap_user.attrs['uid'][0] + user_gid = ldap_user.attrs['gidNumber'][0] + + filterstr = u'(|(gidNumber=%s)(memberUid=%s))' % ( + self.ldap.filter.escape_filter_chars(user_gid), + self.ldap.filter.escape_filter_chars(user_uid) + ) + + search = group_search.search_with_additional_term_string(filterstr) + groups = search.execute(ldap_user.connection) + except (KeyError, IndexError): + pass + + return groups + + def is_member(self, ldap_user, group_dn): + """ + Returns True if the group is the user's primary group or if the user is + listed in the group's memberUid attribute. + """ + try: + user_uid = ldap_user.attrs['uid'][0] + user_gid = ldap_user.attrs['gidNumber'][0] + + try: + is_member = ldap_user.connection.compare_s(group_dn.encode('utf-8'), 'memberUid', user_uid.encode('utf-8')) + except self.ldap.NO_SUCH_ATTRIBUTE: + is_member = False + + if not is_member: + try: + is_member = ldap_user.connection.compare_s(group_dn.encode('utf-8'), 'gidNumber', user_gid.encode('utf-8')) + except self.ldap.NO_SUCH_ATTRIBUTE: + is_member = False + except (KeyError, IndexError): + is_member = False + + return is_member + + +class MemberDNGroupType(LDAPGroupType): + """ + A group type that stores lists of members as distinguished names. + """ + def __init__(self, member_attr, name_attr='cn'): + """ + member_attr is the attribute on the group object that holds the list of + member DNs. + """ + self.member_attr = member_attr + + super(MemberDNGroupType, self).__init__(name_attr) + + def user_groups(self, ldap_user, group_search): + search = group_search.search_with_additional_terms({self.member_attr: ldap_user.dn}) + groups = search.execute(ldap_user.connection) + + return groups + + def is_member(self, ldap_user, group_dn): + try: + result = ldap_user.connection.compare_s( + group_dn.encode('utf-8'), + self.member_attr.encode('utf-8'), + ldap_user.dn.encode('utf-8') + ) + except self.ldap.NO_SUCH_ATTRIBUTE: + result = 0 + + return result + + +class NestedMemberDNGroupType(LDAPGroupType): + """ + A group type that stores lists of members as distinguished names and + supports nested groups. There is no shortcut for is_member in this case, so + it's left unimplemented. + """ + def __init__(self, member_attr, name_attr='cn'): + """ + member_attr is the attribute on the group object that holds the list of + member DNs. + """ + self.member_attr = member_attr + + super(NestedMemberDNGroupType, self).__init__(name_attr) + + def user_groups(self, ldap_user, group_search): + """ + This searches for all of a user's groups from the bottom up. In other + words, it returns the groups that the user belongs to, the groups that + those groups belong to, etc. Circular references will be detected and + pruned. + """ + group_info_map = {} # Maps group_dn to group_info of groups we've found + member_dn_set = set([ldap_user.dn]) # Member DNs to search with next + handled_dn_set = set() # Member DNs that we've already searched with + + while len(member_dn_set) > 0: + group_infos = self.find_groups_with_any_member(member_dn_set, + group_search, ldap_user.connection) + new_group_info_map = dict([(info[0], info) for info in group_infos]) + group_info_map.update(new_group_info_map) + handled_dn_set.update(member_dn_set) + + # Get ready for the next iteration. To avoid cycles, we make sure + # never to search with the same member DN twice. + member_dn_set = set(new_group_info_map.keys()) - handled_dn_set + + return group_info_map.values() + + def find_groups_with_any_member(self, member_dn_set, group_search, connection): + terms = [ + u"(%s=%s)" % (self.member_attr, self.ldap.filter.escape_filter_chars(dn)) + for dn in member_dn_set + ] + + filterstr = u"(|%s)" % "".join(terms) + search = group_search.search_with_additional_term_string(filterstr) + + return search.execute(connection) + + +class GroupOfNamesType(MemberDNGroupType): + """ + An LDAPGroupType subclass that handles groups of class groupOfNames. + """ + def __init__(self, name_attr='cn'): + super(GroupOfNamesType, self).__init__('member', name_attr) + + +class NestedGroupOfNamesType(NestedMemberDNGroupType): + """ + An LDAPGroupType subclass that handles groups of class groupOfNames with + nested group references. + """ + def __init__(self, name_attr='cn'): + super(NestedGroupOfNamesType, self).__init__('member', name_attr) + + +class GroupOfUniqueNamesType(MemberDNGroupType): + """ + An LDAPGroupType subclass that handles groups of class groupOfUniqueNames. + """ + def __init__(self, name_attr='cn'): + super(GroupOfUniqueNamesType, self).__init__('uniqueMember', name_attr) + + +class NestedGroupOfUniqueNamesType(NestedMemberDNGroupType): + """ + An LDAPGroupType subclass that handles groups of class groupOfUniqueNames + with nested group references. + """ + def __init__(self, name_attr='cn'): + super(NestedGroupOfUniqueNamesType, self).__init__('uniqueMember', name_attr) + + +class ActiveDirectoryGroupType(MemberDNGroupType): + """ + An LDAPGroupType subclass that handles Active Directory groups. + """ + def __init__(self, name_attr='cn'): + super(ActiveDirectoryGroupType, self).__init__('member', name_attr) + + +class NestedActiveDirectoryGroupType(NestedMemberDNGroupType): + """ + An LDAPGroupType subclass that handles Active Directory groups with nested + group references. + """ + def __init__(self, name_attr='cn'): + super(NestedActiveDirectoryGroupType, self).__init__('member', name_attr) diff --git a/awx/lib/site-packages/django_auth_ldap/dn.py b/awx/lib/site-packages/django_auth_ldap/dn.py new file mode 100644 index 0000000000..78271abbd4 --- /dev/null +++ b/awx/lib/site-packages/django_auth_ldap/dn.py @@ -0,0 +1,32 @@ +# Copyright (c) 2009, Peter Sagerson +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# - Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# - Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +""" +This is an ldap.dn replacement for old versions of python-ldap. It contains +(often naive) implementations of the methods we care about. +""" + +def escape_dn_chars(dn): + "Old versions of python-ldap won't get DN escaping. Use with care." + return dn diff --git a/awx/lib/site-packages/django_auth_ldap/models.py b/awx/lib/site-packages/django_auth_ldap/models.py new file mode 100644 index 0000000000..b48d7a38ca --- /dev/null +++ b/awx/lib/site-packages/django_auth_ldap/models.py @@ -0,0 +1,31 @@ +from django.db import models + + +# Support for testing Django 1.5's custom user models. +try: + from django.contrib.auth.models import AbstractBaseUser +except ImportError: + from django.contrib.auth.models import User + + TestUser = User +else: + class TestUser(AbstractBaseUser): + identifier = models.CharField(max_length=40, unique=True, db_index=True) + + USERNAME_FIELD = 'identifier' + + def get_full_name(self): + return self.identifier + + def get_short_name(self): + return self.identifier + + +class TestProfile(models.Model): + """ + A user profile model for use by unit tests. This has nothing to do with the + authentication backend itself. + """ + user = models.OneToOneField('auth.User') + is_special = models.BooleanField(default=False) + populated = models.BooleanField(default=False) diff --git a/awx/lib/site-packages/django_auth_ldap/tests.py b/awx/lib/site-packages/django_auth_ldap/tests.py new file mode 100644 index 0000000000..0bdbff5957 --- /dev/null +++ b/awx/lib/site-packages/django_auth_ldap/tests.py @@ -0,0 +1,1401 @@ +# coding: utf-8 + +# Copyright (c) 2009, Peter Sagerson +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# - Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# - Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +try: + set +except NameError: + from sets import Set as set # Python 2.3 fallback + +from collections import defaultdict +from copy import deepcopy +import logging +import pickle +import sys + +from django.conf import settings +import django.db.models.signals +from django.contrib.auth.models import User, Permission, Group +from django.test import TestCase + +try: + from django.test.utils import override_settings +except ImportError: + override_settings = lambda *args, **kwargs: (lambda v: v) + +from django_auth_ldap.models import TestUser, TestProfile +from django_auth_ldap import backend +from django_auth_ldap.config import _LDAPConfig, LDAPSearch, LDAPSearchUnion +from django_auth_ldap.config import PosixGroupType, MemberDNGroupType, NestedMemberDNGroupType +from django_auth_ldap.config import GroupOfNamesType + + +class TestSettings(backend.LDAPSettings): + """ + A replacement for backend.LDAPSettings that does not load settings + from django.conf. + """ + def __init__(self, **kwargs): + for name, default in self.defaults.iteritems(): + value = kwargs.get(name, default) + setattr(self, name, value) + + +class MockLDAP(object): + """ + This is a stand-in for the python-ldap module; it serves as both the ldap + module and the LDAPObject class. While it's temping to add some real LDAP + capabilities here, this is designed to remain as simple as possible, so as + to minimize the risk of creating bogus unit tests through a buggy test + harness. + + Simple operations can be simulated, but for nontrivial searches, the client + will have to seed the mock object with return values for expected API calls. + This may sound like cheating, but it's really no more so than a simulated + LDAP server. The fact is we can not require python-ldap to be installed in + order to run the unit tests, so all we can do is verify that LDAPBackend is + calling the APIs that we expect. + + set_return_value takes the name of an API, a tuple of arguments, and a + return value. Every time an API is called, it looks for a predetermined + return value based on the arguments received. If it finds one, then it + returns it, or raises it if it's an Exception. If it doesn't find one, then + it tries to satisfy the request internally. If it can't, it raises a + PresetReturnRequiredError. + + At any time, the client may call ldap_methods_called_with_arguments() or + ldap_methods_called() to get a record of all of the LDAP API calls that have + been made, with or without arguments. + """ + class PresetReturnRequiredError(Exception): + pass + + SCOPE_BASE = 0 + SCOPE_ONELEVEL = 1 + SCOPE_SUBTREE = 2 + + RES_SEARCH_RESULT = 101 + + class LDAPError(Exception): + pass + + class INVALID_CREDENTIALS(LDAPError): + pass + + class NO_SUCH_OBJECT(LDAPError): + pass + + class NO_SUCH_ATTRIBUTE(LDAPError): + pass + + # + # Submodules + # + class dn(object): + def escape_dn_chars(s): + return s + escape_dn_chars = staticmethod(escape_dn_chars) + + class filter(object): + def escape_filter_chars(s): + return s + escape_filter_chars = staticmethod(escape_filter_chars) + + class cidict(object): + class cidict(dict): + pass + + def __init__(self, directory): + """ + directory is a complex structure with the entire contents of the + mock LDAP directory. directory must be a dictionary mapping + distinguished names to dictionaries of attributes. Each attribute + dictionary maps attribute names to lists of values. e.g.: + + { + "uid=alice,ou=users,dc=example,dc=com": + { + "uid": ["alice"], + "userPassword": ["secret"], + }, + } + """ + self.directory = self.cidict.cidict(directory) + + self.reset() + + def reset(self): + """ + Resets our recorded API calls and queued return values as well as + miscellaneous configuration options. + """ + self.calls = [] + self.return_value_maps = defaultdict(lambda: {}) + self.async_results = [] + self.options = {} + self.tls_enabled = False + + def set_return_value(self, api_name, arguments, value): + """ + Stores a preset return value for a given API with a given set of + arguments. + """ + self.return_value_maps[api_name][arguments] = value + + def ldap_methods_called_with_arguments(self): + """ + Returns a list of 2-tuples, one for each API call made since the last + reset. Each tuple contains the name of the API and a dictionary of + arguments. Argument defaults are included. + """ + return self.calls + + def ldap_methods_called(self): + """ + Returns the list of API names called. + """ + return [call[0] for call in self.calls] + + # + # Begin LDAP methods + # + + def set_option(self, option, invalue): + self._record_call('set_option', { + 'option': option, + 'invalue': invalue + }) + + self.options[option] = invalue + + def initialize(self, uri, trace_level=0, trace_file=sys.stdout, trace_stack_limit=None): + self._record_call('initialize', { + 'uri': uri, + 'trace_level': trace_level, + 'trace_file': trace_file, + 'trace_stack_limit': trace_stack_limit + }) + + value = self._get_return_value('initialize', + (uri, trace_level, trace_file, trace_stack_limit)) + if value is None: + value = self + + return value + + def simple_bind_s(self, who='', cred=''): + self._record_call('simple_bind_s', { + 'who': who, + 'cred': cred + }) + + value = self._get_return_value('simple_bind_s', (who, cred)) + if value is None: + value = self._simple_bind_s(who, cred) + + return value + + def search(self, base, scope, filterstr='(objectClass=*)', attrlist=None, attrsonly=0): + self._record_call('search', { + 'base': base, + 'scope': scope, + 'filterstr': filterstr, + 'attrlist': attrlist, + 'attrsonly': attrsonly + }) + + value = self._get_return_value('search_s', + (base, scope, filterstr, attrlist, attrsonly)) + if value is None: + value = self._search_s(base, scope, filterstr, attrlist, attrsonly) + + return self._add_async_result(value) + + def result(self, msgid, all=1, timeout=None): + self._record_call('result', { + 'msgid': msgid, + 'all': all, + 'timeout': timeout, + }) + + return self.RES_SEARCH_RESULT, self._pop_async_result(msgid) + + def search_s(self, base, scope, filterstr='(objectClass=*)', attrlist=None, attrsonly=0): + self._record_call('search_s', { + 'base': base, + 'scope': scope, + 'filterstr': filterstr, + 'attrlist': attrlist, + 'attrsonly': attrsonly + }) + + value = self._get_return_value('search_s', + (base, scope, filterstr, attrlist, attrsonly)) + if value is None: + value = self._search_s(base, scope, filterstr, attrlist, attrsonly) + + return value + + def start_tls_s(self): + self.tls_enabled = True + + def compare_s(self, dn, attr, value): + self._record_call('compare_s', { + 'dn': dn, + 'attr': attr, + 'value': value + }) + + result = self._get_return_value('compare_s', (dn, attr, value)) + if result is None: + result = self._compare_s(dn, attr, value) + + # print "compare_s('%s', '%s', '%s'): %d" % (dn, attr, value, result) + + return result + + # + # Internal implementations + # + + def _simple_bind_s(self, who='', cred=''): + success = False + + if(who == '' and cred == ''): + success = True + elif self._compare_s(who.lower(), 'userPassword', cred): + success = True + + if success: + return (97, []) # python-ldap returns this; I don't know what it means + else: + raise self.INVALID_CREDENTIALS('%s:%s' % (who, cred)) + + def _compare_s(self, dn, attr, value): + if dn not in self.directory: + raise self.NO_SUCH_OBJECT + + if attr not in self.directory[dn]: + raise self.NO_SUCH_ATTRIBUTE + + return (value in self.directory[dn][attr]) and 1 or 0 + + def _search_s(self, base, scope, filterstr, attrlist, attrsonly): + """ + We can do a SCOPE_BASE search with the default filter. Beyond that, + you're on your own. + """ + if scope != self.SCOPE_BASE: + raise self.PresetReturnRequiredError('search_s("%s", %d, "%s", "%s", %d)' % + (base, scope, filterstr, attrlist, attrsonly)) + + if filterstr != '(objectClass=*)': + raise self.PresetReturnRequiredError('search_s("%s", %d, "%s", "%s", %d)' % + (base, scope, filterstr, attrlist, attrsonly)) + + attrs = self.directory.get(base) + if attrs is None: + raise self.NO_SUCH_OBJECT() + + return [(base, attrs)] + + def _add_async_result(self, value): + self.async_results.append(value) + + return len(self.async_results) - 1 + + def _pop_async_result(self, msgid): + if msgid in xrange(len(self.async_results)): + value = self.async_results[msgid] + self.async_results[msgid] = None + else: + value = None + + return value + + # + # Utils + # + + def _record_call(self, api_name, arguments): + self.calls.append((api_name, arguments)) + + def _get_return_value(self, api_name, arguments): + try: + value = self.return_value_maps[api_name][arguments] + except KeyError: + value = None + + if isinstance(value, Exception): + raise value + + return value + + +class LDAPTest(TestCase): + # Following are the objecgs in our mock LDAP directory + alice = ("uid=alice,ou=people,o=test", { + "uid": ["alice"], + "objectClass": ["person", "organizationalPerson", "inetOrgPerson", "posixAccount"], + "userPassword": ["password"], + "uidNumber": ["1000"], + "gidNumber": ["1000"], + "givenName": ["Alice"], + "sn": ["Adams"] + }) + bob = ("uid=bob,ou=people,o=test", { + "uid": ["bob"], + "objectClass": ["person", "organizationalPerson", "inetOrgPerson", "posixAccount"], + "userPassword": ["password"], + "uidNumber": ["1001"], + "gidNumber": ["50"], + "givenName": ["Robert"], + "sn": ["Barker"] + }) + dressler = (u"uid=dreßler,ou=people,o=test".encode('utf-8'), { + "uid": [u"dreßler".encode('utf-8')], + "objectClass": ["person", "organizationalPerson", "inetOrgPerson", "posixAccount"], + "userPassword": ["password"], + "uidNumber": ["1002"], + "gidNumber": ["50"], + "givenName": ["Wolfgang"], + "sn": [u"Dreßler".encode('utf-8')] + }) + nobody = ("uid=nobody,ou=people,o=test", { + "uid": ["nobody"], + "objectClass": ["person", "organizationalPerson", "inetOrgPerson", "posixAccount"], + "userPassword": ["password"], + "binaryAttr": ["\xb2"] # Invalid UTF-8 + }) + + # posixGroup objects + active_px = ("cn=active_px,ou=groups,o=test", { + "cn": ["active_px"], + "objectClass": ["posixGroup"], + "gidNumber": ["1000"], + }) + staff_px = ("cn=staff_px,ou=groups,o=test", { + "cn": ["staff_px"], + "objectClass": ["posixGroup"], + "gidNumber": ["1001"], + "memberUid": ["alice"], + }) + superuser_px = ("cn=superuser_px,ou=groups,o=test", { + "cn": ["superuser_px"], + "objectClass": ["posixGroup"], + "gidNumber": ["1002"], + "memberUid": ["alice"], + }) + + # groupOfUniqueName groups + active_gon = ("cn=active_gon,ou=groups,o=test", { + "cn": ["active_gon"], + "objectClass": ["groupOfNames"], + "member": ["uid=alice,ou=people,o=test"] + }) + staff_gon = ("cn=staff_gon,ou=groups,o=test", { + "cn": ["staff_gon"], + "objectClass": ["groupOfNames"], + "member": ["uid=alice,ou=people,o=test"] + }) + superuser_gon = ("cn=superuser_gon,ou=groups,o=test", { + "cn": ["superuser_gon"], + "objectClass": ["groupOfNames"], + "member": ["uid=alice,ou=people,o=test"] + }) + + # Nested groups with a circular reference + parent_gon = ("cn=parent_gon,ou=groups,o=test", { + "cn": ["parent_gon"], + "objectClass": ["groupOfNames"], + "member": ["cn=nested_gon,ou=groups,o=test"] + }) + nested_gon = ("CN=nested_gon,ou=groups,o=test", { + "cn": ["nested_gon"], + "objectClass": ["groupOfNames"], + "member": [ + "uid=alice,ou=people,o=test", + "cn=circular_gon,ou=groups,o=test" + ] + }) + circular_gon = ("cn=circular_gon,ou=groups,o=test", { + "cn": ["circular_gon"], + "objectClass": ["groupOfNames"], + "member": ["cn=parent_gon,ou=groups,o=test"] + }) + + mock_ldap = MockLDAP({ + alice[0]: alice[1], + bob[0]: bob[1], + dressler[0]: dressler[1], + nobody[0]: nobody[1], + active_px[0]: active_px[1], + staff_px[0]: staff_px[1], + superuser_px[0]: superuser_px[1], + active_gon[0]: active_gon[1], + staff_gon[0]: staff_gon[1], + superuser_gon[0]: superuser_gon[1], + parent_gon[0]: parent_gon[1], + nested_gon[0]: nested_gon[1], + circular_gon[0]: circular_gon[1], + }) + + logging_configured = False + + def configure_logger(cls): + if not cls.logging_configured: + logger = logging.getLogger('django_auth_ldap') + formatter = logging.Formatter("LDAP auth - %(levelname)s - %(message)s") + handler = logging.StreamHandler() + + handler.setLevel(logging.DEBUG) + handler.setFormatter(formatter) + logger.addHandler(handler) + + logger.setLevel(logging.CRITICAL) + + cls.logging_configured = True + configure_logger = classmethod(configure_logger) + + def setUp(self): + self.configure_logger() + + self.ldap = _LDAPConfig.ldap = self.mock_ldap + + self.backend = backend.LDAPBackend() + self.backend.ldap # Force global configuration + + self.mock_ldap.reset() + + def tearDown(self): + pass + + def test_options(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + CONNECTION_OPTIONS={'opt1': 'value1'} + ) + + self.backend.authenticate(username='alice', password='password') + + self.assertEqual(self.mock_ldap.options, {'opt1': 'value1'}) + + def test_simple_bind(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test' + ) + user_count = User.objects.count() + + user = self.backend.authenticate(username='alice', password='password') + + self.assert_(not user.has_usable_password()) + self.assertEqual(user.username, 'alice') + self.assertEqual(User.objects.count(), user_count + 1) + self.assertEqual(self.mock_ldap.ldap_methods_called(), + ['initialize', 'simple_bind_s']) + + def test_new_user_lowercase(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test' + ) + user_count = User.objects.count() + + user = self.backend.authenticate(username='Alice', password='password') + + self.assert_(not user.has_usable_password()) + self.assertEqual(user.username, 'alice') + self.assertEqual(User.objects.count(), user_count + 1) + self.assertEqual(self.mock_ldap.ldap_methods_called(), + ['initialize', 'simple_bind_s']) + + def test_deepcopy(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test' + ) + + user = self.backend.authenticate(username='Alice', password='password') + user = deepcopy(user) + + @override_settings(AUTH_USER_MODEL='django_auth_ldap.TestUser') + def test_auth_custom_user(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + ) + + user = self.backend.authenticate(username='Alice', password='password') + + self.assert_(isinstance(user, TestUser)) + + @override_settings(AUTH_USER_MODEL='django_auth_ldap.TestUser') + def test_get_custom_user(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + ) + + user = self.backend.authenticate(username='Alice', password='password') + user = self.backend.get_user(user.id) + + self.assert_(isinstance(user, TestUser)) + + def test_new_user_whitespace(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test' + ) + user_count = User.objects.count() + + user = self.backend.authenticate(username=' alice', password='password') + user = self.backend.authenticate(username='alice ', password='password') + + self.assert_(not user.has_usable_password()) + self.assertEqual(user.username, 'alice') + self.assertEqual(User.objects.count(), user_count + 1) + + def test_simple_bind_bad_user(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test' + ) + user_count = User.objects.count() + + user = self.backend.authenticate(username='evil_alice', password='password') + + self.assert_(user is None) + self.assertEqual(User.objects.count(), user_count) + self.assertEqual(self.mock_ldap.ldap_methods_called(), + ['initialize', 'simple_bind_s']) + + def test_simple_bind_bad_password(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test' + ) + user_count = User.objects.count() + + user = self.backend.authenticate(username='alice', password='bogus') + + self.assert_(user is None) + self.assertEqual(User.objects.count(), user_count) + self.assertEqual(self.mock_ldap.ldap_methods_called(), + ['initialize', 'simple_bind_s']) + + def test_existing_user(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test' + ) + User.objects.create(username='alice') + user_count = User.objects.count() + + user = self.backend.authenticate(username='alice', password='password') + + # Make sure we only created one user + self.assert_(user is not None) + self.assertEqual(User.objects.count(), user_count) + + def test_existing_user_insensitive(self): + self._init_settings( + USER_SEARCH=LDAPSearch( + "ou=people,o=test", self.mock_ldap.SCOPE_SUBTREE, '(uid=%(user)s)' + ) + ) + self.mock_ldap.set_return_value('search_s', + ("ou=people,o=test", 2, "(uid=Alice)", None, 0), [self.alice]) + User.objects.create(username='alice') + + user = self.backend.authenticate(username='Alice', password='password') + + self.assert_(user is not None) + self.assertEqual(user.username, 'alice') + self.assertEqual(User.objects.count(), 1) + + def test_convert_username(self): + class MyBackend(backend.LDAPBackend): + def ldap_to_django_username(self, username): + return 'ldap_%s' % username + + def django_to_ldap_username(self, username): + return username[5:] + + self.backend = MyBackend() + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test' + ) + user_count = User.objects.count() + + user1 = self.backend.authenticate(username='alice', password='password') + user2 = self.backend.get_user(user1.pk) + + self.assertEqual(User.objects.count(), user_count + 1) + self.assertEqual(user1.username, 'ldap_alice') + self.assertEqual(user1.ldap_user._username, 'alice') + self.assertEqual(user1.ldap_username, 'alice') + self.assertEqual(user2.username, 'ldap_alice') + self.assertEqual(user2.ldap_user._username, 'alice') + self.assertEqual(user2.ldap_username, 'alice') + + def test_search_bind(self): + self._init_settings( + USER_SEARCH=LDAPSearch( + "ou=people,o=test", self.mock_ldap.SCOPE_SUBTREE, '(uid=%(user)s)' + ) + ) + self.mock_ldap.set_return_value('search_s', + ("ou=people,o=test", 2, "(uid=alice)", None, 0), [self.alice]) + user_count = User.objects.count() + + user = self.backend.authenticate(username='alice', password='password') + + self.assert_(user is not None) + self.assertEqual(User.objects.count(), user_count + 1) + self.assertEqual(self.mock_ldap.ldap_methods_called(), + ['initialize', 'simple_bind_s', 'search_s', 'simple_bind_s']) + + def test_search_bind_no_user(self): + self._init_settings( + USER_SEARCH=LDAPSearch( + "ou=people,o=test", self.mock_ldap.SCOPE_SUBTREE, '(cn=%(user)s)' + ) + ) + self.mock_ldap.set_return_value('search_s', + ("ou=people,o=test", 2, "(cn=alice)", None, 0), []) + + user = self.backend.authenticate(username='alice', password='password') + + self.assert_(user is None) + self.assertEqual(self.mock_ldap.ldap_methods_called(), + ['initialize', 'simple_bind_s', 'search_s']) + + def test_search_bind_multiple_users(self): + self._init_settings( + USER_SEARCH=LDAPSearch( + "ou=people,o=test", self.mock_ldap.SCOPE_SUBTREE, '(uid=*)' + ) + ) + self.mock_ldap.set_return_value('search_s', + ("ou=people,o=test", 2, "(uid=*)", None, 0), [self.alice, self.bob]) + + user = self.backend.authenticate(username='alice', password='password') + + self.assert_(user is None) + self.assertEqual(self.mock_ldap.ldap_methods_called(), + ['initialize', 'simple_bind_s', 'search_s']) + + def test_search_bind_bad_password(self): + self._init_settings( + USER_SEARCH=LDAPSearch( + "ou=people,o=test", self.mock_ldap.SCOPE_SUBTREE, '(uid=%(user)s)' + ) + ) + self.mock_ldap.set_return_value('search_s', + ("ou=people,o=test", 2, "(uid=alice)", None, 0), [self.alice]) + + user = self.backend.authenticate(username='alice', password='bogus') + + self.assert_(user is None) + self.assertEqual(self.mock_ldap.ldap_methods_called(), + ['initialize', 'simple_bind_s', 'search_s', 'simple_bind_s']) + + def test_search_bind_with_credentials(self): + self._init_settings( + BIND_DN='uid=bob,ou=people,o=test', + BIND_PASSWORD='password', + USER_SEARCH=LDAPSearch( + "ou=people,o=test", self.mock_ldap.SCOPE_SUBTREE, '(uid=%(user)s)' + ) + ) + self.mock_ldap.set_return_value('search_s', + ("ou=people,o=test", 2, "(uid=alice)", None, 0), [self.alice]) + + user = self.backend.authenticate(username='alice', password='password') + + self.assert_(user is not None) + self.assert_(user.ldap_user is not None) + self.assertEqual(user.ldap_user.dn, self.alice[0]) + self.assertEqual(user.ldap_user.attrs, self.alice[1]) + self.assertEqual(self.mock_ldap.ldap_methods_called(), + ['initialize', 'simple_bind_s', 'search_s', 'simple_bind_s']) + + def test_search_bind_with_bad_credentials(self): + self._init_settings( + BIND_DN='uid=bob,ou=people,o=test', + BIND_PASSWORD='bogus', + USER_SEARCH=LDAPSearch( + "ou=people,o=test", self.mock_ldap.SCOPE_SUBTREE, '(uid=%(user)s)' + ) + ) + + user = self.backend.authenticate(username='alice', password='password') + + self.assert_(user is None) + self.assertEqual(self.mock_ldap.ldap_methods_called(), + ['initialize', 'simple_bind_s']) + + def test_unicode_user(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + USER_ATTR_MAP={'first_name': 'givenName', 'last_name': 'sn'} + ) + + user = self.backend.authenticate(username=u'dreßler', password='password') + + self.assert_(user is not None) + self.assertEqual(user.username, u'dreßler') + self.assertEqual(user.last_name, u'Dreßler') + + def test_cidict(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + ) + + user = self.backend.authenticate(username="alice", password="password") + + self.assert_(isinstance(user.ldap_user.attrs, self.ldap.cidict.cidict)) + + def test_populate_user(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + USER_ATTR_MAP={'first_name': 'givenName', 'last_name': 'sn'} + ) + + user = self.backend.authenticate(username='alice', password='password') + + self.assertEqual(user.username, 'alice') + self.assertEqual(user.first_name, 'Alice') + self.assertEqual(user.last_name, 'Adams') + + # init, bind as user, bind anonymous, lookup user attrs + self.assertEqual(self.mock_ldap.ldap_methods_called(), + ['initialize', 'simple_bind_s', 'simple_bind_s', 'search_s']) + + def test_bind_as_user(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + USER_ATTR_MAP={'first_name': 'givenName', 'last_name': 'sn'}, + BIND_AS_AUTHENTICATING_USER=True, + ) + + user = self.backend.authenticate(username='alice', password='password') + + self.assertEqual(user.username, 'alice') + self.assertEqual(user.first_name, 'Alice') + self.assertEqual(user.last_name, 'Adams') + + # init, bind as user, lookup user attrs + self.assertEqual(self.mock_ldap.ldap_methods_called(), + ['initialize', 'simple_bind_s', 'search_s']) + + def test_signal_populate_user(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test' + ) + + def handle_populate_user(sender, **kwargs): + self.assert_('user' in kwargs and 'ldap_user' in kwargs) + kwargs['user'].populate_user_handled = True + backend.populate_user.connect(handle_populate_user) + + user = self.backend.authenticate(username='alice', password='password') + + self.assert_(user.populate_user_handled) + + def test_signal_populate_user_profile(self): + settings.AUTH_PROFILE_MODULE = 'django_auth_ldap.TestProfile' + + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test' + ) + + def handle_user_saved(sender, **kwargs): + if kwargs['created']: + TestProfile.objects.create(user=kwargs['instance']) + + def handle_populate_user_profile(sender, **kwargs): + self.assert_('profile' in kwargs and 'ldap_user' in kwargs) + kwargs['profile'].populated = True + + django.db.models.signals.post_save.connect(handle_user_saved, sender=User) + backend.populate_user_profile.connect(handle_populate_user_profile) + + user = self.backend.authenticate(username='alice', password='password') + + self.assert_(user.get_profile().populated) + + def test_no_update_existing(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + USER_ATTR_MAP={'first_name': 'givenName', 'last_name': 'sn'}, + ALWAYS_UPDATE_USER=False + ) + User.objects.create(username='alice', first_name='Alicia', last_name='Astro') + + alice = self.backend.authenticate(username='alice', password='password') + bob = self.backend.authenticate(username='bob', password='password') + + self.assertEqual(alice.first_name, 'Alicia') + self.assertEqual(alice.last_name, 'Astro') + self.assertEqual(bob.first_name, 'Robert') + self.assertEqual(bob.last_name, 'Barker') + + def test_require_group(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), + GROUP_TYPE=MemberDNGroupType(member_attr='member'), + REQUIRE_GROUP="cn=active_gon,ou=groups,o=test" + ) + + alice = self.backend.authenticate(username='alice', password='password') + bob = self.backend.authenticate(username='bob', password='password') + + self.assert_(alice is not None) + self.assert_(bob is None) + self.assertEqual(self.mock_ldap.ldap_methods_called(), + ['initialize', 'simple_bind_s', 'simple_bind_s', 'compare_s', 'initialize', 'simple_bind_s', 'simple_bind_s', 'compare_s']) + + def test_denied_group(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), + GROUP_TYPE=MemberDNGroupType(member_attr='member'), + DENY_GROUP="cn=active_gon,ou=groups,o=test" + ) + + alice = self.backend.authenticate(username='alice', password='password') + bob = self.backend.authenticate(username='bob', password='password') + + self.assert_(alice is None) + self.assert_(bob is not None) + self.assertEqual(self.mock_ldap.ldap_methods_called(), + ['initialize', 'simple_bind_s', 'simple_bind_s', 'compare_s', 'initialize', 'simple_bind_s', 'simple_bind_s', 'compare_s']) + + def test_group_dns(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), + GROUP_TYPE=MemberDNGroupType(member_attr='member'), + ) + self.mock_ldap.set_return_value('search_s', + ("ou=groups,o=test", 2, "(&(objectClass=*)(member=uid=alice,ou=people,o=test))", None, 0), + [self.active_gon, self.staff_gon, self.superuser_gon, self.nested_gon] + ) + + alice = self.backend.authenticate(username='alice', password='password') + + self.assertEqual(alice.ldap_user.group_dns, set((g[0].lower() for g in [self.active_gon, self.staff_gon, self.superuser_gon, self.nested_gon]))) + + def test_group_names(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), + GROUP_TYPE=MemberDNGroupType(member_attr='member'), + ) + self.mock_ldap.set_return_value('search_s', + ("ou=groups,o=test", 2, "(&(objectClass=*)(member=uid=alice,ou=people,o=test))", None, 0), + [self.active_gon, self.staff_gon, self.superuser_gon, self.nested_gon] + ) + + alice = self.backend.authenticate(username='alice', password='password') + + self.assertEqual(alice.ldap_user.group_names, set(['active_gon', 'staff_gon', 'superuser_gon', 'nested_gon'])) + + def test_dn_group_membership(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), + GROUP_TYPE=MemberDNGroupType(member_attr='member'), + USER_FLAGS_BY_GROUP={ + 'is_active': "cn=active_gon,ou=groups,o=test", + 'is_staff': "cn=staff_gon,ou=groups,o=test", + 'is_superuser': "cn=superuser_gon,ou=groups,o=test" + } + ) + + alice = self.backend.authenticate(username='alice', password='password') + bob = self.backend.authenticate(username='bob', password='password') + + self.assert_(alice.is_active) + self.assert_(alice.is_staff) + self.assert_(alice.is_superuser) + self.assert_(not bob.is_active) + self.assert_(not bob.is_staff) + self.assert_(not bob.is_superuser) + + def test_posix_membership(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), + GROUP_TYPE=PosixGroupType(), + USER_FLAGS_BY_GROUP={ + 'is_active': "cn=active_px,ou=groups,o=test", + 'is_staff': "cn=staff_px,ou=groups,o=test", + 'is_superuser': "cn=superuser_px,ou=groups,o=test" + } + ) + + alice = self.backend.authenticate(username='alice', password='password') + bob = self.backend.authenticate(username='bob', password='password') + + self.assert_(alice.is_active) + self.assert_(alice.is_staff) + self.assert_(alice.is_superuser) + self.assert_(not bob.is_active) + self.assert_(not bob.is_staff) + self.assert_(not bob.is_superuser) + + def test_nested_dn_group_membership(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), + GROUP_TYPE=NestedMemberDNGroupType(member_attr='member'), + USER_FLAGS_BY_GROUP={ + 'is_active': "cn=parent_gon,ou=groups,o=test", + 'is_staff': "cn=parent_gon,ou=groups,o=test", + } + ) + self.mock_ldap.set_return_value('search_s', + ("ou=groups,o=test", 2, "(&(objectClass=*)(|(member=uid=alice,ou=people,o=test)))", None, 0), + [self.active_gon, self.nested_gon] + ) + self.mock_ldap.set_return_value('search_s', + ("ou=groups,o=test", 2, "(&(objectClass=*)(|(member=cn=active_gon,ou=groups,o=test)(member=cn=nested_gon,ou=groups,o=test)))", None, 0), + [self.parent_gon] + ) + self.mock_ldap.set_return_value('search_s', + ("ou=groups,o=test", 2, "(&(objectClass=*)(|(member=cn=parent_gon,ou=groups,o=test)))", None, 0), + [self.circular_gon] + ) + self.mock_ldap.set_return_value('search_s', + ("ou=groups,o=test", 2, "(&(objectClass=*)(|(member=cn=circular_gon,ou=groups,o=test)))", None, 0), + [self.nested_gon] + ) + + self.mock_ldap.set_return_value('search_s', + ("ou=groups,o=test", 2, "(&(objectClass=*)(|(member=uid=bob,ou=people,o=test)))", None, 0), + [] + ) + + alice = self.backend.authenticate(username='alice', password='password') + bob = self.backend.authenticate(username='bob', password='password') + + self.assert_(alice.is_active) + self.assert_(alice.is_staff) + self.assert_(not bob.is_active) + self.assert_(not bob.is_staff) + + def test_posix_missing_attributes(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), + GROUP_TYPE=PosixGroupType(), + USER_FLAGS_BY_GROUP={ + 'is_active': "cn=active_px,ou=groups,o=test" + } + ) + + nobody = self.backend.authenticate(username='nobody', password='password') + + self.assert_(not nobody.is_active) + + def test_profile_flags(self): + settings.AUTH_PROFILE_MODULE = 'django_auth_ldap.TestProfile' + + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), + GROUP_TYPE=MemberDNGroupType(member_attr='member'), + PROFILE_FLAGS_BY_GROUP={ + 'is_special': "cn=superuser_gon,ou=groups,o=test" + } + ) + + def handle_user_saved(sender, **kwargs): + if kwargs['created']: + TestProfile.objects.create(user=kwargs['instance']) + + django.db.models.signals.post_save.connect(handle_user_saved, sender=User) + + alice = self.backend.authenticate(username='alice', password='password') + bob = self.backend.authenticate(username='bob', password='password') + + self.assert_(alice.get_profile().is_special) + self.assert_(not bob.get_profile().is_special) + + def test_dn_group_permissions(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), + GROUP_TYPE=MemberDNGroupType(member_attr='member'), + FIND_GROUP_PERMS=True + ) + self._init_groups() + self.mock_ldap.set_return_value('search_s', + ("ou=groups,o=test", 2, "(&(objectClass=*)(member=uid=alice,ou=people,o=test))", None, 0), + [self.active_gon, self.staff_gon, self.superuser_gon, self.nested_gon] + ) + + alice = User.objects.create(username='alice') + alice = self.backend.get_user(alice.pk) + + self.assertEqual(self.backend.get_group_permissions(alice), set(["auth.add_user", "auth.change_user"])) + self.assertEqual(self.backend.get_all_permissions(alice), set(["auth.add_user", "auth.change_user"])) + self.assert_(self.backend.has_perm(alice, "auth.add_user")) + self.assert_(self.backend.has_module_perms(alice, "auth")) + + def test_empty_group_permissions(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), + GROUP_TYPE=MemberDNGroupType(member_attr='member'), + FIND_GROUP_PERMS=True + ) + self._init_groups() + self.mock_ldap.set_return_value('search_s', + ("ou=groups,o=test", 2, "(&(objectClass=*)(member=uid=bob,ou=people,o=test))", None, 0), + [] + ) + + bob = User.objects.create(username='bob') + bob = self.backend.get_user(bob.pk) + + self.assertEqual(self.backend.get_group_permissions(bob), set()) + self.assertEqual(self.backend.get_all_permissions(bob), set()) + self.assert_(not self.backend.has_perm(bob, "auth.add_user")) + self.assert_(not self.backend.has_module_perms(bob, "auth")) + + def test_posix_group_permissions(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + GROUP_SEARCH=LDAPSearch('ou=groups,o=test', + self.mock_ldap.SCOPE_SUBTREE, "(objectClass=posixGroup)" + ), + GROUP_TYPE=PosixGroupType(), + FIND_GROUP_PERMS=True + ) + self._init_groups() + self.mock_ldap.set_return_value('search_s', + ("ou=groups,o=test", 2, "(&(objectClass=posixGroup)(|(gidNumber=1000)(memberUid=alice)))", None, 0), + [self.active_px, self.staff_px, self.superuser_px] + ) + + alice = User.objects.create(username='alice') + alice = self.backend.get_user(alice.pk) + + self.assertEqual(self.backend.get_group_permissions(alice), set(["auth.add_user", "auth.change_user"])) + self.assertEqual(self.backend.get_all_permissions(alice), set(["auth.add_user", "auth.change_user"])) + self.assert_(self.backend.has_perm(alice, "auth.add_user")) + self.assert_(self.backend.has_module_perms(alice, "auth")) + + def test_foreign_user_permissions(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), + GROUP_TYPE=MemberDNGroupType(member_attr='member'), + FIND_GROUP_PERMS=True + ) + self._init_groups() + + alice = User.objects.create(username='alice') + + self.assertEqual(self.backend.get_group_permissions(alice), set()) + + def test_group_cache(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), + GROUP_TYPE=MemberDNGroupType(member_attr='member'), + FIND_GROUP_PERMS=True, + CACHE_GROUPS=True + ) + self._init_groups() + self.mock_ldap.set_return_value('search_s', + ("ou=groups,o=test", 2, "(&(objectClass=*)(member=uid=alice,ou=people,o=test))", None, 0), + [self.active_gon, self.staff_gon, self.superuser_gon, self.nested_gon] + ) + self.mock_ldap.set_return_value('search_s', + ("ou=groups,o=test", 2, "(&(objectClass=*)(member=uid=bob,ou=people,o=test))", None, 0), + [] + ) + + alice_id = User.objects.create(username='alice').pk + bob_id = User.objects.create(username='bob').pk + + # Check permissions twice for each user + for i in range(2): + alice = self.backend.get_user(alice_id) + self.assertEqual(self.backend.get_group_permissions(alice), + set(["auth.add_user", "auth.change_user"])) + + bob = self.backend.get_user(bob_id) + self.assertEqual(self.backend.get_group_permissions(bob), set()) + + # Should have executed one LDAP search per user + self.assertEqual(self.mock_ldap.ldap_methods_called(), + ['initialize', 'simple_bind_s', 'search_s', 'initialize', 'simple_bind_s', 'search_s']) + + def test_group_mirroring(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + GROUP_SEARCH=LDAPSearch('ou=groups,o=test', + self.mock_ldap.SCOPE_SUBTREE, "(objectClass=posixGroup)" + ), + GROUP_TYPE=PosixGroupType(), + MIRROR_GROUPS=True, + ) + self.mock_ldap.set_return_value('search_s', + ("ou=groups,o=test", 2, "(&(objectClass=posixGroup)(|(gidNumber=1000)(memberUid=alice)))", None, 0), + [self.active_px, self.staff_px, self.superuser_px] + ) + + self.assertEqual(Group.objects.count(), 0) + + alice = self.backend.authenticate(username='alice', password='password') + + self.assertEqual(Group.objects.count(), 3) + self.assertEqual(set(alice.groups.all()), set(Group.objects.all())) + + def test_nested_group_mirroring(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), + GROUP_TYPE=NestedMemberDNGroupType(member_attr='member'), + MIRROR_GROUPS=True, + ) + self.mock_ldap.set_return_value('search_s', + ("ou=groups,o=test", 2, "(&(objectClass=*)(|(member=uid=alice,ou=people,o=test)))", None, 0), + [self.active_gon, self.nested_gon] + ) + self.mock_ldap.set_return_value('search_s', + ("ou=groups,o=test", 2, "(&(objectClass=*)(|(member=cn=active_gon,ou=groups,o=test)(member=cn=nested_gon,ou=groups,o=test)))", None, 0), + [self.parent_gon] + ) + self.mock_ldap.set_return_value('search_s', + ("ou=groups,o=test", 2, "(&(objectClass=*)(|(member=cn=parent_gon,ou=groups,o=test)))", None, 0), + [self.circular_gon] + ) + self.mock_ldap.set_return_value('search_s', + ("ou=groups,o=test", 2, "(&(objectClass=*)(|(member=cn=circular_gon,ou=groups,o=test)))", None, 0), + [self.nested_gon] + ) + + alice = self.backend.authenticate(username='alice', password='password') + + self.assertEqual(Group.objects.count(), 4) + self.assertEqual(set(Group.objects.all().values_list('name', flat=True)), + set(['active_gon', 'nested_gon', 'parent_gon', 'circular_gon'])) + self.assertEqual(set(alice.groups.all()), set(Group.objects.all())) + + def test_authorize_external_users(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), + GROUP_TYPE=MemberDNGroupType(member_attr='member'), + FIND_GROUP_PERMS=True, + AUTHORIZE_ALL_USERS=True + ) + self._init_groups() + self.mock_ldap.set_return_value('search_s', + ("ou=groups,o=test", 2, "(&(objectClass=*)(member=uid=alice,ou=people,o=test))", None, 0), + [self.active_gon, self.staff_gon, self.superuser_gon, self.nested_gon] + ) + + alice = User.objects.create(username='alice') + + self.assertEqual(self.backend.get_group_permissions(alice), set(["auth.add_user", "auth.change_user"])) + + def test_create_without_auth(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + ) + + alice = self.backend.populate_user('alice') + bob = self.backend.populate_user('bob') + + self.assert_(alice is not None) + self.assertEqual(alice.first_name, u"") + self.assertEqual(alice.last_name, u"") + self.assert_(alice.is_active) + self.assert_(not alice.is_staff) + self.assert_(not alice.is_superuser) + self.assert_(bob is not None) + self.assertEqual(bob.first_name, u"") + self.assertEqual(bob.last_name, u"") + self.assert_(bob.is_active) + self.assert_(not bob.is_staff) + self.assert_(not bob.is_superuser) + + def test_populate_without_auth(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + ALWAYS_UPDATE_USER=False, + USER_ATTR_MAP={'first_name': 'givenName', 'last_name': 'sn'}, + GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), + GROUP_TYPE=GroupOfNamesType(), + USER_FLAGS_BY_GROUP={ + 'is_active': "cn=active_gon,ou=groups,o=test", + 'is_staff': "cn=staff_gon,ou=groups,o=test", + 'is_superuser': "cn=superuser_gon,ou=groups,o=test" + } + ) + + User.objects.create(username='alice') + User.objects.create(username='bob') + + alice = self.backend.populate_user('alice') + bob = self.backend.populate_user('bob') + + self.assert_(alice is not None) + self.assertEqual(alice.first_name, u"Alice") + self.assertEqual(alice.last_name, u"Adams") + self.assert_(alice.is_active) + self.assert_(alice.is_staff) + self.assert_(alice.is_superuser) + self.assert_(bob is not None) + self.assertEqual(bob.first_name, u"Robert") + self.assertEqual(bob.last_name, u"Barker") + self.assert_(not bob.is_active) + self.assert_(not bob.is_staff) + self.assert_(not bob.is_superuser) + + def test_populate_bogus_user(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + ) + + bogus = self.backend.populate_user('bogus') + + self.assertEqual(bogus, None) + + def test_start_tls_missing(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + START_TLS=False, + ) + + self.assert_(not self.mock_ldap.tls_enabled) + self.backend.authenticate(username='alice', password='password') + self.assert_(not self.mock_ldap.tls_enabled) + + def test_start_tls(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + START_TLS=True, + ) + + self.assert_(not self.mock_ldap.tls_enabled) + self.backend.authenticate(username='alice', password='password') + self.assert_(self.mock_ldap.tls_enabled) + + def test_null_search_results(self): + """ + Make sure we're not phased by referrals. + """ + self._init_settings( + USER_SEARCH=LDAPSearch( + "ou=people,o=test", self.mock_ldap.SCOPE_SUBTREE, '(uid=%(user)s)' + ) + ) + self.mock_ldap.set_return_value('search_s', + ("ou=people,o=test", 2, "(uid=alice)", None, 0), [self.alice, (None, '')]) + + self.backend.authenticate(username='alice', password='password') + + def test_union_search(self): + self._init_settings( + USER_SEARCH=LDAPSearchUnion( + LDAPSearch("ou=groups,o=test", self.mock_ldap.SCOPE_SUBTREE, '(uid=%(user)s)'), + LDAPSearch("ou=people,o=test", self.mock_ldap.SCOPE_SUBTREE, '(uid=%(user)s)'), + ) + ) + self.mock_ldap.set_return_value('search_s', + ("ou=groups,o=test", 2, "(uid=alice)", None, 0), []) + self.mock_ldap.set_return_value('search_s', + ("ou=people,o=test", 2, "(uid=alice)", None, 0), [self.alice]) + + alice = self.backend.authenticate(username='alice', password='password') + + self.assert_(alice is not None) + + self.assertEqual(self.mock_ldap.ldap_methods_called(), + ['initialize', 'simple_bind_s', 'search', 'search', 'result', + 'result', 'simple_bind_s']) + + def test_deny_empty_password(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + ) + + alice = self.backend.authenticate(username=u'alice', password=u'') + + self.assertEqual(alice, None) + self.assertEqual(self.mock_ldap.ldap_methods_called(), []) + + def test_permit_empty_password(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + PERMIT_EMPTY_PASSWORD=True, + ) + + alice = self.backend.authenticate(username=u'alice', password=u'') + + self.assertEqual(alice, None) + self.assertEqual(self.mock_ldap.ldap_methods_called(), + ['initialize', 'simple_bind_s']) + + def test_pickle(self): + self._init_settings( + USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', + GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), + GROUP_TYPE=MemberDNGroupType(member_attr='member'), + FIND_GROUP_PERMS=True + ) + self._init_groups() + self.mock_ldap.set_return_value('search_s', + ("ou=groups,o=test", 2, "(&(objectClass=*)(member=uid=alice,ou=people,o=test))", None, 0), + [self.active_gon, self.staff_gon, self.superuser_gon, self.nested_gon] + ) + + alice0 = self.backend.authenticate(username=u'alice', password=u'password') + + pickled = pickle.dumps(alice0, pickle.HIGHEST_PROTOCOL) + alice = pickle.loads(pickled) + alice.ldap_user.backend.settings = alice0.ldap_user.backend.settings + + self.assert_(alice is not None) + self.assertEqual(self.backend.get_group_permissions(alice), set(["auth.add_user", "auth.change_user"])) + self.assertEqual(self.backend.get_all_permissions(alice), set(["auth.add_user", "auth.change_user"])) + self.assert_(self.backend.has_perm(alice, "auth.add_user")) + self.assert_(self.backend.has_module_perms(alice, "auth")) + + def _init_settings(self, **kwargs): + self.backend.settings = TestSettings(**kwargs) + + def _init_groups(self): + permissions = [ + Permission.objects.get(codename="add_user"), + Permission.objects.get(codename="change_user") + ] + + active_gon = Group.objects.create(name='active_gon') + active_gon.permissions.add(*permissions) + + active_px = Group.objects.create(name='active_px') + active_px.permissions.add(*permissions) diff --git a/requirements/dev.txt b/requirements/dev.txt index 5d750fcfa3..54bf44d9b1 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -7,6 +7,7 @@ Django>=1.4 # The following packages are now bundled with AWX (awx/lib/site-packages): + #django-auth-ldap #django-celery #django-extensions #django-jsonfield @@ -27,6 +28,7 @@ ipython # package manager, or pip if you're running inside a virtualenv. # - ansible (via yum, pip or source checkout) # - psycopg2 (via "yum install python-psycopg2") +# - python-ldap (via "yum install python-ldap") # - coverage (if you want to check test coverage, via "pip install coverage"; # the default python-coverage package is old.) # - readline (for using the ipython interactive shell) diff --git a/requirements/dev_local.txt b/requirements/dev_local.txt index b518c47c35..0f0d0e0dbd 100644 --- a/requirements/dev_local.txt +++ b/requirements/dev_local.txt @@ -19,6 +19,7 @@ Django-1.5.2.tar.gz #celery-3.0.22.tar.gz #pytz-2013b.tar.gz # Remaining dev/prod packages: + #django-auth-ldap-1.1.4.tar.gz #django-celery-3.0.21.tar.gz #django-extensions-1.2.0.tar.gz #django-jsonfield-0.9.10.tar.gz @@ -38,6 +39,7 @@ ipython-1.0.0.tar.gz # package manager, or pip if you're running inside a virtualenv. # - ansible (via yum, pip or source checkout) # - psycopg2 (via "yum install python-psycopg2") +# - python-ldap (via "yum install python-ldap") # - coverage-3.6.tar.gz (if you want to check test coverage; the default # python-coverage package is old.) # - readline-6.2.4.1.tar.gz (for using the ipython interactive shell) diff --git a/requirements/django-auth-ldap-1.1.4.tar.gz b/requirements/django-auth-ldap-1.1.4.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..de2f3f6919a40b68528e925886869c8d94089875 GIT binary patch literal 39252 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

{I4sk&z`P={;Z_o^3o;aPgV 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 M0B;67%K#t)0PHt$LI3~& literal 0 HcmV?d00001 diff --git a/requirements/prod.txt b/requirements/prod.txt index 73b7f95b2e..11635fbaba 100644 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -4,6 +4,7 @@ Django>=1.4 # The following packages are now bundled with AWX (awx/lib/site-packages): + #django-auth-ldap #django-celery #django-extensions #django-jsonfield @@ -19,3 +20,4 @@ Django>=1.4 # package manager, or pip if you're running inside a virtualenv. # - ansible (via yum, pip or source checkout) # - psycopg2 (via "yum install python-psycopg2") +# - python-ldap (via "yum install python-ldap") diff --git a/requirements/prod_local.txt b/requirements/prod_local.txt index 0694fb1249..f6ad22c556 100644 --- a/requirements/prod_local.txt +++ b/requirements/prod_local.txt @@ -17,6 +17,7 @@ Django-1.5.2.tar.gz #celery-3.0.22.tar.gz #pytz-2013b.tar.gz # Remaining dev/prod packages: + #django-auth-ldap-1.1.4.tar.gz #django-celery-3.0.21.tar.gz #django-extensions-1.2.0.tar.gz #django-jsonfield-0.9.10.tar.gz @@ -31,3 +32,4 @@ Django-1.5.2.tar.gz # package manager, or pip if you're running inside a virtualenv. # - ansible (via yum, pip or source checkout) # - psycopg2 (via "yum install python-psycopg2") +# - python-ldap (via "yum install python-ldap")