Merge lp:~tartley/canonical-identity-provider/allow-new-emails into lp:canonical-identity-provider/release

Proposed by Jonathan Hartley
Status: Merged
Approved by: Jonathan Hartley
Approved revision: no longer in the source branch.
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: lp:~tartley/canonical-identity-provider/allow-new-emails
Merge into: lp:canonical-identity-provider/release
Diff against target: 248 lines (+80/-48)
5 files modified
src/api/v20/handlers.py (+9/-11)
src/api/v20/tests/test_handlers.py (+11/-18)
src/identityprovider/models/account.py (+13/-9)
src/identityprovider/tests/test_models_account.py (+44/-4)
src/webui/tests/test_views_ui.py (+3/-6)
To merge this branch: bzr merge lp:~tartley/canonical-identity-provider/allow-new-emails
Reviewer Review Type Date Requested Status
Daniel Manrique (community) Approve
Maximiliano Bertacchini Approve
Review via email: mp+377333@code.launchpad.net

Commit message

Restore users ability to send password reset email to new addresses.

A branch was merged before Christmas to fix a security hole in the
password reset process. In that branch, out of an abundance of
caution, we also prevented password reset emails from being sent
to 'new' email addresses.
https://code.launchpad.net/~tartley/canonical-identity-provider/password-reset/+merge/376991

On reflection, the latter part was more cautious than required.
This MP restores the ability for the password reset email logic
to fall back to using an account's 'new' email address if no
preferred or validated email addresses exist.

To post a comment you must log in.
Revision history for this message
Jonathan Hartley (tartley) wrote :

diff comments for reviewers

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

LGTM +1

review: Approve
Revision history for this message
Daniel Manrique (roadmr) wrote :

+1, LGTM the only thing I'm missing (but I may be missing it by not having read stuff not in the diff) is a test that preferredemail for an account with two NEW addresses returns the most recently-added one.

review: Approve
Revision history for this message
Jonathan Hartley (tartley) wrote :

> +1, LGTM the only thing I'm missing (but I may be missing it by not having
> read stuff not in the diff) is a test that preferredemail for an account with
> two NEW addresses returns the most recently-added one.

You are right. That functionality is existing, not new in this MP, and I didn't add a test for it. I'll see whether I can quickly add a line to existing tests to test it easily.

Revision history for this message
Jonathan Hartley (tartley) wrote :

The only change of this latest commit is adding the new block of tests at MP line 171, and tweaking comments that talked about "most recent address" to say "oldest address".

Revision history for this message
Daniel Manrique (roadmr) wrote :

Surprise surprise is surprising - Yesterday I showed a scenario where returning the newest address made sense, on the other hand it seems reasonable to preserve the old behavior for now, since tweaking that behavior should be out of scope for this restorative MP.

+1 again, just one comment below (though it would apply to other places in the code).

