diff --git a/CHANGES b/CHANGES index 529b300c..7983d527 100644 --- a/CHANGES +++ b/CHANGES @@ -1,6 +1,12 @@ Changes ======= +v1.3.0 (future) + +- Add signals +- Refactoring some classes to facilitate inheritance / customisation +- Documentation for signals and inheritance / customisation + v1.2.2 (2021-05-27) ------------------- diff --git a/djangosaml2/backends.py b/djangosaml2/backends.py index 08ae5f64..6790f081 100644 --- a/djangosaml2/backends.py +++ b/djangosaml2/backends.py @@ -24,6 +24,8 @@ from django.core.exceptions import (ImproperlyConfigured, MultipleObjectsReturned) +from .signals import authenticate, pre_user_save, post_user_save + logger = logging.getLogger('djangosaml2') @@ -123,6 +125,13 @@ def authenticate(self, request, session_info=None, attribute_mapping=None, creat if not self.is_authorized(attributes, attribute_mapping, idp_entityid, assertion_info): logger.error('Request not authorized') + authenticate.send(sender=self, + request=request, + is_authorized=False, + can_authenticate=None, + user=None, + user_created=None, + attributes=attributes) return None user_lookup_key, user_lookup_value = self._extract_user_identifier_params( @@ -141,7 +150,15 @@ def authenticate(self, request, session_info=None, attribute_mapping=None, creat user = self._update_user( user, attributes, attribute_mapping, force_save=created) - if self.user_can_authenticate(user): + can_authenticate = self.user_can_authenticate(user) + authenticate.send(sender=self, + request=request, + is_authorized=True, + can_authenticate=can_authenticate, + user=user, + user_created=created, + attributes=attributes) + if can_authenticate: return user def _update_user(self, user, attributes: dict, attribute_mapping: dict, force_save: bool = False): @@ -156,46 +173,54 @@ def _update_user(self, user, attributes: dict, attribute_mapping: dict, force_sa if not attribute_mapping: # Always save a brand new user instance if user.pk is None: + pre_user_save.send(sender=self, user=user, attributes=attributes) user = self.save_user(user) + post_user_save.send(sender=self, user=user, attributes=attributes) return user # Lookup key - user_lookup_key = self._user_lookup_attribute + has_updated_fields = self.lookup_and_set_attributes(user, attributes, attribute_mapping) + + if has_updated_fields or force_save: + pre_user_save.send(sender=self, user=user, attributes=attributes) + user = self.save_user(user) + post_user_save.send(sender=self, user=user, attributes=attributes) + + return user + + # ################################################ + # Methods to override by end-users in subclasses # + # ################################################ + + def lookup_and_set_attributes(self, user, attributes: dict, attribute_mapping: dict) -> bool: has_updated_fields = False for saml_attr, django_attrs in attribute_mapping.items(): 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}"') - continue - + return has_updated_fields for attr in django_attrs: - if attr == user_lookup_key: - # Don't update user_lookup_key (e.g. username) (issue #245) - # It was just used to find/create this user and might have - # been changed by `clean_user_main_attribute` - continue - elif hasattr(user, attr): - user_attr = getattr(user, attr) - if callable(user_attr): - modified = user_attr(attr_value_list) - else: - 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}"') - - if has_updated_fields or force_save: - user = self.save_user(user) - - return user - - # ############################################ - # Hooks to override by end-users in subclasses - # ############################################ + has_updated_fields = has_updated_fields or self.lookup_and_set_attribute( + user, attr, attr_value_list + ) + return has_updated_fields + + def lookup_and_set_attribute(self, user, attr, attr_value_list) -> bool: + if attr == self._user_lookup_attribute: + # Don't update user_lookup_key (e.g. username) (issue #245) + # It was just used to find/create this user and might have + # been changed by `clean_user_main_attribute` + return False + elif hasattr(user, attr): + user_attr = getattr(user, attr) + if callable(user_attr): + return user_attr(attr_value_list) + else: + return set_attribute(user, attr, attr_value_list[0]) + else: + logger.debug(f'Could not find attribute "{attr}" on user "{user}"') + return False 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. """ diff --git a/djangosaml2/signals.py b/djangosaml2/signals.py index 1c006626..e2c533fb 100644 --- a/djangosaml2/signals.py +++ b/djangosaml2/signals.py @@ -14,7 +14,8 @@ import django.dispatch -pre_user_save = django.dispatch.Signal( - providing_args=['attributes', 'user_modified']) -post_authenticated = django.dispatch.Signal( - providing_args=['session_info', 'request']) +pre_user_save = django.dispatch.Signal(providing_args=['user', 'attributes']) +post_user_save = django.dispatch.Signal(providing_args=['user', 'attributes']) +authenticate = django.dispatch.Signal(providing_args=[ + 'request', 'is_authorized', 'can_authenticate', 'user', 'user_created', 'attributes' +]) diff --git a/docs/source/contents/setup.rst b/docs/source/contents/setup.rst index 75acca80..902f535c 100644 --- a/docs/source/contents/setup.rst +++ b/docs/source/contents/setup.rst @@ -629,3 +629,267 @@ What about configuring the certificates in a different way, in case we are using - You could supply the cert & key as environment variables (base64 encoded) then create the files when the container starts, either in an entry point shell script or in your settings.py file. - Using `Python Tempfile `_ In the settings create two temp files, then write the content configured in environment variables in them, then use tmpfile.name as key/cert values in pysaml2 configuration. + + +Customisation (signals, inheritance) +------------------------------------ + +You have two ways to customize djangosaml2 : + +* using django signals +* using your own classes inheriting from ours (backend, views etc.) + + +Signals +======= + +Django's signals are great to trigger some code execution at some key points of the auth process. +For more general information on how to use signals, please refer to the +`django documentation `_. + +Those signals are provided: + +.. list-table:: Title + :widths: 25 75 + :header-rows: 1 + + * - Signal name + - Description + * - **pre_user_save** + - sent just before an user is saved when the received set of SSO attributes require an update + parameters sent: + + * **user**: the User instance which will be saved + * **attributes**: dict of attributes received from SSO + * - **post_user_save** + - sent just after an user is saved when the received set of SSO attributes require an update + parameters sent: + + * **user**: the User instance which will be saved + * **attributes**: dict of attributes received from SSO + * - **authenticate** + - sent when an authentification request is asked to the backend + + * **request**: request received + * **is_authorized**: True/False if request was not authorized + * **can_authenticate**: True/False if user can authenticate + * **user**: the User instance authenticated (or None if it's a fail) + * **user_created**: True/False if user is newly created + * **attributes**: dict of attributes received from SSO (or None if request was invalid) + +Usage exemple:: + + #myapp.apps.py + from django.apps import AppConfig + from django.apps import apps + from django.conf import settings + from django.utils.translation import ugettext_lazy as _ + + + def check_user_activity(sender, user, attributes, request): + """ + (de)activate the user depending on two Keycloak attributes. + It must be called **before** authentication is done + """ + temporarily_disabled = attributes.get('temporarilyDisabled', [False])[0] + temporarily_disabled = temporarily_disabled in ('true', 'True', True) + userEnabled = attributes.get('userEnabled', [True])[0] + userEnabled = userEnabled in ('true', 'True', True) + is_active = userEnabled and not temporarily_disabled + if user.is_active != is_active: + user.is_active = is_active + user.save() + + def set_email_verified( + sender, request, is_authorized, can_authenticate, user, user_created, attributes, **kwargs + ): + """ + updates the user's primary email address (managed by allauth) + """ + if not is_authorized or not user: + return + emailVerified = attributes.get('emailVerified', [True])[0] in ('true', 'True', True) + + emailaddress, created = user.emailaddress_set.model.objects.get_or_create( + email=user.email, + defaults={'primary': True, 'verified': emailVerified, 'user_id': instance.pk} + ) + if emailaddress.user_id != instance.pk: + msg = 'Email %s is already owned by %s and %s claims it. Please fix it in Keycloak' % ( + user.email, emailaddress.user.username, instance.username + ) + logger.error(msg, exc_info=True) + else: + if not emailaddress.primary: + user.emailaddress_set.filter(primary=True).update(primary=False) + emailaddress.primary = True + changed_emailaddress = True + if emailaddress.verified != emailVerified: + emailaddress.verified = emailVerified + changed_emailaddress = True + if changed_emailaddress: + emailaddress.save() + + + class KeycloakFosmConfig(AppConfig): + name = 'keycloak_fosm' + verbose_name = _('Keycloak for FOSM') + + def ready(self): + from djangosaml2.signals import authenticate + from djangosaml2.signals import pre_user_save + pre_user_save.connect(check_user_activity) + authenticate.connect(set_email_verified) + + +If you want to have a receiver called after an user successfully authenticate, you can also use the +standard django signal `user_logged_in `_. +Used backend is set as an `attribute of the current user `_, +allowing you to execute your code only if authentication comes from the Saml2Backend. ex:: + + def saml2_user_logged_in_receiver(sender, request, user, **kwargs): + backend_path = getattr(user, 'backend') + if backend_path != 'djangosaml2.backends.Saml2Backend': + return # nothing to do, user is logged in via an other backend + # you specific code here + +Inheritance +=========== + +Views and Backend classes are designed to be extended to facilitate specific code addition at some +strategic points. + +djangosaml2.backends.Saml2Backend +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To override default Saml2Backend, do not forget to replace 'djangosaml2.backends.Saml2Backend' by +you own backend in `AUTHENTICATION_BACKENDS`:: + + # settings.py + AUTHENTICATION_BACKENDS = [ + # 'djangosaml2.backends.Saml2Backend', + 'keycloak_fosm.backends.Saml2Backend', + ] + +lookup_and_set_attributes +""""""""""""""""""""""""" + +Loop over eath saml attributes and call `lookup_and_set_attribute` to set (or not) received +data on current user. +Usefull if you need some logic depending on multiple attributes values.:: + + class MyOwnBackend(Saml2Backend): + def lookup_and_set_attributes(self, user, attributes: dict, attribute_mapping: dict) -> bool: + has_updated_fields = super().lookup_and_set_attributes(user, attribute_mapping, attributes) + if attributes.get('something', None) == 'magic' and user.username == 'ProcolHarum': + user.play('https://www.youtube.com/watch?v=Csa6q9iSJEY') + return has_updated_fields + + +lookup_and_set_attribute +"""""""""""""""""""""""" + +Get a specific SAML attribute, retrieve it's mapping configuration and set the new data +on current user. +Usefull if you need some logic / data processing on a specific attribute but you can not +override the User method to do it (remember that if you map saml attribute "toto" to +"something", something can be a callable to do more than just a value assignation.):: + + class MyOwnBackend(Saml2Backend): + def lookup_and_set_attribute(self, user, attr, attr_value_list) -> bool: + if attr != 'great_songs': + return super().lookup_and_set_attribute(user, attr, attr_value_list) + if 'Something Magic' in attr_value_list and 'A Whiter Shade of Pale' attr_value_list: + a_song = random.choice() + if a_song != user.last_listen_song: + user.play(a_song) + user.last_listen_song = a_song + return True + return False + + +clean_attributes +"""""""""""""""" + +Clean or filter attributes from the SAML response.:: + + + class MyOwnBackend(Saml2Backend): + def clean_attributes(self, attributes: dict, idp_entityid: str, **kwargs) -> dict: + attributes = super().clean_attributes(attributes, idp_entityid, **kwargs) + if idp_entityid != 'jukebox': + return attributes + if 'great_bands' in attributes: + try: + attributes['great_bands'].remove('') + except ValueError: + pass + else: + attributes['music_lover_score'] = attributes.get('music_lover_score', 0) - 1 + return attributes + + +is_authorized +""""""""""""" + +Allow custom authorization policies based on SAML attributes.:: + + + class MyOwnBackend(Saml2Backend): + def is_authorized( + self, + attributes: dict, attribute_mapping: dict, + idp_entityid: str, assertion_info: dict, **kwargs + ) -> bool: + is_authorized = super().is_authorized(attributes, attribute_mapping, + idp_entityid, assertion_info, **kwargs) + if not is_authorized or idp_entityid != 'jukebox': + return is_authorized + if '' in attributes['great_bands']: + logger.error('Request not authorized: probably a spam bot') + return False + return is_authorized + + +user_can_authenticate +""""""""""""""""""""" + +Allow custom auth rejection of users.:: + + + class MyOwnBackend(Saml2Backend): + def user_can_authenticate(self, user) -> bool: + can_authenticate = super().user_can_authenticate(user) + if not can_authenticate or idp_entityid != 'jukebox': + return can_authenticate + return can_authenticate and user.music_lover_score >= 0 + + +clean_user_main_attribute +""""""""""""""""""""""""" +Allow to clean the extracted user-identifying value. + + +save_user +""""""""" +Allow add custom logic around saving a user.:: + + + class MyOwnBackend(Saml2Backend): + def save_user(self, user: settings.AUTH_USER_MODEL, *args, **kwargs) -> settings.AUTH_USER_MODEL: + is_new_instance = user.pk is None + user = super().save_user(user, *args, **kwargs) + if is_new_instance: + register_music_lover_stats_on_registration(user.music_lover_score) + return user + + +djangosaml2.views.LoginView +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +TODO + +djangosaml2.views.LogoutView +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +TODO