server/sources/ldapuser.py
changeset 8188 1867e252e487
parent 7884 35d2e2f4e10a
child 8239 c6cdd060212e
--- a/server/sources/ldapuser.py	Thu Feb 02 14:30:07 2012 +0100
+++ b/server/sources/ldapuser.py	Tue Jan 31 21:43:24 2012 +0100
@@ -1,4 +1,4 @@
-# copyright 2003-2011 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
+# copyright 2003-2012 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
 # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
 #
 # This file is part of CubicWeb.
@@ -18,33 +18,20 @@
 """cubicweb ldap user source
 
 this source is for now limited to a read-only CWUser source
-
-Part of the code is coming form Zope's LDAPUserFolder
-
-Copyright (c) 2004 Jens Vagelpohl.
-All Rights Reserved.
-
-This software is subject to the provisions of the Zope Public License,
-Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
-THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
-WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
-WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
-FOR A PARTICULAR PURPOSE.
 """
 from __future__ import division
 from base64 import b64decode
 
 import ldap
-from ldap.ldapobject import ReconnectLDAPObject
-from ldap.filter import filter_format, escape_filter_chars
-from ldapurl import LDAPUrl
+from ldap.filter import escape_filter_chars
 
 from rql.nodes import Relation, VariableRef, Constant, Function
 
-from cubicweb import AuthenticationError, UnknownEid, RepositoryError
+from cubicweb import UnknownEid, RepositoryError
+from cubicweb.server import ldaputils
 from cubicweb.server.utils import cartesian_product
 from cubicweb.server.sources import (AbstractSource, TrFunc, GlobTrFunc,
-                                     ConnectionWrapper, TimedCache)
+                                     TimedCache)
 
 # search scopes
 BASE = ldap.SCOPE_BASE
@@ -58,97 +45,11 @@
               }
 
 
-class LDAPUserSource(AbstractSource):
+class LDAPUserSource(ldaputils.LDAPSourceMixIn, AbstractSource):
     """LDAP read-only CWUser source"""
     support_entities = {'CWUser': False}
 
