From f506302f653c03d5af86cfab1399454711011b44 Mon Sep 17 00:00:00 2001
From: Serghei Mihai <smihai@entrouvert.com>
Date: Tue, 18 Dec 2018 15:10:31 +0100
Subject: [PATCH] backends: add SAML LDAP authentication backend (#30125)

---
 src/authentic2/backends/ldap_backend.py       | 32 +++++----
 src/authentic2_auth_saml/__init__.py          |  3 +-
 .../app_ldap_saml_settings.py                 | 31 +++++++++
 src/authentic2_auth_saml/app_settings.py      |  4 ++
 src/authentic2_auth_saml/backends.py          | 40 +++++++++++
 tests/test_auth_saml.py                       | 66 +++++++++++++++++++
 6 files changed, 162 insertions(+), 14 deletions(-)
 create mode 100644 src/authentic2_auth_saml/app_ldap_saml_settings.py

diff --git a/src/authentic2/backends/ldap_backend.py b/src/authentic2/backends/ldap_backend.py
index b86ff7e1..895db864 100644
--- a/src/authentic2/backends/ldap_backend.py
+++ b/src/authentic2/backends/ldap_backend.py
@@ -316,6 +316,8 @@ class LDAPBackend(object):
         'connect_with_user_credentials': True,
         # can reset password
         'can_reset_password': False,
+        # entity id of the IDP based on the same LDAP
+        'entity_id': ''
     }
     _REQUIRED = ('url', 'basedn')
     _TO_ITERABLE = ('url', 'groupsu', 'groupstaff', 'groupactive')
@@ -947,26 +949,30 @@ class LDAPBackend(object):
                 yield user_dn, user
 
     @classmethod
-    def get_users(cls):
+    def get_block_users(cls, block, user_filter=None):
         logger = logging.getLogger(__name__)
+        conn = cls.get_connection(block)
+        if conn is None:
+            logger.warning(u'unable to synchronize with LDAP servers %r', block['url'])
+            return
+        user_basedn = block.get('user_basedn') or block['basedn']
+        user_filter = user_filter or block['sync_ldap_users_filter'] or block['user_filter']
+        user_filter = user_filter.replace('%s', '*')
+        attrs = cls.get_ldap_attributes_names(block)
+        return cls.paged_search(conn, user_basedn, ldap.SCOPE_SUBTREE, user_filter,
+                                attrlist=attrs)
+
+    @classmethod
+    def get_users(cls):
         for block in cls.get_config():
-            conn = cls.get_connection(block)
-            if conn is None:
-                logger.warning(u'unable to synchronize with LDAP servers %r', block['url'])
-                continue
-            user_basedn = block.get('user_basedn') or block['basedn']
-            user_filter = block['sync_ldap_users_filter'] or block['user_filter']
-            user_filter = user_filter.replace('%s', '*')
-            attrs = cls.get_ldap_attributes_names(block)
-            users = cls.paged_search(conn, user_basedn, ldap.SCOPE_SUBTREE, user_filter,
-                                     attrlist=attrs)
-            backend = cls()
-            for user_dn, data in users:
+            for user_dn, data in cls.get_block_users(block):
                 # ignore referrals
                 if not user_dn:
                     continue
                 data = cls.normalize_ldap_results(data)
                 data['dn'] = user_dn
+                conn = cls.get_connection(block)
+                backend = cls()
                 yield backend._return_user(user_dn, None, conn, block, data)
 
     @classmethod
diff --git a/src/authentic2_auth_saml/__init__.py b/src/authentic2_auth_saml/__init__.py
index 5311a995..8089b0b7 100644
--- a/src/authentic2_auth_saml/__init__.py
+++ b/src/authentic2_auth_saml/__init__.py
@@ -7,7 +7,8 @@ class Plugin(object):
         return ['mellon', __name__]
 
     def get_authentication_backends(self):
-        return ['authentic2_auth_saml.backends.SAMLBackend']
+        return ['authentic2_auth_saml.backends.SAMLLdapBackend',
+                'authentic2_auth_saml.backends.SAMLBackend']
 
     def get_auth_frontends(self):
         return ['authentic2_auth_saml.auth_frontends.SAMLFrontend']
