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)