[ldapfeed] port to ldap3
authorJulien Cristau <julien.cristau@logilab.fr>
Mon, 05 Oct 2015 14:51:24 +0200
changeset 10766 d730f91251af
parent 10765 bd2e3c1d1fed
child 10767 7ec3ca800a06
[ldapfeed] port to ldap3 python-ldap has no python3 support. XXX I only checked that unittest_ldapfeed passes.
server/sources/ldapfeed.py
--- a/server/sources/ldapfeed.py	Fri Oct 02 14:13:26 2015 +0200
+++ b/server/sources/ldapfeed.py	Mon Oct 05 14:51:24 2015 +0200
@@ -17,16 +17,13 @@
 # with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
 """cubicweb ldap feed source"""
 
-from __future__ import division # XXX why?
+from __future__ import division  # XXX why?
 
 from datetime import datetime
 
 from six import string_types
 
-import ldap
-from ldap.ldapobject import ReconnectLDAPObject
-from ldap.filter import filter_format
-from ldapurl import LDAPUrl
+import ldap3
 
 from logilab.common.configuration import merge_options
 
@@ -37,12 +34,12 @@
 from cubicweb import _
 
 # search scopes
-BASE = ldap.SCOPE_BASE
-ONELEVEL = ldap.SCOPE_ONELEVEL
-SUBTREE = ldap.SCOPE_SUBTREE
-LDAP_SCOPES = {'BASE': ldap.SCOPE_BASE,
-               'ONELEVEL': ldap.SCOPE_ONELEVEL,
-               'SUBTREE': ldap.SCOPE_SUBTREE}
+BASE = ldap3.SEARCH_SCOPE_BASE_OBJECT
+ONELEVEL = ldap3.SEARCH_SCOPE_SINGLE_LEVEL
+SUBTREE = ldap3.SEARCH_SCOPE_WHOLE_SUBTREE
+LDAP_SCOPES = {'BASE': BASE,
+               'ONELEVEL': ONELEVEL,
+               'SUBTREE': SUBTREE}
 
 # map ldap protocol to their standard port
 PROTO_PORT = {'ldap': 389,
@@ -51,6 +48,15 @@
               }
 
 
+def replace_filter(s):
+    s = s.replace('*', '\\2A')
+    s = s.replace('(', '\\28')
+    s = s.replace(')', '\\29')
+    s = s.replace('\\', '\\5c')
+    s = s.replace('\0', '\\00')
+    return s
+
+
 class LDAPFeedSource(datafeed.DataFeedSource):
     """LDAP feed source: unlike ldapuser source, this source is copy based and
     will import ldap content (beside passwords for authentication) into the
@@ -63,7 +69,7 @@
         ('auth-mode',
          {'type' : 'choice',
           'default': 'simple',
-          'choices': ('simple', 'cram_md5', 'digest_md5', 'gssapi'),
+          'choices': ('simple', 'digest_md5', 'gssapi'),
           'help': 'authentication mode used to authenticate user to the ldap.',
           'group': 'ldap-source', 'level': 3,
           }),
@@ -186,7 +192,7 @@
         self.user_attrs = {'dn': 'eid', 'modifyTimestamp': 'modification_date'}
         self.user_attrs.update(typedconfig['user-attrs-map'])
         self.user_rev_attrs = dict((v, k) for k, v in self.user_attrs.items())
-        self.base_filters = [filter_format('(%s=%s)', ('objectClass', o))
+        self.base_filters = ['(objectclass=%s)' % replace_filter(o)
                              for o in typedconfig['user-classes']]
         if typedconfig['user-filter']:
             self.base_filters.append(typedconfig['user-filter'])
@@ -196,7 +202,7 @@
         self.group_attrs = {'dn': 'eid', 'modifyTimestamp': 'modification_date'}
         self.group_attrs.update(typedconfig['group-attrs-map'])
         self.group_rev_attrs = dict((v, k) for k, v in self.group_attrs.items())
-        self.group_base_filters = [filter_format('(%s=%s)', ('objectClass', o))
+        self.group_base_filters = ['(objectClass=%s)' % replace_filter(o)
                                    for o in typedconfig['group-classes']]
         if typedconfig['group-filter']:
             self.group_base_filters.append(typedconfig['group-filter'])
@@ -217,9 +223,11 @@
     def connection_info(self):
         assert len(self.urls) == 1, self.urls
         protocol, hostport = self.urls[0].split('://')
-        if protocol != 'ldapi' and not ':' in hostport:
-            hostport = '%s:%s' % (hostport, PROTO_PORT[protocol])
-        return protocol, hostport
+        if protocol != 'ldapi' and ':' in hostport:
+            host, port = hostport.rsplit(':', 1)
+        else:
+            host, port = hostport, PROTO_PORT[protocol]
+        return protocol, host, port
 
     def authenticate(self, cnx, login, password=None, **kwargs):
         """return CWUser eid for the given login/password if this account is
@@ -234,20 +242,20 @@
             # You get Authenticated as: 'NT AUTHORITY\ANONYMOUS LOGON'.
             # we really really don't want that
             raise AuthenticationError()
-        searchfilter = [filter_format('(%s=%s)', (self.user_login_attr, login))]
+        searchfilter = ['(%s=%s)' % (replace_filter(self.user_login_attr), replace_filter(login))]
         searchfilter.extend(self.base_filters)
         searchstr = '(&%s)' % ''.join(searchfilter)
         # first search the user
         try:
             user = self._search(cnx, self.user_base_dn,
                                 self.user_base_scope, searchstr)[0]
-        except (IndexError, ldap.SERVER_DOWN):
+        except IndexError:
             # no such user
             raise AuthenticationError()
         # check password by establishing a (unused) connection
         try:
             self._connect(user, password)
