Roundup Wiki

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)." %\
                                        (server, str(err)))
 229                         return LOGIN_FAILED
 230 
 231                 # you can use %(username)s and %(password)s here to get the
 232                 # stuff entered in the form:
 233                 binddn = self.bind_dn % locals()
 234                 bindpw = self.bind_pw % locals()
 235                 l.simple_bind_s(binddn.encode(coding), bindpw.encode(coding))
 236                 LOG.debug("Bound with binddn %r" % binddn)
 237 
 238                 # you can use %(username)s here to get the stuff entered in
 239                 # the form:
 240                 filterstr = self.search_filter % locals()
 241                 LOG.debug("Searching %r" % filterstr)
 242                 attrs = [getattr(self, attr) for attr in [
 243                                          'email_attribute',
 244                                          'aliasname_attribute',
 245                                          'surname_attribute',
 246                                          'givenname_attribute',
 247                                          'telephonenumber_attribute',
 248                                          'department_attribute',
 249                                          ] if getattr(self, attr) is not None]
 250                 lusers = l.search_st(self.base_dn, self.scope,
 251                                      filterstr.encode(coding), attrlist=attrs,
 252                                      timeout=self.timeout)
 253                 # we remove entries with dn == None to get the real result list:
 254                 lusers = [(dn, ldap_dict) for dn, ldap_dict in lusers \
                              if dn is not None]
 255                 for dn, ldap_dict in lusers:
 256                     LOG.debug("dn:%r" % dn)
 257                     for key, val in ldap_dict.items():
 258                         LOG.debug("    %r: %r" % (key, val))
 259 
 260                 result_length = len(lusers)
 261                 if result_length != 1:
 262                     if result_length > 1:
 263                         LOG.warning("Search found more than one (%d) matches \
 264 for %r." % (result_length, filterstr))
 265                     if result_length == 0:
 266                         LOG.debug("Search found no matches for %r." % \
                                      (filterstr, ))
 267                     msg = _("Invalid username or password.")
 268                     LOG.debug(msg)
 269                     self.client.error_message.append(msg)
 270                     return LOGIN_FAILED
 271 
 272                 dn, ldap_dict = lusers[0]
 273                 if not self.bind_once:
 274                     LOG.debug("DN found is %r, trying to bind with pw" % dn)
 275                     l.simple_bind_s(dn, password.encode(coding))
 276                     LOG.debug("Bound with dn %r (username: %r)" % \
                                  (dn, username))
 277 
 278                 if self.email_callback is None:
 279                     if self.email_attribute:
 280                         email = ldap_dict.get(self.email_attribute, [''])[0].\
decode(coding)
 281                     else:
 282                         email = None
 283                 else:
 284                     email = self.email_callback(ldap_dict)
 285 
 286                 aliasname = ''
 287                 try:
 288                     aliasname = ldap_dict[self.aliasname_attribute][0]
 289                 except (KeyError, IndexError):
 290                     pass
 291                 if not aliasname:
 292                     sn = ldap_dict.get(self.surname_attribute, [''])[0]
 293                     gn = ldap_dict.get(self.givenname_attribute, [''])[0]
 294                     if sn and gn:
 295                         aliasname = "%s, %s" % (sn, gn)
 296                     elif sn:
 297                         aliasname = sn
 298                 aliasname = aliasname.decode(coding)
 299                 try:
 300                     phonenumber = ldap_dict.get(self.telephonenumber_attribute,
 301                                                 [''])[0]
 302                 except (KeyError, IndexError):
 303                     phonenumber = ''
 304                 try:
 305                     department = ldap_dict.get(self.department_attribute,
 306                                                [''])[0]
 307                 except (KeyError, IndexError):
 308                     department = ''
 309 
 310                 LOG.debug("User data [%r, %r, %r, %r, %r] " % \
                              (username, phonenumber, email, department,
 311                                aliasname))
 312                 self.add_attr_local_user(username=username,
 313                                          password=password,
 314                                          phone=phonenumber,
 315                                          address=email,
 316                                          organisation=department,
 317                                          realname=aliasname)
 318                 msg = "Login succeded with LDAP authentication for user '%s'." \
