From 6a6b7da1659b9f5fcfc400fd5f0794f47ecef78c Mon Sep 17 00:00:00 2001
From: Serghei Mihai <smihai@entrouvert.com>
Date: Wed, 27 Nov 2019 10:25:56 +0100
Subject: [PATCH] misc: add support for enable conditions on authentication
 frontends (#28215)

---
 src/authentic2/app_settings.py             |  1 +
 src/authentic2/authenticators.py           | 33 +++++++++++++++--
 src/authentic2/idp/saml/saml2_endpoints.py |  6 ++-
 src/authentic2/saml/common.py              | 11 ++++++
 src/authentic2/templatetags/__init__.py    |  0
 src/authentic2/templatetags/authentic2.py  |  7 ++++
 src/authentic2/utils/__init__.py           |  4 +-
 src/authentic2/views.py                    |  2 +-
 src/authentic2_auth_fc/authenticators.py   | 10 +++--
 src/authentic2_auth_oidc/authenticators.py | 25 +++++++++++--
 src/authentic2_auth_saml/authenticators.py | 17 +++++++--
 tests/test_auth_oidc.py                    | 43 ++++++++++++++++++++++
 tests/test_login.py                        | 24 +++++++++++-
 13 files changed, 165 insertions(+), 18 deletions(-)
 create mode 100644 src/authentic2/templatetags/__init__.py
 create mode 100644 src/authentic2/templatetags/authentic2.py

diff --git a/src/authentic2/app_settings.py b/src/authentic2/app_settings.py
index 6f5a7111..604cffee 100644
--- a/src/authentic2/app_settings.py
+++ b/src/authentic2/app_settings.py
@@ -239,6 +239,7 @@ default_settings = dict(
     A2_AUTH_PASSWORD_ENABLE=Setting(
         default=True,
         definition='Activate login/password authentication', names=('AUTH_PASSWORD',)),
+    A2_AUTH_MODULES_CONDITIONS=Setting(default={}, definition='Modules filters'),
     A2_LOGIN_FAILURE_COUNT_BEFORE_WARNING=Setting(
         default=0,
         definition='Failure count before logging a warning to '
diff --git a/src/authentic2/authenticators.py b/src/authentic2/authenticators.py
index 1d27a103..b19071a8 100644
--- a/src/authentic2/authenticators.py
+++ b/src/authentic2/authenticators.py
@@ -14,25 +14,52 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
+
+import logging
+
 from django.db.models import Count
 from django.shortcuts import render
 from django.utils.translation import ugettext as _, ugettext_lazy
+from django.template import Template, TemplateSyntaxError, RequestContext
 
 from authentic2.a2_rbac.models import OrganizationalUnit as OU, Role
 from authentic2.custom_user.models import User
+
 from . import views, app_settings, utils, constants, models
 from .forms import authentication as authentication_forms
 
 
-class LoginPasswordAuthenticator(object):
+class BaseAuthenticator(object):
+
+    def eval_condition(self, condition, request):
+        try:
+            template = Template("{%% load authentic2 %%}{%% if %s %%}OK{%% endif %%}" % condition)
+            return template.render(RequestContext(request)) == "OK"
+        except TemplateSyntaxError as e:
+            logger = logging.getLogger()
+            logger.error(e)
+            return False
+
+    def is_condition_enabled(self, request):
+        if not (app_settings.A2_AUTH_MODULES_CONDITIONS and
+                self.id in app_settings.A2_AUTH_MODULES_CONDITIONS):
+            return True
+        return self.eval_condition(app_settings.A2_AUTH_MODULES_CONDITIONS[self.id],
+                                   request)
+
+
+class LoginPasswordAuthenticator(BaseAuthenticator):
     submit_name = 'login-password-submit'
 
-    def enabled(self):
-        return app_settings.A2_AUTH_PASSWORD_ENABLE
+    def enabled(self, request=None):
+        if not app_settings.A2_AUTH_PASSWORD_ENABLE:
+            return False
+        return self.is_condition_enabled(request)
 
     def name(self):
         return ugettext_lazy('Password')
 
+    @property
     def id(self):
         return 'password'
 
diff --git a/src/authentic2/idp/saml/saml2_endpoints.py b/src/authentic2/idp/saml/saml2_endpoints.py
index 946f9829..cf03c1fc 100644
--- a/src/authentic2/idp/saml/saml2_endpoints.py
+++ b/src/authentic2/idp/saml/saml2_endpoints.py
@@ -83,7 +83,8 @@ from authentic2.saml.common import redirect_next, asynchronous_bindings, \
     send_soap_request, get_saml2_query_request, \
     get_saml2_request_message_async_binding, create_saml2_server, \
     get_saml2_metadata, get_sp_options_policy, \
-    get_entity_id, AUTHENTIC_SAME_ID_SENTINEL
+    get_entity_id, AUTHENTIC_SAME_ID_SENTINEL, \
+    get_extension_node
 import authentic2.saml.saml2utils as saml2utils
 from authentic2.idp.saml.common import kill_django_sessions
 from authentic2.constants import NONCE_FIELD_NAME
@@ -534,6 +535,9 @@ def sso(request):
             name_id_policy = login.request.nameIdPolicy
         name_id_policy.format = NAME_ID_FORMATS[nid_format]['samlv2']
         logger.debug('set nameID policy format %s', nid_format)
+    hint = get_extension_node(login.request.extensions, 'login-hint')
+    if hint is not None:
+        request.session['login-hint'] = hint.text
     return sso_after_process_request(request, login, nid_format=nid_format)
 
 
diff --git a/src/authentic2/saml/common.py b/src/authentic2/saml/common.py
index 1e8d75b2..fe9675de 100644
--- a/src/authentic2/saml/common.py
+++ b/src/authentic2/saml/common.py
@@ -18,6 +18,7 @@ import os.path
 import logging
 import re
 import datetime
+import xml.etree.ElementTree as ET
 
 import requests
 
@@ -39,6 +40,10 @@ from authentic2.decorators import RequestCache
 from authentic2.idp.saml import app_settings
 from .. import nonce
 
+
+
+EO_NS = 'https://www.entrouvert.com/'
+
 AUTHENTIC_STATUS_CODE_NS = "http://authentic.entrouvert.org/status_code/"
 AUTHENTIC_SAME_ID_SENTINEL = 'urn:authentic.entrouvert.org:same-as-provider-entity-id'
 AUTHENTIC_STATUS_CODE_UNKNOWN_PROVIDER = AUTHENTIC_STATUS_CODE_NS + "UnknownProvider"
@@ -586,3 +591,9 @@ def get_session_not_on_or_after(assertion):
     if session_not_on_or_afters:
         return six.moves.reduce(min, session_not_on_or_afters)
     return None
+
+def get_extension_node(extensions, node, ns=EO_NS):
+    if extensions is not None:
+        root = ET.fromstring(extensions.dump())
+        return root.find('{%s}%s' % (ns, node))
+    return None
diff --git a/src/authentic2/templatetags/__init__.py b/src/authentic2/templatetags/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/authentic2/templatetags/authentic2.py b/src/authentic2/templatetags/authentic2.py
new file mode 100644
index 00000000..97aa4ad2
--- /dev/null
+++ b/src/authentic2/templatetags/authentic2.py
@@ -0,0 +1,7 @@
+from django.template import Library
+
+register = Library()
+
+@register.filter
+def is_for_backoffice(request):
+    return request.session.get('login-hint') == 'backoffice'
diff --git a/src/authentic2/utils/__init__.py b/src/authentic2/utils/__init__.py
index 5147915c..c39e40fe 100644
--- a/src/authentic2/utils/__init__.py
+++ b/src/authentic2/utils/__init__.py
@@ -169,7 +169,7 @@ def load_backend(path):
     return cls()
 
 
-def get_backends(setting_name='IDP_BACKENDS'):
+def get_backends(setting_name='IDP_BACKENDS', request=None):
     '''Return the list of enabled cleaned backends.'''
     backends = []
     for backend_path in getattr(app_settings, setting_name):
@@ -178,7 +178,7 @@ def get_backends(setting_name='IDP_BACKENDS'):
             backend_path, kwargs = backend_path
         backend = load_backend(backend_path)
         # If no enabled method is defined on the backend, backend enabled by default.
-        if hasattr(backend, 'enabled') and not backend.enabled():
+        if hasattr(backend, 'enabled') and not backend.enabled(request):
             continue
         kwargs_settings = getattr(app_settings, setting_name + '_KWARGS', {})
         if backend_path in kwargs_settings:
diff --git a/src/authentic2/views.py b/src/authentic2/views.py
index 8d6d7b7f..486a38d1 100644
--- a/src/authentic2/views.py
+++ b/src/authentic2/views.py
@@ -278,7 +278,7 @@ def login(request, template_name='authentic2/login.html',
             redirect_to = settings.LOGIN_REDIRECT_URL
     nonce = request.GET.get(constants.NONCE_FIELD_NAME)
 
-    authenticators = utils.get_backends('AUTH_FRONTENDS')
+    authenticators = utils.get_backends('AUTH_FRONTENDS', request)
 
     blocks = []
 
diff --git a/src/authentic2_auth_fc/authenticators.py b/src/authentic2_auth_fc/authenticators.py
index dc8ce5c5..e3c3abf7 100644
--- a/src/authentic2_auth_fc/authenticators.py
+++ b/src/authentic2_auth_fc/authenticators.py
@@ -19,17 +19,21 @@ from django.template.loader import render_to_string
 from django.template.response import TemplateResponse
 
 from authentic2 import app_settings as a2_app_settings, utils as a2_utils
+from authentic2.authenticators import BaseAuthenticator
 
 from . import app_settings
 
 
-class FcAuthenticator(object):
-    def enabled(self):
-        return app_settings.enable
+class FcAuthenticator(BaseAuthenticator):
+    def enabled(self, request):
+        if not app_settings.enable:
+            return False
+        return self.is_condition_enabled(request)
 
     def name(self):
         return gettext_noop('FranceConnect')
 
+    @property
     def id(self):
         return 'fc'
 
diff --git a/src/authentic2_auth_oidc/authenticators.py b/src/authentic2_auth_oidc/authenticators.py
index 5fc79612..7e50f0dc 100644
--- a/src/authentic2_auth_oidc/authenticators.py
+++ b/src/authentic2_auth_oidc/authenticators.py
@@ -18,21 +18,40 @@ from django.utils.translation import gettext_noop
 from django.shortcuts import render
 
 from . import app_settings, utils
+from authentic2 import app_settings as a2_settings
 from authentic2.utils import make_url
+from authentic2.authenticators import BaseAuthenticator
 
 
-class OIDCAuthenticator(object):
-    def enabled(self):
+class OIDCAuthenticator(BaseAuthenticator):
+    def enabled(self, request=None):
         return app_settings.ENABLE and utils.has_providers()
 
     def name(self):
         return gettext_noop('OpenIDConnect')
 
+    @property
     def id(self):
         return 'oidc'
 
+    def filter_by_condition(self, request, providers):
+        slugs = []
+        if a2_settings.A2_AUTH_MODULES_CONDITIONS:
+            for key, condition in a2_settings.A2_AUTH_MODULES_CONDITIONS.items():
+                if '/' not in key:
+                    continue
+                id_, slug =  key.split('/', 1)
+                if self.id != id_:
+                    continue
+                if not self.eval_condition(condition, request):
+                    slugs.append(slug)
+        if slugs:
+            providers = providers.exclude(slug__in=slugs)
+        return providers
+
     def instances(self, request, *args, **kwargs):
-        for p in utils.get_providers(shown=True):
+        providers = self.filter_by_condition(request, utils.get_providers(shown=True))
+        for p in providers:
             yield (p.slug, p)
 
     def login(self, request, *args, **kwargs):
diff --git a/src/authentic2_auth_saml/authenticators.py b/src/authentic2_auth_saml/authenticators.py
index ebb221ba..9f927dc0 100644
--- a/src/authentic2_auth_saml/authenticators.py
+++ b/src/authentic2_auth_saml/authenticators.py
@@ -20,15 +20,24 @@ from django.shortcuts import render
 from mellon.utils import get_idp, get_idps
 
 from authentic2.utils import redirect_to_login
+from authentic2.authenticators import BaseAuthenticator
+
 
 from . import app_settings
 
 
-class SAMLAuthenticator(object):
-    id = 'saml'
+class SAMLAuthenticator(BaseAuthenticator):
+
+    def enabled(self, request=None):
+        if not app_settings.enable:
+            return False
+        if not list(get_idps()):
+            return False
+        return self.is_condition_enabled(request)
 
-    def enabled(self):
-        return app_settings.enable and list(get_idps())
+    @property
+    def id(self):
+        return 'saml'
 
     def name(self):
         return gettext_noop('SAML')
diff --git a/tests/test_auth_oidc.py b/tests/test_auth_oidc.py
index 78c7a027..78d5b5ff 100644
--- a/tests/test_auth_oidc.py
+++ b/tests/test_auth_oidc.py
@@ -130,6 +130,7 @@ def oidc_provider(request, db, oidc_provider_jwkset):
     provider = OIDCProvider.objects.create(
         ou=get_default_ou(),
         name='OIDIDP',
+        slug='oididp',
         issuer='http://server.example.com',
         authorization_endpoint='https://server.example.com/authorize',
         token_endpoint='https://server.example.com/token',
@@ -331,6 +332,48 @@ def test_providers_on_login_page(oidc_provider, app):
     assert response.pyquery('p#oidc-p-oidcidp-2')
 
 
+def test_login_with_conditionnal_authenticators(oidc_provider, app, settings, caplog):
+
+    OIDCProvider.objects.create(
+        id=2,
+        ou=get_default_ou(),
+        name='My IDP',
+        slug='my-idp',
+        issuer='https://idp2.example.com/',
+        authorization_endpoint='https://idp2.example.com/authorize',
+        token_endpoint='https://idp2.example.com/token',
+        end_session_endpoint='https://idp2.example.com/logout',
+        userinfo_endpoint='https://idp*é.example.com/user_info',
+        token_revocation_endpoint='https://idp2.example.com/revoke',
+        max_auth_age=10,
+        strategy=OIDCProvider.STRATEGY_CREATE,
+        jwkset_json=None,
+        idtoken_algo=OIDCProvider.ALGO_RSA,
+        claims_parameter_supported=False
+    )
+
+    response = app.get('/login/')
+    session = app.session
+    session['login-hint'] = 'backoffice'
+    session.save()
+
+    settings.A2_AUTH_MODULES_CONDITIONS = {'oidc/my-idp': 'request|is_for_backoffice'}
+    response = app.get('/login/')
+    assert 'My IDP' in response
+    assert 'OIDIDP' in response
+
+    settings.A2_AUTH_MODULES_CONDITIONS = {'oidc/my-idp': 'not request|is_for_backoffice'}
+    response = app.get('/login/')
+    assert 'OIDIDP' in response
+    assert 'My IDP' not in response
+
+    settings.A2_AUTH_MODULES_CONDITIONS = {'oidc/my-idp': 'not request|is_for_backoffice',
+                                           'oidc/oididp': 'not request|is_for_backoffice'}
+    response = app.get('/login/')
+    assert 'OIDIDP' not in response
+    assert 'My IDP' not in response
+
+
 
 
 def test_sso(app, caplog, code, oidc_provider, oidc_provider_jwkset, login_url, login_callback_url, hooks):
diff --git a/tests/test_login.py b/tests/test_login.py
index 0fc2661b..ba68c015 100644
--- a/tests/test_login.py
+++ b/tests/test_login.py
@@ -21,7 +21,7 @@ from django.contrib.auth import get_user_model
 
 from authentic2 import models
 
-from utils import login
+from utils import login, check_log
 
 
 def test_login_inactive_user(db, app):
@@ -50,6 +50,28 @@ def test_login_inactive_user(db, app):
     assert '_auth_user_id' not in app.session
 
 
+def test_login_with_conditionnal_enabled_authenticators(db, app, settings, caplog):
+    settings.A2_AUTH_MODULES_CONDITIONS = {'password': 'request|is_for_backoffice'}
+    # open the page first time so session cookie can be set
+    response = app.get('/login/')
+    session = app.session
+    session['login-hint'] = 'backoffice'
+    session.save()
+    response = app.get('/login/')
+    assert 'name="login-password-submit"' in response
+
+    settings.A2_AUTH_MODULES_CONDITIONS = {'password': 'not request|is_for_backoffice'}
+    response = app.get('/login/')
+    # login form must not be displayed
+    assert 'name="login-password-submit"' not in response
+    assert len(caplog.records) == 0
+    # set a condition with error
+    with check_log(caplog, 'Invalid filter: \'is\''):
+        settings.A2_AUTH_MODULES_CONDITIONS = {'password': 'not request|is'}
+        response = app.get('/login/')
+        assert 'name="login-password-submit"' not in response
+
+
 def test_registration_url_on_login_page(db, app):
     response = app.get('/login/?next=/whatever')
     assert 'register/?next=/whatever"' in response
-- 
2.24.1

