loading...

Dynamic Group based LDAP authentication with django and RegEx

lquenti profile image Lars Quentin ・5 min read

I had the following problem:

  • Authentication over Active Directory through LDAP
  • just certain groups with a specific suffix were allowed to login
  • There was no way to know how many of those groups exist
  • Later views depended on those groups, therefore they had to be mapped to django groups

tl;dr: If you just want the full code, feel free to jump to the end, but at least leave a upvote. :D

Prerequisites (You already know them)

Besides django, you need django-auth-ldap as well

pip3 install django-auth-ldap

with that installed, we are ready to go!

Writing our own basic LDAP Backend

At first, let's create our own Backend, by extending the default LDAPBackend from django-auth-ldap.
For the official documentation, see here.

<name_of_main_application>/ldap.py

class GroupLDAPBackend(LDAPBackend):
  pass

Now before we do further customisation, let's just fill it with our parameters for the Search/Bind authentication:

<name_of_main_application>/ldap.py

import ldap

from django_auth_ldap.backend import LDAPBackend
from django_auth_ldap.config import LDAPSearch

class GroupLDAPBackend(LDAPBackend):
  default_settings = {
    # All those settings are overwriting base class values
    "SERVER_URI": "ldaps://url.to.our.dc.domain.com",
    "CACHE_TIMEOUT": 3600,

    # Those settings should probably be overwritten by the settings.py
    "BIND_DN": "",
    "BIND_PASSWORD": "",
    "USER_SEARCH": LDAPSearch(
      "OU=<OU_OF_USER_LOGGING_IN>, OU=..., DC=..., DC=domain, DC=com",
      # If you know, that all your users logging in are on that 
      # exact ou depth specified above, you can get better performance
      # by using ldap.SCOPE_BASE or for that depth and its direct 
      # children ldap.SCOPE_ONELEVEL, see python-ldap documentation for
      # more.
      ldap.SCOPE_SUBTREE,
      "(uid=%(user)s)"
    ),
  }

Those settings are the same as outside of the class with the AUTH_LDAP Prefix.

Here a short explaination of all those parameters:

Now we can add the ldap authentication in the settings.py, together with the other settings.

<name_of_main_application>/settings.py

...
AUTH_LDAP_BIND_DN = "CN=<user_which_handles_all_binds>, OU=its_ou, ..., DC=domain, DC=com"
AUTH_LDAP_BIND_PASSWORD = "password_for_that_account"

...

AUTHENTICATION_BACKENDS = [
  "<name_of_main_application>.ldap.GroupLDAPBackend",
  # And if wanted, the django one as well
  "django.contrib.auth.backends.ModelBackend",
]

In production, instead of hard coding (and even worse, committing) the credentials please export them into your linux environment and use os.getenv(KEY). This way, you don't save any critical information in your repository.

Also, if you have any problems finding the credentials, you can enable logging as well with

<name_of_main_application>/settings.py

LOGGING = {
  "version": 1,
  "disable_existing_loggers": False,
  "handlers": {"console": {"class": "logging.StreamHandler"}},
  "loggers": {"django_auth_ldap": {"level": "DEBUG", "handlers": ["console"]}},
}

This should already be enough to get the normal authentication working!
Now we just have to implement the group-based authentication.
Let's get right into the "fun"!

Extending our badass Backend with Group Authentication

Let's say, we want to allow any user, which has a group with the suffix "django".

  • "some_group_django": ✔️
  • "admindjango": ✔️
  • "djangogrp": ❌

For educational purposes, they all groups are located in
OU=Groups, DC=domain, DC=com
and its sub organisational units.

With that, we can extend our backend with our own RegEx-Setting as well as GROUP_SEARCH and GROUP_TYPE

import re
import ldap

from django_auth_ldap.backend import LDAPBackend
from django_auth_ldap.config import LDAPSearch, GroupOfNamesType

class GroupLDAPBackend(LDAPBackend):
  default_settings = {
    # Our new settings
    # Lets call the RegEx GROUP_REGEX for simplicity
    "GROUP_REGEX": re.compile(r"^*django$"),
    "GROUP_SEARCH": LDAPSearch(
      "OU=Groups, DC=domain, DC=com",
      ldap.SCOPE_SUBTREE,
      "(cn=*arch)"
    ),
    "GROUP_TYPE": GroupOfNamesType(),

    # All those settings are overwriting base class values
    "SERVER_URI": "ldaps://url.to.our.dc.domain.com",
    "CACHE_TIMEOUT": 3600,

    # Those settings should probably be overwritten by the settings.py
    "BIND_DN": "",
    "BIND_PASSWORD": "",
    "USER_SEARCH": LDAPSearch(
      "OU=<OU_OF_USER_LOGGING_IN>, OU=..., DC=..., DC=domain, DC=com",
      # If you know, that all your users logging in are on that 
      # exact ou depth specified above, you can get better performance
      # by using ldap.SCOPE_BASE or for that depth and its direct 
      # children ldap.SCOPE_ONELEVEL, see python-ldap documentation for
      # more.
      ldap.SCOPE_SUBTREE,
      "(uid=%(user)s)"
    ),
  }