diff --git a/src/authentic2_auth_saml/app_ldap_saml_settings.py b/src/authentic2_auth_saml/app_ldap_saml_settings.py
new file mode 100644
index 00000000..8bdffff6
--- /dev/null
+++ b/src/authentic2_auth_saml/app_ldap_saml_settings.py
@@ -0,0 +1,31 @@
+
+class AppSettings(object):
+    '''Thanks django-allauth'''
+    __SENTINEL = object()
+
+    def __init__(self, prefix):
+        self.prefix = prefix
+
+    def _setting(self, name, dflt=__SENTINEL):
+        from django.conf import settings
+        from django.core.exceptions import ImproperlyConfigured
+
+        v = getattr(settings, self.prefix + name, dflt)
+        if v is self.__SENTINEL:
+            raise ImproperlyConfigured('Missing setting %r' % (self.prefix + name))
+        return v
+
+    @property
+    def enable(self):
+        return self._setting('ENABLE', False)
+
+    @property
+    def enable_condition(self):
+        return self._setting('ENABLE_CONDITION', None)
+
+
+import sys
+
+app_settings = AppSettings('A2_AUTH_SAML_LDAP')
+app_settings.__name__ = __name__
+sys.modules[__name__] = app_settings
diff --git a/src/authentic2_auth_saml/app_settings.py b/src/authentic2_auth_saml/app_settings.py
index f4034a0b..758e1879 100644
--- a/src/authentic2_auth_saml/app_settings.py
+++ b/src/authentic2_auth_saml/app_settings.py
@@ -19,6 +19,10 @@ class AppSettings(object):
     def enable(self):
         return self._setting('ENABLE', False)
 
+    @property
+    def ldap_enable(self):
+        return self._setting('LDAP_ENABLE', False)
+
 
 import sys
 
diff --git a/src/authentic2_auth_saml/backends.py b/src/authentic2_auth_saml/backends.py
index 02d9e6aa..a2e93236 100644
--- a/src/authentic2_auth_saml/backends.py
+++ b/src/authentic2_auth_saml/backends.py
@@ -1,6 +1,11 @@
+import re
+
+from django.conf import settings
+
 from mellon.backends import SAMLBackend
 
 from authentic2.middleware import StoreRequestMiddleware
+from authentic2.backends.ldap_backend import LDAPBackend
 
 from . import app_settings
 
@@ -22,3 +27,38 @@ class SAMLBackend(SAMLBackend):
 
         import lasso
         return lasso.SAML2_AUTHN_CONTEXT_PREVIOUS_SESSION
+
+
+class SAMLLdapBackend(SAMLBackend):
+
+    def authenticate(self, saml_attributes, **credentials):
+        if not app_settings.ldap_enable:
+            return None
+        if not getattr(settings, 'LDAP_AUTH_SETTINGS', []):
+            return None
+        no_ldap_for_entity_id = True
+        for block in settings.LDAP_AUTH_SETTINGS:
+            if block.get('entity_id', '') == saml_attributes['issuer']:
+                no_ldap_for_entity_id = False
+                break
+        if no_ldap_for_entity_id:
+            return None
+
+        user_filter = block['sync_ldap_users_filter'] or block['user_filter']
+        args = []
+        # get user filter attribute names
+        for group in re.findall('\w+=%\w+', user_filter):
+            filter_name, filter_value = group.split('=')
+            args.append(saml_attributes[filter_name][0])
+        user_filter = user_filter % tuple(args)
+
+        users = list(LDAPBackend.get_block_users(block, user_filter))
+        if users:
+            user_dn, data = users[0]
+            conn = LDAPBackend.get_connection(block)
+            backend = LDAPBackend()
+            data = backend.normalize_ldap_results(data)
+            data['dn'] = user_dn
+            return backend._return_user(user_dn, None, conn, block, data)
+        else:
+            return None
diff --git a/tests/test_auth_saml.py b/tests/test_auth_saml.py
index cd9618e8..66d51987 100644
--- a/tests/test_auth_saml.py
+++ b/tests/test_auth_saml.py
@@ -1,6 +1,11 @@
 import pytest
