Merge lp:~matiasb/canonical-identity-provider/prevent-preferring-unvalidated-email into lp:canonical-identity-provider/release

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
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.
Revision history for this message
Natalia Bidart (nataliabidart) wrote :

Looks good!

review: Approve

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&rsquo;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&rsquo;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)