Merge lp:~mikemc/canonical-identity-provider/fix-1169632 into lp:canonical-identity-provider/release

Proposed by Mike McCracken
Status: Rejected
Rejected by: Maximiliano Bertacchini
Proposed branch: lp:~mikemc/canonical-identity-provider/fix-1169632
Merge into: lp:canonical-identity-provider/release
Diff against target: 422 lines (+162/-25)
12 files modified
src/api/v20/handlers.py (+1/-0)
src/api/v20/registration.py (+3/-2)
src/api/v20/tests/test_registration.py (+4/-2)
src/identityprovider/emailutils.py (+8/-3)
src/identityprovider/tests/test_emailutils.py (+21/-0)
src/identityprovider/views/server.py (+25/-16)
src/identityprovider/views/utils.py (+8/-0)
src/webui/templates/account/confirm_new_email.html (+34/-0)
src/webui/tests/test_views_registration.py (+9/-0)
src/webui/tests/test_views_ui.py (+1/-1)
src/webui/views/registration.py (+5/-1)
src/webui/views/ui.py (+43/-0)
To merge this branch: bzr merge lp:~mikemc/canonical-identity-provider/fix-1169632
Reviewer Review Type Date Requested Status
Maximiliano Bertacchini Needs Resubmitting
Natalia Bidart Pending
Review via email: mp+287688@code.launchpad.net

Commit message

