New Upstream Release - django-python3-ldap

Ready changes

Summary

Merged new upstream version: 0.15.4 (was: 0.15.3).

Resulting package

Built on 2022-12-21T13:27 (took 11m45s)

The resulting binary packages can be installed (if you have the apt repository enabled) by running one of:

apt install -t fresh-releases python3-django-python3-ldap

Lintian Result

Diff

diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 0997508..5b3f6af 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,6 +1,57 @@
 django-python3-ldap changelog
 =============================
 
+0.15.4
+------
+
+- BUGFIX: Fixing redundant rebind when `LDAP_AUTH_CONNECTION_USERNAME` matches the existing username (@gagantrivedi).
+
+
+0.15.3
+------
+
+- BUGFIX: TLS connection is not started even if `LDAP_AUTH_USE_TLS = True` (@githubuserx).
+
+0.15.2
+------
+
+- Added ``ldap_clean_users`` command (@jordiromera).
+
+0.15.1
+------
+
+- Bugfix: Allows overriding the SSL/TLS version (@FlipperPA).
+
+
+0.15.0
+------
+
+- Allows overriding the SSL/TLS version (@FlipperPA).
+
+0.14.0
+------
+
+- Added support for multiple LDAP servers to enable a high-availability server pool (@hho6643).
+
+0.13.1
+------
+
+- Django 4.0 compatibility (@sn1c).
+
+
+0.13.0
+------
+
+- Allow syncing individual users with ``ldap_sync_users`` management command (@CristopherH95).
+
+
+0.12.1
+------
+
+- Added support for additional (ignored) keyword arguments to ``authenticate(...)``. This allows use with other
+  authentication backends that require different keyword arguments (@stinovlas).
+
+
 0.12.0
 ------
 
diff --git a/PKG-INFO b/PKG-INFO
index 23ffbc8..8809368 100644
--- a/PKG-INFO
+++ b/PKG-INFO
@@ -1,13 +1,11 @@
-Metadata-Version: 1.1
+Metadata-Version: 2.1
 Name: django-python3-ldap
-Version: 0.12.0
+Version: 0.15.4
 Summary: Django LDAP user authentication backend for Python 3.
 Home-page: https://github.com/etianen/django-python3-ldap
 Author: Dave Hall
 Author-email: dave@etianen.com
 License: BSD
-Description: UNKNOWN
-Platform: UNKNOWN
 Classifier: Development Status :: 4 - Beta
 Classifier: Environment :: Web Environment
 Classifier: Intended Audience :: Developers
@@ -18,3 +16,274 @@ Classifier: Programming Language :: Python :: 3.6
 Classifier: Programming Language :: Python :: 3.7
 Classifier: Programming Language :: Python :: 3.8
 Classifier: Framework :: Django
