Merge lp:~maxiberta/canonical-identity-provider/secure-email-links into lp:canonical-identity-provider/release

Proposed by Maximiliano Bertacchini
Status: Merged
Approved by: Maximiliano Bertacchini
Approved revision: no longer in the source branch.
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: lp:~maxiberta/canonical-identity-provider/secure-email-links
Merge into: lp:canonical-identity-provider/release
Diff against target: 263 lines (+143/-3)
4 files modified
src/identityprovider/emailutils.py (+8/-0)
src/identityprovider/tests/test_emailutils.py (+68/-1)
src/identityprovider/tests/test_utils.py (+54/-1)
src/identityprovider/utils.py (+13/-1)
To merge this branch: bzr merge lp:~maxiberta/canonical-identity-provider/secure-email-links
Reviewer Review Type Date Requested Status
Celso Providelo (community) Approve
Daniel Manrique (community) Approve
Review via email: mp+353604@code.launchpad.net

Commit message

Use the scheme of settings.SSO_ROOT_URL in all links in emails.

Description of the change

IOW, uses http for dev/testing and https for staging/production.

To post a comment you must log in.
Revision history for this message
Daniel Manrique (roadmr) wrote :

Looks good to me; FWIW it's even more elegant than my initial thought of just clobbering the scheme with https in all cases (but I missed the fact that it would screw things up for development/testing).

review: Approve
Revision history for this message
Celso Providelo (cprov) wrote :

Thanks Maxi,

