# copyright 2003-2013 LOGILAB S.A. (Paris, FRANCE), all rights reserved.# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr## This file is part of CubicWeb.## CubicWeb is free software: you can redistribute it and/or modify it under the# terms of the GNU Lesser General Public License as published by the Free# Software Foundation, either version 2.1 of the License, or (at your option)# any later version.## CubicWeb is distributed in the hope that it will be useful, but WITHOUT# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more# details.## You should have received a copy of the GNU Lesser General Public License along# with CubicWeb. If not, see <http://www.gnu.org/licenses/>."""cubicweb ldap feed source"""from__future__importdivision# XXX why?fromdatetimeimportdatetimeimportldapfromldap.ldapobjectimportReconnectLDAPObjectfromldap.filterimportfilter_formatfromldapurlimportLDAPUrlfromlogilab.common.configurationimportmerge_optionsfromcubicwebimportValidationError,AuthenticationError,Binaryfromcubicweb.serverimportutilsfromcubicweb.server.sourcesimportdatafeed_=unicode# search scopesBASE=ldap.SCOPE_BASEONELEVEL=ldap.SCOPE_ONELEVELSUBTREE=ldap.SCOPE_SUBTREELDAP_SCOPES={'BASE':ldap.SCOPE_BASE,'ONELEVEL':ldap.SCOPE_ONELEVEL,'SUBTREE':ldap.SCOPE_SUBTREE}# map ldap protocol to their standard portPROTO_PORT={'ldap':389,'ldaps':636,'ldapi':None,}classLDAPFeedSource(datafeed.DataFeedSource):"""LDAP feed source: unlike ldapuser source, this source is copy based and will import ldap content (beside passwords for authentication) into the system source. """support_entities={'CWUser':False}use_cwuri_as_url=Falseoptions=(('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':'','help':'base DN to lookup for users; disable user importation mechanism if unset','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','userPassword':'upassword'},'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,}),('group-base-dn',{'type':'string','default':'','help':'base DN to lookup for groups; disable group importation mechanism if unset','group':'ldap-source','level':1,}),('group-scope',{'type':'choice','default':'ONELEVEL','choices':('BASE','ONELEVEL','SUBTREE'),'help':'group search scope (valid values: "BASE", "ONELEVEL", "SUBTREE")','group':'ldap-source','level':1,}),('group-classes',{'type':'csv','default':('top','posixGroup'),'help':'classes of group','group':'ldap-source','level':1,}),('group-filter',{'type':'string','default':'','help':'additional filters to be set in the ldap query to find valid groups','group':'ldap-source','level':2,}),('group-attrs-map',{'type':'named','default':{'cn':'name','memberUid':'member'},'help':'map from ldap group attributes to cubicweb attributes','group':'ldap-source','level':1,}),)options=merge_options(datafeed.DataFeedSource.options+options,optgroup='ldap-source',)_conn=Nonedefupdate_config(self,source_entity,typedconfig):"""update configuration from source entity. `typedconfig` is config properly typed with defaults set """super(LDAPFeedSource,self).update_config(source_entity,typedconfig)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={'dn':'eid','modifyTimestamp':'modification_date'}self.user_attrs.update(typedconfig['user-attrs-map'])self.user_rev_attrs=dict((v,k)fork,vinself.user_attrs.iteritems())self.base_filters=[filter_format('(%s=%s)',('objectClass',o))forointypedconfig['user-classes']]iftypedconfig['user-filter']:self.base_filters.append(typedconfig['user-filter'])self.group_base_dn=str(typedconfig['group-base-dn'])self.group_base_scope=LDAP_SCOPES[typedconfig['group-scope']]self.group_attrs=typedconfig['group-attrs-map']self.group_attrs={'dn':'eid','modifyTimestamp':'modification_date'}self.group_attrs.update(typedconfig['group-attrs-map'])self.group_rev_attrs=dict((v,k)fork,vinself.group_attrs.iteritems())self.group_base_filters=[filter_format('(%s=%s)',('objectClass',o))forointypedconfig['group-classes']]iftypedconfig['group-filter']:self.group_base_filters.append(typedconfig['group-filter'])self._conn=Nonedef_entity_update(self,source_entity):super(LDAPFeedSource,self)._entity_update(source_entity)ifself.urls:iflen(self.urls)>1:raiseValidationError(source_entity.eid,{'url':_('can only have one url')})try:protocol,hostport=self.urls[0].split('://')exceptValueError:raiseValidationError(source_entity.eid,{'url':_('badly formatted url')})ifprotocolnotinPROTO_PORT:raiseValidationError(source_entity.eid,{'url':_('unsupported protocol')})defconnection_info(self):assertlen(self.urls)==1,self.urlsprotocol,hostport=self.urls[0].split('://')ifprotocol!='ldapi'andnot':'inhostport:hostport='%s:%s'%(hostport,PROTO_PORT[protocol])returnprotocol,hostportdefauthenticate(self,cnx,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)ifnotpassword:# On Windows + ADAM this would have succeeded (!!!)# You get Authenticated as: 'NT AUTHORITY\ANONYMOUS LOGON'.# we really really don't want thatraiseAuthenticationError()searchfilter=[filter_format('(%s=%s)',(self.user_login_attr,login))]searchfilter.extend(self.base_filters)searchstr='(&%s)'%''.join(searchfilter)# first search the usertry:user=self._search(cnx,self.user_base_dn,self.user_base_scope,searchstr)[0]except(IndexError,ldap.SERVER_DOWN):# no such userraiseAuthenticationError()# check password by establishing a (unused) connectiontry:self._connect(user,password)exceptldap.LDAPErrorasex:# Something went wrong, most likely bad credentialsself.info('while trying to authenticate %s: %s',user,ex)raiseAuthenticationError()exceptException:self.error('while trying to authenticate %s',user,exc_info=True)raiseAuthenticationError()eid=self.repo.extid2eid(self,user['dn'],'CWUser',cnx,insert=False)ifeid<0:# user has been moved away from this sourceraiseAuthenticationError()returneiddef_connect(self,user=None,userpwd=None):protocol,hostport=self.connection_info()self.info('connecting %s://%s as %s',protocol,hostport,useranduser['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 preferredtry:conn.set_option(ldap.OPT_PROTOCOL_VERSION,ldap.VERSION3)exceptldap.LDAPError:# Invalid protocol version, fall back safelyconn.set_option(ldap.OPT_PROTOCOL_VERSION,ldap.VERSION2)# Deny auto-chasing of referrals to be safe, we handle them instead# Required for ADtry:conn.set_option(ldap.OPT_REFERRALS,0)exceptldap.LDAPError:# Cannot set referrals, so do nothingpass#conn.set_option(ldap.OPT_NETWORK_TIMEOUT, conn_timeout)#conn.timeout = op_timeout# Now bind with the credentials given. Let exceptions propagate out.ifuserisNone:# XXX always use simple bind for data connectionifnotself.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 outself._authenticate(conn,user,userpwd)returnconndef_auth_simple(self,conn,user,userpwd):conn.simple_bind_s(user['dn'],userpwd)def_auth_cram_md5(self,conn,user,userpwd):fromldapimportsaslauth_token=sasl.cram_md5(user['dn'],userpwd)conn.sasl_interactive_bind_s('',auth_token)def_auth_digest_md5(self,conn,user,userpwd):fromldapimportsaslauth_token=sasl.digest_md5(user['dn'],userpwd)conn.sasl_interactive_bind_s('',auth_token)def_auth_gssapi(self,conn,user,userpwd):# print XXX not proper sasl/gssapiimportkerberosifnotkerberos.checkPassword(user[self.user_login_attr],userpwd):raiseException('BAD login / mdp')#from ldap import sasl#conn.sasl_interactive_bind_s('', sasl.gssapi())def_search(self,cnx,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))ifself._connisNone:self._conn=self._connect()ldapcnx=self._conntry:res=ldapcnx.search_s(base,scope,searchstr,attrs)exceptldap.PARTIAL_RESULTS:res=ldapcnx.result(all=0)[1]exceptldap.NO_SUCH_OBJECT:self.info('ldap NO SUCH OBJECT %s%s%s',base,scope,searchstr)self._process_no_such_object(cnx,base)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=[]forrec_dn,rec_dictinres:# 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.iteritems()exceptAttributeError:continueelse:itemdict=self._process_ldap_item(rec_dn,items)result.append(itemdict)self.debug('ldap built results %s',len(result))returnresultdef_process_ldap_item(self,dn,iterator):"""Turn an ldap received item into a proper dict."""itemdict={'dn':dn}forkey,valueiniterator:ifself.user_attrs.get(key)=='upassword':# XXx better password detectionvalue=value[0].encode('utf-8')# we only support ldap_salted_sha1 for ldap sources, see: server/utils.pyifnotvalue.startswith('{SSHA}'):value=utils.crypt_password(value)itemdict[key]=Binary(value)elifself.user_attrs.get(key)=='modification_date':itemdict[key]=datetime.strptime(value[0],'%Y%m%d%H%M%SZ')else:value=[unicode(val,'utf-8','replace')forvalinvalue]iflen(value)==1:itemdict[key]=value=value[0]else:itemdict[key]=value# we expect memberUid to be a list of user ids, make sure of itmember=self.group_rev_attrs['member']ifisinstance(itemdict.get(member),basestring):itemdict[member]=[itemdict[member]]returnitemdictdef_process_no_such_object(self,cnx,dn):"""Some search return NO_SUCH_OBJECT error, handle this (usually because an object whose dn is no more existent in ldap as been encountered). Do nothing by default, let sub-classes handle that. """