diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index ef280e58..0f9a1634 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -15,20 +15,13 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] - django-version: ["3.2", "4.1", "4.2", "5.0"] + python-version: ["3.10", "3.11", "3.12"] + django-version: ["4.2", "5.0", "5.1"] include: - - python-version: "3.12" - django-version: "4.2" - - python-version: "3.12" - django-version: "5.0" - exclude: - - python-version: "3.11" - django-version: "3.2" - - python-version: "3.8" - django-version: "5.0" - python-version: "3.9" - django-version: "5.0" + django-version: "4.2" + - python-version: "3.13" + django-version: "5.1" steps: - uses: actions/checkout@v4 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a662b442..c3e6be30 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,7 +2,7 @@ exclude: 'docs|migrations' repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 + rev: v5.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -13,37 +13,37 @@ repos: - id: debug-statements - repo: https://github.com/asottile/pyupgrade - rev: v2.34.0 + rev: v3.19.0 hooks: - id: pyupgrade - args: [--py37-plus] + args: [--py39-plus] - repo: https://github.com/myint/autoflake - rev: 'v1.4' + rev: 'v2.3.1' hooks: - id: autoflake args: ['--in-place', '--remove-all-unused-imports', '--ignore-init-module-imports'] - repo: https://github.com/pycqa/isort - rev: 5.10.1 + rev: 5.13.2 hooks: - id: isort name: isort (python) args: ['--settings-path=pyproject.toml'] - repo: https://github.com/psf/black - rev: 22.6.0 + rev: 24.10.0 hooks: - id: black - repo: https://github.com/adamchainz/django-upgrade - rev: 1.7.0 + rev: 1.22.2 hooks: - id: django-upgrade - args: [--target-version, "3.2"] + args: [--target-version, "4.2"] - repo: https://github.com/pycqa/flake8 - rev: 4.0.1 + rev: 7.1.1 hooks: - id: flake8 args: ['--config=setup.cfg'] diff --git a/djangosaml2/backends.py b/djangosaml2/backends.py index d1c01407..06b8bf8f 100644 --- a/djangosaml2/backends.py +++ b/djangosaml2/backends.py @@ -15,7 +15,7 @@ import logging import warnings -from typing import Any, Optional, Tuple +from typing import Any, Optional from django.apps import apps from django.conf import settings @@ -72,7 +72,7 @@ def _user_lookup_attribute(self) -> str: def _extract_user_identifier_params( self, session_info: dict, attributes: dict, attribute_mapping: dict - ) -> Tuple[str, Optional[Any]]: + ) -> tuple[str, Optional[Any]]: """Returns the attribute to perform a user lookup on, and the value to use for it. The value could be the name_id, or any other saml attribute from the request. """ @@ -262,7 +262,7 @@ def get_or_create_user( attributes: dict, attribute_mapping: dict, request, - ) -> Tuple[Optional[settings.AUTH_USER_MODEL], bool]: + ) -> tuple[Optional[settings.AUTH_USER_MODEL], bool]: """Look up the user to authenticate. If he doesn't exist, this method creates him (if so desired). The default implementation looks only at the user_identifier. Override this method in order to do more complex behaviour, e.g. customize this per IdP. @@ -322,6 +322,7 @@ def get_attribute_value(self, django_field, attributes, attribute_mapping): warnings.warn( "get_attribute_value() is deprecated, look at the Saml2Backend on how to subclass it", DeprecationWarning, + stacklevel=2, ) return self._get_attribute_value(django_field, attributes, attribute_mapping) @@ -329,6 +330,7 @@ def get_django_user_main_attribute(self): warnings.warn( "get_django_user_main_attribute() is deprecated, look at the Saml2Backend on how to subclass it", DeprecationWarning, + stacklevel=2, ) return self._user_lookup_attribute @@ -336,6 +338,7 @@ def get_django_user_main_attribute_lookup(self): warnings.warn( "get_django_user_main_attribute_lookup() is deprecated, look at the Saml2Backend on how to subclass it", DeprecationWarning, + stacklevel=2, ) return getattr(settings, "SAML_DJANGO_USER_MAIN_ATTRIBUTE_LOOKUP", "") @@ -343,6 +346,7 @@ def get_user_query_args(self, main_attribute): warnings.warn( "get_user_query_args() is deprecated, look at the Saml2Backend on how to subclass it", DeprecationWarning, + stacklevel=2, ) return { self.get_django_user_main_attribute() @@ -353,6 +357,7 @@ def configure_user(self, user, attributes, attribute_mapping): warnings.warn( "configure_user() is deprecated, look at the Saml2Backend on how to subclass it", DeprecationWarning, + stacklevel=2, ) return self._update_user(user, attributes, attribute_mapping) @@ -360,6 +365,7 @@ def update_user(self, user, attributes, attribute_mapping, force_save=False): warnings.warn( "update_user() is deprecated, look at the Saml2Backend on how to subclass it", DeprecationWarning, + stacklevel=2, ) return self._update_user(user, attributes, attribute_mapping) @@ -367,6 +373,7 @@ def _set_attribute(self, obj, attr, value): warnings.warn( "_set_attribute() is deprecated, look at the Saml2Backend on how to subclass it", DeprecationWarning, + stacklevel=2, ) return set_attribute(obj, attr, value) @@ -375,5 +382,6 @@ def get_saml_user_model(): warnings.warn( "_set_attribute() is deprecated, look at the Saml2Backend on how to subclass it", DeprecationWarning, + stacklevel=2, ) return Saml2Backend()._user_model diff --git a/djangosaml2/overrides.py b/djangosaml2/overrides.py index f2b8ce34..8f6bc6bf 100644 --- a/djangosaml2/overrides.py +++ b/djangosaml2/overrides.py @@ -19,9 +19,9 @@ class Saml2Client(saml2.client.Saml2Client): def do_logout(self, *args, **kwargs): if not kwargs.get("expected_binding"): try: - kwargs[ - "expected_binding" - ] = settings.SAML_LOGOUT_REQUEST_PREFERRED_BINDING + kwargs["expected_binding"] = ( + settings.SAML_LOGOUT_REQUEST_PREFERRED_BINDING + ) except AttributeError: logger.warning( "SAML_LOGOUT_REQUEST_PREFERRED_BINDING setting is" diff --git a/djangosaml2/tests/__init__.py b/djangosaml2/tests/__init__.py index 8b8602cc..56dc7aeb 100644 --- a/djangosaml2/tests/__init__.py +++ b/djangosaml2/tests/__init__.py @@ -139,9 +139,9 @@ def add_outstanding_query(self, session_id, came_from): came_from, ) self.saml_session.save() - self.client.cookies[ - settings.SESSION_COOKIE_NAME - ] = self.saml_session.session_key + self.client.cookies[settings.SESSION_COOKIE_NAME] = ( + self.saml_session.session_key + ) def b64_for_post(self, xml_text, encoding="utf-8"): return base64.b64encode(xml_text.encode(encoding)).decode("ascii") @@ -308,8 +308,12 @@ def test_unknown_idp(self): metadata_file="remote_metadata_three_idps.xml", ) - response = self.client.get(reverse("saml2_login") + "?idp=https://unknown.org") - self.assertContains(response, "<b>https://unknown.org</b>", status_code=403) + response = self.client.get( + reverse("saml2_login") + "?idp=https://unknown.org" + ) + self.assertContains( + response, "<b>https://unknown.org</b>", status_code=403 + ) def test_login_authn_context(self): sp_kwargs = { diff --git a/djangosaml2/utils.py b/djangosaml2/utils.py index e13182a0..3426ec16 100644 --- a/djangosaml2/utils.py +++ b/djangosaml2/utils.py @@ -47,7 +47,7 @@ def available_idps(config: SPConfig, langpref=None, idp_to_check: str = None) -> for metadata in config.metadata.metadata.values(): # initiate a fetch to the selected idp when using MDQ, otherwise the MetaDataMDX is an empty database if isinstance(metadata, MetaDataMDX) and idp_to_check: - m = metadata[idp_to_check] + m = metadata[idp_to_check] # noqa: F841 result = metadata.any("idpsso_descriptor", "single_sign_on_service") if result: idps.update(result.keys()) @@ -108,11 +108,7 @@ def validate_referral_url(request, url): # SAML_STRICT_URL_VALIDATION setting can be used to turn off this check. # This should only happen if there is no slash, host and/or protocol in the # given URL. A better fix would be to add those to the RelayState. - saml_strict_url_validation = getattr( - settings, - "SAML_STRICT_URL_VALIDATION", - True - ) + saml_strict_url_validation = getattr(settings, "SAML_STRICT_URL_VALIDATION", True) try: if saml_strict_url_validation: # This will also resolve Django URL pattern names @@ -133,8 +129,7 @@ def validate_referral_url(request, url): ) if not url_has_allowed_host_and_scheme(url=url, allowed_hosts=saml_allowed_hosts): - logger.debug("Referral URL not in SAML_ALLOWED_HOSTS or of the origin " - "host.") + logger.debug("Referral URL not in SAML_ALLOWED_HOSTS or of the origin host.") return None return url @@ -210,7 +205,7 @@ def add_idp_hinting(request, http_response) -> bool: return False -@lru_cache() +@lru_cache def get_csp_handler(): """Returns a view decorator for CSP.""" diff --git a/djangosaml2/views.py b/djangosaml2/views.py index 2bf82f86..245caa12 100644 --- a/djangosaml2/views.py +++ b/djangosaml2/views.py @@ -135,7 +135,7 @@ def get_state_client(self, request: HttpRequest): return state, client -@method_decorator(saml2_csp_update, name='dispatch') +@method_decorator(saml2_csp_update, name="dispatch") class LoginView(SPConfigMixin, View): """SAML Authorization Request initiator. @@ -389,7 +389,7 @@ def get(self, request, *args, **kwargs): except TemplateDoesNotExist as e: logger.debug( f"TemplateDoesNotExist: [{self.post_binding_form_template}] - {e}", - exc_info=True + exc_info=True, ) if not http_response: @@ -574,7 +574,7 @@ def post(self, request, attribute_mapping=None, create_unknown_user=None): session_info, attribute_mapping, create_unknown_user, - assertion_info + assertion_info, ) except PermissionDenied as e: return self.handle_acs_failure( @@ -592,7 +592,7 @@ def post(self, request, attribute_mapping=None, create_unknown_user=None): if not relay_state: logger.debug( "RelayState is not a valid URL, redirecting to fallback: %s", - relay_state + relay_state, ) return HttpResponseRedirect(get_fallback_login_redirect_url()) @@ -600,13 +600,13 @@ def post(self, request, attribute_mapping=None, create_unknown_user=None): return HttpResponseRedirect(relay_state) def authenticate_user( - self, - request, - session_info, - attribute_mapping, - create_unknown_user, - assertion_info - ): + self, + request, + session_info, + attribute_mapping, + create_unknown_user, + assertion_info, + ): """Calls Django's authenticate method after the SAML response is verified""" logger.debug("Trying to authenticate the user. Session info: %s", session_info) @@ -685,7 +685,7 @@ def get(self, request, *args, **kwargs): ) -@method_decorator(saml2_csp_update, name='dispatch') +@method_decorator(saml2_csp_update, name="dispatch") class LogoutInitView(LoginRequiredMixin, SPConfigMixin, View): """SAML Logout Request initiator @@ -801,7 +801,9 @@ def do_logout_service(self, request, data, binding, *args, **kwargs): ) except StatusError as e: response = None - logger.warning(f"Error logging out from remote provider: {e}", exc_info=True) + logger.warning( + f"Error logging out from remote provider: {e}", exc_info=True + ) state.sync() return finish_logout(request, response) @@ -853,13 +855,16 @@ def finish_logout(request, response): return HttpResponseRedirect(next_path) elif settings.LOGOUT_REDIRECT_URL is not None: fallback_url = resolve_url(settings.LOGOUT_REDIRECT_URL) - logger.debug("No valid RelayState found; Redirecting to " - "LOGOUT_REDIRECT_URL") + logger.debug( + "No valid RelayState found; Redirecting to " "LOGOUT_REDIRECT_URL" + ) return HttpResponseRedirect(fallback_url) else: current_site = get_current_site(request) - logger.debug("No valid RelayState or LOGOUT_REDIRECT_URL found, " - "rendering fallback template.") + logger.debug( + "No valid RelayState or LOGOUT_REDIRECT_URL found, " + "rendering fallback template." + ) return render( request, "registration/logged_out.html", diff --git a/pyproject.toml b/pyproject.toml index 5d906c1e..28b8614c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.black] force-exclude = '''/(migrations)/''' -target-version = ["py36"] +target-version = ["py39"] [tool.isort] src_paths = ["djangosaml2", "tests"] diff --git a/setup.py b/setup.py index 267921a2..64dbe111 100644 --- a/setup.py +++ b/setup.py @@ -35,19 +35,18 @@ def read(*rnames): "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Framework :: Django", - "Framework :: Django :: 3.2", - "Framework :: Django :: 4.1", "Framework :: Django :: 4.2", "Framework :: Django :: 5.0", + "Framework :: Django :: 5.1", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "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 :: Internet :: WWW/HTTP :: WSGI", "Topic :: Security", @@ -63,5 +62,5 @@ def read(*rnames): packages=find_packages(exclude=["tests", "tests.*"]), include_package_data=True, zip_safe=False, - install_requires=["defusedxml>=0.4.1", "Django>=3.2", "pysaml2>=6.5.1"], + install_requires=["defusedxml>=0.4.1", "Django>=4.2", "pysaml2>=6.5.1"], ) diff --git a/tests/settings.py b/tests/settings.py index 58a2e28c..ca741c77 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -104,8 +104,6 @@ USE_I18N = True -USE_L10N = True - USE_TZ = True diff --git a/tox.ini b/tox.ini index 06f57b11..ccfc3f7d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{3.8,3.9,3.10,3.11,3.12}-django{3.2,4.1,4.2,5.0} + py{3.9,3.10,3.11,3.12,3.13}-django{4.2,5.0,5.1} [testenv] commands = @@ -8,10 +8,9 @@ commands = python tests/run_tests.py deps = - django3.2: django~=3.2 - django4.1: django~=4.1 django4.2: django~=4.2 - django5.0: django==5.0a1 + django5.0: django~=5.0 + django5.1: django~=5.1 djangomaster: https://github.com/django/django/archive/master.tar.gz .