Finally, we can now override the LDAPBackend.authenticate_ldap_user with our own version!

I'll use some type hinting here, since the Documentation for _LDAPUser is more or less nonexisting so that you know where to find the source code.

# Just needed for type hinting lol
from django_auth_ldap.backend import _LDAPUser

class GroupLDAPBackend(LDAPBackend):
  default_settings = {
    ...
  }

  def authenticate_ldap_user(self, ldap_user: _LDAPUser, password: str):
    # This is the default implemented authentication
    user = ldap_user.authenticate(password)

    # If the authentication was denied, we have to return None
    if not user:
      return None

    ldap_groups = ldap_user.group_names
    ldap_groups = {x for x in ldap_groups if self.settings.GROUP_REGEX.match(x)}

    if len(ldap_groups) == 0:
      return None
    return user

I mean, at least for me, that was way easier than expected.
Lastly, we just have to create the django groups from the ldap ones.

Creating Django Groups on the fly!

Luckily, django has the get_or_create pattern, which makes things a lot easier.

from django.contrib.auth.models import Group

class GroupLDAPBackend(LDAPBackend):
  ...
  def create_groups_and_assign_user_to_it(self, user, ldap_groups):
    for group_name in ldap_groups:
      django_group, was_created = Group.objects.get_or_create(name=group_name)
      django_group.user_set.add(user)

That's it! If it helped you or you have further questions, feel free to hook me up on twitter.
Also, if there is a even neater way to implement it, please tell me and I'll correct it.

The whole source code with some logging

<name_of_main_application>/settings.py

...
AUTH_LDAP_BIND_DN = "CN=<user_which_handles_all_binds>, OU=its_ou, ..., DC=domain, DC=com"
AUTH_LDAP_BIND_PASSWORD = "password_for_that_account"

AUTHENTICATION_BACKENDS = [
  "<name_of_main_application>.ldap.GroupLDAPBackend",
  # And if wanted, the django one as well
  "django.contrib.auth.backends.ModelBackend",
]

LOGGING = {
  "version": 1,
  "disable_existing_loggers": False,
  "handlers": {"console": {"class": "logging.StreamHandler"}},
  "loggers": {"django_auth_ldap": {"level": "DEBUG", "handlers": ["console"]}},
}

<name_of_main_application>/ldap.py

import re
import ldap
import logging

from django_auth_ldap.backend import LDAPBackend, _LDAPUser
from django_auth_ldap.config import LDAPSearch, GroupOfNamesType

from django.contrib.auth.models import Group

class GroupLDAPBackend(LDAPBackend):
  default_settings = {
    # Our new settings
    # Lets call the RegEx GROUP_REGEX for simplicity
    "GROUP_REGEX": re.compile(r"^*django$"),
    "GROUP_SEARCH": LDAPSearch(
      "OU=Groups, DC=domain, DC=com",
      ldap.SCOPE_SUBTREE,
      "(cn=*arch)"
    ),
    "GROUP_TYPE": GroupOfNamesType(),

    # All those settings are overwriting base class values
    "SERVER_URI": "ldaps://url.to.our.dc.domain.com",
    "CACHE_TIMEOUT": 3600,

    # Those settings should probably be overwritten by the settings.py
    "BIND_DN": "",
    "BIND_PASSWORD": "",
    "USER_SEARCH": LDAPSearch(
      "OU=<OU_OF_USER_LOGGING_IN>, OU=..., DC=..., DC=domain, DC=com",
      # If you know, that all your users logging in are on that 
      # exact ou depth specified above, you can get better performance
      # by using ldap.SCOPE_BASE or for that depth and its direct 
      # children ldap.SCOPE_ONELEVEL, see python-ldap documentation for
      # more.
      ldap.SCOPE_SUBTREE,
      "(uid=%(user)s)"
    ),
  }
  def authenticate_ldap_user(self, ldap_user: _LDAPUser, password: str):
    # This is the default implemented authentication
    user = ldap_user.authenticate(password)

    # If the authentication was denied, we have to return None
    if not user:
      return None

    ldap_groups = ldap_user.group_names
    ldap_groups = {x for x in ldap_groups if self.settings.GROUP_REGEX.match(x)}

    if len(ldap_groups) == 0:
      return None

    self.create_groups_and_assign_user_to_it(user, ldap_groups)
    return user

  def create_groups_and_assign_user_to_it(self, user, ldap_groups):
    for group_name in ldap_groups:
      django_group, was_created = Group.objects.get_or_create(name=group_name)
      django_group.user_set.add(user)

Posted on by:

lquenti profile

Lars Quentin

@lquenti

CompSci Major while working 2 jobs as a software developer and IT support

Discussion

markdown guide