server/sources/ldapfeed.py
changeset 9461 fc3b8798737c
parent 8989 8742f4bf029f
child 9462 375fc1868b11
equal deleted inserted replaced
9460:a2a0bc984863 9461:fc3b8798737c
    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?
       
    21 
       
    22 from datetime import datetime
       
    23 
    20 import ldap
    24 import ldap
       
    25 from ldap.ldapobject import ReconnectLDAPObject
    21 from ldap.filter import filter_format
    26 from ldap.filter import filter_format
       
    27 from ldapurl import LDAPUrl
    22 
    28 
    23 from logilab.common.configuration import merge_options
    29 from logilab.common.configuration import merge_options
    24 
    30 
       
    31 from cubicweb import ValidationError, AuthenticationError, Binary
       
    32 from cubicweb.server import utils
    25 from cubicweb.server.sources import datafeed
    33 from cubicweb.server.sources import datafeed
    26 from cubicweb.server import ldaputils, utils
       
    27 from cubicweb import Binary
       
    28 
    34 
    29 _ = unicode
    35 _ = unicode
    30 
    36 
    31 # search scopes
    37 # search scopes
    32 ldapscope = {'BASE': ldap.SCOPE_BASE,
    38 BASE = ldap.SCOPE_BASE
    33              'ONELEVEL': ldap.SCOPE_ONELEVEL,
    39 ONELEVEL = ldap.SCOPE_ONELEVEL
    34              'SUBTREE': ldap.SCOPE_SUBTREE}
    40 SUBTREE = ldap.SCOPE_SUBTREE
    35 
    41 LDAP_SCOPES = {'BASE': ldap.SCOPE_BASE,
    36 class LDAPFeedSource(ldaputils.LDAPSourceMixIn,
    42                'ONELEVEL': ldap.SCOPE_ONELEVEL,
    37                      datafeed.DataFeedSource):
    43                'SUBTREE': ldap.SCOPE_SUBTREE}
       
    44 
       
    45 # map ldap protocol to their standard port
       
    46 PROTO_PORT = {'ldap': 389,
       
    47               'ldaps': 636,
       
    48               'ldapi': None,
       
    49               }
       
    50 
       
    51 
       
    52 class ConnectionWrapper(object):
       
    53     def __init__(self, cnx=None):
       
    54         self.cnx = cnx
       
    55     def commit(self):
       
    56         pass
       
    57     def rollback(self):
       
    58         pass
       
    59     def cursor(self):
       
    60         return None # no actual cursor support
       
    61     def close(self):
       
    62         if hasattr(self.cnx, 'close'):
       
    63             self.cnx.close()
       
    64 
       
    65 
       
    66 class LDAPFeedSource(datafeed.DataFeedSource):
    38     """LDAP feed source: unlike ldapuser source, this source is copy based and
    67     """LDAP feed source: unlike ldapuser source, this source is copy based and
    39     will import ldap content (beside passwords for authentication) into the
    68     will import ldap content (beside passwords for authentication) into the
    40     system source.
    69     system source.
    41     """
    70     """
    42     support_entities = {'CWUser': False}
    71     support_entities = {'CWUser': False}
    43     use_cwuri_as_url = False
    72     use_cwuri_as_url = False
    44 
    73 
    45     options_group = (
    74     options = (
       
    75         ('auth-mode',
       
    76          {'type' : 'choice',
       
    77           'default': 'simple',
       
    78           'choices': ('simple', 'cram_md5', 'digest_md5', 'gssapi'),
       
    79           'help': 'authentication mode used to authenticate user to the ldap.',
       
    80           'group': 'ldap-source', 'level': 3,
       
    81           }),
       
    82         ('auth-realm',
       
    83          {'type' : 'string',
       
    84           'default': None,
       
    85           'help': 'realm to use when using gssapi/kerberos authentication.',
       
    86           'group': 'ldap-source', 'level': 3,
       
    87           }),
       
    88 
       
    89         ('data-cnx-dn',
       
    90          {'type' : 'string',
       
    91           'default': '',
       
    92           'help': 'user dn to use to open data connection to the ldap (eg used \
       
    93 to respond to rql queries). Leave empty for anonymous bind',
       
    94           'group': 'ldap-source', 'level': 1,
       
    95           }),
       
    96         ('data-cnx-password',
       
    97          {'type' : 'string',
       
    98           'default': '',
       
    99           'help': 'password to use to open data connection to the ldap (eg used to respond to rql queries). Leave empty for anonymous bind.',
       
   100           'group': 'ldap-source', 'level': 1,
       
   101           }),
       
   102 
       
   103         ('user-base-dn',
       
   104          {'type' : 'string',
       
   105           'default': '',
       
   106           'help': 'base DN to lookup for users; disable user importation mechanism if unset',
       
   107           'group': 'ldap-source', 'level': 1,
       
   108           }),
       
   109         ('user-scope',
       
   110          {'type' : 'choice',
       
   111           'default': 'ONELEVEL',
       
   112           'choices': ('BASE', 'ONELEVEL', 'SUBTREE'),
       
   113           'help': 'user search scope (valid values: "BASE", "ONELEVEL", "SUBTREE")',
       
   114           'group': 'ldap-source', 'level': 1,
       
   115           }),
       
   116         ('user-classes',
       
   117          {'type' : 'csv',
       
   118           'default': ('top', 'posixAccount'),
       
   119           'help': 'classes of user (with Active Directory, you want to say "user" here)',
       
   120           'group': 'ldap-source', 'level': 1,
       
   121           }),
       
   122         ('user-filter',
       
   123          {'type': 'string',
       
   124           'default': '',
       
   125           'help': 'additional filters to be set in the ldap query to find valid users',
       
   126           'group': 'ldap-source', 'level': 2,
       
   127           }),
       
   128         ('user-login-attr',
       
   129          {'type' : 'string',
       
   130           'default': 'uid',
       
   131           'help': 'attribute used as login on authentication (with Active Directory, you want to use "sAMAccountName" here)',
       
   132           'group': 'ldap-source', 'level': 1,
       
   133           }),
       
   134         ('user-default-group',
       
   135          {'type' : 'csv',
       
   136           'default': ('users',),
       
   137           'help': 'name of a group in which ldap users will be by default. \
       
   138 You can set multiple groups by separating them by a comma.',
       
   139           'group': 'ldap-source', 'level': 1,
       
   140           }),
       
   141         ('user-attrs-map',
       
   142          {'type' : 'named',
       
   143           'default': {'uid': 'login', 'gecos': 'email', 'userPassword': 'upassword'},
       
   144           'help': 'map from ldap user attributes to cubicweb attributes (with Active Directory, you want to use sAMAccountName:login,mail:email,givenName:firstname,sn:surname)',
       
   145           'group': 'ldap-source', 'level': 1,
       
   146           }),
    46         ('group-base-dn',
   147         ('group-base-dn',
    47          {'type' : 'string',
   148          {'type' : 'string',
    48           'default': '',
   149           'default': '',
    49           'help': 'base DN to lookup for groups; disable group importation mechanism if unset',
   150           'help': 'base DN to lookup for groups; disable group importation mechanism if unset',
    50           'group': 'ldap-source', 'level': 1,
   151           'group': 'ldap-source', 'level': 1,
    74           'help': 'map from ldap group attributes to cubicweb attributes',
   175           'help': 'map from ldap group attributes to cubicweb attributes',
    75           'group': 'ldap-source', 'level': 1,
   176           'group': 'ldap-source', 'level': 1,
    76           }),
   177           }),
    77     )
   178     )
    78 
   179 
    79     options = merge_options(datafeed.DataFeedSource.options
   180     options = merge_options(datafeed.DataFeedSource.options + options,
    80                             + ldaputils.LDAPSourceMixIn.options
       
    81                             + options_group,
       
    82                             optgroup='ldap-source',)
   181                             optgroup='ldap-source',)
       
   182 
       
   183     _conn = None
    83 
   184 
    84     def update_config(self, source_entity, typedconfig):
   185     def update_config(self, source_entity, typedconfig):
    85         """update configuration from source entity. `typedconfig` is config
   186         """update configuration from source entity. `typedconfig` is config
    86         properly typed with defaults set
   187         properly typed with defaults set
    87         """
   188         """
    88         super(LDAPFeedSource, self).update_config(source_entity, typedconfig)
   189         super(LDAPFeedSource, self).update_config(source_entity, typedconfig)
       
   190         self.authmode = typedconfig['auth-mode']
       
   191         self._authenticate = getattr(self, '_auth_%s' % self.authmode)
       
   192         self.cnx_dn = typedconfig['data-cnx-dn']
       
   193         self.cnx_pwd = typedconfig['data-cnx-password']
       
   194         self.user_base_dn = str(typedconfig['user-base-dn'])
       
   195         self.user_base_scope = globals()[typedconfig['user-scope']]
       
   196         self.user_login_attr = typedconfig['user-login-attr']
       
   197         self.user_default_groups = typedconfig['user-default-group']
       
   198         self.user_attrs = {'dn': 'eid', 'modifyTimestamp': 'modification_date'}
       
   199         self.user_attrs.update(typedconfig['user-attrs-map'])
       
   200         self.user_rev_attrs = dict((v, k) for k, v in self.user_attrs.iteritems())
       
   201         self.base_filters = [filter_format('(%s=%s)', ('objectClass', o))
       
   202                              for o in typedconfig['user-classes']]
       
   203         if typedconfig['user-filter']:
       
   204             self.base_filters.append(typedconfig['user-filter'])
    89         self.group_base_dn = str(typedconfig['group-base-dn'])
   205         self.group_base_dn = str(typedconfig['group-base-dn'])
    90         self.group_base_scope = ldapscope[typedconfig['group-scope']]
   206         self.group_base_scope = LDAP_SCOPES[typedconfig['group-scope']]
    91         self.group_attrs = typedconfig['group-attrs-map']
   207         self.group_attrs = typedconfig['group-attrs-map']
    92         self.group_attrs = {'dn': 'eid', 'modifyTimestamp': 'modification_date'}
   208         self.group_attrs = {'dn': 'eid', 'modifyTimestamp': 'modification_date'}
    93         self.group_attrs.update(typedconfig['group-attrs-map'])
   209         self.group_attrs.update(typedconfig['group-attrs-map'])
    94         self.group_rev_attrs = dict((v, k) for k, v in self.group_attrs.iteritems())
   210         self.group_rev_attrs = dict((v, k) for k, v in self.group_attrs.iteritems())
    95         self.group_base_filters = [filter_format('(%s=%s)', ('objectClass', o))
   211         self.group_base_filters = [filter_format('(%s=%s)', ('objectClass', o))
    96                                    for o in typedconfig['group-classes']]
   212                                    for o in typedconfig['group-classes']]
    97         if typedconfig['group-filter']:
   213         if typedconfig['group-filter']:
    98             self.group_base_filters.append(typedconfig['group-filter'])
   214             self.group_base_filters.append(typedconfig['group-filter'])
       
   215         self._conn = None
       
   216 
       
   217     def _entity_update(self, source_entity):
       
   218         super(LDAPFeedSource, self)._entity_update(source_entity)
       
   219         if self.urls:
       
   220             if len(self.urls) > 1:
       
   221                 raise ValidationError(source_entity.eid, {'url': _('can only have one url')})
       
   222             try:
       
   223                 protocol, hostport = self.urls[0].split('://')
       
   224             except ValueError:
       
   225                 raise ValidationError(source_entity.eid, {'url': _('badly formatted url')})
       
   226             if protocol not in PROTO_PORT:
       
   227                 raise ValidationError(source_entity.eid, {'url': _('unsupported protocol')})
       
   228 
       
   229     def connection_info(self):
       
   230         assert len(self.urls) == 1, self.urls
       
   231         protocol, hostport = self.urls[0].split('://')
       
   232         if protocol != 'ldapi' and not ':' in hostport:
       
   233             hostport = '%s:%s' % (hostport, PROTO_PORT[protocol])
       
   234         return protocol, hostport
       
   235 
       
   236     def get_connection(self):
       
   237         """open and return a connection to the source"""
       
   238         if self._conn is None:
       
   239             try:
       
   240                 self._connect()
       
   241             except Exception:
       
   242                 self.exception('unable to connect to ldap')
       
   243         return ConnectionWrapper(self._conn)
       
   244 
       
   245     def authenticate(self, session, login, password=None, **kwargs):
       
   246         """return CWUser eid for the given login/password if this account is
       
   247         defined in this source, else raise `AuthenticationError`
       
   248 
       
   249         two queries are needed since passwords are stored crypted, so we have
       
   250         to fetch the salt first
       
   251         """
       
   252         self.info('ldap authenticate %s', login)
       
   253         if not password:
       
   254             # On Windows + ADAM this would have succeeded (!!!)
       
   255             # You get Authenticated as: 'NT AUTHORITY\ANONYMOUS LOGON'.
       
   256             # we really really don't want that
       
   257             raise AuthenticationError()
       
   258         searchfilter = [filter_format('(%s=%s)', (self.user_login_attr, login))]
       
   259         searchfilter.extend(self.base_filters)
       
   260         searchstr = '(&%s)' % ''.join(searchfilter)
       
   261         # first search the user
       
   262         try:
       
   263             user = self._search(session, self.user_base_dn,
       
   264                                 self.user_base_scope, searchstr)[0]
       
   265         except (IndexError, ldap.SERVER_DOWN):
       
   266             # no such user
       
   267             raise AuthenticationError()
       
   268         # check password by establishing a (unused) connection
       
   269         try:
       
   270             self._connect(user, password)
       
   271         except ldap.LDAPError as ex:
       
   272             # Something went wrong, most likely bad credentials
       
   273             self.info('while trying to authenticate %s: %s', user, ex)
       
   274             raise AuthenticationError()
       
   275         except Exception:
       
   276             self.error('while trying to authenticate %s', user, exc_info=True)
       
   277             raise AuthenticationError()
       
   278         eid = self.repo.extid2eid(self, user['dn'], 'CWUser', session, {})
       
   279         if eid < 0:
       
   280             # user has been moved away from this source
       
   281             raise AuthenticationError()
       
   282         return eid
       
   283 
       
   284     def _connect(self, user=None, userpwd=None):
       
   285         protocol, hostport = self.connection_info()
       
   286         self.info('connecting %s://%s as %s', protocol, hostport,
       
   287                   user and user['dn'] or 'anonymous')
       
   288         # don't require server certificate when using ldaps (will
       
   289         # enable self signed certs)
       
   290         ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
       
   291         url = LDAPUrl(urlscheme=protocol, hostport=hostport)
       
   292         conn = ReconnectLDAPObject(url.initializeUrl())
       
   293         # Set the protocol version - version 3 is preferred
       
   294         try:
       
   295             conn.set_option(ldap.OPT_PROTOCOL_VERSION, ldap.VERSION3)
       
   296         except ldap.LDAPError: # Invalid protocol version, fall back safely
       
   297             conn.set_option(ldap.OPT_PROTOCOL_VERSION, ldap.VERSION2)
       
   298         # Deny auto-chasing of referrals to be safe, we handle them instead
       
   299         # Required for AD
       
   300         try:
       
   301            conn.set_option(ldap.OPT_REFERRALS, 0)
       
   302         except ldap.LDAPError: # Cannot set referrals, so do nothing
       
   303            pass
       
   304         #conn.set_option(ldap.OPT_NETWORK_TIMEOUT, conn_timeout)
       
   305         #conn.timeout = op_timeout
       
   306         # Now bind with the credentials given. Let exceptions propagate out.
       
   307         if user is None:
       
   308             # no user specified, we want to initialize the 'data' connection,
       
   309             assert self._conn is None
       
   310             self._conn = conn
       
   311             # XXX always use simple bind for data connection
       
   312             if not self.cnx_dn:
       
   313                 conn.simple_bind_s(self.cnx_dn, self.cnx_pwd)
       
   314             else:
       
   315                 self._authenticate(conn, {'dn': self.cnx_dn}, self.cnx_pwd)
       
   316         else:
       
   317             # user specified, we want to check user/password, no need to return
       
   318             # the connection which will be thrown out
       
   319             self._authenticate(conn, user, userpwd)
       
   320         return conn
       
   321 
       
   322     def _auth_simple(self, conn, user, userpwd):
       
   323         conn.simple_bind_s(user['dn'], userpwd)
       
   324 
       
   325     def _auth_cram_md5(self, conn, user, userpwd):
       
   326         from ldap import sasl
       
   327         auth_token = sasl.cram_md5(user['dn'], userpwd)
       
   328         conn.sasl_interactive_bind_s('', auth_token)
       
   329 
       
   330     def _auth_digest_md5(self, conn, user, userpwd):
       
   331         from ldap import sasl
       
   332         auth_token = sasl.digest_md5(user['dn'], userpwd)
       
   333         conn.sasl_interactive_bind_s('', auth_token)
       
   334 
       
   335     def _auth_gssapi(self, conn, user, userpwd):
       
   336         # print XXX not proper sasl/gssapi
       
   337         import kerberos
       
   338         if not kerberos.checkPassword(user[self.user_login_attr], userpwd):
       
   339             raise Exception('BAD login / mdp')
       
   340         #from ldap import sasl
       
   341         #conn.sasl_interactive_bind_s('', sasl.gssapi())
       
   342 
       
   343     def _search(self, session, base, scope,
       
   344                 searchstr='(objectClass=*)', attrs=()):
       
   345         """make an ldap query"""
       
   346         self.debug('ldap search %s %s %s %s %s', self.uri, base, scope,
       
   347                    searchstr, list(attrs))
       
   348         # XXX for now, we do not have connections set support for LDAP, so
       
   349         # this is always self._conn
       
   350         cnx = self.get_connection().cnx #session.cnxset.connection(self.uri).cnx
       
   351         if cnx is None:
       
   352             # cant connect to server
       
   353             msg = session._("can't connect to source %s, some data may be missing")
       
   354             session.set_shared_data('sources_error', msg % self.uri, txdata=True)
       
   355             return []
       
   356         try:
       
   357             res = cnx.search_s(base, scope, searchstr, attrs)
       
   358         except ldap.PARTIAL_RESULTS:
       
   359             res = cnx.result(all=0)[1]
       
   360         except ldap.NO_SUCH_OBJECT:
       
   361             self.info('ldap NO SUCH OBJECT %s %s %s', base, scope, searchstr)
       
   362             self._process_no_such_object(session, base)
       
   363             return []
       
   364         # except ldap.REFERRAL as e:
       
   365         #     cnx = self.handle_referral(e)
       
   366         #     try:
       
   367         #         res = cnx.search_s(base, scope, searchstr, attrs)
       
   368         #     except ldap.PARTIAL_RESULTS:
       
   369         #         res_type, res = cnx.result(all=0)
       
   370         result = []
       
   371         for rec_dn, rec_dict in res:
       
   372             # When used against Active Directory, "rec_dict" may not be
       
   373             # be a dictionary in some cases (instead, it can be a list)
       
   374             #
       
   375             # An example of a useless "res" entry that can be ignored
       
   376             # from AD is
       
   377             # (None, ['ldap://ForestDnsZones.PORTAL.LOCAL/DC=ForestDnsZones,DC=PORTAL,DC=LOCAL'])
       
   378             # This appears to be some sort of internal referral, but
       
   379             # we can't handle it, so we need to skip over it.
       
   380             try:
       
   381                 items = rec_dict.iteritems()
       
   382             except AttributeError:
       
   383                 continue
       
   384             else:
       
   385                 itemdict = self._process_ldap_item(rec_dn, items)
       
   386                 result.append(itemdict)
       
   387         self.debug('ldap built results %s', len(result))
       
   388         return result
    99 
   389 
   100     def _process_ldap_item(self, dn, iterator):
   390     def _process_ldap_item(self, dn, iterator):
   101         itemdict = super(LDAPFeedSource, self)._process_ldap_item(dn, iterator)
   391         """Turn an ldap received item into a proper dict."""
       
   392         itemdict = {'dn': dn}
       
   393         for key, value in iterator:
       
   394             if self.user_attrs.get(key) == 'upassword': # XXx better password detection
       
   395                 value = value[0].encode('utf-8')
       
   396                 # we only support ldap_salted_sha1 for ldap sources, see: server/utils.py
       
   397                 if not value.startswith('{SSHA}'):
       
   398                     value = utils.crypt_password(value)
       
   399                 itemdict[key] = Binary(value)
       
   400             elif self.user_attrs.get(key) == 'modification_date':
       
   401                 itemdict[key] = datetime.strptime(value[0], '%Y%m%d%H%M%SZ')
       
   402             else:
       
   403                 value = [unicode(val, 'utf-8', 'replace') for val in value]
       
   404                 if len(value) == 1:
       
   405                     itemdict[key] = value = value[0]
       
   406                 else:
       
   407                     itemdict[key] = value
   102         # we expect memberUid to be a list of user ids, make sure of it
   408         # we expect memberUid to be a list of user ids, make sure of it
   103         member = self.group_rev_attrs['member']
   409         member = self.group_rev_attrs['member']
   104         if isinstance(itemdict.get(member), basestring):
   410         if isinstance(itemdict.get(member), basestring):
   105             itemdict[member] = [itemdict[member]]
   411             itemdict[member] = [itemdict[member]]
   106         return itemdict
   412         return itemdict
       
   413 
       
   414     def _process_no_such_object(self, session, dn):
       
   415         """Some search return NO_SUCH_OBJECT error, handle this (usually because
       
   416         an object whose dn is no more existent in ldap as been encountered).
       
   417 
       
   418         Do nothing by default, let sub-classes handle that.
       
   419         """