Merge lp:~openerp-community/openobject-addons/stefan-therp_lp794584 into lp:openobject-addons

Proposed by Stefan Rijnhart (Opener)
Status: Merged
Approved by: Olivier Dony (Odoo)
Approved revision: no longer in the source branch.
Merged at revision: 5520
Proposed branch: lp:~openerp-community/openobject-addons/stefan-therp_lp794584
Merge into: lp:openobject-addons
Diff against target: 373 lines (+198/-116)
1 file modified
users_ldap/users_ldap.py (+198/-116)
To merge this branch: bzr merge lp:~openerp-community/openobject-addons/stefan-therp_lp794584
Reviewer Review Type Date Requested Status
Olivier Dony (Odoo) Approve
Stefan Rijnhart (Opener) (community) Needs Resubmitting
Review via email: mp+63872@code.launchpad.net

Description of the change

This branch breaks down LDAP authentication into a number of atomic functions so that additional modules can more easily override specific functionality.

To post a comment you must log in.
Revision history for this message
Olivier Dony (Odoo) (odo-openerp) wrote :

Hello Stefan,

I agree with the idea of the bug report "wishlist", I wonder, isn't this use case sufficiently specific that it could be done in a separate module (e.g. 'ldap_import')?
Using the "template user" system alone is perhaps sufficient for the normal case?

Now, regardless of the above remark, some initial comments:

- the bind() call should now support anonymous login as provided by your other merge proposal ;-)
- DRY question: shouldn't the code of getting an ldap session, and the code of deciding/creating a user be refactored into separate functions? If we're starting to see it 2 or 3 times, it's time to think about it. Splitting these into separate functions would also allow them to be reused/extended into other module, such as perhaps an "ldap_import" one.

What do you think?

review: Needs Information
Revision history for this message
Stefan Rijnhart (Opener) (stefan-opener) wrote :

On 17-06-11 15:51, Olivier Dony (OpenERP) wrote:
> Review: Needs Information
> Hello Stefan,
>
> I agree with the idea of the bug report "wishlist", I wonder, isn't this use case sufficiently specific that it could be done in a separate module (e.g. 'ldap_import')?
> Using the "template user" system alone is perhaps sufficient for the normal case?
>
> Now, regardless of the above remark, some initial comments:
>
> - the bind() call should now support anonymous login as provided by your other merge proposal ;-)
> - DRY question: shouldn't the code of getting an ldap session, and the code of deciding/creating a user be refactored into separate functions? If we're starting to see it 2 or 3 times, it's time to think about it. Splitting these into separate functions would also allow them to be reused/extended into other module, such as perhaps an "ldap_import" one.
>
> What do you think?

Hi Olivier,

you hit the chicken right on the egg! If the basic functionality in the
module were sufficiently broken down, I would prefer to create a new
module for this particular use case. As it is however, I would have to
duplicate a large part of the basic functionality in the new module (I
had no problem doing so in the original module as this merely
demonstrates the need for refacturing).

I will therefore reuse this branch to suggest such a breakdown in
separate functions, and leave the added functionality for a separate
module of our own make.

Cheers,
Stefan.

--
Therp - Maatwerk in open ontwikkeling

Stefan Rijnhart - Ontwerp en implementatie

mail: <email address hidden>
tel: +31 (0) 614478606
web: http://therp.nl

Revision history for this message
Olivier Dony (Odoo) (odo-openerp) wrote :

On 06/20/2011 10:21 AM, Stefan Rijnhart (Therp) wrote:
> I will therefore reuse this branch to suggest such a breakdown in
> separate functions, and leave the added functionality for a separate
> module of our own make.

Sounds great, thanks! :-)

Revision history for this message
Stefan Rijnhart (Opener) (stefan-opener) wrote :

> On 06/20/2011 10:21 AM, Stefan Rijnhart (Therp) wrote:
> > I will therefore reuse this branch to suggest such a breakdown in
> > separate functions, and leave the added functionality for a separate
> > module of our own make.
>
> Sounds great, thanks! :-)

Hi Olivier,

this branch now features the refactured version of users_ldap. Here is an account of the changes I made:

