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