|
1 # copyright 2003-2015 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 ldap feed source""" |
|
19 |
|
20 from __future__ import division # XXX why? |
|
21 |
|
22 from datetime import datetime |
|
23 |
|
24 from six import PY2, string_types |
|
25 |
|
26 import ldap3 |
|
27 |
|
28 from logilab.common.configuration import merge_options |
|
29 |
|
30 from cubicweb import ValidationError, AuthenticationError, Binary |
|
31 from cubicweb.server import utils |
|
32 from cubicweb.server.sources import datafeed |
|
33 |
|
34 from cubicweb import _ |
|
35 |
|
36 # search scopes |
|
37 BASE = ldap3.SEARCH_SCOPE_BASE_OBJECT |
|
38 ONELEVEL = ldap3.SEARCH_SCOPE_SINGLE_LEVEL |
|
39 SUBTREE = ldap3.SEARCH_SCOPE_WHOLE_SUBTREE |
|
40 LDAP_SCOPES = {'BASE': BASE, |
|
41 'ONELEVEL': ONELEVEL, |
|
42 'SUBTREE': SUBTREE} |
|
43 |
|
44 # map ldap protocol to their standard port |
|
45 PROTO_PORT = {'ldap': 389, |
|
46 'ldaps': 636, |
|
47 'ldapi': None, |
|
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 |
|
58 |
|
59 |
|
60 class LDAPFeedSource(datafeed.DataFeedSource): |
|
61 """LDAP feed source: unlike ldapuser source, this source is copy based and |
|
62 will import ldap content (beside passwords for authentication) into the |
|
63 system source. |
|
64 """ |
|
65 support_entities = {'CWUser': False} |
|
66 use_cwuri_as_url = False |
|
67 |
|
68 options = ( |
|
69 ('auth-mode', |
|
70 {'type' : 'choice', |
|
71 'default': 'simple', |
|
72 'choices': ('simple', 'digest_md5', 'gssapi'), |
|
73 'help': 'authentication mode used to authenticate user to the ldap.', |
|
74 'group': 'ldap-source', 'level': 3, |
|
75 }), |
|
76 ('auth-realm', |
|
77 {'type' : 'string', |
|
78 'default': None, |
|
79 'help': 'realm to use when using gssapi/kerberos authentication.', |
|
80 'group': 'ldap-source', 'level': 3, |
|
81 }), |
|
82 |
|
83 ('data-cnx-dn', |
|
84 {'type' : 'string', |
|
85 'default': '', |
|
86 'help': 'user dn to use to open data connection to the ldap (eg used \ |
|
87 to respond to rql queries). Leave empty for anonymous bind', |
|
88 'group': 'ldap-source', 'level': 1, |
|
89 }), |
|
90 ('data-cnx-password', |
|
91 {'type' : 'string', |
|
92 'default': '', |
|
93 'help': 'password to use to open data connection to the ldap (eg used to respond to rql queries). Leave empty for anonymous bind.', |
|
94 'group': 'ldap-source', 'level': 1, |
|
95 }), |
|
96 |
|
97 ('user-base-dn', |
|
98 {'type' : 'string', |
|
99 'default': '', |
|
100 'help': 'base DN to lookup for users; disable user importation mechanism if unset', |
|
101 'group': 'ldap-source', 'level': 1, |
|
102 }), |
|
103 ('user-scope', |
|
104 {'type' : 'choice', |
|
105 'default': 'ONELEVEL', |
|
106 'choices': ('BASE', 'ONELEVEL', 'SUBTREE'), |
|
107 'help': 'user search scope (valid values: "BASE", "ONELEVEL", "SUBTREE")', |
|
108 'group': 'ldap-source', 'level': 1, |
|
109 }), |
|
110 ('user-classes', |
|
111 {'type' : 'csv', |
|
112 'default': ('top', 'posixAccount'), |
|
113 'help': 'classes of user (with Active Directory, you want to say "user" here)', |
|
114 'group': 'ldap-source', 'level': 1, |
|
115 }), |
|
116 ('user-filter', |
|
117 {'type': 'string', |
|
118 'default': '', |
|
119 'help': 'additional filters to be set in the ldap query to find valid users', |
|
120 'group': 'ldap-source', 'level': 2, |
|
121 }), |
|
122 ('user-login-attr', |
|
123 {'type' : 'string', |
|
124 'default': 'uid', |
|
125 'help': 'attribute used as login on authentication (with Active Directory, you want to use "sAMAccountName" here)', |
|
126 'group': 'ldap-source', 'level': 1, |
|
127 }), |
|
128 ('user-default-group', |
|
129 {'type' : 'csv', |
|
130 'default': ('users',), |
|
131 'help': 'name of a group in which ldap users will be by default. \ |
|
132 You can set multiple groups by separating them by a comma.', |
|
133 'group': 'ldap-source', 'level': 1, |
|
134 }), |
|
135 ('user-attrs-map', |
|
136 {'type' : 'named', |
|
137 'default': {'uid': 'login'}, |
|
138 'help': 'map from ldap user attributes to cubicweb attributes (with Active Directory, you want to use sAMAccountName:login,mail:email,givenName:firstname,sn:surname)', |
|
139 'group': 'ldap-source', 'level': 1, |
|
140 }), |
|
141 ('group-base-dn', |
|
142 {'type' : 'string', |
|
143 'default': '', |
|
144 'help': 'base DN to lookup for groups; disable group importation mechanism if unset', |
|
145 'group': 'ldap-source', 'level': 1, |
|
146 }), |
|
147 ('group-scope', |
|
148 {'type' : 'choice', |
|
149 'default': 'ONELEVEL', |
|
150 'choices': ('BASE', 'ONELEVEL', 'SUBTREE'), |
|
151 'help': 'group search scope (valid values: "BASE", "ONELEVEL", "SUBTREE")', |
|
152 'group': 'ldap-source', 'level': 1, |
|
153 }), |
|
154 ('group-classes', |
|
155 {'type' : 'csv', |
|
156 'default': ('top', 'posixGroup'), |
|
157 'help': 'classes of group', |
|
158 'group': 'ldap-source', 'level': 1, |
|
159 }), |
|
160 ('group-filter', |
|
161 {'type': 'string', |
|
162 'default': '', |
|
163 'help': 'additional filters to be set in the ldap query to find valid groups', |
|
164 'group': 'ldap-source', 'level': 2, |
|
165 }), |
|
166 ('group-attrs-map', |
|
167 {'type' : 'named', |
|
168 'default': {'cn': 'name', 'memberUid': 'member'}, |
|
169 'help': 'map from ldap group attributes to cubicweb attributes', |
|
170 'group': 'ldap-source', 'level': 1, |
|
171 }), |
|
172 ) |
|
173 |
|
174 options = merge_options(datafeed.DataFeedSource.options + options, |
|
175 optgroup='ldap-source',) |
|
176 |
|
177 _conn = None |
|
178 |
|
179 def update_config(self, source_entity, typedconfig): |
|
180 """update configuration from source entity. `typedconfig` is config |
|
181 properly typed with defaults set |
|
182 """ |
|
183 super(LDAPFeedSource, self).update_config(source_entity, typedconfig) |
|
184 self.authmode = typedconfig['auth-mode'] |
|
185 self._authenticate = getattr(self, '_auth_%s' % self.authmode) |
|
186 self.cnx_dn = typedconfig['data-cnx-dn'] |
|
187 self.cnx_pwd = typedconfig['data-cnx-password'] |
|
188 self.user_base_dn = str(typedconfig['user-base-dn']) |
|
189 self.user_base_scope = globals()[typedconfig['user-scope']] |
|
190 self.user_login_attr = typedconfig['user-login-attr'] |
|
191 self.user_default_groups = typedconfig['user-default-group'] |
|
192 self.user_attrs = {'dn': 'eid', 'modifyTimestamp': 'modification_date'} |
|
193 self.user_attrs.update(typedconfig['user-attrs-map']) |
|
194 self.user_rev_attrs = dict((v, k) for k, v in self.user_attrs.items()) |
|
195 self.base_filters = ['(objectclass=%s)' % replace_filter(o) |
|
196 for o in typedconfig['user-classes']] |
|
197 if typedconfig['user-filter']: |
|
198 self.base_filters.append(typedconfig['user-filter']) |
|
199 self.group_base_dn = str(typedconfig['group-base-dn']) |
|
200 self.group_base_scope = LDAP_SCOPES[typedconfig['group-scope']] |
|
201 self.group_attrs = typedconfig['group-attrs-map'] |
|
202 self.group_attrs = {'dn': 'eid', 'modifyTimestamp': 'modification_date'} |
|
203 self.group_attrs.update(typedconfig['group-attrs-map']) |
|
204 self.group_rev_attrs = dict((v, k) for k, v in self.group_attrs.items()) |
|
205 self.group_base_filters = ['(objectClass=%s)' % replace_filter(o) |
|
206 for o in typedconfig['group-classes']] |
|
207 if typedconfig['group-filter']: |
|
208 self.group_base_filters.append(typedconfig['group-filter']) |
|
209 self._conn = None |
|
210 |
|
211 def _entity_update(self, source_entity): |
|
212 super(LDAPFeedSource, self)._entity_update(source_entity) |
|
213 if self.urls: |
|
214 if len(self.urls) > 1: |
|
215 raise ValidationError(source_entity.eid, {'url': _('can only have one url')}) |
|
216 try: |
|
217 protocol, hostport = self.urls[0].split('://') |
|
218 except ValueError: |
|
219 raise ValidationError(source_entity.eid, {'url': _('badly formatted url')}) |
|
220 if protocol not in PROTO_PORT: |
|
221 raise ValidationError(source_entity.eid, {'url': _('unsupported protocol')}) |
|
222 |
|
223 def connection_info(self): |
|
224 assert len(self.urls) == 1, self.urls |
|
225 protocol, hostport = self.urls[0].split('://') |
|
226 if protocol != 'ldapi' and ':' in hostport: |
|
227 host, port = hostport.rsplit(':', 1) |
|
228 else: |
|
229 host, port = hostport, PROTO_PORT[protocol] |
|
230 return protocol, host, port |
|
231 |
|
232 def authenticate(self, cnx, login, password=None, **kwargs): |
|
233 """return CWUser eid for the given login/password if this account is |
|
234 defined in this source, else raise `AuthenticationError` |
|
235 |
|
236 two queries are needed since passwords are stored crypted, so we have |
|
237 to fetch the salt first |
|
238 """ |
|
239 self.info('ldap authenticate %s', login) |
|
240 if not password: |
|
241 # On Windows + ADAM this would have succeeded (!!!) |
|
242 # You get Authenticated as: 'NT AUTHORITY\ANONYMOUS LOGON'. |
|
243 # we really really don't want that |
|
244 raise AuthenticationError() |
|
245 searchfilter = ['(%s=%s)' % (replace_filter(self.user_login_attr), replace_filter(login))] |
|
246 searchfilter.extend(self.base_filters) |
|
247 searchstr = '(&%s)' % ''.join(searchfilter) |
|
248 # first search the user |
|
249 try: |
|
250 user = self._search(cnx, self.user_base_dn, |
|
251 self.user_base_scope, searchstr)[0] |
|
252 except IndexError: |
|
253 # no such user |
|
254 raise AuthenticationError() |
|
255 # check password by establishing a (unused) connection |
|
256 try: |
|
257 self._connect(user, password) |
|
258 except ldap3.LDAPException as ex: |
|
259 # Something went wrong, most likely bad credentials |
|
260 self.info('while trying to authenticate %s: %s', user, ex) |
|
261 raise AuthenticationError() |
|
262 except Exception: |
|
263 self.error('while trying to authenticate %s', user, exc_info=True) |
|
264 raise AuthenticationError() |
|
265 eid = self.repo.system_source.extid2eid(cnx, user['dn'].encode('ascii')) |
|
266 if eid is None or eid < 0: |
|
267 # user is not known or has been moved away from this source |
|
268 raise AuthenticationError() |
|
269 return eid |
|
270 |
|
271 def _connect(self, user=None, userpwd=None): |
|
272 protocol, host, port = self.connection_info() |
|
273 self.info('connecting %s://%s:%s as %s', protocol, host, port, |
|
274 user and user['dn'] or 'anonymous') |
|
275 server = ldap3.Server(host, port=int(port)) |
|
276 conn = ldap3.Connection(server, user=user and user['dn'], client_strategy=ldap3.STRATEGY_SYNC_RESTARTABLE, auto_referrals=False) |
|
277 # Now bind with the credentials given. Let exceptions propagate out. |
|
278 if user is None: |
|
279 # XXX always use simple bind for data connection |
|
280 if not self.cnx_dn: |
|
281 conn.bind() |
|
282 else: |
|
283 self._authenticate(conn, {'dn': self.cnx_dn}, self.cnx_pwd) |
|
284 else: |
|
285 # user specified, we want to check user/password, no need to return |
|
286 # the connection which will be thrown out |
|
287 self._authenticate(conn, user, userpwd) |
|
288 return conn |
|
289 |
|
290 def _auth_simple(self, conn, user, userpwd): |
|
291 conn.authentication = ldap3.AUTH_SIMPLE |
|
292 conn.user = user['dn'] |
|
293 conn.password = userpwd |
|
294 conn.bind() |
|
295 |
|
296 def _auth_digest_md5(self, conn, user, userpwd): |
|
297 conn.authentication = ldap3.AUTH_SASL |
|
298 conn.sasl_mechanism = 'DIGEST-MD5' |
|
299 # realm, user, password, authz-id |
|
300 conn.sasl_credentials = (None, user['dn'], userpwd, None) |
|
301 conn.bind() |
|
302 |
|
303 def _auth_gssapi(self, conn, user, userpwd): |
|
304 conn.authentication = ldap3.AUTH_SASL |
|
305 conn.sasl_mechanism = 'GSSAPI' |
|
306 conn.bind() |
|
307 |
|
308 def _search(self, cnx, base, scope, |
|
309 searchstr='(objectClass=*)', attrs=()): |
|
310 """make an ldap query""" |
|
311 self.debug('ldap search %s %s %s %s %s', self.uri, base, scope, |
|
312 searchstr, list(attrs)) |
|
313 if self._conn is None: |
|
314 self._conn = self._connect() |
|
315 ldapcnx = self._conn |
|
316 if not ldapcnx.search(base, searchstr, search_scope=scope, attributes=attrs): |
|
317 return [] |
|
318 result = [] |
|
319 for rec in ldapcnx.response: |
|
320 if rec['type'] != 'searchResEntry': |
|
321 continue |
|
322 items = rec['attributes'].items() |
|
323 itemdict = self._process_ldap_item(rec['dn'], items) |
|
324 result.append(itemdict) |
|
325 self.debug('ldap built results %s', len(result)) |
|
326 return result |
|
327 |
|
328 def _process_ldap_item(self, dn, iterator): |
|
329 """Turn an ldap received item into a proper dict.""" |
|
330 itemdict = {'dn': dn} |
|
331 for key, value in iterator: |
|
332 if self.user_attrs.get(key) == 'upassword': # XXx better password detection |
|
333 value = value[0].encode('utf-8') |
|
334 # we only support ldap_salted_sha1 for ldap sources, see: server/utils.py |
|
335 if not value.startswith(b'{SSHA}'): |
|
336 value = utils.crypt_password(value) |
|
337 itemdict[key] = Binary(value) |
|
338 elif self.user_attrs.get(key) == 'modification_date': |
|
339 itemdict[key] = datetime.strptime(value[0], '%Y%m%d%H%M%SZ') |
|
340 else: |
|
341 if PY2 and value and isinstance(value[0], str): |
|
342 value = [unicode(val, 'utf-8', 'replace') for val in value] |
|
343 if len(value) == 1: |
|
344 itemdict[key] = value = value[0] |
|
345 else: |
|
346 itemdict[key] = value |
|
347 # we expect memberUid to be a list of user ids, make sure of it |
|
348 member = self.group_rev_attrs['member'] |
|
349 if isinstance(itemdict.get(member), string_types): |
|
350 itemdict[member] = [itemdict[member]] |
|
351 return itemdict |
|
352 |
|
353 def _process_no_such_object(self, cnx, dn): |
|
354 """Some search return NO_SUCH_OBJECT error, handle this (usually because |
|
355 an object whose dn is no more existent in ldap as been encountered). |
|
356 |
|
357 Do nothing by default, let sub-classes handle that. |
|
358 """ |