From dab40da06811681bbc8177ef63be8b4e6e5a75ab Mon Sep 17 00:00:00 2001
From: Dylann Cordel <cordel.d@free.fr>
Date: Mon, 7 Jun 2021 16:22:24 +0200
Subject: [PATCH] wip #287

---
 CHANGES                        |   6 +
 djangosaml2/backends.py        |  83 +++++++----
 djangosaml2/signals.py         |   9 +-
 docs/source/contents/setup.rst | 264 +++++++++++++++++++++++++++++++++
 tests/testprofiles/tests.py    |   2 +-
 5 files changed, 330 insertions(+), 34 deletions(-)

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..a07f0995 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,11 +173,26 @@ 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)
@@ -168,34 +200,27 @@ def _update_user(self, user, attributes: dict, attribute_mapping: dict, force_sa
                 logger.debug(
                     f'Could not find value for "{saml_attr}", not updating fields "{django_attrs}"')
                 continue
-
             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 = self.lookup_and_set_attribute(
+                    user, attr, attr_value_list
+                ) or has_updated_fields
+        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 <https://docs.python.org/3/library/tempfile.html>`_ 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 <https://docs.djangoproject.com/en/dev/topics/signals/>`_.
+
+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 <https://docs.djangoproject.com/en/dev/ref/contrib/auth/#django.contrib.auth.signals.user_logged_in>`_.
+Used backend is set as an `attribute of the current user <https://github.com/django/django/blob/main/django/contrib/auth/__init__.py#L83>`_,
+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('<band you dislike>')
+        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 '<band you dislike>' 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
diff --git a/tests/testprofiles/tests.py b/tests/testprofiles/tests.py
index 6f395195..a375806b 100644
--- a/tests/testprofiles/tests.py
+++ b/tests/testprofiles/tests.py
@@ -336,7 +336,7 @@ class CustomizedBackend(Saml2Backend):
     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 '''
         return {