+License-File: LICENSE
+
+django-python3-ldap
+===================
+
+**django-python3-ldap** provides a Django LDAP user authentication backend. Python 3.6+ is required.
+
+
+Features
+--------
+
+- Authenticate users with an LDAP server.
+- Sync LDAP users with a local Django database.
+- Supports custom Django user models.
+
+
+Installation
+------------
+
+1. Install using ``pip install django-python3-ldap``.
+2. Add ``'django_python3_ldap'`` to your ``INSTALLED_APPS`` setting.
+3. Set your ``AUTHENTICATION_BACKENDS`` setting to ``("django_python3_ldap.auth.LDAPBackend",)``
+4. Configure the settings for your LDAP server(s) (see Available settings, below).
+5. Optionally, run ``./manage.py ldap_sync_users`` (or ``./manage.py ldap_sync_users <list of user lookups>``) to perform an initial sync of LDAP users.
+6. Optionally, run ``./manage.py ldap_promote <username>`` to grant superuser admin access to a given user.
+
+
+Available settings
+------------------
+
+**Note**: The settings below show their default values. You only need to add settings to your ``settings.py`` file that you intend to override.
+
+
+.. code:: python
+
+    # The URL of the LDAP server(s).  List multiple servers for high availability ServerPool connection.
+    LDAP_AUTH_URL = ["ldap://localhost:389"]
+
+    # Initiate TLS on connection.
+    LDAP_AUTH_USE_TLS = False
+
+    # Specify which TLS version to use (Python 3.10 requires TLSv1 or higher)
+    import ssl
+    LDAP_AUTH_TLS_VERSION = ssl.PROTOCOL_TLSv1_2
+
+    # The LDAP search base for looking up users.
+    LDAP_AUTH_SEARCH_BASE = "ou=people,dc=example,dc=com"
+
+    # The LDAP class that represents a user.
+    LDAP_AUTH_OBJECT_CLASS = "inetOrgPerson"
+
+    # User model fields mapped to the LDAP
+    # attributes that represent them.
+    LDAP_AUTH_USER_FIELDS = {
+        "username": "uid",
+        "first_name": "givenName",
+        "last_name": "sn",
+        "email": "mail",
+    }
+
+    # A tuple of django model fields used to uniquely identify a user.
+    LDAP_AUTH_USER_LOOKUP_FIELDS = ("username",)
+
+    # Path to a callable that takes a dict of {model_field_name: value},
+    # returning a dict of clean model data.
+    # Use this to customize how data loaded from LDAP is saved to the User model.
+    LDAP_AUTH_CLEAN_USER_DATA = "django_python3_ldap.utils.clean_user_data"
+
+    # Path to a callable that takes a user model, a dict of {ldap_field_name: [value]}
+    # a LDAP connection object (to allow further lookups), and saves any additional
+    # user relationships based on the LDAP data.
+    # Use this to customize how data loaded from LDAP is saved to User model relations.
+    # For customizing non-related User model fields, use LDAP_AUTH_CLEAN_USER_DATA.
+    LDAP_AUTH_SYNC_USER_RELATIONS = "django_python3_ldap.utils.sync_user_relations"
+
+    # Path to a callable that takes a dict of {ldap_field_name: value},
+    # returning a list of [ldap_search_filter]. The search filters will then be AND'd
+    # together when creating the final search filter.
+    LDAP_AUTH_FORMAT_SEARCH_FILTERS = "django_python3_ldap.utils.format_search_filters"
+
+    # Path to a callable that takes a dict of {model_field_name: value}, and returns
+    # a string of the username to bind to the LDAP server.
+    # Use this to support different types of LDAP server.
+    LDAP_AUTH_FORMAT_USERNAME = "django_python3_ldap.utils.format_username_openldap"
+
+    # Sets the login domain for Active Directory users.
+    LDAP_AUTH_ACTIVE_DIRECTORY_DOMAIN = None
+
+    # The LDAP username and password of a user for querying the LDAP database for user
+    # details. If None, then the authenticated user will be used for querying, and
+    # the `ldap_sync_users`, `ldap_clean_users` commands will perform an anonymous query.
+    LDAP_AUTH_CONNECTION_USERNAME = None
+    LDAP_AUTH_CONNECTION_PASSWORD = None
+
+    # Set connection/receive timeouts (in seconds) on the underlying `ldap3` library.
+    LDAP_AUTH_CONNECT_TIMEOUT = None
+    LDAP_AUTH_RECEIVE_TIMEOUT = None
+
+
+Microsoft Active Directory support
+----------------------------------
+
+django-python3-ldap is configured by default to support login via OpenLDAP. To connect to
+a Microsoft Active Directory, you need to modify your settings file.
+
+For simple usernames (e.g. "username"):
+
+.. code:: python
+
+    LDAP_AUTH_FORMAT_USERNAME = "django_python3_ldap.utils.format_username_active_directory"
+
+For down-level login name formats (e.g. "DOMAIN\\username"):
+
+.. code:: python
+
+    LDAP_AUTH_FORMAT_USERNAME = "django_python3_ldap.utils.format_username_active_directory"
+    LDAP_AUTH_ACTIVE_DIRECTORY_DOMAIN = "DOMAIN"
+
+For user-principal-name formats (e.g. "user@domain.com"):
+
+.. code:: python
+
+    LDAP_AUTH_FORMAT_USERNAME = "django_python3_ldap.utils.format_username_active_directory_principal"
+    LDAP_AUTH_ACTIVE_DIRECTORY_DOMAIN = "domain.com"
+
+Depending on how your Active Directory server is configured, the following additional settings may match your server
+better than the defaults used by django-python3-ldap:
+
+.. code:: python
+
+    LDAP_AUTH_USER_FIELDS = {
+        "username": "sAMAccountName",
+        "first_name": "givenName",
+        "last_name": "sn",
+        "email": "mail",
+    }
+
+    LDAP_AUTH_OBJECT_CLASS = "user"
+
+
+Sync User Relations
+-------------------
+
+As part of the user authentication process, django-python3-ldap calls a function specified by the
+LDAP_AUTH_SYNC_USER_RELATIONS configuraton item.  This function can be used for making additional
+updates to the user database (for example updaing the groups the user is a member of), or getting
+further information from the LDAP server.
+
+The signature of the called function is:-
+
+.. code:: python
+
+    def sync_user_relations(user, ldap_attributes, *, connection=None, dn=None):
+
+The parameters are:-
+
+- ``user`` - a Django user model object
+- ``ldap_attributes`` - a dict of LDAP attributes
+- ``connection`` - the LDAP connection object (optional keyword only parameter)
+- ``dn`` - the DN (Distinguished Name) of the LDAP matched user (optional keyword only parameter)
+
+
+Clean User
+----------
+
+When a LDAP user is removed from server it could be interresting to deactive or delete its local Django account
+to prevent unauthorized access.
+
+To do so run:
+
+    ``./manage.py ldap_clean_users`` (or ``./manage.py ldap_clean_users --purge``).
+
+It will deactivate all local users non declared on LDAP server. If ``--purge`` is specified, all local users will be deleted.
+
+
+Can't get authentication to work?
+---------------------------------
+
+LDAP is a very complicated protocol. Enable logging (see below), and see what error messages the LDAP connection is throwing.
+
+
+Logging
+-------
+
+Print information about failed logins to your console by adding the following to your ``settings.py`` file.
+
+.. code:: python
+
+    LOGGING = {
+        "version": 1,
+        "disable_existing_loggers": False,
+        "handlers": {
+            "console": {
+                "class": "logging.StreamHandler",
+            },
+        },
+        "loggers": {
+            "django_python3_ldap": {
+                "handlers": ["console"],
+                "level": "INFO",
+            },
+        },
+    }
+
+
+Custom user filters
+-------------------
+
+By default, any users within ``LDAP_AUTH_SEARCH_BASE`` and of the correct ``LDAP_AUTH_OBJECT_CLASS``
+will be considered a valid user. You can apply further filtering by setting a custom ``LDAP_AUTH_FORMAT_SEARCH_FILTERS``
+callable.
+
+.. code:: python
+
+    # settings.py
+    LDAP_AUTH_FORMAT_SEARCH_FILTERS = "path.to.your.custom_format_search_filters"
+
+    # path/to/your/module.py
+    from django_python3_ldap.utils import format_search_filters
+
+    def custom_format_search_filters(ldap_fields):
+        # Add in simple filters.
+        ldap_fields["memberOf"] = "foo"
+        # Call the base format callable.
+        search_filters = format_search_filters(ldap_fields)
+        # Advanced: apply custom LDAP filter logic.
+        search_filters.append("(|(memberOf=groupA)(memberOf=GroupB))")
+        # All done!
+        return search_filters
+
+The returned list of search filters will be AND'd together to make the final search filter.
+
+
+How it works
+------------
+
+When a user attempts to authenticate, a connection is made to one of the listed LDAP
+servers, and the application attempts to bind using the provided username and password.
+
+If the bind attempt is successful, the user details are loaded from the LDAP server
+and saved in a local Django ``User`` model. The local model is only created once,
+and the details will be kept updated with the LDAP record details on every login.
+
+To perform a full sync of all LDAP users to the local database, run ``./manage.py ldap_sync_users``.
+This is not required, as the authentication backend will create users on demand. Syncing users has
+the advantage of allowing you to assign permissions and groups to the existing users using the Django
+admin interface.
+
+Running ``ldap_sync_users`` as a background cron task is another optional way to
+keep all users in sync on a regular basis.
+
+
+Support and announcements
+-------------------------
+
+Downloads and bug tracking can be found at the `main project
+website <http://github.com/etianen/django-python3-ldap>`_.
+
+
+More information
+----------------
+
+The django-python3-ldap project was developed by Dave Hall. You can get the code
+from the `django-python3-ldap project site <http://github.com/etianen/django-python3-ldap>`_.
+
+Dave Hall is a freelance web developer, based in Cambridge, UK. You can usually
+find him on the Internet in a number of different places:
+
+-  `Website <http://www.etianen.com/>`_
+-  `Twitter <http://twitter.com/etianen>`_
+-  `Google Profile <http://www.google.com/profiles/david.etianen>`_
diff --git a/README.rst b/README.rst
index 7bd08f2..5b5e334 100644
--- a/README.rst
+++ b/README.rst
@@ -1,7 +1,7 @@
 django-python3-ldap
 ===================
 
-**django-python3-ldap** provides a Django LDAP user authentication backend.
+**django-python3-ldap** provides a Django LDAP user authentication backend. Python 3.6+ is required.
 
 
 Features
@@ -18,8 +18,8 @@ Installation
 1. Install using ``pip install django-python3-ldap``.
 2. Add ``'django_python3_ldap'`` to your ``INSTALLED_APPS`` setting.
 3. Set your ``AUTHENTICATION_BACKENDS`` setting to ``("django_python3_ldap.auth.LDAPBackend",)``
-4. Configure the settings for your LDAP server (see Available settings, below).
-5. Optionally, run ``./manage.py ldap_sync_users`` to perform an initial sync of LDAP users.
+4. Configure the settings for your LDAP server(s) (see Available settings, below).
+5. Optionally, run ``./manage.py ldap_sync_users`` (or ``./manage.py ldap_sync_users <list of user lookups>``) to perform an initial sync of LDAP users.
 6. Optionally, run ``./manage.py ldap_promote <username>`` to grant superuser admin access to a given user.
 
 
@@ -31,12 +31,16 @@ Available settings
 
 .. code:: python
 