Even if the hack itself is not pretty, code and tests are more structured now.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/identityprovider/emailutils.py'
2--- src/identityprovider/emailutils.py 2018-08-17 18:38:29 +0000
3+++ src/identityprovider/emailutils.py 2018-08-22 22:34:52 +0000
4@@ -13,6 +13,7 @@
5
6 from identityprovider.models import AuthToken, EmailAddress
7 from identityprovider.models.const import AuthTokenType, EmailStatus
8+from identityprovider.utils import use_root_url_scheme
9
10
11 logger = logging.getLogger(__name__)
12@@ -117,6 +118,7 @@
13 token_path = token.get_absolute_url()
14 if oid_token:
15 token_path = oid_token + token_path
16+ root_url = use_root_url_scheme(root_url)
17 token_url = urljoin(root_url, token_path)
18 context = {
19 'requester': name,
20@@ -135,6 +137,7 @@
21 requester=account, requester_email=requester_email,
22 email=email, token_type=AuthTokenType.INVALIDATEEMAIL,
23 redirection_url=redirection_url)
24+ root_url = use_root_url_scheme(root_url)
25 token_url = urljoin(
26 root_url, invalidate_email_token.get_absolute_url())
27 context['invalidate_email_url'] = token_url
28@@ -144,6 +147,7 @@
29
30 def send_impersonation_email(root_url, email):
31 """Send an email to user warning of attempted registration"""
32+ root_url = use_root_url_scheme(root_url)
33 url = urljoin(root_url, reverse('forgot_password'))
34 context = {
35 'forgotten_password_url': url, 'to_email': email,
36@@ -244,6 +248,7 @@
37 return
38 email = email.email
39 device_urls = []
40+ root_url = use_root_url_scheme(root_url)
41 for device in devices:
42 device_urls.append(
43 urljoin(root_url,
44@@ -289,6 +294,7 @@
45 to_email=email,
46 )
47 if account.unverified_emails().count() > 0:
48+ root_url = use_root_url_scheme(root_url)
49 url = urljoin(root_url, reverse('account-emails'))
50 context['verify_emails_link'] = url
51 send_fancy_email(
52@@ -320,6 +326,7 @@
53
54
55 def send_invitation_after_password_reset(root_url, email):
56+ root_url = use_root_url_scheme(root_url)
57 url = urljoin(root_url, reverse('new_account'))
58 send_fancy_email(
59 _("Password reset request"), 'email/invitation.txt',
60@@ -345,6 +352,7 @@
61 context, token, invalidate_email_token = _context_for_email_request(
62 root_url, account, email, AuthTokenType.VALIDATEEMAIL,
63 redirection_url=None)
64+ root_url = use_root_url_scheme(root_url)
65 context.update(dict(
66 emails_url=urljoin(root_url, reverse('account-emails')),
67 created=account.date_created, action=action,
68
69=== modified file 'src/identityprovider/tests/test_emailutils.py'
70--- src/identityprovider/tests/test_emailutils.py 2018-08-17 18:38:29 +0000
71+++ src/identityprovider/tests/test_emailutils.py 2018-08-22 22:34:52 +0000
72@@ -1,6 +1,6 @@
73 # -*- coding: utf-8 -*-
74
75-# Copyright 2010-2017 Canonical Ltd. This software is licensed under the
76+# Copyright 2010-2018 Canonical Ltd. This software is licensed under the
77 # GNU Affero General Public License version 3 (see the file LICENSE).
78
79 import unittest
80@@ -969,3 +969,70 @@
81 self.factory.make_email_for_account(account, email=email,
82 status=EmailStatus.NEW)
83 self.assertEqual(True, _should_add_invalidation_link(email))
84+
85+
86+@override_settings(SSO_ROOT_URL='https://testserver')
87+class SecureLinksTestCase(SSOBaseTestCase):
88+
89+ email = 'testing@canonical.com'
90+ http_root_url = 'http://testserver'
91+
92+ def setUp(self):
93+ super(SecureLinksTestCase, self).setUp()
94+ self.account = self.factory.make_account(email='test@canonical.com')
95+
96+ def test_send_impersonation_email(self):
97+ send_impersonation_email(self.http_root_url, self.email)
98+ email = mail.outbox[0]
99+ self.assertNotIn('http://', email.body)
100+ self.assertIn('https://', email.body)
101+
102+ def test_send_new_user_email(self):
103+ send_new_user_email(self.http_root_url, self.account, self.email)
104+ email = mail.outbox[0]
105+ self.assertNotIn('http://', email.body)
106+ self.assertIn('https://', email.body)
107+
108+ def test_send_password_reset_email(self):
109+ send_password_reset_email(self.http_root_url, self.account, self.email)
110+ email = mail.outbox[0]
111+ self.assertNotIn('http://', email.body)
112+ self.assertIn('https://', email.body)
113+
114+ def test_send_printed_codes_renewal_email(self):
115+ counter = (settings.TWOFACTOR_PAPER_CODES -
116+ settings.TWOFACTOR_PAPER_CODES_WARN_RENEWAL + 1)
117+ device = self.factory.make_device(
118+ account=self.account, device_type='paper', counter=counter)
119+ send_printed_codes_renewal_email(
120+ self.http_root_url, self.account, [device])
121+ email = mail.outbox[0]
122+ self.assertNotIn('http://', email.body)
123+ self.assertIn('https://', email.body)
124+
125+ def test_send_validation_email_request(self):
126+ send_validation_email_request(
127+ self.http_root_url, self.account, self.email)
128+ email = mail.outbox[0]
129+ self.assertNotIn('http://', email.body)
130+ self.assertIn('https://', email.body)
131+
132+ def test_send_invalidation_email_notice(self):
133+ send_invalidation_email_notice(
134+ self.http_root_url, self.account, self.email)
135+ email = mail.outbox[0]
136+ self.assertNotIn('http://', email.body)
137+ self.assertIn('https://', email.body)
138+
139+ def test_send_invitation_after_password_reset(self):
140+ send_invitation_after_password_reset(self.http_root_url, self.email)
141+ email = mail.outbox[0]
142+ self.assertNotIn('http://', email.body)
143+ self.assertIn('https://', email.body)
144+
145+ def test_send_action_required_warning(self):
146+ send_action_required_warning(
147+ self.http_root_url, self.account, 8, 'suspend')
148+ email = mail.outbox[0]
149+ self.assertNotIn('http://', email.body)
150+ self.assertIn('https://', email.body)
151
152=== modified file 'src/identityprovider/tests/test_utils.py'
153--- src/identityprovider/tests/test_utils.py 2018-02-09 20:56:16 +0000
154+++ src/identityprovider/tests/test_utils.py 2018-08-22 22:34:52 +0000
155@@ -1,4 +1,4 @@
156-# Copyright 2010 Canonical Ltd. This software is licensed under the
157+# Copyright 2010-2018 Canonical Ltd. This software is licensed under the
158 # GNU Affero General Public License version 3 (see the file LICENSE).
159 import os
160 import random
161@@ -21,6 +21,7 @@
162 get_provider_url,
163 polite_form_errors,
164 redirection_url_for_token,
165+ use_root_url_scheme,
166 validate_launchpad_password,
167 )
168
169@@ -39,6 +40,58 @@
170 self.assertEqual(url, "https://launchpad.net/~test/TEST")
171
172
173+class UseRootURLSchemeTestCase(SSOBaseTestCase):
174+
175+ @override_settings(SSO_ROOT_URL='https://testserver')
176+ def test_with_https_root_url(self):
177+ self.assertEqual(
178+ use_root_url_scheme('http://0.0.0.0:8000'),
179+ 'https://0.0.0.0:8000')
180+ self.assertEqual(
181+ use_root_url_scheme('https://0.0.0.0:8000'),
182+ 'https://0.0.0.0:8000')
183+ self.assertEqual(
184+ use_root_url_scheme('http://testserver/a/b/c?x=1;y=2'),
185+ 'https://testserver/a/b/c?x=1;y=2')
186+ self.assertEqual(
187+ use_root_url_scheme('https://testserver/a/b/c?x=1;y=2'),
188+ 'https://testserver/a/b/c?x=1;y=2')
189+
190+ @override_settings(SSO_ROOT_URL='http://testserver')
191+ def test_with_http_root_url(self):
192+ self.assertEqual(
193+ use_root_url_scheme('http://0.0.0.0:8000'), 'http://0.0.0.0:8000')
194+ self.assertEqual(
195+ use_root_url_scheme('https://0.0.0.0:8000'), 'http://0.0.0.0:8000')
196+ self.assertEqual(
197+ use_root_url_scheme('http://testserver/a/b/c?x=1;y=2'),
198+ 'http://testserver/a/b/c?x=1;y=2')
199+ self.assertEqual(
200+ use_root_url_scheme('https://testserver/a/b/c?x=1;y=2'),
201+ 'http://testserver/a/b/c?x=1;y=2')
202+
203+ def test_no_scheme(self):
204+ for url in (
205+ '0.0.0.0:8000',
206+ 'ftp://testserver/a/b/c',
207+ 'x/y/z',
208+ ''):
209+ self.assertEqual(url, use_root_url_scheme(url), url)
210+
211+ def test_idempotency(self):
212+ for url in (
213+ 'http://0.0.0.0:8000',
214+ 'https://0.0.0.0:8000',
215+ 'http://testserver/a/b/c?x=1;y=2',
216+ 'https://testserver/a/b/c?x=1;y=2',
217+ 'x/y/z',
218+ ''):
219+ self.assertEqual(
220+ use_root_url_scheme(url),
221+ use_root_url_scheme(use_root_url_scheme(url)),
222+ url)
223+
224+
225 class PoliteFormErrorsTestCase(SSOBaseTestCase):
226
227 def test_polite_errors(self):
228
229=== modified file 'src/identityprovider/utils.py'
230--- src/identityprovider/utils.py 2018-05-28 16:55:25 +0000
231+++ src/identityprovider/utils.py 2018-08-22 22:34:52 +0000
232@@ -1,4 +1,4 @@
233-# Copyright 2010 Canonical Ltd. This software is licensed under the
234+# Copyright 2010-2018 Canonical Ltd. This software is licensed under the
235 # GNU Affero General Public License version 3 (see the file LICENSE).
236
237 from __future__ import absolute_import
238@@ -8,6 +8,7 @@
239 import hashlib
240 import logging
241 import string
242+import urlparse
243 from datetime import timedelta
244
245
246@@ -42,6 +43,17 @@
247 return None
248
249
250+def use_root_url_scheme(url):
251+ """Replace the given URL's scheme by the one of the root URL, if needed."""
252+ sso_root_scheme = urlparse.urlparse(settings.SSO_ROOT_URL).scheme
253+ parsed_url = urlparse.urlparse(url)
254+ if parsed_url.scheme not in ('http', 'https'):
255+ return url
256+ if parsed_url.scheme == sso_root_scheme:
257+ return url
258+ return urlparse.urlunparse(parsed_url._replace(scheme=sso_root_scheme))
259+
260+
261 # TODO: Remove after migrating all users' passwords to the Django cypher
262 def encrypt_launchpad_password(plaintext, salt=None):
263 plaintext = str(plaintext)