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