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