Replace django-auth-ldap and python-ldap with an ldap3 backend

python-ldap is a C extension that needs the OpenLDAP headers to build
(and needed a workaround in the Dockerfile). ldap3, which the app
already used for all writes, is pure Python.

dal.backends.LdapBackend keeps the django-auth-ldap behaviour: search
the uid in every LDAPSEARCH base as the admin DN, require exactly one
match, log in with the user's DN, and mirror givenName, sn and mail into
the local Django user. Empty passwords are rejected (LDAP treats them
as anonymous logins, which succeed).

LDAP_USE_TLS now applies StartTLS to every connection, including the
admin connection used for writes, not just logins.
This commit is contained in:
sajeyks 2026-11-06 15:15:03 +03:00
commit ab45535f38
5 changed files with 112 additions and 15 deletions

73
dal/backends.py Normal file
View file

@ -0,0 +1,73 @@
"""
LDAP authentication with ldap3, replacing django-auth-ldap and python-ldap.
Same behaviour as the old django-auth-ldap setup: the uid is searched (as
the admin DN) in every base of LDAPSEARCH, exactly one match is required,
the user logs in with their own DN, and a local Django user mirrors the
names and email from LDAP (AUTH_LDAP_USER_ATTR_MAP).
"""
import logging
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend
from ldap3.core.exceptions import LDAPException
from ldap3.utils.conv import escape_filter_chars
from .ungleich_ldap import LdapManager
logger = logging.getLogger(__name__)
class LdapBackend(ModelBackend):
def authenticate(self, request, username=None, password=None, **kwargs):
# An empty password is an anonymous bind in LDAP and would succeed.
if not username or not password:
return None
ldap_manager = LdapManager()
try:
entry = self._find_user(ldap_manager, username)
if entry is None:
return None
conn = ldap_manager.get_conn(user=entry.entry_dn, password=password)
try:
if not conn.bind():
return None
finally:
conn.unbind()
except LDAPException as exc:
logger.error("LDAP authentication for %s failed: %s", username, exc)
return None
user = self._sync_user(username, entry)
return user if self.user_can_authenticate(user) else None
def _find_user(self, ldap_manager, username):
attributes = list(settings.AUTH_LDAP_USER_ATTR_MAP.values())
search_filter = "(uid={})".format(escape_filter_chars(username))
matches = []
conn = ldap_manager.get_admin_conn()
try:
for base in settings.LDAP_SEARCH_BASES:
conn.search(base, search_filter, attributes=attributes)
matches.extend(conn.entries)
finally:
conn.unbind()
if len(matches) != 1:
if matches:
logger.warning("uid %s matched %d entries; refusing login",
username, len(matches))
return None
return matches[0]
def _sync_user(self, username, entry):
user, created = get_user_model().objects.get_or_create(username=username)
for field, attr in settings.AUTH_LDAP_USER_ATTR_MAP.items():
value = entry[attr].value if attr in entry else None
setattr(user, field, value if isinstance(value, str) else "")
if created:
user.set_unusable_password()
user.save()
return user

View file

@ -12,18 +12,13 @@ https://docs.djangoproject.com/en/1.10/ref/settings/
import os
from decouple import config, Csv
import ldap
from django_auth_ldap.config import LDAPSearch, LDAPSearchUnion
# LDAP setup
LDAP_SERVER = config('LDAP_SERVER')
AUTH_LDAP_SERVER_URI = config('LDAPSERVER')
AUTH_LDAP_SERVER = config('LDAPSERVER')
LDAP_ADMIN_DN = config('LDAP_ADMIN_DN')
LDAP_ADMIN_PASSWORD = config('LDAP_ADMIN_PASSWORD')
AUTH_LDAP_BIND_DN = LDAP_ADMIN_DN
AUTH_LDAP_BIND_PASSWORD = LDAP_ADMIN_PASSWORD
AUTH_LDAP_SERVER = AUTH_LDAP_SERVER_URI
LDAP_CUSTOMER_DN = config('LDAP_CUSTOMER_DN')
LDAP_USERS_DN = config('LDAP_USERS_DN')
@ -31,12 +26,11 @@ LDAP_CUSTOMER_GROUP_ID = config('LDAP_CUSTOMER_GROUP_ID', cast=int)
LDAP_DEFAULT_START_UID = config('LDAP_DEFAULT_START_UID', cast=int)
# Search union over OUs
search_base = config('LDAPSEARCH').split()
search_base_ldap = [ LDAPSearch(x, ldap.SCOPE_SUBTREE, "(uid=%(user)s)") for x in search_base ]
AUTH_LDAP_USER_SEARCH = LDAPSearchUnion(*search_base_ldap)
# Users log in from any of these (space separated) search bases.
LDAP_SEARCH_BASES = config('LDAPSEARCH').split()
AUTH_LDAP_START_TLS = config('LDAP_USE_TLS', default=False, cast=bool)
# StartTLS on every connection (use ldaps:// URIs for implicit TLS).
LDAP_USE_TLS = config('LDAP_USE_TLS', default=False, cast=bool)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@ -73,7 +67,7 @@ MIDDLEWARE = [
]
AUTHENTICATION_BACKENDS = (
'django_auth_ldap.backend.LDAPBackend',
'dal.backends.LdapBackend',
)

View file

@ -37,6 +37,34 @@ class LoginTests(LdapTestCase):
self.assertNotIn("_auth_user_id", self.client.session)
self.assertContains(resp, "incorrect")
def test_empty_password_is_rejected(self):
"""An empty password is an anonymous LDAP bind, which succeeds."""
uid = self.make_ldap_user(password="cust-pass-123")
self.assertFalse(self.client.login(username=uid, password=""))
resp = self.client.post("/index/", {"username": uid, "password": ""})
self.assertNotIn("_auth_user_id", self.client.session)
def test_unknown_user_is_rejected(self):
self.assertFalse(self.client.login(username="nobody-here", password="x"))
def test_filter_characters_in_username_do_not_match(self):
self.make_ldap_user(password="cust-pass-123")
self.assertFalse(self.client.login(username="*", password="cust-pass-123"))
def test_uid_in_two_subtrees_cannot_log_in(self):
uid = self.make_ldap_user(password="cust-pass-123")
self.make_ldap_user(uid=uid, password="cust-pass-123", ou="users")
self.assertFalse(self.client.login(username=uid, password="cust-pass-123"))
def test_login_mirrors_ldap_attributes(self):
from django.contrib.auth.models import User
uid = self.make_ldap_user(password="cust-pass-123", email="mirror@example.com")
self.login(uid, "cust-pass-123")
user = User.objects.get(username=uid)
self.assertEqual((user.first_name, user.last_name, user.email),
("Tina", "Tester", "mirror@example.com"))
self.assertFalse(user.has_usable_password())
def test_logged_in_user_gets_options_page(self):
uid = self.make_ldap_user(password="cust-pass-123")
self.login(uid, "cust-pass-123")

View file

@ -46,7 +46,11 @@ class LdapManager:
The *kwargs* are passed to the constructor of :class:`ldap3.Connection` and
can be used to set *user*, *password* and other useful arguments.
"""
return ldap3.Connection(self.server, **kwargs)
conn = ldap3.Connection(self.server, **kwargs)
if settings.LDAP_USE_TLS:
conn.open()
conn.start_tls()
return conn
def _ssha_password(self, password):

View file

@ -1,7 +1,5 @@
# Upgrade step 3: Django 5.2 LTS on Python 3.12.
django==5.2.19
django-auth-ldap==5.4.0
python-ldap==3.4.8
django-bootstrap3==26.3
python-decouple==3.8
ldap3==2.9.1