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)