save return_to url from openid request for redirect after email validation (LP: #1169632)

Description of the change

save return_to url from openid request for redirect after email validation (LP: #1169632)

If the openid request includes a return_to URL, this change will save it into the AuthToken that the email validation URL refers to, so that the user is redirected to the RP's requested page instead of to the /+emails page.

TO TEST:
Use an openid RP or use the /consumer/ openid test endpoint to register a new account, and click on the validate link in the resulting email. You should be redirected to the appropriate place after choosing to validate the email.

To post a comment you must log in.
Revision history for this message
Ricardo Kirkner (ricardokirkner) :
Revision history for this message
Mike McCracken (mikemc) :
Revision history for this message
Mike McCracken (mikemc) wrote :

Thanks for the review, and my apologies for taking so long to get back to this.

Revision history for this message
Mike McCracken (mikemc) wrote :

One more concern here - moving the logic for getting the return_to URL out of the orequest into redirection_url_for_token will require importing get_orequest (and probably get_rpconfig_from_request for checking against phishing) from identityprovider.views.utils into identityprovider.utils, which will be an import loop.

Being unfamiliar with the code, I'm not sure which things to move where to resolve that loop - both moving get_* to .utils and moving redirection_url_for_token to .views.utils will require changing some other files too. Your guidance on what to do there would be useful. Thanks!

Revision history for this message
Natalia Bidart (nataliabidart) wrote :

Ricardo is on parenting leave so he will not be around for a few weeks, but I understand you got his request for the redirection_url_for_token correctly.

Thanks!

Revision history for this message
Mike McCracken (mikemc) wrote :

I just had another chance to look at this today.

The approach in this branch was incorrect, as it only resulted in a simple redirect to the return_to URL from the openid request. This redirected the browser but did not complete the openid auth flow.

What was necessary instead was using the openid request object to create the correct response using orequest.answer(), and then to wrap that in a django response object, as done in identityprovider.views.server._django_response().

I've verified that this approach works in the new-user flow, and am working on cleaning up the branch and adding tests now.

Revision history for this message
Maximiliano Bertacchini (maxiberta) wrote :

Hi, I'm sorry I could not review this before. I've just run this branch and it worked as expected :-D. That said:

* There's a couple of hidden code conflicts (see comments inline). Please merge trunk, fix and run all tests again.
* I think the new "OpenID + mail verification" flow may be a little bit confusing for users as it redirects from the email verification view directly back to the RP. It'd be great if we could show the /+decide view as usual at the end of the OpenID dance instead of merging it into the email verification view.
* I think the email verification view should make that redirection more explicit. Maybe just show a warning (eg. see how it is handled in the OpenID username required case: https://bazaar.launchpad.net/~canonical-isd-hackers/canonical-identity-provider/trunk/view/head:/src/webui/views/account.py#L197).

Haven't checked the tests yet.

review: Needs Fixing
Revision history for this message
Maximiliano Bertacchini (maxiberta) wrote :

This needs some work, but the original developer is not an SSO maintainer and has likely moved on since then. It'd be quicker if someone related to SSO picked it up.

Revision history for this message
Joe Talbott (joetalbott) wrote :

I've pushed an MP with this branch merged with trunk. I'm not sure if I addressed the mail verification view to /+decide view quite the way @maxiberta mentioned.

https://code.launchpad.net/~joetalbott/canonical-identity-provider/bugs_1169632/+merge/312308

Revision history for this message
Maximiliano Bertacchini (maxiberta) wrote :
review: Needs Resubmitting

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/api/v20/handlers.py'
2--- src/api/v20/handlers.py 2016-06-08 00:55:13 +0000
3+++ src/api/v20/handlers.py 2016-07-14 21:39:02 +0000
4@@ -304,6 +304,7 @@
5 account = registration.register(
6 root_url, email, password, displayname,
7 creation_source=data.get('creation_source'),
8+ oid_token=data.get('oid_token')
9 )
10 except ValidationError as e:
11 return errors.INVALID_DATA(**e.message_dict)
12
13=== modified file 'src/api/v20/registration.py'
14--- src/api/v20/registration.py 2015-07-01 19:42:58 +0000
15+++ src/api/v20/registration.py 2016-07-14 21:39:02 +0000
16@@ -28,7 +28,7 @@
17
18 def register(
19 root_url, email, password, displayname, email_validated=False,
20- send_email=True, creation_source=None):
21+ send_email=True, creation_source=None, oid_token=None):
22 """Register a new account"""
23 emails = EmailAddress.objects.filter(email__iexact=email)
24 if emails.count() > 0:
25@@ -67,6 +67,7 @@
26 account_created.send('api', openid_identifier=account.openid_identifier)
27
28 if send_email:
29- send_new_user_email(root_url=root_url, account=account, email=email)
30+ send_new_user_email(root_url=root_url, account=account, email=email,
31+ oid_token=oid_token)
32
33 return account
34
35=== modified file 'src/api/v20/tests/test_registration.py'
36--- src/api/v20/tests/test_registration.py 2015-11-24 14:38:04 +0000
37+++ src/api/v20/tests/test_registration.py 2016-07-14 21:39:02 +0000
38@@ -50,7 +50,8 @@
39 assert Account.objects.all().count() == 0
40
41 registration.register(
42- self.sso_root_url, self.email, 'SecretPassword1', 'displayname')
43+ self.sso_root_url, self.email, 'SecretPassword1', 'displayname',
44+ oid_token="fake_oid_token")
45
46 self.assertEqual(Account.objects.all().count(), 1)
47 account = Account.objects.get()
48@@ -62,7 +63,8 @@
49 self.client.login(username=self.email, password='SecretPassword1'),
50 'Password was not properly set on new account.')
51 self.mock_send_new_user_email.assert_called_once_with(
52- root_url=self.sso_root_url, account=account, email=self.email)
53+ root_url=self.sso_root_url, oid_token="fake_oid_token",
54+ account=account, email=self.email)
55 self.assertFalse(self.mock_send_impersonation_email.called)
56
57 def test_invalid_email_and_pw(self):
58
59=== modified file 'src/identityprovider/emailutils.py'
60--- src/identityprovider/emailutils.py 2016-05-09 21:41:37 +0000
61+++ src/identityprovider/emailutils.py 2016-07-14 21:39:02 +0000
62@@ -101,6 +101,7 @@
63
64 def _context_for_email_request(root_url, account, email, token_type,
65 redirection_url, requester_email=None,
66+ oid_token=None,
67 **kwargs):
68 """
69 Build and return a context (dictionary) for rendering an e-mail template.
70@@ -116,7 +117,10 @@
71 redirection_url=redirection_url, **kwargs)
72
73 name = getattr(account, 'displayname', kwargs.get('displayname'))
74- token_url = urljoin(root_url, token.get_absolute_url())
75+ token_path = token.get_absolute_url()
76+ if oid_token:
77+ token_path = oid_token + token_path
78+ token_url = urljoin(root_url, token_path)
79 context = {
80 'requester': name,
81 'requester_email': token.requester_email,
82@@ -152,7 +156,7 @@
83
84
85 def send_new_user_email(root_url, account, email, redirection_url=None,
86- platform='all'):
87+ platform='all', oid_token=None):
88 """
89 Send an email to a new user.
90
91@@ -164,7 +168,8 @@
92 """
93
94 context, token, invalidate_email_token = _context_for_email_request(
95- root_url, account, email, AuthTokenType.VALIDATEEMAIL, redirection_url)
96+ root_url, account, email, AuthTokenType.VALIDATEEMAIL, redirection_url,
97+ oid_token=oid_token)
98
99 if platform != 'desktop':
100 template = 'email/welcome.txt'
101
102=== modified file 'src/identityprovider/tests/test_emailutils.py'
103--- src/identityprovider/tests/test_emailutils.py 2016-05-09 02:47:48 +0000
104+++ src/identityprovider/tests/test_emailutils.py 2016-07-14 21:39:02 +0000
105@@ -5,6 +5,7 @@
106
107 import re
108 import unittest
109+from urlparse import urlparse, urlunparse
110
111 from django.core import mail
112 from django.core.urlresolvers import reverse
113@@ -255,6 +256,13 @@
114 self.assertEqual(db_token, token)
115 requester = getattr(self.account, 'displayname', token.displayname)
116 token_url = self.request.build_absolute_uri(token.get_absolute_url())
117+
118+ if kwargs.get('oid_token'):
119+ p = urlparse(token_url)
120+ token_url = urlunparse((p.scheme, p.netloc,
121+ kwargs['oid_token'] + p.path,
122+ p.params, p.query, p.fragment))
123+
124 context = {
125 'requester': requester,
126 'requester_email': token.requester_email,
127@@ -376,6 +384,19 @@
128 token_type=AuthTokenType.VALIDATEEMAIL, token=token,
129 invalidate_token=invalidate_token)
130
131+ def test_send_new_user_email_with_oid_token(self):
132+ oid_token = "iama_oid_token"
133+ token, invalidate_token = send_new_user_email(
134+ self.sso_root_url, self.account, self.email, self.redirection_url,
135+ oid_token=oid_token)
136+
137+ self.assert_tokens()
138+ self.assert_email_properly_formatted(
139+ 'Finish your registration', 'email/welcome.txt',
140+ token_type=AuthTokenType.VALIDATEEMAIL, token=token,
141+ invalidate_token=invalidate_token,
142+ oid_token=oid_token)
143+
144 def test_send_password_reset_email_with_redirection_url(self):
145 token, invalidate_token = send_password_reset_email(
146 self.sso_root_url, self.account, self.email, self.redirection_url)
147
148=== modified file 'src/identityprovider/views/server.py'
149--- src/identityprovider/views/server.py 2016-07-12 14:21:03 +0000
150+++ src/identityprovider/views/server.py 2016-07-14 21:39:02 +0000
151@@ -293,6 +293,29 @@
152 (rpconfig is not None and rpconfig.auto_authorize)):
153 return _process_decide(request, orequest, decision=True)
154
155+ user_attribs_form, teams_form, macaroon_form = get_extension_forms(
156+ request, orequest, rpconfig
157+ )
158+
159+ context = {
160+ 'account': request.user,
161+ 'trust_root': orequest.trust_root,
162+ 'rpconfig': rpconfig,
163+ 'user_attribs_form': user_attribs_form,
164+ 'teams_form': teams_form,
165+ 'macaroon_form': macaroon_form,
166+ 'token': token,
167+ 'sane_trust_root': _request_has_sane_trust_root(orequest)
168+ }
169+ # Log existing authentication request (i.e. checked via pre-verified
170+ # cookie).
171+ login_succeeded.send(
172+ sender=request.user, user=request.user, request=request,
173+ authlogtype=AuthLogType.OPENID_EXISTING)
174+ return render(request, 'server/decide.html', context)
175+
176+
177+def get_extension_forms(request, orequest, rpconfig):
178 sreg_request = SRegRequest.fromOpenIDRequest(orequest)
179 ax_request = ax.FetchRequest.fromOpenIDRequest(orequest)
180 teams_request = TeamsRequest.fromOpenIDRequest(orequest)
181@@ -324,22 +347,8 @@
182 macaroon_form = MacaroonRequestForm(
183 request, macaroon_request, rpconfig, orequest.trust_root,
184 approved_data=approved_data.get('macaroon'))
185- context = {
186- 'account': request.user,
187- 'trust_root': orequest.trust_root,
188- 'rpconfig': rpconfig,
189- 'user_attribs_form': user_attribs_form,
190- 'teams_form': teams_form,
191- 'macaroon_form': macaroon_form,
192- 'token': token,
193- 'sane_trust_root': _request_has_sane_trust_root(orequest)
194- }
195- # Log existing authentication request (i.e. checked via pre-verified
196- # cookie).
197- login_succeeded.send(
198- sender=request.user, user=request.user, request=request,
199- authlogtype=AuthLogType.OPENID_EXISTING)
200- return render(request, 'server/decide.html', context)
201+
202+ return user_attribs_form, teams_form, macaroon_form
203
204
205 def _request_has_sane_trust_root(openid_request):
206
207=== modified file 'src/identityprovider/views/utils.py'
208--- src/identityprovider/views/utils.py 2016-06-24 23:04:37 +0000
209+++ src/identityprovider/views/utils.py 2016-07-14 21:39:02 +0000
210@@ -48,6 +48,14 @@
211 return rpconfig[0]
212
213
214+def get_orequest(request, token):
215+ """Returns the OpenID request for the given request from the
216+ user's browser. May throw an exception if there is no OpenID
217+ request, or if it is invalid."""
218+ raw_orequest = request.session.get(token, None)
219+ return signed.loads(raw_orequest, settings.SECRET_KEY)
220+
221+
222 def get_rpconfig_from_request(request, token):
223 rpconfig = None
224 if token: # won't have an rpconfig without a token
225
226=== modified file 'src/webui/templates/account/confirm_new_email.html'
227--- src/webui/templates/account/confirm_new_email.html 2016-03-10 17:30:19 +0000
228+++ src/webui/templates/account/confirm_new_email.html 2016-07-14 21:39:02 +0000
229@@ -20,6 +20,40 @@
230 <div class="actions">
231 <form action="" method="post">
232 {% csrf_token %}
233+ <div data-qa-id="info-items" class="info-items">
234+ {% if user_attribs_form.has_data or teams_form.has_data or macaroon_form.has_data %}
235+ <ul class="list">
236+ {% if user_attribs_form.has_data %}
237+ {% for field in user_attribs_form %}
238+ <li class="user_attribs"><div class=radio-label-row>{{ field|safe }} {{ field.label_tag }}</div></li>
239+ {% endfor %}
240+ {% endif %}
241+ <div class=radio-label-row>
242+ {% if teams_form.has_data %}
243+ {% ifequal teams_form.fields|length 1 %}
244+ {% for field in teams_form %}
245+ <li><div class=radio-label-row>{{ field|safe }} {{ field.label_tag }}</div></li>
246+ {% endfor %}
247+ {% else %}
248+ <li id="teamslist_item">
249+ <span id="teamslist_label">{% trans "Team Membership:"%}</span>
250+ <ul class="teams-list" id="teamslist">
251+ {% for field in teams_form %}
252+ <li>{{ field|safe }} {{ field.label_tag }}</li>
253+ {% endfor %}
254+ </ul>
255+ </li>
256+ {% endifequal %}
257+ {% endif %}
258+ </div>
259+ {% if macaroon_form.has_data %}
260+ {% for field in macaroon_form %}
261+ <li class="macaroon"><div class="radio-label-row">{{ field|safe }} {{ field.label_tag }}</div></li>
262+ {% endfor %}
263+ {% endif %}
264+ </ul>
265+ {% endif %}
266+ </div>
267
268 {% if captcha_required %}
269 <p class="captcha" id="captcha">
270
271=== modified file 'src/webui/tests/test_views_registration.py'
272--- src/webui/tests/test_views_registration.py 2016-05-10 15:13:36 +0000
273+++ src/webui/tests/test_views_registration.py 2016-07-14 21:39:02 +0000
274@@ -192,6 +192,15 @@
275 response['Location'],
276 reverse('server-decide', kwargs=dict(token=token))
277 )
278+ expected_args = dict(email=self.TESTDATA['email'],
279+ password=self.TESTDATA['password'],
280+ displayname=self.TESTDATA['displayname'],
281+ create_captcha=False,
282+ captcha_solution=None,
283+ captcha_id=None,
284+ creation_source='web-flow',
285+ oid_token=token)
286+ self.mock_api_register.assert_called_once_with(**expected_args)
287
288 def test_post_already_registered(self):
289 exc = api_errors.AlreadyRegistered(Mock())
290
291=== modified file 'src/webui/tests/test_views_ui.py'
292--- src/webui/tests/test_views_ui.py 2016-06-02 04:15:06 +0000
293+++ src/webui/tests/test_views_ui.py 2016-07-14 21:39:02 +0000
294@@ -15,7 +15,7 @@
295 from functools import partial
296 from StringIO import StringIO
297 from textwrap import dedent
298-from urlparse import urlsplit
299+from urlparse import urlsplit, urlparse, urlunparse
300 from urllib import quote
301
302 from django.conf import settings
303
304=== modified file 'src/webui/views/registration.py'
305--- src/webui/views/registration.py 2016-04-29 14:17:15 +0000
306+++ src/webui/views/registration.py 2016-07-14 21:39:02 +0000
307@@ -14,6 +14,7 @@
308 from django.http import (
309 HttpResponseNotAllowed,
310 HttpResponseRedirect,
311+ HttpResponseServerError
312 )
313 from django.utils.decorators import method_decorator
314 from django.utils.translation import ugettext_lazy as _
315@@ -115,6 +116,8 @@
316 # we'll handle our own capture generation
317 data['create_captcha'] = False
318 data['creation_source'] = WEB_CREATION_SOURCE
319+ if token:
320+ data['oid_token'] = token
321
322 url = redirection_url_for_token(token)
323
324@@ -141,7 +144,8 @@
325 captcha_error = '&error=' + e.extra.get('captcha_message', '')
326 captcha_error_message = _('Incorrect captcha solution')
327 collect_stats('error.captcha')
328-
329+ except Exception as e:
330+ return HttpResponseServerError("exception: " + str(e))
331 else:
332 collect_stats('success')
333
334
335=== modified file 'src/webui/views/ui.py'
336--- src/webui/views/ui.py 2016-06-14 21:03:43 +0000
337+++ src/webui/views/ui.py 2016-07-14 21:39:02 +0000
338@@ -32,6 +32,8 @@
339 from django.views.decorators.cache import cache_control
340 from django.views.decorators.gzip import gzip_page
341 from gargoyle import gargoyle
342+from openid.extensions import ax
343+from openid.extensions.sreg import SRegRequest
344
345 from identityprovider import emailutils
346 from identityprovider.forms import (
347@@ -40,6 +42,7 @@
348 ResetPasswordForm,
349 TwoFactorForm,
350 TwoFactorLoginForm,
351+ UserAttribsRequestForm,
352 )
353 from identityprovider.login import (
354 AuthenticationError,
355@@ -70,8 +73,16 @@
356 from identityprovider.validators import PASSWORD_LEAKED
357 from identityprovider.views.utils import (
358 get_rpconfig_from_request,
359+ get_orequest,
360 is_safe_redirect_url,
361 )
362+from identityprovider.views.server import (
363+ _django_response,
364+ _add_user_attribs,
365+ _check_team_membership,
366+ _add_macaroon,
367+ get_extension_forms
368+)
369 from webui.decorators import (
370 check_readonly,
371 dont_cache,
372@@ -543,6 +554,9 @@
373 # the TransactionMiddleware will not rollback the dirty transaction
374 return HttpResponseNotFound()
375
376+ if token:
377+ orequest = get_orequest(request, token)
378+
379 if request.method == 'POST':
380 captcha_solution = request.POST.get('g-recaptcha-response')
381 verified = False
382@@ -575,6 +589,23 @@
383 email=email, token_type=AuthTokenType.INVALIDATEEMAIL)
384 for t in tokens:
385 t.consume()
386+
387+ if token:
388+ if orequest.idSelect():
389+ openid_identity_url = request.build_absolute_uri(
390+ request.user.get_absolute_url())
391+ oresponse = orequest.answer(True,
392+ identity=openid_identity_url)
393+ else:
394+ oresponse = orequest.answer(True)
395+ _add_user_attribs(request, orequest, oresponse)
396+ _check_team_membership(
397+ request, orequest, oresponse, immediate=True)
398+ _add_macaroon(request, orequest, oresponse)
399+
400+ return _django_response(
401+ request, oresponse, auth_success=True, orequest=orequest)
402+
403 return HttpResponseRedirect(
404 atrequest.redirection_url or reverse('account-index')
405 )
406@@ -588,6 +619,18 @@
407 'captcha_required': captcha_switch_active,
408 'captcha_error_message': captcha_error_message,
409 'CAPTCHA_PUBLIC_KEY': settings.CAPTCHA_PUBLIC_KEY}
410+
411+ if token:
412+ rpconfig = get_rpconfig_from_request(request, token)
413+ user_attribs_form, teams_form, macaroon_form = get_extension_forms(
414+ request, orequest, rpconfig
415+ )
416+ context.update({
417+ 'user_attribs_form': user_attribs_form,
418+ 'teams_form': teams_form,
419+ 'macaroon_form': macaroon_form
420+ })
421+
422 return render(request, 'account/confirm_new_email.html', context)
423
424