AC-156. Add dependencies for LDAP support.

This commit is contained in:
Chris Church 2013-09-06 17:11:38 -04:00
parent 8cdbaa83b2
commit 1763d373eb
12 changed files with 2856 additions and 0 deletions

View File

@ -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/*)

View File

@ -0,0 +1,2 @@
version = (1, 1, 4)
version_string = "1.1.4"

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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)

File diff suppressed because it is too large Load Diff

View File

@ -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)

View File

@ -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)

Binary file not shown.

View File

@ -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")

View File

@ -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")