-    # The URL of the LDAP server.
-    LDAP_AUTH_URL = "ldap://localhost:389"
+    # The URL of the LDAP server(s).  List multiple servers for high availability ServerPool connection.
+    LDAP_AUTH_URL = ["ldap://localhost:389"]
 
     # Initiate TLS on connection.
     LDAP_AUTH_USE_TLS = False
 
+    # Specify which TLS version to use (Python 3.10 requires TLSv1 or higher)
+    import ssl
+    LDAP_AUTH_TLS_VERSION = ssl.PROTOCOL_TLSv1_2
+
     # The LDAP search base for looking up users.
     LDAP_AUTH_SEARCH_BASE = "ou=people,dc=example,dc=com"
 
@@ -82,7 +86,7 @@ Available settings
 
     # The LDAP username and password of a user for querying the LDAP database for user
     # details. If None, then the authenticated user will be used for querying, and
-    # the `ldap_sync_users` command will perform an anonymous query.
+    # the `ldap_sync_users`, `ldap_clean_users` commands will perform an anonymous query.
     LDAP_AUTH_CONNECTION_USERNAME = None
     LDAP_AUTH_CONNECTION_PASSWORD = None
 
@@ -154,6 +158,19 @@ The parameters are:-
 - ``dn`` - the DN (Distinguished Name) of the LDAP matched user (optional keyword only parameter)
 
 
+Clean User
+----------
+
+When a LDAP user is removed from server it could be interresting to deactive or delete its local Django account
+to prevent unauthorized access.
+
+To do so run:
+
+    ``./manage.py ldap_clean_users`` (or ``./manage.py ldap_clean_users --purge``).
+
+It will deactivate all local users non declared on LDAP server. If ``--purge`` is specified, all local users will be deleted.
+
+
 Can't get authentication to work?
 ---------------------------------
 
@@ -215,8 +232,8 @@ The returned list of search filters will be AND'd together to make the final sea
 How it works
 ------------
 
-When a user attempts to authenticate, a connection is made to the LDAP
-server, and the application attempts to bind using the provided username and password.
+When a user attempts to authenticate, a connection is made to one of the listed LDAP
+servers, and the application attempts to bind using the provided username and password.
 
 If the bind attempt is successful, the user details are loaded from the LDAP server
 and saved in a local Django ``User`` model. The local model is only created once,
diff --git a/debian/changelog b/debian/changelog
index c63131b..aa580ac 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,8 +1,10 @@
-django-python3-ldap (0.12.0-2) UNRELEASED; urgency=medium
+django-python3-ldap (0.15.4-1) UNRELEASED; urgency=medium
 
   * Update standards version to 4.6.1, no changes needed.
+  * New upstream release.
+  * New upstream release.
 
- -- Debian Janitor <janitor@jelmer.uk>  Fri, 14 Oct 2022 21:46:49 -0000
+ -- Debian Janitor <janitor@jelmer.uk>  Wed, 21 Dec 2022 13:17:05 -0000
 
 django-python3-ldap (0.12.0-1) unstable; urgency=low
 
diff --git a/django_python3_ldap.egg-info/PKG-INFO b/django_python3_ldap.egg-info/PKG-INFO
index 23ffbc8..8809368 100644
--- a/django_python3_ldap.egg-info/PKG-INFO
+++ b/django_python3_ldap.egg-info/PKG-INFO
@@ -1,13 +1,11 @@
-Metadata-Version: 1.1
+Metadata-Version: 2.1
 Name: django-python3-ldap
-Version: 0.12.0
+Version: 0.15.4
 Summary: Django LDAP user authentication backend for Python 3.
 Home-page: https://github.com/etianen/django-python3-ldap
 Author: Dave Hall
 Author-email: dave@etianen.com
 License: BSD
-Description: UNKNOWN
-Platform: UNKNOWN
 Classifier: Development Status :: 4 - Beta
 Classifier: Environment :: Web Environment
 Classifier: Intended Audience :: Developers
@@ -18,3 +16,274 @@ Classifier: Programming Language :: Python :: 3.6
 Classifier: Programming Language :: Python :: 3.7
 Classifier: Programming Language :: Python :: 3.8
 Classifier: Framework :: Django
