Merge lp:~jamestait/canonical-identity-provider/lp1648775-email-reminder-of-paper-exhaustion into lp:canonical-identity-provider/release

Proposed by James Tait
Status: Merged
Approved by: James Tait
Approved revision: no longer in the source branch.
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: lp:~jamestait/canonical-identity-provider/lp1648775-email-reminder-of-paper-exhaustion
Merge into: lp:canonical-identity-provider/release
Diff against target: 273 lines (+176/-3)
6 files modified
src/identityprovider/emailutils.py (+20/-0)
src/identityprovider/templates/email/printed-codes-nearly-exhausted-warning.txt (+19/-0)
src/identityprovider/tests/test_emailutils.py (+71/-0)
src/webui/tests/test_views_account.py (+2/-2)
src/webui/tests/test_views_ui.py (+56/-0)
src/webui/views/ui.py (+8/-1)
To merge this branch: bzr merge lp:~jamestait/canonical-identity-provider/lp1648775-email-reminder-of-paper-exhaustion
Reviewer Review Type Date Requested Status
Daniel Manrique (community) Approve
Review via email: mp+323685@code.launchpad.net

Commit message

Send an email reminder to renew paper codes that are close to exhaustion, as long as another list exists that does not need renewal (otherwise renewal is forced).

Description of the change

In the case where a user has multiple lists of paper codes and at least one is
close to exhaustion and at least one is not, send an email reminder to renew
the lists that are close to exhaustion. In the case where all lists are close
to exhaustion (where all can also mean all of a list of length 1), renewal is
forced at login.

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

A couple of questions/nitpicks below.

review: Needs Information
Revision history for this message
James Tait (jamestait) :
Revision history for this message
Daniel Manrique (roadmr) wrote :

looks mostly good to me now, just let me know if you want to remove the "assert" and handle absence of preferredemail some other way (since you said you didn't want it to blow up).

Revision history for this message
James Tait (jamestait) :
Revision history for this message
James Tait (jamestait) wrote :
Download full text (5.6 KiB)

IRC conversation log:

2017-05-08T18:47:23+0100 <@roadmr> jayteeuk: so about the e-mail reminder thing...
2017-05-08T18:48:36+0100 < jayteeuk> roadmr, yeah. What do you think about the AuthToken?
2017-05-08T18:48:40+0100 <@roadmr> jayteeuk: authtokens are tricky because the token by design is lost once the e-mail is sent, so testing them is a bit messy. Would just directing the user to his devices page (instead of an individual link per almost-expired device) obviate the need for an extra token?
2017-05-08T18:51:24+0100 <@roadmr> jayteeuk: example, https://login.ubuntu.com/device-list is *your* device list, so there's nothing secret to tokenize there
2017-05-08T18:51:59+0100 <@roadmr> jayteeuk: if we clearly flag the about-to-expire devices there (may already be done in the other branch?) then we shouldn't need any trickery to protect the link
2017-05-08T18:52:10+0100 < jayteeuk> roadmr, I'm thinking about the case where the user has logged in, taken a device over the reminder threshold, logged out and then seen the email.
2017-05-08T18:52:47+0100 < jayteeuk> roadmr, right - because device-list is just the list for whichever user happens to then login.
2017-05-08T18:53:21+0100 <@roadmr> jayteeuk: I see.. so how about the "safety factor" idea? give them warning when there are n codes left (n > 1)
2017-05-08T18:53:45+0100 < jayteeuk> But are they able to login if their codes are exhausted? The e-mail should only have been sent if they have another set of paper codes they can use, so they ought to have at least one other OTP device.
2017-05-08T18:54:19+0100 < jayteeuk> roadmr, the exhaustion limit is configurable.
2017-05-08T18:54:23+0100 <@roadmr> jayteeuk: ah awesome :)
2017-05-08T18:54:45+0100 <@roadmr> jayteeuk: well if we know by design they have extra codes to use, then just the link should suffice, I guess
2017-05-08T18:54:53+0100 < jayteeuk> So for tests it's 1, but I think it's 3 in production.
2017-05-08T18:58:11+0100 < jayteeuk> roadmr, it's a tricky little area, because we don't want to make it possible to hijack the account, but nor do we want to lock them out of the account in the case where they happen to have two printed lists associated with the account because they lost the first and just created a new one.
2017-05-08T18:59:05+0100 <@roadmr> hm, interesting scenario
2017-05-08T18:59:30+0100 < jayteeuk> Maybe I'm over-thinking it (which is not unusual).
2017-05-08T19:01:07+0100 <@roadmr> hehe :) that's how you catch corner cases
2017-05-08T19:15:49+0100 < jayteeuk> roadmr, so WDYT? Should we go with the AuthToken approach to ensure we keep the account secure without risking locking the user out?
2017-05-08T19:18:11+0100 <@roadmr> jayteeuk: when would you send an authtoken'd link? if I had one of my paper devices close to exhaustion?
2017-05-08T19:18:30+0100 <@roadmr> 1- I use a paper code and go under the threshold (potentially meaning I have no more codes to use)
2017-05-08T19:18:44+0100 <@roadmr> 2- I get a link with which I can go into the account and... what?
2017-05-08T19:19:53+0100 < jayteeuk> roadmr: If the account only has one paper device and that device drops below the threshold (i.e. the user just used it), we force ...

