Merge lp:~mhall119/django-openid-auth/provides-784239 into lp:~django-openid-auth/django-openid-auth/trunk

Proposed by Michael Hall
Status: Merged
Merged at revision: 85
Proposed branch: lp:~mhall119/django-openid-auth/provides-784239
Merge into: lp:~django-openid-auth/django-openid-auth/trunk
Prerequisite: lp:~mhall119/django-openid-auth/django-yubikey-auth
Diff against target: 462 lines (+261/-28)
5 files modified
README.txt (+12/-0)
django_openid_auth/auth.py (+15/-12)
django_openid_auth/exceptions.py (+68/-0)
django_openid_auth/tests/test_views.py (+150/-4)
django_openid_auth/views.py (+16/-12)
To merge this branch: bzr merge lp:~mhall119/django-openid-auth/provides-784239
Reviewer Review Type Date Requested Status
Anthony Lenton Approve
Software Center Agent (community) Approve
Review via email: mp+61497@code.launchpad.net

Commit message

Allow authentication exceptions to bubble up and get passed along to the failure view.

Description of the change

Overview
========
Allow exceptions thrown during authentication to be used to inform the user of the cause of a login failure.

Details
=======
Previously we silently swallowed exceptions during calls to authenticate. Instead we will catch them and pass them along to the failure view so the user can be told explicitly what the problem was with their login attempt.

To post a comment you must log in.
Revision history for this message
Anthony Lenton (elachuni) wrote :

Hi Michael,

I like the approach in general. A few comments/suggestions,
 - you import all defined exceptions on lines 145/151 of the diff, but then you don't seem to use them.
 - does StrictUsernameViolation make sense now? you only use it to inherit from it from two other classes that you raise separately and catch together with all other DjangoOpenIDException. If you think eventually people will want to catch all StrictUsernameViolations together then it's fine as is, otherwise removing it will flatten exception class tree and make it clearer I think.
 - and ideally it would have a test or two I think (to ensure that the messages do indeed reach the user, for example?)
Thanks!

85. By Michael Hall

Cleanup exception class heirarchy and imports, make overriding the login failure handler a settings option, add additional test cases to veryfiy that overriding the handler works and that the proper exceptions are being passed

86. By Michael Hall

Merge from prerequisite branch

87. By Michael Hall

Merge from trunk

88. By Michael Hall

Merge from prerequisite branch

89. By Michael Hall

Fix syntax error from merge

Revision history for this message
Software Center Agent (software-center-agent) wrote :

Thanks Michael!