+License-File: LICENSE
+
+django-python3-ldap
+===================
+
+**django-python3-ldap** provides a Django LDAP user authentication backend. Python 3.6+ is required.
+
+
+Features
+--------
+
+- Authenticate users with an LDAP server.
+- Sync LDAP users with a local Django database.
+- Supports custom Django user models.
+
+
+Installation
+------------
+
+1. Install using ``pip install django-python3-ldap``.
+2. Add ``'django_python3_ldap'`` to your ``INSTALLED_APPS`` setting.
+3. Set your ``AUTHENTICATION_BACKENDS`` setting to ``("django_python3_ldap.auth.LDAPBackend",)``
+4. Configure the settings for your LDAP server(s) (see Available settings, below).
+5. Optionally, run ``./manage.py ldap_sync_users`` (or ``./manage.py ldap_sync_users <list of user lookups>``) to perform an initial sync of LDAP users.
+6. Optionally, run ``./manage.py ldap_promote <username>`` to grant superuser admin access to a given user.
+
+
+Available settings
+------------------
+
+**Note**: The settings below show their default values. You only need to add settings to your ``settings.py`` file that you intend to override.
+
+
+.. code:: python
+
+    # The URL of the LDAP server(s).  List multiple servers for high availability ServerPool connection.
+    LDAP_AUTH_URL = ["ldap://localhost:389"]
+
+    # Initiate TLS on connection.
+    LDAP_AUTH_USE_TLS = False
+
+    # Specify which TLS version to use (Python 3.10 requires TLSv1 or higher)
+    import ssl
+    LDAP_AUTH_TLS_VERSION = ssl.PROTOCOL_TLSv1_2
+
+    # The LDAP search base for looking up users.
+    LDAP_AUTH_SEARCH_BASE = "ou=people,dc=example,dc=com"
+
+    # The LDAP class that represents a user.
+    LDAP_AUTH_OBJECT_CLASS = "inetOrgPerson"
+
+    # User model fields mapped to the LDAP
+    # attributes that represent them.
+    LDAP_AUTH_USER_FIELDS = {
+        "username": "uid",
+        "first_name": "givenName",
+        "last_name": "sn",
+        "email": "mail",
+    }
+
+    # A tuple of django model fields used to uniquely identify a user.
+    LDAP_AUTH_USER_LOOKUP_FIELDS = ("username",)
+
+    # Path to a callable that takes a dict of {model_field_name: value},
+    # returning a dict of clean model data.
+    # Use this to customize how data loaded from LDAP is saved to the User model.
+    LDAP_AUTH_CLEAN_USER_DATA = "django_python3_ldap.utils.clean_user_data"
+
+    # Path to a callable that takes a user model, a dict of {ldap_field_name: [value]}
+    # a LDAP connection object (to allow further lookups), and saves any additional
+    # user relationships based on the LDAP data.
+    # Use this to customize how data loaded from LDAP is saved to User model relations.
+    # For customizing non-related User model fields, use LDAP_AUTH_CLEAN_USER_DATA.
+    LDAP_AUTH_SYNC_USER_RELATIONS = "django_python3_ldap.utils.sync_user_relations"
+
+    # Path to a callable that takes a dict of {ldap_field_name: value},
+    # returning a list of [ldap_search_filter]. The search filters will then be AND'd
+    # together when creating the final search filter.
+    LDAP_AUTH_FORMAT_SEARCH_FILTERS = "django_python3_ldap.utils.format_search_filters"
+
+    # Path to a callable that takes a dict of {model_field_name: value}, and returns
+    # a string of the username to bind to the LDAP server.
+    # Use this to support different types of LDAP server.
+    LDAP_AUTH_FORMAT_USERNAME = "django_python3_ldap.utils.format_username_openldap"
+
+    # Sets the login domain for Active Directory users.
+    LDAP_AUTH_ACTIVE_DIRECTORY_DOMAIN = None
+
+    # The LDAP username and password of a user for querying the LDAP database for user
+    # details. If None, then the authenticated user will be used for querying, and
+    # the `ldap_sync_users`, `ldap_clean_users` commands will perform an anonymous query.
+    LDAP_AUTH_CONNECTION_USERNAME = None
+    LDAP_AUTH_CONNECTION_PASSWORD = None
+
+    # Set connection/receive timeouts (in seconds) on the underlying `ldap3` library.
+    LDAP_AUTH_CONNECT_TIMEOUT = None
+    LDAP_AUTH_RECEIVE_TIMEOUT = None
+
+
+Microsoft Active Directory support
+----------------------------------
+
+django-python3-ldap is configured by default to support login via OpenLDAP. To connect to
+a Microsoft Active Directory, you need to modify your settings file.
+
+For simple usernames (e.g. "username"):
+
+.. code:: python
+
+    LDAP_AUTH_FORMAT_USERNAME = "django_python3_ldap.utils.format_username_active_directory"
+
+For down-level login name formats (e.g. "DOMAIN\\username"):
+
+.. code:: python
+
+    LDAP_AUTH_FORMAT_USERNAME = "django_python3_ldap.utils.format_username_active_directory"
+    LDAP_AUTH_ACTIVE_DIRECTORY_DOMAIN = "DOMAIN"
+
+For user-principal-name formats (e.g. "user@domain.com"):
+
+.. code:: python
+
+    LDAP_AUTH_FORMAT_USERNAME = "django_python3_ldap.utils.format_username_active_directory_principal"
+    LDAP_AUTH_ACTIVE_DIRECTORY_DOMAIN = "domain.com"
+
+Depending on how your Active Directory server is configured, the following additional settings may match your server
+better than the defaults used by django-python3-ldap:
+
+.. code:: python
+
+    LDAP_AUTH_USER_FIELDS = {
+        "username": "sAMAccountName",
+        "first_name": "givenName",
+        "last_name": "sn",
+        "email": "mail",
+    }
+
+    LDAP_AUTH_OBJECT_CLASS = "user"
+
+
+Sync User Relations
+-------------------
+
+As part of the user authentication process, django-python3-ldap calls a function specified by the
+LDAP_AUTH_SYNC_USER_RELATIONS configuraton item.  This function can be used for making additional
+updates to the user database (for example updaing the groups the user is a member of), or getting
+further information from the LDAP server.
+
+The signature of the called function is:-
+
+.. code:: python
+
+    def sync_user_relations(user, ldap_attributes, *, connection=None, dn=None):
+
+The parameters are:-
+
+- ``user`` - a Django user model object
+- ``ldap_attributes`` - a dict of LDAP attributes
+- ``connection`` - the LDAP connection object (optional keyword only parameter)
+- ``dn`` - the DN (Distinguished Name) of the LDAP matched user (optional keyword only parameter)
+
+
+Clean User
+----------
+
+When a LDAP user is removed from server it could be interresting to deactive or delete its local Django account
+to prevent unauthorized access.
+
+To do so run:
+
+    ``./manage.py ldap_clean_users`` (or ``./manage.py ldap_clean_users --purge``).
+
+It will deactivate all local users non declared on LDAP server. If ``--purge`` is specified, all local users will be deleted.
+
+
+Can't get authentication to work?
+---------------------------------
+
+LDAP is a very complicated protocol. Enable logging (see below), and see what error messages the LDAP connection is throwing.
+
+
+Logging
+-------
+
+Print information about failed logins to your console by adding the following to your ``settings.py`` file.
+
+.. code:: python
+
+    LOGGING = {
+        "version": 1,
+        "disable_existing_loggers": False,
+        "handlers": {
+            "console": {
+                "class": "logging.StreamHandler",
+            },
+        },
+        "loggers": {
+            "django_python3_ldap": {
+                "handlers": ["console"],
+                "level": "INFO",
+            },
+        },
+    }
+
+
+Custom user filters
+-------------------
+
+By default, any users within ``LDAP_AUTH_SEARCH_BASE`` and of the correct ``LDAP_AUTH_OBJECT_CLASS``
+will be considered a valid user. You can apply further filtering by setting a custom ``LDAP_AUTH_FORMAT_SEARCH_FILTERS``
+callable.
+
+.. code:: python
+
+    # settings.py
+    LDAP_AUTH_FORMAT_SEARCH_FILTERS = "path.to.your.custom_format_search_filters"
+
+    # path/to/your/module.py
+    from django_python3_ldap.utils import format_search_filters
+
+    def custom_format_search_filters(ldap_fields):
+        # Add in simple filters.
+        ldap_fields["memberOf"] = "foo"
+        # Call the base format callable.
+        search_filters = format_search_filters(ldap_fields)
+        # Advanced: apply custom LDAP filter logic.
+        search_filters.append("(|(memberOf=groupA)(memberOf=GroupB))")
+        # All done!
+        return search_filters
+
+The returned list of search filters will be AND'd together to make the final search filter.
+
+
+How it works
+------------
+
+When a user attempts to authenticate, a connection is made to one of the listed LDAP
+servers, and the application attempts to bind using the provided username and password.
+
+If the bind attempt is successful, the user details are loaded from the LDAP server
+and saved in a local Django ``User`` model. The local model is only created once,
+and the details will be kept updated with the LDAP record details on every login.
+
+To perform a full sync of all LDAP users to the local database, run ``./manage.py ldap_sync_users``.
+This is not required, as the authentication backend will create users on demand. Syncing users has
+the advantage of allowing you to assign permissions and groups to the existing users using the Django
+admin interface.
+
+Running ``ldap_sync_users`` as a background cron task is another optional way to
+keep all users in sync on a regular basis.
+
+
+Support and announcements
+-------------------------
+
+Downloads and bug tracking can be found at the `main project
+website <http://github.com/etianen/django-python3-ldap>`_.
+
+
+More information
+----------------
+
+The django-python3-ldap project was developed by Dave Hall. You can get the code
+from the `django-python3-ldap project site <http://github.com/etianen/django-python3-ldap>`_.
+
+Dave Hall is a freelance web developer, based in Cambridge, UK. You can usually
+find him on the Internet in a number of different places:
+
+-  `Website <http://www.etianen.com/>`_
+-  `Twitter <http://twitter.com/etianen>`_
+-  `Google Profile <http://www.google.com/profiles/david.etianen>`_
diff --git a/django_python3_ldap.egg-info/SOURCES.txt b/django_python3_ldap.egg-info/SOURCES.txt
index 4ccf85b..2d41e69 100644
--- a/django_python3_ldap.egg-info/SOURCES.txt
+++ b/django_python3_ldap.egg-info/SOURCES.txt
@@ -17,6 +17,7 @@ django_python3_ldap.egg-info/requires.txt
 django_python3_ldap.egg-info/top_level.txt
 django_python3_ldap/management/__init__.py
 django_python3_ldap/management/commands/__init__.py
+django_python3_ldap/management/commands/ldap_clean_users.py
 django_python3_ldap/management/commands/ldap_promote.py
 django_python3_ldap/management/commands/ldap_sync_users.py
 tests/manage.py
diff --git a/django_python3_ldap/__init__.py b/django_python3_ldap/__init__.py
index ac323d9..3b4743a 100644
--- a/django_python3_ldap/__init__.py
+++ b/django_python3_ldap/__init__.py
@@ -3,4 +3,4 @@ Django LDAP user authentication backend for Python 3.
 """
 
 
-__version__ = (0, 12, 0)
+__version__ = (0, 15, 4)
diff --git a/django_python3_ldap/conf.py b/django_python3_ldap/conf.py
index 35811fb..f598759 100644
--- a/django_python3_ldap/conf.py
+++ b/django_python3_ldap/conf.py
@@ -1,6 +1,7 @@
 """
 Settings used by django-python3.
 """
+from ssl import PROTOCOL_TLS
 
 from django.conf import settings
 
@@ -35,7 +36,7 @@ class LazySettings(object):
 
     LDAP_AUTH_URL = LazySetting(
         name="LDAP_AUTH_URL",
-        default="ldap://localhost:389",
+        default=["ldap://localhost:389"],
     )
 
     LDAP_AUTH_USE_TLS = LazySetting(
@@ -43,6 +44,11 @@ class LazySettings(object):
         default=False,
     )
 
+    LDAP_AUTH_TLS_VERSION = LazySetting(
+        name="LDAP_AUTH_TLS_VERSION",
+        default=PROTOCOL_TLS,
+    )
+
     LDAP_AUTH_SEARCH_BASE = LazySetting(
         name="LDAP_AUTH_SEARCH_BASE",
         default="ou=people,dc=example,dc=com",
@@ -100,6 +106,11 @@ class LazySettings(object):
         default="",
     )
 
+    LDAP_AUTH_TEST_USER_EMAIL = LazySetting(
+        name="LDAP_AUTH_TEST_USER_EMAIL",
+        default=""
+    )
+
     LDAP_AUTH_TEST_USER_PASSWORD = LazySetting(
         name="LDAP_AUTH_TEST_USER_PASSWORD",
         default="",
diff --git a/django_python3_ldap/ldap.py b/django_python3_ldap/ldap.py
index cd8b0ab..a45e5e8 100644
--- a/django_python3_ldap/ldap.py
+++ b/django_python3_ldap/ldap.py
@@ -116,17 +116,27 @@ class Connection(object):
         in settings.LDAP_AUTH_USER_LOOKUP_FIELDS.
         """
         # Search the LDAP database.
-        if self._connection.search(
+        if self.has_user(**kwargs):
+            return self._get_or_create_user(self._connection.response[0])
+        logger.warning("LDAP user lookup failed")
+        return None
+
+    def has_user(self, **kwargs):
+        """
+        Returns True if the user with the given identifier exists.
+
+        The user identifier should be keyword arguments matching the fields
+        in settings.LDAP_AUTH_USER_LOOKUP_FIELDS.
+        """
+        # Search the LDAP database.
+        return self._connection.search(
             search_base=settings.LDAP_AUTH_SEARCH_BASE,
             search_filter=format_search_filter(kwargs),
             search_scope=ldap3.SUBTREE,
             attributes=ldap3.ALL_ATTRIBUTES,
             get_operational_attributes=True,
             size_limit=1,
-        ):
-            return self._get_or_create_user(self._connection.response[0])
-        logger.warning("LDAP user lookup failed")
-        return None
+        )
 
 
 @contextmanager
@@ -150,20 +160,41 @@ def connection(**kwargs):
     if kwargs:
         password = kwargs.pop("password")
         username = format_username(kwargs)
+    # Build server pool
+    server_pool = ldap3.ServerPool(None, ldap3.RANDOM, active=True, exhaust=5)
+    auth_url = settings.LDAP_AUTH_URL
+    if not isinstance(auth_url, list):
+        auth_url = [auth_url]
+    for u in auth_url:
+        # Include SSL / TLS, if requested.
+        server_args = {
+            "allowed_referral_hosts": [("*", True)],
+            "get_info": ldap3.NONE,
+            "connect_timeout": settings.LDAP_AUTH_CONNECT_TIMEOUT,
+        }
+        if settings.LDAP_AUTH_USE_TLS:
+            server_args["tls"] = ldap3.Tls(
+                ciphers="ALL",
+                version=settings.LDAP_AUTH_TLS_VERSION,
+            )
+        server_pool.add(
+            ldap3.Server(
+                u,
+                **server_args,
+            )
+        )
     # Connect.
     try:
+        connection_args = {
+            "user": username,
+            "password": password,
+            "auto_bind": False,
+            "raise_exceptions": True,
+            "receive_timeout": settings.LDAP_AUTH_RECEIVE_TIMEOUT,
+        }
         c = ldap3.Connection(
-            ldap3.Server(
-                settings.LDAP_AUTH_URL,
-                allowed_referral_hosts=[("*", True)],
-                get_info=ldap3.NONE,
-                connect_timeout=settings.LDAP_AUTH_CONNECT_TIMEOUT,
-            ),
-            user=username,
-            password=password,
-            auto_bind=False,
-            raise_exceptions=True,
-            receive_timeout=settings.LDAP_AUTH_RECEIVE_TIMEOUT,
+            server_pool,
+            **connection_args,
         )
     except LDAPException as ex:
         logger.warning("LDAP connect failed: {ex}".format(ex=ex))
@@ -176,18 +207,22 @@ def connection(**kwargs):
             c.start_tls(read_server_info=False)
         # Perform initial authentication bind.
         c.bind(read_server_info=True)
+        User = get_user_model()
         # If the settings specify an alternative username and password for querying, rebind as that.
