DEV Community

Lars Quentin
Lars Quentin

Posted on • Edited on

Dynamic Group based LDAP authentication with django and RegEx

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)"
    ),
  }
Enter fullscreen mode Exit fullscreen mode

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",
]
Enter fullscreen mode Exit fullscreen mode

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"]}},
}

Enter fullscreen mode Exit fullscreen mode

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=*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)"
    ),
  }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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"]}},
}

Enter fullscreen mode Exit fullscreen mode

<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=*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)
Enter fullscreen mode Exit fullscreen mode

Top comments (5)

Collapse
 
selva316 profile image
Selva

Hi Lars,

The below code is connecting only with local user data(Data available in superuser). It's not fetching from LDAP. Could you please help me with view.py code

View.py
def login(request):

try:
    user = authenticate( username= request.REQUEST.get('email'), password= request.REQUEST.get('password'))
    if user is not None:
        return render(request, 'shopping/welcome.html')
    else:
        return render(request, 'shopping/failure.html')
except:
    return render(request, 'shopping/failure.html')
Enter fullscreen mode Exit fullscreen mode
Collapse
 
arezazadeh profile image
Ahmad

question, if i want to attach the group name to user's session, so i can grab it in the template and use it to show or not show some content based on that, how do i accomplish that? today i can do it because i have local user in django db, but if i integrate with corporate AD, then i lose that flexibility. i wanna say, if you are part of this group you will not be able to see these content, or you will not be able to access these features, and so on.

Collapse
 
lquenti profile image
Lars Quentin

If I understand you correctly:

Hide Content:

  • Dynamically create the group as done above.
  • Pass the groups to the view renderer
  • Exclude the code based on conditionals with the django templating engine / Jinja2

Permit View:
Probably the cleanest way would be to create a decorator with functools.wraps.
In this decorator you'd get the group from the request and then permit or deny based on the group names.

Collapse
 
vangelisp profile image
vangelis-p

In key GROUP_SEARCH, you have cn=*arch, what is the functionality of this ?
"GROUP_SEARCH": LDAPSearch(
"OU=Groups, DC=domain, DC=com",
ldap.SCOPE_SUBTREE,
"(cn=*arch)"
)

Collapse
 
lquenti profile image
Lars Quentin

Oh god, that's embarassing. This should be *django, I accidentally copied it from my usage...

This is the LDAP Query

"Satisfy that the Common Name matches the RegEx *arch"

or

"Satify that the Common Name has the Suffix arch"

I changed it, thanks!