-    options = (
-        ('host',
-         {'type' : 'string',
-          'default': 'ldap',
-          'help': 'ldap host. It may contains port information using \
-<host>:<port> notation.',
-          'group': 'ldap-source', 'level': 1,
-          }),
-        ('protocol',
-         {'type' : 'choice',
-          'default': 'ldap',
-          'choices': ('ldap', 'ldaps', 'ldapi'),
-          'help': 'ldap protocol (allowed values: ldap, ldaps, ldapi)',
-          'group': 'ldap-source', 'level': 1,
-          }),
-        ('auth-mode',
-         {'type' : 'choice',
-          'default': 'simple',
-          'choices': ('simple', 'cram_md5', 'digest_md5', 'gssapi'),
-          'help': 'authentication mode used to authenticate user to the ldap.',
-          'group': 'ldap-source', 'level': 3,
-          }),
-        ('auth-realm',
-         {'type' : 'string',
-          'default': None,
-          'help': 'realm to use when using gssapi/kerberos authentication.',
-          'group': 'ldap-source', 'level': 3,
-          }),
-
-        ('data-cnx-dn',
-         {'type' : 'string',
-          'default': '',
-          'help': 'user dn to use to open data connection to the ldap (eg used \
-to respond to rql queries). Leave empty for anonymous bind',
-          'group': 'ldap-source', 'level': 1,
-          }),
-        ('data-cnx-password',
-         {'type' : 'string',
-          'default': '',
-          'help': 'password to use to open data connection to the ldap (eg used to respond to rql queries). Leave empty for anonymous bind.',
-          'group': 'ldap-source', 'level': 1,
-          }),
-
-        ('user-base-dn',
-         {'type' : 'string',
-          'default': 'ou=People,dc=logilab,dc=fr',
-          'help': 'base DN to lookup for users',
-          'group': 'ldap-source', 'level': 1,
-          }),
-        ('user-scope',
-         {'type' : 'choice',
-          'default': 'ONELEVEL',
-          'choices': ('BASE', 'ONELEVEL', 'SUBTREE'),
-          'help': 'user search scope (valid values: "BASE", "ONELEVEL", "SUBTREE")',
-          'group': 'ldap-source', 'level': 1,
-          }),
-        ('user-classes',
-         {'type' : 'csv',
-          'default': ('top', 'posixAccount'),
-          'help': 'classes of user (with Active Directory, you want to say "user" here)',
-          'group': 'ldap-source', 'level': 1,
-          }),
-        ('user-filter',
-         {'type': 'string',
-          'default': '',
-          'help': 'additional filters to be set in the ldap query to find valid users',
-          'group': 'ldap-source', 'level': 2,
-          }),
-        ('user-login-attr',
-         {'type' : 'string',
-          'default': 'uid',
-          'help': 'attribute used as login on authentication (with Active Directory, you want to use "sAMAccountName" here)',
-          'group': 'ldap-source', 'level': 1,
-          }),
-        ('user-default-group',
-         {'type' : 'csv',
-          'default': ('users',),
-          'help': 'name of a group in which ldap users will be by default. \
-You can set multiple groups by separating them by a comma.',
-          'group': 'ldap-source', 'level': 1,
-          }),
-        ('user-attrs-map',
-         {'type' : 'named',
-          'default': {'uid': 'login', 'gecos': 'email'},
-          'help': 'map from ldap user attributes to cubicweb attributes (with Active Directory, you want to use sAMAccountName:login,mail:email,givenName:firstname,sn:surname)',
-          'group': 'ldap-source', 'level': 1,
-          }),
+    options = ldaputils.LDAPSourceMixIn.options + (
 
         ('synchronization-interval',
          {'type' : 'time',
@@ -168,35 +69,32 @@
 
     def __init__(self, repo, source_config, eid=None):
         AbstractSource.__init__(self, repo, source_config, eid)
-        self.update_config(None, self.check_conf_dict(eid, source_config))
-        self._conn = None
+        self.update_config(None, self.check_conf_dict(eid, source_config,
+                                                      fail_if_unknown=False))
+
+    def _entity_update(self, source_entity):
+        # XXX copy from datafeed source
+        if source_entity.url:
+            self.urls = [url.strip() for url in source_entity.url.splitlines()
+                         if url.strip()]
+        else:
+            self.urls = []
+        # /end XXX
+        ldaputils.LDAPSourceMixIn._entity_update(self, source_entity)
 
     def update_config(self, source_entity, typedconfig):
         """update configuration from source entity. `typedconfig` is config
         properly typed with defaults set
         """
-        self.host = typedconfig['host']
-        self.protocol = typedconfig['protocol']
-        self.authmode = typedconfig['auth-mode']
-        self._authenticate = getattr(self, '_auth_%s' % self.authmode)
-        self.cnx_dn = typedconfig['data-cnx-dn']
-        self.cnx_pwd = typedconfig['data-cnx-password']
-        self.user_base_dn = str(typedconfig['user-base-dn'])
-        self.user_base_scope = globals()[typedconfig['user-scope']]
-        self.user_login_attr = typedconfig['user-login-attr']
-        self.user_default_groups = typedconfig['user-default-group']
-        self.user_attrs = typedconfig['user-attrs-map']
-        self.user_rev_attrs = {'eid': 'dn'}
-        for ldapattr, cwattr in self.user_attrs.items():
-            self.user_rev_attrs[cwattr] = ldapattr
-        self.base_filters = [filter_format('(%s=%s)', ('objectClass', o))
-                             for o in typedconfig['user-classes']]
-        if typedconfig['user-filter']:
-            self.base_filters.append(typedconfig['user-filter'])
+        ldaputils.LDAPSourceMixIn.update_config(self, source_entity, typedconfig)
         self._interval = typedconfig['synchronization-interval']
         self._cache_ttl = max(71, typedconfig['cache-life-time'])
         self.reset_caches()
-        self._conn = None
+        # XXX copy from datafeed source
+        if source_entity is not None:
+            self._entity_update(source_entity)
+        self.config = typedconfig
+        # /end XXX
 
     def reset_caches(self):
         """method called during test to reset potential source caches"""
@@ -207,21 +105,24 @@
         """method called by the repository once ready to handle request"""
         if activated:
             self.info('ldap init')
+            self._entity_update(source_entity)
             # set minimum period of 5min 1s (the additional second is to
             # minimize resonnance effet)
-            self.repo.looping_task(max(301, self._interval), self.synchronize)
+            if self.user_rev_attrs['email']:
+                self.repo.looping_task(max(301, self._interval), self.synchronize)
             self.repo.looping_task(self._cache_ttl // 10,
                                    self._query_cache.clear_expired)
 
     def synchronize(self):
+        self.pull_data(self.repo.internal_session())
+
+    def pull_data(self, session, force=False, raise_on_error=False):
         """synchronize content known by this repository with content in the
         external repository
         """
         self.info('synchronizing ldap source %s', self.uri)
-        try:
-            ldap_emailattr = self.user_rev_attrs['email']
-        except KeyError:
-            return # no email in ldap, we're done
+        ldap_emailattr = self.user_rev_attrs['email']
+        assert ldap_emailattr
         session = self.repo.internal_session()
         execute = session.execute
         try:
@@ -268,54 +169,6 @@
             session.commit()
             session.close()
 
-    def get_connection(self):
-        """open and return a connection to the source"""
-        if self._conn is None:
-            try:
-                self._connect()
-            except Exception:
-                self.exception('unable to connect to ldap:')
-        return ConnectionWrapper(self._conn)
-
-    def authenticate(self, session, login, password=None, **kwargs):
-        """return CWUser eid for the given login/password if this account is
-        defined in this source, else raise `AuthenticationError`
-
-        two queries are needed since passwords are stored crypted, so we have
-        to fetch the salt first
-        """
-        self.info('ldap authenticate %s', login)
-        if not password:
-            # On Windows + ADAM this would have succeeded (!!!)
-            # 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.extend(self.base_filters)
-        searchstr = '(&%s)' % ''.join(searchfilter)
-        # first search the user
-        try:
-            user = self._search(session, self.user_base_dn,
-                                self.user_base_scope, searchstr)[0]
-        except IndexError:
-            # no such user
-            raise AuthenticationError()
-        # check password by establishing a (unused) connection
-        try:
-            self._connect(user, password)
-        except ldap.LDAPError, ex:
-            # Something went wrong, most likely bad credentials
-            self.info('while trying to authenticate %s: %s', user, ex)
-            raise AuthenticationError()
-        except Exception:
-            self.error('while trying to authenticate %s', user, exc_info=True)
-            raise AuthenticationError()
-        eid = self.repo.extid2eid(self, user['dn'], 'CWUser', session)
-        if eid < 0:
-            # user has been moved away from this source
-            raise AuthenticationError()
-        return eid
-
     def ldap_name(self, var):
         if var.stinfo['relations']:
             relname = iter(var.stinfo['relations']).next().r_type
@@ -459,127 +312,18 @@
         #print '--> ldap result', result
         return result
 
-
-    def _connect(self, user=None, userpwd=None):
-        if self.protocol == 'ldapi':
-            hostport = self.host
-        elif not ':' in self.host:
-            hostport = '%s:%s' % (self.host, PROTO_PORT[self.protocol])
-        else:
-            hostport = self.host
-        self.info('connecting %s://%s as %s', self.protocol, hostport,
-                  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=self.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
-        #try:
-        #    connection.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
-        # Now bind with the credentials given. Let exceptions propagate out.
-        if user is None:
-            # no user specified, we want to initialize the 'data' connection,
-            assert self._conn is None
-            self._conn = conn
-            # XXX always use simple bind for data connection
-            if not self.cnx_dn:
-                conn.simple_bind_s(self.cnx_dn, self.cnx_pwd)
-            else:
-                self._authenticate(conn, {'dn': self.cnx_dn}, self.cnx_pwd)
-        else:
-            # user specified, we want to check user/password, no need to return
-            # the connection which will be thrown out
-            self._authenticate(conn, user, userpwd)
-        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)
-
-    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)
+    def _process_ldap_item(self, dn, iterator):
+        itemdict = super(LDAPUserSource, self)._process_ldap_item(dn, iterator)
+        self._cache[dn] = itemdict
+        return itemdict
 
-    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())
-
-    def _search(self, session, base, scope,
-                searchstr='(objectClass=*)', attrs=()):
-        """make an ldap query"""
-        self.debug('ldap search %s %s %s %s %s', self.uri, base, scope,
-                   searchstr, list(attrs))
-        # XXX for now, we do not have connections set support for LDAP, so
-        # this is always self._conn
-        cnx = session.cnxset.connection(self.uri).cnx
-        try:
-            res = cnx.search_s(base, scope, searchstr, attrs)
-        except ldap.PARTIAL_RESULTS:
-            res = cnx.result(all=0)[1]
-        except ldap.NO_SUCH_OBJECT:
-            self.info('ldap NO SUCH OBJECT')
-            eid = self.repo.extid2eid(self, base, 'CWUser', session, insert=False)
-            if eid:
-                self.warning('deleting ldap user with eid %s and dn %s',
-                             eid, base)
-                entity = session.entity_from_eid(eid, 'CWUser')
-                self.repo.delete_info(session, entity, self.uri)
-                self.reset_caches()
-            return []
-        # except ldap.REFERRAL, e:
-        #     cnx = self.handle_referral(e)
-        #     try:
-        #         res = cnx.search_s(base, scope, searchstr, attrs)
-        #     except ldap.PARTIAL_RESULTS:
-        #         res_type, res = cnx.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:
-                # 'items' not found on rec_dict, skip
-                continue
-            for key, value in items: # XXX syt: huuum ?
-                if not isinstance(value, str):
-                    try:
-                        for i in range(len(value)):
-                            value[i] = unicode(value[i], 'utf8')
-                    except Exception:
-                        pass
-                if isinstance(value, list) and len(value) == 1:
-                    rec_dict[key] = value = value[0]
-            rec_dict['dn'] = rec_dn
-            self._cache[rec_dn] = rec_dict
-            result.append(rec_dict)
-        #print '--->', result
-        self.debug('ldap built results %s', len(result))
-        return result
+    def _process_no_such_object(self, session, dn):
+        eid = self.repo.extid2eid(self, dn, 'CWUser', session, insert=False)
+        if eid:
+            self.warning('deleting ldap user with eid %s and dn %s', eid, dn)
+            entity = session.entity_from_eid(eid, 'CWUser')
+            self.repo.delete_info(session, entity, self.uri)
+            self.reset_caches()
 
     def before_entity_insertion(self, session, lid, etype, eid, sourceparams):
         """called by the repository when an eid has been attributed for an
@@ -604,13 +348,13 @@
         self.debug('ldap after entity insertion')
         super(LDAPUserSource, self).after_entity_insertion(
             session, lid, entity, sourceparams)
-        dn = lid
         for group in self.user_default_groups:
             session.execute('SET X in_group G WHERE X eid %(x)s, G name %(group)s',
                             {'x': entity.eid, 'group': group})
         # search for existant email first
         try:
-            emailaddr = self._cache[dn][self.user_rev_attrs['email']]
+            # lid = dn
+            emailaddr = self._cache[lid][self.user_rev_attrs['email']]
         except KeyError:
             return
         if isinstance(emailaddr, list):
@@ -632,6 +376,7 @@
         """delete an entity from the source"""
         raise RepositoryError('this source is read only')
 
+
 def _insert_email(session, emailaddr, ueid):
     session.execute('INSERT EmailAddress X: X address %(addr)s, U primary_email X '
                     'WHERE U eid %(x)s', {'addr': emailaddr, 'x': ueid})