##// END OF EJS Templates
ldap: changed plugin to help with debugging and error handling....
marcink -
r4303:5bc0438b default
parent child Browse files
Show More
@@ -1,535 +1,551 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2019 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 RhodeCode authentication plugin for LDAP
23 23 """
24 24
25 25 import logging
26 26 import traceback
27 27
28 28 import colander
29 29 from rhodecode.translation import _
30 30 from rhodecode.authentication.base import (
31 31 RhodeCodeExternalAuthPlugin, AuthLdapBase, hybrid_property)
32 32 from rhodecode.authentication.schema import AuthnPluginSettingsSchemaBase
33 33 from rhodecode.authentication.routes import AuthnPluginResourceBase
34 34 from rhodecode.lib.colander_utils import strip_whitespace
35 35 from rhodecode.lib.exceptions import (
36 36 LdapConnectionError, LdapUsernameError, LdapPasswordError, LdapImportError
37 37 )
38 38 from rhodecode.lib.utils2 import safe_unicode, safe_str
39 39 from rhodecode.model.db import User
40 40 from rhodecode.model.validators import Missing
41 41
42 42 log = logging.getLogger(__name__)
43 43
44 44 try:
45 45 import ldap
46 46 except ImportError:
47 47 # means that python-ldap is not installed, we use Missing object to mark
48 48 # ldap lib is Missing
49 49 ldap = Missing
50 50
51 51
52 52 class LdapError(Exception):
53 53 pass
54 54
55 55
56 56 def plugin_factory(plugin_id, *args, **kwargs):
57 57 """
58 58 Factory function that is called during plugin discovery.
59 59 It returns the plugin instance.
60 60 """
61 61 plugin = RhodeCodeAuthPlugin(plugin_id)
62 62 return plugin
63 63
64 64
65 65 class LdapAuthnResource(AuthnPluginResourceBase):
66 66 pass
67 67
68 68
69 69 class AuthLdap(AuthLdapBase):
70 70 default_tls_cert_dir = '/etc/openldap/cacerts'
71 71
72 scope_labels = {
73 ldap.SCOPE_BASE: 'SCOPE_BASE',
74 ldap.SCOPE_ONELEVEL: 'SCOPE_ONELEVEL',
75 ldap.SCOPE_SUBTREE: 'SCOPE_SUBTREE',
76 }
77
72 78 def __init__(self, server, base_dn, port=389, bind_dn='', bind_pass='',
73 79 tls_kind='PLAIN', tls_reqcert='DEMAND', tls_cert_file=None,
74 80 tls_cert_dir=None, ldap_version=3,
75 81 search_scope='SUBTREE', attr_login='uid',
76 82 ldap_filter='', timeout=None):
77 83 if ldap == Missing:
78 84 raise LdapImportError("Missing or incompatible ldap library")
79 85
80 86 self.debug = False
81 87 self.timeout = timeout or 60 * 5
82 88 self.ldap_version = ldap_version
83 89 self.ldap_server_type = 'ldap'
84 90
85 91 self.TLS_KIND = tls_kind
86 92
87 93 if self.TLS_KIND == 'LDAPS':
88 94 port = port or 636
89 95 self.ldap_server_type += 's'
90 96
91 97 OPT_X_TLS_DEMAND = 2
92 98 self.TLS_REQCERT = getattr(ldap, 'OPT_X_TLS_%s' % tls_reqcert, OPT_X_TLS_DEMAND)
93 99 self.TLS_CERT_FILE = tls_cert_file or ''
94 100 self.TLS_CERT_DIR = tls_cert_dir or self.default_tls_cert_dir
95 101
96 102 # split server into list
97 103 self.SERVER_ADDRESSES = self._get_server_list(server)
98 104 self.LDAP_SERVER_PORT = port
99 105
100 106 # USE FOR READ ONLY BIND TO LDAP SERVER
101 107 self.attr_login = attr_login
102 108
103 109 self.LDAP_BIND_DN = safe_str(bind_dn)
104 110 self.LDAP_BIND_PASS = safe_str(bind_pass)
105 111
106 112 self.SEARCH_SCOPE = getattr(ldap, 'SCOPE_%s' % search_scope)
107 113 self.BASE_DN = safe_str(base_dn)
108 114 self.LDAP_FILTER = safe_str(ldap_filter)
109 115
110 116 def _get_ldap_conn(self):
111 117
112 118 if self.debug:
113 119 ldap.set_option(ldap.OPT_DEBUG_LEVEL, 255)
114 120
115 121 if self.TLS_CERT_FILE and hasattr(ldap, 'OPT_X_TLS_CACERTFILE'):
116 122 ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, self.TLS_CERT_FILE)
117 123
118 124 elif hasattr(ldap, 'OPT_X_TLS_CACERTDIR'):
119 125 ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, self.TLS_CERT_DIR)
120 126
121 127 if self.TLS_KIND != 'PLAIN':
122 128 ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, self.TLS_REQCERT)
123 129
124 130 ldap.set_option(ldap.OPT_REFERRALS, ldap.OPT_OFF)
125 131 ldap.set_option(ldap.OPT_RESTART, ldap.OPT_ON)
126 132
127 133 # init connection now
128 134 ldap_servers = self._build_servers(
129 135 self.ldap_server_type, self.SERVER_ADDRESSES, self.LDAP_SERVER_PORT)
130 136 log.debug('initializing LDAP connection to:%s', ldap_servers)
131 137 ldap_conn = ldap.initialize(ldap_servers)
132 138 ldap_conn.set_option(ldap.OPT_NETWORK_TIMEOUT, self.timeout)
133 139 ldap_conn.set_option(ldap.OPT_TIMEOUT, self.timeout)
134 140 ldap_conn.timeout = self.timeout
135 141
136 142 if self.ldap_version == 2:
137 143 ldap_conn.protocol = ldap.VERSION2
138 144 else:
139 145 ldap_conn.protocol = ldap.VERSION3
140 146
141 147 if self.TLS_KIND == 'START_TLS':
142 148 ldap_conn.start_tls_s()
143 149
144 150 if self.LDAP_BIND_DN and self.LDAP_BIND_PASS:
145 151 log.debug('Trying simple_bind with password and given login DN: %r',
146 152 self.LDAP_BIND_DN)
147 153 ldap_conn.simple_bind_s(self.LDAP_BIND_DN, self.LDAP_BIND_PASS)
148 154 log.debug('simple_bind successful')
149 155 return ldap_conn
150 156
151 def fetch_attrs_from_simple_bind(self, server, dn, username, password):
157 def fetch_attrs_from_simple_bind(self, ldap_conn, dn, username, password):
158 scope = ldap.SCOPE_BASE
159 scope_label = self.scope_labels.get(scope)
160 ldap_filter = '(objectClass=*)'
161
152 162 try:
153 log.debug('Trying simple bind with %r', dn)
154 server.simple_bind_s(dn, safe_str(password))
155 _dn, attrs = server.search_ext_s(
156 dn, ldap.SCOPE_BASE, '(objectClass=*)', )[0]
163 log.debug('Trying authenticated search bind with dn: %r SCOPE: %s (and filter: %s)',
164 dn, scope_label, ldap_filter)
165 ldap_conn.simple_bind_s(dn, safe_str(password))
166 response = ldap_conn.search_ext_s(dn, scope, ldap_filter, attrlist=['*', '+'])
157 167
168 if not response:
169 log.error('search bind returned empty results: %r', response)
170 return {}
171 else:
172 _dn, attrs = response[0]
158 173 return attrs
159 174
160 175 except ldap.INVALID_CREDENTIALS:
161 log.debug(
162 "LDAP rejected password for user '%s': %s, org_exc:",
176 log.debug("LDAP rejected password for user '%s': %s, org_exc:",
163 177 username, dn, exc_info=True)
164 178
165 179 def authenticate_ldap(self, username, password):
166 180 """
167 181 Authenticate a user via LDAP and return his/her LDAP properties.
168 182
169 183 Raises AuthenticationError if the credentials are rejected, or
170 184 EnvironmentError if the LDAP server can't be reached.
171 185
172 186 :param username: username
173 187 :param password: password
174 188 """
175 189
176 190 uid = self.get_uid(username, self.SERVER_ADDRESSES)
177 191 user_attrs = {}
178 192 dn = ''
179 193
180 194 self.validate_password(username, password)
181 195 self.validate_username(username)
196 scope_label = self.scope_labels.get(self.SEARCH_SCOPE)
182 197
183 198 ldap_conn = None
184 199 try:
185 200 ldap_conn = self._get_ldap_conn()
186 201 filter_ = '(&%s(%s=%s))' % (
187 202 self.LDAP_FILTER, self.attr_login, username)
188 log.debug("Authenticating %r filter %s", self.BASE_DN, filter_)
203 log.debug("Authenticating %r filter %s and scope: %s",
204 self.BASE_DN, filter_, scope_label)
189 205
190 lobjects = ldap_conn.search_ext_s(
191 self.BASE_DN, self.SEARCH_SCOPE, filter_)
206 ldap_objects = ldap_conn.search_ext_s(
207 self.BASE_DN, self.SEARCH_SCOPE, filter_, attrlist=['*', '+'])
192 208
193 if not lobjects:
209 if not ldap_objects:
194 210 log.debug("No matching LDAP objects for authentication "
195 211 "of UID:'%s' username:(%s)", uid, username)
196 212 raise ldap.NO_SUCH_OBJECT()
197 213
198 log.debug('Found matching ldap object, trying to authenticate')
199 for (dn, _attrs) in lobjects:
214 log.debug('Found %s matching ldap object[s], trying to authenticate on each one now...', len(ldap_objects))
215 for (dn, _attrs) in ldap_objects:
200 216 if dn is None:
201 217 continue
202 218
203 219 user_attrs = self.fetch_attrs_from_simple_bind(
204 220 ldap_conn, dn, username, password)
221
205 222 if user_attrs:
223 log.debug('Got authenticated user attributes from DN:%s', dn)
206 224 break
207 225 else:
208 226 raise LdapPasswordError(
209 'Failed to authenticate user `{}` '
210 'with given password'.format(username))
227 'Failed to authenticate user `{}` with given password'.format(username))
211 228
212 229 except ldap.NO_SUCH_OBJECT:
213 230 log.debug("LDAP says no such user '%s' (%s), org_exc:",
214 231 uid, username, exc_info=True)
215 232 raise LdapUsernameError('Unable to find user')
216 233 except ldap.SERVER_DOWN:
217 234 org_exc = traceback.format_exc()
218 235 raise LdapConnectionError(
219 "LDAP can't access authentication "
220 "server, org_exc:%s" % org_exc)
236 "LDAP can't access authentication server, org_exc:%s" % org_exc)
221 237 finally:
222 238 if ldap_conn:
223 239 log.debug('ldap: connection release')
224 240 try:
225 241 ldap_conn.unbind_s()
226 242 except Exception:
227 243 # for any reason this can raise exception we must catch it
228 244 # to not crush the server
229 245 pass
230 246
231 247 return dn, user_attrs
232 248
233 249
234 250 class LdapSettingsSchema(AuthnPluginSettingsSchemaBase):
235 251 tls_kind_choices = ['PLAIN', 'LDAPS', 'START_TLS']
236 252 tls_reqcert_choices = ['NEVER', 'ALLOW', 'TRY', 'DEMAND', 'HARD']
237 253 search_scope_choices = ['BASE', 'ONELEVEL', 'SUBTREE']
238 254
239 255 host = colander.SchemaNode(
240 256 colander.String(),
241 257 default='',
242 258 description=_('Host[s] of the LDAP Server \n'
243 259 '(e.g., 192.168.2.154, or ldap-server.domain.com.\n '
244 260 'Multiple servers can be specified using commas'),
245 261 preparer=strip_whitespace,
246 262 title=_('LDAP Host'),
247 263 widget='string')
248 264 port = colander.SchemaNode(
249 265 colander.Int(),
250 266 default=389,
251 267 description=_('Custom port that the LDAP server is listening on. '
252 268 'Default value is: 389, use 636 for LDAPS (SSL)'),
253 269 preparer=strip_whitespace,
254 270 title=_('Port'),
255 271 validator=colander.Range(min=0, max=65536),
256 272 widget='int')
257 273
258 274 timeout = colander.SchemaNode(
259 275 colander.Int(),
260 276 default=60 * 5,
261 277 description=_('Timeout for LDAP connection'),
262 278 preparer=strip_whitespace,
263 279 title=_('Connection timeout'),
264 280 validator=colander.Range(min=1),
265 281 widget='int')
266 282
267 283 dn_user = colander.SchemaNode(
268 284 colander.String(),
269 285 default='',
270 286 description=_('Optional user DN/account to connect to LDAP if authentication is required. \n'
271 287 'e.g., cn=admin,dc=mydomain,dc=com, or '
272 288 'uid=root,cn=users,dc=mydomain,dc=com, or admin@mydomain.com'),
273 289 missing='',
274 290 preparer=strip_whitespace,
275 291 title=_('Bind account'),
276 292 widget='string')
277 293 dn_pass = colander.SchemaNode(
278 294 colander.String(),
279 295 default='',
280 296 description=_('Password to authenticate for given user DN.'),
281 297 missing='',
282 298 preparer=strip_whitespace,
283 299 title=_('Bind account password'),
284 300 widget='password')
285 301 tls_kind = colander.SchemaNode(
286 302 colander.String(),
287 303 default=tls_kind_choices[0],
288 304 description=_('TLS Type'),
289 305 title=_('Connection Security'),
290 306 validator=colander.OneOf(tls_kind_choices),
291 307 widget='select')
292 308 tls_reqcert = colander.SchemaNode(
293 309 colander.String(),
294 310 default=tls_reqcert_choices[0],
295 311 description=_('Require Cert over TLS?. Self-signed and custom '
296 312 'certificates can be used when\n `RhodeCode Certificate` '
297 313 'found in admin > settings > system info page is extended.'),
298 314 title=_('Certificate Checks'),
299 315 validator=colander.OneOf(tls_reqcert_choices),
300 316 widget='select')
301 317 tls_cert_file = colander.SchemaNode(
302 318 colander.String(),
303 319 default='',
304 320 description=_('This specifies the PEM-format file path containing '
305 321 'certificates for use in TLS connection.\n'
306 322 'If not specified `TLS Cert dir` will be used'),
307 323 title=_('TLS Cert file'),
308 324 missing='',
309 325 widget='string')
310 326 tls_cert_dir = colander.SchemaNode(
311 327 colander.String(),
312 328 default=AuthLdap.default_tls_cert_dir,
313 329 description=_('This specifies the path of a directory that contains individual '
314 330 'CA certificates in separate files.'),
315 331 title=_('TLS Cert dir'),
316 332 widget='string')
317 333 base_dn = colander.SchemaNode(
318 334 colander.String(),
319 335 default='',
320 336 description=_('Base DN to search. Dynamic bind is supported. Add `$login` marker '
321 337 'in it to be replaced with current user username \n'
322 338 '(e.g., dc=mydomain,dc=com, or ou=Users,dc=mydomain,dc=com)'),
323 339 missing='',
324 340 preparer=strip_whitespace,
325 341 title=_('Base DN'),
326 342 widget='string')
327 343 filter = colander.SchemaNode(
328 344 colander.String(),
329 345 default='',
330 346 description=_('Filter to narrow results \n'
331 347 '(e.g., (&(objectCategory=Person)(objectClass=user)), or \n'
332 348 '(memberof=cn=rc-login,ou=groups,ou=company,dc=mydomain,dc=com)))'),
333 349 missing='',
334 350 preparer=strip_whitespace,
335 351 title=_('LDAP Search Filter'),
336 352 widget='string')
337 353
338 354 search_scope = colander.SchemaNode(
339 355 colander.String(),
340 356 default=search_scope_choices[2],
341 357 description=_('How deep to search LDAP. If unsure set to SUBTREE'),
342 358 title=_('LDAP Search Scope'),
343 359 validator=colander.OneOf(search_scope_choices),
344 360 widget='select')
345 361 attr_login = colander.SchemaNode(
346 362 colander.String(),
347 363 default='uid',
348 364 description=_('LDAP Attribute to map to user name (e.g., uid, or sAMAccountName)'),
349 365 preparer=strip_whitespace,
350 366 title=_('Login Attribute'),
351 367 missing_msg=_('The LDAP Login attribute of the CN must be specified'),
352 368 widget='string')
353 369 attr_email = colander.SchemaNode(
354 370 colander.String(),
355 371 default='',
356 372 description=_('LDAP Attribute to map to email address (e.g., mail).\n'
357 373 'Emails are a crucial part of RhodeCode. \n'
358 374 'If possible add a valid email attribute to ldap users.'),
359 375 missing='',
360 376 preparer=strip_whitespace,
361 377 title=_('Email Attribute'),
362 378 widget='string')
363 379 attr_firstname = colander.SchemaNode(
364 380 colander.String(),
365 381 default='',
366 382 description=_('LDAP Attribute to map to first name (e.g., givenName)'),
367 383 missing='',
368 384 preparer=strip_whitespace,
369 385 title=_('First Name Attribute'),
370 386 widget='string')
371 387 attr_lastname = colander.SchemaNode(
372 388 colander.String(),
373 389 default='',
374 390 description=_('LDAP Attribute to map to last name (e.g., sn)'),
375 391 missing='',
376 392 preparer=strip_whitespace,
377 393 title=_('Last Name Attribute'),
378 394 widget='string')
379 395
380 396
381 397 class RhodeCodeAuthPlugin(RhodeCodeExternalAuthPlugin):
382 398 uid = 'ldap'
383 399 # used to define dynamic binding in the
384 400 DYNAMIC_BIND_VAR = '$login'
385 401 _settings_unsafe_keys = ['dn_pass']
386 402
387 403 def includeme(self, config):
388 404 config.add_authn_plugin(self)
389 405 config.add_authn_resource(self.get_id(), LdapAuthnResource(self))
390 406 config.add_view(
391 407 'rhodecode.authentication.views.AuthnPluginViewBase',
392 408 attr='settings_get',
393 409 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
394 410 request_method='GET',
395 411 route_name='auth_home',
396 412 context=LdapAuthnResource)
397 413 config.add_view(
398 414 'rhodecode.authentication.views.AuthnPluginViewBase',
399 415 attr='settings_post',
400 416 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
401 417 request_method='POST',
402 418 route_name='auth_home',
403 419 context=LdapAuthnResource)
404 420
405 421 def get_settings_schema(self):
406 422 return LdapSettingsSchema()
407 423
408 424 def get_display_name(self):
409 425 return _('LDAP')
410 426
411 427 @classmethod
412 428 def docs(cls):
413 429 return "https://docs.rhodecode.com/RhodeCode-Enterprise/auth/auth-ldap.html"
414 430
415 431 @hybrid_property
416 432 def name(self):
417 433 return u"ldap"
418 434
419 435 def use_fake_password(self):
420 436 return True
421 437
422 438 def user_activation_state(self):
423 439 def_user_perms = User.get_default_user().AuthUser().permissions['global']
424 440 return 'hg.extern_activate.auto' in def_user_perms
425 441
426 442 def try_dynamic_binding(self, username, password, current_args):
427 443 """
428 444 Detects marker inside our original bind, and uses dynamic auth if
429 445 present
430 446 """
431 447
432 448 org_bind = current_args['bind_dn']
433 449 passwd = current_args['bind_pass']
434 450
435 451 def has_bind_marker(username):
436 452 if self.DYNAMIC_BIND_VAR in username:
437 453 return True
438 454
439 455 # we only passed in user with "special" variable
440 456 if org_bind and has_bind_marker(org_bind) and not passwd:
441 457 log.debug('Using dynamic user/password binding for ldap '
442 458 'authentication. Replacing `%s` with username',
443 459 self.DYNAMIC_BIND_VAR)
444 460 current_args['bind_dn'] = org_bind.replace(
445 461 self.DYNAMIC_BIND_VAR, username)
446 462 current_args['bind_pass'] = password
447 463
448 464 return current_args
449 465
450 466 def auth(self, userobj, username, password, settings, **kwargs):
451 467 """
452 468 Given a user object (which may be null), username, a plaintext password,
453 469 and a settings object (containing all the keys needed as listed in
454 470 settings()), authenticate this user's login attempt.
455 471
456 472 Return None on failure. On success, return a dictionary of the form:
457 473
458 474 see: RhodeCodeAuthPluginBase.auth_func_attrs
459 475 This is later validated for correctness
460 476 """
461 477
462 478 if not username or not password:
463 479 log.debug('Empty username or password skipping...')
464 480 return None
465 481
466 482 ldap_args = {
467 483 'server': settings.get('host', ''),
468 484 'base_dn': settings.get('base_dn', ''),
469 485 'port': settings.get('port'),
470 486 'bind_dn': settings.get('dn_user'),
471 487 'bind_pass': settings.get('dn_pass'),
472 488 'tls_kind': settings.get('tls_kind'),
473 489 'tls_reqcert': settings.get('tls_reqcert'),
474 490 'tls_cert_file': settings.get('tls_cert_file'),
475 491 'tls_cert_dir': settings.get('tls_cert_dir'),
476 492 'search_scope': settings.get('search_scope'),
477 493 'attr_login': settings.get('attr_login'),
478 494 'ldap_version': 3,
479 495 'ldap_filter': settings.get('filter'),
480 496 'timeout': settings.get('timeout')
481 497 }
482 498
483 499 ldap_attrs = self.try_dynamic_binding(username, password, ldap_args)
484 500
485 501 log.debug('Checking for ldap authentication.')
486 502
487 503 try:
488 504 aldap = AuthLdap(**ldap_args)
489 505 (user_dn, ldap_attrs) = aldap.authenticate_ldap(username, password)
490 506 log.debug('Got ldap DN response %s', user_dn)
491 507
492 508 def get_ldap_attr(k):
493 509 return ldap_attrs.get(settings.get(k), [''])[0]
494 510
495 511 # old attrs fetched from RhodeCode database
496 512 admin = getattr(userobj, 'admin', False)
497 513 active = getattr(userobj, 'active', True)
498 514 email = getattr(userobj, 'email', '')
499 515 username = getattr(userobj, 'username', username)
500 516 firstname = getattr(userobj, 'firstname', '')
501 517 lastname = getattr(userobj, 'lastname', '')
502 518 extern_type = getattr(userobj, 'extern_type', '')
503 519
504 520 groups = []
505 521
506 522 user_attrs = {
507 523 'username': username,
508 524 'firstname': safe_unicode(get_ldap_attr('attr_firstname') or firstname),
509 525 'lastname': safe_unicode(get_ldap_attr('attr_lastname') or lastname),
510 526 'groups': groups,
511 527 'user_group_sync': False,
512 528 'email': get_ldap_attr('attr_email') or email,
513 529 'admin': admin,
514 530 'active': active,
515 531 'active_from_extern': None,
516 532 'extern_name': user_dn,
517 533 'extern_type': extern_type,
518 534 }
519 535
520 536 log.debug('ldap user: %s', user_attrs)
521 537 log.info('user `%s` authenticated correctly', user_attrs['username'])
522 538
523 539 return user_attrs
524 540
525 541 except (LdapUsernameError, LdapPasswordError, LdapImportError):
526 542 log.exception("LDAP related exception")
527 543 return None
528 544 except (Exception,):
529 545 log.exception("Other exception")
530 546 return None
531 547
532 548
533 549 def includeme(config):
534 550 plugin_id = 'egg:rhodecode-enterprise-ce#{}'.format(RhodeCodeAuthPlugin.uid)
535 551 plugin_factory(plugin_id).includeme(config)
General Comments 0
You need to be logged in to leave comments. Login now