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