Source

django-auth-ldap / django_auth_ldap / config.py

The default branch has multiple heads

Full commit
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
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)