review: Approve
Revision history for this message
Jonathan Hartley (tartley) :

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 2019-12-19 22:43:09 +0000
3+++ src/api/v20/handlers.py 2020-01-08 22:49:02 +0000
4@@ -254,8 +254,8 @@
5 def create(self, request):
6 data = request.data
7 # This email, provided in the password reset request,
8- # may be from an attacker. Even if it case-insentively matches an
9- # email in our database, unicode case trickery mean it might actually
10+ # may be from an attacker. Even if it case-insensitively matches an
11+ # email in our database, Unicode case trickery mean it might actually
12 # be a distinct email address, controlled by an attacker.
13 # So it's important not to use it to generate tokens, send emails.
14 provided_email = valid_email(data.get('email'))
15@@ -318,19 +318,17 @@
16 if matching_validated:
17 account_email = matching_validated.first().email
18 else:
19- # Since no validated email matched, presuambly provided_email
20- # matches one of this account's "new" emails.
21- # Set account_email to the account's preferred email.
22- # If that doesn't exist, this getter falls back to any of the
23- # account's validated emails.
24- preferred = account._get_preferredemail(use_new=False)
25+ # provided_email doesn't match any of this account's validated
26+ # email addresses. So get an account email address from our db.
27+ # This getter falls back through preferred, validated, new.
28+ preferred = account.preferredemail
29 if preferred:
30 account_email = preferred.email
31
32 if not account_email:
33- # The account has no preferred or validated email.
34- # For security, we do not fallback to using new email addresses,
35- # nor the provided_email from the password reset request.
36+ # This should be impossible. We found this account using
37+ # 'provided_email', but now we're finding the account has no
38+ # useable email addresses.
39 return errors.CAN_NOT_RESET_PASSWORD()
40
41 tokens = AuthToken.objects.filter(
42
43=== modified file 'src/api/v20/tests/test_handlers.py'
44--- src/api/v20/tests/test_handlers.py 2019-12-19 19:38:06 +0000
45+++ src/api/v20/tests/test_handlers.py 2020-01-08 22:49:02 +0000
46@@ -1851,6 +1851,17 @@
47 # Then password reset uses the stored validated email
48 self.assert_email_used(result, 'validated@mail.com')
49
50+ def test_reset_password_given_new_email_uses_new(self):
51+ # Given an account with one new email address
52+ self.factory.make_account(
53+ email="new@mail.com", email_validated=False)
54+
55+ # When a password reset request matches the new address
56+ result = self.do_post({'email': "NEW@mail.com"}, status_code=201)
57+
58+ # Then the password reset uses the stored new email
59+ self.assert_email_used(result, 'new@mail.com')
60+
61 def test_reset_password_given_validated_email_uses_it(self):
62 # Given an account with one preferred email address
63 account = self.factory.make_account(email="preferred@mail.com")
64@@ -1864,24 +1875,6 @@
65 # Then the password reset uses the stored validated email
66 self.assert_email_used(result, 'validated@mail.com')
67
68- def test_reset_password_with_no_validated_email_refuses(self):
69- # Given an account with one new email address
70- self.factory.make_account(
71- email="new@mail.com", email_validated=False)
72-
73- # When a password reset request matches the new address
74- result = self.do_post({'email': "NEW@mail.com"}, status_code=403)
75-
76- # Then password reset is refused
77- self.assertEqual(
78- {
79- 'message': 'Can not reset password. '
80- 'Please contact login support',
81- 'code': 'CAN_NOT_RESET_PASSWORD',
82- 'extra': {}
83- },
84- result)
85-
86 def test_too_many_tokens(self):
87 with self.settings(MAX_PASSWORD_RESET_TOKENS=0):
88 content = self.do_post(data=self.data, status_code=403)
89
90=== modified file 'src/identityprovider/models/account.py'
91--- src/identityprovider/models/account.py 2019-12-19 16:30:42 +0000
92+++ src/identityprovider/models/account.py 2020-01-08 22:49:02 +0000
93@@ -318,15 +318,15 @@
94 self.accountpassword.save()
95 self.invalidate_oauth_tokens()
96
97- def _get_preferredemail(self, use_new=True):
98+ def _get_preferredemail(self):
99 """
100 Returns current preferred email address.
101
102 If there is already one, and it is verified, use it.
103- If not, promote the most recently validated email to preferred,
104+ If not, promote the oldest validated email to preferred,
105 and return that.
106- If there are none, then return a 'new' email.
107- Pass use_new=False to turn off this final fallback to new emails.
108+ If there are none, then return the oldest 'new' email,
109+ but don't promote that email to preferred.
110 """
111 email = getattr(self, '_preferredemail', None)
112
113@@ -340,14 +340,17 @@
114 all_emails = sorted(
115 self.emailaddress_set.all(),
116 key=attrgetter('date_created'))
117+
118+ # Get the preferred email address.
119 emails = [
120 e for e in all_emails if e.status == EmailStatus.PREFERRED]
121 if emails:
122 email = emails[0]
123
124+ # If no preferred email address found,
125+ # then get the oldest validated address by date_created,
126+ # and promote it to preferred.
127 if not email:
128- # Try to determine a suitable address, and mark it
129- # as preferred.
130 emails = [
131 e for e in all_emails
132 if e.status == EmailStatus.VALIDATED]
133@@ -360,14 +363,15 @@
134 email, self
135 )
136
137- if not email and use_new:
138- # we have no validated email, so use the first NEW email
139- # but don't save it
140+ # If no validated email found,
141+ # then get the oldest new address. Don't promote it.
142+ if not email:
143 emails = [
144 e for e in all_emails if e.status == EmailStatus.NEW]
145 if emails:
146 email = emails[0]
147
148+ # Cache our result to short-circuit this method next time.
149 self._preferredemail = email
150 except Exception:
151 # no error in preferred email should raise, as this breaks
152
153=== modified file 'src/identityprovider/tests/test_models_account.py'
154--- src/identityprovider/tests/test_models_account.py 2019-12-19 16:30:42 +0000
155+++ src/identityprovider/tests/test_models_account.py 2020-01-08 22:49:02 +0000
156@@ -443,10 +443,6 @@
157 account = self.factory.make_account(email_validated=False)
158 self.assertIsNotNone(account.preferredemail)
159
160- def test_get_preferred_without_falling_back_to_new(self):
161- account = self.factory.make_account(email_validated=False)
162- self.assertIsNone(account._get_preferredemail(use_new=False))
163-
164 def test_set_preferred_with_unverified_email(self):
165 account = self.factory.make_account(email_validated=False)
166 with self.assertRaises(ValidationError):
167@@ -687,6 +683,50 @@
168 consumer__user__username=account.openid_identifier)
169 self.assertEqual(tokens.count(), 0)
170
171+ def test_get_preferredemail_returns_preferred(self):
172+ # Given an account with a preferred email
173+ account = self.factory.make_account()
174+ email = account.emailaddress_set.first()
175+ self.assertEqual(EmailStatus.PREFERRED, email.status) # sanity check
176+ # When getting .preferredemail
177+ # Then it returns the preferred email
178+ self.assertEqual(email, account.preferredemail)
179+
180+ def test_get_preferredemail_returns_and_promotes_oldest_validated(self):
181+ # Given an account with two *validated* emails
182+ account = self.factory.make_account(email_validated=False)
183+ email1 = self.factory.make_email_for_account(account, "one@one.com")
184+ email2 = self.factory.make_email_for_account(account, "two@two.com")
185+
186+ # When getting .preferredemail
187+ # Then it returns email1, the oldest by date_created
188+ self.assertEqual(email1, account.preferredemail)
189+ # and email1 has been promoted to preferred
190+ email1.refresh_from_db()
191+ self.assertEqual(EmailStatus.PREFERRED, email1.status)
192+ # but email2 has not
193+ email2.refresh_from_db()
194+ self.assertEqual(EmailStatus.VALIDATED, email2.status)
195+
196+ def test_get_preferredemail_returns_oldest_new(self):
197+ # Given an account with two *new* emails
198+ account = self.factory.make_account(email_validated=False)
199+ email1 = account.emailaddress_set.first()
200+ self.assertEqual(EmailStatus.NEW, email1.status) # sanity check
201+ email2 = self.factory.make_email_for_account(
202+ account, email="two@two.com", status=EmailStatus.NEW)
203+ self.assertEqual(EmailStatus.NEW, email2.status) # sanity check
204+
205+ # When getting .preferredemail
206+ # Then it returns email1, the oldest by date_created
207+ self.assertEqual(email1, account.preferredemail)
208+ # and email1 has *not* been promoted to preferred
209+ email1.refresh_from_db()
210+ self.assertEqual(EmailStatus.NEW, email1.status)
211+ # and email2 has not either.
212+ email2.refresh_from_db()
213+ self.assertEqual(EmailStatus.NEW, email2.status)
214+
215 def test_reset_password_default_email(self):
216 account = self.factory.make_account()
217
218
219=== modified file 'src/webui/tests/test_views_ui.py'
220--- src/webui/tests/test_views_ui.py 2019-12-19 20:48:07 +0000
221+++ src/webui/tests/test_views_ui.py 2020-01-08 22:49:02 +0000
222@@ -755,8 +755,7 @@
223
224 self.client.post(reverse('forgot_password'), {'email': self.email})
225
226- # we do not send an email if there is no validated email
227- self.assertEqual(self.mock_send_email.call_count, 0)
228+ self.assertEqual(self.mock_send_email.call_count, 1)
229
230 def test_claim_consumed_token(self):
231 self.client.post(reverse('forgot_password'), {'email': self.email})
232@@ -1021,14 +1020,12 @@
233
234 self.client.post(reverse('forgot_password'), {'email': self.email})
235
236- # we do not sent an email, there being no validated email address
237- self.assertEqual(self.mock_send_email.call_count, 0)
238+ self.assertEqual(self.mock_send_email.call_count, 1)
239
240 def test_reset_password_when_account_no_verified_emails(self):
241 self.account.emailaddress_set.update(status=EmailStatus.NEW)
242 self.client.post(reverse('forgot_password'), {'email': self.email})
243- # we do not send an email, there being no validated email address
244- self.assertEqual(self.mock_send_email.call_count, 0)
245+ self.assertEqual(self.mock_send_email.call_count, 1)
246
247 def test_reset_password_unverified_email_but_account_verified_email(self):
248 verified = self.account.verified_emails()