From 50bc92ec8e48e6b873342372231240fb9d7f9992 Mon Sep 17 00:00:00 2001
From: Valentin Deniaud <vdeniaud@entrouvert.com>
Date: Fri, 21 Jun 2019 14:15:34 +0200
Subject: [PATCH] models: mind auth level at service access authorization

Allows to enable multi-factor authentication on a per-service basis via
service access restriction to some roles.
---
 src/authentic2/idp/saml/saml2_endpoints.py |  2 +-
 src/authentic2/manager/role_views.py       |  8 ++++----
 src/authentic2/manager/utils.py            |  5 -----
 src/authentic2/manager/views.py            |  4 ++--
 src/authentic2/middleware.py               |  2 ++
 src/authentic2/models.py                   | 15 +++++++++++----
 src/authentic2/utils.py                    |  9 +++++++++
 src/authentic2_idp_cas/views.py            |  2 +-
 src/authentic2_idp_oidc/views.py           |  2 +-
 9 files changed, 31 insertions(+), 18 deletions(-)

diff --git a/src/authentic2/idp/saml/saml2_endpoints.py b/src/authentic2/idp/saml/saml2_endpoints.py
index 6cbfbf4b..cee31deb 100644
--- a/src/authentic2/idp/saml/saml2_endpoints.py
+++ b/src/authentic2/idp/saml/saml2_endpoints.py
@@ -694,7 +694,7 @@ def sso_after_process_request(request, login, consent_obtained=False,
         set_saml2_response_responder_status_code(login.response, lasso.SAML2_STATUS_CODE_NO_PASSIVE)
         return finish_sso(request, login)
 
-    service.authorize(request.user)
+    service.authorize(request)
 
     hooks.call_hooks('event', name='sso-request', idp='saml2', service=service)
 
diff --git a/src/authentic2/manager/role_views.py b/src/authentic2/manager/role_views.py
index 125ba345..635e0cbb 100644
--- a/src/authentic2/manager/role_views.py
+++ b/src/authentic2/manager/role_views.py
@@ -30,7 +30,7 @@ from django.contrib.auth import get_user_model
 from django_rbac.exceptions import InsufficientAuthLevel
 from django_rbac.utils import get_role_model, get_permission_model, get_ou_model
 
-from authentic2.utils import redirect
+from authentic2.utils import redirect, increase_auth_level
 from authentic2 import hooks, data_transfer
 
 from . import tables, views, resources, forms, app_settings, utils
@@ -178,7 +178,7 @@ class RoleMembersView(views.HideOUColumnMixin, RoleViewMixin, views.BaseSubTable
                                      user=self.request.user, role=self.object, member=user)
         else:
             if self.could_change:
-                return utils.increase_auth_level(self.request)
+                return increase_auth_level(self.request)
             messages.warning(self.request, _('You are not authorized'))
         return super(RoleMembersView, self).form_valid(form)
 
@@ -209,7 +209,7 @@ class RoleDeleteView(RoleViewMixin, views.BaseDeleteView):
     def post(self, request, *args, **kwargs):
         if not self.can_delete:
             if self.could_delete:
-                return utils.increase_auth_level(self.request)
+                return increase_auth_level(self.request)
             raise PermissionDenied
         return super(RoleDeleteView, self).post(request, *args, **kwargs)
 
@@ -265,7 +265,7 @@ class RolePermissionsView(RoleViewMixin, views.BaseSubTableView):
                                          user=self.request.user, role=self.object, permission=perm)
         else:
             if self.could_change:
-                return utils.increase_auth_level(self.request)
+                return increase_auth_level(self.request)
             messages.warning(self.request, _('You are not authorized'))
         return super(RolePermissionsView, self).form_valid(form)
 
diff --git a/src/authentic2/manager/utils.py b/src/authentic2/manager/utils.py
index 10d217ca..c26796a5 100644
--- a/src/authentic2/manager/utils.py
+++ b/src/authentic2/manager/utils.py
@@ -41,8 +41,3 @@ def label_from_user(user):
 @GlobalCache(timeout=10)
 def get_ou_count():
     return get_ou_model().objects.count()
-
-
-def increase_auth_level(request):
-    current_auth_level = request.session.get('auth_level', 1)
-    return login_require(request, params={'auth_level': current_auth_level + 1})
diff --git a/src/authentic2/manager/views.py b/src/authentic2/manager/views.py
index 25942362..1ba5ea32 100644
--- a/src/authentic2/manager/views.py
+++ b/src/authentic2/manager/views.py
@@ -43,7 +43,7 @@ from django_rbac.utils import get_ou_model
 
 from authentic2.data_transfer import export_site, import_site, DataImportError, ImportContext
 from authentic2.forms.profile import modelform_factory
