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 |
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 |
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.""" |