From 1b74a2a92fa6ac2a1a0daaac0bed1a274f68c41d Mon Sep 17 00:00:00 2001
From: Serghei Mihai <smihai@entrouvert.com>
Date: Mon, 9 Mar 2020 15:19:47 +0100
Subject: [PATCH] misc: allow authenticators display conditions (#28215)

---
 src/authentic2/authenticators.py           | 34 +++++++++--
 src/authentic2/utils/__init__.py           | 11 ++--
 src/authentic2/views.py                    |  9 ++-
 src/authentic2_auth_fc/authenticators.py   |  8 +--
 src/authentic2_auth_oidc/authenticators.py |  9 +--
 src/authentic2_auth_saml/authenticators.py |  5 +-
 tests/auth_fc/test_auth_fc.py              | 10 ++++
 tests/test_auth_oidc.py                    | 69 ++++++++++++++++++++++
 tests/test_auth_saml.py                    | 46 +++++++++++++++
 tests/test_login.py                        | 18 +++++-
 10 files changed, 198 insertions(+), 21 deletions(-)

diff --git a/src/authentic2/authenticators.py b/src/authentic2/authenticators.py
index 1d27a103..e92a15eb 100644
--- a/src/authentic2/authenticators.py
+++ b/src/authentic2/authenticators.py
@@ -14,6 +14,8 @@
 # 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
@@ -22,9 +24,36 @@ 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
+from .utils.evaluate import evaluate_condition
+
+logger = logging.getLogger(__name__)
+
+
+class BaseAuthenticator(object):
 
+    def __init__(self, show_condition=None, **kwargs):
+        self.show_condition = show_condition
 
-class LoginPasswordAuthenticator(object):
+    def get_show_condition(self, instance_id=None):
+        if isinstance(self.show_condition, dict):
+            if instance_id and instance_id in self.show_condition:
+                return self.show_condition[instance_id]
+        else:
+            return self.show_condition
+
+    def shown(self, remote_addr=None, instance_id=None):
+        show_condition = self.get_show_condition(instance_id)
+        if not show_condition:
+            return True
+        try:
+            return evaluate_condition(show_condition, {'id': instance_id, 'remote_addr': remote_addr})
+        except Exception as e:
+            logger.error(e)
+            return False
+
+
+class LoginPasswordAuthenticator(BaseAuthenticator):
+    id = 'password'
     submit_name = 'login-password-submit'
 
     def enabled(self):
@@ -33,9 +62,6 @@ class LoginPasswordAuthenticator(object):
     def name(self):
         return ugettext_lazy('Password')
 
-    def id(self):
-        return 'password'
-
     def get_service_ous(self, request):
         service_slug = request.GET.get(constants.SERVICE_FIELD_NAME)
         if not service_slug:
diff --git a/src/authentic2/utils/__init__.py b/src/authentic2/utils/__init__.py
index c6251b7d..62647e23 100644
--- a/src/authentic2/utils/__init__.py
+++ b/src/authentic2/utils/__init__.py
@@ -149,7 +149,7 @@ def accumulate_from_backends(request, method_name, **kwargs):
     return list
 
 
-def load_backend(path):
+def load_backend(path, kwargs):
     '''Load an IdP backend by its module path'''
     i = path.rfind('.')
     module, attr = path[:i], path[i + 1:]
@@ -165,7 +165,10 @@ def load_backend(path):
     except AttributeError:
         raise ImproperlyConfigured('Module "%s" does not define a "%s" idp backend'
                                    % (module, attr))
-    return cls()
+    backend_kwargs = {}
+    if hasattr(cls, 'id'):
+        backend_kwargs.update(kwargs.get(cls.id, {}))
+    return cls(**backend_kwargs)
 
 
 def get_backends(setting_name='IDP_BACKENDS'):
@@ -175,11 +178,11 @@ def get_backends(setting_name='IDP_BACKENDS'):
         kwargs = {}
         if not isinstance(backend_path, six.string_types):
             backend_path, kwargs = backend_path
-        backend = load_backend(backend_path)
+        kwargs_settings = getattr(app_settings, setting_name + '_KWARGS', {})
+        backend = load_backend(backend_path, kwargs_settings)
         # If no enabled method is defined on the backend, backend enabled by default.
         if hasattr(backend, 'enabled') and not backend.enabled():
             continue
-        kwargs_settings = getattr(app_settings, setting_name + '_KWARGS', {})
         if backend_path in kwargs_settings:
             kwargs.update(kwargs_settings[backend_path])
         # Clean id and name for legacy support
diff --git a/src/authentic2/views.py b/src/authentic2/views.py
index bd04cacf..f579b594 100644
--- a/src/authentic2/views.py
+++ b/src/authentic2/views.py
@@ -324,17 +324,21 @@ def login(request, template_name='authentic2/login.html',
             auth_blocks = []
             parameters = {'request': request,
                           'context': context}
+            remote_addr = request.META.get('REMOTE_ADDR')
             # check if the authenticator has multiple instances
             if hasattr(authenticator, 'instances'):
                 for instance_id, instance in authenticator.instances(**parameters):
                     parameters['instance'] = instance
                     parameters['instance_id'] = instance_id
+                    if not authenticator.shown(remote_addr, instance_id):
+                        continue
                     block = utils.get_authenticator_method(authenticator, 'login', parameters)
                     # update block id in order to separate instances
                     block['id'] = '%s_%s' % (block['id'], instance_id)
                     auth_blocks.append(block)
             else:
-                auth_blocks.append(utils.get_authenticator_method(authenticator, 'login', parameters))
+                if authenticator.shown(remote_addr):
+                    auth_blocks.append(utils.get_authenticator_method(authenticator, 'login', parameters))
             # If a login frontend method returns an HttpResponse with a status code != 200
             # this response is returned.
             for block in auth_blocks:
@@ -344,7 +348,8 @@ def login(request, template_name='authentic2/login.html',
                     block['is_hidden'] = False
                     blocks.append(block)
         # TODO: remove attribute below after cleaning up the templates
-        blocks[-1]['is_hidden'] = False
+        if blocks:
+            blocks[-1]['is_hidden'] = False
 
     # Old frontends API
     for block in blocks:
diff --git a/src/authentic2_auth_fc/authenticators.py b/src/authentic2_auth_fc/authenticators.py
index dc8ce5c5..077161f6 100644
--- a/src/authentic2_auth_fc/authenticators.py
+++ b/src/authentic2_auth_fc/authenticators.py
@@ -19,20 +19,20 @@ 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):
+class FcAuthenticator(BaseAuthenticator):
+    id = 'fc'
+
     def enabled(self):
         return app_settings.enable
 
     def name(self):
         return gettext_noop('FranceConnect')
 
-    def id(self):
-        return 'fc'
-
     @property
     def popup(self):
         return app_settings.popup
diff --git a/src/authentic2_auth_oidc/authenticators.py b/src/authentic2_auth_oidc/authenticators.py
index d6f8cfed..46a78313 100644
--- a/src/authentic2_auth_oidc/authenticators.py
+++ b/src/authentic2_auth_oidc/authenticators.py
@@ -19,18 +19,19 @@ from django.shortcuts import render
 
 from . import app_settings, utils
 from authentic2.utils import make_url
+from authentic2.authenticators import BaseAuthenticator
+
+
+class OIDCAuthenticator(BaseAuthenticator):
+    id = 'oidc'
 
 
-class OIDCAuthenticator(object):
     def enabled(self):
         return app_settings.ENABLE and utils.has_providers()
 
     def name(self):
         return gettext_noop('OpenIDConnect')
 
-    def id(self):
-        return 'oidc'
-
     def instances(self, request, *args, **kwargs):
         for p in utils.get_providers(shown=True):
             yield (p.slug, p)
diff --git a/src/authentic2_auth_saml/authenticators.py b/src/authentic2_auth_saml/authenticators.py
index f0e452a5..5131a80b 100644
--- a/src/authentic2_auth_saml/authenticators.py
+++ b/src/authentic2_auth_saml/authenticators.py
@@ -20,11 +20,12 @@ 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):
+class SAMLAuthenticator(BaseAuthenticator):
     id = 'saml'
 
     def enabled(self):
@@ -35,7 +36,7 @@ class SAMLAuthenticator(object):
 
     def instances(self, request, *args, **kwargs):
         for idx, idp in enumerate(get_idps()):
-            yield(idp.get('SLUG') or idx, idp)
+            yield(idp.get('SLUG') or str(idx), idp)
 
     def login(self, request, *args, **kwargs):
         context = kwargs.pop('context', {})
diff --git a/tests/auth_fc/test_auth_fc.py b/tests/auth_fc/test_auth_fc.py
index f9d15b6c..427e4e79 100644
--- a/tests/auth_fc/test_auth_fc.py
+++ b/tests/auth_fc/test_auth_fc.py
@@ -85,6 +85,16 @@ def check_authorization_url(url):
     return parsed['state']
 
 
+def test_login_with_condition(app, fc_settings, settings):
+    # open the page first time so session cookie can be set
+    response = app.get('/login/')
+    assert 'fc-button' in response
+
+    settings.AUTH_FRONTENDS_KWARGS = {'fc': {'show_condition': 'remote_addr==\'0.0.0.0\''}}
+    response = app.get('/login/')
+    assert 'fc-button' not in response
+
+
 @pytest.mark.parametrize('exp', [timestamp_from_datetime(now() + datetime.timedelta(seconds=1000)),
                                  timestamp_from_datetime(now() - datetime.timedelta(seconds=1000))])
 def test_login_simple(app, fc_settings, caplog, hooks, exp):
diff --git a/tests/test_auth_oidc.py b/tests/test_auth_oidc.py
index 629a2918..7cd1e829 100644
--- a/tests/test_auth_oidc.py
+++ b/tests/test_auth_oidc.py
@@ -154,6 +154,7 @@ def make_oidc_provider(
     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',
@@ -348,6 +349,74 @@ 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):
+
+    oidc2_provider = OIDCProvider.objects.create(
+        id=2,
+        ou=get_default_ou(),
+        name='My IDP',
+        slug='myidp',
+        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/')
+    assert 'My IDP' in response
+    assert 'OIDIDP' in response
+
+    settings.AUTH_FRONTENDS_KWARGS = {
+        'oidc': {
+            'show_condition': {
+                'myidp': 'remote_addr==\'0.0.0.0\''
+            }
+        }
+    }
+    response = app.get('/login/')
+    assert 'OIDIDP' in response
+    assert 'My IDP' not in response
+
+    settings.AUTH_FRONTENDS_KWARGS = {
+        'oidc': {
+            'show_condition': {
+                'myid': 'remote_addr==\'0.0.0.0\'',
+                'oididp': 'remote_addr==\'127.0.0.1\''
+            }
+        }
+    }
+    response = app.get('/login/')
+    assert 'OIDIDP' in response
+    assert 'My IDP' in response
+
+    settings.AUTH_FRONTENDS_KWARGS = {
+        'oidc': {
+            'show_condition': {
+                'myidp': 'remote_addr==\'0.0.0.0\'',
+                'oididp': 'remote_addr==\'127.0.0.1\''
+            }
+        }
+    }
+    response = app.get('/login/')
+    assert 'OIDIDP' in response
+    assert 'My IDP' not in response
+
+    settings.AUTH_FRONTENDS_KWARGS = {
+        'oidc': {
+            'show_condition': 'remote_addr==\'127.0.0.1\''
+            }
+    }
+    response = app.get('/login/')
+    assert 'OIDIDP' in response
+    assert 'My IDP' in response
+
+
 def test_sso(app, caplog, code, oidc_provider, oidc_provider_jwkset, hooks):
     OU = get_ou_model()
     cassis = OU.objects.create(name='Cassis', slug='cassis')
diff --git a/tests/test_auth_saml.py b/tests/test_auth_saml.py
index b35050e0..e64b99b5 100644
--- a/tests/test_auth_saml.py
+++ b/tests/test_auth_saml.py
@@ -14,6 +14,7 @@
 # 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 os
 import logging
 
 import pytest
@@ -23,6 +24,7 @@ import lasso
 from django.contrib.auth import get_user_model
 from authentic2.models import Attribute
 
+pytestmark = pytest.mark.django_db
 
 def test_providers_on_login_page(db, app, settings):
     settings.A2_AUTH_SAML_ENABLE = True
@@ -133,3 +135,47 @@ def test_provision_attributes(db, caplog, simple_role):
     # on missing mandatory attribute, no user is created
     del saml_attributes['mail']
     assert adapter.lookup_user(idp, saml_attributes) is None
+
+
+def test_login_with_conditionnal_authenticators(app, settings, caplog):
+
+    settings.A2_AUTH_SAML_ENABLE = True
+    settings.MELLON_IDENTITY_PROVIDERS = [
+        {"METADATA": os.path.join(os.path.dirname(__file__), 'metadata.xml')}
+    ]
+    response = app.get('/login/')
+    assert 'login-saml-0' in response
+
+    settings.AUTH_FRONTENDS_KWARGS = {
+        'saml': {
+            'show_condition': 'remote_addr==\'0.0.0.0\''
+        }
+    }
+    response = app.get('/login/')
+    assert 'login-saml-0' not in response
+
+    settings.MELLON_IDENTITY_PROVIDERS.append(
+        {"METADATA": os.path.join(os.path.dirname(__file__), 'metadata.xml')}
+    )
+    settings.AUTH_FRONTENDS_KWARGS = {
+        'saml': {
+            'show_condition': {
+                '0': 'remote_addr==\'0.0.0.0\''
+            }
+        }
+    }
+    response = app.get('/login/')
+    assert 'login-saml-0' not in response
+    assert 'login-saml-1' in response
+
+    settings.AUTH_FRONTENDS_KWARGS = {
+        'saml': {
+            'show_condition': {
+                '0': 'remote_addr==\'0.0.0.0\'',
+                '1': 'remote_addr==\'0.0.0.0\''
+            }
+        }
+    }
+    response = app.get('/login/')
+    assert 'login-saml-0' not in response
+    assert 'login-saml-1' not in response
diff --git a/tests/test_login.py b/tests/test_login.py
index 0fc2661b..bbd36456 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,22 @@ 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):
+    response = app.get('/login/')
+    assert 'name="login-password-submit"' in response
+
+    settings.AUTH_FRONTENDS_KWARGS = {'password': {'show_condition': 'False'}}
+    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, 'name \'login_hint\' is not defined'):
+        settings.AUTH_FRONTENDS_KWARGS = {'password': {'show_condition': '\'admin\' in login_hint'}}
+        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.25.1