[REM] reverted the populate functionality, moving to extra addon

As discussed, my team will publish a separate module with the populate functionality.

[REF] split up LDAP authentication in atomic methods

As discussed, res.company.ldap now has simple methods for retrieving the ldap configurations, connecting to the ldap server, query the subtree with the user accounts, authenticate dn/password combinations and compose a set of field values for creating res.users.

[IMP] leave it to res.users' defaults to select menu item for new users

Selecting the correct ir_action_action was broken in the module. After having installed several modules, a dashboard from one of these modules would be picked. I noticed that res.users assigned the correct default anyway and removed the corresponding code from users_ldap. As for the user's action_id, users_ldap used to set the menu action here as well which caused a continuous refreshing in the web client when such a user logs on. As res.users leaves this value empty by default, I removed this code as well.

[REF] remove unnecessary lambdas from defaults

[REF] improve pep8 compatibility

Respect 79 columns almost everywhere. Load system libraries first.

[REF] remove unnecessary conditionals when an exception is raised anyway

The old code checks the result of the user authentication against the ldap server. However, an unsuccesful authentication raises an exception which was handled already, rendering the conditional meaningless.

[IMP] do not log redundant exception ldap.INVALID_CREDENTIALS

An invalid login is already logged by WEB-SERVICE.

[IMP] replaced generic exceptions by ldap subclass

[FIX] update timestamp when LDAP user logs in
[FIX] prevent inactive LDAP users from logging in

Looking for guidance in res.users, I encounted these aspects of logins and I remembered coming across lp:784501. It seemed artificial to make an extra effort to preserve the original behaviour, but admittedly this is the only area in which the behaviour of the module has actually changed.

[FIX] use ldap.search_st() which actually does take the timeout argument

The old code uses ldap.search() with a timeout argument, but this function takes no such argument. In fact, ldap.search() is an asynchronous function which the old code does not anticipate so it seems to me that ldap.search_st() was the intended function. Also, the new code replaces ldap.open(), which is deprecated, by ldap.initialize().

Cheers,
Stefan.

review: Needs Resubmitting
Revision history for this message
Olivier Dony (Odoo) (odo-openerp) wrote :

Hello Stefan,

Wow, very impressive work, thanks!!

Here are a few comments (line numbers are within current diff):

- we're trying to unify out docstring formats (at least for new ones, as indeed the old ones are still a mess), and trying to use RST docstrings, so that they will be readily rendered within our online doc, with the help of sphinx's autodoc module. The format is not very different, you can have a look at examples [1][2] for the format, as well as the sphinx reference [3].

- to improve the signal-to-noise ratio of the documentation, we are trying to remove all redundant documentation of the 'cr', 'uid' and 'ids' parameters. Existing docstrings have a lot of these, but we're trying to stop/remove that. 'context' can be documented when a specific key/value in it could impact the behavior of the function, otherwise it's useless too.

- any particular reason you dropped the comments about the anonymous authentication ?

- l.94, l.278, l.339: not sure if this was done on purpose, but when logging from within an except block, you can simply use logger.exception('foo') to automatically log the full exception info and trace (it will use the current exception). If you want to achieve the same with a different logging level, you can use the exc_info kwarg, as in `logger.warning('foo', exc_info=True)`. This is usually better than trying to format the exception yourself, unless you really meant it. In general, it's also best to avoid doing the string format yourself with '%', the logger will do it (and will also avoid it if no message is finally output due to the current logger level)[4].

- l.183: the explicit test on empty passwords at the start of login() is really needed, because login() does not behave like check() and does not raise in case of failure. Therefore we can't distinguish super().login() returning False because of an empty password or because of a wrong password => we need to test this case explicitly, otherwise we'll perform an anonymous auth of the _user_, something that must be prevented by applications, as discussed previously! ;-)

- l.264: 'name' seems unused now?

- l.257-226 and 324-333 seem mostly identical, how about abstracting them out also in their own overridable function, e.g. auth_user() or similar, with only the code handling the result/exception being different?

Everything else looks quite excellent, well done and thanks a lot!

