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