server/sources/ldapfeed.py
changeset 10766 d730f91251af
parent 10666 7f6b5f023884
child 10768 99689a5862ea
equal deleted inserted replaced
10765:bd2e3c1d1fed 10766:d730f91251af
    15 #
    15 #
    16 # You should have received a copy of the GNU Lesser General Public License along
    16 # You should have received a copy of the GNU Lesser General Public License along
    17 # with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
    17 # with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
    18 """cubicweb ldap feed source"""
    18 """cubicweb ldap feed source"""
    19 
    19 
    20 from __future__ import division # XXX why?
    20 from __future__ import division  # XXX why?
    21 
    21 
    22 from datetime import datetime
    22 from datetime import datetime
    23 
    23 
    24 from six import string_types
    24 from six import string_types
    25 
    25 
    26 import ldap
    26 import ldap3
    27 from ldap.ldapobject import ReconnectLDAPObject
       
    28 from ldap.filter import filter_format
       
    29 from ldapurl import LDAPUrl
       
    30 
    27 
    31 from logilab.common.configuration import merge_options
    28 from logilab.common.configuration import merge_options
    32 
    29 
    33 from cubicweb import ValidationError, AuthenticationError, Binary
    30 from cubicweb import ValidationError, AuthenticationError, Binary
    34 from cubicweb.server import utils
    31 from cubicweb.server import utils
    35 from cubicweb.server.sources import datafeed
    32 from cubicweb.server.sources import datafeed
    36 
    33 
    37 from cubicweb import _
    34 from cubicweb import _
    38 
    35 
    39 # search scopes
    36 # search scopes
    40 BASE = ldap.SCOPE_BASE
    37 BASE = ldap3.SEARCH_SCOPE_BASE_OBJECT
    41 ONELEVEL = ldap.SCOPE_ONELEVEL
    38 ONELEVEL = ldap3.SEARCH_SCOPE_SINGLE_LEVEL
    42 SUBTREE = ldap.SCOPE_SUBTREE
    39 SUBTREE = ldap3.SEARCH_SCOPE_WHOLE_SUBTREE
    43 LDAP_SCOPES = {'BASE': ldap.SCOPE_BASE,
    40 LDAP_SCOPES = {'BASE': BASE,
    44                'ONELEVEL': ldap.SCOPE_ONELEVEL,
    41                'ONELEVEL': ONELEVEL,
    45                'SUBTREE': ldap.SCOPE_SUBTREE}
    42                'SUBTREE': SUBTREE}
    46 
    43 
    47 # map ldap protocol to their standard port
    44 # map ldap protocol to their standard port
    48 PROTO_PORT = {'ldap': 389,
    45 PROTO_PORT = {'ldap': 389,
    49               'ldaps': 636,
    46               'ldaps': 636,
    50               'ldapi': None,
    47               'ldapi': None,
    51               }
    48               }
       
    49 
       
    50 
       
    51 def replace_filter(s):
       
    52     s = s.replace('*', '\\2A')
       
    53     s = s.replace('(', '\\28')
       
    54     s = s.replace(')', '\\29')
       
    55     s = s.replace('\\', '\\5c')
       
    56     s = s.replace('\0', '\\00')
       
    57     return s
    52 
    58 
    53 
    59 
    54 class LDAPFeedSource(datafeed.DataFeedSource):
    60 class LDAPFeedSource(datafeed.DataFeedSource):
    55     """LDAP feed source: unlike ldapuser source, this source is copy based and
    61     """LDAP feed source: unlike ldapuser source, this source is copy based and
    56     will import ldap content (beside passwords for authentication) into the
    62     will import ldap content (beside passwords for authentication) into the
    61 
    67 
    62     options = (
    68     options = (
    63         ('auth-mode',
    69         ('auth-mode',
    64          {'type' : 'choice',
    70          {'type' : 'choice',
    65           'default': 'simple',
    71           'default': 'simple',
    66           'choices': ('simple', 'cram_md5', 'digest_md5', 'gssapi'),
    72           'choices': ('simple', 'digest_md5', 'gssapi'),
    67           'help': 'authentication mode used to authenticate user to the ldap.',
    73           'help': 'authentication mode used to authenticate user to the ldap.',
    68           'group': 'ldap-source', 'level': 3,
    74           'group': 'ldap-source', 'level': 3,
    69           }),
    75           }),
    70         ('auth-realm',
    76         ('auth-realm',
    71          {'type' : 'string',
    77          {'type' : 'string',
   184         self.user_login_attr = typedconfig['user-login-attr']
   190         self.user_login_attr = typedconfig['user-login-attr']
   185         self.user_default_groups = typedconfig['user-default-group']
   191         self.user_default_groups = typedconfig['user-default-group']
   186         self.user_attrs = {'dn': 'eid', 'modifyTimestamp': 'modification_date'}
   192         self.user_attrs = {'dn': 'eid', 'modifyTimestamp': 'modification_date'}
   187         self.user_attrs.update(typedconfig['user-attrs-map'])
   193         self.user_attrs.update(typedconfig['user-attrs-map'])
   188         self.user_rev_attrs = dict((v, k) for k, v in self.user_attrs.items())
   194         self.user_rev_attrs = dict((v, k) for k, v in self.user_attrs.items())
   189         self.base_filters = [filter_format('(%s=%s)', ('objectClass', o))
   195         self.base_filters = ['(objectclass=%s)' % replace_filter(o)
   190                              for o in typedconfig['user-classes']]
   196                              for o in typedconfig['user-classes']]
   191         if typedconfig['user-filter']:
   197         if typedconfig['user-filter']:
   192             self.base_filters.append(typedconfig['user-filter'])
   198             self.base_filters.append(typedconfig['user-filter'])
   193         self.group_base_dn = str(typedconfig['group-base-dn'])
   199         self.group_base_dn = str(typedconfig['group-base-dn'])
   194         self.group_base_scope = LDAP_SCOPES[typedconfig['group-scope']]
   200         self.group_base_scope = LDAP_SCOPES[typedconfig['group-scope']]
   195         self.group_attrs = typedconfig['group-attrs-map']
   201         self.group_attrs = typedconfig['group-attrs-map']
   196         self.group_attrs = {'dn': 'eid', 'modifyTimestamp': 'modification_date'}
   202         self.group_attrs = {'dn': 'eid', 'modifyTimestamp': 'modification_date'}
   197         self.group_attrs.update(typedconfig['group-attrs-map'])
   203         self.group_attrs.update(typedconfig['group-attrs-map'])
   198         self.group_rev_attrs = dict((v, k) for k, v in self.group_attrs.items())
   204         self.group_rev_attrs = dict((v, k) for k, v in self.group_attrs.items())
   199         self.group_base_filters = [filter_format('(%s=%s)', ('objectClass', o))
   205         self.group_base_filters = ['(objectClass=%s)' % replace_filter(o)
   200                                    for o in typedconfig['group-classes']]
   206                                    for o in typedconfig['group-classes']]
   201         if typedconfig['group-filter']:
   207         if typedconfig['group-filter']:
   202             self.group_base_filters.append(typedconfig['group-filter'])
   208             self.group_base_filters.append(typedconfig['group-filter'])
   203         self._conn = None
   209         self._conn = None
   204 
   210 
   215                 raise ValidationError(source_entity.eid, {'url': _('unsupported protocol')})
   221                 raise ValidationError(source_entity.eid, {'url': _('unsupported protocol')})
   216 
   222 
   217     def connection_info(self):
   223     def connection_info(self):
   218         assert len(self.urls) == 1, self.urls
   224         assert len(self.urls) == 1, self.urls
   219         protocol, hostport = self.urls[0].split('://')
   225         protocol, hostport = self.urls[0].split('://')
   220         if protocol != 'ldapi' and not ':' in hostport:
   226         if protocol != 'ldapi' and ':' in hostport:
   221             hostport = '%s:%s' % (hostport, PROTO_PORT[protocol])
   227             host, port = hostport.rsplit(':', 1)
   222         return protocol, hostport
   228         else:
       
   229             host, port = hostport, PROTO_PORT[protocol]
       
   230         return protocol, host, port
   223 
   231 
   224     def authenticate(self, cnx, login, password=None, **kwargs):
   232     def authenticate(self, cnx, login, password=None, **kwargs):
   225         """return CWUser eid for the given login/password if this account is
   233         """return CWUser eid for the given login/password if this account is
   226         defined in this source, else raise `AuthenticationError`
   234         defined in this source, else raise `AuthenticationError`
   227 
   235 
   232         if not password:
   240         if not password:
   233             # On Windows + ADAM this would have succeeded (!!!)
   241             # On Windows + ADAM this would have succeeded (!!!)
   234             # You get Authenticated as: 'NT AUTHORITY\ANONYMOUS LOGON'.
   242             # You get Authenticated as: 'NT AUTHORITY\ANONYMOUS LOGON'.
   235             # we really really don't want that
   243             # we really really don't want that
   236             raise AuthenticationError()
   244             raise AuthenticationError()
   237         searchfilter = [filter_format('(%s=%s)', (self.user_login_attr, login))]
   245         searchfilter = ['(%s=%s)' % (replace_filter(self.user_login_attr), replace_filter(login))]
   238         searchfilter.extend(self.base_filters)
   246         searchfilter.extend(self.base_filters)
   239         searchstr = '(&%s)' % ''.join(searchfilter)
   247         searchstr = '(&%s)' % ''.join(searchfilter)
   240         # first search the user
   248         # first search the user
   241         try:
   249         try:
   242             user = self._search(cnx, self.user_base_dn,
   250             user = self._search(cnx, self.user_base_dn,
   243                                 self.user_base_scope, searchstr)[0]
   251                                 self.user_base_scope, searchstr)[0]
   244         except (IndexError, ldap.SERVER_DOWN):
   252         except IndexError:
   245             # no such user
   253             # no such user
   246             raise AuthenticationError()
   254             raise AuthenticationError()
   247         # check password by establishing a (unused) connection
   255         # check password by establishing a (unused) connection
   248         try:
   256         try:
   249             self._connect(user, password)
   257             self._connect(user, password)
   250         except ldap.LDAPError as ex:
   258         except ldap3.LDAPException as ex:
   251             # Something went wrong, most likely bad credentials
   259             # Something went wrong, most likely bad credentials
   252             self.info('while trying to authenticate %s: %s', user, ex)
   260             self.info('while trying to authenticate %s: %s', user, ex)
   253             raise AuthenticationError()
   261             raise AuthenticationError()
   254         except Exception:
   262         except Exception:
   255             self.error('while trying to authenticate %s', user, exc_info=True)
   263             self.error('while trying to authenticate %s', user, exc_info=True)
   259             # user has been moved away from this source
   267             # user has been moved away from this source
   260             raise AuthenticationError()
   268             raise AuthenticationError()
   261         return eid
   269         return eid
   262 
   270 
   263     def _connect(self, user=None, userpwd=None):
   271     def _connect(self, user=None, userpwd=None):
   264         protocol, hostport = self.connection_info()
   272         protocol, host, port = self.connection_info()
   265         self.info('connecting %s://%s as %s', protocol, hostport,
   273         self.info('connecting %s://%s:%s as %s', protocol, host, port,
   266                   user and user['dn'] or 'anonymous')
   274                   user and user['dn'] or 'anonymous')
   267         # don't require server certificate when using ldaps (will
   275         server = ldap3.Server(host, port=int(port))
   268         # enable self signed certs)
   276         conn = ldap3.Connection(server, user=user and user['dn'], client_strategy=ldap3.STRATEGY_SYNC_RESTARTABLE, auto_referrals=False)
   269         ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
       
   270         url = LDAPUrl(urlscheme=protocol, hostport=hostport)
       
   271         conn = ReconnectLDAPObject(url.initializeUrl())
       
   272         # Set the protocol version - version 3 is preferred
       
   273         try:
       
   274             conn.set_option(ldap.OPT_PROTOCOL_VERSION, ldap.VERSION3)
       
   275         except ldap.LDAPError: # Invalid protocol version, fall back safely
       
   276             conn.set_option(ldap.OPT_PROTOCOL_VERSION, ldap.VERSION2)
       
   277         # Deny auto-chasing of referrals to be safe, we handle them instead
       
   278         # Required for AD
       
   279         try:
       
   280             conn.set_option(ldap.OPT_REFERRALS, 0)
       
   281         except ldap.LDAPError: # Cannot set referrals, so do nothing
       
   282             pass
       
   283         #conn.set_option(ldap.OPT_NETWORK_TIMEOUT, conn_timeout)
       
   284         #conn.timeout = op_timeout
       
   285         # Now bind with the credentials given. Let exceptions propagate out.
   277         # Now bind with the credentials given. Let exceptions propagate out.
   286         if user is None:
   278         if user is None:
   287             # XXX always use simple bind for data connection
   279             # XXX always use simple bind for data connection
   288             if not self.cnx_dn:
   280             if not self.cnx_dn:
   289                 conn.simple_bind_s(self.cnx_dn, self.cnx_pwd)
   281                 conn.bind()
   290             else:
   282             else:
   291                 self._authenticate(conn, {'dn': self.cnx_dn}, self.cnx_pwd)
   283                 self._authenticate(conn, {'dn': self.cnx_dn}, self.cnx_pwd)
   292         else:
   284         else:
   293             # user specified, we want to check user/password, no need to return
   285             # user specified, we want to check user/password, no need to return
   294             # the connection which will be thrown out
   286             # the connection which will be thrown out
   295             self._authenticate(conn, user, userpwd)
   287             self._authenticate(conn, user, userpwd)
   296         return conn
   288         return conn
   297 
   289 
   298     def _auth_simple(self, conn, user, userpwd):
   290     def _auth_simple(self, conn, user, userpwd):
   299         conn.simple_bind_s(user['dn'], userpwd)
   291         conn.authentication = ldap3.AUTH_SIMPLE
   300 
   292         conn.user = user['dn']
   301     def _auth_cram_md5(self, conn, user, userpwd):
   293         conn.password = userpwd
   302         from ldap import sasl
   294         conn.bind()
   303         auth_token = sasl.cram_md5(user['dn'], userpwd)
       
   304         conn.sasl_interactive_bind_s('', auth_token)
       
   305 
   295 
   306     def _auth_digest_md5(self, conn, user, userpwd):
   296     def _auth_digest_md5(self, conn, user, userpwd):
   307         from ldap import sasl
   297         conn.authentication = ldap3.AUTH_SASL
   308         auth_token = sasl.digest_md5(user['dn'], userpwd)
   298         conn.sasl_mechanism = 'DIGEST-MD5'
   309         conn.sasl_interactive_bind_s('', auth_token)
   299         # realm, user, password, authz-id
       
   300         conn.sasl_credentials = (None, user['dn'], userpwd, None)
       
   301         conn.bind()
   310 
   302 
   311     def _auth_gssapi(self, conn, user, userpwd):
   303     def _auth_gssapi(self, conn, user, userpwd):
   312         # print XXX not proper sasl/gssapi
   304         conn.authentication = ldap3.AUTH_SASL
   313         import kerberos
   305         conn.sasl_mechanism = 'GSSAPI'
   314         if not kerberos.checkPassword(user[self.user_login_attr], userpwd):
   306         conn.bind()
   315             raise Exception('BAD login / mdp')
       
   316         #from ldap import sasl
       
   317         #conn.sasl_interactive_bind_s('', sasl.gssapi())
       
   318 
   307 
   319     def _search(self, cnx, base, scope,
   308     def _search(self, cnx, base, scope,
   320                 searchstr='(objectClass=*)', attrs=()):
   309                 searchstr='(objectClass=*)', attrs=()):
   321         """make an ldap query"""
   310         """make an ldap query"""
   322         self.debug('ldap search %s %s %s %s %s', self.uri, base, scope,
   311         self.debug('ldap search %s %s %s %s %s', self.uri, base, scope,
   323                    searchstr, list(attrs))
   312                    searchstr, list(attrs))
   324         if self._conn is None:
   313         if self._conn is None:
   325             self._conn = self._connect()
   314             self._conn = self._connect()
   326         ldapcnx = self._conn
   315         ldapcnx = self._conn
   327         try:
   316         if not ldapcnx.search(base, searchstr, search_scope=scope, attributes=attrs):
   328             res = ldapcnx.search_s(base, scope, searchstr, attrs)
       
   329         except ldap.PARTIAL_RESULTS:
       
   330             res = ldapcnx.result(all=0)[1]
       
   331         except ldap.NO_SUCH_OBJECT:
       
   332             self.info('ldap NO SUCH OBJECT %s %s %s', base, scope, searchstr)
       
   333             self._process_no_such_object(cnx, base)
       
   334             return []
   317             return []
   335         # except ldap.REFERRAL as e:
       
   336         #     ldapcnx = self.handle_referral(e)
       
   337         #     try:
       
   338         #         res = ldapcnx.search_s(base, scope, searchstr, attrs)
       
   339         #     except ldap.PARTIAL_RESULTS:
       
   340         #         res_type, res = ldapcnx.result(all=0)
       
   341         result = []
   318         result = []
   342         for rec_dn, rec_dict in res:
   319         for rec in ldapcnx.response:
   343             # When used against Active Directory, "rec_dict" may not be
   320             if rec['type'] != 'searchResEntry':
   344             # be a dictionary in some cases (instead, it can be a list)
       
   345             #
       
   346             # An example of a useless "res" entry that can be ignored
       
   347             # from AD is
       
   348             # (None, ['ldap://ForestDnsZones.PORTAL.LOCAL/DC=ForestDnsZones,DC=PORTAL,DC=LOCAL'])
       
   349             # This appears to be some sort of internal referral, but
       
   350             # we can't handle it, so we need to skip over it.
       
   351             try:
       
   352                 items = rec_dict.items()
       
   353             except AttributeError:
       
   354                 continue
   321                 continue
   355             else:
   322             items = rec['attributes'].items()
   356                 itemdict = self._process_ldap_item(rec_dn, items)
   323             itemdict = self._process_ldap_item(rec['dn'], items)
   357                 result.append(itemdict)
   324             result.append(itemdict)
   358         self.debug('ldap built results %s', len(result))
   325         self.debug('ldap built results %s', len(result))
   359         return result
   326         return result
   360 
   327 
   361     def _process_ldap_item(self, dn, iterator):
   328     def _process_ldap_item(self, dn, iterator):
   362         """Turn an ldap received item into a proper dict."""
   329         """Turn an ldap received item into a proper dict."""