review: Approve
Revision history for this message
Anthony Lenton (elachuni) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'README.txt'
2--- README.txt 2011-08-23 18:55:24 +0000
3+++ README.txt 2011-08-23 18:55:24 +0000
4@@ -154,3 +154,15 @@
5 If the user's OpenID provider supports the PAPE extension and provides the Physical Multifactor authentication policy, this will
6 cause the OpenID login to fail if the user does not provide valid physical authentication to the provider.
7
8+== Override Login Failure Handling ==
9+
10+You can optionally provide your own handler for login failures by adding the following setting:
11+
12+ OPENID_RENDER_FAILURE = failure_handler_function
13+
14+Where failure_handler_function is a function reference that will take the following parameters:
15+
16+ def failure_handler_function(request, message, status=None, template_name=None, exception=None)
17+
18+This function must return a Django.http.HttpResponse instance.
19+
20
21=== modified file 'django_openid_auth/auth.py'
22--- django_openid_auth/auth.py 2011-08-23 18:55:24 +0000
23+++ django_openid_auth/auth.py 2011-08-23 18:55:24 +0000
24@@ -37,16 +37,13 @@
25
26 from django_openid_auth import teams
27 from django_openid_auth.models import UserOpenID
28-
29-
30-class IdentityAlreadyClaimed(Exception):
31- pass
32-
33-class StrictUsernameViolation(Exception):
34- pass
35-
36-class RequiredAttributeNotReturned(Exception):
37- pass
38+from django_openid_auth.exceptions import (
39+ IdentityAlreadyClaimed,
40+ DuplicateUsernameViolation,
41+ MissingUsernameViolation,
42+ MissingPhysicalMultiFactor,
43+ RequiredAttributeNotReturned,
44+)
45
46 class OpenIDBackend:
47 """A django.contrib.auth backend that authenticates the user based on
48@@ -92,7 +89,7 @@
49 pape_response = pape.Response.fromSuccessResponse(openid_response)
50 if pape_response is None or \
51 pape.AUTH_MULTI_FACTOR_PHYSICAL not in pape_response.auth_policies:
52- return None
53+ raise MissingPhysicalMultiFactor()
54
55 teams_response = teams.TeamsResponse.fromSuccessResponse(
56 openid_response)
57@@ -150,6 +147,12 @@
58 first_name=first_name, last_name=last_name)
59
60 def _get_available_username(self, nickname, identity_url):
61+ # If we're being strict about usernames, throw an error if we didn't
62+ # get one back from the provider
63+ if getattr(settings, 'OPENID_STRICT_USERNAMES', False):
64+ if nickname is None or nickname == '':
65+ raise MissingUsernameViolation()
66+
67 # If we don't have a nickname, and we're not being strict, use a default
68 nickname = nickname or 'openiduser'
69
70@@ -184,7 +187,7 @@
71
72 if getattr(settings, 'OPENID_STRICT_USERNAMES', False):
73 if User.objects.filter(username__exact=nickname).count() > 0:
74- raise StrictUsernameViolation(
75+ raise DuplicateUsernameViolation(
76 "The username (%s) with which you tried to log in is "
77 "already in use for a different account." % nickname)
78
79
80=== added file 'django_openid_auth/exceptions.py'
81--- django_openid_auth/exceptions.py 1970-01-01 00:00:00 +0000
82+++ django_openid_auth/exceptions.py 2011-08-23 18:55:24 +0000
83@@ -0,0 +1,68 @@
84+# django-openid-auth - OpenID integration for django.contrib.auth
85+#
86+# Copyright (C) 2008-2010 Canonical Ltd.
87+#
88+# Redistribution and use in source and binary forms, with or without
89+# modification, are permitted provided that the following conditions
90+# are met:
91+#
92+# * Redistributions of source code must retain the above copyright
93+# notice, this list of conditions and the following disclaimer.
94+#
95+# * Redistributions in binary form must reproduce the above copyright
96+# notice, this list of conditions and the following disclaimer in the
97+# documentation and/or other materials provided with the distribution.
98+#
99+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
100+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
101+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
102+# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
103+# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
104+# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
105+# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
106+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
107+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
108+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
109+# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
110+# POSSIBILITY OF SUCH DAMAGE.
111+
112+"""Exception classes thrown by OpenID Authentication and Validation."""
113+
114+class DjangoOpenIDException(Exception):
115+ pass
116+
117+class RequiredAttributeNotReturned(DjangoOpenIDException):
118+ pass
119+
120+class IdentityAlreadyClaimed(DjangoOpenIDException):
121+
122+ def __init__(self, message=None):
123+ if message is None:
124+ self.message = "Another user already exists for your selected OpenID"
125+ else:
126+ self.message = message
127+
128+class DuplicateUsernameViolation(DjangoOpenIDException):
129+
130+ def __init__(self, message=None):
131+ if message is None:
132+ self.message = "Your desired username is already being used."
133+ else:
134+ self.message = message
135+
136+class MissingUsernameViolation(DjangoOpenIDException):
137+
138+ def __init__(self, message=None):
139+ if message is None:
140+ self.message = "No nickname given for your account."
141+ else:
142+ self.message = message
143+
144+class MissingPhysicalMultiFactor(DjangoOpenIDException):
145+
146+ def __init__(self, message=None):
147+ if message is None:
148+ self.message = "Login requires physical multi-factor authentication."
149+ else:
150+ self.message = message
151+
152
153=== modified file 'django_openid_auth/tests/test_views.py'
154--- django_openid_auth/tests/test_views.py 2011-08-23 18:55:24 +0000
155+++ django_openid_auth/tests/test_views.py 2011-08-23 18:55:24 +0000
156@@ -32,7 +32,7 @@
157
158 from django.conf import settings
159 from django.contrib.auth.models import User, Group
160-from django.http import HttpRequest
161+from django.http import HttpRequest, HttpResponse
162 from django.test import TestCase
163 from openid.consumer.consumer import Consumer, SuccessResponse
164 from openid.consumer.discover import OpenIDServiceEndpoint
165@@ -53,11 +53,15 @@
166 from django_openid_auth.auth import OpenIDBackend
167 from django_openid_auth.signals import openid_login_complete
168 from django_openid_auth.store import DjangoOpenIDStore
169-
170+from django_openid_auth.exceptions import (
171+ MissingUsernameViolation,
172+ DuplicateUsernameViolation,
173+ MissingPhysicalMultiFactor,
174+ RequiredAttributeNotReturned,
175+)
176
177 ET = importElementTree()
178
179-
180 class StubOpenIDProvider(HTTPFetcher):
181
182 def __init__(self, base_url):
183@@ -179,7 +183,9 @@
184 self.old_use_as_admin_login = getattr(settings, 'OPENID_USE_AS_ADMIN_LOGIN', False)
185 self.old_follow_renames = getattr(settings, 'OPENID_FOLLOW_RENAMES', False)
186 self.old_physical_multifactor = getattr(settings, 'OPENID_PHYSICAL_MULTIFACTOR_REQUIRED', False)
187+ self.old_login_render_failure = getattr(settings, 'OPENID_RENDER_FAILURE', None)
188 self.old_consumer_complete = Consumer.complete
189+
190 self.old_required_fields = getattr(
191 settings, 'OPENID_SREG_REQUIRED_FIELDS', [])
192
193@@ -203,6 +209,7 @@
194 settings.OPENID_USE_AS_ADMIN_LOGIN = self.old_use_as_admin_login
195 settings.OPENID_FOLLOW_RENAMES = self.old_follow_renames
196 settings.OPENID_PHYSICAL_MULTIFACTOR_REQUIRED = self.old_physical_multifactor
197+ settings.OPENID_RENDER_FAILURE = self.old_login_render_failure
198 Consumer.complete = self.old_consumer_complete
199 settings.OPENID_SREG_REQUIRED_FIELDS = self.old_required_fields
200
201@@ -485,6 +492,63 @@
202
203 response = self.complete(openid_response)
204 self.assertEquals(403, response.status_code)
205+ self.assertContains(response, '<h1>OpenID failed</h1>', status_code=403)
206+ self.assertContains(response, '<p>Login requires physical multi-factor authentication.</p>', status_code=403)
207+
208+ def test_login_physical_multifactor_not_provided_override(self):
209+ settings.OPENID_PHYSICAL_MULTIFACTOR_REQUIRED = True
210+ preferred_auth = pape.AUTH_MULTI_FACTOR_PHYSICAL
211+ self.provider.type_uris.append(pape.ns_uri)
212+
213+ # Override the login_failure handler
214+ def mock_login_failure_handler(request, message, status=403,
215+ template_name=None,
216+ exception=None):
217+ self.assertTrue(isinstance(exception, MissingPhysicalMultiFactor))
218+ return HttpResponse('Test Failure Override', status=200)
219+ settings.OPENID_RENDER_FAILURE = mock_login_failure_handler
220+
221+ def mock_complete(this, request_args, return_to):
222+ request = {'openid.mode': 'checkid_setup',
223+ 'openid.trust_root': 'http://localhost/',
224+ 'openid.return_to': 'http://localhost/',
225+ 'openid.identity': IDENTIFIER_SELECT,
226+ 'openid.ns.pape' : pape.ns_uri,
227+ 'openid.pape.auth_policies': request_args.get('openid.pape.auth_policies', pape.AUTH_NONE),
228+ }
229+ openid_server = self.provider.server
230+ orequest = openid_server.decodeRequest(request)
231+ response = SuccessResponse(
232+ self.endpoint, orequest.message,
233+ signed_fields=['openid.pape.auth_policies',])
234+ return response
235+ Consumer.complete = mock_complete
236+
237+ user = User.objects.create_user('testuser', 'test@example.com')
238+ useropenid = UserOpenID(
239+ user=user,
240+ claimed_id='http://example.com/identity',
241+ display_id='http://example.com/identity')
242+ useropenid.save()
243+
244+ openid_req = {'openid_identifier': 'http://example.com/identity',
245+ 'next': '/getuser/'}
246+ openid_resp = {'nickname': 'testuser', 'fullname': 'Openid User',
247+ 'email': 'test@example.com'}
248+
249+ openid_request = self._get_login_request(openid_req)
250+ openid_response = self._get_login_response(openid_request, openid_req, openid_resp, use_pape=pape.AUTH_NONE)
251+
252+ response_auth = openid_request.message.getArg(
253+ 'http://specs.openid.net/extensions/pape/1.0',
254+ 'auth_policies',
255+ )
256+ self.assertNotEqual(response_auth, preferred_auth)
257+
258+ # Status code should be 200, since we over-rode the login_failure handler
259+ response = self.complete(openid_response)
260+ self.assertEquals(200, response.status_code)
261+ self.assertContains(response, 'Test Failure Override')
262
263 def test_login_without_nickname(self):
264 settings.OPENID_CREATE_USERS = True
265@@ -680,6 +744,7 @@
266 def test_strict_username_no_nickname(self):
267 settings.OPENID_CREATE_USERS = True
268 settings.OPENID_STRICT_USERNAMES = True
269+ settings.OPENID_SREG_REQUIRED_FIELDS = []
270
271 # Posting in an identity URL begins the authentication request:
272 response = self.client.post('/openid/login/',
273@@ -701,6 +766,44 @@
274
275 # Status code should be 403: Forbidden
276 self.assertEquals(403, response.status_code)
277+ self.assertContains(response, '<h1>OpenID failed</h1>', status_code=403)
278+ self.assertContains(response, "An attribute required for logging in was not returned "
279+ "(nickname)", status_code=403)
280+
281+ def test_strict_username_no_nickname_override(self):
282+ settings.OPENID_CREATE_USERS = True
283+ settings.OPENID_STRICT_USERNAMES = True
284+ settings.OPENID_SREG_REQUIRED_FIELDS = []
285+
286+ # Override the login_failure handler
287+ def mock_login_failure_handler(request, message, status=403,
288+ template_name=None,
289+ exception=None):
290+ self.assertTrue(isinstance(exception, (RequiredAttributeNotReturned, MissingUsernameViolation)))
291+ return HttpResponse('Test Failure Override', status=200)
292+ settings.OPENID_RENDER_FAILURE = mock_login_failure_handler
293+
294+ # Posting in an identity URL begins the authentication request:
295+ response = self.client.post('/openid/login/',
296+ {'openid_identifier': 'http://example.com/identity',
297+ 'next': '/getuser/'})
298+ self.assertContains(response, 'OpenID transaction in progress')
299+
300+ # Complete the request, passing back some simple registration
301+ # data. The user is redirected to the next URL.
302+ openid_request = self.provider.parseFormPost(response.content)
303+ sreg_request = sreg.SRegRequest.fromOpenIDRequest(openid_request)
304+ openid_response = openid_request.answer(True)
305+ sreg_response = sreg.SRegResponse.extractResponse(
306+ sreg_request, {'nickname': '', # No nickname
307+ 'fullname': 'Some User',
308+ 'email': 'foo@example.com'})
309+ openid_response.addExtension(sreg_response)
310+ response = self.complete(openid_response)
311+
312+ # Status code should be 200, since we over-rode the login_failure handler
313+ self.assertEquals(200, response.status_code)
314+ self.assertContains(response, 'Test Failure Override')
315
316 def test_strict_username_duplicate_user(self):
317 settings.OPENID_CREATE_USERS = True
318@@ -731,11 +834,54 @@
319 response = self.complete(openid_response)
320
321 # Status code should be 403: Forbidden
322+ self.assertEquals(403, response.status_code)
323+ self.assertContains(response, '<h1>OpenID failed</h1>', status_code=403)
324 self.assertContains(response,
325 "The username (someuser) with which you tried to log in is "
326 "already in use for a different account.",
327 status_code=403)
328
329+ def test_strict_username_duplicate_user_override(self):
330+ settings.OPENID_CREATE_USERS = True
331+ settings.OPENID_STRICT_USERNAMES = True
332+
333+ # Override the login_failure handler
334+ def mock_login_failure_handler(request, message, status=403,
335+ template_name=None,
336+ exception=None):
337+ self.assertTrue(isinstance(exception, DuplicateUsernameViolation))
338+ return HttpResponse('Test Failure Override', status=200)
339+ settings.OPENID_RENDER_FAILURE = mock_login_failure_handler
340+
341+ # Create a user with the same name as we'll pass back via sreg.
342+ user = User.objects.create_user('someuser', 'someone@example.com')
343+ useropenid = UserOpenID(
344+ user=user,
345+ claimed_id='http://example.com/different_identity',
346+ display_id='http://example.com/different_identity')
347+ useropenid.save()
348+
349+ # Posting in an identity URL begins the authentication request:
350+ response = self.client.post('/openid/login/',
351+ {'openid_identifier': 'http://example.com/identity',
352+ 'next': '/getuser/'})
353+ self.assertContains(response, 'OpenID transaction in progress')
354+
355+ # Complete the request, passing back some simple registration
356+ # data. The user is redirected to the next URL.
357+ openid_request = self.provider.parseFormPost(response.content)
358+ sreg_request = sreg.SRegRequest.fromOpenIDRequest(openid_request)
359+ openid_response = openid_request.answer(True)
360+ sreg_response = sreg.SRegResponse.extractResponse(
361+ sreg_request, {'nickname': 'someuser', 'fullname': 'Some User',
362+ 'email': 'foo@example.com'})
363+ openid_response.addExtension(sreg_response)
364+ response = self.complete(openid_response)
365+
366+ # Status code should be 200, since we over-rode the login_failure handler
367+ self.assertEquals(200, response.status_code)
368+ self.assertContains(response, 'Test Failure Override')
369+
370 def test_login_requires_sreg_required_fields(self):
371 # If any required attributes are not included in the response,
372 # we fail with a forbidden.
373@@ -1054,7 +1200,7 @@
374 self.assertTrue(self.signal_handler_called)
375 openid_login_complete.disconnect(login_callback)
376
377-
378+
379 class HelperFunctionsTest(TestCase):
380 def test_sanitise_redirect_url(self):
381 settings.ALLOWED_EXTERNAL_OPENID_REDIRECT_DOMAINS = [
382
383=== modified file 'django_openid_auth/views.py'
384--- django_openid_auth/views.py 2011-08-23 18:55:24 +0000
385+++ django_openid_auth/views.py 2011-08-23 18:55:24 +0000
386@@ -51,14 +51,14 @@
387 from openid.extensions import sreg, ax, pape
388
389 from django_openid_auth import teams
390-from django_openid_auth.auth import (
391- RequiredAttributeNotReturned,
392- StrictUsernameViolation,
393- )
394 from django_openid_auth.forms import OpenIDLoginForm
395 from django_openid_auth.models import UserOpenID
396 from django_openid_auth.signals import openid_login_complete
397 from django_openid_auth.store import DjangoOpenIDStore
398+from django_openid_auth.exceptions import (
399+ RequiredAttributeNotReturned,
400+ DjangoOpenIDException,
401+)
402
403
404 next_url_re = re.compile('^/[-\w/]+$')
405@@ -122,10 +122,11 @@
406
407
408 def default_render_failure(request, message, status=403,
409- template_name='openid/failure.html'):
410+ template_name='openid/failure.html',
411+ exception=None):
412 """Render an error page to the user."""
413 data = render_to_string(
414- template_name, dict(message=message),
415+ template_name, dict(message=message, exception=exception),
416 context_instance=RequestContext(request))
417 return HttpResponse(data, status=status)
418
419@@ -175,7 +176,8 @@
420 openid_request = consumer.begin(openid_url)
421 except DiscoveryFailure, exc:
422 return render_failure(
423- request, "OpenID discovery error: %s" % (str(exc),), status=500)
424+ request, "OpenID discovery error: %s" % (str(exc),), status=500,
425+ exception=exc)
426
427 # Request some user details. If the provider advertises support
428 # for attribute exchange, use that.
429@@ -220,7 +222,6 @@
430 pape_request = pape.Request(preferred_auth_policies=preferred_auth)
431 openid_request.addExtension(pape_request)
432
433-
434 # Request team info
435 teams_mapping_auto = getattr(settings, 'OPENID_LAUNCHPAD_TEAMS_MAPPING_AUTO', False)
436 teams_mapping_auto_blacklist = getattr(settings, 'OPENID_LAUNCHPAD_TEAMS_MAPPING_AUTO_BLACKLIST', [])
437@@ -250,8 +251,11 @@
438
439 @csrf_exempt
440 def login_complete(request, redirect_field_name=REDIRECT_FIELD_NAME,
441- render_failure=default_render_failure):
442+ render_failure=None):
443 redirect_to = request.REQUEST.get(redirect_field_name, '')
444+ render_failure = render_failure or \
445+ getattr(settings, 'OPENID_RENDER_FAILURE', None) or \
446+ default_render_failure
447
448 openid_response = parse_openid_response(request)
449 if not openid_response:
450@@ -261,9 +265,9 @@
451 if openid_response.status == SUCCESS:
452 try:
453 user = authenticate(openid_response=openid_response)
454- except (StrictUsernameViolation, RequiredAttributeNotReturned), e:
455- return render_failure(request, e)
456-
457+ except DjangoOpenIDException, e:
458+ return render_failure(request, e.message, exception=e)
459+
460 if user is not None:
461 if user.is_active:
462 auth_login(request, user)

Subscribers

People subscribed via source and target branches