diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index bb58e5bc..80dca6f4 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -6,28 +6,19 @@ on: jobs: build: - name: ${{ matrix.tox-environment }} + name: check runs-on: ubuntu-latest - - strategy: - fail-fast: false - matrix: - tox-environment: - - black - - flake8 - - isort - - env: - TOXENV: ${{ matrix.tox-environment }} - steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 + with: + python-version: 3.x + cache: pip - name: Install dependencies run: python -m pip install tox - name: Run - run: tox + run: tox -e ruff diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 10ad872e..d47a1308 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -26,6 +26,9 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 + with: + python-version: 3.x + cache: pip - name: Install dependencies run: python -m pip install tox diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f18e3725..2cbc194f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,8 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.x" + python-version: 3.x + cache: pip - name: Install dependencies run: pip install --user build setuptools twine wheel diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7917c9ed..6c71ffd6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,37 +14,12 @@ jobs: matrix: # https://docs.djangoproject.com/en/dev/faq/install/#what-python-version-can-i-use-with-django python-version: + - '3.8' + - '3.9' - '3.10' - '3.11' - '3.12' - tox-environment: - - django42 - - django50 - include: - # Django 3.2 - - python-version: 3.8 - tox-environment: django32 - - python-version: 3.9 - tox-environment: django32 - - python-version: '3.10' - tox-environment: django32 - # Django 5.0 - - python-version: '3.10' - tox-environment: django50 - - python-version: '3.11' - tox-environment: django50 - - python-version: '3.12' - tox-environment: django50 - # Django main - - python-version: '3.10' - tox-environment: djangomain - - python-version: '3.11' - tox-environment: djangomain - - python-version: '3.12' - tox-environment: djangomain - - env: - TOXENV: ${{ matrix.tox-environment }} + - '3.13-dev' steps: - name: Install LDAP libs @@ -62,9 +37,10 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + cache: pip - name: Install dependencies - run: python -m pip install tox + run: python -m pip install tox-gh - name: Run tests run: tox diff --git a/django_auth_ldap/backend.py b/django_auth_ldap/backend.py index 17c5e0c9..c63b6cec 100644 --- a/django_auth_ldap/backend.py +++ b/django_auth_ldap/backend.py @@ -78,6 +78,31 @@ ldap_error = django.dispatch.Signal() +_error_context_descriptions = { + "authenticate": "while authenticating", + "populate_user": "populating user info", + "get_group_permissions": "loading group permissions", + "search_for_user_dn": "looking up user", + "mirror_groups": "updating mirrored groups", +} + + +def _report_error(sender, context, user, request, exception): + description = _error_context_descriptions.get(context, "from unknown context") + logger.warning( + "Caught LDAPError %s: %s", + description, + pprint.pformat(exception) + ) + ldap_error.send( + sender, + context=context, + user=user, + request=request, + exception=exception, + ) + + class LDAPBackend: """ The main backend class. This implements the auth backend API, although it @@ -354,19 +379,13 @@ def authenticate(self, password): except self.AuthenticationFailed as e: logger.debug("Authentication failed for %s: %s", self._username, e) except ldap.LDAPError as e: - results = ldap_error.send( + _report_error( type(self.backend), - context="authenticate", - user=self._user, - request=self._request, - exception=e, + "authenticate", + self._user, + self._request, + e ) - if len(results) == 0: - logger.warning( - "Caught LDAPError while authenticating %s: %s", - self._username, - pprint.pformat(e), - ) except Exception as e: logger.warning("%s while authenticating %s", e, self._username) raise @@ -386,18 +405,13 @@ def get_group_permissions(self): if self.dn is not None: self._load_group_permissions() except ldap.LDAPError as e: - results = ldap_error.send( + _report_error( type(self.backend), - context="get_group_permissions", - user=self._user, - request=self._request, - exception=e, + "get_group_permissions", + self._user, + self._request, + e ) - if len(results) == 0: - logger.warning( - "Caught LDAPError loading group permissions: %s", - pprint.pformat(e), - ) return self._group_permissions @@ -414,20 +428,17 @@ def populate_user(self): self._get_or_create_user(force_populate=True) user = self._user + except self.AuthenticationFailed as e: + # Mirroring groups can raise AuthenticationFailed + logger.debug("Failed to populate user %s: %s", self._username, e) except ldap.LDAPError as e: - results = ldap_error.send( + _report_error( type(self.backend), - context="populate_user", - user=self._user, - request=self._request, - exception=e, + "populate_user", + self._user, + self._request, + e ) - if len(results) == 0: - logger.warning( - "Caught LDAPError while authenticating %s: %s", - self._username, - pprint.pformat(e), - ) except Exception as e: logger.warning("%s while authenticating %s", e, self._username) raise @@ -537,11 +548,21 @@ def _search_for_user(): "AUTH_LDAP_USER_SEARCH must be an LDAPSearch instance." ) - results = search.execute(self.connection, {"user": self._username}) - if results is not None and len(results) == 1: - (user_dn, self._user_attrs) = next(iter(results)) + user_dn = None + + try: + results = search.execute(self.connection, {"user": self._username}) + except ldap.LDAPError as e: + _report_error( + type(self.backend), + "search_for_user_dn", + self._user, + self._request, + e + ) else: - user_dn = None + if results is not None and len(results) == 1: + (user_dn, self._user_attrs) = next(iter(results)) return user_dn @@ -756,7 +777,19 @@ def _mirror_groups(self): Mirrors the user's LDAP groups in the Django database and updates the user's membership. """ - target_group_names = frozenset(self._get_groups().get_group_names()) + try: + target_group_names = frozenset(self._get_groups().get_group_names()) + except ldap.LDAPError as e: + _report_error( + type(self.backend), + context="mirror_groups", + user=self._user, + request=self._request, + exception=e, + ) + # Prevent user from logging in since their groups are out of sync + raise self.AuthenticationFailed("Error mirroring user groups") + current_group_names = frozenset( self._user.groups.values_list("name", flat=True).iterator() ) diff --git a/django_auth_ldap/config.py b/django_auth_ldap/config.py index 6dcdefa8..64f57eb6 100644 --- a/django_auth_ldap/config.py +++ b/django_auth_ldap/config.py @@ -197,23 +197,13 @@ def execute(self, connection, filterargs=(), escape=True): if escape: filterargs = self._escape_filterargs(filterargs) - try: - filterstr = self.filterstr % filterargs - logger.debug( - "Invoking search_s('%s', %s, '%s')", self.base_dn, self.scope, filterstr - ) - results = connection.search_s( - self.base_dn, self.scope, filterstr, self.attrlist - ) - except ldap.LDAPError as e: - results = [] - logger.error( - "search_s('%s', %s, '%s') raised %s", - self.base_dn, - self.scope, - filterstr, - pprint.pformat(e), - ) + filterstr = self.filterstr % filterargs + logger.debug( + "Invoking search_s('%s', %s, '%s')", self.base_dn, self.scope, filterstr + ) + results = connection.search_s( + self.base_dn, self.scope, filterstr, self.attrlist + ) return self._process_results(results) diff --git a/docs/reference.rst b/docs/reference.rst index a989a152..2332a4bd 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -577,21 +577,24 @@ Backend .. data:: ldap_error - This is a Django signal that is sent when we receive an :exc:`ldap.LDAPError` exception. The signal has four keyword arguments: - ``context``: one of ``'authenticate'``, ``'get_group_permissions'``, or - ``'populate_user'``, indicating which API was being called when the - exception was caught. - - ``user``: the Django user being processed (if available). + ``'populate_user'``, ``'search_for_user_dn'`` or ``'mirror_groups'``, + indicating which API was being called when the exception was caught. + - ``user``: the Django user being processed (if available) or ``None``. - ``request``: the Django request object associated with the - authentication attempt (if available). + authentication attempt (if available) or ``None``. - ``exception``: the :exc:`~ldap.LDAPError` object itself. The sender is the :class:`~django_auth_ldap.backend.LDAPBackend` class (or subclass). + By default, LDAP errors are be handled by ``django_auth_ldap`` by failing + the authentication. If instead you wish to propagate the error to up + application code, then raise an exception from the signal handler. + .. class:: LDAPBackend :class:`~django_auth_ldap.backend.LDAPBackend` has one method that may be diff --git a/pyproject.toml b/pyproject.toml index b2db850a..3b4a52b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,9 +14,9 @@ classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Framework :: Django", - "Framework :: Django :: 3.2", "Framework :: Django :: 4.2", "Framework :: Django :: 5.0", + "Framework :: Django :: 5.1", "Intended Audience :: Developers", "Intended Audience :: System Administrators", "License :: OSI Approved :: BSD License", @@ -28,6 +28,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Internet :: WWW/HTTP", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: System :: Systems Administration :: Authentication/Directory :: LDAP", @@ -35,7 +36,7 @@ classifiers = [ dynamic = ["version"] dependencies = [ - "Django>=3.2", + "Django>=4.2", "python-ldap>=3.1", ] @@ -46,8 +47,15 @@ Source = "https://github.com/django-auth-ldap/django-auth-ldap" Tracker = "https://github.com/django-auth-ldap/django-auth-ldap/issues" Changelog = "https://github.com/django-auth-ldap/django-auth-ldap/releases/" -[tool.isort] -profile = "black" +[tool.ruff.lint] +# See prefixes in https://beta.ruff.rs/docs/rules/ +select = [ + "F", # pyflakes + "E", # pycodestyle errors + "W", # pycodestyle warnings + "I", # isort + "C4", # flake8-comprehension +] [build-system] requires = [ diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 2bcd70e3..00000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[flake8] -max-line-length = 88 diff --git a/tests/tests.py b/tests/tests.py index 79646833..41cb3735 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -29,6 +29,7 @@ import pickle from copy import deepcopy from unittest import mock +from unittest.mock import ANY import ldap import slapdtest @@ -602,6 +603,31 @@ def test_populate_user_with_missing_attribute(self): ], ) + def test_populate_user_ldap_error(self): + self._init_settings( + USER_DN_TEMPLATE="uid=%(user)s,ou=people,o=test", + USER_ATTR_MAP={"first_name": "givenName", "last_name": "sn"}, + SERVER_URI="", # This will cause a network error + ) + + with self.assertLogs("django_auth_ldap", level=logging.DEBUG) as logs: + with catch_signal(ldap_error) as handler: + LDAPBackend().populate_user('alice') + + handler.assert_called_once_with( + signal=ldap_error, + sender=LDAPBackend, + context="populate_user", + user=None, + request=None, + exception=ANY, + ) + self.assertEqual( + logs.output[-1], + "WARNING:django_auth_ldap:Caught LDAPError populating user info: " + "LDAPError(0, 'Error')" + ) + @mock.patch.object(LDAPSearch, "execute", return_value=None) def test_populate_user_with_bad_search(self, mock_execute): self._init_settings( @@ -720,11 +746,46 @@ def handle_ldap_error(sender, **kwargs): request = RequestFactory().get("/") with self.assertRaises(ldap.LDAPError): authenticate(request=request, username="alice", password="password") - handler.assert_called_once() + assert handler.mock_calls[0].kwargs['context'] == 'search_for_user_dn' + assert handler.mock_calls[1].kwargs['context'] == 'authenticate' + assert handler.call_count == 2 _args, kwargs = handler.call_args self.assertEqual(kwargs["context"], "authenticate") self.assertEqual(kwargs["request"], request) + def test_search_for_user_dn_error(self): + self._init_settings( + USER_DN_TEMPLATE=None, + USER_SEARCH=LDAPSearch("ou=people,o=test", ldap.SCOPE_SUBTREE, "(uid=*)"), + USER_ATTR_MAP={"first_name": "givenName", "last_name": "sn"}, + SERVER_URI="", # This will cause a network error + ) + + request = RequestFactory().get("/") + + with self.assertLogs("django_auth_ldap", level=logging.DEBUG) as logs: + with catch_signal(ldap_error) as handler: + authenticate(request=request, username="alice", password="password") + + handler.assert_called_once_with( + signal=ldap_error, + sender=LDAPBackend, + context="search_for_user_dn", + user=None, + request=request, + exception=ANY, + ) + self.assertEqual( + logs.output[-2], + "WARNING:django_auth_ldap:Caught LDAPError looking up user: " + "LDAPError(0, 'Error')" + ) + self.assertEqual( + logs.output[-1], + "DEBUG:django_auth_ldap:Authentication failed for alice: failed " + "to map the username to a DN.", + ) + def test_populate_signal_ldap_error(self): self._init_settings( BIND_DN="uid=bob,ou=people,o=test", @@ -1421,6 +1482,52 @@ def test_group_mirroring_blacklist_noop(self): set(alice.groups.values_list("name", flat=True)), {"mirror1", "mirror3"} ) + def test_group_mirroring_error(self): + self._init_settings( + USER_DN_TEMPLATE="uid=%(user)s,ou=people,o=test", + GROUP_SEARCH=LDAPSearch( + "ou=groups,o=test", ldap.SCOPE_SUBTREE, "(objectClass=posixGroup)" + ), + GROUP_TYPE=PosixGroupType(), + MIRROR_GROUPS=True, + ) + + grp = Group.objects.create(name="test_group") + alice = User.objects.create(username="alice") + alice.groups.add(grp) + + with self.assertLogs("django_auth_ldap", level=logging.DEBUG) as logs: + with catch_signal(ldap_error) as handler: + with mock.patch( + "django_auth_ldap.backend._LDAPUserGroups.get_group_names", + side_effect=ldap.LDAPError(0, "Error") + ): + user = authenticate(username="alice", password="password") + + self.assertIsNone(user) + + # When there's an error populating groups, preserve old user groups. + self.assertEqual(set(alice.groups.all()), {grp}) + + handler.assert_called_once_with( + signal=ldap_error, + sender=LDAPBackend, + context="mirror_groups", + user=alice, + request=None, + exception=ANY, + ) + self.assertEqual( + logs.output[-2], + "WARNING:django_auth_ldap:Caught LDAPError updating mirrored groups: " + "LDAPError(0, 'Error')" + ) + self.assertEqual( + logs.output[-1], + "DEBUG:django_auth_ldap:Authentication failed for alice: Error " + "mirroring user groups" + ) + def test_authorize_external_users(self): self._init_settings( USER_DN_TEMPLATE="uid=%(user)s,ou=people,o=test", @@ -1542,7 +1649,7 @@ def test_start_tls(self, mock): self.assertEqual(log2, "DEBUG:django_auth_ldap:Initiating TLS") self.assertTrue( log3.startswith( - "WARNING:django_auth_ldap:Caught LDAPError while authenticating alice: " + "WARNING:django_auth_ldap:Caught LDAPError while authenticating: " ) ) diff --git a/tox.ini b/tox.ini index 5a191e25..87ffa51a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,42 +1,40 @@ [tox] envlist = - black - flake8 - isort + ruff docs - django32 django42 django50 + django51 djangomain isolated_build = true +[gh] +python = + 3.8 = django42 + 3.9 = django42 + 3.10 = django{42,50,51,main} + 3.11 = django{42,50,51,main} + 3.12 = django{42,50,51,main} + 3.13 = django{42,50,51,main} + [testenv] commands = {envpython} -Wa -b -m django test --settings tests.settings deps = - django32: Django>=3.2,<4.0 django42: Django>=4.2,<4.3 django50: Django>=5.0,<5.1 + django51: Django>=5.1b1,<5.2 djangomain: https://github.com/django/django/archive/main.tar.gz -[testenv:black] -deps = black -commands = black --check --diff . -skip_install = true - -[testenv:flake8] -deps = flake8 -commands = flake8 -skip_install = true - -[testenv:isort] -deps = isort>=5.0.1 -commands = isort --check --diff . +[testenv:ruff] +deps = ruff +commands = ruff check . skip_install = true [testenv:docs] isolated_build = true deps = readme_renderer + setuptools>=65.0 sphinx commands = make -C docs html