From dbd4d45b46f701304952649720acfbb91655fd4d 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             |  2 ++
 src/authentic2/authenticators.py           | 25 +++++++++++++++---
 src/authentic2/utils/__init__.py           |  4 +--
 src/authentic2/views.py                    |  2 +-
 src/authentic2_auth_fc/app_settings.py     |  4 +++
 src/authentic2_auth_fc/authenticators.py   |  7 +++--
 src/authentic2_auth_oidc/app_settings.py   |  4 +++
 src/authentic2_auth_oidc/authenticators.py | 11 +++++---
 src/authentic2_auth_saml/app_settings.py   |  4 +++
 src/authentic2_auth_saml/authenticators.py | 11 +++++---
 tests/test_auth_oidc.py                    | 30 ++++++++++++++++++++++
 tests/test_login.py                        | 19 +++++++++++++-
 12 files changed, 108 insertions(+), 15 deletions(-)

diff --git a/src/authentic2/app_settings.py b/src/authentic2/app_settings.py
index 6f5a7111..42d321eb 100644
--- a/src/authentic2/app_settings.py
+++ b/src/authentic2/app_settings.py
@@ -239,6 +239,8 @@ default_settings = dict(
     A2_AUTH_PASSWORD_ENABLE=Setting(
         default=True,
         definition='Activate login/password authentication', names=('AUTH_PASSWORD',)),
+    A2_AUTH_PASSWORD_ENABLE_CONDITION=Setting(default=None, definition='Filters',
+                                              names=('FRONTEND ENABLE CONDITION',)),
     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 5d905d4c..a176135a 100644
--- a/src/authentic2/authenticators.py
+++ b/src/authentic2/authenticators.py
@@ -14,20 +14,39 @@
 # 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.shortcuts import render
 from django.utils.translation import ugettext as _, ugettext_lazy
 
 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_enable_condition(self, condition, request, **kwargs):
+        if condition:
+            try:
+                context = {'request': request}
+                if kwargs:
+                    context.update(kwargs)
+                return eval(condition, context)
+            except Exception, e:
+                logging.getLogger(__name__).warning('filter expression error: %r' % e)
+                return False
+        return True
+
+
+class LoginPasswordAuthenticator(BaseAuthenticator):
     submit_name = 'login-password-submit'
 
-    def enabled(self):
-        return app_settings.A2_AUTH_PASSWORD_ENABLE
+    def enabled(self, request=None):
+        if app_settings.A2_AUTH_PASSWORD_ENABLE:
+            return self.eval_enable_condition(app_settings.A2_AUTH_PASSWORD_ENABLE_CONDITION, request)
+        return False
 
     def name(self):
         return ugettext_lazy('Password')
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 ae2859eb..1e2fb5f7 100644
--- a/src/authentic2/views.py
+++ b/src/authentic2/views.py
@@ -277,7 +277,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/app_settings.py b/src/authentic2_auth_fc/app_settings.py
index a7b5cf03..bb9d24e1 100644
--- a/src/authentic2_auth_fc/app_settings.py
+++ b/src/authentic2_auth_fc/app_settings.py
@@ -134,6 +134,10 @@ class AppSettings(object):
     def popup(self):
         return self._setting('POPUP', False)
 
+    @property
+    def enable_condition(self):
+        return self._setting('ENABLE_CONDITION', None)
+
 app_settings = AppSettings('A2_FC_')
 app_settings.__name__ = __name__
 sys.modules[__name__] = app_settings
diff --git a/src/authentic2_auth_fc/authenticators.py b/src/authentic2_auth_fc/authenticators.py
index dc8ce5c5..03a01913 100644
--- a/src/authentic2_auth_fc/authenticators.py
+++ b/src/authentic2_auth_fc/authenticators.py
@@ -19,12 +19,15 @@ 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):
+class FcAuthenticator(BaseAuthenticator):
+    def enabled(self, request):
+        if app_settings.enable:
+            return self.eval_enable_condition(app_settings.enable_condition, request)
         return app_settings.enable
 
     def name(self):