+import mock
+from pprint import pprint
 
 from django.contrib.auth import get_user_model
+from django.utils.timezone import datetime
+from django.test.utils import override_settings
+
 from authentic2.models import Attribute
 
 pytestmark = pytest.mark.django_db
@@ -38,3 +43,64 @@ def test_provision_attributes():
     del saml_attributes['mail']
     with pytest.raises(ValueError):
         adapter.finish_create_user(idp, saml_attributes, user)
+
+
+@mock.patch('authentic2.backends.ldap_backend.LDAPBackend.get_block_users')
+@mock.patch('authentic2.backends.ldap_backend.LDAPBackend.get_connection')
+@mock.patch('authentic2.backends.ldap_backend.LDAPBackend._return_user')
+def test_disabled_saml_ldap_backend(ldap_return_user, ldap_connection, ldap_block_users):
+    from authentic2_auth_saml.backends import SAMLLdapBackend
+    backend = SAMLLdapBackend()
+    saml_attributes = {}
+    assert backend.authenticate(saml_attributes) == None
+    with override_settings(A2_AUTH_SAML_LDAP_ENABLE=True, LDAP_AUTH_SETTINGS=[]):
+        assert backend.authenticate(saml_attributes) == None
+
+
+    LDAP_SETTINGS = {
+        "realm": "example.com",
+        "url": "ldaps://ldap.example.com/",
+        "basedn": "ou=people,o=example,o=com",
+        "user_filter": "(|(mail=%s)(uid=%s))",
+        "sync_ldap_users_filter": "",
+        "username_template": "{uid[0]}",
+        "attributes": [ "uid" ],
+        "set_mandatory_groups": ["LDAP Entrouvert"],
+        "timeout": 3,
+        "use_tls": False,
+        'entity_id': 'https://some-idp.com/idp/saml2/metadata',
+        'global_ldap_options': {},
+    }
+
+    saml_attributes = {'username': [u'foo'], 'first_name': [u'Foo'],
+                'last_name': [u'Bar'], 'uid': [u'foobar'],
+                'name_id_name_qualifier': u'https://some-idp.com/idp/saml2/metadata',
+                'authn_instant': datetime(2019, 1, 30, 11, 12, 40),
+                'name_id_format': u'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified',
+                'name_id_content': u'9f160fa0eb6045e5a5257a34fb4651e0',
+                'mail': [u'foo@example.com'],
+                'issuer': 'https://some-idp.com/idp/saml2/metadata'
+    }
+
+    with override_settings(A2_AUTH_SAML_LDAP_ENABLE=True, LDAP_AUTH_SETTINGS=[LDAP_SETTINGS]):
+        ldap_block_users.return_value = []
+        backend.authenticate(saml_attributes)
+        assert ldap_block_users.call_args[0][1] == '(|(mail=foo@example.com)(uid=foobar))'
+        ldap_connection.assert_not_called()
+        user_dn = 'uid=foobar,ou=people,o=example,o=com'
+        user_data = {'mail': ['foo@example.com'], 'givenName': ['Foo'],
+                     'uid': ['foobar'], 'sn': ['Bar']}
+        ldap_block_users.return_value = [(user_dn, user_data)]
+        ldap_return_user.return_value = None
+
+        backend.authenticate(saml_attributes)
+        assert ldap_block_users.call_args[0][1] == '(|(mail=foo@example.com)(uid=foobar))'
+        assert ldap_connection.call_args[0][0] == LDAP_SETTINGS
+        assert ldap_return_user.call_args[0][0] == user_dn
+        assert ldap_return_user.call_args[0][3] == LDAP_SETTINGS
+        data = ldap_return_user.call_args[0][4]
+        assert data['dn'] == user_dn
+        assert data['mail'] == user_data['mail']
+        assert data['givenname'] == user_data['givenName']
+        assert data['uid'] == user_data['uid']
+        assert data['sn'] == user_data['sn']
-- 
2.20.1