Read more...

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

+1, FWIW most users *should* have a preferred email, but I think it's good to just warn instead of blowing up if someone doesn't. If we care about users having a preferred email, we could enforce that some other way. But "that is another story and shall be told another time".

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 2017-02-26 13:08:57 +0000
3+++ src/identityprovider/emailutils.py 2017-05-09 18:04:11 +0000
4@@ -222,6 +222,26 @@
5 return token, invalidate_email_token
6
7
8+def send_printed_codes_renewal_email(root_url, account, devices):
9+ email = account.preferredemail
10+ if email is None:
11+ logger.warning(
12+ 'Unable to find a suitable email address to send OTP reminder '
13+ 'for account %s', account)
14+ return
15+ email = email.email
16+ device_urls = []
17+ for device in devices:
18+ device_urls.append(
19+ urljoin(root_url,
20+ reverse('device-print', kwargs=dict(device_id=device.id))))
21+ context = dict(display_name=account.displayname, device_urls=device_urls)
22+ send_fancy_email(
23+ _("Printed list of backup codes is nearly used up"),
24+ 'email/printed-codes-nearly-exhausted-warning.txt',
25+ context=context, email=email)
26+
27+
28 def send_validation_email_request(root_url, account, email,
29 redirection_url=None, oid_token=None):
30 """
31
32=== added file 'src/identityprovider/templates/email/printed-codes-nearly-exhausted-warning.txt'
33--- src/identityprovider/templates/email/printed-codes-nearly-exhausted-warning.txt 1970-01-01 00:00:00 +0000
34+++ src/identityprovider/templates/email/printed-codes-nearly-exhausted-warning.txt 2017-05-09 18:04:11 +0000
35@@ -0,0 +1,19 @@
36+{% load i18n %}{% load static_url %}{% blocktrans %}Hello {{ display_name }}!{% endblocktrans %}
37+
38+{% if device_urls|length == 1 %}
39+{% blocktrans %}Your printed list of backup codes is nearly used up. Please
40+print a new list by visiting the following page:{% endblocktrans %}
41+{% else %}
42+{% blocktrans %}Some of your printed lists of backup codes are nearly used up.
43+Please print new lists by visiting the following pages:{% endblocktrans %}
44+{% endif %}
45+
46+{% for device_url in device_urls %}
47+ - {{ device_url }}
48+{% endfor %}
49+
50+{% blocktrans %}If you have any questions, please contact us at:{% endblocktrans %}
51+
52+{{ "support_form"|static_url }}
53+
54+{% include "email/thank_you.txt" %}
55
56=== modified file 'src/identityprovider/tests/test_emailutils.py'
57--- src/identityprovider/tests/test_emailutils.py 2017-02-26 13:08:57 +0000
58+++ src/identityprovider/tests/test_emailutils.py 2017-05-09 18:04:11 +0000
59@@ -6,6 +6,7 @@
60 import unittest
61 from urlparse import urlparse, urlunparse
62
63+from django.conf import settings
64 from django.core import mail
65 from django.core.urlresolvers import reverse
66 from django.template.loader import render_to_string
67@@ -27,6 +28,7 @@
68 send_notification_to_invalidated_email_address,
69 send_password_reset_email,
70 send_preferred_changed_notification,
71+ send_printed_codes_renewal_email,
72 send_templated_email,
73 send_twofactor_failure_warning,
74 send_validation_email_request,
75@@ -648,6 +650,75 @@
76 self.test_send_action_applied_notice(action='delete',
77 result='deleted')
78
79+ def test_send_printed_codes_renewal_email_with_one_exhausted(self):
80+ counter = (settings.TWOFACTOR_PAPER_CODES -
81+ settings.TWOFACTOR_PAPER_CODES_WARN_RENEWAL + 1)
82+ device = self.factory.make_device(
83+ account=self.account, device_type='paper', counter=counter)
84+ send_printed_codes_renewal_email(
85+ self.sso_root_url, self.account, [device])
86+ url = self.request.build_absolute_uri(
87+ reverse('device-print', kwargs=dict(device_id=device.id)))
88+ context = dict(display_name=self.account.displayname,
89+ device_urls=[url])
90+ self.assert_email_properly_formatted(
91+ 'Printed list of backup codes is nearly used up',
92+ 'email/printed-codes-nearly-exhausted-warning.txt',
93+ context=context)
94+ email = mail.outbox[0]
95+ self.assertIn(
96+ 'Your printed list of backup codes is nearly used up', email.body)
97+ self.assertIn(
98+ 'print a new list by visiting the following page',
99+ email.body)
100+ self.assertIn(url, email.body)
101+
102+ def test_send_printed_codes_renewal_email_with_many_exhausted(self):
103+ counter = (settings.TWOFACTOR_PAPER_CODES -
104+ settings.TWOFACTOR_PAPER_CODES_WARN_RENEWAL + 1)
105+ device1 = self.factory.make_device(
106+ account=self.account, device_type='paper', counter=counter)
107+ device2 = self.factory.make_device(
108+ account=self.account, device_type='paper', counter=counter)
109+ send_printed_codes_renewal_email(
110+ self.sso_root_url, self.account, [device1, device2])
111+ url1 = self.request.build_absolute_uri(
112+ reverse('device-print', kwargs=dict(device_id=device1.id)))
113+ url2 = self.request.build_absolute_uri(
114+ reverse('device-print', kwargs=dict(device_id=device2.id)))
115+ context = dict(display_name=self.account.displayname,
116+ device_urls=[url1, url2])
117+ self.assert_email_properly_formatted(
118+ 'Printed list of backup codes is nearly used up',
119+ 'email/printed-codes-nearly-exhausted-warning.txt',
120+ context=context)
121+ email = mail.outbox[0]
122+ self.assertIn(
123+ 'Some of your printed lists of backup codes are nearly used up',
124+ email.body)
125+ self.assertIn(
126+ 'print new lists by visiting the following pages',
127+ email.body)
128+ for url in (url1, url2):
129+ self.assertIn(url, email.body)
130+
131+ def test_send_printed_codes_renewal_email_with_one_exhausted_no_addr(self):
132+ self.account.emails().delete()
133+ assert self.account.emails().count() == 0
134+ # Force re-evaluation of account.preferredemail
135+ self.account._preferredemail = None
136+ counter = (settings.TWOFACTOR_PAPER_CODES -
137+ settings.TWOFACTOR_PAPER_CODES_WARN_RENEWAL + 1)
138+ device = self.factory.make_device(
139+ account=self.account, device_type='paper', counter=counter)
140+ with patch('identityprovider.emailutils.logger') as mock_logger:
141+ send_printed_codes_renewal_email(
142+ self.sso_root_url, self.account, [device])
143+ self.assertEqual(len(mail.outbox), 0)
144+ mock_logger.warning.assert_called_once_with(
145+ 'Unable to find a suitable email address to send OTP reminder '
146+ 'for account %s', self.account)
147+
148
149 class SendEmailToPreferredAddressTestCase(SendEmailTestCase):
150
151
152=== modified file 'src/webui/tests/test_views_account.py'
153--- src/webui/tests/test_views_account.py 2017-04-19 15:36:34 +0000
154+++ src/webui/tests/test_views_account.py 2017-05-09 18:04:11 +0000
155@@ -587,7 +587,7 @@
156 user_wants_twofactor = False
157 user_wants_warn = True
158 devices_count = 0
159- paper_device_exhausted = 0
160+ paper_device_exhausted = False
161
162 def setUp(self):
163 super(AccountEditTestCase, self).setUp()
164@@ -635,7 +635,7 @@
165
166 def test_codes_nearly_exhausted_warning(self):
167 response = self.client.get(self.url)
168- devices = self.account.devices.all()
169+ devices = list(self.account.paper_devices_needing_renewal)
170
171 if (self.twofactor_enabled and self.devices_count > 0 and
172 self.paper_device_exhausted):
173
174=== modified file 'src/webui/tests/test_views_ui.py'
175--- src/webui/tests/test_views_ui.py 2017-04-24 17:17:02 +0000
176+++ src/webui/tests/test_views_ui.py 2017-05-09 18:04:11 +0000
177@@ -2110,6 +2110,62 @@
178 self.assertTrue(ui.twofactor.authenticate(account, otp))
179
180
181+class PrintedCodesRenewalEmailTestCase(BaseTwoFactorLoginTestCase):
182+ """We have two paper devices, one of which needs renewal - send email."""
183+
184+ def test_email_reminder_when_one_of_many_needs_renewal(self):
185+ counter = (settings.TWOFACTOR_PAPER_CODES -
186+ settings.TWOFACTOR_PAPER_CODES_WARN_RENEWAL + 1)
187+ nearly_exhausted = self.factory.make_device(
188+ account=self.account, counter=counter, device_type='paper')
189+ self.factory.make_device(account=self.account, device_type='paper')
190+ self.patch('webui.views.ui.twofactor.authenticate', return_value=True)
191+ self.do_login(next=reverse('account-edit'))
192+ self.do_twofactor(oath_token='123456')
193+ self.assertEqual(len(mail.outbox), 1)
194+ email = mail.outbox[0]
195+ self.assertEqual(email.to, [self.email])
196+ self.assertIn(
197+ 'Printed list of backup codes is nearly used up', email.subject)
198+ self.assertIn(
199+ 'Your printed list of backup codes is nearly used up.', email.body)
200+ url = reverse(
201+ 'device-print', kwargs=dict(device_id=nearly_exhausted.id))
202+ self.assertIn(url, email.body)
203+
204+ def test_email_reminder_when_many_of_many_need_renewal(self):
205+ counter = (settings.TWOFACTOR_PAPER_CODES -
206+ settings.TWOFACTOR_PAPER_CODES_WARN_RENEWAL + 1)
207+ nearly_exhausted1 = self.factory.make_device(
208+ account=self.account, counter=counter, device_type='paper')
209+ nearly_exhausted2 = self.factory.make_device(
210+ account=self.account, counter=counter, device_type='paper')
211+ self.factory.make_device(account=self.account, device_type='paper')
212+ self.patch('webui.views.ui.twofactor.authenticate', return_value=True)
213+ self.do_login(next=reverse('account-edit'))
214+ self.do_twofactor(oath_token='123456')
215+ self.assertEqual(len(mail.outbox), 1)
216+ email = mail.outbox[0]
217+ self.assertEqual(email.to, [self.email])
218+ self.assertIn(
219+ 'Printed list of backup codes is nearly used up', email.subject)
220+ self.assertIn(
221+ 'Some of your printed lists of backup codes are nearly used up',
222+ email.body)
223+ for device in (nearly_exhausted1, nearly_exhausted2):
224+ url = reverse(
225+ 'device-print', kwargs=dict(device_id=device.id))
226+ self.assertIn(url, email.body)
227+
228+ def test_no_email_reminder_unless_one_of_many_needs_renewal(self):
229+ self.factory.make_device(account=self.account, device_type='paper')
230+ self.factory.make_device(account=self.account, device_type='paper')
231+ self.patch('webui.views.ui.twofactor.authenticate', return_value=True)
232+ self.do_login(next=reverse('account-edit'))
233+ self.do_twofactor(oath_token='123456')
234+ self.assertEqual(len(mail.outbox), 0)
235+
236+
237 class ComboViewTestCase(SSOBaseTestCase):
238
239 def test_combine_files(self):
240
241=== modified file 'src/webui/views/ui.py'
242--- src/webui/views/ui.py 2017-04-24 17:17:02 +0000
243+++ src/webui/views/ui.py 2017-05-09 18:04:11 +0000
244@@ -34,6 +34,7 @@
245 from gargoyle import gargoyle
246
247 from identityprovider import emailutils
248+from identityprovider.emailutils import send_printed_codes_renewal_email
249 from identityprovider.forms import (
250 LoginForm,
251 NewAccountForm,
252@@ -348,14 +349,20 @@
253
254 next_url = request.POST.get('next')
255 paper_devices = request.user.devices.filter(device_type='paper')
256+ paper_devices_needing_renewal = list(
257+ request.user.paper_devices_needing_renewal)
258 # We only force renewal if all paper devices are at or near exhaustion
259 needs_renewal = (paper_devices and len(paper_devices) ==
260- len(list(request.user.paper_devices_needing_renewal)))
261+ len(paper_devices_needing_renewal))
262 if token and needs_renewal:
263 url = reverse('device-list', kwargs=dict(token=token))
264 if next_url:
265 url += "?next=" + urlquote(next_url)
266 return HttpResponseRedirect(url)
267+ elif paper_devices_needing_renewal:
268+ root_url = request.build_absolute_uri('/')
269+ send_printed_codes_renewal_email(
270+ root_url, request.user, paper_devices_needing_renewal)
271 if next_url and is_safe_redirect_url(next_url):
272 return HttpResponseRedirect(next_url)
273 elif token: