Authenticating and populating users in Django using a Windows Active Directory and SASL

I’ve been trying to get some Django stuff running that can securely authenticate users against Windows Active Directory and also populate some info (first/last name, email address, maybe groups etc.). There are lots of resources out there but nothing was fully complete or modern and it took me some figuring/hacking to get it done.

Resources I found include:

  • django-auth-ldap — the normal LDAP plugin. Problem: It does not natively support SASL and simple binds would send clear-text passwords. I think normal people would just activate TLS in this case but I didn’t want to do that
  • A relevant SO post — with an answer linking to a useful snippet that no longer works on recent django versions.
  • django-auth-ldap-ad — Someone’s entire different ldap plugin made specifically for this purpose. But it isn’t being maintained, is GPL-2, and doesn’t work directly in Python 3 or recent django.

I worked hard on that last one and actually got it running in Python 3, Django 2.1. But it still needed work (Needed a different signature in the `authenticate` method, bytes vs. strings issues, etc.).

In the end I made a custom subclass of the `LDAPBackend` in django-auth-ldap.

Activate the custom subclass and specify some settings:

from django_auth_ldap.config import LDAPSearch
import ldap

AUTHENTICATION_BACKENDS = [
    'nick.auth.LDAPSaslBackend',
    #'django_auth_ldap_ad.backend.LDAPBackend',
    'django.contrib.auth.backends.ModelBackend',
]

AUTH_LDAP_SERVER_URI = "ldap://server.domain.tld"
AUTH_LDAP_SEARCH_DN = "dc=domain,dc=tld"

AUTH_LDAP_CONNECTION_OPTIONS = {
    ldap.OPT_REFERRALS: 0  # important to prevent constant disconnects
}

AUTH_LDAP_BIND_DN = ""
AUTH_LDAP_BIND_PASSWORD = ""
AUTH_LDAP_USER_SEARCH = LDAPSearch("dc=domain,dc=tld",
    ldap.SCOPE_SUBTREE, "(sAMAccountName=%(user)s)")
AUTH_LDAP_USER_ATTR_MAP = {"first_name": "givenName", "last_name": "sn", "email":"mail"}

Make the custom authenticator subclass in auth.py. Note that this is heavily based on the original class in the library. I did a few things with this that weren’t awesome but worked:

  • Copy/pasted lots of code with _LDAPUser switched to my new subclass of that user. A better solution would be to change the library code to use a factory method and just update that. This subclass would shrink dramatically with that change.
  • Overrode the attempt to know the DN before authenticating. SASL works fine with just the username.
  • Eliminated re-binding when getting the connection. It was grabbing that through a property and failing authentication all the time because the auth_tokens weren’t cached anywhere. Another option would have been to cache the auth tokens.
import logging

from django_auth_ldap.backend import LDAPBackend, _LDAPUser
import ldap
import ldap.sasl

logger = logging.getLogger('nick')

class LDAPSaslBackend(LDAPBackend):
        """Specialized SASL backend for Kerberos LDAP.

        See https://django-auth-ldap.readthedocs.io/en/latest/install.html

        """
        def authenticate(self, request, username=None, password=None, **kwargs):
                logger.info('Trying to authenticate %s. ' % (username))
                if password or self.settings.PERMIT_EMPTY_PASSWORD:
                    ldap_user = SASLUser(self, username=username.strip(), request=request)
                    user = self.authenticate_ldap_user(ldap_user, password)
                else:
                    logger.debug("Rejecting empty password for {}".format(username))
                    user = None

                return user

        def get_user(self, user_id):
                user = None

                logger.info('Getting %s' % user_id)
                try:
                    user = self.get_user_model().objects.get(pk=user_id)
                    SASLUser(self, user=user)  # This sets user.ldap_user
                except ObjectDoesNotExist:
                    pass

                return user

        def get_group_permissions(self, user, obj=None):
                if not hasattr(user, "ldap_user") and self.settings.AUTHORIZE_ALL_USERS:
                    SASLUser(self, user=user)  # This sets user.ldap_user

                if hasattr(user, "ldap_user"):
                    permissions = user.ldap_user.get_group_permissions()
                else:
                    permissions = set()

                return permissions

        def populate_user(self, username):
                logger.info('populating  %s' % username)
                ldap_user = SASLUser(self, username=username)
                user = ldap_user.populate_user()

                return user

        def authenticate_ldap_user(self, ldap_user, password):
                """
                Returns an authenticated Django user or None.
                """
                return ldap_user.authenticate(password)


class SASLUser(_LDAPUser):
        def _authenticate_user_dn(self, password):
            """
            Binds to the LDAP server with the user's DN and password. Raises
            AuthenticationFailed on failure.
            """
            try:
                sticky = self.settings.BIND_AS_AUTHENTICATING_USER
                self._bind_as(None, password, sticky=sticky)
            except ldap.INVALID_CREDENTIALS:
                raise self.AuthenticationFailed("user DN/password rejected by LDAP server.")


        def _bind_as(self, bind_dn, bind_password, sticky=False):
                conn = self._get_connection()
                bind_dn = self._username 
                auth_tokens = ldap.sasl.digest_md5(bind_dn, bind_password)      
                conn.sasl_interactive_bind_s(bind_dn, auth_tokens)
                self._connection_bound = sticky
     
        @property
        def connection(self):
            return self._get_connection()
   

      

And boom, it works! Users get authenticated and name/email gets populated. Quite glorious. I haven’t messed with groups much but that’s the next step.

Moving forward, I’ll consider cleaning this up and contributing it to django-auth-ldap.

 

Leave a Reply

Your email address will not be published. Required fields are marked *