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