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