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