-        except ldap.LDAPError as ex:
+        except ldap3.LDAPException as ex:
             # Something went wrong, most likely bad credentials
             self.info('while trying to authenticate %s: %s', user, ex)
             raise AuthenticationError()
@@ -261,32 +269,16 @@
         return eid
 
     def _connect(self, user=None, userpwd=None):
-        protocol, hostport = self.connection_info()
-        self.info('connecting %s://%s as %s', protocol, hostport,
+        protocol, host, port = self.connection_info()
+        self.info('connecting %s://%s:%s as %s', protocol, host, port,
                   user and user['dn'] or 'anonymous')
-        # don't require server certificate when using ldaps (will
-        # enable self signed certs)
-        ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
-        url = LDAPUrl(urlscheme=protocol, hostport=hostport)
-        conn = ReconnectLDAPObject(url.initializeUrl())
-        # Set the protocol version - version 3 is preferred
-        try:
-            conn.set_option(ldap.OPT_PROTOCOL_VERSION, ldap.VERSION3)
-        except ldap.LDAPError: # Invalid protocol version, fall back safely
-            conn.set_option(ldap.OPT_PROTOCOL_VERSION, ldap.VERSION2)
-        # Deny auto-chasing of referrals to be safe, we handle them instead
-        # Required for AD
-        try:
-            conn.set_option(ldap.OPT_REFERRALS, 0)
-        except ldap.LDAPError: # Cannot set referrals, so do nothing
-            pass
-        #conn.set_option(ldap.OPT_NETWORK_TIMEOUT, conn_timeout)
-        #conn.timeout = op_timeout
+        server = ldap3.Server(host, port=int(port))
+        conn = ldap3.Connection(server, user=user and user['dn'], client_strategy=ldap3.STRATEGY_SYNC_RESTARTABLE, auto_referrals=False)
         # Now bind with the credentials given. Let exceptions propagate out.
         if user is None:
             # XXX always use simple bind for data connection
             if not self.cnx_dn:
-                conn.simple_bind_s(self.cnx_dn, self.cnx_pwd)
+                conn.bind()
             else:
                 self._authenticate(conn, {'dn': self.cnx_dn}, self.cnx_pwd)
         else:
@@ -296,25 +288,22 @@
         return conn
 
     def _auth_simple(self, conn, user, userpwd):
-        conn.simple_bind_s(user['dn'], userpwd)
-
-    def _auth_cram_md5(self, conn, user, userpwd):
-        from ldap import sasl
-        auth_token = sasl.cram_md5(user['dn'], userpwd)
-        conn.sasl_interactive_bind_s('', auth_token)
+        conn.authentication = ldap3.AUTH_SIMPLE
+        conn.user = user['dn']
+        conn.password = userpwd
+        conn.bind()
 
     def _auth_digest_md5(self, conn, user, userpwd):
-        from ldap import sasl
-        auth_token = sasl.digest_md5(user['dn'], userpwd)
-        conn.sasl_interactive_bind_s('', auth_token)
+        conn.authentication = ldap3.AUTH_SASL
+        conn.sasl_mechanism = 'DIGEST-MD5'
+        # realm, user, password, authz-id
+        conn.sasl_credentials = (None, user['dn'], userpwd, None)
+        conn.bind()
 
     def _auth_gssapi(self, conn, user, userpwd):
-        # print XXX not proper sasl/gssapi
-        import kerberos
-        if not kerberos.checkPassword(user[self.user_login_attr], userpwd):
-            raise Exception('BAD login / mdp')
-        #from ldap import sasl
-        #conn.sasl_interactive_bind_s('', sasl.gssapi())
+        conn.authentication = ldap3.AUTH_SASL
+        conn.sasl_mechanism = 'GSSAPI'
+        conn.bind()
 
     def _search(self, cnx, base, scope,
                 searchstr='(objectClass=*)', attrs=()):
@@ -324,37 +313,15 @@
         if self._conn is None:
             self._conn = self._connect()
         ldapcnx = self._conn
-        try:
-            res = ldapcnx.search_s(base, scope, searchstr, attrs)
-        except ldap.PARTIAL_RESULTS:
-            res = ldapcnx.result(all=0)[1]
-        except ldap.NO_SUCH_OBJECT:
-            self.info('ldap NO SUCH OBJECT %s %s %s', base, scope, searchstr)
-            self._process_no_such_object(cnx, base)
+        if not ldapcnx.search(base, searchstr, search_scope=scope, attributes=attrs):
             return []
-        # except ldap.REFERRAL as e:
-        #     ldapcnx = self.handle_referral(e)
-        #     try:
-        #         res = ldapcnx.search_s(base, scope, searchstr, attrs)
-        #     except ldap.PARTIAL_RESULTS:
-        #         res_type, res = ldapcnx.result(all=0)
         result = []
-        for rec_dn, rec_dict in res:
-            # When used against Active Directory, "rec_dict" may not be
-            # be a dictionary in some cases (instead, it can be a list)
-            #
-            # An example of a useless "res" entry that can be ignored
-            # from AD is
-            # (None, ['ldap://ForestDnsZones.PORTAL.LOCAL/DC=ForestDnsZones,DC=PORTAL,DC=LOCAL'])
-            # This appears to be some sort of internal referral, but
-            # we can't handle it, so we need to skip over it.
-            try:
-                items = rec_dict.items()
-            except AttributeError:
+        for rec in ldapcnx.response:
+            if rec['type'] != 'searchResEntry':
                 continue
-            else:
-                itemdict = self._process_ldap_item(rec_dn, items)
-                result.append(itemdict)
+            items = rec['attributes'].items()
+            itemdict = self._process_ldap_item(rec['dn'], items)
+            result.append(itemdict)
         self.debug('ldap built results %s', len(result))
         return result