From f2209bc423d106e78ed65cae9342a3dc739e9918 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Schneider?= <jschneider@entrouvert.com>
Date: Mon, 15 Sep 2014 10:48:46 +0200
Subject: [PATCH] backends: implement a new ldap storage backend

---
 ldap/mandaye.schema                            |  93 +++++++++++++++++
 mandaye/backends/default.py                    |   5 +
 mandaye/backends/ldap_back.py                  | 137 +++++++++++++++++++++++++
 mandaye/global_config.py                       |   9 +-
 mandaye/skel/example.module/config.py          |  13 ++-
 mandaye/skel/example.module/default-config.ini |  10 +-
 6 files changed, 261 insertions(+), 6 deletions(-)
 create mode 100644 ldap/mandaye.schema
 create mode 100644 mandaye/backends/ldap_back.py

diff --git a/ldap/mandaye.schema b/ldap/mandaye.schema
new file mode 100644
index 0000000..7cb911a
--- /dev/null
+++ b/ldap/mandaye.schema
@@ -0,0 +1,93 @@
+# vim:et:sw=4
+#
+# CUD Schema
+#
+# Date : 20140908
+# Révision :
+#  * 20140908 - Jérôme Schneider <jschneider@entrouvert.com> - Création initiale
+#
+
+objectIdentifier EoRoot                      1.3.6.1.4.1.36560
+objectIdentifier EoClientRoot                EoRoot:3
+objectIdentifier MandayeRoot                 EoClientRoot:1127
+objectIdentifier MandayeLdap                 MandayeRoot:1
+objectIdentifier MandayeLdapAttributes       MandayeLdap:1
+objectIdentifier MandayeLdapObjectClass      MandayeLdap:2
+
+objectIdentifier Int                   1.3.6.1.4.1.1466.115.121.1.27
+objectIdentifier UTF8                  1.3.6.1.4.1.1466.115.121.1.15
+objectIdentifier Boolean               1.3.6.1.4.1.1466.115.121.1.7
+objectIdentifier Binary                1.3.6.1.4.1.1466.115.121.1.40
+objectIdentifier DateTime              1.3.6.1.4.1.1466.115.121.1.24
+objectIdentifier IA5String             1.3.6.1.4.1.1466.115.121.1.26
+
+attributetype (MandayeLdapAttributes:1
+  NAME 'uniqueID'
+  DESC 'Mandaye user unique identifier'
+  EQUALITY caseIgnoreMatch
+  SYNTAX UTF8
+  SINGLE-VALUE )
+
+attributetype (MandayeLdapAttributes:2
+  NAME 'idpUniqueID'
+  DESC 'IDP unique id (like NameID)'
+  EQUALITY caseIgnoreMatch
+  SUBSTR caseIgnoreSubstringsMatch
+  SYNTAX UTF8
+  SINGLE-VALUE )
+
+attributetype (MandayeLdapAttributes:3
+  NAME 'idpName'
+  DESC 'Name of the idp'
+  EQUALITY caseIgnoreMatch
+  SYNTAX UTF8
+  SINGLE-VALUE )
+
+attributetype (MandayeLdapAttributes:4
+  NAME 'spLogin'
+  DESC 'SP login'
+  EQUALITY caseIgnoreMatch
+  SYNTAX UTF8
+  SINGLE-VALUE )
+
+attributetype (MandayeLdapAttributes:5
+   NAME 'spPostValues'
+   DESC 'SP post values'
+   EQUALITY caseIgnoreMatch
+   SYNTAX UTF8
+   SINGLE-VALUE )
+
+attributetype (MandayeLdapAttributes:6
+   NAME 'spName'
+   DESC 'name of the service provider'
+   EQUALITY caseIgnoreMatch
+   SYNTAX UTF8
+   SINGLE-VALUE )
+
+attributetype (MandayeLdapAttributes:7
+   NAME 'creationDate'
+   DESC 'creation date of the association (ISO8601 format)'
+   EQUALITY generalizedTimeMatch
+   SYNTAX DateTime
+   SINGLE-VALUE )
+
+attributetype (MandayeLdapAttributes:8
+   NAME 'lastConnectionDate'
+   DESC 'Last connection date ISO8601'
+   EQUALITY generalizedTimeMatch
+   ORDERING generalizedTimeOrderingMatch
+   SYNTAX DateTime
+   SINGLE-VALUE )
+
+#
+# Classes d'objets:
+#
+
+objectclass (MandayeLdapObjectClass:1
+  NAME 'MandayeUser'
+  DESC 'Mandaye user Objectclass'
+  SUP top
+  STRUCTURAL
+  MUST ( uniqueID $ idpUniqueId $ idpName $ spLogin $ spPostValues $ creationDate $ spName )
+  MAY ( lastConnectionDate ) )
+
diff --git a/mandaye/backends/default.py b/mandaye/backends/default.py
index f9ede95..f312e10 100644
--- a/mandaye/backends/default.py
+++ b/mandaye/backends/default.py
@@ -28,6 +28,11 @@ if config.storage_backend == "mandaye.backends.sql":
                     bind=create_engine(config.db_url)
                     )
                 )