-from authentic2.utils import redirect, batch_queryset
+from authentic2.utils import redirect, batch_queryset, increase_auth_level
 from authentic2.decorators import json as json_view
 from authentic2 import hooks
 
@@ -150,7 +150,7 @@ class PermissionMixin(object):
         try:
             response = self.authorize(request, *args, **kwargs)
         except InsufficientAuthLevel:
-            return utils.increase_auth_level(request)
+            return increase_auth_level(request)
         if response is not None:
             return response
         return super(PermissionMixin, self).dispatch(request, *args, **kwargs)
diff --git a/src/authentic2/middleware.py b/src/authentic2/middleware.py
index 7dbe1c48..1da662fe 100644
--- a/src/authentic2/middleware.py
+++ b/src/authentic2/middleware.py
@@ -265,4 +265,6 @@ class ServiceAccessControlMiddleware(object):
     def process_exception(self, request, exception):
         if not isinstance(exception, (utils.ServiceAccessDenied,)):
             return None
+        if isinstance(exception, (utils.IncreaseAuthLevel,)):
+            return utils.increase_auth_level(request)
         return utils.unauthorized_view(request, exception.service)
diff --git a/src/authentic2/models.py b/src/authentic2/models.py
index e7a86f81..390fa02b 100644
--- a/src/authentic2/models.py
+++ b/src/authentic2/models.py
@@ -39,7 +39,7 @@ except ImportError:
 from . import managers
 # install our natural_key implementation
 from . import natural_key as unused_natural_key  # noqa: F401
-from .utils import ServiceAccessDenied
+from .utils import ServiceAccessDenied, IncreaseAuthLevel
 
 
 class DeletedUser(models.Model):
@@ -369,11 +369,18 @@ class Service(models.Model):
     def __repr__(self):
         return '<%s %r>' % (self.__class__.__name__, six.text_type(self))
 
-    def authorize(self, user):
+    def authorize(self, request):
         if not self.authorized_roles.exists():
             return True
-        if user.roles_and_parents().filter(allowed_services=self).exists():
-            return True
+        allowed_roles = request.user.roles_and_parents() \
+            .filter(allowed_services=self) \
+            .set_needed_auth_levels(request.user)
+        user_auth_level = request.session.get('auth_level', 1)
+        for role in allowed_roles:
+            if role.needed_auth_level <= user_auth_level:
+                return True
+        if allowed_roles:
+            raise IncreaseAuthLevel(service=self)
         raise ServiceAccessDenied(service=self)
 
     def add_authorized_role(self, role):
diff --git a/src/authentic2/utils.py b/src/authentic2/utils.py
index dccdc537..b110aca1 100644
--- a/src/authentic2/utils.py
+++ b/src/authentic2/utils.py
@@ -977,6 +977,10 @@ class ServiceAccessDenied(Exception):
         self.service = service
 
 
+class IncreaseAuthLevel(ServiceAccessDenied):
+    pass
+
+
 def unauthorized_view(request, service):
     context = {'callback_url': service.unauthorized_url or reverse('auth_homepage')}
     return render(request, 'authentic2/unauthorized.html', context=context)
@@ -1157,3 +1161,8 @@ def get_authentication_events(request=None, session=None):
     if session is not None:
         return session.get(constants.AUTHENTICATION_EVENTS_SESSION_KEY, [])
     return []
+
+
+def increase_auth_level(request):
+    current_auth_level = request.session.get('auth_level', 1)
+    return login_require(request, params={'auth_level': current_auth_level + 1})
diff --git a/src/authentic2_idp_cas/views.py b/src/authentic2_idp_cas/views.py
index 1bfca94c..95a25f68 100644
--- a/src/authentic2_idp_cas/views.py
+++ b/src/authentic2_idp_cas/views.py
@@ -181,7 +181,7 @@ class ContinueView(CasMixin, View):
             return self.authenticate(request, st)
         # if user not authorized, a ServiceAccessDenied exception
         # is raised and handled by ServiceAccessMiddleware
-        st.service.authorize(request.user)
+        st.service.authorize(request)
 
         self.validate_ticket(request, st)
         if st.valid():
diff --git a/src/authentic2_idp_oidc/views.py b/src/authentic2_idp_oidc/views.py
index b28973e8..4e6c6cbf 100644
--- a/src/authentic2_idp_oidc/views.py
+++ b/src/authentic2_idp_oidc/views.py
@@ -202,7 +202,7 @@ def authorize(request, *args, **kwargs):
 
     # if user not authorized, a ServiceAccessDenied exception
     # is raised and handled by ServiceAccessMiddleware
-    client.authorize(request.user)
+    client.authorize(request)
 
     last_auth = last_authentication_event(request=request)
     if max_age is not None and time.time() - last_auth['when'] >= max_age:
-- 
2.20.1