diff --git a/src/authentic2_auth_oidc/app_settings.py b/src/authentic2_auth_oidc/app_settings.py
index e998d8b3..48dade98 100644
--- a/src/authentic2_auth_oidc/app_settings.py
+++ b/src/authentic2_auth_oidc/app_settings.py
@@ -37,6 +37,10 @@ class AppSettings(object):
     def ENABLE(self):
         return self._setting('ENABLE', True)
 
+    @property
+    def IDP_ENABLE_CONDITION(self):
+        return self._setting('IDP_ENABLE_CONDITION', None)
+
 app_settings = AppSettings('A2_AUTH_OIDC_')
 app_settings.__name__ = __name__
 sys.modules[__name__] = app_settings
diff --git a/src/authentic2_auth_oidc/authenticators.py b/src/authentic2_auth_oidc/authenticators.py
index 5fc79612..74b00856 100644
--- a/src/authentic2_auth_oidc/authenticators.py
+++ b/src/authentic2_auth_oidc/authenticators.py
@@ -19,10 +19,11 @@ from django.shortcuts import render
 
 from . import app_settings, utils
 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):
@@ -32,7 +33,11 @@ class OIDCAuthenticator(object):
         return 'oidc'
 
     def instances(self, request, *args, **kwargs):
-        for p in utils.get_providers(shown=True):
+        providers = utils.get_providers(shown=True)
+        if app_settings.IDP_ENABLE_CONDITION:
+            providers = self.eval_enable_condition(app_settings.IDP_ENABLE_CONDITION, request,
+                                                   providers=providers)
+        for p in providers:
             yield (p.slug, p)
 
     def login(self, request, *args, **kwargs):
diff --git a/src/authentic2_auth_saml/app_settings.py b/src/authentic2_auth_saml/app_settings.py
index adbcab7a..565596d1 100644
--- a/src/authentic2_auth_saml/app_settings.py
+++ b/src/authentic2_auth_saml/app_settings.py
@@ -37,6 +37,10 @@ class AppSettings(object):
     def enable(self):
         return self._setting('ENABLE', False)
 
+    @property
+    def enable_condition(self):
+        return self._setting('ENABLE_CONDITION', False)
+
 app_settings = AppSettings('A2_AUTH_SAML_')
 app_settings.__name__ = __name__
 sys.modules[__name__] = app_settings
diff --git a/src/authentic2_auth_saml/authenticators.py b/src/authentic2_auth_saml/authenticators.py
index ebb221ba..c323044e 100644
--- a/src/authentic2_auth_saml/authenticators.py
+++ b/src/authentic2_auth_saml/authenticators.py
@@ -20,15 +20,20 @@ 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):
-        return app_settings.enable and list(get_idps())
+    def enabled(self, request=None):
+        idps = list(get_idps())
+        if app_settings.enable and idps:
+            return self.eval_enable_condition(app_settings.enable_condition, request, idps=idps)
+        return False
 
     def name(self):
         return gettext_noop('SAML')
diff --git a/tests/test_auth_oidc.py b/tests/test_auth_oidc.py
index 78c7a027..db27dee1 100644
--- a/tests/test_auth_oidc.py
+++ b/tests/test_auth_oidc.py
@@ -331,6 +331,36 @@ 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',
+        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
+    )
+    settings.A2_AUTH_OIDC_IDP_ENABLE_CONDITION = "providers.filter(name='My IDP')"
+    response = app.get('/login/')
+    assert 'My IDP' in response
+    assert 'OIDIDP' not in response
+    assert len(caplog.records) == 0
+
+    settings.A2_AUTH_OIDC_IDP_ENABLE_CONDITION = "providers.filter(name='OIDIDP')"
+    response = app.get('/login/')
+    assert 'OIDIDP' 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 91478326..1165184e 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,23 @@ 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_PASSWORD_ENABLE_CONDITION = "request.META['REMOTE_ADDR'] in ('127.0.0.1',)"
+    response = app.get('/login/')
+    assert 'name="login-password-submit"' in response
+    # set invalid display condition
+    settings.A2_AUTH_PASSWORD_ENABLE_CONDITION = "request.META['REMOTE_ADDR'] in ('0.0.0.0',)"
+    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, 'filter expression error: SyntaxError(\'unexpected EOF while parsing\''):
+        settings.A2_AUTH_PASSWORD_ENABLE_CONDITION = "request.META['REMOTE_ADDR'] in "
+        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.0

