diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..c06a4d20 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,51 @@ +exclude: 'docs|migrations' + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.1.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-case-conflict + - id: check-merge-conflict + - id: debug-statements + + - repo: https://github.com/asottile/pyupgrade + rev: v2.31.0 + hooks: + - id: pyupgrade + args: [--py36-plus] + + - repo: https://github.com/myint/autoflake + rev: 'v1.4' + hooks: + - id: autoflake + args: ['--in-place', '--remove-all-unused-imports', '--ignore-init-module-imports'] + + - repo: https://github.com/pycqa/isort + rev: 5.10.1 + hooks: + - id: isort + name: isort (python) + args: ['--settings-path=pyproject.toml'] + + - repo: https://github.com/psf/black + rev: 21.12b0 + hooks: + - id: black + + - repo: https://github.com/adamchainz/django-upgrade + rev: 1.3.2 + hooks: + - id: django-upgrade + args: [--target-version, "2.2"] + + - repo: https://github.com/pycqa/flake8 + rev: 4.0.1 + hooks: + - id: flake8 + args: ['--config=setup.cfg'] + additional_dependencies: [flake8-bugbear, flake8-isort] + verbose: true diff --git a/djangosaml2/__init__.py b/djangosaml2/__init__.py index 7744fbcc..6a4a7459 100644 --- a/djangosaml2/__init__.py +++ b/djangosaml2/__init__.py @@ -1 +1 @@ -default_app_config = 'djangosaml2.apps.DjangoSaml2Config' +default_app_config = "djangosaml2.apps.DjangoSaml2Config" diff --git a/djangosaml2/apps.py b/djangosaml2/apps.py index e03f7c8d..8fc266d8 100644 --- a/djangosaml2/apps.py +++ b/djangosaml2/apps.py @@ -2,7 +2,7 @@ class DjangoSaml2Config(AppConfig): - name = 'djangosaml2' + name = "djangosaml2" verbose_name = "DjangoSAML2" def ready(self): diff --git a/djangosaml2/backends.py b/djangosaml2/backends.py index 08ae5f64..7528a35a 100644 --- a/djangosaml2/backends.py +++ b/djangosaml2/backends.py @@ -14,23 +14,22 @@ # limitations under the License. import logging -from typing import Any, Optional, Tuple import warnings +from typing import Any, Optional, Tuple from django.apps import apps from django.conf import settings +from django.core.exceptions import ImproperlyConfigured, MultipleObjectsReturned + from django.contrib import auth from django.contrib.auth.backends import ModelBackend -from django.core.exceptions import (ImproperlyConfigured, - MultipleObjectsReturned) - -logger = logging.getLogger('djangosaml2') +logger = logging.getLogger("djangosaml2") def set_attribute(obj: Any, attr: str, new_value: Any) -> bool: - """ Set an attribute of an object to a specific value, if it wasn't that already. - Return True if the attribute was changed and False otherwise. + """Set an attribute of an object to a specific value, if it wasn't that already. + Return True if the attribute was changed and False otherwise. """ if not hasattr(obj, attr): setattr(obj, attr, new_value) @@ -49,52 +48,60 @@ class Saml2Backend(ModelBackend): @property def _user_model(self): - """ Returns the user model specified in the settings, or the default one from this Django installation """ - if hasattr(settings, 'SAML_USER_MODEL'): + """Returns the user model specified in the settings, or the default one from this Django installation""" + if hasattr(settings, "SAML_USER_MODEL"): try: return apps.get_model(settings.SAML_USER_MODEL) except LookupError: raise ImproperlyConfigured( - f"Model '{settings.SAML_USER_MODEL}' could not be loaded") + f"Model '{settings.SAML_USER_MODEL}' could not be loaded" + ) except ValueError: raise ImproperlyConfigured( - f"Model was specified as '{settings.SAML_USER_MODEL}', but it must be of the form 'app_label.model_name'") + f"Model was specified as '{settings.SAML_USER_MODEL}', but it must be of the form 'app_label.model_name'" + ) return auth.get_user_model() @property def _user_lookup_attribute(self) -> str: - """ Returns the attribute on which to match the identifier with when performing a user lookup """ - if hasattr(settings, 'SAML_DJANGO_USER_MAIN_ATTRIBUTE'): + """Returns the attribute on which to match the identifier with when performing a user lookup""" + if hasattr(settings, "SAML_DJANGO_USER_MAIN_ATTRIBUTE"): return settings.SAML_DJANGO_USER_MAIN_ATTRIBUTE - return getattr(self._user_model, 'USERNAME_FIELD', 'username') + return getattr(self._user_model, "USERNAME_FIELD", "username") - def _extract_user_identifier_params(self, session_info: dict, attributes: dict, attribute_mapping: dict) -> 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. + def _extract_user_identifier_params( + self, session_info: dict, attributes: dict, attribute_mapping: dict + ) -> 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. """ # Lookup key user_lookup_key = self._user_lookup_attribute # Lookup value - if getattr(settings, 'SAML_USE_NAME_ID_AS_USERNAME', False): - if session_info.get('name_id'): + if getattr(settings, "SAML_USE_NAME_ID_AS_USERNAME", False): + if session_info.get("name_id"): logger.debug(f"name_id: {session_info['name_id']}") - user_lookup_value = session_info['name_id'].text + user_lookup_value = session_info["name_id"].text else: logger.error( - 'The nameid is not available. Cannot find user without a nameid.') + "The nameid is not available. Cannot find user without a nameid." + ) user_lookup_value = None else: # Obtain the value of the custom attribute to use user_lookup_value = self._get_attribute_value( - user_lookup_key, attributes, attribute_mapping) + user_lookup_key, attributes, attribute_mapping + ) return user_lookup_key, self.clean_user_main_attribute(user_lookup_value) - def _get_attribute_value(self, django_field: str, attributes: dict, attribute_mapping: dict): + def _get_attribute_value( + self, django_field: str, attributes: dict, attribute_mapping: dict + ): saml_attribute = None - logger.debug('attribute_mapping: %s', attribute_mapping) + logger.debug("attribute_mapping: %s", attribute_mapping) for saml_attr, django_fields in attribute_mapping.items(): if django_field in django_fields and saml_attr in attributes: saml_attribute = attributes.get(saml_attr, [None]) @@ -102,54 +109,75 @@ def _get_attribute_value(self, django_field: str, attributes: dict, attribute_ma if saml_attribute: return saml_attribute[0] else: - logger.error('attributes[saml_attr] attribute ' - 'value is missing. Probably the user ' - 'session is expired.') - - def authenticate(self, request, session_info=None, attribute_mapping=None, create_unknown_user=True, assertion_info=None, **kwargs): + logger.error( + "attributes[saml_attr] attribute " + "value is missing. Probably the user " + "session is expired." + ) + + def authenticate( + self, + request, + session_info=None, + attribute_mapping=None, + create_unknown_user=True, + assertion_info=None, + **kwargs, + ): if session_info is None or attribute_mapping is None: - logger.info('Session info or attribute mapping are None') + logger.info("Session info or attribute mapping are None") return None - if 'ava' not in session_info: + if "ava" not in session_info: logger.error('"ava" key not found in session_info') return None - idp_entityid = session_info['issuer'] + idp_entityid = session_info["issuer"] - attributes = self.clean_attributes(session_info['ava'], idp_entityid) + attributes = self.clean_attributes(session_info["ava"], idp_entityid) - logger.debug(f'attributes: {attributes}') + logger.debug(f"attributes: {attributes}") - if not self.is_authorized(attributes, attribute_mapping, idp_entityid, assertion_info): - logger.error('Request not authorized') + if not self.is_authorized( + attributes, attribute_mapping, idp_entityid, assertion_info + ): + logger.error("Request not authorized") return None user_lookup_key, user_lookup_value = self._extract_user_identifier_params( - session_info, attributes, attribute_mapping) + session_info, attributes, attribute_mapping + ) if not user_lookup_value: - logger.error('Could not determine user identifier') + logger.error("Could not determine user identifier") return None user, created = self.get_or_create_user( - user_lookup_key, user_lookup_value, create_unknown_user, - idp_entityid=idp_entityid, attributes=attributes, attribute_mapping=attribute_mapping, request=request + user_lookup_key, + user_lookup_value, + create_unknown_user, + idp_entityid=idp_entityid, + attributes=attributes, + attribute_mapping=attribute_mapping, + request=request, ) # Update user with new attributes from incoming request if user is not None: user = self._update_user( - user, attributes, attribute_mapping, force_save=created) + user, attributes, attribute_mapping, force_save=created + ) if self.user_can_authenticate(user): return user - def _update_user(self, user, attributes: dict, attribute_mapping: dict, force_save: bool = False): - """ Update a user with a set of attributes and returns the updated user. + def _update_user( + self, user, attributes: dict, attribute_mapping: dict, force_save: bool = False + ): + """Update a user with a set of attributes and returns the updated user. - By default it uses a mapping defined in the settings constant - SAML_ATTRIBUTE_MAPPING. For each attribute, if the user object has - that field defined it will be set. + By default it uses a mapping defined in the settings constant + SAML_ATTRIBUTE_MAPPING. For each attribute, if the user object has + that field defined it will be set. """ # No attributes to set on the user instance, nothing to update @@ -166,7 +194,8 @@ def _update_user(self, user, attributes: dict, attribute_mapping: dict, force_sa attr_value_list = attributes.get(saml_attr) if not attr_value_list: logger.debug( - f'Could not find value for "{saml_attr}", not updating fields "{django_attrs}"') + f'Could not find value for "{saml_attr}", not updating fields "{django_attrs}"' + ) continue for attr in django_attrs: @@ -180,13 +209,11 @@ def _update_user(self, user, attributes: dict, attribute_mapping: dict, force_sa if callable(user_attr): modified = user_attr(attr_value_list) else: - modified = set_attribute( - user, attr, attr_value_list[0]) + modified = set_attribute(user, attr, attr_value_list[0]) has_updated_fields = has_updated_fields or modified else: - logger.debug( - f'Could not find attribute "{attr}" on user "{user}"') + logger.debug(f'Could not find attribute "{attr}" on user "{user}"') if has_updated_fields or force_save: user = self.save_user(user) @@ -198,11 +225,18 @@ def _update_user(self, user, attributes: dict, attribute_mapping: dict, force_sa # ############################################ def clean_attributes(self, attributes: dict, idp_entityid: str, **kwargs) -> dict: - """ Hook to clean or filter attributes from the SAML response. No-op by default. """ + """Hook to clean or filter attributes from the SAML response. No-op by default.""" return attributes - def is_authorized(self, attributes: dict, attribute_mapping: dict, idp_entityid: str, assertion_info: dict, **kwargs) -> bool: - """ Hook to allow custom authorization policies based on SAML attributes. True by default. """ + def is_authorized( + self, + attributes: dict, + attribute_mapping: dict, + idp_entityid: str, + assertion_info: dict, + **kwargs, + ) -> bool: + """Hook to allow custom authorization policies based on SAML attributes. True by default.""" return True def user_can_authenticate(self, user) -> bool: @@ -210,26 +244,35 @@ def user_can_authenticate(self, user) -> bool: Reject users with is_active=False. Custom user models that don't have that attribute are allowed. """ - is_active = getattr(user, 'is_active', None) + is_active = getattr(user, "is_active", None) return is_active or is_active is None def clean_user_main_attribute(self, main_attribute: Any) -> Any: - """ Hook to clean the extracted user-identifying value. No-op by default. """ + """Hook to clean the extracted user-identifying value. No-op by default.""" return main_attribute - def get_or_create_user(self, - user_lookup_key: str, user_lookup_value: Any, create_unknown_user: bool, - idp_entityid: str, attributes: dict, attribute_mapping: dict, request - ) -> 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. + def get_or_create_user( + self, + user_lookup_key: str, + user_lookup_value: Any, + create_unknown_user: bool, + idp_entityid: str, + attributes: dict, + attribute_mapping: dict, + request, + ) -> 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. """ UserModel = self._user_model # Construct query parameters to query the userModel with. An additional lookup modifier could be specified in the settings. user_query_args = { - user_lookup_key + getattr(settings, 'SAML_DJANGO_USER_MAIN_ATTRIBUTE_LOOKUP', ''): user_lookup_value + user_lookup_key + + getattr( + settings, "SAML_DJANGO_USER_MAIN_ATTRIBUTE_LOOKUP", "" + ): user_lookup_value } # Lookup existing user @@ -238,30 +281,35 @@ def get_or_create_user(self, try: user = UserModel.objects.get(**user_query_args) except MultipleObjectsReturned: - logger.error("Multiple users match, model: %s, lookup: %s", - UserModel._meta, user_query_args) + logger.error( + "Multiple users match, model: %s, lookup: %s", + UserModel._meta, + user_query_args, + ) except UserModel.DoesNotExist: # Create new one if desired by settings if create_unknown_user: user = UserModel(**{user_lookup_key: user_lookup_value}) created = True - logger.debug(f'New user created: {user}') + logger.debug(f"New user created: {user}") else: logger.error( - f'The user does not exist, model: {UserModel._meta}, lookup: {user_query_args}') + f"The user does not exist, model: {UserModel._meta}, lookup: {user_query_args}" + ) return user, created - def save_user(self, user: settings.AUTH_USER_MODEL, *args, **kwargs) -> settings.AUTH_USER_MODEL: - """ Hook to add custom logic around saving a user. Return the saved user instance. - """ + def save_user( + self, user: settings.AUTH_USER_MODEL, *args, **kwargs + ) -> settings.AUTH_USER_MODEL: + """Hook to add custom logic around saving a user. Return the saved user instance.""" is_new_instance = user.pk is None user.save() if is_new_instance: - logger.debug('New user created') + logger.debug("New user created") else: - logger.debug(f'User {user} updated with incoming attributes') + logger.debug(f"User {user} updated with incoming attributes") return user @@ -271,41 +319,60 @@ def save_user(self, user: settings.AUTH_USER_MODEL, *args, **kwargs) -> settings 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) + "get_attribute_value() is deprecated, look at the Saml2Backend on how to subclass it", + DeprecationWarning, + ) return self._get_attribute_value(django_field, attributes, attribute_mapping) 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) + "get_django_user_main_attribute() is deprecated, look at the Saml2Backend on how to subclass it", + DeprecationWarning, + ) return self._user_lookup_attribute 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) - return getattr(settings, 'SAML_DJANGO_USER_MAIN_ATTRIBUTE_LOOKUP', '') + "get_django_user_main_attribute_lookup() is deprecated, look at the Saml2Backend on how to subclass it", + DeprecationWarning, + ) + return getattr(settings, "SAML_DJANGO_USER_MAIN_ATTRIBUTE_LOOKUP", "") 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) - return {self.get_django_user_main_attribute() + self.get_django_user_main_attribute_lookup()} + "get_user_query_args() is deprecated, look at the Saml2Backend on how to subclass it", + DeprecationWarning, + ) + return { + self.get_django_user_main_attribute() + + self.get_django_user_main_attribute_lookup() + } def configure_user(self, user, attributes, attribute_mapping): warnings.warn( - "configure_user() is deprecated, look at the Saml2Backend on how to subclass it", DeprecationWarning) + "configure_user() is deprecated, look at the Saml2Backend on how to subclass it", + DeprecationWarning, + ) return self._update_user(user, attributes, attribute_mapping) 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) + "update_user() is deprecated, look at the Saml2Backend on how to subclass it", + DeprecationWarning, + ) return self._update_user(user, attributes, attribute_mapping) def _set_attribute(self, obj, attr, value): warnings.warn( - "_set_attribute() is deprecated, look at the Saml2Backend on how to subclass it", DeprecationWarning) + "_set_attribute() is deprecated, look at the Saml2Backend on how to subclass it", + DeprecationWarning, + ) return set_attribute(obj, attr, value) def get_saml_user_model(): warnings.warn( - "_set_attribute() is deprecated, look at the Saml2Backend on how to subclass it", DeprecationWarning) + "_set_attribute() is deprecated, look at the Saml2Backend on how to subclass it", + DeprecationWarning, + ) return Saml2Backend()._user_model diff --git a/djangosaml2/cache.py b/djangosaml2/cache.py index c6d2f16b..b3d31fc4 100644 --- a/djangosaml2/cache.py +++ b/djangosaml2/cache.py @@ -19,7 +19,7 @@ class DjangoSessionCacheAdapter(dict): """A cache of things that are stored in the Django Session""" - key_prefix = '_saml2' + key_prefix = "_saml2" def __init__(self, django_session, key_suffix): self.session = django_session @@ -43,14 +43,13 @@ def sync(self): self.session.modified = True -class OutstandingQueriesCache(object): +class OutstandingQueriesCache: """Handles the queries that have been sent to the IdP and have not been replied yet. """ def __init__(self, django_session): - self._db = DjangoSessionCacheAdapter( - django_session, '_outstanding_queries') + self._db = DjangoSessionCacheAdapter(django_session, "_outstanding_queries") def outstanding_queries(self): return self._db._get_objects() @@ -79,7 +78,7 @@ class IdentityCache(Cache): """ def __init__(self, django_session): - self._db = DjangoSessionCacheAdapter(django_session, '_identities') + self._db = DjangoSessionCacheAdapter(django_session, "_identities") self._sync = True @@ -89,4 +88,4 @@ class StateCache(DjangoSessionCacheAdapter): """ def __init__(self, django_session): - super().__init__(django_session, '_state') + super().__init__(django_session, "_state") diff --git a/djangosaml2/conf.py b/djangosaml2/conf.py index 811791fb..e4cf140a 100644 --- a/djangosaml2/conf.py +++ b/djangosaml2/conf.py @@ -20,47 +20,48 @@ from django.core.exceptions import ImproperlyConfigured from django.http import HttpRequest from django.utils.module_loading import import_string + from saml2.config import SPConfig from .utils import get_custom_setting def get_config_loader(path: str) -> Callable: - """ Import the function at a given path and return it - """ + """Import the function at a given path and return it""" try: config_loader = import_string(path) except ImportError as e: - raise ImproperlyConfigured( - f'Error importing SAML config loader {path}: "{e}"') + raise ImproperlyConfigured(f'Error importing SAML config loader {path}: "{e}"') if not callable(config_loader): - raise ImproperlyConfigured( - "SAML config loader must be a callable object.") + raise ImproperlyConfigured("SAML config loader must be a callable object.") return config_loader def config_settings_loader(request: Optional[HttpRequest] = None) -> SPConfig: - """ Utility function to load the pysaml2 configuration. - The configuration can be modified based on the request being passed. - This is the default config loader, which just loads the config from the settings. + """Utility function to load the pysaml2 configuration. + The configuration can be modified based on the request being passed. + This is the default config loader, which just loads the config from the settings. """ conf = SPConfig() conf.load(copy.deepcopy(settings.SAML_CONFIG)) return conf -def get_config(config_loader_path: Optional[Union[Callable, str]] = None, - request: Optional[HttpRequest] = None) -> SPConfig: - """ Load a config_loader function if necessary, and call that - function with the request as argument. - If the config_loader_path is a callable instead of a string, - no importing is necessary and it will be used directly. - Return the resulting SPConfig. +def get_config( + config_loader_path: Optional[Union[Callable, str]] = None, + request: Optional[HttpRequest] = None, +) -> SPConfig: + """Load a config_loader function if necessary, and call that + function with the request as argument. + If the config_loader_path is a callable instead of a string, + no importing is necessary and it will be used directly. + Return the resulting SPConfig. """ config_loader_path = config_loader_path or get_custom_setting( - 'SAML_CONFIG_LOADER', 'djangosaml2.conf.config_settings_loader') + "SAML_CONFIG_LOADER", "djangosaml2.conf.config_settings_loader" + ) if callable(config_loader_path): config_loader = config_loader_path diff --git a/djangosaml2/middleware.py b/djangosaml2/middleware.py index abebaae9..6418c473 100644 --- a/djangosaml2/middleware.py +++ b/djangosaml2/middleware.py @@ -2,19 +2,19 @@ from django import VERSION from django.conf import settings -from django.contrib.sessions.backends.base import UpdateError -from django.contrib.sessions.middleware import SessionMiddleware from django.core.exceptions import SuspiciousOperation from django.utils.cache import patch_vary_headers from django.utils.http import http_date +from django.contrib.sessions.backends.base import UpdateError +from django.contrib.sessions.middleware import SessionMiddleware -django_version = float('{}.{}'.format(*VERSION[:2])) -SAMESITE_NONE = None if django_version < 3.1 else 'None' +django_version = float("{}.{}".format(*VERSION[:2])) +SAMESITE_NONE = None if django_version < 3.1 else "None" class SamlSessionMiddleware(SessionMiddleware): - cookie_name = getattr(settings, 'SAML_SESSION_COOKIE_NAME', 'saml_session') + cookie_name = getattr(settings, "SAML_SESSION_COOKIE_NAME", "saml_session") def process_request(self, request): session_key = request.COOKIES.get(self.cookie_name, None) @@ -41,10 +41,10 @@ def process_response(self, request, response): domain=settings.SESSION_COOKIE_DOMAIN, samesite=SAMESITE_NONE, ) - patch_vary_headers(response, ('Cookie',)) + patch_vary_headers(response, ("Cookie",)) else: if accessed: - patch_vary_headers(response, ('Cookie',)) + patch_vary_headers(response, ("Cookie",)) # relies and the global one if (modified or settings.SESSION_SAVE_EVERY_REQUEST) and not empty: if request.saml_session.get_expire_at_browser_close(): @@ -69,10 +69,11 @@ def process_response(self, request, response): self.cookie_name, request.saml_session.session_key, max_age=max_age, - expires=expires, domain=settings.SESSION_COOKIE_DOMAIN, + expires=expires, + domain=settings.SESSION_COOKIE_DOMAIN, path=settings.SESSION_COOKIE_PATH, secure=settings.SESSION_COOKIE_SECURE or None, httponly=settings.SESSION_COOKIE_HTTPONLY or None, - samesite=SAMESITE_NONE + samesite=SAMESITE_NONE, ) return response diff --git a/djangosaml2/overrides.py b/djangosaml2/overrides.py index 4c158c54..f2b8ce34 100644 --- a/djangosaml2/overrides.py +++ b/djangosaml2/overrides.py @@ -1,9 +1,10 @@ import logging -import saml2.client from django.conf import settings -logger = logging.getLogger('djangosaml2') +import saml2.client + +logger = logging.getLogger("djangosaml2") class Saml2Client(saml2.client.Saml2Client): @@ -16,10 +17,14 @@ class Saml2Client(saml2.client.Saml2Client): """ def do_logout(self, *args, **kwargs): - if not kwargs.get('expected_binding'): + 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' - ' not defined. Default binding will be used.') + logger.warning( + "SAML_LOGOUT_REQUEST_PREFERRED_BINDING setting is" + " not defined. Default binding will be used." + ) return super().do_logout(*args, **kwargs) diff --git a/djangosaml2/templatetags/idplist.py b/djangosaml2/templatetags/idplist.py index cf4eccad..47959704 100644 --- a/djangosaml2/templatetags/idplist.py +++ b/djangosaml2/templatetags/idplist.py @@ -21,14 +21,13 @@ class IdPListNode(template.Node): - def __init__(self, variable_name): self.variable_name = variable_name def render(self, context): conf = config_settings_loader() context[self.variable_name] = available_idps(conf) - return '' + return "" @register.tag @@ -37,9 +36,11 @@ def idplist(parser, token): tag_name, as_part, variable = token.split_contents() except ValueError: raise template.TemplateSyntaxError( - '%r tag requires two arguments' % token.contents.split()[0]) - if not as_part == 'as': + "%r tag requires two arguments" % token.contents.split()[0] + ) + if not as_part == "as": raise template.TemplateSyntaxError( - '%r tag first argument must be the literal "as"' % tag_name) + '%r tag first argument must be the literal "as"' % tag_name + ) return IdPListNode(variable) diff --git a/djangosaml2/tests/__init__.py b/djangosaml2/tests/__init__.py index 867c05c7..03e0e93a 100644 --- a/djangosaml2/tests/__init__.py +++ b/djangosaml2/tests/__init__.py @@ -23,27 +23,34 @@ from django import http from django.conf import settings -from django.contrib.auth import SESSION_KEY, get_user_model -from django.contrib.auth.models import AnonymousUser from django.core.exceptions import ImproperlyConfigured from django.test import Client, TestCase, override_settings from django.test.client import RequestFactory from django.urls import reverse, reverse_lazy + +from django.contrib.auth import SESSION_KEY, get_user_model +from django.contrib.auth.models import AnonymousUser + +from saml2.config import SPConfig +from saml2.s_utils import ( + UnknownSystemEntity, + decode_base64_and_inflate, + deflate_and_base64_encode, +) + from djangosaml2 import views from djangosaml2.cache import OutstandingQueriesCache from djangosaml2.conf import get_config from djangosaml2.middleware import SamlSessionMiddleware from djangosaml2.tests import conf -from djangosaml2.utils import (get_fallback_login_redirect_url, - get_idp_sso_supported_bindings, - get_session_id_from_saml2, - get_subject_id_from_saml2, - saml2_from_httpredirect_request) -from djangosaml2.views import (EchoAttributesView, finish_logout) -from saml2.config import SPConfig -from saml2.s_utils import (decode_base64_and_inflate, - deflate_and_base64_encode, - UnknownSystemEntity) +from djangosaml2.utils import ( + get_fallback_login_redirect_url, + get_idp_sso_supported_bindings, + get_session_id_from_saml2, + get_subject_id_from_saml2, + saml2_from_httpredirect_request, +) +from djangosaml2.views import EchoAttributesView, finish_logout from .auth_response import auth_response from .utils import SAMLPostFormParser @@ -54,7 +61,7 @@ def dummy_loader(request): - return 'dummy_loader' + return "dummy_loader" def dummy_get_response(request: http.HttpRequest): @@ -66,92 +73,110 @@ def dummy_get_response(request: http.HttpRequest): return http.HttpResponse("Session test") -non_callable = 'just a string' +non_callable = "just a string" class UtilsTests(TestCase): def test_get_config_valid_path(self): - self.assertEqual(get_config( - 'djangosaml2.tests.dummy_loader'), 'dummy_loader') + self.assertEqual(get_config("djangosaml2.tests.dummy_loader"), "dummy_loader") def test_get_config_wrongly_formatted_path(self): - with self.assertRaisesMessage(ImproperlyConfigured, 'SAML config loader must be a callable object.'): - get_config('djangosaml2.tests.non_callable') + with self.assertRaisesMessage( + ImproperlyConfigured, "SAML config loader must be a callable object." + ): + get_config("djangosaml2.tests.non_callable") def test_get_config_nonsense_path(self): - with self.assertRaisesMessage(ImproperlyConfigured, 'Error importing SAML config loader lalala.nonexisting.blabla: "No module named \'lalala\'"'): - get_config('lalala.nonexisting.blabla') + with self.assertRaisesMessage( + ImproperlyConfigured, + "Error importing SAML config loader lalala.nonexisting.blabla: \"No module named 'lalala'\"", + ): + get_config("lalala.nonexisting.blabla") def test_get_config_missing_function(self): - with self.assertRaisesMessage(ImproperlyConfigured, 'Module "djangosaml2.tests" does not define a "nonexisting_function" attribute/class'): - get_config('djangosaml2.tests.nonexisting_function') + with self.assertRaisesMessage( + ImproperlyConfigured, + 'Module "djangosaml2.tests" does not define a "nonexisting_function" attribute/class', + ): + get_config("djangosaml2.tests.nonexisting_function") - @override_settings(LOGIN_REDIRECT_URL='/accounts/profile/') + @override_settings(LOGIN_REDIRECT_URL="/accounts/profile/") def test_get_fallback_login_redirect_url(self): - self.assertEqual(get_fallback_login_redirect_url(), '/accounts/profile/') + self.assertEqual(get_fallback_login_redirect_url(), "/accounts/profile/") with override_settings(): del settings.LOGIN_REDIRECT_URL # Neither LOGIN_REDIRECT_URL nor ACS_DEFAULT_REDIRECT_URL is configured - self.assertEqual(get_fallback_login_redirect_url(), '/') + self.assertEqual(get_fallback_login_redirect_url(), "/") - with override_settings(ACS_DEFAULT_REDIRECT_URL='testprofiles:dashboard'): + with override_settings(ACS_DEFAULT_REDIRECT_URL="testprofiles:dashboard"): # ACS_DEFAULT_REDIRECT_URL is configured, so it is used (and resolved) - self.assertEqual(get_fallback_login_redirect_url(), '/dashboard/') + self.assertEqual(get_fallback_login_redirect_url(), "/dashboard/") - with override_settings(ACS_DEFAULT_REDIRECT_URL=reverse_lazy('testprofiles:dashboard')): + with override_settings( + ACS_DEFAULT_REDIRECT_URL=reverse_lazy("testprofiles:dashboard") + ): # Lazy urls are resolved - self.assertEqual(get_fallback_login_redirect_url(), '/dashboard/') + self.assertEqual(get_fallback_login_redirect_url(), "/dashboard/") class SAML2Tests(TestCase): - urls = 'djangosaml2.tests.urls' + urls = "djangosaml2.tests.urls" def init_cookies(self): - self.client.cookies[settings.SESSION_COOKIE_NAME] = 'testing' + self.client.cookies[settings.SESSION_COOKIE_NAME] = "testing" def add_outstanding_query(self, session_id, came_from): - settings.SESSION_ENGINE = 'django.contrib.sessions.backends.db' + settings.SESSION_ENGINE = "django.contrib.sessions.backends.db" engine = import_module(settings.SESSION_ENGINE) self.saml_session = engine.SessionStore() self.saml_session.save() self.oq_cache = OutstandingQueriesCache(self.saml_session) - self.oq_cache.set(session_id - if isinstance(session_id, str) else session_id.decode(), - came_from) + self.oq_cache.set( + session_id if isinstance(session_id, str) else session_id.decode(), + 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') + def b64_for_post(self, xml_text, encoding="utf-8"): + return base64.b64encode(xml_text.encode(encoding)).decode("ascii") def test_get_idp_sso_supported_bindings_noargs(self): settings.SAML_CONFIG = conf.create_conf( - sp_host='sp.example.com', - idp_hosts=['idp.example.com'], - metadata_file='remote_metadata_one_idp.xml', + sp_host="sp.example.com", + idp_hosts=["idp.example.com"], + metadata_file="remote_metadata_one_idp.xml", + ) + idp_id = "https://idp.example.com/simplesaml/saml2/idp/metadata.php" + self.assertEqual( + get_idp_sso_supported_bindings()[0], + list( + settings.SAML_CONFIG["service"]["sp"]["idp"][idp_id][ + "single_sign_on_service" + ].keys() + )[0], ) - idp_id = 'https://idp.example.com/simplesaml/saml2/idp/metadata.php' - self.assertEqual(get_idp_sso_supported_bindings()[0], list( - settings.SAML_CONFIG['service']['sp']['idp'][idp_id]['single_sign_on_service'].keys())[0]) def test_get_idp_sso_supported_bindings_unknown_idp(self): settings.SAML_CONFIG = conf.create_conf( - sp_host='sp.example.com', - idp_hosts=['idp.example.com'], - metadata_file='remote_metadata_one_idp.xml', + sp_host="sp.example.com", + idp_hosts=["idp.example.com"], + metadata_file="remote_metadata_one_idp.xml", ) with self.assertRaises(UnknownSystemEntity): - get_idp_sso_supported_bindings(idp_entity_id='random') + get_idp_sso_supported_bindings(idp_entity_id="random") def test_get_idp_sso_supported_bindings_no_idps(self): settings.SAML_CONFIG = conf.create_conf( - sp_host='sp.example.com', + sp_host="sp.example.com", idp_hosts=[], - metadata_file='remote_metadata_no_idp.xml', + metadata_file="remote_metadata_no_idp.xml", ) with self.assertRaisesMessage(ImproperlyConfigured, "No IdP configured!"): get_idp_sso_supported_bindings() @@ -164,23 +189,24 @@ def test_unsigned_post_authn_request(self): https://github.com/knaperek/djangosaml2/issues/168 """ settings.SAML_CONFIG = conf.create_conf( - sp_host='sp.example.com', - idp_hosts=['idp.example.com'], - metadata_file='remote_metadata_post_binding.xml', - authn_requests_signed=False + sp_host="sp.example.com", + idp_hosts=["idp.example.com"], + metadata_file="remote_metadata_post_binding.xml", + authn_requests_signed=False, ) - response = self.client.get(reverse('saml2_login')) + response = self.client.get(reverse("saml2_login")) self.assertEqual(response.status_code, 200) # Using POST-binding returns a page with form containing the SAMLRequest response_parser = SAMLPostFormParser() - response_parser.feed(response.content.decode('utf-8')) + response_parser.feed(response.content.decode("utf-8")) saml_request = response_parser.saml_request_value self.assertIsNotNone(saml_request) - self.assertIn('AuthnRequest xmlns', base64.b64decode( - saml_request).decode('utf-8')) + self.assertIn( + "AuthnRequest xmlns", base64.b64decode(saml_request).decode("utf-8") + ) def test_login_evil_redirect(self): """ @@ -190,29 +216,31 @@ def test_login_evil_redirect(self): # monkey patch SAML configuration settings.SAML_CONFIG = conf.create_conf( - sp_host='sp.example.com', - idp_hosts=['idp.example.com'], - metadata_file='remote_metadata_one_idp.xml', + sp_host="sp.example.com", + idp_hosts=["idp.example.com"], + metadata_file="remote_metadata_one_idp.xml", ) - for redirect_url in ['/dashboard/', 'testprofiles:dashboard']: + for redirect_url in ["/dashboard/", "testprofiles:dashboard"]: with self.subTest(LOGIN_REDIRECT_URL=redirect_url): with override_settings(LOGIN_REDIRECT_URL=redirect_url): response = self.client.get( - reverse('saml2_login') + '?next=http://evil.com') - url = urlparse(response['Location']) + reverse("saml2_login") + "?next=http://evil.com" + ) + url = urlparse(response["Location"]) params = parse_qs(url.query) - self.assertEqual(params['RelayState'], ['/dashboard/']) + self.assertEqual(params["RelayState"], ["/dashboard/"]) with self.subTest(ACS_DEFAULT_REDIRECT_URL=redirect_url): with override_settings(ACS_DEFAULT_REDIRECT_URL=redirect_url): response = self.client.get( - reverse('saml2_login') + '?next=http://evil.com') - url = urlparse(response['Location']) + reverse("saml2_login") + "?next=http://evil.com" + ) + url = urlparse(response["Location"]) params = parse_qs(url.query) - self.assertEqual(params['RelayState'], ['/dashboard/']) + self.assertEqual(params["RelayState"], ["/dashboard/"]) def test_no_redirect(self): """ @@ -222,66 +250,70 @@ def test_no_redirect(self): # monkey patch SAML configuration settings.SAML_CONFIG = conf.create_conf( - sp_host='sp.example.com', - idp_hosts=['idp.example.com'], - metadata_file='remote_metadata_one_idp.xml', + sp_host="sp.example.com", + idp_hosts=["idp.example.com"], + metadata_file="remote_metadata_one_idp.xml", ) - for redirect_url in ['/dashboard/', 'testprofiles:dashboard']: + for redirect_url in ["/dashboard/", "testprofiles:dashboard"]: with self.subTest(LOGIN_REDIRECT_URL=redirect_url): with override_settings(LOGIN_REDIRECT_URL=redirect_url): - response = self.client.get(reverse('saml2_login') + '?next=') - url = urlparse(response['Location']) + response = self.client.get(reverse("saml2_login") + "?next=") + url = urlparse(response["Location"]) params = parse_qs(url.query) - self.assertEqual(params['RelayState'], ['/dashboard/']) + self.assertEqual(params["RelayState"], ["/dashboard/"]) with self.subTest(ACS_DEFAULT_REDIRECT_URL=redirect_url): with override_settings(ACS_DEFAULT_REDIRECT_URL=redirect_url): - response = self.client.get(reverse('saml2_login') + '?next=') - url = urlparse(response['Location']) + response = self.client.get(reverse("saml2_login") + "?next=") + url = urlparse(response["Location"]) params = parse_qs(url.query) - self.assertEqual(params['RelayState'], ['/dashboard/']) + self.assertEqual(params["RelayState"], ["/dashboard/"]) @override_settings(SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN=True) def test_login_already_logged(self): - self.client.force_login(User.objects.create(username='user', password='pass')) + self.client.force_login(User.objects.create(username="user", password="pass")) - for redirect_url in ['/dashboard/', 'testprofiles:dashboard']: + for redirect_url in ["/dashboard/", "testprofiles:dashboard"]: with self.subTest(LOGIN_REDIRECT_URL=redirect_url): with override_settings(LOGIN_REDIRECT_URL=redirect_url): - with self.subTest('no next url'): - response = self.client.get(reverse('saml2_login')) - self.assertRedirects(response, '/dashboard/') + with self.subTest("no next url"): + response = self.client.get(reverse("saml2_login")) + self.assertRedirects(response, "/dashboard/") - with self.subTest('evil next url'): - response = self.client.get(reverse('saml2_login') + '?next=http://evil.com') - self.assertRedirects(response, '/dashboard/') + with self.subTest("evil next url"): + response = self.client.get( + reverse("saml2_login") + "?next=http://evil.com" + ) + self.assertRedirects(response, "/dashboard/") with self.subTest(ACS_DEFAULT_REDIRECT_URL=redirect_url): with override_settings(ACS_DEFAULT_REDIRECT_URL=redirect_url): - with self.subTest('no next url'): - response = self.client.get(reverse('saml2_login')) - self.assertRedirects(response, '/dashboard/') + with self.subTest("no next url"): + response = self.client.get(reverse("saml2_login")) + self.assertRedirects(response, "/dashboard/") - with self.subTest('evil next url'): - response = self.client.get(reverse('saml2_login') + '?next=http://evil.com') - self.assertRedirects(response, '/dashboard/') + with self.subTest("evil next url"): + response = self.client.get( + reverse("saml2_login") + "?next=http://evil.com" + ) + self.assertRedirects(response, "/dashboard/") def test_unknown_idp(self): # monkey patch SAML configuration settings.SAML_CONFIG = conf.create_conf( - sp_host='sp.example.com', - metadata_file='remote_metadata_three_idps.xml', + sp_host="sp.example.com", + metadata_file="remote_metadata_three_idps.xml", ) - response = self.client.get(reverse('saml2_login')+'?idp=https://unknown.org') + response = self.client.get(reverse("saml2_login") + "?idp=https://unknown.org") self.assertEqual(response.status_code, 403) - def test_login_authn_context(self): - sp_kwargs = {"requested_authn_context": { + sp_kwargs = { + "requested_authn_context": { "authn_context_class_ref": [ "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport", "urn:oasis:names:tc:SAML:2.0:ac:classes:TLSClient", @@ -292,252 +324,270 @@ def test_login_authn_context(self): # monkey patch SAML configuration settings.SAML_CONFIG = conf.create_conf( - sp_host='sp.example.com', - idp_hosts=['idp.example.com'], - metadata_file='remote_metadata_one_idp.xml', - sp_kwargs=sp_kwargs + sp_host="sp.example.com", + idp_hosts=["idp.example.com"], + metadata_file="remote_metadata_one_idp.xml", + sp_kwargs=sp_kwargs, ) - response = self.client.get(reverse('saml2_login')) + response = self.client.get(reverse("saml2_login")) self.assertEqual(response.status_code, 302) - location = response['Location'] + location = response["Location"] url = urlparse(location) - self.assertEqual(url.hostname, 'idp.example.com') - self.assertEqual(url.path, '/simplesaml/saml2/idp/SSOService.php') + self.assertEqual(url.hostname, "idp.example.com") + self.assertEqual(url.path, "/simplesaml/saml2/idp/SSOService.php") params = parse_qs(url.query) - self.assertIn('SAMLRequest', params) - - saml_request = params['SAMLRequest'][0] - self.assertIn('urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport', decode_base64_and_inflate( - saml_request).decode('utf-8')) + self.assertIn("SAMLRequest", params) + saml_request = params["SAMLRequest"][0] + self.assertIn( + "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport", + decode_base64_and_inflate(saml_request).decode("utf-8"), + ) def test_login_one_idp(self): # monkey patch SAML configuration settings.SAML_CONFIG = conf.create_conf( - sp_host='sp.example.com', - idp_hosts=['idp.example.com'], - metadata_file='remote_metadata_one_idp.xml', + sp_host="sp.example.com", + idp_hosts=["idp.example.com"], + metadata_file="remote_metadata_one_idp.xml", ) - response = self.client.get(reverse('saml2_login')) + response = self.client.get(reverse("saml2_login")) self.assertEqual(response.status_code, 302) - location = response['Location'] + location = response["Location"] url = urlparse(location) - self.assertEqual(url.hostname, 'idp.example.com') - self.assertEqual(url.path, '/simplesaml/saml2/idp/SSOService.php') + self.assertEqual(url.hostname, "idp.example.com") + self.assertEqual(url.path, "/simplesaml/saml2/idp/SSOService.php") params = parse_qs(url.query) - self.assertIn('SAMLRequest', params) - self.assertIn('RelayState', params) - - saml_request = params['SAMLRequest'][0] - self.assertIn('AuthnRequest xmlns', decode_base64_and_inflate( - saml_request).decode('utf-8')) + self.assertIn("SAMLRequest", params) + self.assertIn("RelayState", params) + saml_request = params["SAMLRequest"][0] + self.assertIn( + "AuthnRequest xmlns", + decode_base64_and_inflate(saml_request).decode("utf-8"), + ) # if we set a next arg in the login view, it is preserverd # in the RelayState argument - nexturl = '/another-view/' - response = self.client.get(reverse('saml2_login'), {'next': nexturl}) + nexturl = "/another-view/" + response = self.client.get(reverse("saml2_login"), {"next": nexturl}) self.assertEqual(response.status_code, 302) - location = response['Location'] + location = response["Location"] url = urlparse(location) - self.assertEqual(url.hostname, 'idp.example.com') - self.assertEqual(url.path, '/simplesaml/saml2/idp/SSOService.php') + self.assertEqual(url.hostname, "idp.example.com") + self.assertEqual(url.path, "/simplesaml/saml2/idp/SSOService.php") params = parse_qs(url.query) - self.assertIn('SAMLRequest', params) - self.assertIn('RelayState', params) - self.assertEqual(params['RelayState'][0], nexturl) + self.assertIn("SAMLRequest", params) + self.assertIn("RelayState", params) + self.assertEqual(params["RelayState"][0], nexturl) def test_login_several_idps(self): settings.SAML_CONFIG = conf.create_conf( - sp_host='sp.example.com', - idp_hosts=['idp1.example.com', - 'idp2.example.com', - 'idp3.example.com'], - metadata_file='remote_metadata_three_idps.xml', + sp_host="sp.example.com", + idp_hosts=["idp1.example.com", "idp2.example.com", "idp3.example.com"], + metadata_file="remote_metadata_three_idps.xml", ) - response = self.client.get(reverse('saml2_login')) + response = self.client.get(reverse("saml2_login")) # a WAYF page should be displayed - self.assertContains(response, 'Where are you from?', status_code=200) + self.assertContains(response, "Where are you from?", status_code=200) for i in range(1, 4): - link = '/login/?idp=https://idp%d.example.com/simplesaml/saml2/idp/metadata.php&next=/' + link = "/login/?idp=https://idp%d.example.com/simplesaml/saml2/idp/metadata.php&next=/" self.assertContains(response, link % i) # click on the second idp - response = self.client.get(reverse('saml2_login'), { - 'idp': 'https://idp2.example.com/simplesaml/saml2/idp/metadata.php', - 'next': '/', - }) + response = self.client.get( + reverse("saml2_login"), + { + "idp": "https://idp2.example.com/simplesaml/saml2/idp/metadata.php", + "next": "/", + }, + ) self.assertEqual(response.status_code, 302) - location = response['Location'] + location = response["Location"] url = urlparse(location) - self.assertEqual(url.hostname, 'idp2.example.com') - self.assertEqual(url.path, '/simplesaml/saml2/idp/SSOService.php') + self.assertEqual(url.hostname, "idp2.example.com") + self.assertEqual(url.path, "/simplesaml/saml2/idp/SSOService.php") params = parse_qs(url.query) - self.assertIn('SAMLRequest', params) - self.assertIn('RelayState', params) + self.assertIn("SAMLRequest", params) + self.assertIn("RelayState", params) - saml_request = params['SAMLRequest'][0] - self.assertIn('AuthnRequest xmlns', decode_base64_and_inflate( - saml_request).decode('utf-8')) + saml_request = params["SAMLRequest"][0] + self.assertIn( + "AuthnRequest xmlns", + decode_base64_and_inflate(saml_request).decode("utf-8"), + ) - @override_settings(ACS_DEFAULT_REDIRECT_URL='testprofiles:dashboard') + @override_settings(ACS_DEFAULT_REDIRECT_URL="testprofiles:dashboard") def test_assertion_consumer_service(self): # Get initial number of users initial_user_count = User.objects.count() settings.SAML_CONFIG = conf.create_conf( - sp_host='sp.example.com', - idp_hosts=['idp.example.com'], - metadata_file='remote_metadata_one_idp.xml', + sp_host="sp.example.com", + idp_hosts=["idp.example.com"], + metadata_file="remote_metadata_one_idp.xml", ) - response = self.client.get(reverse('saml2_login')) + response = self.client.get(reverse("saml2_login")) saml2_req = saml2_from_httpredirect_request(response.url) session_id = get_session_id_from_saml2(saml2_req) # session_id should start with a letter since it is a NCName - came_from = '/another-view/' + came_from = "/another-view/" self.add_outstanding_query(session_id, came_from) # this will create a user - saml_response = auth_response(session_id, 'student') - _url = reverse('saml2_acs') - response = self.client.post(_url, { - 'SAMLResponse': self.b64_for_post(saml_response), - 'RelayState': came_from, - }) + saml_response = auth_response(session_id, "student") + _url = reverse("saml2_acs") + response = self.client.post( + _url, + { + "SAMLResponse": self.b64_for_post(saml_response), + "RelayState": came_from, + }, + ) self.assertEqual(response.status_code, 302) - location = response['Location'] + location = response["Location"] url = urlparse(location) self.assertEqual(url.path, came_from) self.assertEqual(User.objects.count(), initial_user_count + 1) user_id = self.client.session[SESSION_KEY] user = User.objects.get(id=user_id) - self.assertEqual(user.username, 'student') + self.assertEqual(user.username, "student") # let's create another user and log in with that one - new_user = User.objects.create(username='teacher', password='not-used') + new_user = User.objects.create(username="teacher", password="not-used") # session_id = "a1111111111111111111111111111111" client = Client() - response = client.get(reverse('saml2_login')) + response = client.get(reverse("saml2_login")) saml2_req = saml2_from_httpredirect_request(response.url) session_id = get_session_id_from_saml2(saml2_req) - came_from = '' # bad, let's see if we can deal with this - saml_response = auth_response(session_id, 'teacher') - self.add_outstanding_query(session_id, '/') - response = client.post(reverse('saml2_acs'), { - 'SAMLResponse': self.b64_for_post(saml_response), - 'RelayState': came_from, - }) + came_from = "" # bad, let's see if we can deal with this + saml_response = auth_response(session_id, "teacher") + self.add_outstanding_query(session_id, "/") + response = client.post( + reverse("saml2_acs"), + { + "SAMLResponse": self.b64_for_post(saml_response), + "RelayState": came_from, + }, + ) # as the RelayState is empty we have redirect to ACS_DEFAULT_REDIRECT_URL - self.assertRedirects(response, '/dashboard/') + self.assertRedirects(response, "/dashboard/") self.assertEqual(str(new_user.id), client.session[SESSION_KEY]) - @override_settings(ACS_DEFAULT_REDIRECT_URL='testprofiles:dashboard') + @override_settings(ACS_DEFAULT_REDIRECT_URL="testprofiles:dashboard") def test_assertion_consumer_service_default_relay_state(self): settings.SAML_CONFIG = conf.create_conf( - sp_host='sp.example.com', - idp_hosts=['idp.example.com'], - metadata_file='remote_metadata_one_idp.xml', + sp_host="sp.example.com", + idp_hosts=["idp.example.com"], + metadata_file="remote_metadata_one_idp.xml", ) - new_user = User.objects.create(username='teacher', password='not-used') + new_user = User.objects.create(username="teacher", password="not-used") - response = self.client.get(reverse('saml2_login')) + response = self.client.get(reverse("saml2_login")) saml2_req = saml2_from_httpredirect_request(response.url) session_id = get_session_id_from_saml2(saml2_req) - saml_response = auth_response(session_id, 'teacher') - self.add_outstanding_query(session_id, '/') - response = self.client.post(reverse('saml2_acs'), { - 'SAMLResponse': self.b64_for_post(saml_response), - }) + saml_response = auth_response(session_id, "teacher") + self.add_outstanding_query(session_id, "/") + response = self.client.post( + reverse("saml2_acs"), + { + "SAMLResponse": self.b64_for_post(saml_response), + }, + ) self.assertEqual(response.status_code, 302) # The RelayState is missing, redirect to ACS_DEFAULT_REDIRECT_URL - self.assertRedirects(response, '/dashboard/') + self.assertRedirects(response, "/dashboard/") self.assertEqual(str(new_user.id), self.client.session[SESSION_KEY]) def test_assertion_consumer_service_already_logged_in_allowed(self): - self.client.force_login(User.objects.create( - username='user', password='pass')) + self.client.force_login(User.objects.create(username="user", password="pass")) settings.SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN = True - came_from = '/dummy-url/' - response = self.client.get( - reverse('saml2_login') + f'?next={came_from}') + came_from = "/dummy-url/" + response = self.client.get(reverse("saml2_login") + f"?next={came_from}") self.assertEqual(response.status_code, 302) - url = urlparse(response['Location']) + url = urlparse(response["Location"]) self.assertEqual(url.path, came_from) def test_assertion_consumer_service_already_logged_in_error(self): - self.client.force_login(User.objects.create( - username='user', password='pass')) + self.client.force_login(User.objects.create(username="user", password="pass")) settings.SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN = False - came_from = '/dummy-url/' - response = self.client.get( - reverse('saml2_login') + f'?next={came_from}') + came_from = "/dummy-url/" + response = self.client.get(reverse("saml2_login") + f"?next={came_from}") self.assertEqual(response.status_code, 200) self.assertInHTML( - "