+elif config.storage_backend == "mandaye.backends.ldap_back":
+    import ldap
+    storage_conn = ldap.initialize(config.ldap_url)
+    storage_conn.protocol_version = ldap.VERSION3
+    storage_conn.simple_bind(config.ldap_bind_dn, config.ldap_bind_password)
 
 backend = import_backend(config.storage_backend)
 Association = backend.Association
diff --git a/mandaye/backends/ldap_back.py b/mandaye/backends/ldap_back.py
new file mode 100644
index 0000000..4720a37
--- /dev/null
+++ b/mandaye/backends/ldap_back.py
@@ -0,0 +1,137 @@
+
+import datetime
+import ldap
+import ldap.modlist
+import random
+
+from mandaye import config
+from mandaye.log import logger
+from mandaye.backends.default import storage_conn
+
+class Association(object):
+    """
+    association dictionary return by the following methods:
+    {
+        'id': '', # identifier of your association (must be unique)
+        'sp_name': '', # name of the service provider (defined in the mappers)
+        'sp_login': '', # login on the service provider
+        'sp_post_values': '', # the post values for sp login form
+        'idp_unique_id:': '', # the unique identifier of the identity provider (ex.: a saml NameID)
+        'idp_name':  '', # identity provide name
+        'last_connection':  datetime.datetime, # last connection with this association
+        'creation_date':  datetime.datetime, # creation date of this association
+    }
+    """
+
+    @staticmethod
+    def ldap2association(ldap_object):
+        return {
+                'id': ldap_object['uniqueID'][0],
+                'sp_name': ldap_object['spName'][0],
+                'sp_login': ldap_object['spLogin'][0],
+                'sp_post_values': ldap_object['spPostValues'][0],
+                'idp_unique_id': ldap_object['idpUniqueID'][0],
+                'idp_name': ldap_object['idpName'][0],
+                'last_connection': datetime.datetime.strptime(
+                    ldap_object['lastConnectionDate'][0][:14],
+                    '%Y%m%d%H%M%S'),
+                'creation_date': datetime.datetime.strptime(
+                    ldap_object['creationDate'][0][:14],
+                    '%Y%m%d%H%M%S'),
+                }
+
+    @staticmethod
+    def get(sp_name, idp_unique_id, idp_name='default'):
+        """ return a list of dict with associations matching all of this options """
+        associations = []
+        results = storage_conn.search_s(config.ldap_base_dn, ldap.SCOPE_ONELEVEL,
+                filterstr='(&(objectClass=MandayeUser)(spName=%s)(idpUniqueID=%s)(idpName=%s))' % (sp_name, idp_unique_id, idp_name))
+        for result in results:
+            associations.append(Association.ldap2association(result[1]))
+        return associations
+
+
+    @staticmethod
+    def get_by_id(asso_id):
+        """ return an dict of the association with the id or None if it doesn't exist """
+        results = storage_conn.search_s(config.ldap_base_dn, ldap.SCOPE_ONELEVEL,
+                filterstr='(&(objectClass=MandayeUser)(uniqueID=%s))' %\
+                        (asso_id))
+        if results:
+            return Association.ldap2association(results[0][1])
+        return None
+
+    @staticmethod
+    def has_id(asso_id):
+        """ check the given user is present in the directory """
+        results = storage_conn.search_s(config.ldap_base_dn, ldap.SCOPE_ONELEVEL,
+                filterstr='(&(objectClass=MandayeUser)(uniqueID=%s))' %\
+                        (asso_id))
+        if results:
+            return True
+        return False
+
+    @staticmethod
+    def update_or_create(sp_name, sp_login, sp_post_values, idp_unique_id, idp_name='default'):
+        """ update or create an associtaion which match the following values
+        return the association id
+        """
+        results = storage_conn.search_s(config.ldap_base_dn, ldap.SCOPE_ONELEVEL,
+                filterstr='(&(objectClass=MandayeUser)(spName=%s)(spLogin=%s)(idpUniqueID=%s)(idpName=%s))' %\
+                        (sp_name, sp_login, idp_unique_id, idp_name))
+        if not results:
+            association = {'spName': sp_name,
+                    'spLogin': sp_login,
+                    'spPostValues': sp_post_values,
+                    'idpUniqueID': idp_unique_id,
+                    'idpName': idp_name,
+                    'creationDate': datetime.datetime.utcnow().strftime('%Y%m%d%H%M%SZ'),
+                    'lastConnectionDate': datetime.datetime.utcnow().strftime('%Y%m%d%H%M%SZ'),
+                    'objectClass': 'MandayeUser'
+                    }
+            mod_list = ldap.modlist.addModlist(association)
+            while True:
+                unique_id = random.randint(1, 5000000)
+                dn = "uniqueID=%s,%s" % (unique_id, config.ldap_base_dn)
+                try:
+                    result = storage_conn.add_s(dn, mod_list)
+                except ldap.ALREADY_EXISTS:
+                    continue
+                break
+            logger.info("New association %r with %r", sp_login, idp_unique_id)
+            return unique_id
+        else:
+            dn = results[0][0]
+            mod_list = [(ldap.MOD_REPLACE, 'spPostValues', sp_post_values)]
+            storage_conn.modify_s(dn, mod_list)
+            logger.info("Update post values for %r (%r)", sp_login, idp_unique_id)
+        return results[0][1]['uniqueID'][0]
+
+    @staticmethod
+    def delete(asso_id):
+        """ delete the association which has the following asso_id """
+        dn = "uniqueID=%s,%s" % (asso_id, config.ldap_base_dn)
+        storage_conn.delete_s(dn)
+        logger.info('Delete %r association', dn)
+
+    @staticmethod
+    def get_last_connected(sp_name, idp_unique_id, idp_name='default'):
+        """ get the last connecting association which match the parameters
+        return a dict of the association
+        """
+        results = storage_conn.search_s(config.ldap_base_dn, ldap.SCOPE_ONELEVEL,
+                filterstr='(&(objectClass=MandayeUser)(spName=%s)(idpUniqueID=%s)(idpName=%s))' % (sp_name, idp_unique_id, idp_name))
+        if results:
+            return Association.ldap2association(results[0][1])
+        return None
+
+    @staticmethod
+    def update_last_connection(asso_id):
+        """ update the association last connection time with the current time
+        return a dict of the association
+        """
+        last_connection = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%SZ")
+        dn = "uniqueID=%s,%s" % (asso_id, config.ldap_base_dn)
+        mod_list = [(ldap.MOD_REPLACE, 'lastConnectionDate', last_connection)]
+        storage_conn.modify_s(dn, mod_list)
+
diff --git a/mandaye/global_config.py b/mandaye/global_config.py
index 3c0cac7..6080777 100644
--- a/mandaye/global_config.py
+++ b/mandaye/global_config.py
@@ -4,16 +4,21 @@ import os
 _PROJECT_PATH = os.path.join(os.path.dirname(__file__), '..')
 
 # Choose storage