-        if (
-            (settings.LDAP_AUTH_CONNECTION_USERNAME or settings.LDAP_AUTH_CONNECTION_PASSWORD) and
-            (
-                settings.LDAP_AUTH_CONNECTION_USERNAME != username or
-                settings.LDAP_AUTH_CONNECTION_PASSWORD != password
+        settings_username = (
+            format_username(
+                {User.USERNAME_FIELD: settings.LDAP_AUTH_CONNECTION_USERNAME}
             )
+            if settings.LDAP_AUTH_CONNECTION_USERNAME
+            else None
+        )
+        settings_password = settings.LDAP_AUTH_CONNECTION_PASSWORD
+        if (settings_username or settings_password) and (
+            settings_username != username or settings_password != password
         ):
-            User = get_user_model()
             c.rebind(
-                user=format_username({User.USERNAME_FIELD: settings.LDAP_AUTH_CONNECTION_USERNAME}),
-                password=settings.LDAP_AUTH_CONNECTION_PASSWORD,
+                user=settings_username,
+                password=settings_password,
             )
         # Return the connection.
         logger.info("LDAP connect succeeded")
@@ -208,11 +243,18 @@ def authenticate(*args, **kwargs):
     in settings.LDAP_AUTH_USER_LOOKUP_FIELDS, plus a `password` argument.
     """
     password = kwargs.pop("password", None)
+    auth_user_lookup_fields = frozenset(settings.LDAP_AUTH_USER_LOOKUP_FIELDS)
+    ldap_kwargs = {
+        key: value for (key, value) in kwargs.items()
+        if key in auth_user_lookup_fields
+    }
+
     # Check that this is valid login data.
-    if not password or frozenset(kwargs.keys()) != frozenset(settings.LDAP_AUTH_USER_LOOKUP_FIELDS):
+    if not password or frozenset(ldap_kwargs.keys()) != auth_user_lookup_fields:
         return None
+
     # Connect to LDAP.
-    with connection(password=password, **kwargs) as c:
+    with connection(password=password, **ldap_kwargs) as c:
         if c is None:
             return None
-        return c.get_user(**kwargs)
+        return c.get_user(**ldap_kwargs)
diff --git a/django_python3_ldap/management/commands/ldap_clean_users.py b/django_python3_ldap/management/commands/ldap_clean_users.py
new file mode 100644
index 0000000..2b5cdba
--- /dev/null
+++ b/django_python3_ldap/management/commands/ldap_clean_users.py
@@ -0,0 +1,112 @@
+from django.contrib.auth import get_user_model
+from django.core.management.base import BaseCommand, CommandError
+from django.db import transaction
+from django.db.models import ProtectedError
+
+from django_python3_ldap import ldap
+from django_python3_ldap.conf import settings
+from django_python3_ldap.utils import group_lookup_args
+
+
+class Command(BaseCommand):
+
+    help = "Remove local user models for users not find anymore in the remote LDAP authentication server."
+
+    def add_arguments(self, parser):
+        parser.add_argument(
+            '-p',
+            '--purge',
+            action='store_true',
+            help='Purge instead of deactive local user models'
+        )
+        parser.add_argument(
+            'lookups',
+            nargs='*',
+            type=str,
+            help='A list of lookup values, matching the fields specified in LDAP_AUTH_USER_LOOKUP_FIELDS. '
+                 'If this is not provided then ALL users are concerned.'
+        )
+        parser.add_argument(
+            '--superuser',
+            action='store_true',
+            help='Handle superuser (by default, superusers are excluded)'
+        )
+        parser.add_argument(
+            '--staff',
+            action='store_true',
+            help='Handle staff user (by default,staff users are excluded)'
+        )
+
+    @staticmethod
+    def _iter_local_users(User, lookups, superuser, staff):
+        """
+        Iterates over local users. If the list of lookups is empty, then all users are returned.
+        However, if lookups are provided, User.object.get is used to clean each user found using the lookups.
+        Exclude or not superuser and or staff user.
+        """
+
+        if len(lookups) < 1:
+            for user in User.objects.filter(is_superuser=superuser,
+                                            is_staff=staff):
+                yield user
+        else:
+            for lookup in group_lookup_args(*lookups):
+                try:
+                    yield User.objects.get(**lookup,
+                                           is_superuser=superuser,
+                                           is_staff=staff)
+                except User.DoesNotExist:
+                    raise CommandError("Could not find user with lookup : {lookup}".format(
+                        lookup=lookup,
+                    ))
+
+    @staticmethod
+    def _remove(user, purge):
+        """
+        Deactivate or purge a given local user
+        """
+        if purge:
+            # Delete local user
+            try:
+                user.delete()
+            except ProtectedError as e:
+                raise CommandError("Could not purge user {user} : {e}".format(
+                    user=user,
+                    e=e
+                ))
+        else:
+            # Deactivate local user
+            user.is_active = False
+            user.save()
+
+    @transaction.atomic()
+    def handle(self, *args, **kwargs):
+        verbosity = int(kwargs.get("verbosity", 1))
+        purge = kwargs.get('purge', False)
+        lookups = kwargs.get('lookups', [])
+        superuser = kwargs.get('superuser', False)
+        staff = kwargs.get('staff', False)
+        User = get_user_model()
+        auth_kwargs = {
+            User.USERNAME_FIELD: settings.LDAP_AUTH_CONNECTION_USERNAME,
+            'password': settings.LDAP_AUTH_CONNECTION_PASSWORD
+        }
+        with ldap.connection(**auth_kwargs) as connection:
+            if connection is None:
+                raise CommandError("Could not connect to LDAP server")
+            for user in self._iter_local_users(User, lookups, superuser, staff):
+                # For each local users
+                # Check if user still exists
+                user_kwargs = {
+                    User.USERNAME_FIELD: getattr(user, User.USERNAME_FIELD)
+                }
+                if connection.has_user(**user_kwargs):
+                    # User still exists on LDAP side
+                    continue
+                # Clean user
+                self._remove(user, purge)
+                if verbosity >= 1:
+                    self.stdout.write("{action} {user}".format(
+                        action=('Purged' if purge else 'Deactivated'),
+                        user=user,
+                    ))
diff --git a/django_python3_ldap/management/commands/ldap_sync_users.py b/django_python3_ldap/management/commands/ldap_sync_users.py
index d184c3a..29a53e4 100644
--- a/django_python3_ldap/management/commands/ldap_sync_users.py
+++ b/django_python3_ldap/management/commands/ldap_sync_users.py
@@ -4,15 +4,39 @@ from django.db import transaction
 
 from django_python3_ldap import ldap
 from django_python3_ldap.conf import settings
+from django_python3_ldap.utils import group_lookup_args
 
 
 class Command(BaseCommand):
 
-    help = "Creates local user models for all users found in the remote LDAP authentication server."
+    help = "Creates local user models for users found in the remote LDAP authentication server."
+
+    def add_arguments(self, parser):
+        parser.add_argument(
+            'lookups',
+            nargs='*',
+            type=str,
+            help='A list of lookup values, matching the fields specified in LDAP_AUTH_USER_LOOKUP_FIELDS. '
+                 'If this is not provided then ALL users are synced.'
+        )
+
+    @staticmethod
+    def _iter_synced_users(connection, lookups):
+        """
+        Iterates over synced users. If the list of lookups is empty, then all users are synced using iter_users.
+        However, if lookups are provided, get_user is used to sync each user found using the lookups.
+        """
+        if len(lookups) < 1:
+            for user in connection.iter_users():
+                yield user
+        else:
+            for lookup in group_lookup_args(*lookups):
+                yield connection.get_user(**lookup)
 
     @transaction.atomic()
     def handle(self, *args, **kwargs):
         verbosity = int(kwargs.get("verbosity", 1))
+        lookups = kwargs.get('lookups', [])
         User = get_user_model()
         auth_kwargs = {
             User.USERNAME_FIELD: settings.LDAP_AUTH_CONNECTION_USERNAME,
@@ -21,7 +45,7 @@ class Command(BaseCommand):
         with ldap.connection(**auth_kwargs) as connection:
             if connection is None:
                 raise CommandError("Could not connect to LDAP server")
-            for user in connection.iter_users():
+            for user in self._iter_synced_users(connection, lookups):
                 if verbosity >= 1:
                     self.stdout.write("Synced {user}".format(
                         user=user,
diff --git a/django_python3_ldap/tests.py b/django_python3_ldap/tests.py
index 91e53b7..dc7cbe6 100644
--- a/django_python3_ldap/tests.py
+++ b/django_python3_ldap/tests.py
@@ -4,7 +4,7 @@ from __future__ import unicode_literals
 from unittest import skipUnless, skip
 from io import StringIO
 
-from django.test import TestCase
+from django.test import TestCase, override_settings
 from django.contrib.auth import authenticate
 from django.contrib.auth.models import User
 from django.conf import settings as django_settings
@@ -57,6 +57,20 @@ class TestLdap(TestCase):
             )
             self.assertEqual(user, None)
 
+    def testHasUserKwargsSuccess(self):
+        with connection() as c:
+            exist = c.has_user(
+                username=settings.LDAP_AUTH_TEST_USER_USERNAME,
+            )
+            self.assertEqual(exist, True)
+
+    def testHasUserKwargsIncorrectUsername(self):
+        with connection() as c:
+            exist = c.has_user(
+                username="bad" + settings.LDAP_AUTH_TEST_USER_USERNAME,
+            )
+            self.assertEqual(exist, False)
+
     # Authentication tests.
 
     def testAuthenticateUserSuccess(self):
@@ -81,6 +95,15 @@ class TestLdap(TestCase):
         )
         self.assertEqual(user, None)
 
+    def testAuthenticateWithAdditonalKwargsUserSuccess(self):
+        user = authenticate(
+            username=settings.LDAP_AUTH_TEST_USER_USERNAME,
+            password=settings.LDAP_AUTH_TEST_USER_PASSWORD,
+            another_kwarg="whatever",
+        )
+        self.assertIsInstance(user, User)
+        self.assertEqual(user.username, settings.LDAP_AUTH_TEST_USER_USERNAME)
+
     def testRepeatedUserAuthenticationDoestRecreateUsers(self):
         user_1 = authenticate(
             username=settings.LDAP_AUTH_TEST_USER_USERNAME,
@@ -132,6 +155,20 @@ class TestLdap(TestCase):
         call_command("ldap_sync_users", verbosity=0)
         self.assertGreater(User.objects.count(), 0)
 
+    def testSyncUserWithLookup(self):
+        call_command("ldap_sync_users", settings.LDAP_AUTH_TEST_USER_USERNAME, verbosity=0)
+        self.assertEqual(User.objects.filter(username=settings.LDAP_AUTH_TEST_USER_USERNAME).count(), 1)
+
+    @override_settings(LDAP_AUTH_USER_LOOKUP_FIELDS=('username', 'email'))
+    def testSyncUserWithMultipleLookups(self):
+        call_command(
+            "ldap_sync_users",
+            settings.LDAP_AUTH_TEST_USER_USERNAME,
+            settings.LDAP_AUTH_TEST_USER_EMAIL,
+            verbosity=0
+        )
+        self.assertEqual(User.objects.filter(username=settings.LDAP_AUTH_TEST_USER_USERNAME).count(), 1)
+
     def testSyncUsersCommandOutput(self):
         out = StringIO()
         call_command("ldap_sync_users", verbosity=1, stdout=out)
@@ -224,3 +261,154 @@ class TestLdap(TestCase):
 
         with self.settings(LDAP_AUTH_SYNC_USER_RELATIONS='django.contrib.auth.get_user_model'):
             self.assertTrue(callable(import_func(settings.LDAP_AUTH_SYNC_USER_RELATIONS)))
+
+    def testCleanUsersDeactivate(self):
+        """
+        ldap_clean_users management command test
+        """
+        from django.contrib.auth import get_user_model
+        User = get_user_model()
+        _username = "nonldap{user}".format(user=settings.LDAP_AUTH_TEST_USER_USERNAME)
+        user = User.objects.create_user(
+            _username,
+            "nonldap{mail}".format(mail=settings.LDAP_AUTH_TEST_USER_EMAIL),
+            settings.LDAP_AUTH_TEST_USER_PASSWORD)
+        user.save()
+        user_count_1 = User.objects.count()
+        self.assertEqual(User.objects.get(username=_username).is_active, True)
+        call_command("ldap_clean_users", verbosity=0)
+        user_count_2 = User.objects.count()
+        self.assertEqual(user_count_1, user_count_2)
+        self.assertEqual(User.objects.get(username=_username).is_active, False)
+
+        """
+        Test with lookup
+        """
+        # Reactivate user
+        user = User.objects.get(username=_username)
+        user.is_active = True
+        user.save()
+        # Create second user
+        _usernameLookup = "nonldaplookup{user}".format(user=settings.LDAP_AUTH_TEST_USER_USERNAME)
+        user = User.objects.create_user(
+            _usernameLookup,
+            "nonldaplookup{mail}".format(mail=settings.LDAP_AUTH_TEST_USER_EMAIL),
+            settings.LDAP_AUTH_TEST_USER_PASSWORD)
+        user.save()
+        user_count_1 = User.objects.count()
+        self.assertEqual(User.objects.get(username=_usernameLookup).is_active, True)
+        # Clean second user
+        call_command("ldap_clean_users", _usernameLookup, verbosity=0)
+        user_count_2 = User.objects.count()
+        self.assertEqual(user_count_1, user_count_2)
+        self.assertEqual(User.objects.get(username=_usernameLookup).is_active, False)
+        self.assertEqual(User.objects.get(username=_username).is_active, True)
+        # Reactivate second user
+        user = User.objects.get(username=_usernameLookup)
+        user.is_active = True
+        user.save()
+        # Clean first user
+        call_command("ldap_clean_users", _username, verbosity=0)
+        self.assertEqual(User.objects.get(username=_username).is_active, False)
+        self.assertEqual(User.objects.get(username=_usernameLookup).is_active, True)
+        # Lookup a non existing user (raise a CommandError)
+        with self.assertRaises(CommandError):
+            call_command("ldap_clean_users", 'doesnonexist', verbosity=0)
+
+        """
+        Test with superuser
+        """
+        # Reactivate first user and promote to superuser
+        user = User.objects.get(username=_username)
+        user.is_active = True
+        user.is_superuser = True
+        user.save()
+        # Reactivate second user
+        user = User.objects.get(username=_usernameLookup)
+        user.is_active = True
+        user.save()
+        call_command("ldap_clean_users", superuser=False, verbosity=0)
+        self.assertEqual(User.objects.get(username=_username).is_active, True)
+        self.assertEqual(User.objects.get(username=_usernameLookup).is_active, False)
+        call_command("ldap_clean_users", superuser=True, verbosity=0)
+        self.assertEqual(User.objects.get(username=_username).is_active, False)
+
+        """
+        Test with staff user
+        """
+        # Reactivate first user and promote to staff
+        user = User.objects.get(username=_username)
+        user.is_active = True
+        user.is_superuser = False
+        user.is_staff = True
+        user.save()
+        # Reactivate second user
+        user = User.objects.get(username=_usernameLookup)
+        user.is_active = True
+        user.save()
+        call_command("ldap_clean_users", staff=False, verbosity=0)
+        self.assertEqual(User.objects.get(username=_username).is_active, True)
+        self.assertEqual(User.objects.get(username=_usernameLookup).is_active, False)
+        call_command("ldap_clean_users", staff=True, verbosity=0)
+        self.assertEqual(User.objects.get(username=_username).is_active, False)
+
+    def testCleanUsersPurge(self):
+        """
+        ldap_clean_users management command test with purge argument
+        """
+        from django.contrib.auth import get_user_model
+        User = get_user_model()
+        user = User.objects.create_user(
+            "nonldap{user}".format(user=settings.LDAP_AUTH_TEST_USER_USERNAME),
+            "nonldap{mail}".format(mail=settings.LDAP_AUTH_TEST_USER_EMAIL),
+            settings.LDAP_AUTH_TEST_USER_PASSWORD)
+        user.save()
+        user_count_1 = User.objects.count()
+        call_command("ldap_clean_users", verbosity=0, purge=True)
+        user_count_2 = User.objects.count()
+        self.assertGreater(user_count_1, user_count_2)
+
+    def testCleanUsersCommandOutput(self):
+        # Test without purge
+        out = StringIO()
+        from django.contrib.auth import get_user_model
+        User = get_user_model()
+        user = User.objects.create_user(
+            "nonldap{user}".format(user=settings.LDAP_AUTH_TEST_USER_USERNAME),
+            "nonldap{mail}".format(mail=settings.LDAP_AUTH_TEST_USER_EMAIL),
+            settings.LDAP_AUTH_TEST_USER_PASSWORD)
+        user.save()
+        call_command("ldap_clean_users", stdout=out, verbosity=1)
+        rows = out.getvalue().split("\n")[:-1]
+        self.assertEqual(len(rows), 1)
+        for row in rows:
+            self.assertRegex(row, r'^Deactivated ')
+        # Reset for next test
+        user.delete()
+        out.truncate(0)
+        out.seek(0)
+        # Test with purge
+        user = User.objects.create_user(
+            "nonldap{user}".format(user=settings.LDAP_AUTH_TEST_USER_USERNAME),
+            "nonldap{mail}".format(mail=settings.LDAP_AUTH_TEST_USER_EMAIL),
+            settings.LDAP_AUTH_TEST_USER_PASSWORD)
+        user.save()
+        call_command("ldap_clean_users", stdout=out, verbosity=1, purge=True)
+        rows = out.getvalue().split("\n")[:-1]
+        self.assertEqual(len(rows), 1)
+        for row in rows:
+            self.assertRegex(row, r'^Purged ')
+
+    def testReCleanUsersDoesntRecreateUsers(self):
+        from django.contrib.auth import get_user_model
+        User = get_user_model()
+        user = User.objects.create_user(
+            "nonldap{user}".format(user=settings.LDAP_AUTH_TEST_USER_USERNAME),
+            "nonldap{mail}".format(mail=settings.LDAP_AUTH_TEST_USER_EMAIL),
+            settings.LDAP_AUTH_TEST_USER_PASSWORD)
+        user.save()
+        call_command("ldap_clean_users", verbosity=0, purge=True)
+        user_count_1 = User.objects.count()
+        call_command("ldap_clean_users", verbosity=0, purge=True)
+        user_count_2 = User.objects.count()
+        self.assertEqual(user_count_1, user_count_2)
diff --git a/django_python3_ldap/utils.py b/django_python3_ldap/utils.py
index e60b42e..44f5053 100644
--- a/django_python3_ldap/utils.py
+++ b/django_python3_ldap/utils.py
@@ -4,7 +4,13 @@ Some useful LDAP utilities.
 
 import re
 import binascii
-from django.utils.encoding import force_text
+import itertools
+
+try:
+    from django.utils.encoding import force_str
+except ImportError:
+    from django.utils.encoding import force_text as force_str
+
 from django.utils.module_loading import import_string
 
 from django_python3_ldap.conf import settings
@@ -25,8 +31,8 @@ def clean_ldap_name(name):
     """
     return re.sub(
         r'[^a-zA-Z0-9 _\-.@:*]',
-        lambda c: "\\" + force_text(binascii.hexlify(c.group(0).encode("latin-1", errors="ignore"))).upper(),
-        force_text(name),
+        lambda c: "\\" + force_str(binascii.hexlify(c.group(0).encode("latin-1", errors="ignore"))).upper(),
+        force_str(name),
     )
 
 
@@ -121,3 +127,19 @@ def format_search_filters(ldap_fields):
         for field_name, field_value
         in ldap_fields.items()
     ]
+
+
+def group_lookup_args(*args):
+    """
+    Yields the given series of arguments as chunks, formatted as dictionaries, which represent field lookups
+    according to the LDAP_AUTH_USER_LOOKUP_FIELDS setting.
+
+    Based on the itertools grouper recipe: https://docs.python.org/3/library/itertools.html#itertools-recipes
+    """
+    fields_len = len(settings.LDAP_AUTH_USER_LOOKUP_FIELDS)
+    fields = [iter(args)] * fields_len
+    for chunk in itertools.zip_longest(*fields, fillvalue=None):
+        lookup = {}
+        for i in range(fields_len):
+            lookup[settings.LDAP_AUTH_USER_LOOKUP_FIELDS[i]] = chunk[i]
+        yield lookup
diff --git a/setup.py b/setup.py
index 6a0ad6d..0dd82fc 100644
--- a/setup.py
+++ b/setup.py
@@ -6,11 +6,16 @@ from django_python3_ldap import __version__
 version_str = ".".join(str(n) for n in __version__)
 
 
+with open("README.rst", "r", encoding="utf-8") as fh:
+    long_description = fh.read()
+
+
 setup(
     name="django-python3-ldap",
     version=version_str,
     license="BSD",
     description="Django LDAP user authentication backend for Python 3.",
+    long_description=long_description,
     author="Dave Hall",
     author_email="dave@etianen.com",
     url="https://github.com/etianen/django-python3-ldap",
diff --git a/tests/django_python3_ldap_test/settings.py b/tests/django_python3_ldap_test/settings.py
index 616ac10..f444aac 100644
--- a/tests/django_python3_ldap_test/settings.py
+++ b/tests/django_python3_ldap_test/settings.py
@@ -29,7 +29,7 @@ ALLOWED_HOSTS = []
 
 # LDAP auth settings.
 
-LDAP_AUTH_URL = "ldap://ldap.forumsys.com:389"
+LDAP_AUTH_URL = ["ldap://ldap.forumsys.com:389"]
 
 LDAP_AUTH_SEARCH_BASE = "dc=example,dc=com"
 
@@ -42,6 +42,8 @@ AUTHENTICATION_BACKENDS = (
 
 LDAP_AUTH_TEST_USER_USERNAME = "tesla"
 
+LDAP_AUTH_TEST_USER_EMAIL = "tesla@ldap.forumsys.com"
+
 LDAP_AUTH_TEST_USER_PASSWORD = "password"
 
 

More details

Full run details