You are already logged in and you are trying to go to the login page again.

", response.content.decode()) + "

You are already logged in and you are trying to go to the login page again.

", + response.content.decode(), + ) def test_assertion_consumer_service_no_session(self): settings.SAML_CONFIG = conf.create_conf( - sp_host='sp.example.com', - idp_hosts=['idp.example.com'], - metadata_file='remote_metadata_one_idp.xml', + sp_host="sp.example.com", + idp_hosts=["idp.example.com"], + metadata_file="remote_metadata_one_idp.xml", ) - response = self.client.get(reverse('saml2_login')) + response = self.client.get(reverse("saml2_login")) saml2_req = saml2_from_httpredirect_request(response.url) session_id = get_session_id_from_saml2(saml2_req) # session_id should start with a letter since it is a NCName - came_from = '/another-view/' + came_from = "/another-view/" self.add_outstanding_query(session_id, came_from) # Authentication is confirmed. - saml_response = auth_response(session_id, 'student') - response = self.client.post(reverse('saml2_acs'), { - 'SAMLResponse': self.b64_for_post(saml_response), - 'RelayState': came_from, - }) + saml_response = auth_response(session_id, "student") + response = self.client.post( + reverse("saml2_acs"), + { + "SAMLResponse": self.b64_for_post(saml_response), + "RelayState": came_from, + }, + ) self.assertEqual(response.status_code, 302) - location = response['Location'] + location = response["Location"] url = urlparse(location) self.assertEqual(url.path, came_from) # Session should no longer be in outstanding queries. - saml_response = auth_response(session_id, 'student') - response = self.client.post(reverse('saml2_acs'), { - 'SAMLResponse': self.b64_for_post(saml_response), - 'RelayState': came_from, - }) + saml_response = auth_response(session_id, "student") + response = self.client.post( + reverse("saml2_acs"), + { + "SAMLResponse": self.b64_for_post(saml_response), + "RelayState": came_from, + }, + ) self.assertEqual(response.status_code, 403) def test_missing_param_to_assertion_consumer_service_request(self): # Send request without SAML2Response parameter - response = self.client.post(reverse('saml2_acs')) + response = self.client.post(reverse("saml2_acs")) # Assert that view responded with "Bad Request" error self.assertEqual(response.status_code, 400) def test_bad_request_method_to_assertion_consumer_service(self): # Send request with non-POST method. - response = self.client.get(reverse('saml2_acs')) + response = self.client.get(reverse("saml2_acs")) # Assert that view responded with method not allowed status self.assertEqual(response.status_code, 405) @@ -545,33 +595,36 @@ def do_login(self): """Auxiliary method used in several tests (mainly logout tests)""" self.init_cookies() - response = self.client.get(reverse('saml2_login')) + response = self.client.get(reverse("saml2_login")) saml2_req = saml2_from_httpredirect_request(response.url) session_id = get_session_id_from_saml2(saml2_req) # session_id should start with a letter since it is a NCName - came_from = '/another-view/' + came_from = "/another-view/" self.add_outstanding_query(session_id, came_from) - saml_response = auth_response(session_id, 'student') + saml_response = auth_response(session_id, "student") # this will create a user - response = self.client.post(reverse('saml2_acs'), { - 'SAMLResponse': self.b64_for_post(saml_response), - 'RelayState': came_from, - }) + response = self.client.post( + reverse("saml2_acs"), + { + "SAMLResponse": self.b64_for_post(saml_response), + "RelayState": came_from, + }, + ) subject_id = get_subject_id_from_saml2(saml_response) self.assertEqual(response.status_code, 302) return subject_id def test_echo_view_no_saml_session(self): settings.SAML_CONFIG = conf.create_conf( - sp_host='sp.example.com', - idp_hosts=['idp.example.com'], - metadata_file='remote_metadata_one_idp.xml', + sp_host="sp.example.com", + idp_hosts=["idp.example.com"], + metadata_file="remote_metadata_one_idp.xml", ) self.do_login() - request = RequestFactory().get('/bar/foo') + request = RequestFactory().get("/bar/foo") request.COOKIES = self.client.cookies request.user = User.objects.last() @@ -580,258 +633,289 @@ def test_echo_view_no_saml_session(self): response = EchoAttributesView.as_view()(request) self.assertEqual(response.status_code, 200) - self.assertEqual(response.content.decode( - ), 'No active SAML identity found. Are you sure you have logged in via SAML?') + self.assertEqual( + response.content.decode(), + "No active SAML identity found. Are you sure you have logged in via SAML?", + ) def test_echo_view_success(self): settings.SAML_CONFIG = conf.create_conf( - sp_host='sp.example.com', - idp_hosts=['idp.example.com'], - metadata_file='remote_metadata_one_idp.xml', + sp_host="sp.example.com", + idp_hosts=["idp.example.com"], + metadata_file="remote_metadata_one_idp.xml", ) self.do_login() - request = RequestFactory().get('/') + request = RequestFactory().get("/") request.user = User.objects.last() middleware = SamlSessionMiddleware(dummy_get_response) middleware.process_request(request) saml_session_name = getattr( - settings, 'SAML_SESSION_COOKIE_NAME', 'saml_session') + settings, "SAML_SESSION_COOKIE_NAME", "saml_session" + ) getattr(request, saml_session_name)[ - '_saml2_subject_id'] = '1f87035b4c1325b296a53d92097e6b3fa36d7e30ee82e3fcb0680d60243c1f03' + "_saml2_subject_id" + ] = "1f87035b4c1325b296a53d92097e6b3fa36d7e30ee82e3fcb0680d60243c1f03" getattr(request, saml_session_name).save() response = EchoAttributesView.as_view()(request) self.assertEqual(response.status_code, 200) - self.assertIn('

SAML attributes

', - response.content.decode(), 'Echo page not rendered') + self.assertIn( + "

SAML attributes

", + response.content.decode(), + "Echo page not rendered", + ) def test_logout(self): settings.SAML_CONFIG = conf.create_conf( - sp_host='sp.example.com', - idp_hosts=['idp.example.com'], - metadata_file='remote_metadata_one_idp.xml', + sp_host="sp.example.com", + idp_hosts=["idp.example.com"], + metadata_file="remote_metadata_one_idp.xml", ) self.do_login() - response = self.client.get(reverse('saml2_logout')) + response = self.client.get(reverse("saml2_logout")) self.assertEqual(response.status_code, 302) - location = response['Location'] + location = response["Location"] url = urlparse(location) - self.assertEqual(url.hostname, 'idp.example.com') - self.assertEqual(url.path, - '/simplesaml/saml2/idp/SingleLogoutService.php') + self.assertEqual(url.hostname, "idp.example.com") + self.assertEqual(url.path, "/simplesaml/saml2/idp/SingleLogoutService.php") params = parse_qs(url.query) - self.assertIn('SAMLRequest', params) + self.assertIn("SAMLRequest", params) - saml_request = params['SAMLRequest'][0] + saml_request = params["SAMLRequest"][0] - self.assertIn('LogoutRequest xmlns', decode_base64_and_inflate( - saml_request).decode('utf-8'), 'Not a valid LogoutRequest') + self.assertIn( + "LogoutRequest xmlns", + decode_base64_and_inflate(saml_request).decode("utf-8"), + "Not a valid LogoutRequest", + ) def test_logout_service_local(self): settings.SAML_CONFIG = conf.create_conf( - sp_host='sp.example.com', - idp_hosts=['idp.example.com'], - metadata_file='remote_metadata_one_idp.xml', + sp_host="sp.example.com", + idp_hosts=["idp.example.com"], + metadata_file="remote_metadata_one_idp.xml", ) self.do_login() - response = self.client.get(reverse('saml2_logout')) + response = self.client.get(reverse("saml2_logout")) self.assertEqual(response.status_code, 302) - location = response['Location'] + location = response["Location"] url = urlparse(location) - self.assertEqual(url.hostname, 'idp.example.com') - self.assertEqual(url.path, - '/simplesaml/saml2/idp/SingleLogoutService.php') + self.assertEqual(url.hostname, "idp.example.com") + self.assertEqual(url.path, "/simplesaml/saml2/idp/SingleLogoutService.php") params = parse_qs(url.query) - self.assertIn('SAMLRequest', params) + self.assertIn("SAMLRequest", params) - saml_request = params['SAMLRequest'][0] + saml_request = params["SAMLRequest"][0] - self.assertIn('LogoutRequest xmlns', decode_base64_and_inflate( - saml_request).decode('utf-8'), 'Not a valid LogoutRequest') + self.assertIn( + "LogoutRequest xmlns", + decode_base64_and_inflate(saml_request).decode("utf-8"), + "Not a valid LogoutRequest", + ) # now simulate a logout response sent by the idp expected_request = """http://sp.example.com/saml2/metadata/1f87035b4c1325b296a53d92097e6b3fa36d7e30ee82e3fcb0680d60243c1f03a0123456789abcdef0123456789abcdef""" request_id = re.findall(r' ID="(.*?)" ', expected_request)[0] - instant = datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ') + instant = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ") saml_response = """ -https://idp.example.com/simplesaml/saml2/idp/metadata.php""" % ( - request_id, instant) +https://idp.example.com/simplesaml/saml2/idp/metadata.php""".format( + request_id, instant + ) - response = self.client.get(reverse('saml2_ls'), { - 'SAMLResponse': deflate_and_base64_encode(saml_response), - }) + response = self.client.get( + reverse("saml2_ls"), + { + "SAMLResponse": deflate_and_base64_encode(saml_response), + }, + ) self.assertContains(response, "Logged out", status_code=200) self.assertListEqual(list(self.client.session.keys()), []) def test_logout_service_global(self): settings.SAML_CONFIG = conf.create_conf( - sp_host='sp.example.com', - idp_hosts=['idp.example.com'], - metadata_file='remote_metadata_one_idp.xml', + sp_host="sp.example.com", + idp_hosts=["idp.example.com"], + metadata_file="remote_metadata_one_idp.xml", ) subject_id = self.do_login() # now simulate a global logout process initiated by another SP subject_id = views._get_subject_id(self.saml_session) - instant = datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ') + instant = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ") saml_request = """ -https://idp.example.com/simplesaml/saml2/idp/metadata.php%s_1837687b7bc9faad85839dbeb319627889f3021757""" % (instant, subject_id) +https://idp.example.com/simplesaml/saml2/idp/metadata.php{}_1837687b7bc9faad85839dbeb319627889f3021757""".format( + instant, subject_id + ) - response = self.client.get(reverse('saml2_ls'), { - 'SAMLRequest': deflate_and_base64_encode(saml_request), - }) + response = self.client.get( + reverse("saml2_ls"), + { + "SAMLRequest": deflate_and_base64_encode(saml_request), + }, + ) self.assertEqual(response.status_code, 302) - location = response['Location'] + location = response["Location"] url = urlparse(location) - self.assertEqual(url.hostname, 'idp.example.com') - self.assertEqual(url.path, - '/simplesaml/saml2/idp/SingleLogoutService.php') + self.assertEqual(url.hostname, "idp.example.com") + self.assertEqual(url.path, "/simplesaml/saml2/idp/SingleLogoutService.php") params = parse_qs(url.query) - self.assertIn('SAMLResponse', params) - saml_response = params['SAMLResponse'][0] + self.assertIn("SAMLResponse", params) + saml_response = params["SAMLResponse"][0] - self.assertIn('Response xmlns', decode_base64_and_inflate( - saml_response).decode('utf-8'), 'Not a valid Response') + self.assertIn( + "Response xmlns", + decode_base64_and_inflate(saml_response).decode("utf-8"), + "Not a valid Response", + ) def test_incomplete_logout(self): - settings.SAML_CONFIG = conf.create_conf(sp_host='sp.example.com', - idp_hosts=['idp.example.com']) + settings.SAML_CONFIG = conf.create_conf( + sp_host="sp.example.com", idp_hosts=["idp.example.com"] + ) # don't do a login # now simulate a global logout process initiated by another SP - instant = datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ') - saml_request = 'https://idp.example.com/simplesaml/saml2/idp/metadata.php%s_1837687b7bc9faad85839dbeb319627889f3021757' % ( - instant, 'invalid-subject-id') + instant = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ") + saml_request = 'https://idp.example.com/simplesaml/saml2/idp/metadata.php{}_1837687b7bc9faad85839dbeb319627889f3021757'.format( + instant, "invalid-subject-id" + ) - response = self.client.get(reverse('saml2_ls'), { - 'SAMLRequest': deflate_and_base64_encode(saml_request), - }) - self.assertContains(response, 'Logout error', status_code=403) + response = self.client.get( + reverse("saml2_ls"), + { + "SAMLRequest": deflate_and_base64_encode(saml_request), + }, + ) + self.assertContains(response, "Logout error", status_code=403) def test_finish_logout_renders_error_template(self): - request = RequestFactory().get('/bar/foo') + request = RequestFactory().get("/bar/foo") response = finish_logout(request, None) self.assertContains(response, "