-# Only sql at the moment
+# mandaye.backends.ldap_back or mandaye.backends.sql
 storage_backend = "mandaye.backends.sql"
 
 ## SQL Backend config
 # Database configuration
-# http://docs.sqlalchemy.org/en/rel_0_7/core/engines.html
+# http://docs.sqlalchemy.org/en/rel_0_8/core/engines.html
 # rfc 1738 https://tools.ietf.org/html/rfc1738
 # dialect+driver://username:password@host:port/database
 db_url = 'sqlite:///test.db'
 
+## LDAP Backend config
+ldap_url = 'ldap://127.0.0.1'
+ldap_bind_dn = 'cn=admin,dc=acompany,dc=org'
+ldap_bind_password = 'MyPassword'
+ldap_base_dn = 'ou=mandaye,dc=acompany,dc=org'
 
 # urllib2 debug mode
 debug = False
diff --git a/mandaye/skel/example.module/config.py b/mandaye/skel/example.module/config.py
index cc384fd..68ba259 100644
--- a/mandaye/skel/example.module/config.py
+++ b/mandaye/skel/example.module/config.py
@@ -30,6 +30,12 @@ config.read(SETTINGS_INI)
 # dialect+driver://username:password@host:port/database
 db_url = config.get('database', 'url')
 