% username
 319                 LOG.debug(msg)
 320                 # Determine whether the user has permission to log in. Base
 321                 # behaviour is to check the user has "Web Access".
 322                 rights = "Web Access"
 323                 if not self.hasPermission(rights):
 324                     msg = _("You do not have permission '%s' to login" % rights)
 325                     LOG.debug("%s, %s, %s", msg, self.client.user, rights)
 326                     raise exceptions.LoginError, msg
 327                 return LOGIN_SUCCEDED
 328             except ldap.INVALID_CREDENTIALS, err:
 329                 LOG.debug("invalid credentials (wrong password?) for dn %r \
 330 (username: %r)" % (dn, username))
 331                 return LOGIN_FAILED
 332         except ldap.SERVER_DOWN, err:
 333             # looks like this LDAP server isn't working, so we just try the
 334             # next authenticator object in cfg.auth list (there could be some
 335             # second ldap authenticator that queries a backup server or any
 336             # other auth method).
 337             ## only one auth server supported for roundup, change it
 338             LOG.error("LDAP server %s failed (%s). Trying to authenticate \
 339 with next auth list entry." % (server, str(err)))
 340             msg = "LDAP server %(server)s failed." % {'server': server}
 341             LOG.debug(msg)
 342             return LOGIN_FAILED
 343         except Exception, err:
 344             LOG.error("Couldn't establish TLS to %r (err: %s)." % (server,
 345                                                                      str(err)))
 346             LOG.exception("caught an exception, traceback follows...")
 347             return LOGIN_FAILED
 348 
 349     def set_values(self, props):
 350         for kprop, value in props.items():
 351             setattr(self, kprop, value)
 352 
 353     def local_user_exists(self):
 354         """Verify if the given user exists. As a side effect set the
 355         'client.userid'."""
 356         # make sure the user exists
 357         try:
 358             self.client.userid = self.db.user.lookup(self.client.user)
 359         except KeyError:
 360             msg = _("Unknown user '%s'") % self.client.user
 361             LOG.debug("__['%s'", msg)
 362             self.client.error_message.append(
 363                         _("Unknown user  '%s'") % self.client.user)
 364             return False
 365         return True
 366 
 367     def local_login(self, password):
 368         """Try local authentication."""
 369         self.auth_method = 'localdb'
 370         if not self.local_user_exists():
 371             return LOGIN_FAILED
 372         if not self.verifyPassword(self.client.userid, password):
 373             msg = _('Invalid password')
 374             LOG.debug("%s for userid=%s", msg, self.client.userid)
 375             self.client.error_message.append(msg)
 376             return LOGIN_FAILED
 377 
 378         # Determine whether the user has permission to log in. Base behaviour
 379         # is to check the user has "Web Access".
 380         rights = "Web Access"
 381         if not self.hasPermission(rights):
 382             msg = _("You do not have permission to login")
 383             LOG.debug("%s, %s, %s", msg, self.client.user, rights)
 384             raise exceptions.LoginError, msg
 385         return LOGIN_SUCCEDED
 386 
 387     def verifyLogin(self, username, password):
 388         """Verify the login of `username` with `password`. Try first LDAP if
 389         this is specified as authentication source, and then login against
 390         local database."""
 391         LOG = self.db.get_logger()
 392         LOG.debug("username=%s password=%s", username, '*'*len(password))
 393         self.set_values(CONFIG_VALS)
 394         authenticated = False
 395         if not self.use_local_auth:
 396             LOG.debug("LDAP authentication")
 397             authenticated = self.ldap_login(username, password)
 398             if authenticated:
 399                 LOG.debug("User '%s' authenticated against LDAP.",
 400                           username)
 401         if not authenticated:
 402             LOG.debug("Local database authentication")
 403             authenticated = self.local_login(password)
 404             if authenticated:
 405                 LOG.debug("User '%s' authenticated against local database.",
 406                           username)
 407         if not authenticated:
 408             msg = _("Could not authenticate user '%s'" % username)
 409             LOG.debug(msg)
 410             raise exceptions.LoginError, msg
 411         return authenticated
 412 
 413     def add_attr_local_user(self, **props):
 414         """Add the attributes `props` for a user to the local database if
 415         those are still empty. If 'self.autocreate' is False then the user is
 416         considered a new user."""
 417         props['password'] = PW.Password(props['password'])
 418         self.db.journaltag = 'admin'
 419         try:
 420             self.client.userid = self.db.user.lookup(self.client.user)
 421             # update the empty values with LDAP values
 422             uid = self.client.userid
 423             if self.autocreate:
 424                 for pkey, prop in props.items():
 425                     try:
 426                         LOG.debug("Look key '%s' for user '%s'", pkey, uid)
 427                         value = self.db.user.get(uid, pkey)
 428                         LOG.debug("Value %r for key,user '%s','%s'", value,
 429                                   pkey, uid)
 430                         if not value:
 431                             LOG.debug("Set value %r for property %r of user \
 432 '%s'", props[pkey], pkey, self.client.user)
 433                             pair = {pkey : props[pkey]}
 434                             self.db.user.set(uid, **pair)
 435                     except Exception, err_msg:
 436                         LOG.exception("caught an exception, traceback follows.\
 437 ..")
 438         except KeyError:
 439             # add new user to local database
 440             props['roles'] = self.db.config.NEW_WEB_USER_ROLES
 441             self.userid = self.db.user.create(**props)
 442             self.db.commit()
 443             ## ?? why do we re-read the userid ??
 444             # self.client.userid = self.db.user.lookup(self.client.user)
 445             msg = u"New account created for user '%s'" % props['username']
 446             LOG.debug(msg)
 447             self.client.ok_message.append(msg)
 448 
 449 def init(instance):
 450     """Register the roundup action 'login'."""
 451     instance.registerAction('login', LDAPLoginAction)