Logout error

", status_code=200) def test_sigalg_not_passed_when_not_signing_request(self): # monkey patch SAML configuration settings.SAML_CONFIG = conf.create_conf( - sp_host='sp.example.com', - idp_hosts=['idp.example.com'], - metadata_file='remote_metadata_one_idp.xml', + sp_host="sp.example.com", + idp_hosts=["idp.example.com"], + metadata_file="remote_metadata_one_idp.xml", ) with mock.patch( - 'djangosaml2.views.Saml2Client.prepare_for_authenticate', - return_value=('session_id', {'url': 'fake'}), - + "djangosaml2.views.Saml2Client.prepare_for_authenticate", + return_value=("session_id", {"url": "fake"}), ) as prepare_for_auth_mock: - self.client.get(reverse('saml2_login')) + self.client.get(reverse("saml2_login")) prepare_for_auth_mock.assert_called_once() _args, kwargs = prepare_for_auth_mock.call_args - self.assertNotIn('sigalg', kwargs) + self.assertNotIn("sigalg", kwargs) def test_sigalg_passed_when_signing_request(self): # monkey patch SAML configuration settings.SAML_CONFIG = conf.create_conf( - sp_host='sp.example.com', - idp_hosts=['idp.example.com'], - metadata_file='remote_metadata_one_idp.xml', + sp_host="sp.example.com", + idp_hosts=["idp.example.com"], + metadata_file="remote_metadata_one_idp.xml", ) - settings.SAML_CONFIG['service']['sp']['authn_requests_signed'] = True + settings.SAML_CONFIG["service"]["sp"]["authn_requests_signed"] = True with mock.patch( - 'djangosaml2.views.Saml2Client.prepare_for_authenticate', - return_value=('session_id', {'url': 'fake'}), - + "djangosaml2.views.Saml2Client.prepare_for_authenticate", + return_value=("session_id", {"url": "fake"}), ) as prepare_for_auth_mock: - self.client.get(reverse('saml2_login')) + self.client.get(reverse("saml2_login")) prepare_for_auth_mock.assert_called_once() _args, kwargs = prepare_for_auth_mock.call_args - self.assertIn('sigalg', kwargs) + self.assertIn("sigalg", kwargs) @override_settings(SAML2_DISCO_URL="https://that-ds.org/ds") def test_discovery_service(self): settings.SAML_CONFIG = conf.create_conf( - sp_host='sp.example.com', - idp_hosts=['idp.example.com'], - metadata_file='remote_metadata_three_idps.xml', + sp_host="sp.example.com", + idp_hosts=["idp.example.com"], + metadata_file="remote_metadata_three_idps.xml", ) - response = self.client.get(reverse('saml2_login')) + response = self.client.get(reverse("saml2_login")) self.assertEqual(response.status_code, 302) self.assertIn("https://that-ds.org/ds", response.url) + def test_config_loader(request): config = SPConfig() - config.load({'entityid': 'testentity'}) + config.load({"entityid": "testentity"}) return config def test_config_loader_callable(request): config = SPConfig() - config.load({'entityid': 'testentity_callable'}) + config.load({"entityid": "testentity_callable"}) return config def test_config_loader_with_real_conf(request): config = SPConfig() - config.load(conf.create_conf(sp_host='sp.example.com', - idp_hosts=['idp.example.com'], - metadata_file='remote_metadata_one_idp.xml')) + config.load( + conf.create_conf( + sp_host="sp.example.com", + idp_hosts=["idp.example.com"], + metadata_file="remote_metadata_one_idp.xml", + ) + ) return config class ConfTests(TestCase): - def test_custom_conf_loader(self): - config_loader_path = 'djangosaml2.tests.test_config_loader' - request = RequestFactory().get('/bar/foo') + config_loader_path = "djangosaml2.tests.test_config_loader" + request = RequestFactory().get("/bar/foo") conf = get_config(config_loader_path, request) - self.assertEqual(conf.entityid, 'testentity') + self.assertEqual(conf.entityid, "testentity") def test_custom_conf_loader_callable(self): config_loader_path = test_config_loader_callable - request = RequestFactory().get('/bar/foo') + request = RequestFactory().get("/bar/foo") conf = get_config(config_loader_path, request) - self.assertEqual(conf.entityid, 'testentity_callable') + self.assertEqual(conf.entityid, "testentity_callable") def test_custom_conf_loader_from_view(self): - config_loader_path = 'djangosaml2.tests.test_config_loader_with_real_conf' - request = RequestFactory().get('/login/') + config_loader_path = "djangosaml2.tests.test_config_loader_with_real_conf" + request = RequestFactory().get("/login/") request.user = AnonymousUser() middleware = SamlSessionMiddleware(dummy_get_response) middleware.process_request(request) saml_session_name = getattr( - settings, 'SAML_SESSION_COOKIE_NAME', 'saml_session') + settings, "SAML_SESSION_COOKIE_NAME", "saml_session" + ) getattr(request, saml_session_name).save() - response = views.LoginView.as_view( - config_loader_path=config_loader_path)(request) + response = views.LoginView.as_view(config_loader_path=config_loader_path)( + request + ) self.assertEqual(response.status_code, 302) - location = response['Location'] + location = response["Location"] url = urlparse(location) - self.assertEqual(url.hostname, 'idp.example.com') - self.assertEqual(url.path, '/simplesaml/saml2/idp/SSOService.php') + self.assertEqual(url.hostname, "idp.example.com") + self.assertEqual(url.path, "/simplesaml/saml2/idp/SSOService.php") class SessionEnabledTestCase(TestCase): @@ -845,11 +929,12 @@ def set_session_cookies(self, session): session_cookie = settings.SESSION_COOKIE_NAME self.client.cookies[session_cookie] = session.session_key cookie_data = { - 'max-age': None, - 'path': '/', - 'domain': settings.SESSION_COOKIE_DOMAIN, - 'secure': settings.SESSION_COOKIE_SECURE or None, - 'expires': None} + "max-age": None, + "path": "/", + "domain": settings.SESSION_COOKIE_DOMAIN, + "secure": settings.SESSION_COOKIE_SECURE or None, + "expires": None, + } self.client.cookies[session_cookie].update(cookie_data) @@ -860,26 +945,28 @@ def test_middleware_cookie_expireatbrowserclose(self): session.save() self.set_session_cookies(session) - config_loader_path = 'djangosaml2.tests.test_config_loader_with_real_conf' - request = RequestFactory().get('/login/') + config_loader_path = "djangosaml2.tests.test_config_loader_with_real_conf" + request = RequestFactory().get("/login/") request.user = AnonymousUser() request.session = session middleware = SamlSessionMiddleware(dummy_get_response) middleware.process_request(request) saml_session_name = getattr( - settings, 'SAML_SESSION_COOKIE_NAME', 'saml_session') + settings, "SAML_SESSION_COOKIE_NAME", "saml_session" + ) getattr(request, saml_session_name).save() - response = views.LoginView.as_view( - config_loader_path=config_loader_path)(request) + response = views.LoginView.as_view(config_loader_path=config_loader_path)( + request + ) response = middleware.process_response(request, response) cookie = response.cookies[saml_session_name] - self.assertEqual(cookie['expires'], '') - self.assertEqual(cookie['max-age'], '') + self.assertEqual(cookie["expires"], "") + self.assertEqual(cookie["max-age"], "") def test_middleware_cookie_with_expiry(self): with override_settings(SESSION_EXPIRE_AT_BROWSER_CLOSE=False): @@ -887,24 +974,26 @@ def test_middleware_cookie_with_expiry(self): session.save() self.set_session_cookies(session) - config_loader_path = 'djangosaml2.tests.test_config_loader_with_real_conf' - request = RequestFactory().get('/login/') + config_loader_path = "djangosaml2.tests.test_config_loader_with_real_conf" + request = RequestFactory().get("/login/") request.user = AnonymousUser() request.session = session middleware = SamlSessionMiddleware(dummy_get_response) middleware.process_request(request) saml_session_name = getattr( - settings, 'SAML_SESSION_COOKIE_NAME', 'saml_session') + settings, "SAML_SESSION_COOKIE_NAME", "saml_session" + ) getattr(request, saml_session_name).save() - response = views.LoginView.as_view( - config_loader_path=config_loader_path)(request) + response = views.LoginView.as_view(config_loader_path=config_loader_path)( + request + ) response = middleware.process_response(request, response) cookie = response.cookies[saml_session_name] - self.assertIsNotNone(cookie['expires']) - self.assertNotEqual(cookie['expires'], '') - self.assertNotEqual(cookie['max-age'], '') + self.assertIsNotNone(cookie["expires"]) + self.assertNotEqual(cookie["expires"], "") + self.assertNotEqual(cookie["max-age"], "") diff --git a/djangosaml2/tests/attribute-maps/django_saml_uri.py b/djangosaml2/tests/attribute-maps/django_saml_uri.py index 65323c5c..3d6dc33a 100644 --- a/djangosaml2/tests/attribute-maps/django_saml_uri.py +++ b/djangosaml2/tests/attribute-maps/django_saml_uri.py @@ -1,19 +1,19 @@ -X500ATTR_OID = 'urn:oid:2.5.4.' -PKCS_9 = 'urn:oid:1.2.840.113549.1.9.1.' -UCL_DIR_PILOT = 'urn:oid:0.9.2342.19200300.100.1.' +X500ATTR_OID = "urn:oid:2.5.4." +PKCS_9 = "urn:oid:1.2.840.113549.1.9.1." +UCL_DIR_PILOT = "urn:oid:0.9.2342.19200300.100.1." MAP = { - 'identifier': 'urn:oasis:names:tc:SAML:2.0:attrname-format:uri', - 'fro': { - X500ATTR_OID+'3': 'first_name', # cn - X500ATTR_OID+'4': 'last_name', # sn - PKCS_9+'1': 'email', - UCL_DIR_PILOT+'1': 'uid', + "identifier": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri", + "fro": { + X500ATTR_OID + "3": "first_name", # cn + X500ATTR_OID + "4": "last_name", # sn + PKCS_9 + "1": "email", + UCL_DIR_PILOT + "1": "uid", + }, + "to": { + "first_name": X500ATTR_OID + "3", + "last_name": X500ATTR_OID + "4", + "email": PKCS_9 + "1", + "uid": UCL_DIR_PILOT + "1", }, - 'to': { - 'first_name': X500ATTR_OID+'3', - 'last_name': X500ATTR_OID+'4', - 'email': PKCS_9+'1', - 'uid': UCL_DIR_PILOT+'1', - } } diff --git a/djangosaml2/tests/attribute-maps/saml_uri.py b/djangosaml2/tests/attribute-maps/saml_uri.py index f92f49ec..d2e948a9 100644 --- a/djangosaml2/tests/attribute-maps/saml_uri.py +++ b/djangosaml2/tests/attribute-maps/saml_uri.py @@ -1,10 +1,10 @@ -__author__ = 'rolandh' +__author__ = "rolandh" EDUPERSON_OID = "urn:oid:1.3.6.1.4.1.5923.1.1.1." X500ATTR_OID = "urn:oid:2.5.4." NOREDUPERSON_OID = "urn:oid:1.3.6.1.4.1.2428.90.1." NETSCAPE_LDAP = "urn:oid:2.16.840.1.113730.3.1." -UCL_DIR_PILOT = 'urn:oid:0.9.2342.19200300.100.1.' +UCL_DIR_PILOT = "urn:oid:0.9.2342.19200300.100.1." PKCS_9 = "urn:oid:1.2.840.113549.1.9.1." UMICH = "urn:oid:1.3.6.1.4.1.250.1.57." SCHAC = "urn:oid:1.3.6.1.4.1.25178.1.2." @@ -12,232 +12,232 @@ MAP = { "identifier": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri", "fro": { - EDUPERSON_OID+'2': 'eduPersonNickname', - EDUPERSON_OID+'9': 'eduPersonScopedAffiliation', - EDUPERSON_OID+'11': 'eduPersonAssurance', - EDUPERSON_OID+'10': 'eduPersonTargetedID', - EDUPERSON_OID+'4': 'eduPersonOrgUnitDN', - NOREDUPERSON_OID+'6': 'norEduOrgAcronym', - NOREDUPERSON_OID+'7': 'norEduOrgUniqueIdentifier', - NOREDUPERSON_OID+'4': 'norEduPersonLIN', - EDUPERSON_OID+'1': 'eduPersonAffiliation', - NOREDUPERSON_OID+'2': 'norEduOrgUnitUniqueNumber', - NETSCAPE_LDAP+'40': 'userSMIMECertificate', - NOREDUPERSON_OID+'1': 'norEduOrgUniqueNumber', - NETSCAPE_LDAP+'241': 'displayName', - UCL_DIR_PILOT+'37': 'associatedDomain', - EDUPERSON_OID+'6': 'eduPersonPrincipalName', - NOREDUPERSON_OID+'8': 'norEduOrgUnitUniqueIdentifier', - NOREDUPERSON_OID+'9': 'federationFeideSchemaVersion', - X500ATTR_OID+'53': 'deltaRevocationList', - X500ATTR_OID+'52': 'supportedAlgorithms', - X500ATTR_OID+'51': 'houseIdentifier', - X500ATTR_OID+'50': 'uniqueMember', - X500ATTR_OID+'19': 'physicalDeliveryOfficeName', - X500ATTR_OID+'18': 'postOfficeBox', - X500ATTR_OID+'17': 'postalCode', - X500ATTR_OID+'16': 'postalAddress', - X500ATTR_OID+'15': 'businessCategory', - X500ATTR_OID+'14': 'searchGuide', - EDUPERSON_OID+'5': 'eduPersonPrimaryAffiliation', - X500ATTR_OID+'12': 'title', - X500ATTR_OID+'11': 'ou', - X500ATTR_OID+'10': 'o', - X500ATTR_OID+'37': 'cACertificate', - X500ATTR_OID+'36': 'userCertificate', - X500ATTR_OID+'31': 'member', - X500ATTR_OID+'30': 'supportedApplicationContext', - X500ATTR_OID+'33': 'roleOccupant', - X500ATTR_OID+'32': 'owner', - NETSCAPE_LDAP+'1': 'carLicense', - PKCS_9+'1': 'email', - NETSCAPE_LDAP+'3': 'employeeNumber', - NETSCAPE_LDAP+'2': 'departmentNumber', - X500ATTR_OID+'39': 'certificateRevocationList', - X500ATTR_OID+'38': 'authorityRevocationList', - NETSCAPE_LDAP+'216': 'userPKCS12', - EDUPERSON_OID+'8': 'eduPersonPrimaryOrgUnitDN', - X500ATTR_OID+'9': 'street', - X500ATTR_OID+'8': 'st', - NETSCAPE_LDAP+'39': 'preferredLanguage', - EDUPERSON_OID+'7': 'eduPersonEntitlement', - X500ATTR_OID+'2': 'knowledgeInformation', - X500ATTR_OID+'7': 'l', - X500ATTR_OID+'6': 'c', - X500ATTR_OID+'5': 'serialNumber', - X500ATTR_OID+'4': 'sn', - X500ATTR_OID+'3': 'cn', - UCL_DIR_PILOT+'60': 'jpegPhoto', - X500ATTR_OID+'65': 'pseudonym', - NOREDUPERSON_OID+'5': 'norEduPersonNIN', - UCL_DIR_PILOT+'3': 'mail', - UCL_DIR_PILOT+'25': 'dc', - X500ATTR_OID+'40': 'crossCertificatePair', - X500ATTR_OID+'42': 'givenName', - X500ATTR_OID+'43': 'initials', - X500ATTR_OID+'44': 'generationQualifier', - X500ATTR_OID+'45': 'x500UniqueIdentifier', - X500ATTR_OID+'46': 'dnQualifier', - X500ATTR_OID+'47': 'enhancedSearchGuide', - X500ATTR_OID+'48': 'protocolInformation', - X500ATTR_OID+'54': 'dmdName', - NETSCAPE_LDAP+'4': 'employeeType', - X500ATTR_OID+'22': 'teletexTerminalIdentifier', - X500ATTR_OID+'23': 'facsimileTelephoneNumber', - X500ATTR_OID+'20': 'telephoneNumber', - X500ATTR_OID+'21': 'telexNumber', - X500ATTR_OID+'26': 'registeredAddress', - X500ATTR_OID+'27': 'destinationIndicator', - X500ATTR_OID+'24': 'x121Address', - X500ATTR_OID+'25': 'internationaliSDNNumber', - X500ATTR_OID+'28': 'preferredDeliveryMethod', - X500ATTR_OID+'29': 'presentationAddress', - EDUPERSON_OID+'3': 'eduPersonOrgDN', - NOREDUPERSON_OID+'3': 'norEduPersonBirthDate', - UMICH+'57': 'labeledURI', - UCL_DIR_PILOT+'1': 'uid', - SCHAC+'1': 'schacMotherTongue', - SCHAC+'2': 'schacGender', - SCHAC+'3': 'schacDateOfBirth', - SCHAC+'4': 'schacPlaceOfBirth', - SCHAC+'5': 'schacCountryOfCitizenship', - SCHAC+'6': 'schacSn1', - SCHAC+'7': 'schacSn2', - SCHAC+'8': 'schacPersonalTitle', - SCHAC+'9': 'schacHomeOrganization', - SCHAC+'10': 'schacHomeOrganizationType', - SCHAC+'11': 'schacCountryOfResidence', - SCHAC+'12': 'schacUserPresenceID', - SCHAC+'13': 'schacPersonalPosition', - SCHAC+'14': 'schacPersonalUniqueCode', - SCHAC+'15': 'schacPersonalUniqueID', - SCHAC+'17': 'schacExpiryDate', - SCHAC+'18': 'schacUserPrivateAttribute', - SCHAC+'19': 'schacUserStatus', - SCHAC+'20': 'schacProjectMembership', - SCHAC+'21': 'schacProjectSpecificRole', + EDUPERSON_OID + "2": "eduPersonNickname", + EDUPERSON_OID + "9": "eduPersonScopedAffiliation", + EDUPERSON_OID + "11": "eduPersonAssurance", + EDUPERSON_OID + "10": "eduPersonTargetedID", + EDUPERSON_OID + "4": "eduPersonOrgUnitDN", + NOREDUPERSON_OID + "6": "norEduOrgAcronym", + NOREDUPERSON_OID + "7": "norEduOrgUniqueIdentifier", + NOREDUPERSON_OID + "4": "norEduPersonLIN", + EDUPERSON_OID + "1": "eduPersonAffiliation", + NOREDUPERSON_OID + "2": "norEduOrgUnitUniqueNumber", + NETSCAPE_LDAP + "40": "userSMIMECertificate", + NOREDUPERSON_OID + "1": "norEduOrgUniqueNumber", + NETSCAPE_LDAP + "241": "displayName", + UCL_DIR_PILOT + "37": "associatedDomain", + EDUPERSON_OID + "6": "eduPersonPrincipalName", + NOREDUPERSON_OID + "8": "norEduOrgUnitUniqueIdentifier", + NOREDUPERSON_OID + "9": "federationFeideSchemaVersion", + X500ATTR_OID + "53": "deltaRevocationList", + X500ATTR_OID + "52": "supportedAlgorithms", + X500ATTR_OID + "51": "houseIdentifier", + X500ATTR_OID + "50": "uniqueMember", + X500ATTR_OID + "19": "physicalDeliveryOfficeName", + X500ATTR_OID + "18": "postOfficeBox", + X500ATTR_OID + "17": "postalCode", + X500ATTR_OID + "16": "postalAddress", + X500ATTR_OID + "15": "businessCategory", + X500ATTR_OID + "14": "searchGuide", + EDUPERSON_OID + "5": "eduPersonPrimaryAffiliation", + X500ATTR_OID + "12": "title", + X500ATTR_OID + "11": "ou", + X500ATTR_OID + "10": "o", + X500ATTR_OID + "37": "cACertificate", + X500ATTR_OID + "36": "userCertificate", + X500ATTR_OID + "31": "member", + X500ATTR_OID + "30": "supportedApplicationContext", + X500ATTR_OID + "33": "roleOccupant", + X500ATTR_OID + "32": "owner", + NETSCAPE_LDAP + "1": "carLicense", + PKCS_9 + "1": "email", + NETSCAPE_LDAP + "3": "employeeNumber", + NETSCAPE_LDAP + "2": "departmentNumber", + X500ATTR_OID + "39": "certificateRevocationList", + X500ATTR_OID + "38": "authorityRevocationList", + NETSCAPE_LDAP + "216": "userPKCS12", + EDUPERSON_OID + "8": "eduPersonPrimaryOrgUnitDN", + X500ATTR_OID + "9": "street", + X500ATTR_OID + "8": "st", + NETSCAPE_LDAP + "39": "preferredLanguage", + EDUPERSON_OID + "7": "eduPersonEntitlement", + X500ATTR_OID + "2": "knowledgeInformation", + X500ATTR_OID + "7": "l", + X500ATTR_OID + "6": "c", + X500ATTR_OID + "5": "serialNumber", + X500ATTR_OID + "4": "sn", + X500ATTR_OID + "3": "cn", + UCL_DIR_PILOT + "60": "jpegPhoto", + X500ATTR_OID + "65": "pseudonym", + NOREDUPERSON_OID + "5": "norEduPersonNIN", + UCL_DIR_PILOT + "3": "mail", + UCL_DIR_PILOT + "25": "dc", + X500ATTR_OID + "40": "crossCertificatePair", + X500ATTR_OID + "42": "givenName", + X500ATTR_OID + "43": "initials", + X500ATTR_OID + "44": "generationQualifier", + X500ATTR_OID + "45": "x500UniqueIdentifier", + X500ATTR_OID + "46": "dnQualifier", + X500ATTR_OID + "47": "enhancedSearchGuide", + X500ATTR_OID + "48": "protocolInformation", + X500ATTR_OID + "54": "dmdName", + NETSCAPE_LDAP + "4": "employeeType", + X500ATTR_OID + "22": "teletexTerminalIdentifier", + X500ATTR_OID + "23": "facsimileTelephoneNumber", + X500ATTR_OID + "20": "telephoneNumber", + X500ATTR_OID + "21": "telexNumber", + X500ATTR_OID + "26": "registeredAddress", + X500ATTR_OID + "27": "destinationIndicator", + X500ATTR_OID + "24": "x121Address", + X500ATTR_OID + "25": "internationaliSDNNumber", + X500ATTR_OID + "28": "preferredDeliveryMethod", + X500ATTR_OID + "29": "presentationAddress", + EDUPERSON_OID + "3": "eduPersonOrgDN", + NOREDUPERSON_OID + "3": "norEduPersonBirthDate", + UMICH + "57": "labeledURI", + UCL_DIR_PILOT + "1": "uid", + SCHAC + "1": "schacMotherTongue", + SCHAC + "2": "schacGender", + SCHAC + "3": "schacDateOfBirth", + SCHAC + "4": "schacPlaceOfBirth", + SCHAC + "5": "schacCountryOfCitizenship", + SCHAC + "6": "schacSn1", + SCHAC + "7": "schacSn2", + SCHAC + "8": "schacPersonalTitle", + SCHAC + "9": "schacHomeOrganization", + SCHAC + "10": "schacHomeOrganizationType", + SCHAC + "11": "schacCountryOfResidence", + SCHAC + "12": "schacUserPresenceID", + SCHAC + "13": "schacPersonalPosition", + SCHAC + "14": "schacPersonalUniqueCode", + SCHAC + "15": "schacPersonalUniqueID", + SCHAC + "17": "schacExpiryDate", + SCHAC + "18": "schacUserPrivateAttribute", + SCHAC + "19": "schacUserStatus", + SCHAC + "20": "schacProjectMembership", + SCHAC + "21": "schacProjectSpecificRole", }, "to": { - 'roleOccupant': X500ATTR_OID+'33', - 'gn': X500ATTR_OID+'42', - 'norEduPersonNIN': NOREDUPERSON_OID+'5', - 'title': X500ATTR_OID+'12', - 'facsimileTelephoneNumber': X500ATTR_OID+'23', - 'mail': UCL_DIR_PILOT+'3', - 'postOfficeBox': X500ATTR_OID+'18', - 'fax': X500ATTR_OID+'23', - 'telephoneNumber': X500ATTR_OID+'20', - 'norEduPersonBirthDate': NOREDUPERSON_OID+'3', - 'rfc822Mailbox': UCL_DIR_PILOT+'3', - 'dc': UCL_DIR_PILOT+'25', - 'countryName': X500ATTR_OID+'6', - 'emailAddress': PKCS_9+'1', - 'employeeNumber': NETSCAPE_LDAP+'3', - 'organizationName': X500ATTR_OID+'10', - 'eduPersonAssurance': EDUPERSON_OID+'11', - 'norEduOrgAcronym': NOREDUPERSON_OID+'6', - 'registeredAddress': X500ATTR_OID+'26', - 'physicalDeliveryOfficeName': X500ATTR_OID+'19', - 'associatedDomain': UCL_DIR_PILOT+'37', - 'l': X500ATTR_OID+'7', - 'stateOrProvinceName': X500ATTR_OID+'8', - 'federationFeideSchemaVersion': NOREDUPERSON_OID+'9', - 'pkcs9email': PKCS_9+'1', - 'givenName': X500ATTR_OID+'42', - 'givenname': X500ATTR_OID+'42', - 'x500UniqueIdentifier': X500ATTR_OID+'45', - 'eduPersonNickname': EDUPERSON_OID+'2', - 'houseIdentifier': X500ATTR_OID+'51', - 'street': X500ATTR_OID+'9', - 'supportedAlgorithms': X500ATTR_OID+'52', - 'preferredLanguage': NETSCAPE_LDAP+'39', - 'postalAddress': X500ATTR_OID+'16', - 'email': PKCS_9+'1', - 'norEduOrgUnitUniqueIdentifier': NOREDUPERSON_OID+'8', - 'eduPersonPrimaryOrgUnitDN': EDUPERSON_OID+'8', - 'c': X500ATTR_OID+'6', - 'teletexTerminalIdentifier': X500ATTR_OID+'22', - 'o': X500ATTR_OID+'10', - 'cACertificate': X500ATTR_OID+'37', - 'telexNumber': X500ATTR_OID+'21', - 'ou': X500ATTR_OID+'11', - 'initials': X500ATTR_OID+'43', - 'eduPersonOrgUnitDN': EDUPERSON_OID+'4', - 'deltaRevocationList': X500ATTR_OID+'53', - 'norEduPersonLIN': NOREDUPERSON_OID+'4', - 'supportedApplicationContext': X500ATTR_OID+'30', - 'eduPersonEntitlement': EDUPERSON_OID+'7', - 'generationQualifier': X500ATTR_OID+'44', - 'eduPersonAffiliation': EDUPERSON_OID+'1', - 'edupersonaffiliation': EDUPERSON_OID+'1', - 'eduPersonPrincipalName': EDUPERSON_OID+'6', - 'edupersonprincipalname': EDUPERSON_OID+'6', - 'localityName': X500ATTR_OID+'7', - 'owner': X500ATTR_OID+'32', - 'norEduOrgUnitUniqueNumber': NOREDUPERSON_OID+'2', - 'searchGuide': X500ATTR_OID+'14', - 'certificateRevocationList': X500ATTR_OID+'39', - 'organizationalUnitName': X500ATTR_OID+'11', - 'userCertificate': X500ATTR_OID+'36', - 'preferredDeliveryMethod': X500ATTR_OID+'28', - 'internationaliSDNNumber': X500ATTR_OID+'25', - 'uniqueMember': X500ATTR_OID+'50', - 'departmentNumber': NETSCAPE_LDAP+'2', - 'enhancedSearchGuide': X500ATTR_OID+'47', - 'userPKCS12': NETSCAPE_LDAP+'216', - 'eduPersonTargetedID': EDUPERSON_OID+'10', - 'norEduOrgUniqueNumber': NOREDUPERSON_OID+'1', - 'x121Address': X500ATTR_OID+'24', - 'destinationIndicator': X500ATTR_OID+'27', - 'eduPersonPrimaryAffiliation': EDUPERSON_OID+'5', - 'surname': X500ATTR_OID+'4', - 'jpegPhoto': UCL_DIR_PILOT+'60', - 'eduPersonScopedAffiliation': EDUPERSON_OID+'9', - 'edupersonscopedaffiliation': EDUPERSON_OID+'9', - 'protocolInformation': X500ATTR_OID+'48', - 'knowledgeInformation': X500ATTR_OID+'2', - 'employeeType': NETSCAPE_LDAP+'4', - 'userSMIMECertificate': NETSCAPE_LDAP+'40', - 'member': X500ATTR_OID+'31', - 'streetAddress': X500ATTR_OID+'9', - 'dmdName': X500ATTR_OID+'54', - 'postalCode': X500ATTR_OID+'17', - 'pseudonym': X500ATTR_OID+'65', - 'dnQualifier': X500ATTR_OID+'46', - 'crossCertificatePair': X500ATTR_OID+'40', - 'eduPersonOrgDN': EDUPERSON_OID+'3', - 'authorityRevocationList': X500ATTR_OID+'38', - 'displayName': NETSCAPE_LDAP+'241', - 'businessCategory': X500ATTR_OID+'15', - 'serialNumber': X500ATTR_OID+'5', - 'norEduOrgUniqueIdentifier': NOREDUPERSON_OID+'7', - 'st': X500ATTR_OID+'8', - 'carLicense': NETSCAPE_LDAP+'1', - 'presentationAddress': X500ATTR_OID+'29', - 'sn': X500ATTR_OID+'4', - 'cn': X500ATTR_OID+'3', - 'domainComponent': UCL_DIR_PILOT+'25', - 'labeledURI': UMICH+'57', - 'uid': UCL_DIR_PILOT+'1', - 'schacMotherTongue': SCHAC+'1', - 'schacGender': SCHAC+'2', - 'schacDateOfBirth': SCHAC+'3', - 'schacPlaceOfBirth': SCHAC+'4', - 'schacCountryOfCitizenship': SCHAC+'5', - 'schacSn1': SCHAC+'6', - 'schacSn2': SCHAC+'7', - 'schacPersonalTitle': SCHAC+'8', - 'schacHomeOrganization': SCHAC+'9', - 'schacHomeOrganizationType': SCHAC+'10', - 'schacCountryOfResidence': SCHAC+'11', - 'schacUserPresenceID': SCHAC+'12', - 'schacPersonalPosition': SCHAC+'13', - 'schacPersonalUniqueCode': SCHAC+'14', - 'schacPersonalUniqueID': SCHAC+'15', - 'schacExpiryDate': SCHAC+'17', - 'schacUserPrivateAttribute': SCHAC+'18', - 'schacUserStatus': SCHAC+'19', - 'schacProjectMembership': SCHAC+'20', - 'schacProjectSpecificRole': SCHAC+'21', - } + "roleOccupant": X500ATTR_OID + "33", + "gn": X500ATTR_OID + "42", + "norEduPersonNIN": NOREDUPERSON_OID + "5", + "title": X500ATTR_OID + "12", + "facsimileTelephoneNumber": X500ATTR_OID + "23", + "mail": UCL_DIR_PILOT + "3", + "postOfficeBox": X500ATTR_OID + "18", + "fax": X500ATTR_OID + "23", + "telephoneNumber": X500ATTR_OID + "20", + "norEduPersonBirthDate": NOREDUPERSON_OID + "3", + "rfc822Mailbox": UCL_DIR_PILOT + "3", + "dc": UCL_DIR_PILOT + "25", + "countryName": X500ATTR_OID + "6", + "emailAddress": PKCS_9 + "1", + "employeeNumber": NETSCAPE_LDAP + "3", + "organizationName": X500ATTR_OID + "10", + "eduPersonAssurance": EDUPERSON_OID + "11", + "norEduOrgAcronym": NOREDUPERSON_OID + "6", + "registeredAddress": X500ATTR_OID + "26", + "physicalDeliveryOfficeName": X500ATTR_OID + "19", + "associatedDomain": UCL_DIR_PILOT + "37", + "l": X500ATTR_OID + "7", + "stateOrProvinceName": X500ATTR_OID + "8", + "federationFeideSchemaVersion": NOREDUPERSON_OID + "9", + "pkcs9email": PKCS_9 + "1", + "givenName": X500ATTR_OID + "42", + "givenname": X500ATTR_OID + "42", + "x500UniqueIdentifier": X500ATTR_OID + "45", + "eduPersonNickname": EDUPERSON_OID + "2", + "houseIdentifier": X500ATTR_OID + "51", + "street": X500ATTR_OID + "9", + "supportedAlgorithms": X500ATTR_OID + "52", + "preferredLanguage": NETSCAPE_LDAP + "39", + "postalAddress": X500ATTR_OID + "16", + "email": PKCS_9 + "1", + "norEduOrgUnitUniqueIdentifier": NOREDUPERSON_OID + "8", + "eduPersonPrimaryOrgUnitDN": EDUPERSON_OID + "8", + "c": X500ATTR_OID + "6", + "teletexTerminalIdentifier": X500ATTR_OID + "22", + "o": X500ATTR_OID + "10", + "cACertificate": X500ATTR_OID + "37", + "telexNumber": X500ATTR_OID + "21", + "ou": X500ATTR_OID + "11", + "initials": X500ATTR_OID + "43", + "eduPersonOrgUnitDN": EDUPERSON_OID + "4", + "deltaRevocationList": X500ATTR_OID + "53", + "norEduPersonLIN": NOREDUPERSON_OID + "4", + "supportedApplicationContext": X500ATTR_OID + "30", + "eduPersonEntitlement": EDUPERSON_OID + "7", + "generationQualifier": X500ATTR_OID + "44", + "eduPersonAffiliation": EDUPERSON_OID + "1", + "edupersonaffiliation": EDUPERSON_OID + "1", + "eduPersonPrincipalName": EDUPERSON_OID + "6", + "edupersonprincipalname": EDUPERSON_OID + "6", + "localityName": X500ATTR_OID + "7", + "owner": X500ATTR_OID + "32", + "norEduOrgUnitUniqueNumber": NOREDUPERSON_OID + "2", + "searchGuide": X500ATTR_OID + "14", + "certificateRevocationList": X500ATTR_OID + "39", + "organizationalUnitName": X500ATTR_OID + "11", + "userCertificate": X500ATTR_OID + "36", + "preferredDeliveryMethod": X500ATTR_OID + "28", + "internationaliSDNNumber": X500ATTR_OID + "25", + "uniqueMember": X500ATTR_OID + "50", + "departmentNumber": NETSCAPE_LDAP + "2", + "enhancedSearchGuide": X500ATTR_OID + "47", + "userPKCS12": NETSCAPE_LDAP + "216", + "eduPersonTargetedID": EDUPERSON_OID + "10", + "norEduOrgUniqueNumber": NOREDUPERSON_OID + "1", + "x121Address": X500ATTR_OID + "24", + "destinationIndicator": X500ATTR_OID + "27", + "eduPersonPrimaryAffiliation": EDUPERSON_OID + "5", + "surname": X500ATTR_OID + "4", + "jpegPhoto": UCL_DIR_PILOT + "60", + "eduPersonScopedAffiliation": EDUPERSON_OID + "9", + "edupersonscopedaffiliation": EDUPERSON_OID + "9", + "protocolInformation": X500ATTR_OID + "48", + "knowledgeInformation": X500ATTR_OID + "2", + "employeeType": NETSCAPE_LDAP + "4", + "userSMIMECertificate": NETSCAPE_LDAP + "40", + "member": X500ATTR_OID + "31", + "streetAddress": X500ATTR_OID + "9", + "dmdName": X500ATTR_OID + "54", + "postalCode": X500ATTR_OID + "17", + "pseudonym": X500ATTR_OID + "65", + "dnQualifier": X500ATTR_OID + "46", + "crossCertificatePair": X500ATTR_OID + "40", + "eduPersonOrgDN": EDUPERSON_OID + "3", + "authorityRevocationList": X500ATTR_OID + "38", + "displayName": NETSCAPE_LDAP + "241", + "businessCategory": X500ATTR_OID + "15", + "serialNumber": X500ATTR_OID + "5", + "norEduOrgUniqueIdentifier": NOREDUPERSON_OID + "7", + "st": X500ATTR_OID + "8", + "carLicense": NETSCAPE_LDAP + "1", + "presentationAddress": X500ATTR_OID + "29", + "sn": X500ATTR_OID + "4", + "cn": X500ATTR_OID + "3", + "domainComponent": UCL_DIR_PILOT + "25", + "labeledURI": UMICH + "57", + "uid": UCL_DIR_PILOT + "1", + "schacMotherTongue": SCHAC + "1", + "schacGender": SCHAC + "2", + "schacDateOfBirth": SCHAC + "3", + "schacPlaceOfBirth": SCHAC + "4", + "schacCountryOfCitizenship": SCHAC + "5", + "schacSn1": SCHAC + "6", + "schacSn2": SCHAC + "7", + "schacPersonalTitle": SCHAC + "8", + "schacHomeOrganization": SCHAC + "9", + "schacHomeOrganizationType": SCHAC + "10", + "schacCountryOfResidence": SCHAC + "11", + "schacUserPresenceID": SCHAC + "12", + "schacPersonalPosition": SCHAC + "13", + "schacPersonalUniqueCode": SCHAC + "14", + "schacPersonalUniqueID": SCHAC + "15", + "schacExpiryDate": SCHAC + "17", + "schacUserPrivateAttribute": SCHAC + "18", + "schacUserStatus": SCHAC + "19", + "schacProjectMembership": SCHAC + "20", + "schacProjectSpecificRole": SCHAC + "21", + }, } diff --git a/djangosaml2/tests/auth_response.py b/djangosaml2/tests/auth_response.py index 3c84c8fd..b25a6ced 100644 --- a/djangosaml2/tests/auth_response.py +++ b/djangosaml2/tests/auth_response.py @@ -16,12 +16,14 @@ import datetime -def auth_response(session_id, - uid, - audience='http://sp.example.com/saml2/metadata/', - acs_url='http://sp.example.com/saml2/acs/', - metadata_url='http://sp.example.com/saml2/metadata/', - attribute_statements=None): +def auth_response( + session_id, + uid, + audience="http://sp.example.com/saml2/metadata/", + acs_url="http://sp.example.com/saml2/acs/", + metadata_url="http://sp.example.com/saml2/metadata/", + attribute_statements=None, +): """Generates a fresh signed authentication response Params: @@ -44,60 +46,61 @@ def auth_response(session_id, if attribute_statements is None: attribute_statements = ( - '' + "" '' '' - '%(uid)s' - '' - '' - '' - ) % {'uid': uid} + "%(uid)s" + "" + "" + "" + ) % {"uid": uid} saml_response_tpl = ( "" '' '' - 'https://idp.example.com/simplesaml/saml2/idp/metadata.php' - '' - '' + "https://idp.example.com/simplesaml/saml2/idp/metadata.php" + "" + "" '' - '' + "" '' '' - 'https://idp.example.com/simplesaml/saml2/idp/metadata.php' - '' - '' + "https://idp.example.com/simplesaml/saml2/idp/metadata.php" + "" + "" '' - '1f87035b4c1325b296a53d92097e6b3fa36d7e30ee82e3fcb0680d60243c1f03' - '' + "1f87035b4c1325b296a53d92097e6b3fa36d7e30ee82e3fcb0680d60243c1f03" + "" '' '' - '' - '' + "" + "" '' - '' - '' - '%(audience)s' - '' - '' - '' + "" + "" + "%(audience)s" + "" + "" + "" '' - '' - '' - 'urn:oasis:names:tc:SAML:2.0:ac:classes:Password' - '' - '' - '' - '%(attribute_statements)s' - '' - '') + "" + "" + "urn:oasis:names:tc:SAML:2.0:ac:classes:Password" + "" + "" + "" + "%(attribute_statements)s" + "" + "" + ) return saml_response_tpl % { - 'session_id': session_id, - 'audience': audience, - 'acs_url': acs_url, - 'metadata_url': metadata_url, - 'attribute_statements': attribute_statements, - 'timestamp': timestamp.strftime('%Y-%m-%dT%H:%M:%SZ'), - 'tomorrow': tomorrow.strftime('%Y-%m-%dT%H:%M:%SZ'), - 'yesterday': yesterday.strftime('%Y-%m-%dT%H:%M:%SZ'), + "session_id": session_id, + "audience": audience, + "acs_url": acs_url, + "metadata_url": metadata_url, + "attribute_statements": attribute_statements, + "timestamp": timestamp.strftime("%Y-%m-%dT%H:%M:%SZ"), + "tomorrow": tomorrow.strftime("%Y-%m-%dT%H:%M:%SZ"), + "yesterday": yesterday.strftime("%Y-%m-%dT%H:%M:%SZ"), } diff --git a/djangosaml2/tests/conf.py b/djangosaml2/tests/conf.py index 8bb6af4a..d02fe17f 100644 --- a/djangosaml2/tests/conf.py +++ b/djangosaml2/tests/conf.py @@ -18,9 +18,15 @@ import saml2 -def create_conf(sp_host='sp.example.com', idp_hosts=['idp.example.com'], - metadata_file='remote_metadata.xml', authn_requests_signed=None, - sp_kwargs:dict={}): +def create_conf( + sp_host="sp.example.com", + idp_hosts=None, + metadata_file="remote_metadata.xml", + authn_requests_signed=None, + sp_kwargs: dict = None, +): + if idp_hosts is None: + idp_hosts = ["idp.example.com"] try: from saml2.sigver import get_xmlsec_binary @@ -30,79 +36,78 @@ def create_conf(sp_host='sp.example.com', idp_hosts=['idp.example.com'], if get_xmlsec_binary: xmlsec_path = get_xmlsec_binary(["/opt/local/bin"]) else: - xmlsec_path = '/usr/bin/xmlsec1' + xmlsec_path = "/usr/bin/xmlsec1" BASEDIR = os.path.dirname(os.path.abspath(__file__)) config = { - 'xmlsec_binary': xmlsec_path, - 'entityid': 'http://%s/saml2/metadata/' % sp_host, - 'attribute_map_dir': os.path.join(BASEDIR, 'attribute-maps'), - - 'service': { - 'sp': { - 'name': 'Test SP', - 'name_id_format': saml2.saml.NAMEID_FORMAT_PERSISTENT, - 'endpoints': { - 'assertion_consumer_service': [ - ('http://%s/saml2/acs/' % sp_host, - saml2.BINDING_HTTP_POST), + "xmlsec_binary": xmlsec_path, + "entityid": "http://%s/saml2/metadata/" % sp_host, + "attribute_map_dir": os.path.join(BASEDIR, "attribute-maps"), + "service": { + "sp": { + "name": "Test SP", + "name_id_format": saml2.saml.NAMEID_FORMAT_PERSISTENT, + "endpoints": { + "assertion_consumer_service": [ + ("http://%s/saml2/acs/" % sp_host, saml2.BINDING_HTTP_POST), ], - 'single_logout_service': [ - ('http://%s/saml2/ls/' % sp_host, - saml2.BINDING_HTTP_REDIRECT), + "single_logout_service": [ + ("http://%s/saml2/ls/" % sp_host, saml2.BINDING_HTTP_REDIRECT), ], }, - 'required_attributes': ['uid'], - 'optional_attributes': ['eduPersonAffiliation'], - 'idp': {}, # this is filled later - 'want_response_signed': False, + "required_attributes": ["uid"], + "optional_attributes": ["eduPersonAffiliation"], + "idp": {}, # this is filled later + "want_response_signed": False, }, }, - - 'metadata': { - 'local': [os.path.join(BASEDIR, metadata_file)], + "metadata": { + "local": [os.path.join(BASEDIR, metadata_file)], }, - - 'debug': 1, - + "debug": 1, # certificates - 'key_file': os.path.join(BASEDIR, 'mycert.key'), - 'cert_file': os.path.join(BASEDIR, 'mycert.pem'), - + "key_file": os.path.join(BASEDIR, "mycert.key"), + "cert_file": os.path.join(BASEDIR, "mycert.pem"), # These fields are only used when generating the metadata - 'contact_person': [ - {'given_name': 'Technical givenname', - 'sur_name': 'Technical surname', - 'company': 'Example Inc.', - 'email_address': 'technical@sp.example.com', - 'contact_type': 'technical'}, - {'given_name': 'Administrative givenname', - 'sur_name': 'Administrative surname', - 'company': 'Example Inc.', - 'email_address': 'administrative@sp.example.ccom', - 'contact_type': 'administrative'}, + "contact_person": [ + { + "given_name": "Technical givenname", + "sur_name": "Technical surname", + "company": "Example Inc.", + "email_address": "technical@sp.example.com", + "contact_type": "technical", + }, + { + "given_name": "Administrative givenname", + "sur_name": "Administrative surname", + "company": "Example Inc.", + "email_address": "administrative@sp.example.ccom", + "contact_type": "administrative", + }, ], - 'organization': { - 'name': [('Ejemplo S.A.', 'es'), ('Example Inc.', 'en')], - 'display_name': [('Ejemplo', 'es'), ('Example', 'en')], - 'url': [('http://www.example.es', 'es'), - ('http://www.example.com', 'en')], + "organization": { + "name": [("Ejemplo S.A.", "es"), ("Example Inc.", "en")], + "display_name": [("Ejemplo", "es"), ("Example", "en")], + "url": [("http://www.example.es", "es"), ("http://www.example.com", "en")], }, - 'valid_for': 24, + "valid_for": 24, } - config['service']['sp'].update(**sp_kwargs) + if sp_kwargs is not None: + config["service"]["sp"].update(**sp_kwargs) if authn_requests_signed is not None: - config['service']['sp']['authn_requests_signed'] = authn_requests_signed + config["service"]["sp"]["authn_requests_signed"] = authn_requests_signed for idp in idp_hosts: - entity_id = 'https://%s/simplesaml/saml2/idp/metadata.php' % idp - config['service']['sp']['idp'][entity_id] = { - 'single_sign_on_service': { - saml2.BINDING_HTTP_REDIRECT: 'https://%s/simplesaml/saml2/idp/SSOService.php' % idp, + entity_id = "https://%s/simplesaml/saml2/idp/metadata.php" % idp + config["service"]["sp"]["idp"][entity_id] = { + "single_sign_on_service": { + saml2.BINDING_HTTP_REDIRECT: "https://%s/simplesaml/saml2/idp/SSOService.php" + % idp, }, - 'single_logout_service': { - saml2.BINDING_HTTP_REDIRECT: 'https://%s/simplesaml/saml2/idp/SingleLogoutService.php' % idp, + "single_logout_service": { + saml2.BINDING_HTTP_REDIRECT: "https://%s/simplesaml/saml2/idp/SingleLogoutService.php" + % idp, }, } diff --git a/djangosaml2/urls.py b/djangosaml2/urls.py index 48d767f5..32e896b3 100644 --- a/djangosaml2/urls.py +++ b/djangosaml2/urls.py @@ -18,10 +18,10 @@ from . import views urlpatterns = [ - path('login/', views.LoginView.as_view(), name='saml2_login'), - path('acs/', views.AssertionConsumerServiceView.as_view(), name='saml2_acs'), - path('logout/', views.LogoutInitView.as_view(), name='saml2_logout'), - path('ls/', views.LogoutView.as_view(), name='saml2_ls'), - path('ls/post/', views.LogoutView.as_view(), name='saml2_ls_post'), - path('metadata/', views.MetadataView.as_view(), name='saml2_metadata'), + path("login/", views.LoginView.as_view(), name="saml2_login"), + path("acs/", views.AssertionConsumerServiceView.as_view(), name="saml2_acs"), + path("logout/", views.LogoutInitView.as_view(), name="saml2_logout"), + path("ls/", views.LogoutView.as_view(), name="saml2_ls"), + path("ls/post/", views.LogoutView.as_view(), name="saml2_ls_post"), + path("metadata/", views.MetadataView.as_view(), name="saml2_metadata"), ] diff --git a/djangosaml2/utils.py b/djangosaml2/utils.py index ae392c0a..af2770b8 100644 --- a/djangosaml2/utils.py +++ b/djangosaml2/utils.py @@ -22,14 +22,15 @@ from django.core.exceptions import ImproperlyConfigured from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import resolve_url + try: from django.utils.http import url_has_allowed_host_and_scheme except ImportError: # django 2.2 from django.utils.http import is_safe_url as url_has_allowed_host_and_scheme + from saml2.config import SPConfig from saml2.s_utils import UnknownSystemEntity - logger = logging.getLogger(__name__) @@ -43,26 +44,26 @@ def available_idps(config: SPConfig, langpref=None) -> dict: idps = set() - for metadata_name, metadata in config.metadata.metadata.items(): - result = metadata.any('idpsso_descriptor', 'single_sign_on_service') + for metadata in config.metadata.metadata.values(): + result = metadata.any("idpsso_descriptor", "single_sign_on_service") if result: idps.update(result.keys()) - return { - idp: config.metadata.name(idp, langpref) - for idp in idps - } + return {idp: config.metadata.name(idp, langpref) for idp in idps} -def get_idp_sso_supported_bindings(idp_entity_id: Optional[str] = None, config: Optional[SPConfig] = None) -> list: +def get_idp_sso_supported_bindings( + idp_entity_id: Optional[str] = None, config: Optional[SPConfig] = None +) -> list: """Returns the list of bindings supported by an IDP This is not clear in the pysaml2 code, so wrapping it in a util""" if config is None: # avoid circular import from .conf import get_config + config = get_config() # load metadata store from config - meta = getattr(config, 'metadata', {}) + meta = getattr(config, "metadata", {}) # if idp is None, assume only one exists so just use that if idp_entity_id is None: try: @@ -70,24 +71,31 @@ def get_idp_sso_supported_bindings(idp_entity_id: Optional[str] = None, config: except IndexError: raise ImproperlyConfigured("No IdP configured!") try: - return list(meta.service(idp_entity_id, 'idpsso_descriptor', 'single_sign_on_service').keys()) + return list( + meta.service( + idp_entity_id, "idpsso_descriptor", "single_sign_on_service" + ).keys() + ) except UnknownSystemEntity: raise UnknownSystemEntity except Exception as e: logger.error(f"get_idp_sso_supported_bindings failed with: {e}") + def get_location(http_info): """Extract the redirect URL from a pysaml2 http_info object""" try: - headers = dict(http_info['headers']) - return headers['Location'] + headers = dict(http_info["headers"]) + return headers["Location"] except KeyError: - return http_info['url'] + return http_info["url"] def get_fallback_login_redirect_url(): - login_redirect_url = get_custom_setting('LOGIN_REDIRECT_URL', '/') - return resolve_url(get_custom_setting('ACS_DEFAULT_REDIRECT_URL', login_redirect_url)) + login_redirect_url = get_custom_setting("LOGIN_REDIRECT_URL", "/") + return resolve_url( + get_custom_setting("ACS_DEFAULT_REDIRECT_URL", login_redirect_url) + ) def validate_referral_url(request, url): @@ -97,7 +105,8 @@ def validate_referral_url(request, url): # If this setting is absent, the default is to use the hostname that was used for the current # request. saml_allowed_hosts = set( - getattr(settings, 'SAML_ALLOWED_HOSTS', [request.get_host()])) + getattr(settings, "SAML_ALLOWED_HOSTS", [request.get_host()]) + ) if not url_has_allowed_host_and_scheme(url=url, allowed_hosts=saml_allowed_hosts): return get_fallback_login_redirect_url() @@ -106,7 +115,7 @@ def validate_referral_url(request, url): def saml2_from_httpredirect_request(url): urlquery = urllib.parse.urlparse(url).query - b64_inflated_saml2req = urllib.parse.parse_qs(urlquery)['SAMLRequest'][0] + b64_inflated_saml2req = urllib.parse.parse_qs(urlquery)["SAMLRequest"][0] inflated_saml2req = base64.b64decode(b64_inflated_saml2req) deflated_saml2req = zlib.decompress(inflated_saml2req, -15) @@ -124,14 +133,14 @@ def get_subject_id_from_saml2(saml2_xml): def add_param_in_url(url: str, param_key: str, param_value: str): - params = list(url.split('?')) - params.append(f'{param_key}={param_value}') - new_url = params[0] + '?' + ''.join(params[1:]) + params = list(url.split("?")) + params.append(f"{param_key}={param_value}") + new_url = params[0] + "?" + "".join(params[1:]) return new_url def add_idp_hinting(request, http_response) -> bool: - idphin_param = getattr(settings, 'SAML2_IDPHINT_PARAM', 'idphint') + idphin_param = getattr(settings, "SAML2_IDPHINT_PARAM", "idphint") urllib.parse.urlencode(request.GET) if idphin_param not in request.GET.keys(): @@ -139,36 +148,36 @@ def add_idp_hinting(request, http_response) -> bool: idphint = request.GET[idphin_param] # validation : TODO -> improve! - if idphint[0:4] != 'http': + if idphint[0:4] != "http": logger.warning( f'Idp hinting: "{idphint}" doesn\'t contain a valid value.' - 'idphint paramenter ignored.' + "idphint paramenter ignored." ) return False if http_response.status_code in (302, 303): # redirect binding # urlp = urllib.parse.urlparse(http_response.url) - new_url = add_param_in_url(http_response.url, - idphin_param, idphint) + new_url = add_param_in_url(http_response.url, idphin_param, idphint) return HttpResponseRedirect(new_url) elif http_response.status_code == 200: # post binding - res = re.search(r'action="(?P[a-z0-9\:\/\_\-\.]*)"', - http_response.content.decode(), re.I) + res = re.search( + r'action="(?P[a-z0-9\:\/\_\-\.]*)"', + http_response.content.decode(), + re.I, + ) if not res: return False - orig_url = res.groupdict()['url'] + orig_url = res.groupdict()["url"] # new_url = add_param_in_url(orig_url, idphin_param, idphint) - content = http_response.content.decode()\ - .replace(orig_url, new_url)\ - .encode() + content = http_response.content.decode().replace(orig_url, new_url).encode() return HttpResponse(content) else: logger.warning( - f'Idp hinting: cannot detect request type [{http_response.status_code}]' + f"Idp hinting: cannot detect request type [{http_response.status_code}]" ) return False diff --git a/djangosaml2/views.py b/djangosaml2/views.py index 0e382c94..a00c4268 100644 --- a/djangosaml2/views.py +++ b/djangosaml2/views.py @@ -15,36 +15,46 @@ import base64 import logging -import saml2 from urllib.parse import quote from django.conf import settings -from django.contrib import auth -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.auth.views import LogoutView as AuthLogoutView from django.core.exceptions import PermissionDenied, SuspiciousOperation -from django.http import (HttpRequest, HttpResponse, HttpResponseBadRequest, - HttpResponseRedirect, HttpResponseServerError) +from django.http import ( + HttpRequest, + HttpResponse, + HttpResponseBadRequest, + HttpResponseRedirect, + HttpResponseServerError, +) from django.shortcuts import render from django.template import TemplateDoesNotExist from django.urls import reverse from django.utils.decorators import method_decorator +from django.utils.module_loading import import_string from django.views.decorators.csrf import csrf_exempt from django.views.generic import View -from django.utils.module_loading import import_string + +from django.contrib import auth +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.views import LogoutView as AuthLogoutView + +import saml2 from saml2.client_base import LogoutError from saml2.config import SPConfig from saml2.ident import code, decode from saml2.mdstore import SourceNotFound from saml2.metadata import entity_descriptor -from saml2.response import (RequestVersionTooLow, - SignatureError, StatusAuthnFailed, StatusError, - StatusNoAuthnContext, StatusRequestDenied, - UnsolicitedResponse) +from saml2.response import ( + RequestVersionTooLow, + SignatureError, + StatusAuthnFailed, + StatusError, + StatusNoAuthnContext, + StatusRequestDenied, + UnsolicitedResponse, +) from saml2.s_utils import UnsupportedBinding from saml2.saml import SCM_BEARER -from saml2.saml import AuthnContextClassRef -from saml2.samlp import RequestedAuthnContext from saml2.samlp import AuthnRequest, IDPEntry, IDPList, Scoping from saml2.sigver import MissingKey from saml2.validate import ResponseLifetimeExceed, ToEarly @@ -53,29 +63,32 @@ from .conf import get_config from .exceptions import IdPConfigurationMissing from .overrides import Saml2Client -from .utils import (add_idp_hinting, available_idps, get_custom_setting, - get_fallback_login_redirect_url, - get_idp_sso_supported_bindings, get_location, - validate_referral_url) - +from .utils import ( + add_idp_hinting, + available_idps, + get_custom_setting, + get_fallback_login_redirect_url, + get_idp_sso_supported_bindings, + get_location, + validate_referral_url, +) -logger = logging.getLogger('djangosaml2') +logger = logging.getLogger("djangosaml2") def _set_subject_id(session, subject_id): - session['_saml2_subject_id'] = code(subject_id) + session["_saml2_subject_id"] = code(subject_id) def _get_subject_id(session): try: - return decode(session['_saml2_subject_id']) + return decode(session["_saml2_subject_id"]) except KeyError: return None class SPConfigMixin: - """ Mixin for some of the SAML views with re-usable methods. - """ + """Mixin for some of the SAML views with re-usable methods.""" config_loader_path = None @@ -89,82 +102,78 @@ def get_state_client(self, request: HttpRequest): conf = self.get_sp_config(request) state = StateCache(request.saml_session) client = Saml2Client( - conf, state_cache=state, - identity_cache=IdentityCache(request.saml_session) + conf, state_cache=state, identity_cache=IdentityCache(request.saml_session) ) return state, client class LoginView(SPConfigMixin, View): - """ SAML Authorization Request initiator. - - This view initiates the SAML2 Authorization handshake - using the pysaml2 library to create the AuthnRequest. - - post_binding_form_template is a path to a template containing HTML form with - hidden input elements, used to send the SAML message data when HTTP POST - binding is being used. You can customize this template to include custom - branding and/or text explaining the automatic redirection process. Please - see the example template in templates/djangosaml2/example_post_binding_form.html - If set to None or nonexistent template, default form from the saml2 library - will be rendered. + """SAML Authorization Request initiator. + + This view initiates the SAML2 Authorization handshake + using the pysaml2 library to create the AuthnRequest. + + post_binding_form_template is a path to a template containing HTML form with + hidden input elements, used to send the SAML message data when HTTP POST + binding is being used. You can customize this template to include custom + branding and/or text explaining the automatic redirection process. Please + see the example template in templates/djangosaml2/example_post_binding_form.html + If set to None or nonexistent template, default form from the saml2 library + will be rendered. """ wayf_template = getattr( - settings, - 'SAML2_CUSTOM_WAYF_TEMPLATE','djangosaml2/wayf.html' + settings, "SAML2_CUSTOM_WAYF_TEMPLATE", "djangosaml2/wayf.html" ) authorization_error_template = getattr( settings, - 'SAML2_CUSTOM_AUTHORIZATION_ERROR_TEMPLATE', - 'djangosaml2/auth_error.html' + "SAML2_CUSTOM_AUTHORIZATION_ERROR_TEMPLATE", + "djangosaml2/auth_error.html", ) post_binding_form_template = getattr( settings, - 'SAML2_CUSTOM_POST_BINDING_FORM_TEMPLATE', - 'djangosaml2/post_binding_form.html' + "SAML2_CUSTOM_POST_BINDING_FORM_TEMPLATE", + "djangosaml2/post_binding_form.html", ) def get_next_path(self, request: HttpRequest) -> str: - ''' Returns the path to put in the RelayState to redirect the user to after having logged in. - If the user is already logged in (and if allowed), he will redirect to there immediately. - ''' + """Returns the path to put in the RelayState to redirect the user to after having logged in. + If the user is already logged in (and if allowed), he will redirect to there immediately. + """ next_path = get_fallback_login_redirect_url() - if 'next' in request.GET: - next_path = request.GET['next'] - elif 'RelayState' in request.GET: - next_path = request.GET['RelayState'] + if "next" in request.GET: + next_path = request.GET["next"] + elif "RelayState" in request.GET: + next_path = request.GET["RelayState"] next_path = validate_referral_url(request, next_path) return next_path def unknown_idp(self, request, idp): - msg = (f'Error: IdP EntityID {idp} was not found in metadata') + msg = f"Error: IdP EntityID {idp} was not found in metadata" logger.error(msg) - return HttpResponse( - msg.format('Please contact technical support.'), status=403 - ) + return HttpResponse(msg.format("Please contact technical support."), status=403) def load_sso_kwargs_scoping(self, sso_kwargs): - """ Performs IdP Scoping if scoping param is present. """ - idp_scoping_param = self.request.GET.get('scoping', None) + """Performs IdP Scoping if scoping param is present.""" + idp_scoping_param = self.request.GET.get("scoping", None) if idp_scoping_param: idp_scoping = Scoping() idp_scoping.idp_list = IDPList() idp_scoping.idp_list.idp_entry.append( - IDPEntry(provider_id = idp_scoping_param) + IDPEntry(provider_id=idp_scoping_param) ) - sso_kwargs['scoping'] = idp_scoping + sso_kwargs["scoping"] = idp_scoping def load_sso_kwargs(self, sso_kwargs): - """ Inherit me if you want to put your desidered things in sso_kwargs """ + """Inherit me if you want to put your desidered things in sso_kwargs""" def add_idp_hinting(self, http_response): return add_idp_hinting(self.request, http_response) or http_response def get(self, request, *args, **kwargs): - logger.debug('Login process started') + logger.debug("Login process started") next_path = self.get_next_path(request) # if the user is already authenticated that maybe because of two reasons: @@ -178,114 +187,125 @@ def get(self, request, *args, **kwargs): # is True (default value) we will redirect him to the next_path path. # Otherwise, we will show an (configurable) authorization error. if request.user.is_authenticated: - if get_custom_setting('SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN', True): + if get_custom_setting("SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN", True): return HttpResponseRedirect(next_path) - logger.debug('User is already logged in') - return render(request, self.authorization_error_template, { - 'came_from': next_path, - }) + logger.debug("User is already logged in") + return render( + request, + self.authorization_error_template, + { + "came_from": next_path, + }, + ) try: conf = self.get_sp_config(request) - except SourceNotFound as excp: # pragma: no cover + except SourceNotFound: # pragma: no cover # this is deprecated and it's here only for the doubts that something # would happen the day after I'll remove it! :) - return self.unknown_idp(request, idp='unknown') + return self.unknown_idp(request, idp="unknown") # is a embedded wayf or DiscoveryService needed? configured_idps = available_idps(conf) - selected_idp = request.GET.get('idp', None) + selected_idp = request.GET.get("idp", None) self.conf = conf sso_kwargs = {} # Do we have a Discovery Service? if not selected_idp: - discovery_service = getattr(settings, 'SAML2_DISCO_URL', None) + discovery_service = getattr(settings, "SAML2_DISCO_URL", None) if discovery_service: # We have to build the URL to redirect to with all the information # for the Discovery Service to know how to send the flow back to us - logger.debug(("A discovery process is needed trough a" - "Discovery Service: {}").format(discovery_service)) - login_url = request.build_absolute_uri(reverse('saml2_login')) - login_url = '{0}?next={1}'.format(login_url, quote(next_path, safe='')) - ds_url = '{0}?entityID={1}&return={2}&returnIDParam=idp' - ds_url = ds_url.format(discovery_service, - quote(getattr(conf, 'entityid'), safe=''), - quote(login_url, safe='')) + logger.debug( + ( + "A discovery process is needed trough a" "Discovery Service: {}" + ).format(discovery_service) + ) + login_url = "{}?next={}".format( + request.build_absolute_uri(reverse("saml2_login")), + quote(next_path, safe=""), + ) + ds_url = "{}?entityID={}&return={}&returnIDParam=idp".format( + discovery_service, + quote(conf.entityid, safe=""), + quote(login_url, safe=""), + ) return HttpResponseRedirect(ds_url) elif len(configured_idps) > 1: - logger.debug('A discovery process trough WAYF page is needed') - return render(request, self.wayf_template, { - 'available_idps': configured_idps.items(), - 'came_from': next_path, - }) + logger.debug("A discovery process trough WAYF page is needed") + return render( + request, + self.wayf_template, + { + "available_idps": configured_idps.items(), + "came_from": next_path, + }, + ) # is the first one, otherwise next logger message will print None - if not configured_idps: # pragma: no cover - raise IdPConfigurationMissing( - ('IdP is missing or its metadata is expired.')) + if not configured_idps: # pragma: no cover + raise IdPConfigurationMissing("IdP is missing or its metadata is expired.") if selected_idp is None: selected_idp = list(configured_idps.keys())[0] # choose a binding to try first - binding = getattr(settings, 'SAML_DEFAULT_BINDING', - saml2.BINDING_HTTP_POST) - logger.debug(f'Trying binding {binding} for IDP {selected_idp}') + binding = getattr(settings, "SAML_DEFAULT_BINDING", saml2.BINDING_HTTP_POST) + logger.debug(f"Trying binding {binding} for IDP {selected_idp}") # ensure our selected binding is supported by the IDP try: supported_bindings = get_idp_sso_supported_bindings( - selected_idp, config=conf) + selected_idp, config=conf + ) except saml2.s_utils.UnknownSystemEntity: return self.unknown_idp(request, selected_idp) if binding not in supported_bindings: logger.debug( - f'Binding {binding} not in IDP {selected_idp} ' - f'supported bindings: {supported_bindings}. Trying to switch ...', + f"Binding {binding} not in IDP {selected_idp} " + f"supported bindings: {supported_bindings}. Trying to switch ...", ) if binding == saml2.BINDING_HTTP_POST: logger.warning( - f'IDP {selected_idp} does not support {binding} ' - f'trying {saml2.BINDING_HTTP_REDIRECT}', + f"IDP {selected_idp} does not support {binding} " + f"trying {saml2.BINDING_HTTP_REDIRECT}", ) binding = saml2.BINDING_HTTP_REDIRECT - else: # pragma: no cover + else: # pragma: no cover logger.warning( - f'IDP {selected_idp} does not support {binding} ' - f'trying {saml2.BINDING_HTTP_POST}', + f"IDP {selected_idp} does not support {binding} " + f"trying {saml2.BINDING_HTTP_POST}", ) binding = saml2.BINDING_HTTP_POST # if switched binding still not supported, give up - if binding not in supported_bindings: # pragma: no cover + if binding not in supported_bindings: # pragma: no cover raise UnsupportedBinding( - f'IDP {selected_idp} does not support ' - f'{saml2.BINDING_HTTP_POST} or {saml2.BINDING_HTTP_REDIRECT}' + f"IDP {selected_idp} does not support " + f"{saml2.BINDING_HTTP_POST} or {saml2.BINDING_HTTP_REDIRECT}" ) client = Saml2Client(conf) # SSO options - sign_requests = getattr(conf, '_sp_authn_requests_signed', False) + sign_requests = getattr(conf, "_sp_authn_requests_signed", False) if sign_requests: - sso_kwargs["sigalg"] = getattr(conf, '_sp_signing_algorithm', - saml2.xmldsig.SIG_RSA_SHA256 + sso_kwargs["sigalg"] = getattr( + conf, "_sp_signing_algorithm", saml2.xmldsig.SIG_RSA_SHA256 ) - sso_kwargs["digest_alg"] = getattr(conf, - '_sp_digest_algorithm', - saml2.xmldsig.DIGEST_SHA256 + sso_kwargs["digest_alg"] = getattr( + conf, "_sp_digest_algorithm", saml2.xmldsig.DIGEST_SHA256 ) # pysaml needs a string otherwise: "cannot serialize True (type bool)" - if getattr(conf, '_sp_force_authn', False): - sso_kwargs['force_authn'] = "true" - if getattr(conf, '_sp_allow_create', False): - sso_kwargs['allow_create'] = "true" + if getattr(conf, "_sp_force_authn", False): + sso_kwargs["force_authn"] = "true" + if getattr(conf, "_sp_allow_create", False): + sso_kwargs["allow_create"] = "true" # custom nsprefixes - sso_kwargs['nsprefix'] = get_namespace_prefixes() - + sso_kwargs["nsprefix"] = get_namespace_prefixes() # Enrich sso_kwargs ... # idp scoping @@ -293,18 +313,21 @@ def get(self, request, *args, **kwargs): # other customization to be inherited self.load_sso_kwargs(sso_kwargs) - logger.debug(f'Redirecting user to the IdP via {binding} binding.') - _msg = 'Unable to know which IdP to use' + logger.debug(f"Redirecting user to the IdP via {binding} binding.") + _msg = "Unable to know which IdP to use" http_response = None if binding == saml2.BINDING_HTTP_REDIRECT: try: session_id, result = client.prepare_for_authenticate( - entityid=selected_idp, relay_state=next_path, - binding=binding, sign=sign_requests, - **sso_kwargs) + entityid=selected_idp, + relay_state=next_path, + binding=binding, + sign=sign_requests, + **sso_kwargs, + ) except TypeError as e: - logger.error(f'{_msg}: {e}') + logger.error(f"{_msg}: {e}") return HttpResponse(_msg) else: http_response = HttpResponseRedirect(get_location(result)) @@ -315,54 +338,60 @@ def get(self, request, *args, **kwargs): try: location = client.sso_location(selected_idp, binding) except TypeError as e: - logger.error(f'{_msg}: {e}') + logger.error(f"{_msg}: {e}") return HttpResponse(_msg) session_id, request_xml = client.create_authn_request( - location, - binding=binding, - **sso_kwargs + location, binding=binding, **sso_kwargs ) try: if isinstance(request_xml, AuthnRequest): # request_xml will be an instance of AuthnRequest if the message is not signed request_xml = str(request_xml) - saml_request = base64.b64encode( - bytes(request_xml, 'UTF-8')).decode('utf-8') - - http_response = render(request, self.post_binding_form_template, { - 'target_url': location, - 'params': { - 'SAMLRequest': saml_request, - 'RelayState': next_path, + saml_request = base64.b64encode(bytes(request_xml, "UTF-8")).decode( + "utf-8" + ) + + http_response = render( + request, + self.post_binding_form_template, + { + "target_url": location, + "params": { + "SAMLRequest": saml_request, + "RelayState": next_path, + }, }, - }) + ) except TemplateDoesNotExist as e: logger.debug( - f'TemplateDoesNotExist: [{self.post_binding_form_template}] - {e}' + f"TemplateDoesNotExist: [{self.post_binding_form_template}] - {e}" ) if not http_response: # use the html provided by pysaml2 if no template was specified or it doesn't exist try: session_id, result = client.prepare_for_authenticate( - entityid=selected_idp, relay_state=next_path, - binding=binding, **sso_kwargs) + entityid=selected_idp, + relay_state=next_path, + binding=binding, + **sso_kwargs, + ) except TypeError as e: _msg = f"Can't prepare the authentication for {selected_idp}" - logger.error(f'{_msg}: {e}') + logger.error(f"{_msg}: {e}") return HttpResponse(_msg) else: - http_response = HttpResponse(result['data']) + http_response = HttpResponse(result["data"]) else: - raise UnsupportedBinding(f'Unsupported binding: {binding}') + raise UnsupportedBinding(f"Unsupported binding: {binding}") # success, so save the session ID and return our response oq_cache = OutstandingQueriesCache(request.saml_session) oq_cache.set(session_id, next_path) logger.debug( f'Saving the session_id "{oq_cache.__dict__}" ' - 'in the OutstandingQueries cache', + "in the OutstandingQueries cache", ) # idp hinting support, add idphint url parameter if present in this request @@ -370,43 +399,54 @@ def get(self, request, *args, **kwargs): return response -@method_decorator(csrf_exempt, name='dispatch') +@method_decorator(csrf_exempt, name="dispatch") class AssertionConsumerServiceView(SPConfigMixin, View): - """ The IdP will send its response to this view, which will process it using pysaml2 and - log the user in using whatever SAML authentication backend has been enabled in - settings.py. The `djangosaml2.backends.Saml2Backend` can be used for this purpose, - though some implementations may instead register their own subclasses of Saml2Backend. + """The IdP will send its response to this view, which will process it using pysaml2 and + log the user in using whatever SAML authentication backend has been enabled in + settings.py. The `djangosaml2.backends.Saml2Backend` can be used for this purpose, + though some implementations may instead register their own subclasses of Saml2Backend. """ def custom_validation(self, response): pass def handle_acs_failure(self, request, exception=None, status=403, **kwargs): - """ Error handler if the login attempt fails. Override this to customize the error response. - """ + """Error handler if the login attempt fails. Override this to customize the error response.""" # Backwards compatibility: if a custom setting was defined, use that one custom_failure_function = get_custom_setting( - 'SAML_ACS_FAILURE_RESPONSE_FUNCTION') + "SAML_ACS_FAILURE_RESPONSE_FUNCTION" + ) if custom_failure_function: - failure_function = custom_failure_function if callable( - custom_failure_function) else import_string(custom_failure_function) + failure_function = ( + custom_failure_function + if callable(custom_failure_function) + else import_string(custom_failure_function) + ) return failure_function(request, exception, status, **kwargs) - return render(request, 'djangosaml2/login_error.html', {'exception': exception}, status=status) + return render( + request, + "djangosaml2/login_error.html", + {"exception": exception}, + status=status, + ) def post(self, request, attribute_mapping=None, create_unknown_user=None): - """ SAML Authorization Response endpoint - """ + """SAML Authorization Response endpoint""" - if 'SAMLResponse' not in request.POST: + if "SAMLResponse" not in request.POST: logger.warning('Missing "SAMLResponse" parameter in POST data.') - return HttpResponseBadRequest('Missing "SAMLResponse" parameter in POST data.') + return HttpResponseBadRequest( + 'Missing "SAMLResponse" parameter in POST data.' + ) attribute_mapping = attribute_mapping or get_custom_setting( - 'SAML_ATTRIBUTE_MAPPING', {'uid': ('username', )}) + "SAML_ATTRIBUTE_MAPPING", {"uid": ("username",)} + ) create_unknown_user = create_unknown_user or get_custom_setting( - 'SAML_CREATE_UNKNOWN_USER', True) + "SAML_CREATE_UNKNOWN_USER", True + ) conf = self.get_sp_config(request) identity_cache = IdentityCache(request.saml_session) @@ -417,43 +457,46 @@ def post(self, request, attribute_mapping=None, create_unknown_user=None): _exception = None try: - response = client.parse_authn_request_response(request.POST['SAMLResponse'], - saml2.BINDING_HTTP_POST, - outstanding_queries) + response = client.parse_authn_request_response( + request.POST["SAMLResponse"], + saml2.BINDING_HTTP_POST, + outstanding_queries, + ) except (StatusError, ToEarly) as e: _exception = e logger.exception("Error processing SAML Assertion.") except ResponseLifetimeExceed as e: _exception = e logger.info( - ("SAML Assertion is no longer valid. Possibly caused " - "by network delay or replay attack."), exc_info=True) + ( + "SAML Assertion is no longer valid. Possibly caused " + "by network delay or replay attack." + ), + exc_info=True, + ) except SignatureError as e: _exception = e logger.info("Invalid or malformed SAML Assertion.", exc_info=True) except StatusAuthnFailed as e: _exception = e - logger.info("Authentication denied for user by IdP.", - exc_info=True) + logger.info("Authentication denied for user by IdP.", exc_info=True) except StatusRequestDenied as e: _exception = e logger.warning("Authentication interrupted at IdP.", exc_info=True) except StatusNoAuthnContext as e: _exception = e - logger.warning( - "Missing Authentication Context from IdP.", exc_info=True) + logger.warning("Missing Authentication Context from IdP.", exc_info=True) except MissingKey as e: _exception = e logger.exception( - "SAML Identity Provider is not configured correctly: certificate key is missing!") + "SAML Identity Provider is not configured correctly: certificate key is missing!" + ) except UnsolicitedResponse as e: _exception = e - logger.exception( - "Received SAMLResponse when no request has been made.") + logger.exception("Received SAMLResponse when no request has been made.") except RequestVersionTooLow as e: _exception = e - logger.exception( - "Received SAMLResponse have a deprecated SAML2 VERSION.") + logger.exception("Received SAMLResponse have a deprecated SAML2 VERSION.") except Exception as e: _exception = e logger.exception("SAMLResponse Error") @@ -462,13 +505,21 @@ def post(self, request, attribute_mapping=None, create_unknown_user=None): return self.handle_acs_failure(request, exception=_exception) elif response is None: logger.warning("Invalid SAML Assertion received (unknown error).") - return self.handle_acs_failure(request, status=400, exception=SuspiciousOperation('Unknown SAML2 error')) + return self.handle_acs_failure( + request, + status=400, + exception=SuspiciousOperation("Unknown SAML2 error"), + ) try: self.custom_validation(response) except Exception as e: logger.warning(f"SAML Response validation error: {e}") - return self.handle_acs_failure(request, status=400, exception=SuspiciousOperation('SAML2 validation error')) + return self.handle_acs_failure( + request, + status=400, + exception=SuspiciousOperation("SAML2 validation error"), + ) session_id = response.session_id() oq_cache.delete(session_id) @@ -482,8 +533,10 @@ def post(self, request, attribute_mapping=None, create_unknown_user=None): for sc in assertion.subject.subject_confirmation: if sc.method == SCM_BEARER: assertion_not_on_or_after = sc.subject_confirmation_data.not_on_or_after - assertion_info = {'assertion_id': assertion.id, - 'not_on_or_after': assertion_not_on_or_after} + assertion_info = { + "assertion_id": assertion.id, + "not_on_or_after": assertion_not_on_or_after, + } break if callable(attribute_mapping): @@ -491,71 +544,73 @@ def post(self, request, attribute_mapping=None, create_unknown_user=None): if callable(create_unknown_user): create_unknown_user = create_unknown_user() - logger.debug( - 'Trying to authenticate the user. Session info: %s', session_info) - user = auth.authenticate(request=request, - session_info=session_info, - attribute_mapping=attribute_mapping, - create_unknown_user=create_unknown_user, - assertion_info=assertion_info) + logger.debug("Trying to authenticate the user. Session info: %s", session_info) + user = auth.authenticate( + request=request, + session_info=session_info, + attribute_mapping=attribute_mapping, + create_unknown_user=create_unknown_user, + assertion_info=assertion_info, + ) if user is None: logger.warning( - "Could not authenticate user received in SAML Assertion. Session info: %s", session_info) - return self.handle_acs_failure(request, exception=PermissionDenied('No user could be authenticated.'), - session_info=session_info) + "Could not authenticate user received in SAML Assertion. Session info: %s", + session_info, + ) + return self.handle_acs_failure( + request, + exception=PermissionDenied("No user could be authenticated."), + session_info=session_info, + ) auth.login(self.request, user) - _set_subject_id(request.saml_session, session_info['name_id']) + _set_subject_id(request.saml_session, session_info["name_id"]) logger.debug("User %s authenticated via SSO.", user) self.post_login_hook(request, user, session_info) self.customize_session(user, session_info) relay_state = self.build_relay_state() - custom_redirect_url = self.custom_redirect( - user, relay_state, session_info) + custom_redirect_url = self.custom_redirect(user, relay_state, session_info) if custom_redirect_url: return HttpResponseRedirect(custom_redirect_url) relay_state = validate_referral_url(request, relay_state) - logger.debug('Redirecting to the RelayState: %s', relay_state) + logger.debug("Redirecting to the RelayState: %s", relay_state) return HttpResponseRedirect(relay_state) - def post_login_hook(self, request: HttpRequest, user: settings.AUTH_USER_MODEL, session_info: dict) -> None: - """ If desired, a hook to add logic after a user has succesfully logged in. - """ + def post_login_hook( + self, request: HttpRequest, user: settings.AUTH_USER_MODEL, session_info: dict + ) -> None: + """If desired, a hook to add logic after a user has succesfully logged in.""" def build_relay_state(self) -> str: - """ The relay state is a URL used to redirect the user to the view where they came from. - """ + """The relay state is a URL used to redirect the user to the view where they came from.""" default_relay_state = get_fallback_login_redirect_url() - relay_state = self.request.POST.get('RelayState', default_relay_state) + relay_state = self.request.POST.get("RelayState", default_relay_state) relay_state = self.customize_relay_state(relay_state) if not relay_state: - logger.warning('The RelayState parameter exists but is empty') + logger.warning("The RelayState parameter exists but is empty") relay_state = default_relay_state return relay_state def customize_session(self, user, session_info: dict): - """ Subclasses can use this for customized functionality around user sessions. - """ + """Subclasses can use this for customized functionality around user sessions.""" def customize_relay_state(self, relay_state: str) -> str: - """ Subclasses may override this method to implement custom logic for relay state. - """ + """Subclasses may override this method to implement custom logic for relay state.""" return relay_state def custom_redirect(self, user, relay_state: str, session_info) -> str: - """ Subclasses may override this method to implement custom logic for redirect. + """Subclasses may override this method to implement custom logic for redirect. - For example, some sites may require user registration if the user has not - yet been provisioned. + For example, some sites may require user registration if the user has not + yet been provisioned. """ return None class EchoAttributesView(LoginRequiredMixin, SPConfigMixin, View): - """Example view that echo the SAML attributes of an user - """ + """Example view that echo the SAML attributes of an user""" def get(self, request, *args, **kwargs): state, client = self.get_state_client(request) @@ -563,18 +618,23 @@ def get(self, request, *args, **kwargs): subject_id = _get_subject_id(request.saml_session) try: identity = client.users.get_identity( - subject_id, check_not_on_or_after=False) + subject_id, check_not_on_or_after=False + ) except AttributeError: - return HttpResponse("No active SAML identity found. Are you sure you have logged in via SAML?") + return HttpResponse( + "No active SAML identity found. Are you sure you have logged in via SAML?" + ) - return render(request, 'djangosaml2/echo_attributes.html', {'attributes': identity[0]}) + return render( + request, "djangosaml2/echo_attributes.html", {"attributes": identity[0]} + ) class LogoutInitView(LoginRequiredMixin, SPConfigMixin, View): - """ SAML Logout Request initiator + """SAML Logout Request initiator - This view initiates the SAML2 Logout request - using the pysaml2 library to create the LogoutRequest. + This view initiates the SAML2 Logout request + using the pysaml2 library to create the LogoutRequest. """ def get(self, request, *args, **kwargs): @@ -583,18 +643,17 @@ def get(self, request, *args, **kwargs): subject_id = _get_subject_id(request.saml_session) if subject_id is None: logger.warning( - 'The session does not contain the subject id for user %s', request.user) + "The session does not contain the subject id for user %s", request.user + ) _error = None try: result = client.global_logout(subject_id) except LogoutError as exp: - logger.exception( - 'Error Handled - SLO not supported by IDP: {}'.format(exp)) + logger.exception(f"Error Handled - SLO not supported by IDP: {exp}") _error = exp except UnsupportedBinding as exp: - logger.exception( - 'Error Handled - SLO - unsupported binding by IDP: {}'.format(exp)) + logger.exception(f"Error Handled - SLO - unsupported binding by IDP: {exp}") _error = exp auth.logout(request) @@ -603,61 +662,65 @@ def get(self, request, *args, **kwargs): if _error: return self.handle_unsupported_slo_exception(request, _error) - if not result: logger.error( - "Looks like the user %s is not logged in any IdP/AA", subject_id) + "Looks like the user %s is not logged in any IdP/AA", subject_id + ) return HttpResponseBadRequest("You are not logged in any IdP/AA") if len(result) > 1: logger.error( - 'Sorry, I do not know how to logout from several sources. I will logout just from the first one') + "Sorry, I do not know how to logout from several sources. I will logout just from the first one" + ) - for entityid, logout_info in result.items(): + for logout_info in result.values(): if isinstance(logout_info, tuple): binding, http_info = logout_info if binding == saml2.BINDING_HTTP_POST: logger.debug( - 'Returning form to the IdP to continue the logout process') - body = ''.join(http_info['data']) + "Returning form to the IdP to continue the logout process" + ) + body = "".join(http_info["data"]) return HttpResponse(body) elif binding == saml2.BINDING_HTTP_REDIRECT: logger.debug( - 'Redirecting to the IdP to continue the logout process') + "Redirecting to the IdP to continue the logout process" + ) return HttpResponseRedirect(get_location(http_info)) else: - logger.error('Unknown binding: %s', binding) - return HttpResponseServerError('Failed to log out') + logger.error("Unknown binding: %s", binding) + return HttpResponseServerError("Failed to log out") # We must have had a soap logout return finish_logout(request, logout_info) logger.error( - 'Could not logout because there only the HTTP_REDIRECT is supported') - return HttpResponseServerError('Logout Binding not supported') + "Could not logout because there only the HTTP_REDIRECT is supported" + ) + return HttpResponseServerError("Logout Binding not supported") def handle_unsupported_slo_exception(self, request, exception, *args, **kwargs): - """ Subclasses may override this method to implement custom logic for - handling logout errors. Redirects to LOGOUT_REDIRECT_URL by default. + """Subclasses may override this method to implement custom logic for + handling logout errors. Redirects to LOGOUT_REDIRECT_URL by default. - For example, a site may want to perform additional logic and redirect - users somewhere other than the LOGOUT_REDIRECT_URL. + For example, a site may want to perform additional logic and redirect + users somewhere other than the LOGOUT_REDIRECT_URL. """ - return HttpResponseRedirect(getattr(settings, 'LOGOUT_REDIRECT_URL', '/')) + return HttpResponseRedirect(getattr(settings, "LOGOUT_REDIRECT_URL", "/")) -@method_decorator(csrf_exempt, name='dispatch') +@method_decorator(csrf_exempt, name="dispatch") class LogoutView(SPConfigMixin, View): - """ SAML Logout Response endpoint - - The IdP will send the logout response to this view, - which will process it with pysaml2 help and log the user - out. - Note that the IdP can request a logout even when - we didn't initiate the process as a single logout - request started by another SP. + """SAML Logout Response endpoint + + The IdP will send the logout response to this view, + which will process it with pysaml2 help and log the user + out. + Note that the IdP can request a logout even when + we didn't initiate the process as a single logout + request started by another SP. """ - logout_error_template = 'djangosaml2/logout_error.html' + logout_error_template = "djangosaml2/logout_error.html" def get(self, request, *args, **kwargs): return self.do_logout_service( @@ -670,79 +733,87 @@ def post(self, request, *args, **kwargs): ) def do_logout_service(self, request, data, binding): - logger.debug('Logout service started') + logger.debug("Logout service started") state, client = self.get_state_client(request) - if 'SAMLResponse' in data: # we started the logout - logger.debug('Receiving a logout response from the IdP') + if "SAMLResponse" in data: # we started the logout + logger.debug("Receiving a logout response from the IdP") try: response = client.parse_logout_request_response( - data['SAMLResponse'], binding) + data["SAMLResponse"], binding + ) except StatusError as e: response = None - logger.warning( - "Error logging out from remote provider: " + str(e)) + logger.warning("Error logging out from remote provider: " + str(e)) state.sync() return finish_logout(request, response) - elif 'SAMLRequest' in data: # logout started by the IdP - logger.debug('Receiving a logout request from the IdP') + elif "SAMLRequest" in data: # logout started by the IdP + logger.debug("Receiving a logout request from the IdP") subject_id = _get_subject_id(request.saml_session) if subject_id is None: logger.warning( - 'The session does not contain the subject id for user %s. Performing local logout', - request.user) + "The session does not contain the subject id for user %s. Performing local logout", + request.user, + ) auth.logout(request) return render(request, self.logout_error_template, status=403) http_info = client.handle_logout_request( - data['SAMLRequest'], + data["SAMLRequest"], subject_id, binding, - relay_state=data.get('RelayState', '')) + relay_state=data.get("RelayState", ""), + ) state.sync() auth.logout(request) if ( - http_info.get('method', 'GET') == 'POST' and - 'data' in http_info and - ('Content-type', 'text/html') in http_info.get('headers', []) + http_info.get("method", "GET") == "POST" + and "data" in http_info + and ("Content-type", "text/html") in http_info.get("headers", []) ): # need to send back to the IDP a signed POST response with user session # return HTML form content to browser with auto form validation # to finally send request to the IDP - return HttpResponse(http_info['data']) + return HttpResponse(http_info["data"]) return HttpResponseRedirect(get_location(http_info)) - logger.error('No SAMLResponse or SAMLRequest parameter found') - return HttpResponseBadRequest('No SAMLResponse or SAMLRequest parameter found') + logger.error("No SAMLResponse or SAMLRequest parameter found") + return HttpResponseBadRequest("No SAMLResponse or SAMLRequest parameter found") def finish_logout(request, response, next_page=None): - if (getattr(settings, 'SAML_IGNORE_LOGOUT_ERRORS', False) or (response and response.status_ok())): + if getattr(settings, "SAML_IGNORE_LOGOUT_ERRORS", False) or ( + response and response.status_ok() + ): if not next_page: - next_page = getattr(settings, 'LOGOUT_REDIRECT_URL', '/') - logger.debug( - 'Performing django logout with a next_page of %s', next_page) + next_page = getattr(settings, "LOGOUT_REDIRECT_URL", "/") + logger.debug("Performing django logout with a next_page of %s", next_page) return AuthLogoutView.as_view()(request, next_page=next_page) - logger.error('Unknown error during the logout') + logger.error("Unknown error during the logout") return render(request, "djangosaml2/logout_error.html", {}) class MetadataView(SPConfigMixin, View): - """ Returns an XML with the SAML 2.0 metadata for this SP as configured in the settings.py file. - """ + """Returns an XML with the SAML 2.0 metadata for this SP as configured in the settings.py file.""" def get(self, request, *args, **kwargs): conf = self.get_sp_config(request) metadata = entity_descriptor(conf) - return HttpResponse(content=str(metadata).encode('utf-8'), content_type="text/xml; charset=utf-8") + return HttpResponse( + content=str(metadata).encode("utf-8"), + content_type="text/xml; charset=utf-8", + ) def get_namespace_prefixes(): - from saml2 import md, saml, samlp, xmlenc, xmldsig - return {'saml': saml.NAMESPACE, - 'samlp': samlp.NAMESPACE, - 'md': md.NAMESPACE, - 'ds': xmldsig.NAMESPACE, - 'xenc': xmlenc.NAMESPACE} + from saml2 import md, saml, samlp, xmldsig, xmlenc + + return { + "saml": saml.NAMESPACE, + "samlp": samlp.NAMESPACE, + "md": md.NAMESPACE, + "ds": xmldsig.NAMESPACE, + "xenc": xmlenc.NAMESPACE, + } diff --git a/docs/source/_static/custom.css b/docs/source/_static/custom.css index 5584d1b7..9d0a45b4 100644 --- a/docs/source/_static/custom.css +++ b/docs/source/_static/custom.css @@ -1,7 +1,7 @@ body, -h1, h2, -.rst-content .toctree-wrapper p.caption, -h3, h4, h5, h6, +h1, h2, +.rst-content .toctree-wrapper p.caption, +h3, h4, h5, h6, legend{ font-family: Lato,'Helvetica Neue',Arial,Helvetica,sans-serif; } @@ -10,13 +10,13 @@ legend{ background: #ffffff; } -.wy-side-nav-search>a, +.wy-side-nav-search>a, .wy-side-nav-search .wy-dropdown>a{ color: #9b9c9e; font-weight: normal; } -.wy-menu-vertical header, +.wy-menu-vertical header, .wy-menu-vertical p.caption{ color: #fff; font-size:85%; diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..5d906c1e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,14 @@ +[tool.black] +force-exclude = '''/(migrations)/''' +target-version = ["py36"] + +[tool.isort] +src_paths = ["djangosaml2", "tests"] +profile = "black" +known_django = ["django"] +known_contrib = ["django.contrib"] +known_saml2 = ["saml2"] +known_first_party = ["djangosaml2"] +known_tests = ["tests"] +sections = ["FUTURE", "STDLIB", "DJANGO", "CONTRIB", "THIRDPARTY", "SAML2", "FIRSTPARTY", "TESTS", "LOCALFOLDER"] +skip_glob = ["**/migrations/*.py"] diff --git a/setup.cfg b/setup.cfg index 2a9acf13..4ed9ab4e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,11 @@ [bdist_wheel] universal = 1 + +[flake8] +# E203 ignore +# https://github.com/PyCQA/pycodestyle/issues/373 +# https://github.com/PyCQA/pycodestyle/pull/914 +ignore = D106,E203,W503 +select = B0,B901,B902,B903,C,F,I,W +max-line-length = 88 +exclude = .tox,.git,*/migrations/*,docs diff --git a/setup.py b/setup.py index 0b507233..e6765652 100644 --- a/setup.py +++ b/setup.py @@ -15,19 +15,22 @@ import codecs import os -from setuptools import setup, find_packages + +from setuptools import find_packages, setup def read(*rnames): - return codecs.open(os.path.join(os.path.dirname(__file__), *rnames), encoding='utf-8').read() + return codecs.open( + os.path.join(os.path.dirname(__file__), *rnames), encoding="utf-8" + ).read() setup( - name='djangosaml2', - version='1.3.5', - description='pysaml2 integration for Django', - long_description=read('README.md'), - long_description_content_type='text/markdown', + name="djangosaml2", + version="1.3.6", + description="pysaml2 integration for Django", + long_description=read("README.md"), + long_description_content_type="text/markdown", classifiers=[ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", @@ -49,24 +52,20 @@ def read(*rnames): "Topic :: Internet :: WWW/HTTP :: WSGI", "Topic :: Security", "Topic :: Software Development :: Libraries :: Application Frameworks", - ], + ], keywords="django,pysaml2,sso,saml2,federated authentication,authentication", author="Yaco Sistemas and independent contributors", author_email="lgs@yaco.es", maintainer="Giuseppe De Marco", url="https://github.com/IdentityPython/djangosaml2", download_url="https://pypi.org/project/djangosaml2/", - license='Apache 2.0', + license="Apache 2.0", packages=find_packages(exclude=["tests", "tests.*"]), include_package_data=True, zip_safe=False, install_requires=[ - 'defusedxml>=0.4.1', - 'Django>=2.2,<5', - 'pysaml2>=6.5.1', - ], - tests_require=[ - # Provides assert_called_once. - 'mock', - ] - ) + "defusedxml>=0.4.1", + "Django>=2.2,<5", + "pysaml2>=6.5.1", + ], +) diff --git a/tests/.coveragerc b/tests/.coveragerc index d513e78f..f18e3f25 100644 --- a/tests/.coveragerc +++ b/tests/.coveragerc @@ -1,4 +1,3 @@ # .coveragerc to control coverage.py [run] source = djangosaml2 - diff --git a/tests/run_tests.py b/tests/run_tests.py index 7ef77c94..0ab2d7ee 100755 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -17,8 +17,8 @@ import os import sys -from django.core.wsgi import get_wsgi_application from django.core import management +from django.core.wsgi import get_wsgi_application PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), os.path.pardir)) os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") @@ -26,4 +26,4 @@ # Load models application = get_wsgi_application() -management.call_command('test', 'djangosaml2.tests', 'testprofiles') +management.call_command("test", "djangosaml2.tests", "testprofiles") diff --git a/tests/settings.py b/tests/settings.py index ba8a7d83..400b7d98 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -12,70 +12,66 @@ import os - # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Make this unique, and don't share it with anybody. -SECRET_KEY = 'xvds$ppv5ha75qg1yx3aax7ugr_2*fmdrc(lrc%x7kdez-63xn' +SECRET_KEY = "xvds$ppv5ha75qg1yx3aax7ugr_2*fmdrc(lrc%x7kdez-63xn" DEBUG = True ALLOWED_HOSTS = [] INSTALLED_APPS = ( - 'testprofiles', - - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - - 'djangosaml2', + "testprofiles", + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "djangosaml2", ) MIDDLEWARE = ( - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', - + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", # SameSite Cookie handler - 'djangosaml2.middleware.SamlSessionMiddleware' + "djangosaml2.middleware.SamlSessionMiddleware", ) -ROOT_URLCONF = 'testprofiles.urls' +ROOT_URLCONF = "testprofiles.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] -WSGI_APPLICATION = 'testprofiles.wsgi.application' +WSGI_APPLICATION = "testprofiles.wsgi.application" # Database # https://docs.djangoproject.com/en/1.10/ref/settings/#databases DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'tests/db.sqlite3'), + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.path.join(BASE_DIR, "tests/db.sqlite3"), } } @@ -84,25 +80,25 @@ AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] # Internationalization # https://docs.djangoproject.com/en/1.10/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" SITE_ID = 1 @@ -116,10 +112,10 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/1.10/howto/static-files/ -STATIC_URL = '/static/' +STATIC_URL = "/static/" -AUTH_USER_MODEL = 'testprofiles.TestUser' +AUTH_USER_MODEL = "testprofiles.TestUser" # A sample logging configuration. The only tangible logging # performed by this configuration is to send an email to @@ -127,38 +123,32 @@ # See http://docs.djangoproject.com/en/dev/topics/logging for # more details on how to customize your logging configuration. LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'filters': { - 'require_debug_false': { - '()': 'django.utils.log.RequireDebugFalse' - } - }, - 'handlers': { - 'mail_admins': { - 'level': 'ERROR', - 'filters': ['require_debug_false'], - 'class': 'django.utils.log.AdminEmailHandler' + "version": 1, + "disable_existing_loggers": False, + "filters": {"require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}}, + "handlers": { + "mail_admins": { + "level": "ERROR", + "filters": ["require_debug_false"], + "class": "django.utils.log.AdminEmailHandler", }, - 'console': { - 'level': 'DEBUG', - 'class': 'logging.StreamHandler', + "console": { + "level": "DEBUG", + "class": "logging.StreamHandler", }, }, - 'loggers': { - 'django.request': { - 'handlers': ['mail_admins'], - 'level': 'ERROR', - 'propagate': True, + "loggers": { + "django.request": { + "handlers": ["mail_admins"], + "level": "ERROR", + "propagate": True, }, - 'djangosaml2': { - 'handlers': ['console'], - 'level': 'DEBUG', + "djangosaml2": { + "handlers": ["console"], + "level": "DEBUG", }, - } + }, } -AUTHENTICATION_BACKENDS = ( - 'djangosaml2.backends.Saml2Backend', -) +AUTHENTICATION_BACKENDS = ("djangosaml2.backends.Saml2Backend",) diff --git a/tests/testprofiles/app.py b/tests/testprofiles/app.py index ce963e8d..045d32d0 100644 --- a/tests/testprofiles/app.py +++ b/tests/testprofiles/app.py @@ -2,6 +2,6 @@ class TestProfilesConfig(AppConfig): - name = 'testprofiles' - verbose_name = 'Test profiles' - default_auto_field = 'django.db.models.AutoField' + name = "testprofiles" + verbose_name = "Test profiles" + default_auto_field = "django.db.models.AutoField" diff --git a/tests/testprofiles/models.py b/tests/testprofiles/models.py index 4062ddec..d7cb5b25 100644 --- a/tests/testprofiles/models.py +++ b/tests/testprofiles/models.py @@ -13,9 +13,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -from django.contrib.auth.models import AbstractUser from django.db import models +from django.contrib.auth.models import AbstractUser + class TestUser(AbstractUser): age = models.CharField(max_length=100, blank=True) @@ -24,7 +25,7 @@ def process_first_name(self, first_name): self.first_name = first_name[0] class Meta: - app_label = 'testprofiles' + app_label = "testprofiles" class StandaloneUserModel(models.Model): @@ -32,6 +33,7 @@ class StandaloneUserModel(models.Model): Does not inherit from Django's base abstract user and does not define a USERNAME_FIELD. """ + username = models.CharField(max_length=30, unique=True) @@ -39,7 +41,7 @@ class RequiredFieldUser(models.Model): email = models.EmailField(unique=True) email_verified = models.BooleanField() - USERNAME_FIELD = 'email' + USERNAME_FIELD = "email" def __repr__(self): return self.email diff --git a/tests/testprofiles/tests.py b/tests/testprofiles/tests.py index 707a74fc..9bb39e72 100644 --- a/tests/testprofiles/tests.py +++ b/tests/testprofiles/tests.py @@ -15,173 +15,184 @@ # limitations under the License. from django.conf import settings -from django.contrib.auth import get_user_model -from djangosaml2.backends import get_saml_user_model -from django.contrib.auth.models import User as DjangoUserModel from django.core.exceptions import ImproperlyConfigured from django.test import TestCase, override_settings -from djangosaml2.backends import Saml2Backend, set_attribute +from django.contrib.auth import get_user_model +from django.contrib.auth.models import User as DjangoUserModel + +from djangosaml2.backends import Saml2Backend, get_saml_user_model, set_attribute from testprofiles.models import TestUser class BackendUtilMethodsTests(TestCase): - def test_set_attribute(self): u = TestUser() - self.assertFalse(hasattr(u, 'custom_attribute')) + self.assertFalse(hasattr(u, "custom_attribute")) # Set attribute initially - changed = set_attribute(u, 'custom_attribute', 'value') + changed = set_attribute(u, "custom_attribute", "value") self.assertTrue(changed) - self.assertEqual(u.custom_attribute, 'value') + self.assertEqual(u.custom_attribute, "value") # 'Update' to the same value again - changed_same = set_attribute(u, 'custom_attribute', 'value') + changed_same = set_attribute(u, "custom_attribute", "value") self.assertFalse(changed_same) - self.assertEqual(u.custom_attribute, 'value') + self.assertEqual(u.custom_attribute, "value") # Update to a different value - changed_different = set_attribute(u, 'custom_attribute', 'new_value') + changed_different = set_attribute(u, "custom_attribute", "new_value") self.assertTrue(changed_different) - self.assertEqual(u.custom_attribute, 'new_value') + self.assertEqual(u.custom_attribute, "new_value") class dummyNameId: - text = 'dummyNameId' + text = "dummyNameId" class Saml2BackendTests(TestCase): - """ UnitTests on backend classes - """ + """UnitTests on backend classes""" + backend_cls = Saml2Backend def setUp(self): self.backend = self.backend_cls() - self.user = TestUser.objects.create(username='john') + self.user = TestUser.objects.create(username="john") def test_get_model_ok(self): self.assertEqual(self.backend._user_model, TestUser) def test_get_model_nonexisting(self): - with override_settings(SAML_USER_MODEL='testprofiles.NonExisting'): - with self.assertRaisesMessage(ImproperlyConfigured, "Model 'testprofiles.NonExisting' could not be loaded"): + with override_settings(SAML_USER_MODEL="testprofiles.NonExisting"): + with self.assertRaisesMessage( + ImproperlyConfigured, + "Model 'testprofiles.NonExisting' could not be loaded", + ): self.assertEqual(self.backend._user_model, None) def test_get_model_invalid_specifier(self): - with override_settings(SAML_USER_MODEL='random_package.specifier.testprofiles.NonExisting'): - with self.assertRaisesMessage(ImproperlyConfigured, "Model was specified as 'random_package.specifier.testprofiles.NonExisting', but it must be of the form 'app_label.model_name'"): + with override_settings( + SAML_USER_MODEL="random_package.specifier.testprofiles.NonExisting" + ): + with self.assertRaisesMessage( + ImproperlyConfigured, + "Model was specified as 'random_package.specifier.testprofiles.NonExisting', but it must be of the form 'app_label.model_name'", + ): self.assertEqual(self.backend._user_model, None) def test_user_model_specified(self): - with override_settings(AUTH_USER_MODEL='auth.User'): - with override_settings(SAML_USER_MODEL='testprofiles.TestUser'): + with override_settings(AUTH_USER_MODEL="auth.User"): + with override_settings(SAML_USER_MODEL="testprofiles.TestUser"): self.assertEqual(self.backend._user_model, TestUser) def test_user_model_default(self): - with override_settings(AUTH_USER_MODEL='auth.User'): + with override_settings(AUTH_USER_MODEL="auth.User"): self.assertEqual(self.backend._user_model, DjangoUserModel) def test_user_lookup_attribute_specified(self): - with override_settings(SAML_USER_MODEL='testprofiles.TestUser'): - with override_settings(SAML_DJANGO_USER_MAIN_ATTRIBUTE='age'): - self.assertEqual(self.backend._user_lookup_attribute, 'age') + with override_settings(SAML_USER_MODEL="testprofiles.TestUser"): + with override_settings(SAML_DJANGO_USER_MAIN_ATTRIBUTE="age"): + self.assertEqual(self.backend._user_lookup_attribute, "age") def test_user_lookup_attribute_default(self): - with override_settings(SAML_USER_MODEL='testprofiles.TestUser'): - self.assertEqual(self.backend._user_lookup_attribute, 'username') + with override_settings(SAML_USER_MODEL="testprofiles.TestUser"): + self.assertEqual(self.backend._user_lookup_attribute, "username") def test_extract_user_identifier_params_use_nameid_present(self): - with override_settings(SAML_USER_MODEL='testprofiles.TestUser'): + with override_settings(SAML_USER_MODEL="testprofiles.TestUser"): with override_settings(SAML_USE_NAME_ID_AS_USERNAME=True): - _, lookup_value = self.backend._extract_user_identifier_params({'name_id': dummyNameId()}, {}, {}) - self.assertEqual(lookup_value, 'dummyNameId') + _, lookup_value = self.backend._extract_user_identifier_params( + {"name_id": dummyNameId()}, {}, {} + ) + self.assertEqual(lookup_value, "dummyNameId") def test_extract_user_identifier_params_use_nameid_missing(self): - with override_settings(SAML_USER_MODEL='testprofiles.TestUser'): + with override_settings(SAML_USER_MODEL="testprofiles.TestUser"): with override_settings(SAML_USE_NAME_ID_AS_USERNAME=True): - _, lookup_value = self.backend._extract_user_identifier_params({}, {}, {}) + _, lookup_value = self.backend._extract_user_identifier_params( + {}, {}, {} + ) self.assertEqual(lookup_value, None) def test_is_authorized(self): - self.assertTrue(self.backend.is_authorized({}, {}, '', {})) + self.assertTrue(self.backend.is_authorized({}, {}, "", {})) def test_clean_attributes(self): - attributes = {'random': 'dummy', 'value': 123} - self.assertEqual(self.backend.clean_attributes(attributes, ''), attributes) + attributes = {"random": "dummy", "value": 123} + self.assertEqual(self.backend.clean_attributes(attributes, ""), attributes) def test_clean_user_main_attribute(self): - self.assertEqual(self.backend.clean_user_main_attribute('value'), 'value') + self.assertEqual(self.backend.clean_user_main_attribute("value"), "value") def test_update_user_simple(self): - u = TestUser(username='johny') + u = TestUser(username="johny") self.assertIsNone(u.pk) u = self.backend._update_user(u, {}, {}) self.assertIsNotNone(u.pk) def test_update_user(self): attribute_mapping = { - 'uid': ('username', ), - 'mail': ('email', ), - 'cn': ('first_name', ), - 'sn': ('last_name', ), - } + "uid": ("username",), + "mail": ("email",), + "cn": ("first_name",), + "sn": ("last_name",), + } attributes = { - 'uid': ('john', ), - 'mail': ('john@example.com', ), - 'cn': ('John', ), - 'sn': ('Doe', ), - } + "uid": ("john",), + "mail": ("john@example.com",), + "cn": ("John",), + "sn": ("Doe",), + } self.backend._update_user(self.user, attributes, attribute_mapping) - self.assertEqual(self.user.email, 'john@example.com') - self.assertEqual(self.user.first_name, 'John') - self.assertEqual(self.user.last_name, 'Doe') + self.assertEqual(self.user.email, "john@example.com") + self.assertEqual(self.user.first_name, "John") + self.assertEqual(self.user.last_name, "Doe") - attribute_mapping['saml_age'] = ('age', ) - attributes['saml_age'] = ('22', ) + attribute_mapping["saml_age"] = ("age",) + attributes["saml_age"] = ("22",) self.backend._update_user(self.user, attributes, attribute_mapping) - self.assertEqual(self.user.age, '22') + self.assertEqual(self.user.age, "22") def test_update_user_callable_attributes(self): attribute_mapping = { - 'uid': ('username', ), - 'mail': ('email', ), - 'cn': ('process_first_name', ), - 'sn': ('last_name', ), - } + "uid": ("username",), + "mail": ("email",), + "cn": ("process_first_name",), + "sn": ("last_name",), + } attributes = { - 'uid': ('john', ), - 'mail': ('john@example.com', ), - 'cn': ('John', ), - 'sn': ('Doe', ), - } + "uid": ("john",), + "mail": ("john@example.com",), + "cn": ("John",), + "sn": ("Doe",), + } self.backend._update_user(self.user, attributes, attribute_mapping) - self.assertEqual(self.user.email, 'john@example.com') - self.assertEqual(self.user.first_name, 'John') - self.assertEqual(self.user.last_name, 'Doe') + self.assertEqual(self.user.email, "john@example.com") + self.assertEqual(self.user.first_name, "John") + self.assertEqual(self.user.last_name, "Doe") def test_update_user_empty_attribute(self): - self.user.last_name = 'Smith' + self.user.last_name = "Smith" self.user.save() attribute_mapping = { - 'uid': ('username', ), - 'mail': ('email', ), - 'cn': ('first_name', ), - 'sn': ('last_name', ), - } + "uid": ("username",), + "mail": ("email",), + "cn": ("first_name",), + "sn": ("last_name",), + } attributes = { - 'uid': ('john', ), - 'mail': ('john@example.com', ), - 'cn': ('John', ), - 'sn': (), - } - with self.assertLogs('djangosaml2', level='DEBUG') as logs: + "uid": ("john",), + "mail": ("john@example.com",), + "cn": ("John",), + "sn": (), + } + with self.assertLogs("djangosaml2", level="DEBUG") as logs: self.backend._update_user(self.user, attributes, attribute_mapping) - self.assertEqual(self.user.email, 'john@example.com') - self.assertEqual(self.user.first_name, 'John') + self.assertEqual(self.user.email, "john@example.com") + self.assertEqual(self.user.first_name, "John") # empty attribute list: no update - self.assertEqual(self.user.last_name, 'Smith') + self.assertEqual(self.user.last_name, "Smith") self.assertIn( 'DEBUG:djangosaml2:Could not find value for "sn", not updating fields "(\'last_name\',)"', logs.output, @@ -189,16 +200,24 @@ def test_update_user_empty_attribute(self): def test_invalid_model_attribute_log(self): attribute_mapping = { - 'uid': ['username'], - 'cn': ['nonexistent'], + "uid": ["username"], + "cn": ["nonexistent"], } attributes = { - 'uid': ['john'], - 'cn': ['John'], + "uid": ["john"], + "cn": ["John"], } - with self.assertLogs('djangosaml2', level='DEBUG') as logs: - user, _ = self.backend.get_or_create_user(self.backend._user_lookup_attribute, 'john', True, None, None, None, None) + with self.assertLogs("djangosaml2", level="DEBUG") as logs: + user, _ = self.backend.get_or_create_user( + self.backend._user_lookup_attribute, + "john", + True, + None, + None, + None, + None, + ) self.backend._update_user(user, attributes, attribute_mapping) self.assertIn( @@ -206,20 +225,25 @@ def test_invalid_model_attribute_log(self): logs.output, ) - @override_settings(SAML_USER_MODEL='testprofiles.RequiredFieldUser') + @override_settings(SAML_USER_MODEL="testprofiles.RequiredFieldUser") def test_create_user_with_required_fields(self): - attribute_mapping = { - 'mail': ['email'], - 'mail_verified': ['email_verified'] - } + attribute_mapping = {"mail": ["email"], "mail_verified": ["email_verified"]} attributes = { - 'mail': ['john@example.org'], - 'mail_verified': [True], + "mail": ["john@example.org"], + "mail_verified": [True], } # User creation does not fail if several fields are required. - user, created = self.backend.get_or_create_user(self.backend._user_lookup_attribute, 'john@example.org', True, None, None, None, None) + user, created = self.backend.get_or_create_user( + self.backend._user_lookup_attribute, + "john@example.org", + True, + None, + None, + None, + None, + ) - self.assertEqual(user.email, 'john@example.org') + self.assertEqual(user.email, "john@example.org") self.assertIs(user.email_verified, None) user = self.backend._update_user(user, attributes, attribute_mapping, created) @@ -227,37 +251,44 @@ def test_create_user_with_required_fields(self): def test_django_user_main_attribute(self): old_username_field = get_user_model().USERNAME_FIELD - get_user_model().USERNAME_FIELD = 'slug' - self.assertEqual(self.backend._user_lookup_attribute, 'slug') + get_user_model().USERNAME_FIELD = "slug" + self.assertEqual(self.backend._user_lookup_attribute, "slug") get_user_model().USERNAME_FIELD = old_username_field - with override_settings(AUTH_USER_MODEL='auth.User'): + with override_settings(AUTH_USER_MODEL="auth.User"): self.assertEqual( - DjangoUserModel.USERNAME_FIELD, - self.backend._user_lookup_attribute) + DjangoUserModel.USERNAME_FIELD, self.backend._user_lookup_attribute + ) - with override_settings( - AUTH_USER_MODEL='testprofiles.StandaloneUserModel'): - self.assertEqual( - self.backend._user_lookup_attribute, - 'username') + with override_settings(AUTH_USER_MODEL="testprofiles.StandaloneUserModel"): + self.assertEqual(self.backend._user_lookup_attribute, "username") - with override_settings(SAML_DJANGO_USER_MAIN_ATTRIBUTE='foo'): - self.assertEqual(self.backend._user_lookup_attribute, 'foo') + with override_settings(SAML_DJANGO_USER_MAIN_ATTRIBUTE="foo"): + self.assertEqual(self.backend._user_lookup_attribute, "foo") def test_get_or_create_user_existing(self): - with override_settings(SAML_USER_MODEL='testprofiles.TestUser'): - user, created = self.backend.get_or_create_user(self.backend._user_lookup_attribute, 'john', False, None, None, None, None) + with override_settings(SAML_USER_MODEL="testprofiles.TestUser"): + user, created = self.backend.get_or_create_user( + self.backend._user_lookup_attribute, + "john", + False, + None, + None, + None, + None, + ) self.assertTrue(isinstance(user, TestUser)) self.assertFalse(created) def test_get_or_create_user_duplicates(self): - TestUser.objects.create(username='paul') + TestUser.objects.create(username="paul") - with self.assertLogs('djangosaml2', level='DEBUG') as logs: - with override_settings(SAML_USER_MODEL='testprofiles.TestUser'): - user, created = self.backend.get_or_create_user('age', '', False, None, None, None, None) + with self.assertLogs("djangosaml2", level="DEBUG") as logs: + with override_settings(SAML_USER_MODEL="testprofiles.TestUser"): + user, created = self.backend.get_or_create_user( + "age", "", False, None, None, None, None + ) self.assertTrue(user is None) self.assertFalse(created) @@ -267,9 +298,17 @@ def test_get_or_create_user_duplicates(self): ) def test_get_or_create_user_no_create(self): - with self.assertLogs('djangosaml2', level='DEBUG') as logs: - with override_settings(SAML_USER_MODEL='testprofiles.TestUser'): - user, created = self.backend.get_or_create_user(self.backend._user_lookup_attribute, 'paul', False, None, None, None, None) + with self.assertLogs("djangosaml2", level="DEBUG") as logs: + with override_settings(SAML_USER_MODEL="testprofiles.TestUser"): + user, created = self.backend.get_or_create_user( + self.backend._user_lookup_attribute, + "paul", + False, + None, + None, + None, + None, + ) self.assertTrue(user is None) self.assertFalse(created) @@ -279,77 +318,95 @@ def test_get_or_create_user_no_create(self): ) def test_get_or_create_user_create(self): - with self.assertLogs('djangosaml2', level='DEBUG') as logs: - with override_settings(SAML_USER_MODEL='testprofiles.TestUser'): - user, created = self.backend.get_or_create_user(self.backend._user_lookup_attribute, 'paul', True, None, None, None, None) + with self.assertLogs("djangosaml2", level="DEBUG") as logs: + with override_settings(SAML_USER_MODEL="testprofiles.TestUser"): + user, created = self.backend.get_or_create_user( + self.backend._user_lookup_attribute, + "paul", + True, + None, + None, + None, + None, + ) self.assertTrue(isinstance(user, TestUser)) self.assertTrue(created) self.assertIn( - "DEBUG:djangosaml2:New user created: {}".format(user), + f"DEBUG:djangosaml2:New user created: {user}", logs.output, ) def test_deprecations(self): - attribute_mapping = { - 'mail': ['email'], - 'mail_verified': ['email_verified'] - } + attribute_mapping = {"mail": ["email"], "mail_verified": ["email_verified"]} attributes = { - 'mail': ['john@example.org'], - 'mail_verified': [True], + "mail": ["john@example.org"], + "mail_verified": [True], } - old = self.backend.get_attribute_value('email_verified', attributes, attribute_mapping) + old = self.backend.get_attribute_value( + "email_verified", attributes, attribute_mapping + ) self.assertEqual(old, True) - self.assertEqual(self.backend.get_django_user_main_attribute(), self.backend._user_lookup_attribute) + self.assertEqual( + self.backend.get_django_user_main_attribute(), + self.backend._user_lookup_attribute, + ) - with override_settings(SAML_DJANGO_USER_MAIN_ATTRIBUTE_LOOKUP='user_name'): - self.assertEqual(self.backend.get_django_user_main_attribute_lookup(), settings.SAML_DJANGO_USER_MAIN_ATTRIBUTE_LOOKUP) + with override_settings(SAML_DJANGO_USER_MAIN_ATTRIBUTE_LOOKUP="user_name"): + self.assertEqual( + self.backend.get_django_user_main_attribute_lookup(), + settings.SAML_DJANGO_USER_MAIN_ATTRIBUTE_LOOKUP, + ) - self.assertEqual(self.backend.get_user_query_args(''), {'username'}) + self.assertEqual(self.backend.get_user_query_args(""), {"username"}) - u = TestUser(username='mathieu') - self.assertEqual(u.email, '') + u = TestUser(username="mathieu") + self.assertEqual(u.email, "") new_u = self.backend.configure_user(u, attributes, attribute_mapping) self.assertIsNotNone(new_u.pk) - self.assertEqual(new_u.email, 'john@example.org') + self.assertEqual(new_u.email, "john@example.org") - u = TestUser(username='mathieu_2') - self.assertEqual(u.email, '') + u = TestUser(username="mathieu_2") + self.assertEqual(u.email, "") new_u = self.backend.update_user(u, attributes, attribute_mapping) self.assertIsNotNone(new_u.pk) - self.assertEqual(new_u.email, 'john@example.org') + self.assertEqual(new_u.email, "john@example.org") u = TestUser() - self.assertTrue(self.backend._set_attribute(u, 'new_attribute', True)) - self.assertFalse(self.backend._set_attribute(u, 'new_attribute', True)) - self.assertTrue(self.backend._set_attribute(u, 'new_attribute', False)) + self.assertTrue(self.backend._set_attribute(u, "new_attribute", True)) + self.assertFalse(self.backend._set_attribute(u, "new_attribute", True)) + self.assertTrue(self.backend._set_attribute(u, "new_attribute", False)) self.assertEqual(get_saml_user_model(), TestUser) class CustomizedBackend(Saml2Backend): - """ Override the available methods with some customized implementation to test customization - """ - def is_authorized(self, attributes, attribute_mapping, idp_entityid: str, assertion_info, **kwargs): - ''' Allow only staff users from the IDP ''' - return attributes.get('is_staff', (None, ))[0] == True and assertion_info.get('assertion_id', None) != None - + """Override the available methods with some customized implementation to test customization""" + + def is_authorized( + self, attributes, attribute_mapping, idp_entityid: str, assertion_info, **kwargs + ): + """Allow only staff users from the IDP""" + return ( + attributes.get("is_staff", (None,))[0] == True + and assertion_info.get("assertion_id", None) != None + ) + def clean_attributes(self, attributes: dict, idp_entityid: str, **kwargs) -> dict: - ''' Keep only certain attribute ''' + """Keep only certain attribute""" return { - 'age': attributes.get('age', (None, )), - 'mail': attributes.get('mail', (None, )), - 'is_staff': attributes.get('is_staff', (None, )), - 'uid': attributes.get('uid', (None, )), + "age": attributes.get("age", (None,)), + "mail": attributes.get("mail", (None,)), + "is_staff": attributes.get("is_staff", (None,)), + "uid": attributes.get("uid", (None,)), } def clean_user_main_attribute(self, main_attribute): - ''' Partition string on @ and return the first part ''' + """Partition string on @ and return the first part""" if main_attribute: - return main_attribute.partition('@')[0] + return main_attribute.partition("@")[0] return main_attribute @@ -358,70 +415,82 @@ class CustomizedSaml2BackendTests(Saml2BackendTests): def test_is_authorized(self): attribute_mapping = { - 'uid': ('username', ), - 'mail': ('email', ), - 'cn': ('first_name', ), - 'sn': ('last_name', ), - } + "uid": ("username",), + "mail": ("email",), + "cn": ("first_name",), + "sn": ("last_name",), + } attributes = { - 'uid': ('john', ), - 'mail': ('john@example.com', ), - 'cn': ('John', ), - 'sn': ('Doe', ), - } + "uid": ("john",), + "mail": ("john@example.com",), + "cn": ("John",), + "sn": ("Doe",), + } assertion_info = { - 'assertion_id': None, - 'not_on_or_after': None, + "assertion_id": None, + "not_on_or_after": None, } - self.assertFalse(self.backend.is_authorized(attributes, attribute_mapping, '', assertion_info)) - attributes['is_staff'] = (True, ) - self.assertFalse(self.backend.is_authorized(attributes, attribute_mapping, '', assertion_info)) - assertion_info['assertion_id'] = 'abcdefg12345' - self.assertTrue(self.backend.is_authorized(attributes, attribute_mapping, '', assertion_info)) + self.assertFalse( + self.backend.is_authorized( + attributes, attribute_mapping, "", assertion_info + ) + ) + attributes["is_staff"] = (True,) + self.assertFalse( + self.backend.is_authorized( + attributes, attribute_mapping, "", assertion_info + ) + ) + assertion_info["assertion_id"] = "abcdefg12345" + self.assertTrue( + self.backend.is_authorized( + attributes, attribute_mapping, "", assertion_info + ) + ) def test_clean_attributes(self): - attributes = {'random': 'dummy', 'value': 123, 'age': '28'} + attributes = {"random": "dummy", "value": 123, "age": "28"} self.assertEqual( - self.backend.clean_attributes(attributes, ''), - {'age': '28', 'mail': (None,), 'is_staff': (None,), 'uid': (None,)} + self.backend.clean_attributes(attributes, ""), + {"age": "28", "mail": (None,), "is_staff": (None,), "uid": (None,)}, ) def test_clean_user_main_attribute(self): - self.assertEqual(self.backend.clean_user_main_attribute('john@example.com'), 'john') + self.assertEqual( + self.backend.clean_user_main_attribute("john@example.com"), "john" + ) def test_authenticate(self): attribute_mapping = { - 'uid': ('username', ), - 'mail': ('email', ), - 'cn': ('first_name', ), - 'sn': ('last_name', ), - 'age': ('age', ), - 'is_staff': ('is_staff', ), - } + "uid": ("username",), + "mail": ("email",), + "cn": ("first_name",), + "sn": ("last_name",), + "age": ("age",), + "is_staff": ("is_staff",), + } attributes = { - 'uid': ('john', ), - 'mail': ('john@example.com', ), - 'cn': ('John', ), - 'sn': ('Doe', ), - 'age': ('28', ), - 'is_staff': (True, ), - } + "uid": ("john",), + "mail": ("john@example.com",), + "cn": ("John",), + "sn": ("Doe",), + "age": ("28",), + "is_staff": (True,), + } assertion_info = { - 'assertion_id': 'abcdefg12345', - 'not_on_or_after': '', + "assertion_id": "abcdefg12345", + "not_on_or_after": "", } - self.assertEqual(self.user.age, '') + self.assertEqual(self.user.age, "") self.assertEqual(self.user.is_staff, False) - user = self.backend.authenticate( - None - ) + user = self.backend.authenticate(None) self.assertIsNone(user) user = self.backend.authenticate( None, - session_info={'random': 'content'}, + session_info={"random": "content"}, attribute_mapping=attribute_mapping, assertion_info=assertion_info, ) @@ -430,25 +499,25 @@ def test_authenticate(self): with override_settings(SAML_USE_NAME_ID_AS_USERNAME=True): user = self.backend.authenticate( None, - session_info={'ava': attributes, 'issuer': 'dummy_entity_id'}, + session_info={"ava": attributes, "issuer": "dummy_entity_id"}, attribute_mapping=attribute_mapping, assertion_info=assertion_info, ) self.assertIsNone(user) - attributes['is_staff'] = (False, ) + attributes["is_staff"] = (False,) user = self.backend.authenticate( None, - session_info={'ava': attributes, 'issuer': 'dummy_entity_id'}, + session_info={"ava": attributes, "issuer": "dummy_entity_id"}, attribute_mapping=attribute_mapping, assertion_info=assertion_info, ) self.assertIsNone(user) - attributes['is_staff'] = (True, ) + attributes["is_staff"] = (True,) user = self.backend.authenticate( None, - session_info={'ava': attributes, 'issuer': 'dummy_entity_id'}, + session_info={"ava": attributes, "issuer": "dummy_entity_id"}, attribute_mapping=attribute_mapping, assertion_info=assertion_info, ) @@ -456,7 +525,7 @@ def test_authenticate(self): self.assertEqual(user, self.user) self.user.refresh_from_db() - self.assertEqual(self.user.age, '28') + self.assertEqual(self.user.age, "28") self.assertEqual(self.user.is_staff, True) def test_user_cleaned_main_attribute(self): @@ -466,27 +535,27 @@ def test_user_cleaned_main_attribute(self): updating the user, the username remains the same. """ attribute_mapping = { - 'mail': ('username',), - 'cn': ('first_name',), - 'sn': ('last_name',), - 'is_staff': ('is_staff', ), + "mail": ("username",), + "cn": ("first_name",), + "sn": ("last_name",), + "is_staff": ("is_staff",), } attributes = { - 'mail': ('john@example.com',), - 'cn': ('John',), - 'sn': ('Doe',), - 'is_staff': (True, ), + "mail": ("john@example.com",), + "cn": ("John",), + "sn": ("Doe",), + "is_staff": (True,), } assertion_info = { - 'assertion_id': 'abcdefg12345', + "assertion_id": "abcdefg12345", } user = self.backend.authenticate( None, - session_info={'ava': attributes, 'issuer': 'dummy_entity_id'}, + session_info={"ava": attributes, "issuer": "dummy_entity_id"}, attribute_mapping=attribute_mapping, assertion_info=assertion_info, ) self.assertEqual(user, self.user) self.user.refresh_from_db() - self.assertEqual(user.username, 'john') + self.assertEqual(user.username, "john") diff --git a/tests/testprofiles/urls.py b/tests/testprofiles/urls.py index 3cc5838a..0d8ec524 100644 --- a/tests/testprofiles/urls.py +++ b/tests/testprofiles/urls.py @@ -1,16 +1,15 @@ +from django.http import HttpResponse from django.urls import include, path + from django.contrib import admin -from django.http import HttpResponse testpatterns = ( - [ - path('dashboard/', lambda request: HttpResponse(''), name='dashboard') - ], - 'testprofiles' # app_name + [path("dashboard/", lambda request: HttpResponse(""), name="dashboard")], + "testprofiles", # app_name ) urlpatterns = [ - path('saml2/', include('djangosaml2.urls')), - path('admin/', admin.site.urls), - path('', include(testpatterns)) + path("saml2/", include("djangosaml2.urls")), + path("admin/", admin.site.urls), + path("", include(testpatterns)), ] diff --git a/tox.ini b/tox.ini index 1c9469a4..a04936ec 100644 --- a/tox.ini +++ b/tox.ini @@ -12,7 +12,7 @@ deps = django3.2: django~=3.2 django4.0: django~=4.0 djangomaster: https://github.com/django/django/archive/master.tar.gz - .[test] + . ignore_outcome = djangomaster: True