[1] Simple (outdated) example: http://bazaar.launchpad.net/~openerp/openobject-server/trunk/view/head:/openerp/osv/orm.py#L2031
[2] Complicated example, showing many options http://bazaar.launchpad.net/~openerp/openobject-server/trunk/view/head:/openerp/osv/fields.py#L755
[3] Sphinx reference: http://sphinx.pocoo.org/domains.html#info-field-lists
[4] http://docs.python.org/library/logging.html#logging.Logger.debug

review: Needs Fixing (technical)
Revision history for this message
Stefan Rijnhart (Opener) (stefan-opener) wrote :

Hi Olivier,

glad you like our work! Thank you for your valuable comments. I have tried to honour all of them in my latest commit. Additional comments:

> - l.94, l.278, l.339: not sure if this was done on purpose, but when logging
> from within an except block, you can simply use logger.exception('foo') to
> automatically log the full exception info and trace

Did not think that the trace is very interesting in these cases, as the exceptions most likely occur due to a configuration error or an error on the side of the LDAP server. I did change the code so as to leave the formatting to the logger though.

> - l.183: the explicit test on empty passwords at the start of login() is
> really needed

Ouch, that hurts :-( Thank you for saving me there! I put the check right in the heart of the authorization method of the ldap class, so there is no escaping it now.

Regards,
Stefan.

review: Needs Resubmitting
Revision history for this message
Olivier Dony (Odoo) (odo-openerp) wrote :

Excellent work, Stefan, looks good for merging.
Nice touch with the RFC reference in the docstrings. I didn't know the :rfc: RST role yet :-)

Thanks a lot for another great contribution!

review: Approve
Revision history for this message
Olivier Dony (Odoo) (odo-openerp) wrote :

Just merged after re-testing and replacing UID 1 with the new constant meant for that.

Thanks for your great work on this!

I will proceed with Ian's dependent branch next.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'users_ldap/users_ldap.py'
2--- users_ldap/users_ldap.py 2011-06-16 16:02:19 +0000
3+++ users_ldap/users_ldap.py 2011-07-27 22:12:47 +0000
4@@ -18,19 +18,181 @@
5 #
6 ##############################################################################
7
8+import ldap
9+from ldap.filter import filter_format
10 from osv import fields, osv
11 import pooler
12 import tools
13 import logging
14 from service import security
15-import ldap
16-from ldap.filter import filter_format
17-
18
19 class CompanyLDAP(osv.osv):
20 _name = 'res.company.ldap'
21 _order = 'sequence'
22 _rec_name = 'ldap_server'
23+
24+ def get_ldap_dicts(self, cr, ids=None):
25+ """
26+ Retrieve res_company_ldap resources from the database in dictionary
27+ format.
28+
29+ :param list ids: Valid ids of model res_company_ldap. If not \
30+ specified, process all resources (unlike other ORM methods).
31+ :return: ldap configurations
32+ :rtype: list of dictionaries
33+ """
34+
35+ if ids:
36+ id_clause = 'AND id IN (%s)'
37+ args = [tuple(ids)]
38+ else:
39+ id_clause = ''
40+ args = []
41+ cr.execute("""
42+ SELECT id, company, ldap_server, ldap_server_port, ldap_binddn,
43+ ldap_password, ldap_filter, ldap_base, "user", create_user
44+ FROM res_company_ldap
45+ WHERE ldap_server != '' """ + id_clause + """ ORDER BY sequence
46+ """, args)
47+ return cr.dictfetchall()
48+
49+ def connect(self, conf):
50+ """
51+ Connect to an LDAP server specified by an ldap
52+ configuration dictionary.
53+
54+ :param dict conf: LDAP configuration
55+ :return: an LDAP object
56+ """
57+
58+ uri = 'ldap://%s:%d' % (conf['ldap_server'],
59+ conf['ldap_server_port'])
60+ return ldap.initialize(uri)
61+
62+ def authenticate(self, conf, login, password):
63+ """
64+ Authenticate a user against the specified LDAP server.
65+
66+ In order to prevent an unintended 'unauthenticated authentication',
67+ which is an anonymous bind with a valid dn and a blank password,
68+ check for empty passwords explicitely (:rfc:`4513#section-6.3.1`)
69+
70+ :param dict conf: LDAP configuration
71+ :param login: username
72+ :param password: Password for the LDAP user
73+ :return: LDAP entry of authenticated user or False
74+ :rtype: dictionary of attributes
75+ """
76+
77+ if not password:
78+ return False
79+
80+ entry = False
81+ filter = filter_format(conf['ldap_filter'], (login,))
82+ try:
83+ results = self.query(conf, filter)
84+ if results and len(results) == 1:
85+ dn = results[0][0]
86+ conn = self.connect(conf)
87+ conn.simple_bind_s(dn, password)
88+ conn.unbind()
89+ entry = results[0]
90+ except ldap.INVALID_CREDENTIALS:
91+ return False
92+ except ldap.LDAPError, e:
93+ logger = logging.getLogger('orm.ldap')
94+ logger.error('An LDAP exception occurred: %s', e)
95+ return entry
96+
97+ def query(self, conf, filter, retrieve_attributes=None):
98+ """
99+ Query an LDAP server with the filter argument and scope subtree.
100+
101+ Allow for all authentication methods of the simple authentication
102+ method:
103+
104+ - authenticated bind (non-empty binddn + valid password)
105+ - anonymous bind (empty binddn + empty password)
106+ - unauthenticated authentication (non-empty binddn + empty password)
107+
108+ .. seealso::
109+ :rfc:`4513#section-5.1` - LDAP: Simple Authentication Method.
110+
111+ :param dict conf: LDAP configuration
112+ :param filter: valid LDAP filter
113+ :param list retrieve_attributes: LDAP attributes to be retrieved. \
114+ If not specified, return all attributes.
115+ :return: ldap entries
116+ :rtype: list of tuples (dn, attrs)
117+
118+ """
119+
120+ results = []
121+ logger = logging.getLogger('orm.ldap')
122+ try:
123+ conn = self.connect(conf)
124+ conn.simple_bind_s(conf['ldap_binddn'] or '',
125+ conf['ldap_password'] or '')
126+ results = conn.search_st(conf['ldap_base'], ldap.SCOPE_SUBTREE,
127+ filter, retrieve_attributes, timeout=60)
128+ conn.unbind()
129+ except ldap.INVALID_CREDENTIALS:
130+ logger.error('LDAP bind failed.')
131+ except ldap.LDAPError, e:
132+ logger.error('An LDAP exception occurred: %s', e)
133+ return results
134+
135+ def map_ldap_attributes(self, cr, uid, conf, login, ldap_entry):
136+ """
137+ Compose values for a new resource of model res_users,
138+ based upon the retrieved ldap entry and the LDAP settings.
139+
140+ :param dict conf: LDAP configuration
141+ :param login: the new user's login
142+ :param tuple ldap_entry: single LDAP result (dn, attrs)
143+ :return: parameters for a new resource of model res_users
144+ :rtype: dict
145+ """
146+
147+ values = { 'name': ldap_entry[1]['cn'][0],
148+ 'login': login,
149+ 'company_id': conf['company']
150+ }
151+ return values
152+
153+ def get_or_create_user(self, cr, uid, conf, login, ldap_entry,
154+ context=None):
155+ """
156+ Retrieve an active resource of model res_users with the specified
157+ login. Create the user if it is not initially found.
158+
159+ :param dict conf: LDAP configuration
160+ :param login: the user's login
161+ :param tuple ldap_entry: single LDAP result (dn, attrs)
162+ :return: res_users id
163+ :rtype: int
164+ """
165+
166+ user_id = False
167+ login = tools.ustr(login)
168+ cr.execute("SELECT id, active FROM res_users WHERE login=%s", (login,))
169+ res = cr.fetchone()
170+ if res:
171+ if res[1]:
172+ user_id = res[0]
173+ elif conf['create_user']:
174+ logger = logging.getLogger('orm.ldap')
175+ logger.debug("Creating new OpenERP user \"%s\" from LDAP" % login)
176+ user_obj = self.pool.get('res.users')
177+ values = self.map_ldap_attributes(cr, uid, conf, login, ldap_entry)
178+ if conf['user']:
179+ user_id = user_obj.copy(cr, 1, conf['user'],
180+ default={'active': True})
181+ user_obj.write(cr, 1, user_id, values)
182+ else:
183+ user_id = user_obj.create(cr, 1, values)
184+ return user_id
185+
186 _columns = {
187 'sequence': fields.integer('Sequence'),
188 'company': fields.many2one('res.company', 'Company', required=True,
189@@ -43,7 +205,7 @@
190 'ldap_password': fields.char('LDAP password', size=64,
191 help=("The password of the user account on the LDAP server that is "
192 "used to query the directory.")),
193- 'ldap_filter': fields.char('LDAP filter', size=64, required=True),
194+ 'ldap_filter': fields.char('LDAP filter', size=256, required=True),
195 'ldap_base': fields.char('LDAP base', size=64, required=True),
196 'user': fields.many2one('res.users', 'Model User',
197 help="Model used for user creation"),
198@@ -51,10 +213,10 @@
199 help="Create the user if not in database"),
200 }
201 _defaults = {
202- 'ldap_server': lambda *a: '127.0.0.1',
203- 'ldap_server_port': lambda *a: 389,
204- 'sequence': lambda *a: 10,
205- 'create_user': lambda *a: True,
206+ 'ldap_server': '127.0.0.1',
207+ 'ldap_server_port': 389,
208+ 'sequence': 10,
209+ 'create_user': True,
210 }
211
212 CompanyLDAP()
213@@ -63,87 +225,32 @@
214 class res_company(osv.osv):
215 _inherit = "res.company"
216 _columns = {
217- 'ldaps': fields.one2many('res.company.ldap', 'company', 'LDAP Parameters'),
218+ 'ldaps': fields.one2many(
219+ 'res.company.ldap', 'company', 'LDAP Parameters'),
220 }
221 res_company()
222
223+
224 class users(osv.osv):
225 _inherit = "res.users"
226 def login(self, db, login, password):
227-
228- if not password:
229- # empty passwords are disallowed for obvious security reasons
230- return False
231-
232- ret = super(users,self).login(db, login, password)
233- if ret:
234- return ret
235- logger = logging.getLogger('orm.ldap')
236- pool = pooler.get_pool(db)
237+ user_id = super(users, self).login(db, login, password)
238+ if user_id:
239+ return user_id
240 cr = pooler.get_db(db).cursor()
241- action_obj = pool.get('ir.actions.actions')
242- cr.execute("""
243- SELECT id, company, ldap_server, ldap_server_port, ldap_binddn, ldap_password,
244- ldap_filter, ldap_base, "user", create_user
245- FROM res_company_ldap
246- WHERE ldap_server != '' ORDER BY sequence""")
247- for res_company_ldap in cr.dictfetchall():
248- logger.debug(res_company_ldap)
249- try:
250- l = ldap.open(res_company_ldap['ldap_server'], res_company_ldap['ldap_server_port'])
251- # An empty binddn means anonymous auth, so it should be replaced w/ an empty string
252- # See LDAP RFC 4513, Section 5.1.1
253- if l.simple_bind_s(res_company_ldap['ldap_binddn'] or '',
254- res_company_ldap['ldap_password'] or ''):
255- base = res_company_ldap['ldap_base']
256- scope = ldap.SCOPE_SUBTREE
257- filter = filter_format(res_company_ldap['ldap_filter'], (login,))
258- retrieve_attributes = None
259- result_id = l.search(base, scope, filter, retrieve_attributes)
260- timeout = 60
261- result_type, result_data = l.result(result_id, timeout)
262- if not result_data:
263- continue
264- if result_type == ldap.RES_SEARCH_RESULT and len(result_data) == 1:
265- dn = result_data[0][0]
266- logger.debug(dn)
267- name = result_data[0][1]['cn'][0]
268- if l.bind_s(dn, password):
269- l.unbind()
270- cr.execute("SELECT id FROM res_users WHERE login=%s",(tools.ustr(login),))
271- res = cr.fetchone()
272- logger.debug(res)
273- if res:
274- cr.close()
275- return res[0]
276- if not res_company_ldap['create_user']:
277- continue
278- action_id = action_obj.search(cr, 1, [('usage', '=', 'menu')])[0]
279- if res_company_ldap['user']:
280- res = self.copy(cr, 1, res_company_ldap['user'],
281- default={'active': True})
282- self.write(cr, 1, res, {
283- 'name': name,
284- 'login': login.encode('utf-8'),
285- 'company_id': res_company_ldap['company'],
286- })
287- else:
288- res = self.create(cr, 1, {
289- 'name': name,
290- 'login': login.encode('utf-8'),
291- 'company_id': res_company_ldap['company'],
292- 'action_id': action_id,
293- 'menu_id': action_id,
294- })
295- cr.commit()
296- cr.close()
297- return res
298- l.unbind()
299- except Exception:
300- logger.warning("Cannot auth", exc_info=True)
301- continue
302+ ldap_obj = pooler.get_pool(db).get('res.company.ldap')
303+ for conf in ldap_obj.get_ldap_dicts(cr):
304+ entry = ldap_obj.authenticate(conf, login, password)
305+ if entry:
306+ user_id = ldap_obj.get_or_create_user(
307+ cr, 1, conf, login, entry)
308+ if user_id:
309+ cr.execute('UPDATE res_users SET date=now() WHERE '
310+ 'login=%s', (tools.ustr(login),))
311+ cr.commit()
312+ break
313 cr.close()
314- return False
315+ return user_id
316
317 def check(self, db, uid, passwd):
318 try:
319@@ -151,44 +258,19 @@
320 except security.ExceptionNoTb: # AccessDenied
321 pass
322
323- if not passwd:
324- # empty passwords disallowed for obvious security reasons
325- raise security.ExceptionNoTb('AccessDenied')
326-
327 cr = pooler.get_db(db).cursor()
328- user = self.browse(cr, 1, uid)
329- logger = logging.getLogger('orm.ldap')
330- if user and user.company_id.ldaps:
331- for res_company_ldap in user.company_id.ldaps:
332- try:
333- l = ldap.open(res_company_ldap.ldap_server, res_company_ldap.ldap_server_port)
334- # An empty binddn means anonymous auth, so it should be replaced w/ an empty string
335- # See LDAP RFC 4513, Section 5.1.1
336- if l.simple_bind_s(res_company_ldap.ldap_binddn or '',
337- res_company_ldap.ldap_password or ''):
338- base = res_company_ldap.ldap_base
339- scope = ldap.SCOPE_SUBTREE
340- filter = filter_format(res_company_ldap.ldap_filter, (user.login,))
341- retrieve_attributes = None
342- result_id = l.search(base, scope, filter, retrieve_attributes)
343- timeout = 60
344- result_type, result_data = l.result(result_id, timeout)
345- if result_data and result_type == ldap.RES_SEARCH_RESULT and len(result_data) == 1:
346- dn = result_data[0][0]
347- # some LDAP servers allow anonymous binding with blank passwords,
348- # but these have been rejected above, so we're safe to use bind()
349- if l.bind_s(dn, passwd):
350- l.unbind()
351- self._uid_cache.setdefault(db, {})[uid] = passwd
352- cr.close()
353- return True
354- l.unbind()
355- except Exception:
356- logger.warning('cannot check', exc_info=True)
357- pass
358+ cr.execute('SELECT login FROM res_users WHERE id=%s AND active=TRUE',
359+ (int(uid),))
360+ res = cr.fetchone()
361+ if res:
362+ ldap_obj = pooler.get_pool(db).get('res.company.ldap')
363+ for conf in ldap_obj.get_ldap_dicts(cr):
364+ if ldap_obj.authenticate(conf, res[0], passwd):
365+ self._uid_cache.setdefault(db, {})[uid] = passwd
366+ cr.close()
367+ return True
368 cr.close()
369 raise security.ExceptionNoTb('AccessDenied')
370
371 users()
372 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
373-

Subscribers

People subscribed via source and target branches

to all changes: