server/ldaputils.py
changeset 8188 1867e252e487
child 8244 c7d89541e3c5
equal deleted inserted replaced
8187:981f6e487788 8188:1867e252e487
       
     1 # copyright 2003-2012 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
       
     2 # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
       
     3 #
       
     4 # This file is part of CubicWeb.
       
     5 #
       
     6 # CubicWeb is free software: you can redistribute it and/or modify it under the
       
     7 # terms of the GNU Lesser General Public License as published by the Free
       
     8 # Software Foundation, either version 2.1 of the License, or (at your option)
       
     9 # any later version.
       
    10 #
       
    11 # CubicWeb is distributed in the hope that it will be useful, but WITHOUT
       
    12 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
       
    13 # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
       
    14 # details.
       
    15 #
       
    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/>.
       
    18 """cubicweb utilities for ldap sources
       
    19 
       
    20 Part of the code is coming form Zope's LDAPUserFolder
       
    21 
       
    22 Copyright (c) 2004 Jens Vagelpohl.
       
    23 All Rights Reserved.
       
    24 
       
    25 This software is subject to the provisions of the Zope Public License,
       
    26 Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
       
    27 THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
       
    28 WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
       
    29 WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
       
    30 FOR A PARTICULAR PURPOSE.
       
    31 """
       
    32 
       
    33 from __future__ import division # XXX why?
       
    34 
       
    35 import ldap
       
    36 from ldap.ldapobject import ReconnectLDAPObject
       
    37 from ldap.filter import filter_format
       
    38 from ldapurl import LDAPUrl
       
    39 
       
    40 from cubicweb import ValidationError, AuthenticationError
       
    41 from cubicweb.server.sources import ConnectionWrapper
       
    42 
       
    43 _ = unicode
       
    44 
       
    45 # search scopes
       
    46 BASE = ldap.SCOPE_BASE
       
    47 ONELEVEL = ldap.SCOPE_ONELEVEL
       
    48 SUBTREE = ldap.SCOPE_SUBTREE
       
    49 
       
    50 # map ldap protocol to their standard port
       
    51 PROTO_PORT = {'ldap': 389,
       
    52               'ldaps': 636,
       
    53               'ldapi': None,
       
    54               }
       
    55 
       
    56 
       
    57 class LDAPSourceMixIn(object):
       
    58     """a mix-in for LDAP based source"""
       
    59     options = (
       
    60         ('auth-mode',
       
    61          {'type' : 'choice',
       
    62           'default': 'simple',
       
    63           'choices': ('simple', 'cram_md5', 'digest_md5', 'gssapi'),
       
    64           'help': 'authentication mode used to authenticate user to the ldap.',
       
    65           'group': 'ldap-source', 'level': 3,
       
    66           }),
       
    67         ('auth-realm',
       
    68          {'type' : 'string',
       
    69           'default': None,
       
    70           'help': 'realm to use when using gssapi/kerberos authentication.',
       
    71           'group': 'ldap-source', 'level': 3,
       
    72           }),
       
    73 
       
    74         ('data-cnx-dn',
       
    75          {'type' : 'string',
       
    76           'default': '',
       
    77           'help': 'user dn to use to open data connection to the ldap (eg used \
       
    78 to respond to rql queries). Leave empty for anonymous bind',
       
    79           'group': 'ldap-source', 'level': 1,
       
    80           }),
       
    81         ('data-cnx-password',
       
    82          {'type' : 'string',
       
    83           'default': '',
       
    84           'help': 'password to use to open data connection to the ldap (eg used to respond to rql queries). Leave empty for anonymous bind.',
       
    85           'group': 'ldap-source', 'level': 1,
       
    86           }),
       
    87 
       
    88         ('user-base-dn',
       
    89          {'type' : 'string',
       
    90           'default': 'ou=People,dc=logilab,dc=fr',
       
    91           'help': 'base DN to lookup for users',
       
    92           'group': 'ldap-source', 'level': 1,
       
    93           }),
       
    94         ('user-scope',
       
    95          {'type' : 'choice',
       
    96           'default': 'ONELEVEL',
       
    97           'choices': ('BASE', 'ONELEVEL', 'SUBTREE'),
       
    98           'help': 'user search scope (valid values: "BASE", "ONELEVEL", "SUBTREE")',
       
    99           'group': 'ldap-source', 'level': 1,
       
   100           }),
       
   101         ('user-classes',
       
   102          {'type' : 'csv',
       
   103           'default': ('top', 'posixAccount'),
       
   104           'help': 'classes of user (with Active Directory, you want to say "user" here)',
       
   105           'group': 'ldap-source', 'level': 1,
       
   106           }),
       
   107         ('user-filter',
       
   108          {'type': 'string',
       
   109           'default': '',
       
   110           'help': 'additional filters to be set in the ldap query to find valid users',
       
   111           'group': 'ldap-source', 'level': 2,
       
   112           }),
       
   113         ('user-login-attr',
       
   114          {'type' : 'string',
       
   115           'default': 'uid',
       
   116           'help': 'attribute used as login on authentication (with Active Directory, you want to use "sAMAccountName" here)',
       
   117           'group': 'ldap-source', 'level': 1,
       
   118           }),
       
   119         ('user-default-group',
       
   120          {'type' : 'csv',
       
   121           'default': ('users',),
       
   122           'help': 'name of a group in which ldap users will be by default. \
       
   123 You can set multiple groups by separating them by a comma.',
       
   124           'group': 'ldap-source', 'level': 1,
       
   125           }),
       
   126         ('user-attrs-map',
       
   127          {'type' : 'named',
       
   128           'default': {'uid': 'login', 'gecos': 'email'},
       
   129           'help': 'map from ldap user attributes to cubicweb attributes (with Active Directory, you want to use sAMAccountName:login,mail:email,givenName:firstname,sn:surname)',
       
   130           'group': 'ldap-source', 'level': 1,
       
   131           }),
       
   132 
       
   133     )
       
   134 
       
   135     _conn = None
       
   136 
       
   137     def _entity_update(self, source_entity):
       
   138         if self.urls:
       
   139             if len(self.urls) > 1:
       
   140                 raise ValidationError(source_entity, {'url': _('can only have one url')})
       
   141             try:
       
   142                 protocol, hostport = self.urls[0].split('://')
       
   143             except ValueError:
       
   144                 raise ValidationError(source_entity, {'url': _('badly formatted url')})
       
   145             if protocol not in PROTO_PORT:
       
   146                 raise ValidationError(source_entity, {'url': _('unsupported protocol')})
       
   147 
       
   148     def update_config(self, source_entity, typedconfig):
       
   149         """update configuration from source entity. `typedconfig` is config
       
   150         properly typed with defaults set
       
   151         """
       
   152         self.authmode = typedconfig['auth-mode']
       
   153         self._authenticate = getattr(self, '_auth_%s' % self.authmode)
       
   154         self.cnx_dn = typedconfig['data-cnx-dn']
       
   155         self.cnx_pwd = typedconfig['data-cnx-password']
       
   156         self.user_base_dn = str(typedconfig['user-base-dn'])
       
   157         self.user_base_scope = globals()[typedconfig['user-scope']]
       
   158         self.user_login_attr = typedconfig['user-login-attr']
       
   159         self.user_default_groups = typedconfig['user-default-group']
       
   160         self.user_attrs = typedconfig['user-attrs-map']
       
   161         self.user_rev_attrs = {'eid': 'dn'}
       
   162         for ldapattr, cwattr in self.user_attrs.items():
       
   163             self.user_rev_attrs[cwattr] = ldapattr
       
   164         self.base_filters = [filter_format('(%s=%s)', ('objectClass', o))
       
   165                              for o in typedconfig['user-classes']]
       
   166         if typedconfig['user-filter']:
       
   167             self.base_filters.append(typedconfig['user-filter'])
       
   168         self._conn = None
       
   169 
       
   170     def connection_info(self):
       
   171         assert len(self.urls) == 1, self.urls
       
   172         protocol, hostport = self.urls[0].split('://')
       
   173         if protocol != 'ldapi' and not ':' in hostport:
       
   174             hostport = '%s:%s' % (hostport, PROTO_PORT[protocol])
       
   175         return protocol, hostport
       
   176 
       
   177     def get_connection(self):
       
   178         """open and return a connection to the source"""
       
   179         if self._conn is None:
       
   180             try:
       
   181                 self._connect()
       
   182             except Exception:
       
   183                 self.exception('unable to connect to ldap')
       
   184         return ConnectionWrapper(self._conn)
       
   185 
       
   186     def authenticate(self, session, login, password=None, **kwargs):
       
   187         """return CWUser eid for the given login/password if this account is
       
   188         defined in this source, else raise `AuthenticationError`
       
   189 
       
   190         two queries are needed since passwords are stored crypted, so we have
       
   191         to fetch the salt first
       
   192         """
       
   193         self.info('ldap authenticate %s', login)
       
   194         if not password:
       
   195             # On Windows + ADAM this would have succeeded (!!!)
       
   196             # You get Authenticated as: 'NT AUTHORITY\ANONYMOUS LOGON'.
       
   197             # we really really don't want that
       
   198             raise AuthenticationError()
       
   199         searchfilter = [filter_format('(%s=%s)', (self.user_login_attr, login))]
       
   200         searchfilter.extend(self.base_filters)
       
   201         searchstr = '(&%s)' % ''.join(searchfilter)
       
   202         # first search the user
       
   203         try:
       
   204             user = self._search(session, self.user_base_dn,
       
   205                                 self.user_base_scope, searchstr)[0]
       
   206         except IndexError:
       
   207             # no such user
       
   208             raise AuthenticationError()
       
   209         # check password by establishing a (unused) connection
       
   210         try:
       
   211             self._connect(user, password)
       
   212         except ldap.LDAPError, ex:
       
   213             # Something went wrong, most likely bad credentials
       
   214             self.info('while trying to authenticate %s: %s', user, ex)
       
   215             raise AuthenticationError()
       
   216         except Exception:
       
   217             self.error('while trying to authenticate %s', user, exc_info=True)
       
   218             raise AuthenticationError()
       
   219         eid = self.repo.extid2eid(self, user['dn'], 'CWUser', session)
       
   220         if eid < 0:
       
   221             # user has been moved away from this source
       
   222             raise AuthenticationError()
       
   223         return eid
       
   224 
       
   225     def object_exists_in_ldap(self, dn):
       
   226         cnx = self.get_connection().cnx #session.cnxset.connection(self.uri).cnx
       
   227         if cnx is None:
       
   228             return True # ldap unreachable, suppose it exists
       
   229         try:
       
   230             cnx.search_s(base, scope, searchstr, attrs)
       
   231         except ldap.PARTIAL_RESULTS:
       
   232             pass
       
   233         except ldap.NO_SUCH_OBJECT:
       
   234             return False
       
   235         return True
       
   236 
       
   237     def _connect(self, user=None, userpwd=None):
       
   238         protocol, hostport = self.connection_info()
       
   239         self.info('connecting %s://%s as %s', protocol, hostport,
       
   240                   user and user['dn'] or 'anonymous')
       
   241         # don't require server certificate when using ldaps (will
       
   242         # enable self signed certs)
       
   243         ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
       
   244         url = LDAPUrl(urlscheme=protocol, hostport=hostport)
       
   245         conn = ReconnectLDAPObject(url.initializeUrl())
       
   246         # Set the protocol version - version 3 is preferred
       
   247         try:
       
   248             conn.set_option(ldap.OPT_PROTOCOL_VERSION, ldap.VERSION3)
       
   249         except ldap.LDAPError: # Invalid protocol version, fall back safely
       
   250             conn.set_option(ldap.OPT_PROTOCOL_VERSION, ldap.VERSION2)
       
   251         # Deny auto-chasing of referrals to be safe, we handle them instead
       
   252         #try:
       
   253         #    connection.set_option(ldap.OPT_REFERRALS, 0)
       
   254         #except ldap.LDAPError: # Cannot set referrals, so do nothing
       
   255         #    pass
       
   256         #conn.set_option(ldap.OPT_NETWORK_TIMEOUT, conn_timeout)
       
   257         #conn.timeout = op_timeout
       
   258         # Now bind with the credentials given. Let exceptions propagate out.
       
   259         if user is None:
       
   260             # no user specified, we want to initialize the 'data' connection,
       
   261             assert self._conn is None
       
   262             self._conn = conn
       
   263             # XXX always use simple bind for data connection
       
   264             if not self.cnx_dn:
       
   265                 conn.simple_bind_s(self.cnx_dn, self.cnx_pwd)
       
   266             else:
       
   267                 self._authenticate(conn, {'dn': self.cnx_dn}, self.cnx_pwd)
       
   268         else:
       
   269             # user specified, we want to check user/password, no need to return
       
   270             # the connection which will be thrown out
       
   271             self._authenticate(conn, user, userpwd)
       
   272         return conn
       
   273 
       
   274     def _auth_simple(self, conn, user, userpwd):
       
   275         conn.simple_bind_s(user['dn'], userpwd)
       
   276 
       
   277     def _auth_cram_md5(self, conn, user, userpwd):
       
   278         from ldap import sasl
       
   279         auth_token = sasl.cram_md5(user['dn'], userpwd)
       
   280         conn.sasl_interactive_bind_s('', auth_token)
       
   281 
       
   282     def _auth_digest_md5(self, conn, user, userpwd):
       
   283         from ldap import sasl
       
   284         auth_token = sasl.digest_md5(user['dn'], userpwd)
       
   285         conn.sasl_interactive_bind_s('', auth_token)
       
   286 
       
   287     def _auth_gssapi(self, conn, user, userpwd):
       
   288         # print XXX not proper sasl/gssapi
       
   289         import kerberos
       
   290         if not kerberos.checkPassword(user[self.user_login_attr], userpwd):
       
   291             raise Exception('BAD login / mdp')
       
   292         #from ldap import sasl
       
   293         #conn.sasl_interactive_bind_s('', sasl.gssapi())
       
   294 
       
   295     def _search(self, session, base, scope,
       
   296                 searchstr='(objectClass=*)', attrs=()):
       
   297         """make an ldap query"""
       
   298         self.debug('ldap search %s %s %s %s %s', self.uri, base, scope,
       
   299                    searchstr, list(attrs))
       
   300         # XXX for now, we do not have connections set support for LDAP, so
       
   301         # this is always self._conn
       
   302         cnx = self.get_connection().cnx #session.cnxset.connection(self.uri).cnx
       
   303         if cnx is None:
       
   304             # cant connect to server
       
   305             msg = session._("can't connect to source %s, some data may be missing")
       
   306             session.set_shared_data('sources_error', msg % self.uri)
       
   307             return []
       
   308         try:
       
   309             res = cnx.search_s(base, scope, searchstr, attrs)
       
   310         except ldap.PARTIAL_RESULTS:
       
   311             res = cnx.result(all=0)[1]
       
   312         except ldap.NO_SUCH_OBJECT:
       
   313             self.info('ldap NO SUCH OBJECT %s %s %s', base, scope, searchstr)
       
   314             self._process_no_such_object(session, base)
       
   315             return []
       
   316         # except ldap.REFERRAL, e:
       
   317         #     cnx = self.handle_referral(e)
       
   318         #     try:
       
   319         #         res = cnx.search_s(base, scope, searchstr, attrs)
       
   320         #     except ldap.PARTIAL_RESULTS:
       
   321         #         res_type, res = cnx.result(all=0)
       
   322         result = []
       
   323         for rec_dn, rec_dict in res:
       
   324             # When used against Active Directory, "rec_dict" may not be
       
   325             # be a dictionary in some cases (instead, it can be a list)
       
   326             #
       
   327             # An example of a useless "res" entry that can be ignored
       
   328             # from AD is
       
   329             # (None, ['ldap://ForestDnsZones.PORTAL.LOCAL/DC=ForestDnsZones,DC=PORTAL,DC=LOCAL'])
       
   330             # This appears to be some sort of internal referral, but
       
   331             # we can't handle it, so we need to skip over it.
       
   332             try:
       
   333                 items = rec_dict.iteritems()
       
   334             except AttributeError:
       
   335                 continue
       
   336             else:
       
   337                 itemdict = self._process_ldap_item(rec_dn, items)
       
   338                 result.append(itemdict)
       
   339         #print '--->', result
       
   340         self.debug('ldap built results %s', len(result))
       
   341         return result
       
   342 
       
   343     def _process_ldap_item(self, dn, iterator):
       
   344         """Turn an ldap received item into a proper dict."""
       
   345         itemdict = {'dn': dn}
       
   346         for key, value in iterator:
       
   347             if not isinstance(value, str):
       
   348                 try:
       
   349                     for i in range(len(value)):
       
   350                         value[i] = unicode(value[i], 'utf8')
       
   351                 except Exception:
       
   352                     pass
       
   353             if isinstance(value, list) and len(value) == 1:
       
   354                 itemdict[key] = value = value[0]
       
   355         return itemdict
       
   356 
       
   357     def _process_no_such_object(self, session, dn):
       
   358         """Some search return NO_SUCH_OBJECT error, handle this (usually because
       
   359         an object whose dn is no more existent in ldap as been encountered).
       
   360 
       
   361         Do nothing by default, let sub-classes handle that.
       
   362         """