Merge lp:~mikemc/canonical-identity-provider/fix-1169632 into lp:canonical-identity-provider/release
- fix-1169632
- Merge into trunk
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 |
Related bugs: |
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.
Ricardo Kirkner (ricardokirkner) : | # |
Mike McCracken (mikemc) : | # |
Mike McCracken (mikemc) wrote : | # |
Mike McCracken (mikemc) wrote : | # |
One more concern here - moving the logic for getting the return_to URL out of the orequest into redirection_
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_
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_
Thanks!
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 identityprovide
I've verified that this approach works in the new-user flow, and am working on cleaning up the branch and adding tests now.
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:/
Haven't checked the tests yet.
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.
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:/
Maximiliano Bertacchini (maxiberta) wrote : | # |
I'm closing this merge proposal as it's been superseded by https:/
Preview Diff
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 |
Thanks for the review, and my apologies for taking so long to get back to this.