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