· 10 years ago · Jun 16, 2016, 03:42 PM
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2007 John Hampton <pacopablo@pacopablo.com>
4# All rights reserved.
5#
6# This software is licensed as described in the file COPYING, which
7# you should have received as part of this distribution.
8#
9# Author: John Hampton <pacopablo@pacopablo.com>
10# Extended: Branson Matheson <branson.matheson@nasa.gov>
11# changed/added lines 308, 356 361 - Alvaro Reguly
12
13import cPickle
14import hashlib
15import ldap
16import time
17
18from trac.config import IntOption, Option
19from trac.core import Component, TracError, implements
20from trac.perm import IPermissionGroupProvider
21from trac.util.text import to_unicode
22from trac.util.translation import _
23
24from acct_mgr.api import IPasswordStore
25
26GROUP_PREFIX = '@'
27NOCACHE = 0
28
29__all__ = ['DirAuthStore']
30
31
32def to_utf8(text):
33 # Account for poor behavior of to_utf8 in Trac < 1.0.2
34 if isinstance(text, unicode):
35 return text.encode('utf-8')
36 else:
37 from trac.util.text import to_utf8
38 return to_utf8(text)
39
40
41class DirAuthStore(Component):
42 """Directory Password Store for Account Manager """
43 implements(IPasswordStore, IPermissionGroupProvider)
44
45 dir_uri = Option('account-manager', 'dir_uri', 'ldap://localhost',
46 "URI of the LDAP or Active Directory Server")
47
48 dir_charset = Option('account-manager', 'dir_charset', 'utf-8',
49 "Text encoding used by the LDAP or Active "
50 "Directory Server")
51
52 dir_scope = IntOption('account-manager', 'dir_scope', 1,
53 "0=Base, 1=OneLevel, 2=Subtree")
54
55 dir_binddn = Option('account-manager', 'dir_binddn', '',
56 "DN used to bind to AD, leave blank for "
57 "anonymous bind")
58
59 dir_bindpw = Option('account-manager', 'dir_bindpw', '',
60 "Password used when binding to AD, leave blank for "
61 "anonymous bind")
62
63 dir_timeout = IntOption('account-manager', 'dir_timeout', 5,
64 "ldap response timeout in seconds")
65
66 dir_basedn = Option('account-manager', 'dir_basedn', None,
67 "Base DN used for account searches")
68
69 user_attr = Option('account-manager', 'user_attr', 'sAMAccountName',
70 "Attribute of the user in the directory")
71
72 name_attr = Option('account-manager', 'name_attr', 'displayName',
73 "Attribute of the users name in the directory")
74
75 email_attr = Option('account-manager', 'email_attr', 'mail',
76 "Attribute of the users email in the directory")
77
78 proxy_attr = Option('account-manager', 'proxy_attr', 'proxyAddress',
79 "Attribute of the users proxyAddress in the directory")
80
81 member_attr = Option('account-manager', 'member_attr', 'member',
82 "Attribute to determine members of a group")
83
84 group_class_attr = Option('account-manager', 'group_class_attr', 'group',
85 "Attribute of the group class")
86
87 group_basedn = Option('account-manager', 'group_basedn', None,
88 "Base DN used for group searches")
89
90 group_validusers = Option('account-manager', 'group_validusers', None,
91 "CN of group containing valid users. If None, "
92 "any AD user is valid")
93
94 group_tracadmin = Option('account-manager', 'group_tracadmin', None,
95 "CN of group containing TRAC_ADMIN users (can "
96 "also assign TRAC_ADMIN to an LDAP group.)")
97
98 group_expand = IntOption('account-manager', 'group_expand', 1,
99 "binary: expand ldap_groups into trac groups.")
100
101 cache_ttl = IntOption('account-manager', 'cache_timeout', 60,
102 "cache timeout in seconds")
103
104 cache_memsize = IntOption('account-manager', 'cache_memsize', 400,
105 "size of memcache in entries, zero to disable")
106
107 cache_memprune = IntOption('account-manager', 'cache_memprune', 5,
108 "percent of entries to prune")
109
110 cache_memsize_warn = IntOption('account-manager', 'cache_memsize_warn',
111 300,
112 "warning message for cache pruning in "
113 "seconds")
114
115 def __init__(self, ldap=None):
116 self._ldap = ldap
117 self._cache = {}
118
119 # IPasswordStore methods
120
121 def config_key(self):
122 """Deprecated"""
123 raise NotImplementedError
124
125 def get_users(self):
126 """Grab a list of users from the session store."""
127 all_users = self._cache_get('allusers')
128 if all_users:
129 return all_users
130
131 # Cache miss
132 lcnx = self._bind_dir()
133 self.log.info('get users')
134 if lcnx:
135 if self.group_validusers:
136 userinfo = self.expand_group_users(lcnx, self.group_validusers)
137 else:
138 users = lcnx.search_s(self.dir_basedn, ldap.SCOPE_SUBTREE,
139 "objectClass=person",
140 [to_utf8(self.user_attr),
141 to_utf8(self.email_attr),
142 to_utf8(self.proxy_attr),
143 to_utf8(self.name_attr)])
144 userinfo = [self._get_userinfo(u[1]) for u in users]
145 else:
146 raise TracError('Unable to bind to Active Directory')
147 self.log.info('get users: ' + str(userinfo))
148 return [u[0] for u in userinfo]
149
150 def expand_group_users(self, cnx, group):
151 """Given a group name, enumerate all members"""
152 if group.startswith('@'):
153 group = group[1:]
154 self.log.debug("search groups cn=%s,%s"
155 % (group, self.group_basedn))
156 g = cnx.search_s("cn=%s,%s" % (group.replace('_', ' '), self.group_basedn),
157 ldap.SCOPE_BASE,
158 attrlist=[to_utf8(self.member_attr)])
159 self.log.debug(g)
160 if g and self.member_attr in g[0][1]:
161 users = []
162 for m in g[0][1][str(self.member_attr)]:
163 self.log.debug("group expand: " + m)
164 try:
165 e = cnx.search_s(m, ldap.SCOPE_BASE)
166 if e:
167 if 'person' in e[0][1]['objectClass']:
168 users.append(self._get_userinfo(e[0][1]))
169 elif str(self.group_class_attr) in e[0][1]['objectClass']:
170 users.extend(self.expand_group_users(cnx, e[0][0]))
171 else:
172 self.log.debug('The group member (%s) is neither a group nor a person' % e[0][0])
173 else:
174 self.log.debug('Unable to find user listed in group: %s' % str(m))
175 self.log.debug('This is very strange and you should probably check '
176 'the consistency of your LDAP directory.' % str(m))
177 except Exception:
178 self.log.debug('Unable to find ldap user listed in group: %s' % str(m))
179# users.append(m)
180 return users
181 else:
182 self.log.debug('Unable to find any members of the group %s' % group)
183 return []
184
185 def has_user(self, user):
186 users = self.get_users()
187 if user in users:
188 return True
189 else:
190 return False
191
192 def check_password(self, user, password):
193 """Checks the password against LDAP."""
194 success = None
195 msg = "User Login: %s" % user
196
197 if not user or not password:
198 msg += " username or password can't be empty!"
199 self.log.info(msg)
200 return success
201
202 user_dn = self._get_user_dn(user, NOCACHE)
203 if user_dn:
204 success = self._bind_dir(user_dn, password.encode(self.dir_charset)) or False
205 if success:
206 msg += " Password Verified"
207 success = True
208 elif success is False:
209 msg += " Password Failed"
210 self.log.info(msg)
211 else:
212 msg += " does not exist, deferring authentication"
213 self.log.info(msg)
214 return success
215
216 # Check the user is part of the right group, we don't use the cache
217 # Here as this is part of 'authentication' vs 'authorization'
218 if self.group_validusers:
219 usergroups = self._expand_user_groups(user, NOCACHE)
220 if self.group_validusers not in usergroups:
221 msg += " but user is not in %s" % self.group_validusers
222 self.log.info(msg)
223 return False
224
225 # Update the session data at each login,
226 # Note the use of NoCache to force the update(s)
227 attrs = [self.user_attr, self.email_attr, self.proxy_attr, self.name_attr]
228 lfilter = '(&(%s=%s)(objectClass=person))' % (self.user_attr, user)
229 users = self._dir_search(self.dir_basedn, self.dir_scope,
230 lfilter, attrs, NOCACHE)
231
232 if not users:
233 raise TracError(_("Authenticated, but didn't find the user with "
234 "filter: %(filter)s (%(users)s)",
235 filter=filter, users=users))
236
237 # Update the session table to make this a valid user.
238 user_info = self._get_userinfo(users[0][1])
239 self._populate_user_session(user_info)
240
241 # Update the users by doing a search w/o cache
242 self.get_users()
243
244 return success
245
246 def delete_user(self, user):
247 """Can't delete from LDAP."""
248 raise NotImplementedError(_("Deleting users is not supported."))
249
250 def get_user_groups(self, user):
251 """Returns all groups for a user."""
252 return self._expand_user_groups(user)
253
254 def get_permission_groups(self, username):
255 """Return a list of names of the groups that the user with the
256 specified name is a member of."""
257 return self._expand_user_groups(username)
258
259 # Internal methods
260
261 def _bind_dir(self, user_dn=None, passwd=None):
262
263 if not self.dir_uri:
264 raise TracError(_("The dir_uri ini option must be set."))
265
266 if not self.dir_uri.lower().startswith('ldap'):
267 raise TracError(_("The dir_uri URI must start with ldaps."))
268
269 if user_dn and passwd:
270 user_ldap = ldap.ldapobject.ReconnectLDAPObject(self.dir_uri, 0,
271 '', 0, 2, 1)
272
273 self.log.debug("_bind_dir: attempting specific bind to %s as %s",
274 self.dir_uri, unicode(user_dn, 'utf8'))
275 try:
276 user_ldap.simple_bind_s(user_dn, passwd)
277 except Exception, e:
278 self.log.error("_bind_dir: binding failed. %s", e)
279 return None
280 return 1
281
282 # Return cached handle for default use.
283 if self._ldap:
284 return self._ldap
285
286 self._ldap = ldap.ldapobject.ReconnectLDAPObject(self.dir_uri,
287 retry_max=5,
288 retry_delay=1)
289
290 if self.dir_binddn:
291 self.log.debug("_bind_dir: attempting general bind to %s as %s",
292 self.dir_uri, self.dir_binddn)
293 else:
294 self.log.debug("_bind_dir: attempting general bind to %s "
295 "anonymously", self.dir_uri)
296
297 try:
298 self._ldap.simple_bind_s(self.dir_binddn, self.dir_bindpw)
299 except ldap.LDAPError, e:
300 raise TracError("cannot bind to %s: %s" % (self.dir_uri, e))
301
302 self.log.info("Bound to %s correctly.", self.dir_uri)
303
304 # Allow restarting.
305 self._ldap.set_option(ldap.OPT_RESTART, 1)
306 self._ldap.set_option(ldap.OPT_TIMEOUT, self.dir_timeout)
307 # Disable LDAP referrals
308 self._ldap.set_option(ldap.OPT_REFERRALS,0)
309
310
311 return self._ldap
312
313 # ## searches
314 def _get_user_dn(self, user, cache=1):
315 """Get users dn."""
316
317 dn = self._cache_get('dn: %s' % user)
318 if dn:
319 return dn
320
321 u = self._dir_search(self.dir_basedn, self.dir_scope,
322 "(&(%s=%s)(objectClass=person))"
323 % (self.user_attr, user),
324 [self.user_attr], cache)
325
326 if not u:
327 self.log.debug("user not found: %s", user)
328 dn = None
329 else:
330 dn = u[0][0]
331 self._cache_set('dn: %s' % user, dn)
332 self.log.debug("user %s has dn: %s", user, dn)
333 return dn
334
335 def _expand_user_groups(self, user, use_cache=1):
336 """Get a list of all groups this user belongs to. This recurses up
337 to make sure we get them all.
338 """
339
340 if use_cache:
341 groups = self._cache_get('usergroups:%s' % user)
342 if groups:
343 return groups
344
345 groups = []
346 user_dn = self._get_user_dn(user)
347
348 if not user_dn:
349 self.log.debug("username: %s has no dn.", user)
350 return []
351
352 basedn = self.group_basedn or self.dir_basedn
353 group_filter = ('(&(objectClass=%s)(%s=%s))') % (self.group_class_attr, self.member_attr, user_dn)
354 # Added replace filter to escape \ on CN
355 user_groups = self._dir_search(basedn, self.dir_scope,
356 group_filter.replace('\\', '\\\\'), ['cn'])
357 for entry in user_groups:
358 groupdn = entry[0]
359 group = entry[1]['cn'][0]
360 # Return spaces to the group names so matching works
361 group = '%s%s' % (GROUP_PREFIX, group.replace(' ', '_').lower())
362 groups.append(group) # dn
363 if group not in groups:
364 groups.append(self._get_parent_groups(groups, groupdn))
365
366 self._cache_set('usergroups:%s' % user, groups)
367 if groups:
368 self.log.debug("username %s has groups %s", user, ', '.join(groups))
369 return sorted(groups)
370 else:
371 self.log.info("username: %s has no groups.", user)
372 return []
373
374 def _get_parent_groups(self, groups, group_dn):
375 group_filter = '(&(objectClass=%s)(%s=%s)' % (self.group_class_attr, self.member_attr, group_dn)
376 basedn = self.group_basedn or self.dir_basedn
377 ldap_groups = self._dir_search(basedn, self.dir_scope,
378 group_filter, ['cn'])
379 if ldap_groups:
380 for entry in ldap_groups:
381 groupdn = entry[0]
382 group = entry[1]['cn'][0]
383 group = group.replace(' ', '_').lower()
384 if group not in groups:
385 groups.append(group)
386 groups.append(self._get_parent_groups(groups, groupdn))
387 return groups
388
389 def _get_userinfo(self, attrs):
390 """Extract the userinfo tuple from the LDAP search result."""
391 user_name = attrs[self.user_attr][0].lower()
392 display_name = attrs.get(self.name_attr, [''])[0]
393 email = ''
394 if self.email_attr in attrs:
395 email = attrs[self.email_attr][0].lower()
396 elif 'proxyAddresses' in attrs:
397 for e in attrs['proxyAddresses']:
398 if e.startswith('SMTP:'):
399 email = e[5:]
400 continue
401 return user_name, display_name, email
402
403 def _populate_user_session(self, userinfo):
404 """Create user session entries and populate email and last visit."""
405
406 # Kind of ugly. First try to insert a new session record. If it
407 # fails, don't worry, means it's already there. Second, insert the
408 # email address session attribute. If it fails, don't worry, it's
409 # already there.
410 uname, displayname, email = userinfo
411
412 db = self.env.get_db_cnx()
413 cur = db.cursor()
414 try:
415 cur.execute("""
416 DELETE FROM session
417 WHERE sid=%s AND authenticated=1
418 """, (uname,))
419 cur.execute("""
420 INSERT INTO session
421 (sid, authenticated, last_visit)
422 VALUES (%s, 1, %s)""", (uname, 0))
423 except:
424 self.log.debug("Session for %s exists.", uname)
425
426 # Assume enabled if we get this far self.env.get_known_users()
427 # needs this..
428 # TODO need to have it updated by the get_dn stuff long term so the
429 # db matches the auth source.
430 cur = db.cursor()
431 try:
432 cur.execute("""
433 DELETE FROM session_attribute
434 WHERE sid=%s AND authenticated=1 AND name='enabled'
435 """, (uname,))
436 cur.execute("""
437 INSERT INTO session_attribute
438 (sid, authenticated, name, value)
439 VALUES (%s, 1, 'enabled', '1')
440 """, (uname,))
441 except:
442 self.log.debug("Session for %s exists.", uname)
443
444 if email:
445 cur = db.cursor()
446 cur.execute("""
447 DELETE FROM session_attribute
448 WHERE sid=%s AND authenticated=1 AND name='email'
449 """, (uname,))
450 cur.execute("""
451 INSERT INTO session_attribute
452 (sid, authenticated, name, value)
453 VALUES (%s, 1, 'email', %s)
454 """, (uname, to_unicode(email)))
455 self.log.info("updating user session email info for %s (%s)",
456 uname, to_unicode(email))
457
458 if displayname:
459 cur = db.cursor()
460 cur.execute("""
461 DELETE FROM session_attribute
462 WHERE sid=%s AND authenticated=1 AND name='name'
463 """, (uname,))
464 cur.execute("""
465 INSERT INTO session_attribute
466 (sid, authenticated, name, value)
467 VALUES (%s, 1, 'name', %s)
468 """, (uname, to_unicode(displayname)))
469 self.log.info("updating user session displayname info for %s (%s)",
470 uname, to_unicode(displayname))
471 db.commit()
472 return db.close()
473
474 def _cache_get(self, key=None, ttl=None):
475 """Get an item from memory cache"""
476 cache_ttl = ttl or self.cache_ttl
477 if not self.cache_memsize:
478 return None
479
480 now = time.time()
481
482 if key in self._cache:
483 lut, data = self._cache[key]
484 if lut + cache_ttl >= now:
485 self.log.debug("memcache hit for %s", key)
486 return data
487 else:
488 del self._cache[key]
489 return None
490
491 def _cache_set(self, key=None, data=None, cache_time=None):
492 if not self.cache_memsize:
493 return None
494 now = time.time()
495 if not cache_time:
496 cache_time = now
497
498 # Prune if we need to.
499 if len(self._cache) > self.cache_memsize:
500 # Warn if too frequent.
501 if 'last_prune' in self._cache:
502 last_prune, data = self._cache['last_prune']
503 if last_prune + self.cache_memsize_warn > now:
504 self.log.info("pruning memcache in less than %d seconds, "
505 "you might increase cache_memsize.",
506 self.cache_memsize_warn)
507
508 self.log.debug("pruning memcache by %d: (current: %d > max: %d )",
509 self.cache_memprune, len(self._cache),
510 self.cache_memsize)
511 cache_keys = self._cache.keys()
512 cache_keys.sort(lambda x, y: cmp(self._cache[x][0],
513 self._cache[y][0]))
514 # Discards the 10% oldest.
515 upper = self.cache_memprune * self.cache_memsize / 100
516 old_keys = cache_keys[:upper]
517 for k in old_keys:
518 del self._cache[k]
519 self._cache['last_prune'] = [now, []]
520
521 self._cache[key] = [cache_time, data]
522 return data
523
524 def _dir_search(self, basedn, scope, lfilter, attrs=None, check_cache=1):
525 current_time = time.time()
526
527 attrs = self._decode_list(attrs or [])
528
529 if not basedn:
530 raise TracError(_("basedn not defined!"))
531 if not lfilter:
532 raise TracError(_("filter not defined!"))
533
534 # Create unique key from the filter and the attributes.
535 keystr = ','.join([basedn, str(scope), lfilter, ':'.join(attrs)])
536 key = hashlib.md5(keystr).hexdigest()
537 self.log.debug("_dir_search: searching %s for %s(%s)",
538 basedn, lfilter, key)
539
540 db = self.env.get_db_cnx()
541
542 # Check mem cache.
543 if check_cache:
544 ret = self._cache_get(key)
545 if ret:
546 return ret
547
548 # -- Check database
549 cur = db.cursor()
550 cur.execute("""
551 SELECT lut,data FROM dir_cache WHERE id=%s
552 """, (key,))
553 row = cur.fetchone()
554 if row:
555 lut, data = row
556
557 if current_time < lut + self.cache_ttl:
558 self.log.debug("dbcache hit for %s", lfilter)
559 ret = cPickle.loads(str(data))
560 self._cache_set(key, ret, lut)
561 return ret
562 else:
563 # Old data, delete it and anything else that's old.
564 lut = current_time - self.cache_ttl
565 cur.execute("""
566 DELETE FROM dir_cache WHERE lut < %s
567 """, (lut,))
568 db.commit()
569 else:
570 self.log.debug("_dir_search: skipping cache.")
571
572 d = self._bind_dir()
573 self.log.debug("_dir_search: starting LDAP search of %s %s using %s "
574 "for %s", self.dir_uri, basedn, lfilter, attrs)
575
576 res = []
577 try:
578 res = d.search_s(basedn.encode(self.dir_charset), scope,
579 lfilter, attrs)
580 except ldap.LDAPError, e:
581 self.log.error("Error searching %s using %s: %s",
582 basedn, lfilter, e)
583
584 if res:
585 self.log.debug("_dir_search: dir hit, %d entries.", len(res))
586 else:
587 self.log.debug("_dir_search: dir miss.")
588
589 if not check_cache:
590 return res
591
592 # Set the db cache for the next search, even if results are empty.
593 res_str = cPickle.dumps(res, 0)
594 try:
595 cur = db.cursor()
596 cur.execute("""
597 DELETE FROM dir_cache WHERE id=%s
598 """, (key,))
599 self.log.debug("INSERT VALUES (%s, %s, %s)"
600 % (key, current_time, buffer(res_str)))
601 cur.execute("""
602 INSERT INTO dir_cache (id, lut, data)
603 VALUES (%s, %s, %s)
604 """, (key, current_time, buffer(res_str)))
605 db.commit()
606 except Exception, e:
607 db.rollback()
608 self.log.warn("_dir_search: db cache update failed. %s" % e)
609
610 self._cache_set(key, res)
611
612 return res
613
614 # helper method for UserExtensiblePermissionStore
615 def get_all_groups(self):
616 """Get all groups. Returns an array containing arrays [dn, cn]
617 """
618
619 basedn = self.group_basedn or self.dir_basedn
620 group_filter = ('(objectClass=%s)') % self.group_class_attr
621 all_groups = self._dir_search(basedn, self.dir_scope, group_filter, ['cn'])
622
623 self.log.debug("all groups: %s" % all_groups)
624 return all_groups
625
626 def get_group_users(self, groupdn):
627 """Grab a list of users from the session store."""
628
629 lcnx = self._bind_dir()
630 self.log.info('get users')
631 if lcnx:
632 userinfo = self.expand_group_users(lcnx, groupdn)
633 else:
634 raise TracError('Unable to bind to Active Directory')
635 self.log.debug('get users: ' + str(userinfo))
636 return [u[0] for u in userinfo]
637
638 @staticmethod
639 def _decode_list(l=None):
640 newlist = []
641 if not l:
642 return l
643 for val in l:
644 newlist.append(val.encode('ascii', 'ignore'))
645 return newlist