Merge lp:~matiasb/canonical-identity-provider/prevent-preferring-unvalidated-email into lp:canonical-identity-provider/release
- prevent-preferring-unvalidated-email
- Merge into trunk
Proposed by
Matias Bordese
Status: | Merged |
---|---|
Approved by: | Natalia Bidart |
Approved revision: | no longer in the source branch. |
Merged at revision: | 631 |
Proposed branch: | lp:~matiasb/canonical-identity-provider/prevent-preferring-unvalidated-email |
Merge into: | lp:canonical-identity-provider/release |
Diff against target: |
475 lines (+161/-90) 11 files modified
identityprovider/forms.py (+17/-17) identityprovider/models/account.py (+4/-1) identityprovider/models/emailaddress.py (+3/-0) identityprovider/tests/unit/test_forms.py (+35/-0) identityprovider/tests/unit/test_models_account.py (+9/-0) identityprovider/tests/unit/test_models_emailaddress.py (+10/-0) ubuntu_sso_saml/tests.py (+1/-1) webui/templates/account/edit.html (+15/-6) webui/tests/test_views_account.py (+2/-0) webui/tests/test_views_ui.py (+32/-39) webui/views/ui.py (+33/-26) |
To merge this branch: | bzr merge lp:~matiasb/canonical-identity-provider/prevent-preferring-unvalidated-email |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Natalia Bidart (community) | Approve | ||
Review via email: mp+145032@code.launchpad.net |
Commit message
Updated checks and validation for users and (un)verified email addresses:
- Prevent user changing preferred email address to an unverified email address.
- Prevent password reset for users without a verified email address.
Description of the change
Updated checks and validation for users and (un)verified email addresses:
- Prevent user changing preferred email address to an unverified email address.
- Prevent password reset for users without a verified email address.
To post a comment you must log in.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'identityprovider/forms.py' |
2 | --- identityprovider/forms.py 2012-12-18 18:37:03 +0000 |
3 | +++ identityprovider/forms.py 2013-01-25 22:16:21 +0000 |
4 | @@ -154,12 +154,6 @@ |
5 | displayname = fields.CharField( |
6 | error_messages=default_errors, |
7 | widget=ROAwareTextInput(attrs={'class': 'textType', 'size': '20'})) |
8 | - preferred_email = PreferredEmailField( |
9 | - error_messages=email_errors, |
10 | - queryset=EmailAddress.objects.none(), |
11 | - widget=ROAwareSelect, |
12 | - empty_label=None, |
13 | - ) |
14 | password = fields.CharField( |
15 | required=False, |
16 | help_text=PASSWORD_POLICY_HELP_TEXT, |
17 | @@ -193,16 +187,21 @@ |
18 | else: |
19 | preferredemail_id = self.instance.preferredemail.id |
20 | |
21 | - # Override the field to set initial value (current preferred email) |
22 | - emails = EmailAddress.objects.filter(account=self.instance) |
23 | - self.fields['preferred_email'] = PreferredEmailField( |
24 | - queryset=emails.exclude(status=EmailStatus.NEW).order_by('-status', |
25 | - 'email'), |
26 | - initial=preferredemail_id, |
27 | - widget=ROAwareSelect, |
28 | - error_messages=email_errors, |
29 | - empty_label=None |
30 | - ) |
31 | + # check for emails that can be preferred |
32 | + validated_emails = self.instance.verified_emails() |
33 | + |
34 | + if validated_emails.count() > 0: |
35 | + # add and display a dropdown with the valid choices |
36 | + self.fields['preferred_email'] = PreferredEmailField( |
37 | + queryset=validated_emails.order_by('email'), |
38 | + initial=preferredemail_id, |
39 | + widget=ROAwareSelect, |
40 | + error_messages=email_errors, |
41 | + empty_label=None, |
42 | + help_text=_( |
43 | + 'Only verified email addresses are listed. ' |
44 | + 'You can add and verify emails through the link below.'), |
45 | + ) |
46 | |
47 | class Meta: |
48 | fields = ('displayname', 'warn_about_backup_device', |
49 | @@ -257,7 +256,8 @@ |
50 | new_password = encrypt_launchpad_password(password) |
51 | self.instance.accountpassword.password = new_password |
52 | self.instance.accountpassword.save() |
53 | - self.instance.preferredemail = self.cleaned_data['preferred_email'] |
54 | + if 'preferred_email' in self.cleaned_data: |
55 | + self.instance.preferredemail = self.cleaned_data['preferred_email'] |
56 | |
57 | super(EditAccountForm, self).save() |
58 | |
59 | |
60 | === modified file 'identityprovider/models/account.py' |
61 | --- identityprovider/models/account.py 2013-01-22 18:28:03 +0000 |
62 | +++ identityprovider/models/account.py 2013-01-25 22:16:21 +0000 |
63 | @@ -225,6 +225,8 @@ |
64 | return self._preferredemail |
65 | |
66 | def _set_preferredemail(self, email): |
67 | + if not email.is_verified(): |
68 | + raise ValidationError('Email must be verified') |
69 | current = self.preferredemail |
70 | if current is not None: |
71 | current.status = EmailStatus.VALIDATED |
72 | @@ -298,7 +300,8 @@ |
73 | |
74 | @property |
75 | def can_reset_password(self): |
76 | - return (self.is_active or self.can_reactivate) |
77 | + return ((self.is_active or self.can_reactivate) and |
78 | + self.verified_emails().count() > 0) |
79 | |
80 | @property |
81 | def is_active(self): |
82 | |
83 | === modified file 'identityprovider/models/emailaddress.py' |
84 | --- identityprovider/models/emailaddress.py 2013-01-22 18:28:03 +0000 |
85 | +++ identityprovider/models/emailaddress.py 2013-01-25 22:16:21 +0000 |
86 | @@ -39,6 +39,9 @@ |
87 | def is_preferred(self): |
88 | return self.status == EmailStatus.PREFERRED |
89 | |
90 | + def is_verified(self): |
91 | + return self.status in (EmailStatus.VALIDATED, EmailStatus.PREFERRED) |
92 | + |
93 | def account_admin_link(self): |
94 | if self.account is None: |
95 | return "None" |
96 | |
97 | === modified file 'identityprovider/tests/unit/test_forms.py' |
98 | --- identityprovider/tests/unit/test_forms.py 2012-12-17 16:27:17 +0000 |
99 | +++ identityprovider/tests/unit/test_forms.py 2013-01-25 22:16:21 +0000 |
100 | @@ -9,6 +9,7 @@ |
101 | from openid.extensions.sreg import SRegRequest |
102 | |
103 | from identityprovider.models.account import Account |
104 | +from identityprovider.models.const import EmailStatus |
105 | from identityprovider.forms import ( |
106 | DeviceRenameForm, |
107 | EditAccountForm, |
108 | @@ -49,6 +50,40 @@ |
109 | |
110 | self.assertEqual(form.initial.get('displayname', ''), "Sample Person") |
111 | |
112 | + def test_account_without_validated_emails_no_field(self): |
113 | + self.account.emailaddress_set.update(status=EmailStatus.NEW) |
114 | + form = EditAccountForm(instance=self.account) |
115 | + self.assertNotIn('preferred_email', form.fields) |
116 | + |
117 | + def test_account_with_validated_emails_editable_field(self): |
118 | + form = EditAccountForm(instance=self.account) |
119 | + self.assertIn('preferred_email', form.fields) |
120 | + choices = form.fields['preferred_email'].queryset.all() |
121 | + valid_choices = self.account.verified_emails() |
122 | + self.assertEqual(choices.count(), valid_choices.count()) |
123 | + for email in choices: |
124 | + self.assertIn(email, valid_choices) |
125 | + |
126 | + def test_account_without_validated_emails_post_preferred_email(self): |
127 | + self.account.emailaddress_set.update(status=EmailStatus.NEW) |
128 | + original_value = self.account.preferredemail |
129 | + invalid_email = self.account.unverified_emails().exclude( |
130 | + email=original_value)[0] |
131 | + form = EditAccountForm(instance=self.account, |
132 | + data={'displayname': self.account.displayname, |
133 | + 'preferred_email': invalid_email.email}) |
134 | + self.assertTrue(form.is_valid()) |
135 | + form.save() |
136 | + account = Account.objects.get(id=self.account.id) |
137 | + self.assertEqual(account.preferredemail, original_value) |
138 | + |
139 | + def test_account_with_validated_email_changing_to_unvalidate(self): |
140 | + invalid_email = self.account.unverified_emails()[0] |
141 | + form = EditAccountForm(instance=self.account, |
142 | + data={'displayname': self.account.displayname, |
143 | + 'preferred_email': invalid_email.id}) |
144 | + self.assertFalse(form.is_valid()) |
145 | + |
146 | def test_displayname_validation(self): |
147 | form = EditAccountForm(instance=self.account, |
148 | data={'displayname': ' '}) |
149 | |
150 | === modified file 'identityprovider/tests/unit/test_models_account.py' |
151 | --- identityprovider/tests/unit/test_models_account.py 2013-01-14 12:31:37 +0000 |
152 | +++ identityprovider/tests/unit/test_models_account.py 2013-01-25 22:16:21 +0000 |
153 | @@ -209,6 +209,15 @@ |
154 | account = self.factory.make_account(email_validated=False) |
155 | self.assertIsNone(account.preferredemail) |
156 | |
157 | + def test_set_preferred_with_unverified_email(self): |
158 | + account = self.factory.make_account(email_validated=False) |
159 | + with self.assertRaises(ValidationError): |
160 | + account.preferredemail = account.emailaddress_set.get() |
161 | + |
162 | + def test_cant_reset_password_without_verified_email(self): |
163 | + account = self.factory.make_account(email_validated=False) |
164 | + self.assertFalse(account.can_reset_password) |
165 | + |
166 | def test_validated_email_address_is_preferred_email_by_default(self): |
167 | account = self.factory.make_account(email_validated=False) |
168 | email_address = account.emailaddress_set.create( |
169 | |
170 | === modified file 'identityprovider/tests/unit/test_models_emailaddress.py' |
171 | --- identityprovider/tests/unit/test_models_emailaddress.py 2012-10-24 20:04:50 +0000 |
172 | +++ identityprovider/tests/unit/test_models_emailaddress.py 2013-01-25 22:16:21 +0000 |
173 | @@ -1,6 +1,7 @@ |
174 | from mock import patch |
175 | |
176 | from identityprovider.models import Account, EmailAddress |
177 | +from identityprovider.models.const import EmailStatus |
178 | from identityprovider.tests.utils import SSOBaseTestCase |
179 | |
180 | |
181 | @@ -12,6 +13,15 @@ |
182 | email = EmailAddress.objects.get(email='test@canonical.com') |
183 | self.assertEqual(account, email.account) |
184 | |
185 | + def test_emailaddress_is_verified(self): |
186 | + email = EmailAddress.objects.get(email='test@canonical.com') |
187 | + assert email.status == EmailStatus.PREFERRED |
188 | + self.assertTrue(email.is_verified()) |
189 | + email.status = EmailStatus.VALIDATED |
190 | + self.assertTrue(email.is_verified()) |
191 | + email.status = EmailStatus.NEW |
192 | + self.assertFalse(email.is_verified()) |
193 | + |
194 | @patch('identityprovider.models.emailaddress.reverse') |
195 | def test_account_admin_link(self, mock_reverse): |
196 | mock_reverse.return_value = '/foo' |
197 | |
198 | === modified file 'ubuntu_sso_saml/tests.py' |
199 | --- ubuntu_sso_saml/tests.py 2013-01-15 18:20:50 +0000 |
200 | +++ ubuntu_sso_saml/tests.py 2013-01-25 22:16:21 +0000 |
201 | @@ -176,7 +176,7 @@ |
202 | alternate_email = 'alternate@example.com' |
203 | alternate, _ = EmailAddress.objects.get_or_create( |
204 | email=alternate_email, defaults={'account': account, |
205 | - 'status': EmailStatus.NEW}) |
206 | + 'status': EmailStatus.VALIDATED}) |
207 | # and make the alternate email the preferred |
208 | account = change_preferred_email(self.login_email, alternate_email) |
209 | self.assertEqual(account.preferredemail, alternate) |
210 | |
211 | === modified file 'webui/templates/account/edit.html' |
212 | --- webui/templates/account/edit.html 2012-12-04 18:51:42 +0000 |
213 | +++ webui/templates/account/edit.html 2013-01-25 22:16:21 +0000 |
214 | @@ -95,13 +95,22 @@ |
215 | </span> |
216 | </label> |
217 | <br /> |
218 | - {% if form.preferred_email.errors %} |
219 | - <span class="error"> |
220 | - {{ form.preferred_email.errors|first }} |
221 | - </span> |
222 | - <br /> |
223 | + {% if form.preferred_email %} |
224 | + {% if form.preferred_email.errors %} |
225 | + <span class="error"> |
226 | + {{ form.preferred_email.errors|first }} |
227 | + </span> |
228 | + <br /> |
229 | + {% endif %} |
230 | + {{ form.preferred_email }} |
231 | + <span class="formHelp">{{ form.preferred_email.help_text }}</span> |
232 | + {% else %} |
233 | + {# Manually added, it will be ignored by the django form #} |
234 | + {% with form.instance.preferredemail as unvalidated_preferred %} |
235 | + <input type="text" value="{{ unvalidated_preferred }}" disabled="true" /> |
236 | + <span class="formHelp">You still need to <a href="{% url verify_email %}?id={{ unvalidated_preferred.id }}">verify</a> your email address.</span> |
237 | + {% endwith %} |
238 | {% endif %} |
239 | - {{ form.preferred_email }} |
240 | </p> |
241 | {% if not embedded %} |
242 | <p><a href="/+emails">{% trans "Manage email addresses" %}</a></p> |
243 | |
244 | === modified file 'webui/tests/test_views_account.py' |
245 | --- webui/tests/test_views_account.py 2013-01-17 20:27:23 +0000 |
246 | +++ webui/tests/test_views_account.py 2013-01-25 22:16:21 +0000 |
247 | @@ -414,6 +414,8 @@ |
248 | def test_deactivate_account(self): |
249 | # make sure we have a preferredemail |
250 | email = self.account.emailaddress_set.all()[0] |
251 | + email.status = EmailStatus.VALIDATED |
252 | + email.save() |
253 | self.account.preferredemail = email |
254 | |
255 | # deactivate account |
256 | |
257 | === modified file 'webui/tests/test_views_ui.py' |
258 | --- webui/tests/test_views_ui.py 2013-01-22 18:28:03 +0000 |
259 | +++ webui/tests/test_views_ui.py 2013-01-25 22:16:21 +0000 |
260 | @@ -273,26 +273,17 @@ |
261 | self.assertContains(response, "_gaq.push(['_setAccount', '12345']);") |
262 | |
263 | def test_claim_token_for_password_recovery_no_preferredemail(self): |
264 | - """ According to bug #524582 this should be handled gracefully by |
265 | - verifying the email address and resetting the password. """ |
266 | + """Prevent password reset for unvalidated emails.""" |
267 | account = Account.objects.get_by_email('test@canonical.com') |
268 | - _preferred_email = account.preferredemail |
269 | for email_obj in account.emailaddress_set.all(): |
270 | email_obj.status = EmailStatus.NEW |
271 | email_obj.save() |
272 | |
273 | - r = self.client.post(reverse('forgot_password'), |
274 | - {'email': 'test@canonical.com'}) |
275 | - |
276 | - # reset password |
277 | - data = {'password': 'Password1', 'passwordconfirm': 'Password1'} |
278 | - r = self.client.post(self._token_url(), data) |
279 | - self.assertRedirects(r, reverse('account-index')) |
280 | - |
281 | - for email_obj in account.emailaddress_set.all(): |
282 | - email_obj.status = EmailStatus.VALIDATED |
283 | - email_obj.save() |
284 | - account.preferredemail = _preferred_email |
285 | + self.client.post(reverse('forgot_password'), |
286 | + {'email': 'test@canonical.com'}) |
287 | + |
288 | + # since there is verified email address, no email was sent |
289 | + self.assertEqual(self.mock_send_email.call_count, 0) |
290 | |
291 | |
292 | class EnterTokenTestCase(UIViewsBaseTestCase): |
293 | @@ -638,8 +629,7 @@ |
294 | self.assertRedirects(r, reverse('account-index')) |
295 | |
296 | def test_reset_password_when_account_deactivated(self): |
297 | - """ Deactivated accounts can be reactivated using the reset password |
298 | - functionality. Bug #556878 """ |
299 | + """Prevent password reset for unvalidated emails.""" |
300 | r = self.client.post(reverse('forgot_password'), |
301 | {'email': 'test@canonical.com'}) |
302 | |
303 | @@ -656,38 +646,41 @@ |
304 | def test_reset_password_when_account_deactivated_no_preferred_email(self): |
305 | account = Account.objects.get_by_email('test@canonical.com') |
306 | # make sure the account is deactivated and preferred email is removed |
307 | - _preferred_email = account.preferredemail |
308 | account.status = AccountStatus.DEACTIVATED |
309 | # Blow out the cached value |
310 | account.save() |
311 | - for email_obj in account.emailaddress_set.all(): |
312 | - email_obj.status = EmailStatus.NEW |
313 | - email_obj.save() |
314 | + account.emailaddress_set.update(status=EmailStatus.NEW) |
315 | |
316 | account = Account.objects.get_by_email('test@canonical.com') |
317 | |
318 | if gargoyle.is_active('ALLOW_UNVALIDATED'): |
319 | - self.assertEqual(account.preferredemail.email, 'test@canoical.com') |
320 | + self.assertEqual(account.preferredemail.email, |
321 | + 'test@canonical.com') |
322 | else: |
323 | self.assertEqual(account.preferredemail, None) |
324 | |
325 | - r = self.client.post(reverse('forgot_password'), |
326 | - {'email': 'test@canonical.com'}) |
327 | - |
328 | - self.assertEqual(self.mock_send_email.call_count, 1) |
329 | - |
330 | - # reset password |
331 | - data = {'password': 'Password1', 'passwordconfirm': 'Password1'} |
332 | - r = self.client.post(self._token_url(), data) |
333 | - self.assertRedirects(r, reverse('account-index')) |
334 | - |
335 | - account = Account.objects.get_by_email('test@canonical.com') |
336 | - self.assertEqual(account.preferredemail.email, 'test@canonical.com') |
337 | - |
338 | - for email_obj in account.emailaddress_set.all(): |
339 | - email_obj.status = EmailStatus.VALIDATED |
340 | - email_obj.save() |
341 | - account.preferredemail = _preferred_email |
342 | + self.client.post(reverse('forgot_password'), |
343 | + {'email': 'test@canonical.com'}) |
344 | + |
345 | + # since there is verified email address, no email was sent |
346 | + self.assertEqual(self.mock_send_email.call_count, 0) |
347 | + |
348 | + def test_reset_password_when_account_no_verified_emails(self): |
349 | + account = Account.objects.get_by_email('test@canonical.com') |
350 | + account.emailaddress_set.update(status=EmailStatus.NEW) |
351 | + self.client.post(reverse('forgot_password'), |
352 | + {'email': 'test@canonical.com'}) |
353 | + # since there is verified email address, no email was sent |
354 | + self.assertEqual(self.mock_send_email.call_count, 0) |
355 | + |
356 | + def test_reset_password_unverified_email_but_account_verified_email(self): |
357 | + account = Account.objects.get_by_email('test@canonical.com') |
358 | + verified = account.verified_emails() |
359 | + unverified = account.unverified_emails()[0] |
360 | + self.client.post(reverse('forgot_password'), |
361 | + {'email': unverified.email}) |
362 | + # email sent to verified email addresses instead |
363 | + self.assertEqual(self.mock_send_email.call_count, len(verified)) |
364 | |
365 | def test_reset_password_when_account_noaccount(self): |
366 | r = self.client.post(reverse('forgot_password'), |
367 | |
368 | === modified file 'webui/views/ui.py' |
369 | --- webui/views/ui.py 2013-01-22 18:28:03 +0000 |
370 | +++ webui/views/ui.py 2013-01-25 22:16:21 +0000 |
371 | @@ -768,30 +768,36 @@ |
372 | |
373 | |
374 | def _handle_reset_request(request, email, token): |
375 | - # options for actions to be taken |
376 | - NONE, RESET, CREATE = range(3) |
377 | - # assume invalid |
378 | - send_email = NONE |
379 | account = Account.objects.get_by_email(email) |
380 | if account is not None: |
381 | + verified_emails = account.verified_emails() |
382 | if account.can_reset_password: |
383 | - send_email = RESET |
384 | + redirection_url = redirection_url_for_token(token) |
385 | + if not verified_emails.filter(email=email).count() > 0: |
386 | + # provided email is not verified |
387 | + # send email to verified addresses instead |
388 | + for email_obj in verified_emails: |
389 | + emailutils.send_password_reset_email(account, |
390 | + email_obj.email, |
391 | + redirection_url) |
392 | + else: |
393 | + emailutils.send_password_reset_email(account, email, |
394 | + redirection_url) |
395 | + set_session_email(request.session, email) |
396 | + elif verified_emails.count() == 0: |
397 | + # user does not have any verified email address |
398 | + # he should contact support |
399 | + condition = ("account '%s' has no verified email address" % |
400 | + account.displayname) |
401 | + logger.debug("In view 'forgot_password' email was not " |
402 | + "sent out because %s" % condition) |
403 | else: |
404 | - send_email = NONE |
405 | # log why email was not sent |
406 | condition = ("account '%s' is not active" % |
407 | account.displayname) |
408 | logger.debug("In view 'forgot_password' email was not " |
409 | "sent out because %s" % condition) |
410 | else: |
411 | - send_email = CREATE |
412 | - |
413 | - # we should know now the action we need to take |
414 | - if send_email == RESET: |
415 | - redirection_url = redirection_url_for_token(token) |
416 | - emailutils.send_password_reset_email(account, email, redirection_url) |
417 | - set_session_email(request.session, email) |
418 | - elif send_email == CREATE: |
419 | # they've tried to reset with an invalid email, so send them an email |
420 | # on how to create an account |
421 | emailutils.send_invitation_after_password_reset(email) |
422 | @@ -833,18 +839,26 @@ |
423 | msgs = { |
424 | 'email_to': email, |
425 | 'email_from': settings.NOREPLY_FROM_ADDRESS, |
426 | + 'support_form_url': settings.SUPPORT_FORM_URL, |
427 | } |
428 | + reason = _( |
429 | + "We've just emailed " |
430 | + "%(email_to)s (from %(email_from)s) with " |
431 | + "instructions on resetting your password.<br/><br/>" |
432 | + "If the email address you provided has not been verified " |
433 | + "we'll use your account's verified email address instead. " |
434 | + "If you don't have a verified email address please " |
435 | + "<a href='%(support_form_url)s'>contact support</a>." |
436 | + ) % msgs |
437 | return display_email_sent( |
438 | request, |
439 | email, |
440 | _("Forgotten your password?"), |
441 | - _("We’ve just emailed " |
442 | - "%(email_to)s (from %(email_from)s) with " |
443 | - "instructions on resetting your password.") % msgs, |
444 | + reason, |
445 | _("Check that you’ve actually " |
446 | "entered a subscribed email address."), |
447 | token=token, |
448 | - rpconfig=rpconfig |
449 | + rpconfig=rpconfig, |
450 | ) |
451 | else: |
452 | # track form errors |
453 | @@ -864,6 +878,7 @@ |
454 | |
455 | atrequest = verify_token_string(authtoken, email_address) |
456 | account = atrequest and atrequest.requester |
457 | + |
458 | if (atrequest is None or |
459 | atrequest.token_type != TokenType.PASSWORDRECOVERY or |
460 | not account.can_reset_password): |
461 | @@ -894,14 +909,6 @@ |
462 | msg = _("Your account was reactivated, and the preferred " |
463 | "email address was set to {email}.") |
464 | messages.info(request, msg.format(email=email_obj.email)) |
465 | - elif account.preferredemail is None: |
466 | - # handle this case gracefully according to #524582 |
467 | - email_obj, created = EmailAddress.objects.get_or_create( |
468 | - account=account, |
469 | - email=atrequest.requester_email, |
470 | - defaults={'status': EmailStatus.PREFERRED}) |
471 | - if not created: |
472 | - account.preferredemail = email_obj |
473 | email = account.preferredemail.email |
474 | account.set_password(password) |
475 | user = auth.authenticate(username=email, password=password) |
Looks good!