Dynamic Group based LDAP authentication with django and RegEx
Tags: djangoauthldap, ldap, django, activedirectory
Published: 2020-03-01 20:11:22

---
title: Dynamic Group based LDAP authentication with django and RegEx
published: true
description: configuring django to authenticate against LDAP or Active Directory depending on groups.
tags: djangoauthldap, ldap, django, activedirectory
---

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](https://django-auth-ldap.readthedocs.io/en/latest/custombehavior.html).

`<name_of_main_application>/ldap.py`
```python
class GroupLDAPBackend(LDAPBackend):
  pass
```

Now before we do further customisation, let's just fill it with our parameters for the [Search/Bind](https://django-auth-ldap.readthedocs.io/en/latest/authentication.html) authentication:

`<name_of_main_application>/ldap.py`
```python
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:
- [SERVER_URI](https://django-auth-ldap.readthedocs.io/en/latest/reference.html#auth-ldap-server-uri): The URL to the domain controller
- [CACHE_TIMEOUT](https://django-auth-ldap.readthedocs.io/en/latest/reference.html#auth-ldap-cache-timeout): How long the data from the LDAP Server is cached. Here set to one hour.
- [BIND_DN](https://django-auth-ldap.readthedocs.io/en/latest/reference.html#auth-ldap-bind-dn): The [fully qualified Distinguished Name](https://ldapwiki.com/wiki/Distinguished%20Names), which means that dc's/ou's cant be before the cn logging in mustn't be omitted. You'll see an example later.
- [USER_SEARCH](https://django-auth-ldap.readthedocs.io/en/latest/authentication.html#search-bind): The search, which will be performed on in the authentication process

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

`<name_of_main_application>/settings.py`
```python
...
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`
```python
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](https://django-auth-ldap.readthedocs.io/en/latest/groups.html#finding-groups) 

```python
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=*django)"
    ),
    "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](https://docs.python.org/3/library/typing.html) here, since the Documentation for `_LDAPUser` is more or less nonexisting so that you know where to find the source code.

```python
# 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.

```python
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`
```python
...
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`
```python
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=*django)"
    ),
    "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)
```