+## LDAP Backend config
+ldap_url = config.get('ldap', 'url')
+ldap_bind_dn = config.get('ldap', 'base_dn')
+ldap_bind_password = config.get('ldap', 'bind_password')
+ldap_base_dn = config.get('ldap', 'base_dn')
+
 debug = config.getboolean('debug', 'debug')
 
 # Log configuration
@@ -137,12 +143,13 @@ mandaye_offline_toolbar = config.getboolean('mandaye', 'offline_toolbar')
 # Authentic 2 auto connection
 a2_auto_connection = config.getboolean('mandaye', 'a2_auto_connection')
 
-# Choose storage
-# Only mandaye.backends.sql at the moment
+# Choose storage (sql or ldap)
 if config.get('mandaye', 'storage_backend') == 'sql':
     storage_backend = "mandaye.backends.sql"
+elif config.get('mandaye', 'storage_backend') == 'ldap':
+    storage_backend = "mandaye.backends.ldap_back"
 else:
-    ImproperlyConfigured('Storage backend must be sql')
+    ImproperlyConfigured('Storage backend must be sql or ldap')
 
 # Encrypt service provider passwords with a secret
 # You should install pycypto to use this feature
diff --git a/mandaye/skel/example.module/default-config.ini b/mandaye/skel/example.module/default-config.ini
index d0fe8b1..5be6b17 100644
--- a/mandaye/skel/example.module/default-config.ini
+++ b/mandaye/skel/example.module/default-config.ini
@@ -2,9 +2,17 @@
 base_dir: .
 
 [database]
+; use by sql backend
 ; http://docs.sqlalchemy.org/en/rel_0_8/core/engines.html
 url: sqlite:///%(base_dir)s/{project_name}.db
 
+[ldap]
+; use by ldap backend
+url: ldap://127.0.0.1
+bind_dn: cn=admin,dc=acompany,dc=org
+bind_password: AdminPassword
+base_dn: ou=mandaye,dc=acompany,dc=org
+
 [dirs]
 config_root: %(base_dir)s/conf.d
 data_dir: %(base_dir)s/data
@@ -23,7 +31,7 @@ sentry_dsn:
 toolbar: true
 offline_toolbar: true
 a2_auto_connection: false
-; only sql at the moment
+; sql or ldap
 storage_backend: sql
 auto_decompress: true
 ; if you want to encypt password set to true
-- 
2.1.0

