├── .hgignore ├── MANIFEST.in ├── django_auth_ldap ├── __init__.py ├── models.py ├── dn.py ├── config.py ├── backend.py └── tests.py ├── setup.py ├── README.rst ├── docs ├── Makefile ├── conf.py └── index.rst └── .hgtags /.hgignore: -------------------------------------------------------------------------------- 1 | ^build/ 2 | ^dist/ 3 | ^docs/_build/ 4 | ^MANIFEST$ -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | recursive-include docs Makefile *.py *.rst 3 | -------------------------------------------------------------------------------- /django_auth_ldap/__init__.py: -------------------------------------------------------------------------------- 1 | version = (1, 1, 0) 2 | version_string = "1.1.0" 3 | -------------------------------------------------------------------------------- /django_auth_ldap/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class TestProfile(models.Model): 5 | """ 6 | A user profile model for use by unit tests. This has nothing to do with the 7 | authentication backend itself. 8 | """ 9 | user = models.OneToOneField('auth.User') 10 | is_special = models.BooleanField(default=False) 11 | populated = models.BooleanField(default=False) 12 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from distutils.core import setup 4 | 5 | setup( 6 | name="django-auth-ldap", 7 | version="1.1", 8 | description="Django LDAP authentication backend", 9 | long_description=open('README.rst').read(), 10 | url="http://bitbucket.org/psagers/django-auth-ldap/", 11 | author="Peter Sagerson", 12 | author_email="psagers.pypi@ignorare.net", 13 | license="BSD", 14 | packages=["django_auth_ldap"], 15 | classifiers=[ 16 | "Development Status :: 5 - Production/Stable", 17 | "Environment :: Web Environment", 18 | "Programming Language :: Python", 19 | "Framework :: Django", 20 | "Intended Audience :: Developers", 21 | "Intended Audience :: System Administrators", 22 | "License :: OSI Approved :: BSD License", 23 | "Topic :: Internet :: WWW/HTTP", 24 | "Topic :: System :: Systems Administration :: Authentication/Directory :: LDAP", 25 | "Topic :: Software Development :: Libraries :: Python Modules", 26 | ], 27 | keywords=["django", "ldap", "authentication", "auth"], 28 | ) 29 | -------------------------------------------------------------------------------- /django_auth_ldap/dn.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2009, Peter Sagerson 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are met: 6 | # 7 | # - Redistributions of source code must retain the above copyright notice, this 8 | # list of conditions and the following disclaimer. 9 | # 10 | # - Redistributions in binary form must reproduce the above copyright notice, 11 | # this list of conditions and the following disclaimer in the documentation 12 | # and/or other materials provided with the distribution. 13 | # 14 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 15 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 16 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 18 | # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 19 | # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 20 | # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 21 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 22 | # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 23 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | """ 26 | This is an ldap.dn replacement for old versions of python-ldap. It contains 27 | (often naive) implementations of the methods we care about. 28 | """ 29 | 30 | def escape_dn_chars(dn): 31 | "Old versions of python-ldap won't get DN escaping. Use with care." 32 | return dn 33 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | This is a Django authentication backend that authenticates against an LDAP 2 | service. Configuration can be as simple as a single distinguished name 3 | template, but there are many rich configuration options for working with users, 4 | groups, and permissions. 5 | 6 | This version requires at least Django 1.3 and python-ldap 2.0. Full 7 | documentation can be found at http://packages.python.org/django-auth-ldap/; 8 | following is an example configuration, just to whet your appetite:: 9 | 10 | import ldap 11 | from django_auth_ldap.config import LDAPSearch, GroupOfNamesType 12 | 13 | 14 | # Baseline configuration. 15 | AUTH_LDAP_SERVER_URI = "ldap://ldap.example.com" 16 | 17 | AUTH_LDAP_BIND_DN = "cn=django-agent,dc=example,dc=com" 18 | AUTH_LDAP_BIND_PASSWORD = "phlebotinum" 19 | AUTH_LDAP_USER_SEARCH = LDAPSearch("ou=users,dc=example,dc=com", 20 | ldap.SCOPE_SUBTREE, "(uid=%(user)s)") 21 | # or perhaps: 22 | # AUTH_LDAP_USER_DN_TEMPLATE = "uid=%(user)s,ou=users,dc=example,dc=com" 23 | 24 | # Set up the basic group parameters. 25 | AUTH_LDAP_GROUP_SEARCH = LDAPSearch("ou=django,ou=groups,dc=example,dc=com", 26 | ldap.SCOPE_SUBTREE, "(objectClass=groupOfNames)" 27 | ) 28 | AUTH_LDAP_GROUP_TYPE = GroupOfNamesType() 29 | 30 | # Simple group restrictions 31 | AUTH_LDAP_REQUIRE_GROUP = "cn=enabled,ou=django,ou=groups,dc=example,dc=com" 32 | AUTH_LDAP_DENY_GROUP = "cn=disabled,ou=django,ou=groups,dc=example,dc=com" 33 | 34 | # Populate the Django user from the LDAP directory. 35 | AUTH_LDAP_USER_ATTR_MAP = { 36 | "first_name": "givenName", 37 | "last_name": "sn", 38 | "email": "mail" 39 | } 40 | 41 | AUTH_LDAP_PROFILE_ATTR_MAP = { 42 | "employee_number": "employeeNumber" 43 | } 44 | 45 | AUTH_LDAP_USER_FLAGS_BY_GROUP = { 46 | "is_active": "cn=active,ou=django,ou=groups,dc=example,dc=com", 47 | "is_staff": "cn=staff,ou=django,ou=groups,dc=example,dc=com", 48 | "is_superuser": "cn=superuser,ou=django,ou=groups,dc=example,dc=com" 49 | } 50 | 51 | AUTH_LDAP_PROFILE_FLAGS_BY_GROUP = { 52 | "is_awesome": "cn=awesome,ou=django,ou=groups,dc=example,dc=com", 53 | } 54 | 55 | # Use LDAP group membership to calculate group permissions. 56 | AUTH_LDAP_FIND_GROUP_PERMS = True 57 | 58 | # Cache group memberships for an hour to minimize LDAP traffic 59 | AUTH_LDAP_CACHE_GROUPS = True 60 | AUTH_LDAP_GROUP_CACHE_TIMEOUT = 3600 61 | 62 | 63 | # Keep ModelBackend around for per-user permissions and maybe a local 64 | # superuser. 65 | AUTHENTICATION_BACKENDS = ( 66 | 'django_auth_ldap.backend.LDAPBackend', 67 | 'django.contrib.auth.backends.ModelBackend', 68 | ) 69 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | 9 | # Internal variables. 10 | PAPEROPT_a4 = -D latex_paper_size=a4 11 | PAPEROPT_letter = -D latex_paper_size=letter 12 | ALLSPHINXOPTS = -d _build/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 13 | 14 | .PHONY: help clean html dirhtml pickle json htmlhelp qthelp latex changes linkcheck doctest 15 | 16 | help: 17 | @echo "Please use 'make ' where is one of" 18 | @echo " html to make standalone HTML files" 19 | @echo " dirhtml to make HTML files named index.html in directories" 20 | @echo " pickle to make pickle files" 21 | @echo " json to make JSON files" 22 | @echo " htmlhelp to make HTML files and a HTML help project" 23 | @echo " qthelp to make HTML files and a qthelp project" 24 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 25 | @echo " changes to make an overview of all changed/added/deprecated items" 26 | @echo " linkcheck to check all external links for integrity" 27 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 28 | 29 | clean: 30 | -rm -rf _build 31 | 32 | html: 33 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) _build/html 34 | @echo 35 | @echo "Build finished. The HTML pages are in _build/html." 36 | 37 | dirhtml: 38 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) _build/dirhtml 39 | @echo 40 | @echo "Build finished. The HTML pages are in _build/dirhtml." 41 | 42 | pickle: 43 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) _build/pickle 44 | @echo 45 | @echo "Build finished; now you can process the pickle files." 46 | 47 | json: 48 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) _build/json 49 | @echo 50 | @echo "Build finished; now you can process the JSON files." 51 | 52 | htmlhelp: 53 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) _build/htmlhelp 54 | @echo 55 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 56 | ".hhp project file in _build/htmlhelp." 57 | 58 | qthelp: 59 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) _build/qthelp 60 | @echo 61 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 62 | ".qhcp project file in _build/qthelp, like this:" 63 | @echo "# qcollectiongenerator _build/qthelp/django-auth-ldap.qhcp" 64 | @echo "To view the help file:" 65 | @echo "# assistant -collectionFile _build/qthelp/django-auth-ldap.qhc" 66 | 67 | latex: 68 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) _build/latex 69 | @echo 70 | @echo "Build finished; the LaTeX files are in _build/latex." 71 | @echo "Run 'make all-pdf' or 'make all-ps' in that directory to" \ 72 | "run these through (pdf)latex." 73 | 74 | changes: 75 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) _build/changes 76 | @echo 77 | @echo "The overview file is in _build/changes." 78 | 79 | linkcheck: 80 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) _build/linkcheck 81 | @echo 82 | @echo "Link check complete; look for any errors in the above output " \ 83 | "or in _build/linkcheck/output.txt." 84 | 85 | doctest: 86 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) _build/doctest 87 | @echo "Testing of doctests in the sources finished, look at the " \ 88 | "results in _build/doctest/output.txt." 89 | 90 | zip: 91 | rm _build/html.zip || true 92 | cd _build/html && zip -R ../html.zip '*' -x .buildinfo -x '_sources/*' 93 | -------------------------------------------------------------------------------- /.hgtags: -------------------------------------------------------------------------------- 1 | ec5118a71e2bbfd3dc4113a257c83d0bd287f9e1 1.0 2 | ec5118a71e2bbfd3dc4113a257c83d0bd287f9e1 1.0.0 3 | ec5118a71e2bbfd3dc4113a257c83d0bd287f9e1 1.0.X 4 | ec5118a71e2bbfd3dc4113a257c83d0bd287f9e1 1.0 5 | 0000000000000000000000000000000000000000 1.0 6 | 7c444f0c59bfe347da5241921683c359374c277e 1.0.1 7 | ec5118a71e2bbfd3dc4113a257c83d0bd287f9e1 1.0.X 8 | 047f90ab09d4193cbf18e6c03827c05a7a33b5ee 1.0.X 9 | 047f90ab09d4193cbf18e6c03827c05a7a33b5ee 1.0.X 10 | 7c444f0c59bfe347da5241921683c359374c277e 1.0.X 11 | 7c444f0c59bfe347da5241921683c359374c277e 1.0.1 12 | b06c3a4d20c9816cfbd04f642b1724a284c72d9b 1.0.1 13 | 7c444f0c59bfe347da5241921683c359374c277e 1.0.X 14 | b06c3a4d20c9816cfbd04f642b1724a284c72d9b 1.0.X 15 | b06c3a4d20c9816cfbd04f642b1724a284c72d9b 1.0.1 16 | 6e5d1a8a881a852a46387af63dca7f07042d05f6 1.0.1 17 | b06c3a4d20c9816cfbd04f642b1724a284c72d9b 1.0.X 18 | 6e5d1a8a881a852a46387af63dca7f07042d05f6 1.0.X 19 | 6e5d1a8a881a852a46387af63dca7f07042d05f6 1.0.1 20 | b06c3a4d20c9816cfbd04f642b1724a284c72d9b 1.0.1 21 | 9010b64a7b2b8da99bb37a2820628061cef17aa4 1.0.2 22 | 9010b64a7b2b8da99bb37a2820628061cef17aa4 6e5d1a 23 | 9010b64a7b2b8da99bb37a2820628061cef17aa4 1.0.2 24 | 9010b64a7b2b8da99bb37a2820628061cef17aa4 1.0.2 25 | 9010b64a7b2b8da99bb37a2820628061cef17aa4 6e5d1a 26 | 0000000000000000000000000000000000000000 6e5d1a 27 | 9010b64a7b2b8da99bb37a2820628061cef17aa4 1.0.2 28 | 6e5d1a8a881a852a46387af63dca7f07042d05f6 1.0.2 29 | 4e4301c7a9cce41fbebce74d104dedb85ecd9047 1.0.3 30 | 6e5d1a8a881a852a46387af63dca7f07042d05f6 1.0.X 31 | 4e4301c7a9cce41fbebce74d104dedb85ecd9047 1.0.X 32 | 4e4301c7a9cce41fbebce74d104dedb85ecd9047 1.0.3 33 | 11981f7a20fbec7463a875a5fddb57030dca8088 1.0.3 34 | 4e4301c7a9cce41fbebce74d104dedb85ecd9047 1.0.X 35 | 11981f7a20fbec7463a875a5fddb57030dca8088 1.0.X 36 | 54d464733f7ea244effbeac16e8e9e03bfb765cb 1.0.4 37 | 11981f7a20fbec7463a875a5fddb57030dca8088 1.0.X 38 | 54d464733f7ea244effbeac16e8e9e03bfb765cb 1.0.X 39 | 54d464733f7ea244effbeac16e8e9e03bfb765cb 1.0.4 40 | fddef2a7222343d2cff8320a0869f5a922a4c97d 1.0.4 41 | 54d464733f7ea244effbeac16e8e9e03bfb765cb 1.0.X 42 | fddef2a7222343d2cff8320a0869f5a922a4c97d 1.0.X 43 | a1a5b69501e2187d63b8e4d4ed069dc20352027d 1.0.5 44 | fddef2a7222343d2cff8320a0869f5a922a4c97d 1.0.X 45 | a1a5b69501e2187d63b8e4d4ed069dc20352027d 1.0.X 46 | 4d93e1c05b5255c7113fc1270891f346950f9780 1.0.6 47 | a1a5b69501e2187d63b8e4d4ed069dc20352027d 1.0.X 48 | 4d93e1c05b5255c7113fc1270891f346950f9780 1.0.X 49 | 4d93e1c05b5255c7113fc1270891f346950f9780 1.0.X 50 | 3dace40e699542fb865ee91bac3453082f611f0e 1.0.X 51 | 3dace40e699542fb865ee91bac3453082f611f0e 1.0.7 52 | 7ef83f808504509a1d4a3c7ccbdd2797a6efcd6d 1.0.8 53 | 3dace40e699542fb865ee91bac3453082f611f0e 1.0.X 54 | 7ef83f808504509a1d4a3c7ccbdd2797a6efcd6d 1.0.X 55 | abcd0f3b5e88283a03b2fa42c66ce081f7e5f721 1.0.9 56 | 7ef83f808504509a1d4a3c7ccbdd2797a6efcd6d 1.0.X 57 | abcd0f3b5e88283a03b2fa42c66ce081f7e5f721 1.0.X 58 | 590bfc80259fa5643bce36fd06ffa8177e6a9bfd 1.0.10 59 | abcd0f3b5e88283a03b2fa42c66ce081f7e5f721 1.0.X 60 | 590bfc80259fa5643bce36fd06ffa8177e6a9bfd 1.0.X 61 | 590bfc80259fa5643bce36fd06ffa8177e6a9bfd 1.0.X 62 | 95295813234af0d2014ac6e09bd208509322cf0a 1.0.X 63 | 95295813234af0d2014ac6e09bd208509322cf0a 1.0.11 64 | c377aabe7ec71f7d3db2594aa77afa6b8a7ef84b 1.0.13 65 | 95295813234af0d2014ac6e09bd208509322cf0a 1.0.X 66 | c377aabe7ec71f7d3db2594aa77afa6b8a7ef84b 1.0.X 67 | c377aabe7ec71f7d3db2594aa77afa6b8a7ef84b 1.0.13 68 | e4fcec28b2a976571dda2783c5b17068185d5d2c 1.0.13 69 | c377aabe7ec71f7d3db2594aa77afa6b8a7ef84b 1.0.X 70 | e4fcec28b2a976571dda2783c5b17068185d5d2c 1.0.X 71 | cd95c5a50d7e7a0f6803d18f640e4bb9b1bb6f3f 1.0.14 72 | e4fcec28b2a976571dda2783c5b17068185d5d2c 1.0.X 73 | cd95c5a50d7e7a0f6803d18f640e4bb9b1bb6f3f 1.0.X 74 | 88080998a94f63cdad567e64014811e187cae0e7 1.0.15 75 | cd95c5a50d7e7a0f6803d18f640e4bb9b1bb6f3f 1.0.X 76 | 88080998a94f63cdad567e64014811e187cae0e7 1.0.X 77 | 122a14afca302c493e2bfcdcca453d9d079e52d6 1.0.16 78 | 88080998a94f63cdad567e64014811e187cae0e7 1.0.X 79 | 122a14afca302c493e2bfcdcca453d9d079e52d6 1.0.X 80 | 64e2cab2bc4ae76e509485bee31a5d0375fc3d8b 1.0.17 81 | 122a14afca302c493e2bfcdcca453d9d079e52d6 1.0.X 82 | 64e2cab2bc4ae76e509485bee31a5d0375fc3d8b 1.0.X 83 | 64e2cab2bc4ae76e509485bee31a5d0375fc3d8b 1.0.17 84 | 728405b2853e2128257a0510aec5fc74395c728e 1.0.17 85 | 64e2cab2bc4ae76e509485bee31a5d0375fc3d8b 1.0.X 86 | 728405b2853e2128257a0510aec5fc74395c728e 1.0.X 87 | 7c26fb861ea9c1c0c8898fc1c9153c14cc1c585e 1.0.18 88 | 728405b2853e2128257a0510aec5fc74395c728e 1.0.X 89 | 7c26fb861ea9c1c0c8898fc1c9153c14cc1c585e 1.0.X 90 | 6bcd3729cfc2a66dbf5c7789d1c7653afeb775a1 1.0.19 91 | 7c26fb861ea9c1c0c8898fc1c9153c14cc1c585e 1.0.X 92 | 6bcd3729cfc2a66dbf5c7789d1c7653afeb775a1 1.0.X 93 | 87036f22518b20e71dfde2815c5b981ff120d87a 1.1 94 | 87036f22518b20e71dfde2815c5b981ff120d87a 1.1.x 95 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-auth-ldap documentation build configuration file, created by 4 | # sphinx-quickstart on Wed Sep 23 18:06:43 2009. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | # If extensions (or modules to document with autodoc) are in another directory, 15 | # add these directories to sys.path here. If the directory is relative to the 16 | # documentation root, use os.path.abspath to make it absolute, like shown here. 17 | #sys.path.append(os.path.abspath('.')) 18 | 19 | # -- General configuration ----------------------------------------------------- 20 | 21 | # Add any Sphinx extension module names here, as strings. They can be extensions 22 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 23 | extensions = [] 24 | 25 | # Add any paths that contain templates here, relative to this directory. 26 | templates_path = ['_templates'] 27 | 28 | # The suffix of source filenames. 29 | source_suffix = '.rst' 30 | 31 | # The encoding of source files. 32 | source_encoding = 'utf-8' 33 | 34 | # The master toctree document. 35 | master_doc = 'index' 36 | 37 | # General information about the project. 38 | project = u'django-auth-ldap' 39 | copyright = u'2009, Peter Sagerson' 40 | 41 | # The version info for the project you're documenting, acts as replacement for 42 | # |version| and |release|, also used in various other places throughout the 43 | # built documents. 44 | # 45 | # The short X.Y version. 46 | version = '1.1' 47 | # The full version, including alpha/beta/rc tags. 48 | release = '1.1' 49 | 50 | # The language for content autogenerated by Sphinx. Refer to documentation 51 | # for a list of supported languages. 52 | #language = None 53 | 54 | # There are two options for replacing |today|: either, you set today to some 55 | # non-false value, then it is used: 56 | #today = '' 57 | # Else, today_fmt is used as the format for a strftime call. 58 | #today_fmt = '%B %d, %Y' 59 | 60 | # List of documents that shouldn't be included in the build. 61 | #unused_docs = [] 62 | 63 | # List of directories, relative to source directory, that shouldn't be searched 64 | # for source files. 65 | exclude_trees = ['_build'] 66 | 67 | # The reST default role (used for this markup: `text`) to use for all documents. 68 | #default_role = None 69 | 70 | # If true, '()' will be appended to :func: etc. cross-reference text. 71 | #add_function_parentheses = True 72 | 73 | # If true, the current module name will be prepended to all description 74 | # unit titles (such as .. function::). 75 | #add_module_names = True 76 | 77 | # If true, sectionauthor and moduleauthor directives will be shown in the 78 | # output. They are ignored by default. 79 | #show_authors = False 80 | 81 | # The name of the Pygments (syntax highlighting) style to use. 82 | pygments_style = 'sphinx' 83 | 84 | # A list of ignored prefixes for module index sorting. 85 | #modindex_common_prefix = [] 86 | 87 | 88 | # -- Options for HTML output --------------------------------------------------- 89 | 90 | # The theme to use for HTML and HTML Help pages. Major themes that come with 91 | # Sphinx are currently 'default' and 'sphinxdoc'. 92 | html_theme = 'default' 93 | 94 | # Theme options are theme-specific and customize the look and feel of a theme 95 | # further. For a list of options available for each theme, see the 96 | # documentation. 97 | html_theme_options = { 98 | "rightsidebar": True, 99 | } 100 | 101 | # Add any paths that contain custom themes here, relative to this directory. 102 | #html_theme_path = [] 103 | 104 | # The name for this set of Sphinx documents. If None, it defaults to 105 | # " v documentation". 106 | #html_title = None 107 | 108 | # A shorter title for the navigation bar. Default is the same as html_title. 109 | #html_short_title = None 110 | 111 | # The name of an image file (relative to this directory) to place at the top 112 | # of the sidebar. 113 | #html_logo = None 114 | 115 | # The name of an image file (within the static path) to use as favicon of the 116 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 117 | # pixels large. 118 | #html_favicon = None 119 | 120 | # Add any paths that contain custom static files (such as style sheets) here, 121 | # relative to this directory. They are copied after the builtin static files, 122 | # so a file named "default.css" will overwrite the builtin "default.css". 123 | #html_static_path = ['_static'] 124 | 125 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 126 | # using the given strftime format. 127 | #html_last_updated_fmt = '%b %d, %Y' 128 | 129 | # If true, SmartyPants will be used to convert quotes and dashes to 130 | # typographically correct entities. 131 | #html_use_smartypants = True 132 | 133 | # Custom sidebar templates, maps document names to template names. 134 | #html_sidebars = {} 135 | 136 | # Additional templates that should be rendered to pages, maps page names to 137 | # template names. 138 | #html_additional_pages = {} 139 | 140 | # If false, no module index is generated. 141 | #html_use_modindex = True 142 | 143 | # If false, no index is generated. 144 | #html_use_index = True 145 | 146 | # If true, the index is split into individual pages for each letter. 147 | #html_split_index = False 148 | 149 | # If true, links to the reST sources are added to the pages. 150 | #html_show_sourcelink = True 151 | 152 | # If true, an OpenSearch description file will be output, and all pages will 153 | # contain a tag referring to it. The value of this option must be the 154 | # base URL from which the finished HTML is served. 155 | #html_use_opensearch = '' 156 | 157 | # If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). 158 | #html_file_suffix = '' 159 | 160 | # Output file base name for HTML help builder. 161 | htmlhelp_basename = 'django-auth-ldapdoc' 162 | 163 | 164 | # -- Options for LaTeX output -------------------------------------------------- 165 | 166 | # The paper size ('letter' or 'a4'). 167 | #latex_paper_size = 'letter' 168 | 169 | # The font size ('10pt', '11pt' or '12pt'). 170 | #latex_font_size = '10pt' 171 | 172 | # Grouping the document tree into LaTeX files. List of tuples 173 | # (source start file, target name, title, author, documentclass [howto/manual]). 174 | latex_documents = [ 175 | ('index', 'django-auth-ldap.tex', u'django-auth-ldap Documentation', 176 | u'Peter Sagerson', 'manual'), 177 | ] 178 | 179 | # The name of an image file (relative to this directory) to place at the top of 180 | # the title page. 181 | #latex_logo = None 182 | 183 | # For "manual" documents, if this is true, then toplevel headings are parts, 184 | # not chapters. 185 | #latex_use_parts = False 186 | 187 | # Additional stuff for the LaTeX preamble. 188 | #latex_preamble = '' 189 | 190 | # Documents to append as an appendix to all manuals. 191 | #latex_appendices = [] 192 | 193 | # If false, no module index is generated. 194 | #latex_use_modindex = True 195 | -------------------------------------------------------------------------------- /django_auth_ldap/config.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2009, Peter Sagerson 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are met: 6 | # 7 | # - Redistributions of source code must retain the above copyright notice, this 8 | # list of conditions and the following disclaimer. 9 | # 10 | # - Redistributions in binary form must reproduce the above copyright notice, 11 | # this list of conditions and the following disclaimer in the documentation 12 | # and/or other materials provided with the distribution. 13 | # 14 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 15 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 16 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 18 | # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 19 | # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 20 | # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 21 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 22 | # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 23 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | 26 | """ 27 | This module contains classes that will be needed for configuration of LDAP 28 | authentication. Unlike backend.py, this is safe to import into settings.py. 29 | Please see the docstring on the backend module for more information, including 30 | notes on naming conventions. 31 | """ 32 | 33 | try: 34 | set 35 | except NameError: 36 | from sets import Set as set # Python 2.3 fallback 37 | 38 | import logging 39 | import pprint 40 | 41 | 42 | class _LDAPConfig(object): 43 | """ 44 | A private class that loads and caches some global objects. 45 | """ 46 | ldap = None 47 | logger = None 48 | 49 | _ldap_configured = False 50 | 51 | def get_ldap(cls, global_options=None): 52 | """ 53 | Returns the ldap module. The unit test harness will assign a mock object 54 | to _LDAPConfig.ldap. It is imperative that the ldap module not be 55 | imported anywhere else so that the unit tests will pass in the absence 56 | of python-ldap. 57 | """ 58 | if cls.ldap is None: 59 | import ldap 60 | import ldap.filter 61 | 62 | # Support for python-ldap < 2.0.6 63 | try: 64 | import ldap.dn 65 | except ImportError: 66 | from django_auth_ldap import dn 67 | ldap.dn = dn 68 | 69 | cls.ldap = ldap 70 | 71 | # Apply global LDAP options once 72 | if (not cls._ldap_configured) and (global_options is not None): 73 | for opt, value in global_options.iteritems(): 74 | cls.ldap.set_option(opt, value) 75 | 76 | cls._ldap_configured = True 77 | 78 | return cls.ldap 79 | get_ldap = classmethod(get_ldap) 80 | 81 | def get_logger(cls): 82 | """ 83 | Initializes and returns our logger instance. 84 | """ 85 | if cls.logger is None: 86 | class NullHandler(logging.Handler): 87 | def emit(self, record): 88 | pass 89 | 90 | cls.logger = logging.getLogger('django_auth_ldap') 91 | cls.logger.addHandler(NullHandler()) 92 | 93 | return cls.logger 94 | get_logger = classmethod(get_logger) 95 | 96 | 97 | # Our global logger 98 | logger = _LDAPConfig.get_logger() 99 | 100 | 101 | class LDAPSearch(object): 102 | """ 103 | Public class that holds a set of LDAP search parameters. Objects of this 104 | class should be considered immutable. Only the initialization method is 105 | documented for configuration purposes. Internal clients may use the other 106 | methods to refine and execute the search. 107 | """ 108 | def __init__(self, base_dn, scope, filterstr=u'(objectClass=*)'): 109 | """ 110 | These parameters are the same as the first three parameters to 111 | ldap.search_s. 112 | """ 113 | self.base_dn = base_dn 114 | self.scope = scope 115 | self.filterstr = filterstr 116 | self.ldap = _LDAPConfig.get_ldap() 117 | 118 | def search_with_additional_terms(self, term_dict, escape=True): 119 | """ 120 | Returns a new search object with additional search terms and-ed to the 121 | filter string. term_dict maps attribute names to assertion values. If 122 | you don't want the values escaped, pass escape=False. 123 | """ 124 | term_strings = [self.filterstr] 125 | 126 | for name, value in term_dict.iteritems(): 127 | if escape: 128 | value = self.ldap.filter.escape_filter_chars(value) 129 | term_strings.append(u'(%s=%s)' % (name, value)) 130 | 131 | filterstr = u'(&%s)' % ''.join(term_strings) 132 | 133 | return self.__class__(self.base_dn, self.scope, filterstr) 134 | 135 | def search_with_additional_term_string(self, filterstr): 136 | """ 137 | Returns a new search object with filterstr and-ed to the original filter 138 | string. The caller is responsible for passing in a properly escaped 139 | string. 140 | """ 141 | filterstr = u'(&%s%s)' % (self.filterstr, filterstr) 142 | 143 | return self.__class__(self.base_dn, self.scope, filterstr) 144 | 145 | def execute(self, connection, filterargs=()): 146 | """ 147 | Executes the search on the given connection (an LDAPObject). filterargs 148 | is an object that will be used for expansion of the filter string. 149 | 150 | The python-ldap library returns utf8-encoded strings. For the sake of 151 | sanity, this method will decode all result strings and return them as 152 | Unicode. 153 | """ 154 | try: 155 | filterstr = self.filterstr % filterargs 156 | results = connection.search_s(self.base_dn.encode('utf-8'), 157 | self.scope, filterstr.encode('utf-8')) 158 | except self.ldap.LDAPError, e: 159 | results = [] 160 | logger.error(u"search_s('%s', %d, '%s') raised %s" % 161 | (self.base_dn, self.scope, filterstr, pprint.pformat(e))) 162 | 163 | return self._process_results(results) 164 | 165 | def _begin(self, connection, filterargs=()): 166 | """ 167 | Begins an asynchronous search and returns the message id to retrieve 168 | the results. 169 | """ 170 | try: 171 | filterstr = self.filterstr % filterargs 172 | msgid = connection.search(self.base_dn.encode('utf-8'), 173 | self.scope, filterstr.encode('utf-8')) 174 | except self.ldap.LDAPError, e: 175 | msgid = None 176 | logger.error(u"search('%s', %d, '%s') raised %s" % 177 | (self.base_dn, self.scope, filterstr, pprint.pformat(e))) 178 | 179 | return msgid 180 | 181 | def _results(self, connection, msgid): 182 | """ 183 | Returns the result of a previous asynchronous query. 184 | """ 185 | try: 186 | kind, results = connection.result(msgid) 187 | if kind != self.ldap.RES_SEARCH_RESULT: 188 | results = [] 189 | except self.ldap.LDAPError, e: 190 | results = [] 191 | logger.error(u"result(%d) raised %s" % (msgid, pprint.pformat(e))) 192 | 193 | return self._process_results(results) 194 | 195 | def _process_results(self, results): 196 | """ 197 | Returns a sanitized copy of raw LDAP results. This scrubs out 198 | references, decodes utf8, etc. 199 | """ 200 | results = filter(lambda r: r[0] is not None, results) 201 | results = _DeepStringCoder('utf-8').decode(results) 202 | 203 | result_dns = [result[0] for result in results] 204 | logger.debug(u"search_s('%s', %d, '%s') returned %d objects: %s" % 205 | (self.base_dn, self.scope, self.filterstr, len(result_dns), "; ".join(result_dns))) 206 | 207 | return results 208 | 209 | 210 | class LDAPSearchUnion(object): 211 | """ 212 | A compound search object that returns the union of the results. Instantiate 213 | it with one or more LDAPSearch objects. 214 | """ 215 | def __init__(self, *args): 216 | self.searches = args 217 | self.ldap = _LDAPConfig.get_ldap() 218 | 219 | def execute(self, connection, filterargs=()): 220 | msgids = [search._begin(connection, filterargs) for search in self.searches] 221 | results = {} 222 | 223 | for search, msgid in zip(self.searches, msgids): 224 | result = search._results(connection, msgid) 225 | results.update(dict(result)) 226 | 227 | return results.items() 228 | 229 | 230 | class _DeepStringCoder(object): 231 | """ 232 | Encodes and decodes strings in a nested structure of lists, tuples, and 233 | dicts. This is helpful when interacting with the Unicode-unaware 234 | python-ldap. 235 | """ 236 | def __init__(self, encoding): 237 | self.encoding = encoding 238 | self.ldap = _LDAPConfig.get_ldap() 239 | 240 | def decode(self, value): 241 | try: 242 | if isinstance(value, str): 243 | value = value.decode(self.encoding) 244 | elif isinstance(value, list): 245 | value = self._decode_list(value) 246 | elif isinstance(value, tuple): 247 | value = tuple(self._decode_list(value)) 248 | elif isinstance(value, dict): 249 | value = self._decode_dict(value) 250 | except UnicodeDecodeError: 251 | pass 252 | 253 | return value 254 | 255 | def _decode_list(self, value): 256 | return [self.decode(v) for v in value] 257 | 258 | def _decode_dict(self, value): 259 | # Attribute dictionaries should be case-insensitive. python-ldap 260 | # defines this, although for some reason, it doesn't appear to use it 261 | # for search results. 262 | decoded = self.ldap.cidict.cidict() 263 | 264 | for k, v in value.iteritems(): 265 | decoded[self.decode(k)] = self.decode(v) 266 | 267 | return decoded 268 | 269 | 270 | class LDAPGroupType(object): 271 | """ 272 | This is an abstract base class for classes that determine LDAP group 273 | membership. A group can mean many different things in LDAP, so we will need 274 | a concrete subclass for each grouping mechanism. Clients may subclass this 275 | if they have a group mechanism that is not handled by a built-in 276 | implementation. 277 | 278 | name_attr is the name of the LDAP attribute from which we will take the 279 | Django group name. 280 | 281 | Subclasses in this file must use self.ldap to access the python-ldap module. 282 | This will be a mock object during unit tests. 283 | """ 284 | def __init__(self, name_attr="cn"): 285 | self.name_attr = name_attr 286 | self.ldap = _LDAPConfig.get_ldap() 287 | 288 | def user_groups(self, ldap_user, group_search): 289 | """ 290 | Returns a list of group_info structures, each one a group to which 291 | ldap_user belongs. group_search is an LDAPSearch object that returns all 292 | of the groups that the user might belong to. Typical implementations 293 | will apply additional filters to group_search and return the results of 294 | the search. ldap_user represents the user and has the following three 295 | properties: 296 | 297 | dn: the distinguished name 298 | attrs: a dictionary of LDAP attributes (with lists of values) 299 | connection: an LDAPObject that has been bound with credentials 300 | 301 | This is the primitive method in the API and must be implemented. 302 | """ 303 | return [] 304 | 305 | def is_member(self, ldap_user, group_dn): 306 | """ 307 | This method is an optimization for determining group membership without 308 | loading all of the user's groups. Subclasses that are able to do this 309 | may return True or False. ldap_user is as above. group_dn is the 310 | distinguished name of the group in question. 311 | 312 | The base implementation returns None, which means we don't have enough 313 | information. The caller will have to call user_groups() instead and look 314 | for group_dn in the results. 315 | """ 316 | return None 317 | 318 | def group_name_from_info(self, group_info): 319 | """ 320 | Given the (DN, attrs) 2-tuple of an LDAP group, this returns the name of 321 | the Django group. This may return None to indicate that a particular 322 | LDAP group has no corresponding Django group. 323 | 324 | The base implementation returns the value of the cn attribute, or 325 | whichever attribute was given to __init__ in the name_attr 326 | parameter. 327 | """ 328 | try: 329 | name = group_info[1][self.name_attr][0] 330 | except (KeyError, IndexError): 331 | name = None 332 | 333 | return name 334 | 335 | 336 | class PosixGroupType(LDAPGroupType): 337 | """ 338 | An LDAPGroupType subclass that handles groups of class posixGroup. 339 | """ 340 | def user_groups(self, ldap_user, group_search): 341 | """ 342 | Searches for any group that is either the user's primary or contains the 343 | user as a member. 344 | """ 345 | groups = [] 346 | 347 | try: 348 | user_uid = ldap_user.attrs['uid'][0] 349 | user_gid = ldap_user.attrs['gidNumber'][0] 350 | 351 | filterstr = u'(|(gidNumber=%s)(memberUid=%s))' % ( 352 | self.ldap.filter.escape_filter_chars(user_gid), 353 | self.ldap.filter.escape_filter_chars(user_uid) 354 | ) 355 | 356 | search = group_search.search_with_additional_term_string(filterstr) 357 | groups = search.execute(ldap_user.connection) 358 | except (KeyError, IndexError): 359 | pass 360 | 361 | return groups 362 | 363 | def is_member(self, ldap_user, group_dn): 364 | """ 365 | Returns True if the group is the user's primary group or if the user is 366 | listed in the group's memberUid attribute. 367 | """ 368 | try: 369 | user_uid = ldap_user.attrs['uid'][0] 370 | user_gid = ldap_user.attrs['gidNumber'][0] 371 | 372 | try: 373 | is_member = ldap_user.connection.compare_s(group_dn.encode('utf-8'), 'memberUid', user_uid.encode('utf-8')) 374 | except self.ldap.NO_SUCH_ATTRIBUTE: 375 | is_member = False 376 | 377 | if not is_member: 378 | try: 379 | is_member = ldap_user.connection.compare_s(group_dn.encode('utf-8'), 'gidNumber', user_gid.encode('utf-8')) 380 | except self.ldap.NO_SUCH_ATTRIBUTE: 381 | is_member = False 382 | except (KeyError, IndexError): 383 | is_member = False 384 | 385 | return is_member 386 | 387 | 388 | class MemberDNGroupType(LDAPGroupType): 389 | """ 390 | A group type that stores lists of members as distinguished names. 391 | """ 392 | def __init__(self, member_attr, name_attr='cn'): 393 | """ 394 | member_attr is the attribute on the group object that holds the list of 395 | member DNs. 396 | """ 397 | self.member_attr = member_attr 398 | 399 | super(MemberDNGroupType, self).__init__(name_attr) 400 | 401 | def user_groups(self, ldap_user, group_search): 402 | search = group_search.search_with_additional_terms({self.member_attr: ldap_user.dn}) 403 | groups = search.execute(ldap_user.connection) 404 | 405 | return groups 406 | 407 | def is_member(self, ldap_user, group_dn): 408 | try: 409 | result = ldap_user.connection.compare_s( 410 | group_dn.encode('utf-8'), 411 | self.member_attr.encode('utf-8'), 412 | ldap_user.dn.encode('utf-8') 413 | ) 414 | except self.ldap.NO_SUCH_ATTRIBUTE: 415 | result = 0 416 | 417 | return result 418 | 419 | 420 | class NestedMemberDNGroupType(LDAPGroupType): 421 | """ 422 | A group type that stores lists of members as distinguished names and 423 | supports nested groups. There is no shortcut for is_member in this case, so 424 | it's left unimplemented. 425 | """ 426 | def __init__(self, member_attr, name_attr='cn'): 427 | """ 428 | member_attr is the attribute on the group object that holds the list of 429 | member DNs. 430 | """ 431 | self.member_attr = member_attr 432 | 433 | super(NestedMemberDNGroupType, self).__init__(name_attr) 434 | 435 | def user_groups(self, ldap_user, group_search): 436 | """ 437 | This searches for all of a user's groups from the bottom up. In other 438 | words, it returns the groups that the user belongs to, the groups that 439 | those groups belong to, etc. Circular references will be detected and 440 | pruned. 441 | """ 442 | group_info_map = {} # Maps group_dn to group_info of groups we've found 443 | member_dn_set = set([ldap_user.dn]) # Member DNs to search with next 444 | handled_dn_set = set() # Member DNs that we've already searched with 445 | 446 | while len(member_dn_set) > 0: 447 | group_infos = self.find_groups_with_any_member(member_dn_set, 448 | group_search, ldap_user.connection) 449 | new_group_info_map = dict([(info[0], info) for info in group_infos]) 450 | group_info_map.update(new_group_info_map) 451 | handled_dn_set.update(member_dn_set) 452 | 453 | # Get ready for the next iteration. To avoid cycles, we make sure 454 | # never to search with the same member DN twice. 455 | member_dn_set = set(new_group_info_map.keys()) - handled_dn_set 456 | 457 | return group_info_map.values() 458 | 459 | def find_groups_with_any_member(self, member_dn_set, group_search, connection): 460 | terms = [ 461 | u"(%s=%s)" % (self.member_attr, self.ldap.filter.escape_filter_chars(dn)) 462 | for dn in member_dn_set 463 | ] 464 | 465 | filterstr = u"(|%s)" % "".join(terms) 466 | search = group_search.search_with_additional_term_string(filterstr) 467 | 468 | return search.execute(connection) 469 | 470 | 471 | class GroupOfNamesType(MemberDNGroupType): 472 | """ 473 | An LDAPGroupType subclass that handles groups of class groupOfNames. 474 | """ 475 | def __init__(self, name_attr='cn'): 476 | super(GroupOfNamesType, self).__init__('member', name_attr) 477 | 478 | 479 | class NestedGroupOfNamesType(NestedMemberDNGroupType): 480 | """ 481 | An LDAPGroupType subclass that handles groups of class groupOfNames with 482 | nested group references. 483 | """ 484 | def __init__(self, name_attr='cn'): 485 | super(NestedGroupOfNamesType, self).__init__('member', name_attr) 486 | 487 | 488 | class GroupOfUniqueNamesType(MemberDNGroupType): 489 | """ 490 | An LDAPGroupType subclass that handles groups of class groupOfUniqueNames. 491 | """ 492 | def __init__(self, name_attr='cn'): 493 | super(GroupOfUniqueNamesType, self).__init__('uniqueMember', name_attr) 494 | 495 | 496 | class NestedGroupOfUniqueNamesType(NestedMemberDNGroupType): 497 | """ 498 | An LDAPGroupType subclass that handles groups of class groupOfUniqueNames 499 | with nested group references. 500 | """ 501 | def __init__(self, name_attr='cn'): 502 | super(NestedGroupOfUniqueNamesType, self).__init__('uniqueMember', name_attr) 503 | 504 | 505 | class ActiveDirectoryGroupType(MemberDNGroupType): 506 | """ 507 | An LDAPGroupType subclass that handles Active Directory groups. 508 | """ 509 | def __init__(self, name_attr='cn'): 510 | super(ActiveDirectoryGroupType, self).__init__('member', name_attr) 511 | 512 | 513 | class NestedActiveDirectoryGroupType(NestedMemberDNGroupType): 514 | """ 515 | An LDAPGroupType subclass that handles Active Directory groups with nested 516 | group references. 517 | """ 518 | def __init__(self, name_attr='cn'): 519 | super(NestedActiveDirectoryGroupType, self).__init__('member', name_attr) 520 | -------------------------------------------------------------------------------- /django_auth_ldap/backend.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2009, Peter Sagerson 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are met: 6 | # 7 | # - Redistributions of source code must retain the above copyright notice, this 8 | # list of conditions and the following disclaimer. 9 | # 10 | # - Redistributions in binary form must reproduce the above copyright notice, 11 | # this list of conditions and the following disclaimer in the documentation 12 | # and/or other materials provided with the distribution. 13 | # 14 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 15 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 16 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 18 | # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 19 | # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 20 | # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 21 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 22 | # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 23 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | """ 26 | LDAP authentication backend 27 | 28 | Complete documentation can be found in docs/howto/auth-ldap.txt (or the thing it 29 | compiles to). 30 | 31 | Use of this backend requires the python-ldap module. To support unit tests, we 32 | import ldap in a single centralized place (config._LDAPConfig) so that the test 33 | harness can insert a mock object. 34 | 35 | A few notes on naming conventions. If an identifier ends in _dn, it is a string 36 | representation of a distinguished name. If it ends in _info, it is a 2-tuple 37 | containing a DN and a dictionary of lists of attributes. ldap.search_s returns a 38 | list of such structures. An identifier that ends in _attrs is the dictionary of 39 | attributes from the _info structure. 40 | 41 | A connection is an LDAPObject that has been successfully bound with a DN and 42 | password. The identifier 'user' always refers to a User model object; LDAP user 43 | information will be user_dn or user_info. 44 | 45 | Additional classes can be found in the config module next to this one. 46 | """ 47 | 48 | try: 49 | set 50 | except NameError: 51 | from sets import Set as set # Python 2.3 fallback 52 | 53 | import sys 54 | import traceback 55 | import pprint 56 | import copy 57 | 58 | import django.db 59 | from django.contrib.auth.models import User, Group, Permission, SiteProfileNotAvailable 60 | from django.core.cache import cache 61 | from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist 62 | import django.dispatch 63 | 64 | from django_auth_ldap.config import _LDAPConfig, LDAPSearch 65 | 66 | 67 | logger = _LDAPConfig.get_logger() 68 | 69 | 70 | # Signals for populating user objects. 71 | populate_user = django.dispatch.Signal(providing_args=["user", "ldap_user"]) 72 | populate_user_profile = django.dispatch.Signal(providing_args=["profile", "ldap_user"]) 73 | 74 | 75 | class LDAPBackend(object): 76 | """ 77 | The main backend class. This implements the auth backend API, although it 78 | actually delegates most of its work to _LDAPUser, which is defined next. 79 | """ 80 | supports_anonymous_user = False 81 | supports_object_permissions = True 82 | supports_inactive_user = False 83 | 84 | ldap = None # The cached ldap module (or mock object) 85 | 86 | # This is prepended to our internal setting names to produce the names we 87 | # expect in Django's settings file. Subclasses can change this in order to 88 | # support multiple collections of settings. 89 | settings_prefix = 'AUTH_LDAP_' 90 | 91 | def __init__(self): 92 | self.settings = LDAPSettings(self.settings_prefix) 93 | self.ldap = self.ldap_module() 94 | 95 | def ldap_module(self): 96 | """ 97 | Requests the ldap module from _LDAPConfig. Under a test harness, this 98 | will be a mock object. 99 | """ 100 | from django.conf import settings 101 | 102 | options = getattr(settings, 'AUTH_LDAP_GLOBAL_OPTIONS', None) 103 | 104 | return _LDAPConfig.get_ldap(options) 105 | 106 | 107 | # 108 | # The Django auth backend API 109 | # 110 | 111 | def authenticate(self, username, password): 112 | ldap_user = _LDAPUser(self, username=username.strip()) 113 | user = ldap_user.authenticate(password) 114 | 115 | return user 116 | 117 | def get_user(self, user_id): 118 | user = None 119 | 120 | try: 121 | user = User.objects.get(pk=user_id) 122 | _LDAPUser(self, user=user) # This sets user.ldap_user 123 | except User.DoesNotExist: 124 | pass 125 | 126 | return user 127 | 128 | def has_perm(self, user, perm, obj=None): 129 | return perm in self.get_all_permissions(user, obj) 130 | 131 | def has_module_perms(self, user, app_label): 132 | for perm in self.get_all_permissions(user): 133 | if perm[:perm.index('.')] == app_label: 134 | return True 135 | 136 | return False 137 | 138 | def get_all_permissions(self, user, obj=None): 139 | return self.get_group_permissions(user, obj) 140 | 141 | def get_group_permissions(self, user, obj=None): 142 | if not hasattr(user, 'ldap_user') and self.settings.AUTHORIZE_ALL_USERS: 143 | _LDAPUser(self, user=user) # This sets user.ldap_user 144 | 145 | if hasattr(user, 'ldap_user'): 146 | return user.ldap_user.get_group_permissions() 147 | else: 148 | return set() 149 | 150 | # 151 | # Bonus API: populate the Django user from LDAP without authenticating. 152 | # 153 | 154 | def populate_user(self, username): 155 | ldap_user = _LDAPUser(self, username=username) 156 | user = ldap_user.populate_user() 157 | 158 | return user 159 | 160 | # 161 | # Hooks for subclasses 162 | # 163 | 164 | def get_or_create_user(self, username, ldap_user): 165 | """ 166 | This must return a (User, created) 2-tuple for the given LDAP user. 167 | username is the Django-friendly username of the user. ldap_user.dn is 168 | the user's DN and ldap_user.attrs contains all of their LDAP attributes. 169 | """ 170 | return User.objects.get_or_create(username__iexact=username, defaults={'username': username.lower()}) 171 | 172 | def ldap_to_django_username(self, username): 173 | return username 174 | 175 | def django_to_ldap_username(self, username): 176 | return username 177 | 178 | 179 | class _LDAPUser(object): 180 | """ 181 | Represents an LDAP user and ultimately fields all requests that the 182 | backend receives. This class exists for two reasons. First, it's 183 | convenient to have a separate object for each request so that we can use 184 | object attributes without running into threading problems. Second, these 185 | objects get attached to the User objects, which allows us to cache 186 | expensive LDAP information, especially around groups and permissions. 187 | 188 | self.backend is a reference back to the LDAPBackend instance, which we need 189 | to access the ldap module and any hooks that a subclass has overridden. 190 | """ 191 | class AuthenticationFailed(Exception): 192 | pass 193 | 194 | # 195 | # Initialization 196 | # 197 | 198 | def __init__(self, backend, username=None, user=None): 199 | """ 200 | A new LDAPUser must be initialized with either a username or an 201 | authenticated User object. If a user is given, the username will be 202 | ignored. 203 | """ 204 | self.backend = backend 205 | self.ldap = backend.ldap 206 | self.settings = backend.settings 207 | self._username = username 208 | self._user_dn = None 209 | self._user_attrs = None 210 | self._user = None 211 | self._groups = None 212 | self._group_permissions = None 213 | self._connection = None 214 | self._connection_bound = False # True if we're bound as AUTH_LDAP_BIND_* 215 | 216 | if user is not None: 217 | self._set_authenticated_user(user) 218 | 219 | if username is None and user is None: 220 | raise Exception("Internal error: _LDAPUser improperly initialized.") 221 | 222 | def __deepcopy__(self, memo): 223 | obj = object.__new__(self.__class__) 224 | obj.backend = self.backend 225 | obj.ldap = self.ldap 226 | obj._user = copy.deepcopy(self._user, memo) 227 | 228 | # This is all just cached immutable data. There's no point copying it. 229 | obj._username = self._username 230 | obj._user_dn = self._user_dn 231 | obj._user_attrs = self._user_attrs 232 | obj._groups = self._groups 233 | obj._group_permissions = self._group_permissions 234 | 235 | # The connection couldn't be copied even if we wanted to 236 | obj._connection = self._connection 237 | obj._connection_bound = self._connection_bound 238 | 239 | return obj 240 | 241 | def _set_authenticated_user(self, user): 242 | self._user = user 243 | self._username = self.backend.django_to_ldap_username(user.username) 244 | 245 | user.ldap_user = self 246 | user.ldap_username = self._username 247 | 248 | # 249 | # Entry points 250 | # 251 | 252 | def authenticate(self, password): 253 | """ 254 | Authenticates against the LDAP directory and returns the corresponding 255 | User object if successful. Returns None on failure. 256 | """ 257 | user = None 258 | 259 | try: 260 | self._authenticate_user_dn(password) 261 | self._check_requirements() 262 | self._get_or_create_user() 263 | 264 | user = self._user 265 | except self.AuthenticationFailed, e: 266 | logger.debug(u"Authentication failed for %s" % self._username) 267 | except self.ldap.LDAPError, e: 268 | logger.warning(u"Caught LDAPError while authenticating %s: %s", 269 | self._username, pprint.pformat(e)) 270 | except Exception, e: 271 | logger.error(u"Caught Exception while authenticating %s: %s", 272 | self._username, pprint.pformat(e)) 273 | logger.error(''.join(traceback.format_tb(sys.exc_info()[2]))) 274 | raise 275 | 276 | return user 277 | 278 | def get_group_permissions(self): 279 | """ 280 | If allowed by the configuration, this returns the set of permissions 281 | defined by the user's LDAP group memberships. 282 | """ 283 | if self._group_permissions is None: 284 | self._group_permissions = set() 285 | 286 | if self.settings.FIND_GROUP_PERMS: 287 | try: 288 | self._load_group_permissions() 289 | except self.ldap.LDAPError, e: 290 | logger.warning("Caught LDAPError loading group permissions: %s", 291 | pprint.pformat(e)) 292 | 293 | return self._group_permissions 294 | 295 | def populate_user(self): 296 | """ 297 | Populates the Django user object using the default bind credentials. 298 | """ 299 | user = None 300 | 301 | try: 302 | # self.attrs will only be non-None if we were able to load this user 303 | # from the LDAP directory, so this filters out nonexistent users. 304 | if self.attrs is not None: 305 | self._get_or_create_user(force_populate=True) 306 | 307 | user = self._user 308 | except self.ldap.LDAPError, e: 309 | logger.warning(u"Caught LDAPError while authenticating %s: %s", 310 | self._username, pprint.pformat(e)) 311 | except Exception, e: 312 | logger.error(u"Caught Exception while authenticating %s: %s", 313 | self._username, pprint.pformat(e)) 314 | logger.error(''.join(traceback.format_tb(sys.exc_info()[2]))) 315 | raise 316 | 317 | return user 318 | 319 | # 320 | # Public properties (callbacks). These are all lazy for performance reasons. 321 | # 322 | 323 | def _get_user_dn(self): 324 | if self._user_dn is None: 325 | self._load_user_dn() 326 | 327 | return self._user_dn 328 | dn = property(_get_user_dn) 329 | 330 | def _get_user_attrs(self): 331 | if self._user_attrs is None: 332 | self._load_user_attrs() 333 | 334 | return self._user_attrs 335 | attrs = property(_get_user_attrs) 336 | 337 | def _get_group_dns(self): 338 | return self._get_groups().get_group_dns() 339 | group_dns = property(_get_group_dns) 340 | 341 | def _get_group_names(self): 342 | return self._get_groups().get_group_names() 343 | group_names = property(_get_group_names) 344 | 345 | def _get_bound_connection(self): 346 | if not self._connection_bound: 347 | self._bind() 348 | 349 | return self._get_connection() 350 | connection = property(_get_bound_connection) 351 | 352 | # 353 | # Authentication 354 | # 355 | 356 | def _authenticate_user_dn(self, password): 357 | """ 358 | Binds to the LDAP server with the user's DN and password. Raises 359 | AuthenticationFailed on failure. 360 | """ 361 | if self.dn is None: 362 | raise self.AuthenticationFailed("Failed to map the username to a DN.") 363 | 364 | try: 365 | sticky = self.settings.BIND_AS_AUTHENTICATING_USER 366 | 367 | self._bind_as(self.dn, password, sticky=sticky) 368 | 369 | if sticky and self.settings.AUTH_LDAP_USER_SEARCH: 370 | self._search_for_user_dn() 371 | 372 | except self.ldap.INVALID_CREDENTIALS: 373 | raise self.AuthenticationFailed("User DN/password rejected by LDAP server.") 374 | 375 | def _load_user_attrs(self): 376 | if self.dn is not None: 377 | search = LDAPSearch(self.dn, self.ldap.SCOPE_BASE) 378 | results = search.execute(self.connection) 379 | 380 | if results is not None and len(results) > 0: 381 | self._user_attrs = results[0][1] 382 | 383 | def _load_user_dn(self): 384 | """ 385 | Populates self._user_dn with the distinguished name of our user. This 386 | will either construct the DN from a template in 387 | AUTH_LDAP_USER_DN_TEMPLATE or connect to the server and search for it. 388 | """ 389 | if self._using_simple_bind_mode(): 390 | self._construct_simple_user_dn() 391 | else: 392 | self._search_for_user_dn() 393 | 394 | def _using_simple_bind_mode(self): 395 | return (self.settings.USER_DN_TEMPLATE is not None) 396 | 397 | def _construct_simple_user_dn(self): 398 | template = self.settings.USER_DN_TEMPLATE 399 | username = self.ldap.dn.escape_dn_chars(self._username) 400 | 401 | self._user_dn = template % {'user': username} 402 | 403 | def _search_for_user_dn(self): 404 | """ 405 | Searches the directory for a user matching AUTH_LDAP_USER_SEARCH. 406 | Populates self._user_dn and self._user_attrs. 407 | """ 408 | search = self.settings.USER_SEARCH 409 | if search is None: 410 | raise ImproperlyConfigured('AUTH_LDAP_USER_SEARCH must be an LDAPSearch instance.') 411 | 412 | results = search.execute(self.connection, {'user': self._username}) 413 | if results is not None and len(results) == 1: 414 | (self._user_dn, self._user_attrs) = results[0] 415 | 416 | def _check_requirements(self): 417 | """ 418 | Checks all authentication requirements beyond credentials. Raises 419 | AuthenticationFailed on failure. 420 | """ 421 | self._check_required_group() 422 | self._check_denied_group() 423 | 424 | def _check_required_group(self): 425 | """ 426 | Returns True if the group requirement (AUTH_LDAP_REQUIRE_GROUP) is 427 | met. Always returns True if AUTH_LDAP_REQUIRE_GROUP is None. 428 | """ 429 | required_group_dn = self.settings.REQUIRE_GROUP 430 | 431 | if required_group_dn is not None: 432 | is_member = self._get_groups().is_member_of(required_group_dn) 433 | if not is_member: 434 | raise self.AuthenticationFailed("User is not a member of AUTH_LDAP_REQUIRE_GROUP") 435 | 436 | return True 437 | 438 | def _check_denied_group(self): 439 | """ 440 | Returns True if the negative group requirement (AUTH_LDAP_DENY_GROUP) 441 | is met. Always returns True if AUTH_LDAP_DENY_GROUP is None. 442 | """ 443 | denied_group_dn = self.settings.DENY_GROUP 444 | 445 | if denied_group_dn is not None: 446 | is_member = self._get_groups().is_member_of(denied_group_dn) 447 | if is_member: 448 | raise self.AuthenticationFailed("User is a member of AUTH_LDAP_DENY_GROUP") 449 | 450 | return True 451 | 452 | # 453 | # User management 454 | # 455 | 456 | def _get_or_create_user(self, force_populate=False): 457 | """ 458 | Loads the User model object from the database or creates it if it 459 | doesn't exist. Also populates the fields, subject to 460 | AUTH_LDAP_ALWAYS_UPDATE_USER. 461 | """ 462 | save_user = False 463 | 464 | username = self.backend.ldap_to_django_username(self._username) 465 | 466 | self._user, created = self.backend.get_or_create_user(username, self) 467 | self._user.ldap_user = self 468 | self._user.ldap_username = self._username 469 | 470 | should_populate = force_populate or self.settings.ALWAYS_UPDATE_USER or created 471 | 472 | if created: 473 | logger.debug("Created Django user %s", username) 474 | self._user.set_unusable_password() 475 | save_user = True 476 | 477 | if should_populate: 478 | logger.debug("Populating Django user %s", username) 479 | self._populate_user() 480 | save_user = True 481 | 482 | if self.settings.MIRROR_GROUPS: 483 | self._mirror_groups() 484 | 485 | # Give the client a chance to finish populating the user just before 486 | # saving. 487 | if should_populate: 488 | signal_responses = populate_user.send(self.backend.__class__, user=self._user, ldap_user=self) 489 | if len(signal_responses) > 0: 490 | save_user = True 491 | 492 | if save_user: 493 | self._user.save() 494 | 495 | # We populate the profile after the user model is saved to give the 496 | # client a chance to create the profile. 497 | if should_populate: 498 | self._populate_and_save_user_profile() 499 | 500 | def _populate_user(self): 501 | """ 502 | Populates our User object with information from the LDAP directory. 503 | """ 504 | self._populate_user_from_attributes() 505 | self._populate_user_from_group_memberships() 506 | 507 | def _populate_user_from_attributes(self): 508 | for field, attr in self.settings.USER_ATTR_MAP.iteritems(): 509 | try: 510 | setattr(self._user, field, self.attrs[attr][0]) 511 | except StandardError: 512 | logger.warning("%s does not have a value for the attribute %s", self.dn, attr) 513 | 514 | def _populate_user_from_group_memberships(self): 515 | for field, group_dn in self.settings.USER_FLAGS_BY_GROUP.iteritems(): 516 | value = self._get_groups().is_member_of(group_dn) 517 | setattr(self._user, field, value) 518 | 519 | def _populate_and_save_user_profile(self): 520 | """ 521 | Populates a User profile object with fields from the LDAP directory. 522 | """ 523 | try: 524 | profile = self._user.get_profile() 525 | save_profile = False 526 | 527 | logger.debug("Populating Django user profile for %s", self._user.username) 528 | 529 | save_profile = self._populate_profile_from_attributes(profile) or save_profile 530 | save_profile = self._populate_profile_from_group_memberships(profile) or save_profile 531 | 532 | signal_responses = populate_user_profile.send(self.backend.__class__, profile=profile, ldap_user=self) 533 | if len(signal_responses) > 0: 534 | save_profile = True 535 | 536 | if save_profile: 537 | profile.save() 538 | except (SiteProfileNotAvailable, ObjectDoesNotExist): 539 | logger.debug("Django user %s does not have a profile to populate", self._user.username) 540 | 541 | def _populate_profile_from_attributes(self, profile): 542 | """ 543 | Populate the given profile object from AUTH_LDAP_PROFILE_ATTR_MAP. 544 | Returns True if the profile was modified. 545 | """ 546 | save_profile = False 547 | 548 | for field, attr in self.settings.PROFILE_ATTR_MAP.iteritems(): 549 | try: 550 | # user_attrs is a hash of lists of attribute values 551 | setattr(profile, field, self.attrs[attr][0]) 552 | save_profile = True 553 | except StandardError: 554 | logger.warning("%s does not have a value for the attribute %s", self.dn, attr) 555 | 556 | return save_profile 557 | 558 | def _populate_profile_from_group_memberships(self, profile): 559 | """ 560 | Populate the given profile object from AUTH_LDAP_PROFILE_FLAGS_BY_GROUP. 561 | Returns True if the profile was modified. 562 | """ 563 | save_profile = False 564 | 565 | for field, group_dn in self.settings.PROFILE_FLAGS_BY_GROUP.iteritems(): 566 | value = self._get_groups().is_member_of(group_dn) 567 | setattr(profile, field, value) 568 | save_profile = True 569 | 570 | return save_profile 571 | 572 | def _mirror_groups(self): 573 | """ 574 | Mirrors the user's LDAP groups in the Django database and updates the 575 | user's membership. 576 | """ 577 | group_names = self._get_groups().get_group_names() 578 | groups = [Group.objects.get_or_create(name=group_name)[0] for group_name 579 | in group_names] 580 | 581 | self._user.groups = groups 582 | 583 | # 584 | # Group information 585 | # 586 | 587 | def _load_group_permissions(self): 588 | """ 589 | Populates self._group_permissions based on LDAP group membership and 590 | Django group permissions. 591 | """ 592 | group_names = self._get_groups().get_group_names() 593 | 594 | perms = Permission.objects.filter(group__name__in=group_names 595 | ).values_list('content_type__app_label', 'codename' 596 | ).order_by() 597 | 598 | self._group_permissions = set(["%s.%s" % (ct, name) for ct, name in perms]) 599 | 600 | def _get_groups(self): 601 | """ 602 | Returns an _LDAPUserGroups object, which can determine group 603 | membership. 604 | """ 605 | if self._groups is None: 606 | self._groups = _LDAPUserGroups(self) 607 | 608 | return self._groups 609 | 610 | # 611 | # LDAP connection 612 | # 613 | 614 | def _bind(self): 615 | """ 616 | Binds to the LDAP server with AUTH_LDAP_BIND_DN and 617 | AUTH_LDAP_BIND_PASSWORD. 618 | """ 619 | self._bind_as(self.settings.BIND_DN, 620 | self.settings.BIND_PASSWORD, 621 | sticky=True) 622 | 623 | def _bind_as(self, bind_dn, bind_password, sticky=False): 624 | """ 625 | Binds to the LDAP server with the given credentials. This does not trap 626 | exceptions. 627 | 628 | If sticky is True, then we will consider the connection to be bound for 629 | the life of this object. If False, then the caller only wishes to test 630 | the credentials, after which the connection will be considered unbound. 631 | """ 632 | self._get_connection().simple_bind_s(bind_dn.encode('utf-8'), 633 | bind_password.encode('utf-8')) 634 | 635 | self._connection_bound = sticky 636 | 637 | def _get_connection(self): 638 | """ 639 | Returns our cached LDAPObject, which may or may not be bound. 640 | """ 641 | if self._connection is None: 642 | self._connection = self.ldap.initialize(self.settings.SERVER_URI) 643 | 644 | for opt, value in self.settings.CONNECTION_OPTIONS.iteritems(): 645 | self._connection.set_option(opt, value) 646 | 647 | if self.settings.START_TLS: 648 | logger.debug("Initiating TLS") 649 | self._connection.start_tls_s() 650 | 651 | return self._connection 652 | 653 | 654 | 655 | class _LDAPUserGroups(object): 656 | """ 657 | Represents the set of groups that a user belongs to. 658 | """ 659 | def __init__(self, ldap_user): 660 | self.settings = ldap_user.settings 661 | self._ldap_user = ldap_user 662 | self._group_type = None 663 | self._group_search = None 664 | self._group_infos = None 665 | self._group_dns = None 666 | self._group_names = None 667 | 668 | self._init_group_settings() 669 | 670 | def _init_group_settings(self): 671 | """ 672 | Loads the settings we need to deal with groups. Raises 673 | ImproperlyConfigured if anything's not right. 674 | """ 675 | self._group_type = self.settings.GROUP_TYPE 676 | if self._group_type is None: 677 | raise ImproperlyConfigured("AUTH_LDAP_GROUP_TYPE must be an LDAPGroupType instance.") 678 | 679 | self._group_search = self.settings.GROUP_SEARCH 680 | if self._group_search is None: 681 | raise ImproperlyConfigured("AUTH_LDAP_GROUP_SEARCH must be an LDAPSearch instance.") 682 | 683 | def get_group_names(self): 684 | """ 685 | Returns the set of Django group names that this user belongs to by 686 | virtue of LDAP group memberships. 687 | """ 688 | if self._group_names is None: 689 | self._load_cached_attr("_group_names") 690 | 691 | if self._group_names is None: 692 | group_infos = self._get_group_infos() 693 | self._group_names = set([self._group_type.group_name_from_info(group_info) 694 | for group_info in group_infos]) 695 | self._cache_attr("_group_names") 696 | 697 | return self._group_names 698 | 699 | def is_member_of(self, group_dn): 700 | """ 701 | Returns true if our user is a member of the given group. 702 | """ 703 | is_member = None 704 | 705 | # If we have self._group_dns, we'll use it. Otherwise, we'll try to 706 | # avoid the cost of loading it. 707 | if self._group_dns is None: 708 | is_member = self._group_type.is_member(self._ldap_user, group_dn) 709 | 710 | if is_member is None: 711 | is_member = (group_dn in self.get_group_dns()) 712 | 713 | logger.debug("%s is%sa member of %s", self._ldap_user.dn, 714 | is_member and " " or " not ", group_dn) 715 | 716 | return is_member 717 | 718 | def get_group_dns(self): 719 | """ 720 | Returns a (cached) set of the distinguished names in self._group_infos. 721 | """ 722 | if self._group_dns is None: 723 | group_infos = self._get_group_infos() 724 | self._group_dns = set([group_info[0] for group_info in group_infos]) 725 | 726 | return self._group_dns 727 | 728 | def _get_group_infos(self): 729 | """ 730 | Returns a (cached) list of group_info structures for the groups that our 731 | user is a member of. 732 | """ 733 | if self._group_infos is None: 734 | self._group_infos = self._group_type.user_groups(self._ldap_user, 735 | self._group_search) 736 | 737 | return self._group_infos 738 | 739 | def _load_cached_attr(self, attr_name): 740 | if self.settings.CACHE_GROUPS: 741 | key = self._cache_key(attr_name) 742 | value = cache.get(key) 743 | setattr(self, attr_name, value) 744 | 745 | def _cache_attr(self, attr_name): 746 | if self.settings.CACHE_GROUPS: 747 | key = self._cache_key(attr_name) 748 | value = getattr(self, attr_name, None) 749 | cache.set(key, value, self.settings.GROUP_CACHE_TIMEOUT) 750 | 751 | def _cache_key(self, attr_name): 752 | """ 753 | Memcache keys can't have spaces in them, so we'll remove them from the 754 | DN for maximum compatibility. 755 | """ 756 | dn = self._ldap_user.dn.replace(' ', '%20') 757 | key = u'auth_ldap.%s.%s.%s' % (self.__class__.__name__, attr_name, dn) 758 | 759 | return key 760 | 761 | 762 | class LDAPSettings(object): 763 | """ 764 | This is a simple class to take the place of the global settings object. An 765 | instance will contain all of our settings as attributes, with default values 766 | if they are not specified by the configuration. 767 | """ 768 | defaults = { 769 | 'ALWAYS_UPDATE_USER': True, 770 | 'AUTHORIZE_ALL_USERS': False, 771 | 'BIND_AS_AUTHENTICATING_USER': False, 772 | 'BIND_DN': '', 773 | 'BIND_PASSWORD': '', 774 | 'CACHE_GROUPS': False, 775 | 'CONNECTION_OPTIONS': {}, 776 | 'DENY_GROUP': None, 777 | 'FIND_GROUP_PERMS': False, 778 | 'GROUP_CACHE_TIMEOUT': None, 779 | 'GROUP_SEARCH': None, 780 | 'GROUP_TYPE': None, 781 | 'MIRROR_GROUPS': False, 782 | 'PROFILE_ATTR_MAP': {}, 783 | 'PROFILE_FLAGS_BY_GROUP': {}, 784 | 'REQUIRE_GROUP': None, 785 | 'SERVER_URI': 'ldap://localhost', 786 | 'START_TLS': False, 787 | 'USER_ATTR_MAP': {}, 788 | 'USER_DN_TEMPLATE': None, 789 | 'USER_FLAGS_BY_GROUP': {}, 790 | 'USER_SEARCH': None, 791 | } 792 | 793 | def __init__(self, prefix='AUTH_LDAP_'): 794 | """ 795 | Loads our settings from django.conf.settings, applying defaults for any 796 | that are omitted. 797 | """ 798 | from django.conf import settings 799 | 800 | for name, default in self.defaults.iteritems(): 801 | value = getattr(settings, prefix + name, default) 802 | setattr(self, name, value) 803 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ================================ 2 | Django authentication using LDAP 3 | ================================ 4 | 5 | This authentication backend enables a Django project to authenticate against any 6 | LDAP server. To use it, add :class:`django_auth_ldap.backend.LDAPBackend` to 7 | AUTHENTICATION_BACKENDS. It is not necessary to add `django_auth_ldap` to 8 | INSTALLED_APPLICATIONS unless you would like to run the unit tests. LDAP 9 | configuration can be as simple as a single distinguished name template, but 10 | there are many rich options for working with 11 | :class:`~django.contrib.auth.models.User` objects, groups, and permissions. This 12 | backend depends on the `python-ldap `_ module. 13 | 14 | .. note:: 15 | 16 | :class:`~django_auth_ldap.backend.LDAPBackend` does not inherit from 17 | :class:`~django.contrib.auth.backends.ModelBackend`. It is possible to use 18 | :class:`~django_auth_ldap.backend.LDAPBackend` exclusively by configuring it 19 | to draw group membership from the LDAP server. However, if you would like to 20 | assign permissions to individual users or add users to groups within Django, 21 | you'll need to have both backends installed: 22 | 23 | .. code-block:: python 24 | 25 | AUTHENTICATION_BACKENDS = ( 26 | 'django_auth_ldap.backend.LDAPBackend', 27 | 'django.contrib.auth.backends.ModelBackend', 28 | ) 29 | 30 | Version 1.1 of django-auth-ldap is tested with Django 1.3 and 1.4. Earlier 31 | versions may still work, but they are not supported. 32 | 33 | Configuring basic authentication 34 | ================================ 35 | 36 | If your LDAP server isn't running locally on the default port, you'll want to 37 | start by setting :ref:`AUTH_LDAP_SERVER_URI` to point to your server. 38 | 39 | .. code-block:: python 40 | 41 | AUTH_LDAP_SERVER_URI = "ldap://ldap.example.com" 42 | 43 | That done, the first step is to authenticate a username and password against the 44 | LDAP service. There are two ways to do this, called search/bind and simply bind. 45 | The first one involves connecting to the LDAP server either anonymously or with 46 | a fixed account and searching for the distinguished name of the authenticating 47 | user. Then we can attempt to bind again with the user's password. The second 48 | method is to derive the user's DN from his username and attempt to bind as the 49 | user directly. 50 | 51 | Because LDAP searches appear elsewhere in the configuration, the 52 | :class:`~django_auth_ldap.config.LDAPSearch` class is provided to encapsulate 53 | search information. In this case, the filter parameter should contain the 54 | placeholder ``%(user)s``. A simple configuration for the search/bind approach 55 | looks like this (some defaults included for completeness):: 56 | 57 | import ldap 58 | from django_auth_ldap.config import LDAPSearch 59 | 60 | AUTH_LDAP_BIND_DN = "" 61 | AUTH_LDAP_BIND_PASSWORD = "" 62 | AUTH_LDAP_USER_SEARCH = LDAPSearch("ou=users,dc=example,dc=com", 63 | ldap.SCOPE_SUBTREE, "(uid=%(user)s)") 64 | 65 | This will perform an anonymous bind, search under 66 | ``"ou=users,dc=example,dc=com"`` for an object with a uid matching the user's 67 | name, and try to bind using that DN and the user's password. The search must 68 | return exactly one result or authentication will fail. If you can't search 69 | anonymously, you can set :ref:`AUTH_LDAP_BIND_DN` to the distinguished name of 70 | an authorized user and :ref:`AUTH_LDAP_BIND_PASSWORD` to the password. 71 | 72 | (New in 1.1) If you need to search in more than one place for a user, you can use 73 | :class:`~django_auth_ldap.config.LDAPSearchUnion`. This takes multiple 74 | LDAPSearch objects and returns the union of the results. The precedence of the 75 | underlying searches is unspecified. 76 | 77 | .. code-block:: python 78 | 79 | import ldap 80 | from django_auth_ldap.config import LDAPSearch, LDAPSearchUnion 81 | 82 | AUTH_LDAP_USER_SEARCH = LDAPSearchUnion( 83 | LDAPSearch("ou=users,dc=example,dc=com", ldap.SCOPE_SUBTREE, "(uid=%(user)s)"), 84 | LDAPSearch("ou=otherusers,dc=example,dc=com", ldap.SCOPE_SUBTREE, "(uid=%(user)s)"), 85 | ) 86 | 87 | To skip the search phase, set :ref:`AUTH_LDAP_USER_DN_TEMPLATE` to a template 88 | that will produce the authenticating user's DN directly. This template should 89 | have one placeholder, ``%(user)s``. If the previous example had used 90 | ``ldap.SCOPE_ONELEVEL``, the following would be a more straightforward (and 91 | efficient) equivalent:: 92 | 93 | AUTH_LDAP_USER_DN_TEMPLATE = "uid=%(user)s,ou=users,dc=example,dc=com" 94 | 95 | LDAP is fairly flexible when it comes to matching DNs. 96 | :class:`~django_auth_ldap.backend.LDAPBackend` makes an effort to accommodate 97 | this by forcing usernames to lower case when creating Django users and trimming 98 | whitespace when authenticating. 99 | 100 | By default, all LDAP operations are performed with the :ref:`AUTH_LDAP_BIND_DN` 101 | and :ref:`AUTH_LDAP_BIND_PASSWORD` credentials, not with the user's. Otherwise, 102 | the LDAP connection would be bound as the authenticating user during login 103 | requests and as the default credentials during other requests, so you would see 104 | inconsistent LDAP attributes depending on the nature of the Django view. If 105 | you're willing to accept the inconsistency in order to retrieve attributes 106 | while bound as the authenticating user. see 107 | :ref:`AUTH_LDAP_BIND_AS_AUTHENTICATING_USER`. 108 | 109 | By default, LDAP connections are unencrypted and make no attempt to protect 110 | sensitive information, such as passwords. When communicating with an LDAP server 111 | on localhost or on a local network, this might be fine. If you need a secure 112 | connection to the LDAP server, you can either use an ``ldaps://`` URL or enable 113 | the StartTLS extension. The latter is generally the preferred mechanism. To 114 | enable StartTLS, set :ref:`AUTH_LDAP_START_TLS` to ``True``:: 115 | 116 | AUTH_LDAP_START_TLS = True 117 | 118 | 119 | Working with groups 120 | =================== 121 | 122 | Working with groups in LDAP can be a tricky business, mostly because there are 123 | so many different kinds. This module includes an extensible API for working with 124 | any kind of group and includes implementations for the most common ones. 125 | :class:`~django_auth_ldap.config.LDAPGroupType` is a base class whose concrete 126 | subclasses can determine group membership for particular grouping mechanisms. 127 | Three built-in subclasses cover most grouping mechanisms: 128 | 129 | * :class:`~django_auth_ldap.config.PosixGroupType` 130 | * :class:`~django_auth_ldap.config.MemberDNGroupType` 131 | * :class:`~django_auth_ldap.config.NestedMemberDNGroupType` 132 | 133 | posixGroup objects are somewhat specialized, so they get their own class. The 134 | other two cover mechanisms whereby a group object stores a list of its members 135 | as distinguished names. This includes groupOfNames, groupOfUniqueNames, and 136 | Active Directory groups, among others. The nested variant allows groups to 137 | contain other groups, to as many levels as you like. For convenience and 138 | readability, several trivial subclasses of the above are provided: 139 | 140 | * :class:`~django_auth_ldap.config.GroupOfNamesType` 141 | * :class:`~django_auth_ldap.config.NestedGroupOfNamesType` 142 | * :class:`~django_auth_ldap.config.GroupOfUniqueNamesType` 143 | * :class:`~django_auth_ldap.config.NestedGroupOfUniqueNamesType` 144 | * :class:`~django_auth_ldap.config.ActiveDirectoryGroupType` 145 | * :class:`~django_auth_ldap.config.NestedActiveDirectoryGroupType` 146 | 147 | To get started, you'll need to provide some basic information about your LDAP 148 | groups. :ref:`AUTH_LDAP_GROUP_SEARCH` is an 149 | :class:`~django_auth_ldap.config.LDAPSearch` object that identifies the set of 150 | relevant group objects. That is, all groups that users might belong to as well 151 | as any others that we might need to know about (in the case of nested groups, 152 | for example). :ref:`AUTH_LDAP_GROUP_TYPE` is an instance of the class 153 | corresponding to the type of group that will be returned by 154 | :ref:`AUTH_LDAP_GROUP_SEARCH`. All groups referenced elsewhere in the 155 | configuration must be of this type and part of the search results. 156 | 157 | .. code-block:: python 158 | 159 | import ldap 160 | from django_auth_ldap.config import LDAPSearch, GroupOfNamesType 161 | 162 | AUTH_LDAP_GROUP_SEARCH = LDAPSearch("ou=groups,dc=example,dc=com", 163 | ldap.SCOPE_SUBTREE, "(objectClass=groupOfNames)" 164 | ) 165 | AUTH_LDAP_GROUP_TYPE = GroupOfNamesType() 166 | 167 | The simplest use of groups is to limit the users who are allowed to log in. If 168 | :ref:`AUTH_LDAP_REQUIRE_GROUP` is set, then only users who are members of that 169 | group will successfully authenticate. :ref:`AUTH_LDAP_DENY_GROUP` is the 170 | reverse: if given, members of this group will be rejected. 171 | 172 | .. code-block:: python 173 | 174 | AUTH_LDAP_REQUIRE_GROUP = "cn=enabled,ou=groups,dc=example,dc=com" 175 | AUTH_LDAP_DENY_GROUP = "cn=disabled,ou=groups,dc=example,dc=com" 176 | 177 | More advanced uses of groups are covered in the next two sections. 178 | 179 | 180 | User objects 181 | ============ 182 | 183 | Authenticating against an external source is swell, but Django's auth module is 184 | tightly bound to the :class:`django.contrib.auth.models.User` model. Thus, when 185 | a user logs in, we have to create a :class:`~django.contrib.auth.models.User` 186 | object to represent him in the database. Because the LDAP search is 187 | case-insenstive, the default implementation also searches for existing Django 188 | users with an iexact query and new users are created with lowercase usernames. 189 | See :meth:`~django_auth_ldap.backend.LDAPBackend.get_or_create_user` if you'd 190 | like to override this behavior. 191 | 192 | The only required field for a user is the username, which we obviously have. The 193 | :class:`~django.contrib.auth.models.User` model is picky about the characters 194 | allowed in usernames, so :class:`~django_auth_ldap.backend.LDAPBackend` includes 195 | a pair of hooks, 196 | :meth:`~django_auth_ldap.backend.LDAPBackend.ldap_to_django_username` and 197 | :meth:`~django_auth_ldap.backend.LDAPBackend.django_to_ldap_username`, to 198 | translate between LDAP usernames and Django usernames. You'll need this, for 199 | example, if your LDAP names have periods in them. You can subclass 200 | :class:`~django_auth_ldap.backend.LDAPBackend` to implement these hooks; by 201 | default the username is not modified. :class:`~django.contrib.auth.models.User` 202 | objects that are authenticated by :class:`~django_auth_ldap.backend.LDAPBackend` 203 | will have an :attr:`~django.contrib.auth.models.User.ldap_username` attribute 204 | with the original (LDAP) username. 205 | :attr:`~django.contrib.auth.models.User.username` will, of course, be the Django 206 | username. 207 | 208 | LDAP directories tend to contain much more information about users that you may 209 | wish to propagate. A pair of settings, :ref:`AUTH_LDAP_USER_ATTR_MAP` and 210 | :ref:`AUTH_LDAP_PROFILE_ATTR_MAP`, serve to copy directory information into 211 | :class:`~django.contrib.auth.models.User` and profile objects. These are 212 | dictionaries that map user and profile model keys, respectively, to 213 | (case-insensitive) LDAP attribute names:: 214 | 215 | AUTH_LDAP_USER_ATTR_MAP = {"first_name": "givenName", "last_name": "sn"} 216 | AUTH_LDAP_PROFILE_ATTR_MAP = {"home_directory": "homeDirectory"} 217 | 218 | Only string fields can be mapped to attributes. Boolean fields can be defined by 219 | group membership:: 220 | 221 | AUTH_LDAP_USER_FLAGS_BY_GROUP = { 222 | "is_active": "cn=active,ou=groups,dc=example,dc=com", 223 | "is_staff": "cn=staff,ou=groups,dc=example,dc=com", 224 | "is_superuser": "cn=superuser,ou=groups,dc=example,dc=com" 225 | } 226 | 227 | AUTH_LDAP_PROFILE_FLAGS_BY_GROUP = { 228 | "is_awesome": "cn=awesome,ou=django,ou=groups,dc=example,dc=com" 229 | } 230 | 231 | By default, all mapped user fields will be updated each time the user logs in. 232 | To disable this, set :ref:`AUTH_LDAP_ALWAYS_UPDATE_USER` to ``False``. If you 233 | need to populate a user outside of the authentication process—for example, to 234 | create associated model objects before the user logs in for the first time—you 235 | can call :meth:`django_auth_ldap.backend.LDAPBackend.populate_user`. You'll 236 | need an instance of :class:`~django_auth_ldap.backend.LDAPBackend`, which you 237 | should feel free to create yourself. 238 | :meth:`~django_auth_ldap.backend.LDAPBackend.populate_user` returns the new 239 | :class:`~django.contrib.auth.models.User` or `None` if the user could not be 240 | found in LDAP. 241 | 242 | If you need to access multi-value attributes or there is some other reason that 243 | the above is inadequate, you can also access the user's raw LDAP attributes. 244 | ``user.ldap_user`` is an object with four public properties. The group 245 | properties are, of course, only valid if groups are configured. 246 | 247 | * ``dn``: The user's distinguished name. 248 | * ``attrs``: The user's LDAP attributes as a dictionary of lists of string 249 | values. The dictionaries are modified to use case-insensitive keys. 250 | * ``group_dns``: The set of groups that this user belongs to, as DNs. 251 | * ``group_names``: The set of groups that this user belongs to, as simple 252 | names. These are the names that will be used if 253 | :ref:`AUTH_LDAP_MIRROR_GROUPS` is used. 254 | 255 | Python-ldap returns all attribute values as utf8-encoded strings. For 256 | convenience, this module will try to decode all values into Unicode strings. Any 257 | string that can not be successfully decoded will be left as-is; this may apply 258 | to binary values such as Active Directory's objectSid. 259 | 260 | If you would like to perform any additional population of user or profile 261 | objects, django_auth_ldap exposes two custom signals to help: 262 | :data:`~django_auth_ldap.backend.populate_user` and 263 | :data:`~django_auth_ldap.backend.populate_user_profile`. These are sent after 264 | the backend has finished populating the respective objects and before they are 265 | saved to the database. You can use this to propagate additional information from 266 | the LDAP directory to the user and profile objects any way you like. 267 | 268 | .. note:: 269 | 270 | Users created by :class:`~django_auth_ldap.backend.LDAPBackend` will have an 271 | unusable password set. This will only happen when the user is created, so if 272 | you set a valid password in Django, the user will be able to log in through 273 | :class:`~django.contrib.auth.backends.ModelBackend` (if configured) even if 274 | he is rejected by LDAP. This is not generally recommended, but could be 275 | useful as a fail-safe for selected users in case the LDAP server is 276 | unavailable. 277 | 278 | 279 | Permissions 280 | =========== 281 | 282 | Groups are useful for more than just populating the user's ``is_*`` fields. 283 | :class:`~django_auth_ldap.backend.LDAPBackend` would not be complete without 284 | some way to turn a user's LDAP group memberships into Django model permissions. 285 | In fact, there are two ways to do this. 286 | 287 | Ultimately, both mechanisms need some way to map LDAP groups to Django groups. 288 | Implementations of :class:`~django_auth_ldap.config.LDAPGroupType` will have an 289 | algorithm for deriving the Django group name from the LDAP group. Clients that 290 | need to modify this behavior can subclass the 291 | :class:`~django_auth_ldap.config.LDAPGroupType` class. All of the built-in 292 | implementations take a ``name_attr`` argument to ``__init__``, which 293 | specifies the LDAP attribute from which to take the Django group name. By 294 | default, the ``cn`` attribute is used. 295 | 296 | The least invasive way to map group permissions is to set 297 | :ref:`AUTH_LDAP_FIND_GROUP_PERMS` to ``True``. 298 | :class:`~django_auth_ldap.backend.LDAPBackend` will then find all of the LDAP 299 | groups that a user belongs to, map them to Django groups, and load the 300 | permissions for those groups. You will need to create the Django groups 301 | yourself, generally through the admin interface. 302 | 303 | To minimize traffic to the LDAP server, 304 | :class:`~django_auth_ldap.backend.LDAPBackend` can make use of Django's cache 305 | framework to keep a copy of a user's LDAP group memberships. To enable this 306 | feature, set :ref:`AUTH_LDAP_CACHE_GROUPS` to ``True``. You can also set 307 | :ref:`AUTH_LDAP_GROUP_CACHE_TIMEOUT` to override the timeout of cache entries 308 | (in seconds). 309 | 310 | .. code-block:: python 311 | 312 | AUTH_LDAP_CACHE_GROUPS = True 313 | AUTH_LDAP_GROUP_CACHE_TIMEOUT = 300 314 | 315 | The second way to turn LDAP group memberships into permissions is to mirror the 316 | groups themselves. If :ref:`AUTH_LDAP_MIRROR_GROUPS` is ``True``, then every 317 | time a user logs in, :class:`~django_auth_ldap.backend.LDAPBackend` will update 318 | the database with the user's LDAP groups. Any group that doesn't exist will be 319 | created and the user's Django group membership will be updated to exactly match 320 | his LDAP group membership. Note that if the LDAP server has nested groups, the 321 | Django database will end up with a flattened representation. 322 | 323 | This approach has two main differences from :ref:`AUTH_LDAP_FIND_GROUP_PERMS`. 324 | First, :ref:`AUTH_LDAP_FIND_GROUP_PERMS` will query for LDAP group membership 325 | either for every request or according to the cache timeout. With group 326 | mirroring, membership will be updated when the user authenticates. This may not 327 | be appropriate for sites with long session timeouts. The second difference is 328 | that with :ref:`AUTH_LDAP_FIND_GROUP_PERMS`, there is no way for clients to 329 | determine a user's group memberships, only their permissions. If you want to 330 | make decisions based directly on group membership, you'll have to mirror the 331 | groups. 332 | 333 | :class:`~django_auth_ldap.backend.LDAPBackend` has one more feature pertaining 334 | to permissions, which is the ability to handle authorization for users that it 335 | did not authenticate. For example, you might be using Django's RemoteUserBackend 336 | to map externally authenticated users to Django users. By setting 337 | :ref:`AUTH_LDAP_AUTHORIZE_ALL_USERS`, 338 | :class:`~django_auth_ldap.backend.LDAPBackend` will map these users to LDAP 339 | users in the normal way in order to provide authorization information. Note that 340 | this does *not* work with :ref:`AUTH_LDAP_MIRROR_GROUPS`; group mirroring is a 341 | feature of authentication, not authorization. 342 | 343 | 344 | Multiple LDAP Configs 345 | ===================== 346 | 347 | (New in 1.1) 348 | 349 | You've probably noticed that all of the settings for this backend have the 350 | prefix AUTH_LDAP\_. This is the default, but it can be customized by subclasses 351 | of :class:`~django_auth_ldap.backend.LDAPBackend`. The main reason you would 352 | want to do this is to create two backend subclasses that reference different 353 | collections of settings and thus operate independently. For example, you might 354 | have two separate LDAP servers that you want to authenticate against. A short 355 | example should demonstrate this: 356 | 357 | .. code-block:: python 358 | 359 | # mypackage.ldap 360 | 361 | from django_auth_ldap.backend import LDAPBackend 362 | 363 | class LDAPBackend1(LDAPBackend): 364 | settings_prefix = "AUTH_LDAP_1_" 365 | 366 | class LDAPBackend2(LDAPBackend): 367 | settings_prefix = "AUTH_LDAP_2_" 368 | 369 | 370 | .. code-block:: python 371 | 372 | # settings.py 373 | 374 | AUTH_LDAP_1_SERVER_URI = "ldap://ldap1.example.com" 375 | AUTH_LDAP_1_USER_DN_TEMPLATE = "uid=%(user)s,ou=users,dc=example,dc=com" 376 | 377 | AUTH_LDAP_2_SERVER_URI = "ldap://ldap2.example.com" 378 | AUTH_LDAP_2_USER_DN_TEMPLATE = "uid=%(user)s,ou=users,dc=example,dc=com" 379 | 380 | AUTHENTICATION_BACKENDS = ( 381 | "mypackage.ldap.LDAPBackend1", 382 | "mypackage.ldap.LDAPBackend2", 383 | ) 384 | 385 | All of the usual rules apply: Django will attempt to authenticate a user with 386 | each backend in turn until one of them succeeds. When a particular backend 387 | successfully authenticates a user, that user will be linked to the backend for 388 | the duration of their session. 389 | 390 | 391 | Logging 392 | ======= 393 | 394 | :class:`~django_auth_ldap.backend.LDAPBackend` uses the standard logging module 395 | to log debug and warning messages to the logger named ``'django_auth_ldap'``. If 396 | you need debug messages to help with configuration issues, you should add a 397 | handler to this logger. Note that this logger is initialized with a level of 398 | NOTSET, so you may need to change the level of the logger in order to get debug 399 | messages. 400 | 401 | .. code-block:: python 402 | 403 | import logging 404 | 405 | logger = logging.getLogger('django_auth_ldap') 406 | logger.addHandler(logging.StreamHandler()) 407 | logger.setLevel(logging.DEBUG) 408 | 409 | More options 410 | ============ 411 | 412 | Miscellaneous settings for :class:`~django_auth_ldap.backend.LDAPBackend`: 413 | 414 | * :ref:`AUTH_LDAP_GLOBAL_OPTIONS`: A dictionary of options to pass to 415 | python-ldap via ``ldap.set_option()``. 416 | * :ref:`AUTH_LDAP_CONNECTION_OPTIONS`: A dictionary of options to pass to 417 | each LDAPObject instance via ``LDAPObject.set_option()``. 418 | 419 | 420 | Performance 421 | =========== 422 | 423 | :class:`~django_auth_ldap.backend.LDAPBackend` is carefully designed not to 424 | require a connection to the LDAP service for every request. Of course, this 425 | depends heavily on how it is configured. If LDAP traffic or latency is a concern 426 | for your deployment, this section has a few tips on minimizing it, in decreasing 427 | order of impact. 428 | 429 | #. **Cache groups**. If :ref:`AUTH_LDAP_FIND_GROUP_PERMS` is ``True``, the 430 | default behavior is to reload a user's group memberships on every 431 | request. This is the safest behavior, as any membership change takes 432 | effect immediately, but it is expensive. If possible, set 433 | :ref:`AUTH_LDAP_CACHE_GROUPS` to ``True`` to remove most of this traffic. 434 | Alternatively, you might consider using :ref:`AUTH_LDAP_MIRROR_GROUPS` 435 | and relying on :class:`~django.contrib.auth.backends.ModelBackend` to 436 | supply group permissions. 437 | #. **Don't access user.ldap_user.***. These properties are only cached 438 | on a per-request basis. If you can propagate LDAP attributes to a 439 | :class:`~django.contrib.auth.models.User` or profile object, they will 440 | only be updated at login. ``user.ldap_user.attrs`` triggers an LDAP 441 | connection for every request in which it's accessed. If you're not using 442 | :ref:`AUTH_LDAP_USER_DN_TEMPLATE`, then accessing ``user.ldap_user.dn`` 443 | will also trigger an LDAP connection. 444 | #. **Use simpler group types**. Some grouping mechanisms are more expensive 445 | than others. This will often be outside your control, but it's important 446 | to note that the extra functionality of more complex group types like 447 | :class:`~django_auth_ldap.config.NestedGroupOfNamesType` is not free and 448 | will generally require a greater number and complexity of LDAP queries. 449 | #. **Use direct binding**. Binding with 450 | :ref:`AUTH_LDAP_USER_DN_TEMPLATE` is a little bit more efficient than 451 | relying on :ref:`AUTH_LDAP_USER_SEARCH`. Specifically, it saves two LDAP 452 | operations (one bind and one search) per login. 453 | 454 | 455 | Example configuration 456 | ===================== 457 | 458 | Here is a complete example configuration from :file:`settings.py` that exercises 459 | nearly all of the features. In this example, we're authenticating against a 460 | global pool of users in the directory, but we have a special area set aside for 461 | Django groups (ou=django,ou=groups,dc=example,dc=com). Remember that most of 462 | this is optional if you just need simple authentication. Some default settings 463 | and arguments are included for completeness. 464 | 465 | .. code-block:: python 466 | 467 | import ldap 468 | from django_auth_ldap.config import LDAPSearch, GroupOfNamesType 469 | 470 | 471 | # Baseline configuration. 472 | AUTH_LDAP_SERVER_URI = "ldap://ldap.example.com" 473 | 474 | AUTH_LDAP_BIND_DN = "cn=django-agent,dc=example,dc=com" 475 | AUTH_LDAP_BIND_PASSWORD = "phlebotinum" 476 | AUTH_LDAP_USER_SEARCH = LDAPSearch("ou=users,dc=example,dc=com", 477 | ldap.SCOPE_SUBTREE, "(uid=%(user)s)") 478 | # or perhaps: 479 | # AUTH_LDAP_USER_DN_TEMPLATE = "uid=%(user)s,ou=users,dc=example,dc=com" 480 | 481 | # Set up the basic group parameters. 482 | AUTH_LDAP_GROUP_SEARCH = LDAPSearch("ou=django,ou=groups,dc=example,dc=com", 483 | ldap.SCOPE_SUBTREE, "(objectClass=groupOfNames)" 484 | ) 485 | AUTH_LDAP_GROUP_TYPE = GroupOfNamesType(name_attr="cn") 486 | 487 | # Simple group restrictions 488 | AUTH_LDAP_REQUIRE_GROUP = "cn=enabled,ou=django,ou=groups,dc=example,dc=com" 489 | AUTH_LDAP_DENY_GROUP = "cn=disabled,ou=django,ou=groups,dc=example,dc=com" 490 | 491 | # Populate the Django user from the LDAP directory. 492 | AUTH_LDAP_USER_ATTR_MAP = { 493 | "first_name": "givenName", 494 | "last_name": "sn", 495 | "email": "mail" 496 | } 497 | 498 | AUTH_LDAP_PROFILE_ATTR_MAP = { 499 | "employee_number": "employeeNumber" 500 | } 501 | 502 | AUTH_LDAP_USER_FLAGS_BY_GROUP = { 503 | "is_active": "cn=active,ou=django,ou=groups,dc=example,dc=com", 504 | "is_staff": "cn=staff,ou=django,ou=groups,dc=example,dc=com", 505 | "is_superuser": "cn=superuser,ou=django,ou=groups,dc=example,dc=com" 506 | } 507 | 508 | AUTH_LDAP_PROFILE_FLAGS_BY_GROUP = { 509 | "is_awesome": "cn=awesome,ou=django,ou=groups,dc=example,dc=com", 510 | } 511 | 512 | # This is the default, but I like to be explicit. 513 | AUTH_LDAP_ALWAYS_UPDATE_USER = True 514 | 515 | # Use LDAP group membership to calculate group permissions. 516 | AUTH_LDAP_FIND_GROUP_PERMS = True 517 | 518 | # Cache group memberships for an hour to minimize LDAP traffic 519 | AUTH_LDAP_CACHE_GROUPS = True 520 | AUTH_LDAP_GROUP_CACHE_TIMEOUT = 3600 521 | 522 | 523 | # Keep ModelBackend around for per-user permissions and maybe a local 524 | # superuser. 525 | AUTHENTICATION_BACKENDS = ( 526 | 'django_auth_ldap.backend.LDAPBackend', 527 | 'django.contrib.auth.backends.ModelBackend', 528 | ) 529 | 530 | 531 | Reference 532 | ========= 533 | 534 | Settings 535 | -------- 536 | 537 | .. _AUTH_LDAP_ALWAYS_UPDATE_USER: 538 | 539 | AUTH_LDAP_ALWAYS_UPDATE_USER 540 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 541 | 542 | Default: ``True`` 543 | 544 | If ``True``, the fields of a :class:`~django.contrib.auth.models.User` object 545 | will be updated with the latest values from the LDAP directory every time the 546 | user logs in. Otherwise the :class:`~django.contrib.auth.models.User` object 547 | will only be populated when it is automatically created. 548 | 549 | 550 | .. _AUTH_LDAP_AUTHORIZE_ALL_USERS: 551 | 552 | AUTH_LDAP_AUTHORIZE_ALL_USERS 553 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 554 | 555 | Default: ``False`` 556 | 557 | If ``True``, :class:`~django_auth_ldap.backend.LDAPBackend` will be able furnish 558 | permissions for any Django user, regardless of which backend authenticated it. 559 | 560 | 561 | .. _AUTH_LDAP_BIND_AS_AUTHENTICATING_USER: 562 | 563 | AUTH_LDAP_BIND_AS_AUTHENTICATING_USER 564 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 565 | 566 | Default: ``False`` 567 | 568 | If ``True``, authentication will leave the LDAP connection bound as the 569 | authenticating user, rather than forcing it to re-bind with the default 570 | credentials after authentication succeeds. This may be desirable if you do not 571 | have global credentials that are able to access the user's attributes. 572 | django-auth-ldap never stores the user's password, so this only applies to 573 | requests where the user is authenticated. Thus, the downside to this setting is 574 | that LDAP results may vary based on whether the user was authenticated earlier 575 | in the Django view, which could be surprising to code not directly concerned 576 | with authentication. 577 | 578 | 579 | .. _AUTH_LDAP_BIND_DN: 580 | 581 | AUTH_LDAP_BIND_DN 582 | ~~~~~~~~~~~~~~~~~ 583 | 584 | Default: ``''`` (Empty string) 585 | 586 | The distinguished name to use when binding to the LDAP server (with 587 | :ref:`AUTH_LDAP_BIND_PASSWORD`). Use the empty string (the default) for an 588 | anonymous bind. To authenticate a user, we will bind with that user's DN and 589 | password, but for all other LDAP operations, we will be bound as the DN in this 590 | setting. For example, if :ref:`AUTH_LDAP_USER_DN_TEMPLATE` is not set, we'll use 591 | this to search for the user. If :ref:`AUTH_LDAP_FIND_GROUP_PERMS` is ``True``, 592 | we'll also use it to determine group membership. 593 | 594 | 595 | .. _AUTH_LDAP_BIND_PASSWORD: 596 | 597 | AUTH_LDAP_BIND_PASSWORD 598 | ~~~~~~~~~~~~~~~~~~~~~~~ 599 | 600 | Default: ``''`` (Empty string) 601 | 602 | The password to use with :ref:`AUTH_LDAP_BIND_DN`. 603 | 604 | 605 | .. _AUTH_LDAP_CACHE_GROUPS: 606 | 607 | AUTH_LDAP_CACHE_GROUPS 608 | ~~~~~~~~~~~~~~~~~~~~~~ 609 | 610 | Default: ``False`` 611 | 612 | If ``True``, LDAP group membership will be cached using Django's cache 613 | framework. The cache timeout can be customized with 614 | :ref:`AUTH_LDAP_GROUP_CACHE_TIMEOUT`. 615 | 616 | 617 | .. _AUTH_LDAP_CONNECTION_OPTIONS: 618 | 619 | AUTH_LDAP_CONNECTION_OPTIONS 620 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 621 | 622 | Default: ``{}`` 623 | 624 | A dictionary of options to pass to each connection to the LDAP server via 625 | ``LDAPObject.set_option()``. Keys are ``ldap.OPT_*`` constants. 626 | 627 | 628 | .. _AUTH_LDAP_DENY_GROUP: 629 | 630 | AUTH_LDAP_DENY_GROUP 631 | ~~~~~~~~~~~~~~~~~~~~~~~ 632 | 633 | Default: ``None`` 634 | 635 | The distinguished name of a group; authentication will fail for any user 636 | that belongs to this group. 637 | 638 | 639 | .. _AUTH_LDAP_FIND_GROUP_PERMS: 640 | 641 | AUTH_LDAP_FIND_GROUP_PERMS 642 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 643 | 644 | Default: ``False`` 645 | 646 | If ``True``, :class:`~django_auth_ldap.backend.LDAPBackend` will furnish group 647 | permissions based on the LDAP groups the authenticated user belongs to. 648 | :ref:`AUTH_LDAP_GROUP_SEARCH` and :ref:`AUTH_LDAP_GROUP_TYPE` must also be set. 649 | 650 | 651 | .. _AUTH_LDAP_GLOBAL_OPTIONS: 652 | 653 | AUTH_LDAP_GLOBAL_OPTIONS 654 | ~~~~~~~~~~~~~~~~~~~~~~~~ 655 | 656 | Default: ``{}`` 657 | 658 | A dictionary of options to pass to ``ldap.set_option()``. Keys are 659 | ``ldap.OPT_*`` constants. 660 | 661 | 662 | .. _AUTH_LDAP_GROUP_CACHE_TIMEOUT: 663 | 664 | AUTH_LDAP_GROUP_CACHE_TIMEOUT 665 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 666 | 667 | Default: ``None`` 668 | 669 | If :ref:`AUTH_LDAP_CACHE_GROUPS` is ``True``, this is the cache timeout for 670 | group memberships. If ``None``, the global cache timeout will be used. 671 | 672 | 673 | .. _AUTH_LDAP_GROUP_SEARCH: 674 | 675 | AUTH_LDAP_GROUP_SEARCH 676 | ~~~~~~~~~~~~~~~~~~~~~~ 677 | 678 | Default: ``None`` 679 | 680 | An :class:`~django_auth_ldap.config.LDAPSearch` object that finds all LDAP 681 | groups that users might belong to. If your configuration makes any references to 682 | LDAP groups, this and :ref:`AUTH_LDAP_GROUP_TYPE` must be set. 683 | 684 | 685 | .. _AUTH_LDAP_GROUP_TYPE: 686 | 687 | AUTH_LDAP_GROUP_TYPE 688 | ~~~~~~~~~~~~~~~~~~~~ 689 | 690 | Default: ``None`` 691 | 692 | An :class:`~django_auth_ldap.config.LDAPGroupType` instance describing the type 693 | of group returned by :ref:`AUTH_LDAP_GROUP_SEARCH`. 694 | 695 | 696 | .. _AUTH_LDAP_MIRROR_GROUPS: 697 | 698 | AUTH_LDAP_MIRROR_GROUPS 699 | ~~~~~~~~~~~~~~~~~~~~~~~ 700 | 701 | Default: ``False`` 702 | 703 | If ``True``, :class:`~django_auth_ldap.backend.LDAPBackend` will mirror a user's 704 | LDAP group membership in the Django database. Any time a user authenticates, we 705 | will create all of his LDAP groups as Django groups and update his Django group 706 | membership to exactly match his LDAP group membership. If the LDAP server has 707 | nested groups, the Django database will end up with a flattened representation. 708 | 709 | 710 | .. _AUTH_LDAP_PROFILE_ATTR_MAP: 711 | 712 | AUTH_LDAP_PROFILE_ATTR_MAP 713 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 714 | 715 | Default: ``{}`` 716 | 717 | A mapping from user profile field names to LDAP attribute names. A user's 718 | profile will be populated from his LDAP attributes at login. 719 | 720 | 721 | .. _AUTH_LDAP_PROFILE_FLAGS_BY_GROUP: 722 | 723 | AUTH_LDAP_PROFILE_FLAGS_BY_GROUP 724 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 725 | 726 | Default: ``{}`` 727 | 728 | A mapping from boolean profile field names to distinguished names of LDAP 729 | groups. The corresponding field in a user's profile is set to ``True`` or 730 | ``False`` according to whether the user is a member of the group. 731 | 732 | 733 | .. _AUTH_LDAP_REQUIRE_GROUP: 734 | 735 | AUTH_LDAP_REQUIRE_GROUP 736 | ~~~~~~~~~~~~~~~~~~~~~~~ 737 | 738 | Default: ``None`` 739 | 740 | The distinguished name of a group; authentication will fail for any user that 741 | does not belong to this group. 742 | 743 | 744 | .. _AUTH_LDAP_SERVER_URI: 745 | 746 | AUTH_LDAP_SERVER_URI 747 | ~~~~~~~~~~~~~~~~~~~~ 748 | 749 | Default: ``ldap://localhost`` 750 | 751 | The URI of the LDAP server. This can be any URI that is supported by your 752 | underlying LDAP libraries. 753 | 754 | 755 | .. _AUTH_LDAP_START_TLS: 756 | 757 | AUTH_LDAP_START_TLS 758 | ~~~~~~~~~~~~~~~~~~~ 759 | 760 | Default: ``False`` 761 | 762 | If ``True``, each connection to the LDAP server will call start_tls to enable 763 | TLS encryption over the standard LDAP port. There are a number of configuration 764 | options that can be given to :ref:`AUTH_LDAP_GLOBAL_OPTIONS` that affect the 765 | TLS connection. For example, ``ldap.OPT_X_TLS_REQUIRE_CERT`` can be set to 766 | ``ldap.OPT_X_TLS_NEVER`` to disable certificate verification, perhaps to allow 767 | self-signed certificates. 768 | 769 | 770 | .. _AUTH_LDAP_USER_ATTR_MAP: 771 | 772 | AUTH_LDAP_USER_ATTR_MAP 773 | ~~~~~~~~~~~~~~~~~~~~~~~ 774 | 775 | Default: ``{}`` 776 | 777 | A mapping from :class:`~django.contrib.auth.models.User` field names to LDAP 778 | attribute names. A users's :class:`~django.contrib.auth.models.User` object will 779 | be populated from his LDAP attributes at login. 780 | 781 | 782 | .. _AUTH_LDAP_USER_DN_TEMPLATE: 783 | 784 | AUTH_LDAP_USER_DN_TEMPLATE 785 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 786 | 787 | Default: ``None`` 788 | 789 | A string template that describes any user's distinguished name based on the 790 | username. This must contain the placeholder ``%(user)s``. 791 | 792 | 793 | .. _AUTH_LDAP_USER_FLAGS_BY_GROUP: 794 | 795 | AUTH_LDAP_USER_FLAGS_BY_GROUP 796 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 797 | 798 | Default: ``{}`` 799 | 800 | A mapping from boolean :class:`~django.contrib.auth.models.User` field names to 801 | distinguished names of LDAP groups. The corresponding field is set to ``True`` 802 | or ``False`` according to whether the user is a member of the group. 803 | 804 | 805 | .. _AUTH_LDAP_USER_SEARCH: 806 | 807 | AUTH_LDAP_USER_SEARCH 808 | ~~~~~~~~~~~~~~~~~~~~~ 809 | 810 | Default: ``None`` 811 | 812 | An :class:`~django_auth_ldap.config.LDAPSearch` object that will locate a user 813 | in the directory. The filter parameter should contain the placeholder 814 | ``%(user)s`` for the username. It must return exactly one result for 815 | authentication to succeed. 816 | 817 | 818 | Module Properties 819 | ----------------- 820 | 821 | .. module:: django_auth_ldap 822 | 823 | .. data:: version 824 | 825 | The library's current version number as a 3-tuple. 826 | 827 | .. data:: version_string 828 | 829 | The library's current version number as a string. 830 | 831 | 832 | Configuration 833 | ------------- 834 | 835 | .. module:: django_auth_ldap.config 836 | 837 | .. class:: LDAPSearch 838 | 839 | .. method:: __init__(base_dn, scope, filterstr='(objectClass=*)') 840 | 841 | * ``base_dn``: The distinguished name of the search base. 842 | * ``scope``: One of ``ldap.SCOPE_*``. 843 | * ``filterstr``: An optional filter string (e.g. '(objectClass=person)'). 844 | In order to be valid, ``filterstr`` must be enclosed in parentheses. 845 | 846 | .. class:: LDAPSearchUnion 847 | 848 | (New in 1.1) 849 | 850 | .. method:: __init__(\*searches) 851 | 852 | * ``searches``: Zero or more LDAPSearch objects. The result of the 853 | overall search is the union (by DN) of the results of the underlying 854 | searches. The precedence of the underlying results and the ordering of 855 | the final results are both undefined. 856 | 857 | .. class:: LDAPGroupType 858 | 859 | The base class for objects that will determine group membership for various 860 | LDAP grouping mechanisms. Implementations are provided for common group 861 | types or you can write your own. See the source code for subclassing notes. 862 | 863 | .. method:: __init__(name_attr='cn') 864 | 865 | By default, LDAP groups will be mapped to Django groups by taking the 866 | first value of the cn attribute. You can specify a different attribute 867 | with ``name_attr``. 868 | 869 | 870 | .. class:: PosixGroupType 871 | 872 | A concrete subclass of :class:`~django_auth_ldap.config.LDAPGroupType` that 873 | handles the ``posixGroup`` object class. This checks for both primary group 874 | and group membership. 875 | 876 | .. method:: __init__(name_attr='cn') 877 | 878 | .. class:: MemberDNGroupType 879 | 880 | A concrete subclass of 881 | :class:`~django_auth_ldap.config.LDAPGroupType` that handles grouping 882 | mechanisms wherein the group object contains a list of its member DNs. 883 | 884 | .. method:: __init__(member_attr, name_attr='cn') 885 | 886 | * ``member_attr``: The attribute on the group object that contains a 887 | list of member DNs. 'member' and 'uniqueMember' are common examples. 888 | 889 | 890 | .. class:: NestedMemberDNGroupType 891 | 892 | Similar to :class:`~django_auth_ldap.config.MemberDNGroupType`, except this 893 | allows groups to contain other groups as members. Group hierarchies will be 894 | traversed to determine membership. 895 | 896 | .. method:: __init__(member_attr, name_attr='cn') 897 | 898 | As above. 899 | 900 | 901 | .. class:: GroupOfNamesType 902 | 903 | A concrete subclass of :class:`~django_auth_ldap.config.MemberDNGroupType` 904 | that handles the ``groupOfNames`` object class. Equivalent to 905 | ``MemberDNGroupType('member')``. 906 | 907 | .. method:: __init__(name_attr='cn') 908 | 909 | 910 | .. class:: NestedGroupOfNamesType 911 | 912 | A concrete subclass of 913 | :class:`~django_auth_ldap.config.NestedMemberDNGroupType` that handles the 914 | ``groupOfNames`` object class. Equivalent to 915 | ``NestedMemberDNGroupType('member')``. 916 | 917 | .. method:: __init__(name_attr='cn') 918 | 919 | 920 | .. class:: GroupOfUniqueNamesType 921 | 922 | A concrete subclass of :class:`~django_auth_ldap.config.MemberDNGroupType` 923 | that handles the ``groupOfUniqueNames`` object class. Equivalent to 924 | ``MemberDNGroupType('uniqueMember')``. 925 | 926 | .. method:: __init__(name_attr='cn') 927 | 928 | 929 | .. class:: NestedGroupOfUniqueNamesType 930 | 931 | A concrete subclass of 932 | :class:`~django_auth_ldap.config.NestedMemberDNGroupType` that handles the 933 | ``groupOfUniqueNames`` object class. Equivalent to 934 | ``NestedMemberDNGroupType('uniqueMember')``. 935 | 936 | .. method:: __init__(name_attr='cn') 937 | 938 | 939 | .. class:: ActiveDirectoryGroupType 940 | 941 | A concrete subclass of :class:`~django_auth_ldap.config.MemberDNGroupType` 942 | that handles Active Directory groups. Equivalent to 943 | ``MemberDNGroupType('member')``. 944 | 945 | .. method:: __init__(name_attr='cn') 946 | 947 | 948 | .. class:: NestedActiveDirectoryGroupType 949 | 950 | A concrete subclass of 951 | :class:`~django_auth_ldap.config.NestedMemberDNGroupType` that handles 952 | Active Directory groups. Equivalent to 953 | ``NestedMemberDNGroupType('member')``. 954 | 955 | .. method:: __init__(name_attr='cn') 956 | 957 | 958 | Backend 959 | ------- 960 | 961 | .. module:: django_auth_ldap.backend 962 | 963 | .. data:: populate_user 964 | 965 | This is a Django signal that is sent when clients should perform additional 966 | customization of a :class:`~django.contrib.auth.models.User` object. It is 967 | sent after a user has been authenticated and the backend has finished 968 | populating it, and just before it is saved. The client may take this 969 | opportunity to populate additional model fields, perhaps based on 970 | ``ldap_user.attrs``. This signal has two keyword arguments: ``user`` is the 971 | :class:`~django.contrib.auth.models.User` object and ``ldap_user`` is the 972 | same as ``user.ldap_user``. The sender is the 973 | :class:`~django_auth_ldap.backend.LDAPBackend` class. 974 | 975 | .. data:: populate_user_profile 976 | 977 | Like :data:`~django_auth_ldap.backend.populate_user`, but sent for the user 978 | profile object. This will only be sent if the user has an existing profile. 979 | As with :data:`~django_auth_ldap.backend.populate_user`, it is sent after the 980 | backend has finished setting properties and before the object is saved. This 981 | signal has two keyword arguments: ``profile`` is the user profile object and 982 | ``ldap_user`` is the same as ``user.ldap_user``. The sender is the 983 | :class:`~django_auth_ldap.backend.LDAPBackend` class. 984 | 985 | .. class:: LDAPBackend 986 | 987 | :class:`~django_auth_ldap.backend.LDAPBackend` has one method that may be 988 | called directly and several that may be overridden in subclasses. 989 | 990 | .. data:: settings_prefix 991 | 992 | A prefix for all of our Django settings. By default, this is 993 | ``"AUTH_LDAP_"``, but subclasses can override this. When different 994 | subclasses use different prefixes, they can both be installed and 995 | operate independently. 996 | 997 | .. method:: populate_user(username) 998 | 999 | Populates the Django user for the given LDAP username. This connects to 1000 | the LDAP directory with the default credentials and attempts to populate 1001 | the indicated Django user as if they had just logged in. 1002 | :ref:`AUTH_LDAP_ALWAYS_UPDATE_USER` is ignored (assumed ``True``). 1003 | 1004 | .. method:: get_or_create_user(self, username, ldap_user) 1005 | 1006 | Given a username and an LDAP user object, this must return the 1007 | associated Django User object. The ``username`` argument has already 1008 | been passed through 1009 | :meth:`~django_auth_ldap.backend.LDAPBackend.ldap_to_django_username`. 1010 | You can get information about the LDAP user via ``ldap_user.dn`` and 1011 | ``ldap_user.attrs``. The return value must be the same as 1012 | ``User.objects.get_or_create()``: a (User, created) two-tuple. 1013 | 1014 | The default implementation calls ``User.objects.get_or_create()``, using 1015 | a case-insensitive query and creating new users with lowercase 1016 | usernames. Subclasses are welcome to associate LDAP users to Django 1017 | users any way they like. 1018 | 1019 | .. method:: ldap_to_django_username(username) 1020 | 1021 | Returns a valid Django username based on the given LDAP username (which 1022 | is what the user enters). By default, ``username`` is returned 1023 | unchanged. This can be overriden by subclasses. 1024 | 1025 | .. method:: django_to_ldap_username(username) 1026 | 1027 | The inverse of 1028 | :meth:`~django_auth_ldap.backend.LDAPBackend.ldap_to_django_username`. 1029 | If this is not symmetrical to 1030 | :meth:`~django_auth_ldap.backend.LDAPBackend.ldap_to_django_username`, 1031 | the behavior is undefined. 1032 | 1033 | 1034 | License 1035 | ======= 1036 | 1037 | Copyright (c) 2009, Peter Sagerson 1038 | All rights reserved. 1039 | 1040 | Redistribution and use in source and binary forms, with or without modification, 1041 | are permitted provided that the following conditions are met: 1042 | 1043 | - Redistributions of source code must retain the above copyright notice, this 1044 | list of conditions and the following disclaimer. 1045 | 1046 | - Redistributions in binary form must reproduce the above copyright notice, this 1047 | list of conditions and the following disclaimer in the documentation and/or 1048 | other materials provided with the distribution. 1049 | 1050 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 1051 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 1052 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 1053 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 1054 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 1055 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 1056 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 1057 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 1058 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 1059 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 1060 | -------------------------------------------------------------------------------- /django_auth_ldap/tests.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | # Copyright (c) 2009, Peter Sagerson 4 | # All rights reserved. 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are met: 8 | # 9 | # - Redistributions of source code must retain the above copyright notice, this 10 | # list of conditions and the following disclaimer. 11 | # 12 | # - Redistributions in binary form must reproduce the above copyright notice, 13 | # this list of conditions and the following disclaimer in the documentation 14 | # and/or other materials provided with the distribution. 15 | # 16 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | 27 | try: 28 | set 29 | except NameError: 30 | from sets import Set as set # Python 2.3 fallback 31 | 32 | import sys 33 | import logging 34 | from collections import defaultdict 35 | 36 | from django.conf import settings 37 | import django.db.models.signals 38 | from django.contrib.auth.models import User, Permission, Group 39 | from django.test import TestCase 40 | 41 | import django_auth_ldap.models 42 | from django_auth_ldap import backend 43 | from django_auth_ldap.config import _LDAPConfig, LDAPSearch, LDAPSearchUnion 44 | from django_auth_ldap.config import PosixGroupType, MemberDNGroupType, NestedMemberDNGroupType 45 | from django_auth_ldap.config import GroupOfNamesType 46 | 47 | 48 | class TestSettings(backend.LDAPSettings): 49 | """ 50 | A replacement for backend.LDAPSettings that does not load settings 51 | from django.conf. 52 | """ 53 | def __init__(self, **kwargs): 54 | for name, default in self.defaults.iteritems(): 55 | value = kwargs.get(name, default) 56 | setattr(self, name, value) 57 | 58 | 59 | class MockLDAP(object): 60 | """ 61 | This is a stand-in for the python-ldap module; it serves as both the ldap 62 | module and the LDAPObject class. While it's temping to add some real LDAP 63 | capabilities here, this is designed to remain as simple as possible, so as 64 | to minimize the risk of creating bogus unit tests through a buggy test 65 | harness. 66 | 67 | Simple operations can be simulated, but for nontrivial searches, the client 68 | will have to seed the mock object with return values for expected API calls. 69 | This may sound like cheating, but it's really no more so than a simulated 70 | LDAP server. The fact is we can not require python-ldap to be installed in 71 | order to run the unit tests, so all we can do is verify that LDAPBackend is 72 | calling the APIs that we expect. 73 | 74 | set_return_value takes the name of an API, a tuple of arguments, and a 75 | return value. Every time an API is called, it looks for a predetermined 76 | return value based on the arguments received. If it finds one, then it 77 | returns it, or raises it if it's an Exception. If it doesn't find one, then 78 | it tries to satisfy the request internally. If it can't, it raises a 79 | PresetReturnRequiredError. 80 | 81 | At any time, the client may call ldap_methods_called_with_arguments() or 82 | ldap_methods_called() to get a record of all of the LDAP API calls that have 83 | been made, with or without arguments. 84 | """ 85 | 86 | class PresetReturnRequiredError(Exception): pass 87 | 88 | SCOPE_BASE = 0 89 | SCOPE_ONELEVEL = 1 90 | SCOPE_SUBTREE = 2 91 | 92 | RES_SEARCH_RESULT = 101 93 | 94 | class LDAPError(Exception): pass 95 | class INVALID_CREDENTIALS(LDAPError): pass 96 | class NO_SUCH_OBJECT(LDAPError): pass 97 | class NO_SUCH_ATTRIBUTE(LDAPError): pass 98 | 99 | # 100 | # Submodules 101 | # 102 | class dn(object): 103 | def escape_dn_chars(s): 104 | return s 105 | escape_dn_chars = staticmethod(escape_dn_chars) 106 | 107 | class filter(object): 108 | def escape_filter_chars(s): 109 | return s 110 | escape_filter_chars = staticmethod(escape_filter_chars) 111 | 112 | class cidict(object): 113 | class cidict(dict): 114 | pass 115 | 116 | 117 | def __init__(self, directory): 118 | """ 119 | directory is a complex structure with the entire contents of the 120 | mock LDAP directory. directory must be a dictionary mapping 121 | distinguished names to dictionaries of attributes. Each attribute 122 | dictionary maps attribute names to lists of values. e.g.: 123 | 124 | { 125 | "uid=alice,ou=users,dc=example,dc=com": 126 | { 127 | "uid": ["alice"], 128 | "userPassword": ["secret"], 129 | }, 130 | } 131 | """ 132 | self.directory = directory 133 | 134 | self.reset() 135 | 136 | def reset(self): 137 | """ 138 | Resets our recorded API calls and queued return values as well as 139 | miscellaneous configuration options. 140 | """ 141 | self.calls = [] 142 | self.return_value_maps = defaultdict(lambda: {}) 143 | self.async_results = [] 144 | self.options = {} 145 | self.tls_enabled = False 146 | 147 | def set_return_value(self, api_name, arguments, value): 148 | """ 149 | Stores a preset return value for a given API with a given set of 150 | arguments. 151 | """ 152 | self.return_value_maps[api_name][arguments] = value 153 | 154 | def ldap_methods_called_with_arguments(self): 155 | """ 156 | Returns a list of 2-tuples, one for each API call made since the last 157 | reset. Each tuple contains the name of the API and a dictionary of 158 | arguments. Argument defaults are included. 159 | """ 160 | return self.calls 161 | 162 | def ldap_methods_called(self): 163 | """ 164 | Returns the list of API names called. 165 | """ 166 | return [call[0] for call in self.calls] 167 | 168 | # 169 | # Begin LDAP methods 170 | # 171 | 172 | def set_option(self, option, invalue): 173 | self._record_call('set_option', { 174 | 'option': option, 175 | 'invalue': invalue 176 | }) 177 | 178 | self.options[option] = invalue 179 | 180 | def initialize(self, uri, trace_level=0, trace_file=sys.stdout, trace_stack_limit=None): 181 | self._record_call('initialize', { 182 | 'uri': uri, 183 | 'trace_level': trace_level, 184 | 'trace_file': trace_file, 185 | 'trace_stack_limit': trace_stack_limit 186 | }) 187 | 188 | value = self._get_return_value('initialize', 189 | (uri, trace_level, trace_file, trace_stack_limit)) 190 | if value is None: 191 | value = self 192 | 193 | return value 194 | 195 | def simple_bind_s(self, who='', cred=''): 196 | self._record_call('simple_bind_s', { 197 | 'who': who, 198 | 'cred': cred 199 | }) 200 | 201 | value = self._get_return_value('simple_bind_s', (who, cred)) 202 | if value is None: 203 | value = self._simple_bind_s(who, cred) 204 | 205 | return value 206 | 207 | def search(self, base, scope, filterstr='(objectClass=*)', attrlist=None, attrsonly=0): 208 | self._record_call('search', { 209 | 'base': base, 210 | 'scope': scope, 211 | 'filterstr':filterstr, 212 | 'attrlist':attrlist, 213 | 'attrsonly':attrsonly 214 | }) 215 | 216 | value = self._get_return_value('search_s', 217 | (base, scope, filterstr, attrlist, attrsonly)) 218 | if value is None: 219 | value = self._search_s(base, scope, filterstr, attrlist, attrsonly) 220 | 221 | return self._add_async_result(value) 222 | 223 | def result(self, msgid, all=1, timeout=None): 224 | self._record_call('result', { 225 | 'msgid': msgid, 226 | 'all': all, 227 | 'timeout': timeout, 228 | }) 229 | 230 | return self.RES_SEARCH_RESULT, self._pop_async_result(msgid) 231 | 232 | def search_s(self, base, scope, filterstr='(objectClass=*)', attrlist=None, attrsonly=0): 233 | self._record_call('search_s', { 234 | 'base': base, 235 | 'scope': scope, 236 | 'filterstr':filterstr, 237 | 'attrlist':attrlist, 238 | 'attrsonly':attrsonly 239 | }) 240 | 241 | value = self._get_return_value('search_s', 242 | (base, scope, filterstr, attrlist, attrsonly)) 243 | if value is None: 244 | value = self._search_s(base, scope, filterstr, attrlist, attrsonly) 245 | 246 | return value 247 | 248 | def start_tls_s(self): 249 | self.tls_enabled = True 250 | 251 | def compare_s(self, dn, attr, value): 252 | self._record_call('compare_s', { 253 | 'dn': dn, 254 | 'attr': attr, 255 | 'value': value 256 | }) 257 | 258 | result = self._get_return_value('compare_s', (dn, attr, value)) 259 | if result is None: 260 | result = self._compare_s(dn, attr, value) 261 | 262 | # print "compare_s('%s', '%s', '%s'): %d" % (dn, attr, value, result) 263 | 264 | return result 265 | 266 | # 267 | # Internal implementations 268 | # 269 | 270 | def _simple_bind_s(self, who='', cred=''): 271 | success = False 272 | 273 | if(who == '' and cred == ''): 274 | success = True 275 | elif self._compare_s(who.lower(), 'userPassword', cred): 276 | success = True 277 | 278 | if success: 279 | return (97, []) # python-ldap returns this; I don't know what it means 280 | else: 281 | raise self.INVALID_CREDENTIALS('%s:%s' % (who, cred)) 282 | 283 | def _compare_s(self, dn, attr, value): 284 | if dn not in self.directory: 285 | raise self.NO_SUCH_OBJECT 286 | 287 | if attr not in self.directory[dn]: 288 | raise self.NO_SUCH_ATTRIBUTE 289 | 290 | return (value in self.directory[dn][attr]) and 1 or 0 291 | 292 | def _search_s(self, base, scope, filterstr, attrlist, attrsonly): 293 | """ 294 | We can do a SCOPE_BASE search with the default filter. Beyond that, 295 | you're on your own. 296 | """ 297 | if scope != self.SCOPE_BASE: 298 | raise self.PresetReturnRequiredError('search_s("%s", %d, "%s", "%s", %d)' % 299 | (base, scope, filterstr, attrlist, attrsonly)) 300 | 301 | if filterstr != '(objectClass=*)': 302 | raise self.PresetReturnRequiredError('search_s("%s", %d, "%s", "%s", %d)' % 303 | (base, scope, filterstr, attrlist, attrsonly)) 304 | 305 | attrs = self.directory.get(base) 306 | if attrs is None: 307 | raise self.NO_SUCH_OBJECT() 308 | 309 | return [(base, attrs)] 310 | 311 | def _add_async_result(self, value): 312 | self.async_results.append(value) 313 | 314 | return len(self.async_results) - 1 315 | 316 | def _pop_async_result(self, msgid): 317 | if msgid in xrange(len(self.async_results)): 318 | value = self.async_results[msgid] 319 | self.async_results[msgid] = None 320 | else: 321 | value = None 322 | 323 | return value 324 | 325 | # 326 | # Utils 327 | # 328 | 329 | def _record_call(self, api_name, arguments): 330 | self.calls.append((api_name, arguments)) 331 | 332 | def _get_return_value(self, api_name, arguments): 333 | try: 334 | value = self.return_value_maps[api_name][arguments] 335 | except KeyError: 336 | value = None 337 | 338 | if isinstance(value, Exception): 339 | raise value 340 | 341 | return value 342 | 343 | 344 | class LDAPTest(TestCase): 345 | 346 | # Following are the objecgs in our mock LDAP directory 347 | alice = ("uid=alice,ou=people,o=test", { 348 | "uid": ["alice"], 349 | "objectClass": ["person", "organizationalPerson", "inetOrgPerson", "posixAccount"], 350 | "userPassword": ["password"], 351 | "uidNumber": ["1000"], 352 | "gidNumber": ["1000"], 353 | "givenName": ["Alice"], 354 | "sn": ["Adams"] 355 | }) 356 | bob = ("uid=bob,ou=people,o=test", { 357 | "uid": ["bob"], 358 | "objectClass": ["person", "organizationalPerson", "inetOrgPerson", "posixAccount"], 359 | "userPassword": ["password"], 360 | "uidNumber": ["1001"], 361 | "gidNumber": ["50"], 362 | "givenName": ["Robert"], 363 | "sn": ["Barker"] 364 | }) 365 | dressler = (u"uid=dreßler,ou=people,o=test".encode('utf-8'), { 366 | "uid": [u"dreßler".encode('utf-8')], 367 | "objectClass": ["person", "organizationalPerson", "inetOrgPerson", "posixAccount"], 368 | "userPassword": ["password"], 369 | "uidNumber": ["1002"], 370 | "gidNumber": ["50"], 371 | "givenName": ["Wolfgang"], 372 | "sn": [u"Dreßler".encode('utf-8')] 373 | }) 374 | nobody = ("uid=nobody,ou=people,o=test", { 375 | "uid": ["nobody"], 376 | "objectClass": ["person", "organizationalPerson", "inetOrgPerson", "posixAccount"], 377 | "userPassword": ["password"], 378 | "binaryAttr": ["\xb2"] # Invalid UTF-8 379 | }) 380 | 381 | # posixGroup objects 382 | active_px = ("cn=active_px,ou=groups,o=test", { 383 | "cn": ["active_px"], 384 | "objectClass": ["posixGroup"], 385 | "gidNumber": ["1000"], 386 | }) 387 | staff_px = ("cn=staff_px,ou=groups,o=test", { 388 | "cn": ["staff_px"], 389 | "objectClass": ["posixGroup"], 390 | "gidNumber": ["1001"], 391 | "memberUid": ["alice"], 392 | }) 393 | superuser_px = ("cn=superuser_px,ou=groups,o=test", { 394 | "cn": ["superuser_px"], 395 | "objectClass": ["posixGroup"], 396 | "gidNumber": ["1002"], 397 | "memberUid": ["alice"], 398 | }) 399 | 400 | # groupOfUniqueName groups 401 | active_gon = ("cn=active_gon,ou=groups,o=test", { 402 | "cn": ["active_gon"], 403 | "objectClass": ["groupOfNames"], 404 | "member": ["uid=alice,ou=people,o=test"] 405 | }) 406 | staff_gon = ("cn=staff_gon,ou=groups,o=test", { 407 | "cn": ["staff_gon"], 408 | "objectClass": ["groupOfNames"], 409 | "member": ["uid=alice,ou=people,o=test"] 410 | }) 411 | superuser_gon = ("cn=superuser_gon,ou=groups,o=test", { 412 | "cn": ["superuser_gon"], 413 | "objectClass": ["groupOfNames"], 414 | "member": ["uid=alice,ou=people,o=test"] 415 | }) 416 | 417 | # Nested groups with a circular reference 418 | parent_gon = ("cn=parent_gon,ou=groups,o=test", { 419 | "cn": ["parent_gon"], 420 | "objectClass": ["groupOfNames"], 421 | "member": ["cn=nested_gon,ou=groups,o=test"] 422 | }) 423 | nested_gon = ("cn=nested_gon,ou=groups,o=test", { 424 | "cn": ["nested_gon"], 425 | "objectClass": ["groupOfNames"], 426 | "member": [ 427 | "uid=alice,ou=people,o=test", 428 | "cn=circular_gon,ou=groups,o=test" 429 | ] 430 | }) 431 | circular_gon = ("cn=circular_gon,ou=groups,o=test", { 432 | "cn": ["circular_gon"], 433 | "objectClass": ["groupOfNames"], 434 | "member": ["cn=parent_gon,ou=groups,o=test"] 435 | }) 436 | 437 | 438 | mock_ldap = MockLDAP({ 439 | alice[0]: alice[1], 440 | bob[0]: bob[1], 441 | dressler[0]: dressler[1], 442 | nobody[0]: nobody[1], 443 | active_gon[0]: active_gon[1], 444 | staff_gon[0]: staff_gon[1], 445 | superuser_gon[0]: superuser_gon[1], 446 | parent_gon[0]: parent_gon[1], 447 | nested_gon[0]: nested_gon[1], 448 | circular_gon[0]: circular_gon[1], 449 | active_px[0]: active_px[1], 450 | staff_px[0]: staff_px[1], 451 | superuser_px[0]: superuser_px[1], 452 | }) 453 | 454 | 455 | logging_configured = False 456 | def configure_logger(cls): 457 | if not cls.logging_configured: 458 | logger = logging.getLogger('django_auth_ldap') 459 | formatter = logging.Formatter("LDAP auth - %(levelname)s - %(message)s") 460 | handler = logging.StreamHandler() 461 | 462 | handler.setLevel(logging.DEBUG) 463 | handler.setFormatter(formatter) 464 | logger.addHandler(handler) 465 | 466 | logger.setLevel(logging.CRITICAL) 467 | 468 | cls.logging_configured = True 469 | configure_logger = classmethod(configure_logger) 470 | 471 | 472 | def setUp(self): 473 | self.configure_logger() 474 | self.mock_ldap.reset() 475 | 476 | self.ldap = _LDAPConfig.ldap = self.mock_ldap 477 | self.backend = backend.LDAPBackend() 478 | 479 | 480 | def tearDown(self): 481 | pass 482 | 483 | 484 | def test_options(self): 485 | self._init_settings( 486 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', 487 | CONNECTION_OPTIONS={'opt1': 'value1'} 488 | ) 489 | 490 | self.backend.authenticate(username='alice', password='password') 491 | 492 | self.assertEqual(self.mock_ldap.options, {'opt1': 'value1'}) 493 | 494 | def test_simple_bind(self): 495 | self._init_settings( 496 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test' 497 | ) 498 | user_count = User.objects.count() 499 | 500 | user = self.backend.authenticate(username='alice', password='password') 501 | 502 | self.assert_(not user.has_usable_password()) 503 | self.assertEqual(user.username, 'alice') 504 | self.assertEqual(User.objects.count(), user_count + 1) 505 | self.assertEqual(self.mock_ldap.ldap_methods_called(), 506 | ['initialize', 'simple_bind_s']) 507 | 508 | def test_new_user_lowercase(self): 509 | self._init_settings( 510 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test' 511 | ) 512 | user_count = User.objects.count() 513 | 514 | user = self.backend.authenticate(username='Alice', password='password') 515 | 516 | self.assert_(not user.has_usable_password()) 517 | self.assertEqual(user.username, 'alice') 518 | self.assertEqual(User.objects.count(), user_count + 1) 519 | self.assertEqual(self.mock_ldap.ldap_methods_called(), 520 | ['initialize', 'simple_bind_s']) 521 | 522 | def test_new_user_whitespace(self): 523 | self._init_settings( 524 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test' 525 | ) 526 | user_count = User.objects.count() 527 | 528 | user = self.backend.authenticate(username=' alice', password='password') 529 | user = self.backend.authenticate(username='alice ', password='password') 530 | 531 | self.assert_(not user.has_usable_password()) 532 | self.assertEqual(user.username, 'alice') 533 | self.assertEqual(User.objects.count(), user_count + 1) 534 | 535 | 536 | def test_simple_bind_bad_user(self): 537 | self._init_settings( 538 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test' 539 | ) 540 | user_count = User.objects.count() 541 | 542 | user = self.backend.authenticate(username='evil_alice', password='password') 543 | 544 | self.assert_(user is None) 545 | self.assertEqual(User.objects.count(), user_count) 546 | self.assertEqual(self.mock_ldap.ldap_methods_called(), 547 | ['initialize', 'simple_bind_s']) 548 | 549 | def test_simple_bind_bad_password(self): 550 | self._init_settings( 551 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test' 552 | ) 553 | user_count = User.objects.count() 554 | 555 | user = self.backend.authenticate(username='alice', password='bogus') 556 | 557 | self.assert_(user is None) 558 | self.assertEqual(User.objects.count(), user_count) 559 | self.assertEqual(self.mock_ldap.ldap_methods_called(), 560 | ['initialize', 'simple_bind_s']) 561 | 562 | def test_existing_user(self): 563 | self._init_settings( 564 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test' 565 | ) 566 | User.objects.create(username='alice') 567 | user_count = User.objects.count() 568 | 569 | user = self.backend.authenticate(username='alice', password='password') 570 | 571 | # Make sure we only created one user 572 | self.assert_(user is not None) 573 | self.assertEqual(User.objects.count(), user_count) 574 | 575 | def test_existing_user_insensitive(self): 576 | self._init_settings( 577 | USER_SEARCH=LDAPSearch( 578 | "ou=people,o=test", self.mock_ldap.SCOPE_SUBTREE, '(uid=%(user)s)' 579 | ) 580 | ) 581 | self.mock_ldap.set_return_value('search_s', 582 | ("ou=people,o=test", 2, "(uid=Alice)", None, 0), [self.alice]) 583 | User.objects.create(username='alice') 584 | 585 | user = self.backend.authenticate(username='Alice', password='password') 586 | 587 | self.assert_(user is not None) 588 | self.assertEqual(user.username, 'alice') 589 | self.assertEqual(User.objects.count(), 1) 590 | 591 | def test_convert_username(self): 592 | class MyBackend(backend.LDAPBackend): 593 | def ldap_to_django_username(self, username): 594 | return 'ldap_%s' % username 595 | def django_to_ldap_username(self, username): 596 | return username[5:] 597 | 598 | self.backend = MyBackend() 599 | self._init_settings( 600 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test' 601 | ) 602 | user_count = User.objects.count() 603 | 604 | user1 = self.backend.authenticate(username='alice', password='password') 605 | user2 = self.backend.get_user(user1.pk) 606 | 607 | self.assertEqual(User.objects.count(), user_count + 1) 608 | self.assertEqual(user1.username, 'ldap_alice') 609 | self.assertEqual(user1.ldap_user._username, 'alice') 610 | self.assertEqual(user1.ldap_username, 'alice') 611 | self.assertEqual(user2.username, 'ldap_alice') 612 | self.assertEqual(user2.ldap_user._username, 'alice') 613 | self.assertEqual(user2.ldap_username, 'alice') 614 | 615 | def test_search_bind(self): 616 | self._init_settings( 617 | USER_SEARCH=LDAPSearch( 618 | "ou=people,o=test", self.mock_ldap.SCOPE_SUBTREE, '(uid=%(user)s)' 619 | ) 620 | ) 621 | self.mock_ldap.set_return_value('search_s', 622 | ("ou=people,o=test", 2, "(uid=alice)", None, 0), [self.alice]) 623 | user_count = User.objects.count() 624 | 625 | user = self.backend.authenticate(username='alice', password='password') 626 | 627 | self.assert_(user is not None) 628 | self.assertEqual(User.objects.count(), user_count + 1) 629 | self.assertEqual(self.mock_ldap.ldap_methods_called(), 630 | ['initialize', 'simple_bind_s', 'search_s', 'simple_bind_s']) 631 | 632 | def test_search_bind_no_user(self): 633 | self._init_settings( 634 | USER_SEARCH=LDAPSearch( 635 | "ou=people,o=test", self.mock_ldap.SCOPE_SUBTREE, '(cn=%(user)s)' 636 | ) 637 | ) 638 | self.mock_ldap.set_return_value('search_s', 639 | ("ou=people,o=test", 2, "(cn=alice)", None, 0), []) 640 | 641 | user = self.backend.authenticate(username='alice', password='password') 642 | 643 | self.assert_(user is None) 644 | self.assertEqual(self.mock_ldap.ldap_methods_called(), 645 | ['initialize', 'simple_bind_s', 'search_s']) 646 | 647 | def test_search_bind_multiple_users(self): 648 | self._init_settings( 649 | USER_SEARCH=LDAPSearch( 650 | "ou=people,o=test", self.mock_ldap.SCOPE_SUBTREE, '(uid=*)' 651 | ) 652 | ) 653 | self.mock_ldap.set_return_value('search_s', 654 | ("ou=people,o=test", 2, "(uid=*)", None, 0), [self.alice, self.bob]) 655 | 656 | user = self.backend.authenticate(username='alice', password='password') 657 | 658 | self.assert_(user is None) 659 | self.assertEqual(self.mock_ldap.ldap_methods_called(), 660 | ['initialize', 'simple_bind_s', 'search_s']) 661 | 662 | def test_search_bind_bad_password(self): 663 | self._init_settings( 664 | USER_SEARCH=LDAPSearch( 665 | "ou=people,o=test", self.mock_ldap.SCOPE_SUBTREE, '(uid=%(user)s)' 666 | ) 667 | ) 668 | self.mock_ldap.set_return_value('search_s', 669 | ("ou=people,o=test", 2, "(uid=alice)", None, 0), [self.alice]) 670 | 671 | user = self.backend.authenticate(username='alice', password='bogus') 672 | 673 | self.assert_(user is None) 674 | self.assertEqual(self.mock_ldap.ldap_methods_called(), 675 | ['initialize', 'simple_bind_s', 'search_s', 'simple_bind_s']) 676 | 677 | def test_search_bind_with_credentials(self): 678 | self._init_settings( 679 | BIND_DN='uid=bob,ou=people,o=test', 680 | BIND_PASSWORD='password', 681 | USER_SEARCH=LDAPSearch( 682 | "ou=people,o=test", self.mock_ldap.SCOPE_SUBTREE, '(uid=%(user)s)' 683 | ) 684 | ) 685 | self.mock_ldap.set_return_value('search_s', 686 | ("ou=people,o=test", 2, "(uid=alice)", None, 0), [self.alice]) 687 | 688 | user = self.backend.authenticate(username='alice', password='password') 689 | 690 | self.assert_(user is not None) 691 | self.assert_(user.ldap_user is not None) 692 | self.assertEqual(user.ldap_user.dn, self.alice[0]) 693 | self.assertEqual(user.ldap_user.attrs, self.alice[1]) 694 | self.assertEqual(self.mock_ldap.ldap_methods_called(), 695 | ['initialize', 'simple_bind_s', 'search_s', 'simple_bind_s']) 696 | 697 | def test_search_bind_with_bad_credentials(self): 698 | self._init_settings( 699 | BIND_DN='uid=bob,ou=people,o=test', 700 | BIND_PASSWORD='bogus', 701 | USER_SEARCH=LDAPSearch( 702 | "ou=people,o=test", self.mock_ldap.SCOPE_SUBTREE, '(uid=%(user)s)' 703 | ) 704 | ) 705 | 706 | user = self.backend.authenticate(username='alice', password='password') 707 | 708 | self.assert_(user is None) 709 | self.assertEqual(self.mock_ldap.ldap_methods_called(), 710 | ['initialize', 'simple_bind_s']) 711 | 712 | def test_unicode_user(self): 713 | self._init_settings( 714 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', 715 | USER_ATTR_MAP={'first_name': 'givenName', 'last_name': 'sn'} 716 | ) 717 | 718 | user = self.backend.authenticate(username=u'dreßler', password='password') 719 | 720 | self.assert_(user is not None) 721 | self.assertEqual(user.username, u'dreßler') 722 | self.assertEqual(user.last_name, u'Dreßler') 723 | 724 | def test_cidict(self): 725 | self._init_settings( 726 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', 727 | ) 728 | 729 | user = self.backend.authenticate(username="alice", password="password") 730 | 731 | self.assert_(isinstance(user.ldap_user.attrs, self.ldap.cidict.cidict)) 732 | 733 | def test_populate_user(self): 734 | self._init_settings( 735 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', 736 | USER_ATTR_MAP={'first_name': 'givenName', 'last_name': 'sn'} 737 | ) 738 | 739 | user = self.backend.authenticate(username='alice', password='password') 740 | 741 | self.assertEqual(user.username, 'alice') 742 | self.assertEqual(user.first_name, 'Alice') 743 | self.assertEqual(user.last_name, 'Adams') 744 | 745 | # init, bind as user, bind anonymous, lookup user attrs 746 | self.assertEqual(self.mock_ldap.ldap_methods_called(), 747 | ['initialize', 'simple_bind_s', 'simple_bind_s', 'search_s']) 748 | 749 | def test_bind_as_user(self): 750 | self._init_settings( 751 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', 752 | USER_ATTR_MAP={'first_name': 'givenName', 'last_name': 'sn'}, 753 | BIND_AS_AUTHENTICATING_USER=True, 754 | ) 755 | 756 | user = self.backend.authenticate(username='alice', password='password') 757 | 758 | self.assertEqual(user.username, 'alice') 759 | self.assertEqual(user.first_name, 'Alice') 760 | self.assertEqual(user.last_name, 'Adams') 761 | 762 | # init, bind as user, lookup user attrs 763 | self.assertEqual(self.mock_ldap.ldap_methods_called(), 764 | ['initialize', 'simple_bind_s', 'search_s']) 765 | 766 | def test_signal_populate_user(self): 767 | self._init_settings( 768 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test' 769 | ) 770 | def handle_populate_user(sender, **kwargs): 771 | self.assert_('user' in kwargs and 'ldap_user' in kwargs) 772 | kwargs['user'].populate_user_handled = True 773 | backend.populate_user.connect(handle_populate_user) 774 | 775 | user = self.backend.authenticate(username='alice', password='password') 776 | 777 | self.assert_(user.populate_user_handled) 778 | 779 | def test_signal_populate_user_profile(self): 780 | settings.AUTH_PROFILE_MODULE = 'django_auth_ldap.TestProfile' 781 | 782 | self._init_settings( 783 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test' 784 | ) 785 | 786 | def handle_user_saved(sender, **kwargs): 787 | if kwargs['created']: 788 | django_auth_ldap.models.TestProfile.objects.create(user=kwargs['instance']) 789 | 790 | def handle_populate_user_profile(sender, **kwargs): 791 | self.assert_('profile' in kwargs and 'ldap_user' in kwargs) 792 | kwargs['profile'].populated = True 793 | 794 | django.db.models.signals.post_save.connect(handle_user_saved, sender=User) 795 | backend.populate_user_profile.connect(handle_populate_user_profile) 796 | 797 | user = self.backend.authenticate(username='alice', password='password') 798 | 799 | self.assert_(user.get_profile().populated) 800 | 801 | def test_no_update_existing(self): 802 | self._init_settings( 803 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', 804 | USER_ATTR_MAP={'first_name': 'givenName', 'last_name': 'sn'}, 805 | ALWAYS_UPDATE_USER=False 806 | ) 807 | User.objects.create(username='alice', first_name='Alicia', last_name='Astro') 808 | 809 | alice = self.backend.authenticate(username='alice', password='password') 810 | bob = self.backend.authenticate(username='bob', password='password') 811 | 812 | self.assertEqual(alice.first_name, 'Alicia') 813 | self.assertEqual(alice.last_name, 'Astro') 814 | self.assertEqual(bob.first_name, 'Robert') 815 | self.assertEqual(bob.last_name, 'Barker') 816 | 817 | def test_require_group(self): 818 | self._init_settings( 819 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', 820 | GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), 821 | GROUP_TYPE=MemberDNGroupType(member_attr='member'), 822 | REQUIRE_GROUP="cn=active_gon,ou=groups,o=test" 823 | ) 824 | 825 | alice = self.backend.authenticate(username='alice', password='password') 826 | bob = self.backend.authenticate(username='bob', password='password') 827 | 828 | self.assert_(alice is not None) 829 | self.assert_(bob is None) 830 | self.assertEqual(self.mock_ldap.ldap_methods_called(), 831 | ['initialize', 'simple_bind_s', 'simple_bind_s', 'compare_s', 'initialize', 'simple_bind_s', 'simple_bind_s', 'compare_s']) 832 | 833 | def test_denied_group(self): 834 | self._init_settings( 835 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', 836 | GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), 837 | GROUP_TYPE=MemberDNGroupType(member_attr='member'), 838 | DENY_GROUP="cn=active_gon,ou=groups,o=test" 839 | ) 840 | 841 | alice = self.backend.authenticate(username='alice', password='password') 842 | bob = self.backend.authenticate(username='bob', password='password') 843 | 844 | self.assert_(alice is None) 845 | self.assert_(bob is not None) 846 | self.assertEqual(self.mock_ldap.ldap_methods_called(), 847 | ['initialize', 'simple_bind_s', 'simple_bind_s', 'compare_s', 'initialize', 'simple_bind_s', 'simple_bind_s', 'compare_s']) 848 | 849 | def test_group_dns(self): 850 | self._init_settings( 851 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', 852 | GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), 853 | GROUP_TYPE=MemberDNGroupType(member_attr='member'), 854 | ) 855 | self.mock_ldap.set_return_value('search_s', 856 | ("ou=groups,o=test", 2, "(&(objectClass=*)(member=uid=alice,ou=people,o=test))", None, 0), 857 | [self.active_gon, self.staff_gon, self.superuser_gon, self.nested_gon] 858 | ) 859 | 860 | alice = self.backend.authenticate(username='alice', password='password') 861 | 862 | self.assertEqual(alice.ldap_user.group_dns, set((g[0] for g in [self.active_gon, self.staff_gon, self.superuser_gon, self.nested_gon]))) 863 | 864 | def test_group_names(self): 865 | self._init_settings( 866 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', 867 | GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), 868 | GROUP_TYPE=MemberDNGroupType(member_attr='member'), 869 | ) 870 | self.mock_ldap.set_return_value('search_s', 871 | ("ou=groups,o=test", 2, "(&(objectClass=*)(member=uid=alice,ou=people,o=test))", None, 0), 872 | [self.active_gon, self.staff_gon, self.superuser_gon, self.nested_gon] 873 | ) 874 | 875 | alice = self.backend.authenticate(username='alice', password='password') 876 | 877 | self.assertEqual(alice.ldap_user.group_names, set(['active_gon', 'staff_gon', 'superuser_gon', 'nested_gon'])) 878 | 879 | def test_dn_group_membership(self): 880 | self._init_settings( 881 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', 882 | GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), 883 | GROUP_TYPE=MemberDNGroupType(member_attr='member'), 884 | USER_FLAGS_BY_GROUP={ 885 | 'is_active': "cn=active_gon,ou=groups,o=test", 886 | 'is_staff': "cn=staff_gon,ou=groups,o=test", 887 | 'is_superuser': "cn=superuser_gon,ou=groups,o=test" 888 | } 889 | ) 890 | 891 | alice = self.backend.authenticate(username='alice', password='password') 892 | bob = self.backend.authenticate(username='bob', password='password') 893 | 894 | self.assert_(alice.is_active) 895 | self.assert_(alice.is_staff) 896 | self.assert_(alice.is_superuser) 897 | self.assert_(not bob.is_active) 898 | self.assert_(not bob.is_staff) 899 | self.assert_(not bob.is_superuser) 900 | 901 | def test_posix_membership(self): 902 | self._init_settings( 903 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', 904 | GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), 905 | GROUP_TYPE=PosixGroupType(), 906 | USER_FLAGS_BY_GROUP={ 907 | 'is_active': "cn=active_px,ou=groups,o=test", 908 | 'is_staff': "cn=staff_px,ou=groups,o=test", 909 | 'is_superuser': "cn=superuser_px,ou=groups,o=test" 910 | } 911 | ) 912 | 913 | alice = self.backend.authenticate(username='alice', password='password') 914 | bob = self.backend.authenticate(username='bob', password='password') 915 | 916 | self.assert_(alice.is_active) 917 | self.assert_(alice.is_staff) 918 | self.assert_(alice.is_superuser) 919 | self.assert_(not bob.is_active) 920 | self.assert_(not bob.is_staff) 921 | self.assert_(not bob.is_superuser) 922 | 923 | def test_nested_dn_group_membership(self): 924 | self._init_settings( 925 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', 926 | GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), 927 | GROUP_TYPE=NestedMemberDNGroupType(member_attr='member'), 928 | USER_FLAGS_BY_GROUP={ 929 | 'is_active': "cn=parent_gon,ou=groups,o=test", 930 | 'is_staff': "cn=parent_gon,ou=groups,o=test", 931 | } 932 | ) 933 | self.mock_ldap.set_return_value('search_s', 934 | ("ou=groups,o=test", 2, "(&(objectClass=*)(|(member=uid=alice,ou=people,o=test)))", None, 0), 935 | [self.active_gon, self.nested_gon] 936 | ) 937 | self.mock_ldap.set_return_value('search_s', 938 | ("ou=groups,o=test", 2, "(&(objectClass=*)(|(member=cn=active_gon,ou=groups,o=test)(member=cn=nested_gon,ou=groups,o=test)))", None, 0), 939 | [self.parent_gon] 940 | ) 941 | self.mock_ldap.set_return_value('search_s', 942 | ("ou=groups,o=test", 2, "(&(objectClass=*)(|(member=cn=parent_gon,ou=groups,o=test)))", None, 0), 943 | [self.circular_gon] 944 | ) 945 | self.mock_ldap.set_return_value('search_s', 946 | ("ou=groups,o=test", 2, "(&(objectClass=*)(|(member=cn=circular_gon,ou=groups,o=test)))", None, 0), 947 | [self.nested_gon] 948 | ) 949 | 950 | self.mock_ldap.set_return_value('search_s', 951 | ("ou=groups,o=test", 2, "(&(objectClass=*)(|(member=uid=bob,ou=people,o=test)))", None, 0), 952 | [] 953 | ) 954 | 955 | alice = self.backend.authenticate(username='alice', password='password') 956 | bob = self.backend.authenticate(username='bob', password='password') 957 | 958 | self.assert_(alice.is_active) 959 | self.assert_(alice.is_staff) 960 | self.assert_(not bob.is_active) 961 | self.assert_(not bob.is_staff) 962 | 963 | def test_posix_missing_attributes(self): 964 | self._init_settings( 965 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', 966 | GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), 967 | GROUP_TYPE=PosixGroupType(), 968 | USER_FLAGS_BY_GROUP={ 969 | 'is_active': "cn=active_px,ou=groups,o=test" 970 | } 971 | ) 972 | 973 | nobody = self.backend.authenticate(username='nobody', password='password') 974 | 975 | self.assert_(not nobody.is_active) 976 | 977 | def test_profile_flags(self): 978 | settings.AUTH_PROFILE_MODULE = 'django_auth_ldap.TestProfile' 979 | 980 | self._init_settings( 981 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', 982 | GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), 983 | GROUP_TYPE=MemberDNGroupType(member_attr='member'), 984 | PROFILE_FLAGS_BY_GROUP={ 985 | 'is_special': "cn=superuser_gon,ou=groups,o=test" 986 | } 987 | ) 988 | 989 | def handle_user_saved(sender, **kwargs): 990 | if kwargs['created']: 991 | django_auth_ldap.models.TestProfile.objects.create(user=kwargs['instance']) 992 | 993 | django.db.models.signals.post_save.connect(handle_user_saved, sender=User) 994 | 995 | alice = self.backend.authenticate(username='alice', password='password') 996 | bob = self.backend.authenticate(username='bob', password='password') 997 | 998 | self.assert_(alice.get_profile().is_special) 999 | self.assert_(not bob.get_profile().is_special) 1000 | 1001 | 1002 | def test_dn_group_permissions(self): 1003 | self._init_settings( 1004 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', 1005 | GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), 1006 | GROUP_TYPE=MemberDNGroupType(member_attr='member'), 1007 | FIND_GROUP_PERMS=True 1008 | ) 1009 | self._init_groups() 1010 | self.mock_ldap.set_return_value('search_s', 1011 | ("ou=groups,o=test", 2, "(&(objectClass=*)(member=uid=alice,ou=people,o=test))", None, 0), 1012 | [self.active_gon, self.staff_gon, self.superuser_gon, self.nested_gon] 1013 | ) 1014 | 1015 | alice = User.objects.create(username='alice') 1016 | alice = self.backend.get_user(alice.pk) 1017 | 1018 | self.assertEqual(self.backend.get_group_permissions(alice), set(["auth.add_user", "auth.change_user"])) 1019 | self.assertEqual(self.backend.get_all_permissions(alice), set(["auth.add_user", "auth.change_user"])) 1020 | self.assert_(self.backend.has_perm(alice, "auth.add_user")) 1021 | self.assert_(self.backend.has_module_perms(alice, "auth")) 1022 | 1023 | def test_empty_group_permissions(self): 1024 | self._init_settings( 1025 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', 1026 | GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), 1027 | GROUP_TYPE=MemberDNGroupType(member_attr='member'), 1028 | FIND_GROUP_PERMS=True 1029 | ) 1030 | self._init_groups() 1031 | self.mock_ldap.set_return_value('search_s', 1032 | ("ou=groups,o=test", 2, "(&(objectClass=*)(member=uid=bob,ou=people,o=test))", None, 0), 1033 | [] 1034 | ) 1035 | 1036 | bob = User.objects.create(username='bob') 1037 | bob = self.backend.get_user(bob.pk) 1038 | 1039 | self.assertEqual(self.backend.get_group_permissions(bob), set()) 1040 | self.assertEqual(self.backend.get_all_permissions(bob), set()) 1041 | self.assert_(not self.backend.has_perm(bob, "auth.add_user")) 1042 | self.assert_(not self.backend.has_module_perms(bob, "auth")) 1043 | 1044 | def test_posix_group_permissions(self): 1045 | self._init_settings( 1046 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', 1047 | GROUP_SEARCH=LDAPSearch('ou=groups,o=test', 1048 | self.mock_ldap.SCOPE_SUBTREE, "(objectClass=posixGroup)" 1049 | ), 1050 | GROUP_TYPE=PosixGroupType(), 1051 | FIND_GROUP_PERMS=True 1052 | ) 1053 | self._init_groups() 1054 | self.mock_ldap.set_return_value('search_s', 1055 | ("ou=groups,o=test", 2, "(&(objectClass=posixGroup)(|(gidNumber=1000)(memberUid=alice)))", None, 0), 1056 | [self.active_px, self.staff_px, self.superuser_px] 1057 | ) 1058 | 1059 | alice = User.objects.create(username='alice') 1060 | alice = self.backend.get_user(alice.pk) 1061 | 1062 | self.assertEqual(self.backend.get_group_permissions(alice), set(["auth.add_user", "auth.change_user"])) 1063 | self.assertEqual(self.backend.get_all_permissions(alice), set(["auth.add_user", "auth.change_user"])) 1064 | self.assert_(self.backend.has_perm(alice, "auth.add_user")) 1065 | self.assert_(self.backend.has_module_perms(alice, "auth")) 1066 | 1067 | def test_foreign_user_permissions(self): 1068 | self._init_settings( 1069 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', 1070 | GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), 1071 | GROUP_TYPE=MemberDNGroupType(member_attr='member'), 1072 | FIND_GROUP_PERMS=True 1073 | ) 1074 | self._init_groups() 1075 | 1076 | alice = User.objects.create(username='alice') 1077 | 1078 | self.assertEqual(self.backend.get_group_permissions(alice), set()) 1079 | 1080 | def test_group_cache(self): 1081 | self._init_settings( 1082 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', 1083 | GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), 1084 | GROUP_TYPE=MemberDNGroupType(member_attr='member'), 1085 | FIND_GROUP_PERMS=True, 1086 | CACHE_GROUPS=True 1087 | ) 1088 | self._init_groups() 1089 | self.mock_ldap.set_return_value('search_s', 1090 | ("ou=groups,o=test", 2, "(&(objectClass=*)(member=uid=alice,ou=people,o=test))", None, 0), 1091 | [self.active_gon, self.staff_gon, self.superuser_gon, self.nested_gon] 1092 | ) 1093 | self.mock_ldap.set_return_value('search_s', 1094 | ("ou=groups,o=test", 2, "(&(objectClass=*)(member=uid=bob,ou=people,o=test))", None, 0), 1095 | [] 1096 | ) 1097 | 1098 | alice_id = User.objects.create(username='alice').pk 1099 | bob_id = User.objects.create(username='bob').pk 1100 | 1101 | # Check permissions twice for each user 1102 | for i in range(2): 1103 | alice = self.backend.get_user(alice_id) 1104 | self.assertEqual(self.backend.get_group_permissions(alice), 1105 | set(["auth.add_user", "auth.change_user"])) 1106 | 1107 | bob = self.backend.get_user(bob_id) 1108 | self.assertEqual(self.backend.get_group_permissions(bob), set()) 1109 | 1110 | # Should have executed one LDAP search per user 1111 | self.assertEqual(self.mock_ldap.ldap_methods_called(), 1112 | ['initialize', 'simple_bind_s', 'search_s', 'initialize', 'simple_bind_s', 'search_s']) 1113 | 1114 | def test_group_mirroring(self): 1115 | self._init_settings( 1116 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', 1117 | GROUP_SEARCH=LDAPSearch('ou=groups,o=test', 1118 | self.mock_ldap.SCOPE_SUBTREE, "(objectClass=posixGroup)" 1119 | ), 1120 | GROUP_TYPE=PosixGroupType(), 1121 | MIRROR_GROUPS=True, 1122 | ) 1123 | self.mock_ldap.set_return_value('search_s', 1124 | ("ou=groups,o=test", 2, "(&(objectClass=posixGroup)(|(gidNumber=1000)(memberUid=alice)))", None, 0), 1125 | [self.active_px, self.staff_px, self.superuser_px] 1126 | ) 1127 | 1128 | self.assertEqual(Group.objects.count(), 0) 1129 | 1130 | alice = self.backend.authenticate(username='alice', password='password') 1131 | 1132 | self.assertEqual(Group.objects.count(), 3) 1133 | self.assertEqual(set(alice.groups.all()), set(Group.objects.all())) 1134 | 1135 | def test_nested_group_mirroring(self): 1136 | self._init_settings( 1137 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', 1138 | GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), 1139 | GROUP_TYPE=NestedMemberDNGroupType(member_attr='member'), 1140 | MIRROR_GROUPS=True, 1141 | ) 1142 | self.mock_ldap.set_return_value('search_s', 1143 | ("ou=groups,o=test", 2, "(&(objectClass=*)(|(member=uid=alice,ou=people,o=test)))", None, 0), 1144 | [self.active_gon, self.nested_gon] 1145 | ) 1146 | self.mock_ldap.set_return_value('search_s', 1147 | ("ou=groups,o=test", 2, "(&(objectClass=*)(|(member=cn=active_gon,ou=groups,o=test)(member=cn=nested_gon,ou=groups,o=test)))", None, 0), 1148 | [self.parent_gon] 1149 | ) 1150 | self.mock_ldap.set_return_value('search_s', 1151 | ("ou=groups,o=test", 2, "(&(objectClass=*)(|(member=cn=parent_gon,ou=groups,o=test)))", None, 0), 1152 | [self.circular_gon] 1153 | ) 1154 | self.mock_ldap.set_return_value('search_s', 1155 | ("ou=groups,o=test", 2, "(&(objectClass=*)(|(member=cn=circular_gon,ou=groups,o=test)))", None, 0), 1156 | [self.nested_gon] 1157 | ) 1158 | 1159 | alice = self.backend.authenticate(username='alice', password='password') 1160 | 1161 | self.assertEqual(Group.objects.count(), 4) 1162 | self.assertEqual(set(Group.objects.all().values_list('name', flat=True)), 1163 | set(['active_gon', 'nested_gon', 'parent_gon', 'circular_gon'])) 1164 | self.assertEqual(set(alice.groups.all()), set(Group.objects.all())) 1165 | 1166 | def test_authorize_external_users(self): 1167 | self._init_settings( 1168 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', 1169 | GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), 1170 | GROUP_TYPE=MemberDNGroupType(member_attr='member'), 1171 | FIND_GROUP_PERMS=True, 1172 | AUTHORIZE_ALL_USERS=True 1173 | ) 1174 | self._init_groups() 1175 | self.mock_ldap.set_return_value('search_s', 1176 | ("ou=groups,o=test", 2, "(&(objectClass=*)(member=uid=alice,ou=people,o=test))", None, 0), 1177 | [self.active_gon, self.staff_gon, self.superuser_gon, self.nested_gon] 1178 | ) 1179 | 1180 | alice = User.objects.create(username='alice') 1181 | 1182 | self.assertEqual(self.backend.get_group_permissions(alice), set(["auth.add_user", "auth.change_user"])) 1183 | 1184 | def test_create_without_auth(self): 1185 | self._init_settings( 1186 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', 1187 | ) 1188 | 1189 | alice = self.backend.populate_user('alice') 1190 | bob = self.backend.populate_user('bob') 1191 | 1192 | self.assert_(alice is not None) 1193 | self.assertEqual(alice.first_name, u"") 1194 | self.assertEqual(alice.last_name, u"") 1195 | self.assert_(alice.is_active) 1196 | self.assert_(not alice.is_staff) 1197 | self.assert_(not alice.is_superuser) 1198 | self.assert_(bob is not None) 1199 | self.assertEqual(bob.first_name, u"") 1200 | self.assertEqual(bob.last_name, u"") 1201 | self.assert_(bob.is_active) 1202 | self.assert_(not bob.is_staff) 1203 | self.assert_(not bob.is_superuser) 1204 | 1205 | def test_populate_without_auth(self): 1206 | self._init_settings( 1207 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', 1208 | ALWAYS_UPDATE_USER=False, 1209 | USER_ATTR_MAP={'first_name': 'givenName', 'last_name': 'sn'}, 1210 | GROUP_SEARCH=LDAPSearch('ou=groups,o=test', self.mock_ldap.SCOPE_SUBTREE), 1211 | GROUP_TYPE=GroupOfNamesType(), 1212 | USER_FLAGS_BY_GROUP={ 1213 | 'is_active': "cn=active_gon,ou=groups,o=test", 1214 | 'is_staff': "cn=staff_gon,ou=groups,o=test", 1215 | 'is_superuser': "cn=superuser_gon,ou=groups,o=test" 1216 | } 1217 | ) 1218 | 1219 | User.objects.create(username='alice') 1220 | User.objects.create(username='bob') 1221 | 1222 | alice = self.backend.populate_user('alice') 1223 | bob = self.backend.populate_user('bob') 1224 | 1225 | self.assert_(alice is not None) 1226 | self.assertEqual(alice.first_name, u"Alice") 1227 | self.assertEqual(alice.last_name, u"Adams") 1228 | self.assert_(alice.is_active) 1229 | self.assert_(alice.is_staff) 1230 | self.assert_(alice.is_superuser) 1231 | self.assert_(bob is not None) 1232 | self.assertEqual(bob.first_name, u"Robert") 1233 | self.assertEqual(bob.last_name, u"Barker") 1234 | self.assert_(not bob.is_active) 1235 | self.assert_(not bob.is_staff) 1236 | self.assert_(not bob.is_superuser) 1237 | 1238 | def test_populate_bogus_user(self): 1239 | self._init_settings( 1240 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', 1241 | ) 1242 | 1243 | bogus = self.backend.populate_user('bogus') 1244 | 1245 | self.assertEqual(bogus, None) 1246 | 1247 | def test_start_tls_missing(self): 1248 | self._init_settings( 1249 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', 1250 | START_TLS=False, 1251 | ) 1252 | 1253 | self.assert_(not self.mock_ldap.tls_enabled) 1254 | self.backend.authenticate(username='alice', password='password') 1255 | self.assert_(not self.mock_ldap.tls_enabled) 1256 | 1257 | def test_start_tls(self): 1258 | self._init_settings( 1259 | USER_DN_TEMPLATE='uid=%(user)s,ou=people,o=test', 1260 | START_TLS=True, 1261 | ) 1262 | 1263 | self.assert_(not self.mock_ldap.tls_enabled) 1264 | self.backend.authenticate(username='alice', password='password') 1265 | self.assert_(self.mock_ldap.tls_enabled) 1266 | 1267 | def test_null_search_results(self): 1268 | """ 1269 | Make sure we're not phased by referrals. 1270 | """ 1271 | self._init_settings( 1272 | USER_SEARCH=LDAPSearch( 1273 | "ou=people,o=test", self.mock_ldap.SCOPE_SUBTREE, '(uid=%(user)s)' 1274 | ) 1275 | ) 1276 | self.mock_ldap.set_return_value('search_s', 1277 | ("ou=people,o=test", 2, "(uid=alice)", None, 0), [self.alice, (None, '')]) 1278 | 1279 | self.backend.authenticate(username='alice', password='password') 1280 | 1281 | def test_union_search(self): 1282 | self._init_settings( 1283 | USER_SEARCH=LDAPSearchUnion( 1284 | LDAPSearch("ou=groups,o=test", self.mock_ldap.SCOPE_SUBTREE, '(uid=%(user)s)'), 1285 | LDAPSearch("ou=people,o=test", self.mock_ldap.SCOPE_SUBTREE, '(uid=%(user)s)'), 1286 | ) 1287 | ) 1288 | self.mock_ldap.set_return_value('search_s', 1289 | ("ou=groups,o=test", 2, "(uid=alice)", None, 0), []) 1290 | self.mock_ldap.set_return_value('search_s', 1291 | ("ou=people,o=test", 2, "(uid=alice)", None, 0), [self.alice]) 1292 | 1293 | alice = self.backend.authenticate(username='alice', password='password') 1294 | 1295 | self.assert_(alice is not None) 1296 | 1297 | self.assertEqual(self.mock_ldap.ldap_methods_called(), 1298 | ['initialize', 'simple_bind_s', 'search', 'search', 'result', 1299 | 'result', 'simple_bind_s']) 1300 | 1301 | def _init_settings(self, **kwargs): 1302 | self.backend.settings = TestSettings(**kwargs) 1303 | 1304 | def _init_groups(self): 1305 | permissions = [ 1306 | Permission.objects.get(codename="add_user"), 1307 | Permission.objects.get(codename="change_user") 1308 | ] 1309 | 1310 | active_gon = Group.objects.create(name='active_gon') 1311 | active_gon.permissions.add(*permissions) 1312 | 1313 | active_px = Group.objects.create(name='active_px') 1314 | active_px.permissions.add(*permissions) 1315 | --------------------------------------------------------------------------------