Roundup Tracker

This module is another LDAP/AD login method, which is a merge of ldap_login.py from Moinmoin 1.8.2 and ldaplogin.py described in LDAPLogin. Since I could get the LDAP/AD modules working for me, I used this one since it already work quite well with Moinmoin. I am not an LDAP expert at all, therefore I only can say it works for me.

It first tries to authenticate the user against the LDAP/AD. If this is successful then it copies all user attributes over to the local roundup user database, if these values are empty in the roundup database. The attributes are username, password, phone, address, organisation, realname. The LDAP/AD login is done via a simple bind. If no LDAP/AD login is possible a fallback to the roundup database is done. Either the username/password is in the roundup database then the login succeeds or otherwise it finally fails.

To customize for your LDAP/AD environment, use the variable CONFIG_VALS. For an AD configuration you will need to adapt the values for server_uri, bind_dn, base_dn. Furthermore, it might be that you are required to adapt some of the other values depending on your LDAP/AD configuration.

In your 'extensions' directory, create a file 'ldap_login.py' with the below content. The class LDAPLoginAction is registered with the login action.

   1 #!/usr/bin/env python
   2 # -*- coding: iso-8859-1 -*-
   3 """
   4     This is an adpated MoinMoin - LDAP / Active Directory authentication to
   5     fit the needs for roundup ldap authentification. It first tries to login
   6     via LDAP (AD). In case this fails it tries to login against the roundup
   7     database. If a user is authenticated the first time via LDAP his
   8     attributes are copied over to the roundup database, including the
   9     password.  Therefore even if later on the LDAP login does not succeed a
  10     local login would require a password for authentication. If the user exist
  11     both in LDAP and in the local database, all the non-empty attributes which
  12     are empty in the local database are copied over.
  13 
  14     This code only creates a user object, the session will be established by
  15     moin automatically.
  16 
  17     python-ldap needs to be at least 2.0.0pre06 (available since mid 2002) for
  18     ldaps support - some older debian installations (woody and older?) require
  19     libldap2-tls and python2.x-ldap-tls, otherwise you get ldap.SERVER_DOWN:
  20     "Can't contact LDAP server" - more recent debian installations have tls
  21     support in libldap2 (see dependency on gnu2tls) and also in python-ldap.
  22 
  23     TODO: allow more configuration (alias name, ...) by using callables as
  24     parameters
  25 
  26     @copyright: 2006-2008 MoinMoin:ThomasWaldmann,
  27                 2006 Nick Phillips
  28                 2009 Andreas Floeter: adpatation for roundup done
  29     @license: GNU GPL, see COPYING for details.
  30 """
  31 import logging
  32 import os
  33 import sys
  34 
  35 LOG = logging.getLogger(__name__)
  36 INSTPATH = "/var/log/roundup"
  37 logging.basicConfig(level=logging.DEBUG,
  38                     format='%(asctime)s %(levelname)s %(message)s',
  39                     filename=os.path.join(INSTPATH, "ldap.log"),
  40                     filemode='a')
  41 
  42 try:
  43     import ldap
  44 except ImportError, errmsg:
  45     LOG.error("You need to have python-ldap installed (%s)." % str(errmsg))
  46     raise
  47 
  48 from roundup import password as PW
  49 from roundup.cgi import exceptions
  50 from roundup.cgi.actions import LoginAction
  51 from roundup.i18n import _
  52 
  53 LOGIN_FAILED = 0
  54 LOGIN_SUCCEDED = 1
  55 
  56 DEFAULT_VALS = {
  57     'use_local_auth' : None,
  58     # ldap / active directory server URI use ldaps://server:636 url for
  59     # ldaps, use ldap://server for ldap without tls (and set start_tls to
  60     # 0), use ldap://server for ldap with tls (and set start_tls to 1 or
  61     # 2).
  62     'server_uri' : 'ldap://localhost',
  63     # We can either use some fixed user and password for binding to LDAP.
  64     # Be careful if you need a % char in those strings - as they are used
  65     # as a format string, you have to write %% to get a single % in the
  66     # end.
  67 
  68     #'bind_dn' : 'binduser@example.org' # (AD)
  69     #'bind_dn' : 'cn=admin,dc=example,dc=org' # (OpenLDAP)
  70     #'bind_pw' : 'secret'
  71     # or we can use the username and password we got from the user:
  72     #'bind_dn' : '%(username)s@example.org'
  73     # DN we use for first bind (AD)
  74     #'bind_pw' : '%(password)s' # password we use for first bind
  75     # or we can bind anonymously (if that is supported by your directory).
  76     # In any case, bind_dn and bind_pw must be defined.
  77     'bind_dn' : '',  
  78     'bind_pw' : '',
  79     # base DN we use for searching
  80     #base_dn : 'ou=SOMEUNIT,dc=example,dc=org'
  81     'base_dn' : '',
  82     # scope of the search we do (2 == ldap.SCOPE_SUBTREE)
  83     'scope' : ldap.SCOPE_SUBTREE, 
  84     # LDAP REFERRALS (0 needed for AD)
  85     'referrals' : 0, 
  86     # ldap filter used for searching:
  87     #search_filter : '(sAMAccountName=%(username)s)' # (AD)
  88     #search_filter : '(uid=%(username)s)' # (OpenLDAP)
  89     # you can also do more complex filtering like:
  90     # "(&(cn=%(username)s)(memberOf=CN=WikiUsers,OU=Groups,\
  91     #  DC=example,DC=org))"
  92     'search_filter' : '(uid=%(username)s)',
  93     # some attribute names we use to extract information from LDAP:
  94     # ('givenName') ldap attribute we get the first name from
  95     'givenname_attribute' : None, 
  96     # ('sn') ldap attribute we get the family name from
  97     'surname_attribute' : None,
  98     # ('displayName') ldap attribute we get the aliasname from
  99     'aliasname_attribute' : None,
 100     # ('mail') ldap attribute we get the email address from
 101     'email_attribute' : None,
 102     # called to make up email address
 103     'email_callback' : None,
 104     # phone number
 105     'telephonenumber_attribute' : None,
 106     # department
 107     'department_attribute' : None,
 108     # coding used for ldap queries and result values
 109     'coding' : 'utf-8',
 110     # how long we wait for the ldap server [s]
 111     'timeout' : 10,
 112     # 0 = No, 1 = Try, 2 = Required
 113     'start_tls' : 0,
 114     'tls_cacertdir' : '',
 115     'tls_cacertfile' : '',
 116     'tls_certfile' : '',
 117     'tls_keyfile' : '',
 118     # 0 == ldap.OPT_X_TLS_NEVER (needed for self-signed certs)
 119     'tls_require_cert' : 0,
 120     # set to True to only do one bind - useful if configured to bind as
 121     # the user on the first attempt
 122     'bind_once' : False,
 123     # set to True if you want to autocreate user profiles
 124     'autocreate' : False,
 125     }
 126 
 127 CONFIG_VALS = {'referrals' : 0,
 128                'use_local_auth' : None,
 129                'server_uri' : 'ldap://ad_server.your.domain',
 130                'bind_dn' : '%(username)s@AD.DOMAIN.NAME',
 131                'bind_pw' : '%(password)s',
 132                'base_dn' : 'dc=ad,dc=domain,dc=name',
 133                'search_filter' : '(sAMAccountName=%(username)s)',
 134                'givenname_attribute' : 'givenName',
 135                'surname_attribute' : 'sn',
 136                'aliasname_attribute' : 'displayName',
 137                'email_attribute' : 'mail',
 138                'telephonenumber_attribute' : 'telephoneNumber',
 139                'department_attribute' : 'department',
 140                'autocreate' : True
 141                }
 142 
 143 class LDAPLoginAction(LoginAction):
 144     """ get authentication data from form, authenticate against LDAP (or Active
 145         Directory), fetch some user infos from LDAP and create a user object
 146         for that user. The session is kept by moin automatically.
 147     """
 148     def __init__(self, *args):
 149         self.set_values(DEFAULT_VALS)
 150         # self.use_local_auth = use_local_auth
 151 
 152         # self.server_uri = server_uri
 153         # self.bind_dn = bind_dn
 154         # self.bind_pw = bind_pw
 155         # self.base_dn = base_dn
 156         # self.scope = scope
 157         # self.referrals = referrals
 158         # self.search_filter = search_filter
 159 
 160         # self.givenname_attribute = givenname_attribute
 161         # self.surname_attribute = surname_attribute
 162         # self.aliasname_attribute = aliasname_attribute
 163         # self.email_attribute = email_attribute
 164         # self.email_callback = email_callback
 165         # self.telephonenumber_attribute = telephonenumber_attribute
 166         # self.department_attribute = department_attribute
 167 
 168         # self.coding = coding
 169         # self.timeout = timeout
 170 
 171         # self.start_tls = start_tls
 172         # self.tls_cacertdir = tls_cacertdir
 173         # self.tls_cacertfile = tls_cacertfile
 174         # self.tls_certfile = tls_certfile
 175         # self.tls_keyfile = tls_keyfile
 176         # self.tls_require_cert = tls_require_cert
 177 
 178         # self.bind_once = bind_once
 179         # self.autocreate = autocreate
 180         LoginAction.__init__(self, *args)
 181 
 182     def ldap_login(self, username='', password=''):
 183         """Perform a login against LDAP."""
 184         self.auth_method = 'ldap'
 185 
 186         # we require non-empty password as ldap bind does a anon (not password
 187         # protected) bind if the password is empty and SUCCEEDS!
 188         if not password:
 189             msg = _('Empty password for user "%s"') % self.client.user
 190             LOG.debug(msg)
 191             self.client.error_message.append(msg)
 192             return LOGIN_FAILED
 193         try:
 194             try:
 195 #                u = None
 196                 dn = None
 197                 coding = self.coding
 198                 LOG.debug("Setting misc. ldap options...")
 199                 # ldap v2 is outdated
 200                 ldap.set_option(ldap.OPT_PROTOCOL_VERSION, ldap.VERSION3) 
 201                 ldap.set_option(ldap.OPT_REFERRALS, self.referrals)
 202                 ldap.set_option(ldap.OPT_NETWORK_TIMEOUT, self.timeout)
 203 
 204                 if hasattr(ldap, 'TLS_AVAIL') and ldap.TLS_AVAIL:
 205                     for option, value in (
 206                         (ldap.OPT_X_TLS_CACERTDIR, self.tls_cacertdir),
 207                         (ldap.OPT_X_TLS_CACERTFILE, self.tls_cacertfile),
 208                         (ldap.OPT_X_TLS_CERTFILE, self.tls_certfile),
 209                         (ldap.OPT_X_TLS_KEYFILE, self.tls_keyfile),
 210                         (ldap.OPT_X_TLS_REQUIRE_CERT, self.tls_require_cert),
 211                         (ldap.OPT_X_TLS, self.start_tls),
 212                         #(ldap.OPT_X_TLS_ALLOW, 1),
 213                     ):
 214                         if value is not None:
 215                             ldap.set_option(option, value)
 216 
 217                 server = self.server_uri
 218                 LOG.debug("Trying to initialize %r." % server)
 219                 l = ldap.initialize(server)
 220                 LOG.debug("Connected to LDAP server %r." % server)
 221 
 222                 if self.start_tls and server.startswith('ldap:'):
 223                     LOG.debug("Trying to start TLS to %r." % server)
 224                     try:
 225                         l.start_tls_s()
 226                         LOG.debug("Using TLS to %r." % server)
 227                     except (ldap.SERVER_DOWN, ldap.CONNECT_ERROR), err:
 228                         LOG.warning("Couldn't establish TLS to %r (err: %s)." %\
 229                                         (server, str(err)))
 230                         return LOGIN_FAILED
 231 
 232                 # you can use %(username)s and %(password)s here to get the
 233                 # stuff entered in the form:
 234                 binddn = self.bind_dn % locals()
 235                 bindpw = self.bind_pw % locals()
 236                 l.simple_bind_s(binddn.encode(coding), bindpw.encode(coding))
 237                 LOG.debug("Bound with binddn %r" % binddn)
 238 
 239                 # you can use %(username)s here to get the stuff entered in
 240                 # the form:
 241                 filterstr = self.search_filter % locals()
 242                 LOG.debug("Searching %r" % filterstr)
 243                 attrs = [getattr(self, attr) for attr in [
 244                                          'email_attribute',
 245                                          'aliasname_attribute',
 246                                          'surname_attribute',
 247                                          'givenname_attribute',
 248                                          'telephonenumber_attribute',
 249                                          'department_attribute',
 250                                          ] if getattr(self, attr) is not None]
 251                 lusers = l.search_st(self.base_dn, self.scope,
 252                                      filterstr.encode(coding), attrlist=attrs,
 253                                      timeout=self.timeout)
 254                 # we remove entries with dn == None to get the real result list:
 255                 lusers = [(dn, ldap_dict) for dn, ldap_dict in lusers \
 256                               if dn is not None]
 257                 for dn, ldap_dict in lusers:
 258                     LOG.debug("dn:%r" % dn)
 259                     for key, val in ldap_dict.items():
 260                         LOG.debug("    %r: %r" % (key, val))
 261 
 262                 result_length = len(lusers)
 263                 if result_length != 1:
 264                     if result_length > 1:
 265                         LOG.warning("Search found more than one (%d) matches \
 266 for %r." % (result_length, filterstr))
 267                     if result_length == 0:
 268                         LOG.debug("Search found no matches for %r." % \
 269                                       (filterstr, ))
 270                     msg = _("Invalid username or password.")
 271                     LOG.debug(msg)
 272                     self.client.error_message.append(msg)
 273                     return LOGIN_FAILED
 274 
 275                 dn, ldap_dict = lusers[0]
 276                 if not self.bind_once:
 277                     LOG.debug("DN found is %r, trying to bind with pw" % dn)
 278                     l.simple_bind_s(dn, password.encode(coding))
 279                     LOG.debug("Bound with dn %r (username: %r)" % \
 280                                   (dn, username))
 281 
 282                 if self.email_callback is None:
 283                     if self.email_attribute:
 284                         email = ldap_dict.get(self.email_attribute, [''])[0].\
 285 decode(coding)
 286                     else:
 287                         email = None
 288                 else:
 289                     email = self.email_callback(ldap_dict)
 290 
 291                 aliasname = ''
 292                 try:
 293                     aliasname = ldap_dict[self.aliasname_attribute][0]
 294                 except (KeyError, IndexError):
 295                     pass
 296                 if not aliasname:
 297                     sn = ldap_dict.get(self.surname_attribute, [''])[0]
 298                     gn = ldap_dict.get(self.givenname_attribute, [''])[0]
 299                     if sn and gn:
 300                         aliasname = "%s, %s" % (sn, gn)
 301                     elif sn:
 302                         aliasname = sn
 303                 aliasname = aliasname.decode(coding)
 304                 try:
 305                     phonenumber = ldap_dict.get(self.telephonenumber_attribute,
 306                                                 [''])[0]
 307                 except (KeyError, IndexError):
 308                     phonenumber = ''
 309                 try:
 310                     department = ldap_dict.get(self.department_attribute,
 311                                                [''])[0]
 312                 except (KeyError, IndexError):
 313                     department = ''
 314 
 315                 LOG.debug("User data [%r, %r, %r, %r, %r] " % \
 316                               (username, phonenumber, email, department,
 317                                aliasname))
 318                 self.add_attr_local_user(username=username,
 319                                          password=password,
 320                                          phone=phonenumber,
 321                                          address=email,
 322                                          organisation=department,
 323                                          realname=aliasname)
 324                 msg = "Login succeded with LDAP authentication for user '%s'." \
 325 % username
 326                 LOG.debug(msg)
 327                 # Determine whether the user has permission to log in. Base
 328                 # behaviour is to check the user has "Web Access".
 329                 rights = "Web Access"
 330                 if not self.hasPermission(rights):
 331                     msg = _("You do not have permission '%s' to login" % rights)
 332                     LOG.debug("%s, %s, %s", msg, self.client.user, rights)
 333                     raise exceptions.LoginError, msg
 334                 return LOGIN_SUCCEDED
 335             except ldap.INVALID_CREDENTIALS, err:
 336                 LOG.debug("invalid credentials (wrong password?) for dn %r \
 337 (username: %r)" % (dn, username))
 338                 return LOGIN_FAILED
 339         except ldap.SERVER_DOWN, err:
 340             # looks like this LDAP server isn't working, so we just try the
 341             # next authenticator object in cfg.auth list (there could be some
 342             # second ldap authenticator that queries a backup server or any
 343             # other auth method).
 344             ## only one auth server supported for roundup, change it
 345             LOG.error("LDAP server %s failed (%s). Trying to authenticate \
 346 with next auth list entry." % (server, str(err)))
 347             msg = "LDAP server %(server)s failed." % {'server': server}
 348             LOG.debug(msg)
 349             return LOGIN_FAILED
 350         except Exception, err:
 351             LOG.error("Couldn't establish TLS to %r (err: %s)." % (server,
 352                                                                      str(err)))
 353             LOG.exception("caught an exception, traceback follows...")
 354             return LOGIN_FAILED
 355 
 356     def set_values(self, props):
 357         for kprop, value in props.items():
 358             setattr(self, kprop, value)
 359 
 360     def local_user_exists(self):
 361         """Verify if the given user exists. As a side effect set the
 362         'client.userid'."""
 363         # make sure the user exists
 364         try:
 365             self.client.userid = self.db.user.lookup(self.client.user)
 366         except KeyError:
 367             msg = _("Unknown user '%s'") % self.client.user
 368             LOG.debug("__['%s'", msg)
 369             self.client.error_message.append(
 370                         _("Unknown user  '%s'") % self.client.user)
 371             return False
 372         return True
 373 
 374     def local_login(self, password):
 375         """Try local authentication."""
 376         self.auth_method = 'localdb'
 377         if not self.local_user_exists():
 378             return LOGIN_FAILED
 379         if not self.verifyPassword(self.client.userid, password):
 380             msg = _('Invalid password')
 381             LOG.debug("%s for userid=%s", msg, self.client.userid)
 382             self.client.error_message.append(msg)
 383             return LOGIN_FAILED
 384 
 385         # Determine whether the user has permission to log in. Base behaviour
 386         # is to check the user has "Web Access".
 387         rights = "Web Access"
 388         if not self.hasPermission(rights):
 389             msg = _("You do not have permission to login")
 390             LOG.debug("%s, %s, %s", msg, self.client.user, rights)
 391             raise exceptions.LoginError, msg
 392         return LOGIN_SUCCEDED
 393 
 394     def verifyLogin(self, username, password):
 395         """Verify the login of `username` with `password`. Try first LDAP if
 396         this is specified as authentication source, and then login against
 397         local database."""
 398         LOG = self.db.get_logger()
 399         LOG.debug("username=%s password=%s", username, '*'*len(password))
 400         self.set_values(CONFIG_VALS)
 401         authenticated = False
 402         if not self.use_local_auth:
 403             LOG.debug("LDAP authentication")
 404             authenticated = self.ldap_login(username, password)
 405             if authenticated:
 406                 LOG.debug("User '%s' authenticated against LDAP.",
 407                           username)
 408         if not authenticated:
 409             LOG.debug("Local database authentication")
 410             authenticated = self.local_login(password)
 411             if authenticated:
 412                 LOG.debug("User '%s' authenticated against local database.",
 413                           username)
 414         if not authenticated:
 415             msg = _("Could not authenticate user '%s'" % username)
 416             LOG.debug(msg)
 417             raise exceptions.LoginError, msg
 418         return authenticated
 419 
 420     def add_attr_local_user(self, **props):
 421         """Add the attributes `props` for a user to the local database if
 422         those are still empty. If 'self.autocreate' is False then the user is
 423         considered a new user."""
 424         props['password'] = PW.Password(props['password'])
 425         self.db.journaltag = 'admin'
 426         try:
 427             self.client.userid = self.db.user.lookup(self.client.user)
 428             # update the empty values with LDAP values
 429             uid = self.client.userid
 430             if self.autocreate:
 431                 for pkey, prop in props.items():
 432                     try:
 433                         LOG.debug("Look key '%s' for user '%s'", pkey, uid)
 434                         value = self.db.user.get(uid, pkey)
 435                         LOG.debug("Value %r for key,user '%s','%s'", value,
 436                                   pkey, uid)
 437                         if not value:
 438                             LOG.debug("Set value %r for property %r of user \
 439 '%s'", props[pkey], pkey, self.client.user)
 440                             pair = {pkey : props[pkey]}
 441                             self.db.user.set(uid, **pair)
 442                     except Exception, err_msg:
 443                         LOG.exception("caught an exception, traceback follows.\
 444 ..")
 445         except KeyError:
 446             # add new user to local database
 447             props['roles'] = self.db.config.NEW_WEB_USER_ROLES
 448             self.userid = self.db.user.create(**props)
 449             self.db.commit()
 450             ## ?? why do we re-read the userid ??
 451             # self.client.userid = self.db.user.lookup(self.client.user)
 452             msg = u"New account created for user '%s'" % props['username']
 453             LOG.debug(msg)
 454             self.client.ok_message.append(msg)
 455 
 456 def init(instance):
 457     """Register the roundup action 'login'."""
 458     instance.registerAction('login', LDAPLoginAction)


CategoryActions CategoryAuthentication