From ab45535f38ca5a3893dcff867dbd248faab7939a Mon Sep 17 00:00:00 2001 From: Sajeyks Date: Fri, 6 Nov 2026 15:15:03 +0300 Subject: [PATCH] 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. --- dal/backends.py | 73 +++++++++++++++++++++++++++++++++++++++++ dal/settings.py | 18 ++++------ dal/tests/test_flows.py | 28 ++++++++++++++++ dal/ungleich_ldap.py | 6 +++- requirements.txt | 2 -- 5 files changed, 112 insertions(+), 15 deletions(-) create mode 100644 dal/backends.py diff --git a/dal/backends.py b/dal/backends.py new file mode 100644 index 0000000..b28e3af --- /dev/null +++ b/dal/backends.py @@ -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 diff --git a/dal/settings.py b/dal/settings.py index 7dc5c0d..0df1bca 100644 --- a/dal/settings.py +++ b/dal/settings.py @@ -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', ) diff --git a/dal/tests/test_flows.py b/dal/tests/test_flows.py index e89d146..f1c8522 100644 --- a/dal/tests/test_flows.py +++ b/dal/tests/test_flows.py @@ -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") diff --git a/dal/ungleich_ldap.py b/dal/ungleich_ldap.py index 044bc6a..b3ee5ef 100644 --- a/dal/ungleich_ldap.py +++ b/dal/ungleich_ldap.py @@ -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): diff --git a/requirements.txt b/requirements.txt index 538daa7..32bce88 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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