Merge lp:~jamestait/canonical-identity-provider/lp1648775-force-exhausted-paper-renewal 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-force-exhausted-paper-renewal
Merge into: lp:canonical-identity-provider/release
Diff against target: 341 lines (+109/-22)
9 files modified
src/identityprovider/tests/utils.py (+1/-1)
src/webui/templates/common/printed_codes_nearly_exhausted_warning.html (+2/-2)
src/webui/templates/device/print-codes.html (+6/-2)
src/webui/tests/test_views_account.py (+3/-3)
src/webui/tests/test_views_devices.py (+25/-3)
src/webui/tests/test_views_ui.py (+37/-1)
src/webui/urls.py (+11/-5)
src/webui/views/devices.py (+15/-5)
src/webui/views/ui.py (+9/-0)
To merge this branch: bzr merge lp:~jamestait/canonical-identity-provider/lp1648775-force-exhausted-paper-renewal
Reviewer Review Type Date Requested Status
Ricardo Kirkner (community) Approve
Review via email: mp+322778@code.launchpad.net

Commit message

Redirect the user to the device list page and remind them to generate and print new paper codes if their current and only list is near exhaustion.

Description of the change

Redirect the user to the device list page and remind them to generate and
print new paper codes if their current and only list is near exhaustion.

The TwoFactorLoginTestCase has been updated to include the login token in the
URLs - I believe this is valid because redirection to the twofactor view will
now always include that token. The token is then passed between views to
enable the "Continue" link from the device-print view to redirect the user to
their original location in the RP.

Note that if the user has two sets of printed codes and only one is close to exhaustion, they will not be forced to renew the codes. In a follow-up branch, they will be prompted to renew via email.

To post a comment you must log in.
Revision history for this message
Ricardo Kirkner (ricardokirkner) :
Revision history for this message
James Tait (jamestait) :
Revision history for this message
Ricardo Kirkner (ricardokirkner) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'src/identityprovider/tests/utils.py'
--- src/identityprovider/tests/utils.py 2016-12-22 17:43:18 +0000
+++ src/identityprovider/tests/utils.py 2017-04-24 17:17:28 +0000
@@ -66,7 +66,7 @@
66 Please print a new list for the following devices."""66 Please print a new list for the following devices."""
6767
6868
69def assert_exausted_warning(testcase, devices, response):69def assert_exhausted_warning(testcase, devices, response):
70 """Helper to assert warning is correctly displayed"""70 """Helper to assert warning is correctly displayed"""
71 dom = PyQuery(response.content)71 dom = PyQuery(response.content)
72 warning = dom.find('#exhausted_warning')72 warning = dom.find('#exhausted_warning')
7373
=== modified file 'src/webui/templates/common/printed_codes_nearly_exhausted_warning.html'
--- src/webui/templates/common/printed_codes_nearly_exhausted_warning.html 2013-04-17 13:23:35 +0000
+++ src/webui/templates/common/printed_codes_nearly_exhausted_warning.html 2017-04-24 17:17:28 +0000
@@ -1,11 +1,11 @@
1{% load i18n %}1{% load i18n url_with_token %}
22
3{% if paper_devices_needing_renewal %}3{% if paper_devices_needing_renewal %}
4<div id="exhausted_warning" class="message warning">4<div id="exhausted_warning" class="message warning">
5 <p>{% blocktrans %}Your printed list of backup codes is nearly used up.5 <p>{% blocktrans %}Your printed list of backup codes is nearly used up.
6 Please print a new list for the following devices.{% endblocktrans %}</p>6 Please print a new list for the following devices.{% endblocktrans %}</p>
7 {% for device in paper_devices_needing_renewal %}7 {% for device in paper_devices_needing_renewal %}
8 <p>{{ device.name }} <a href="{% url 'device-print' device.id %}" class="btn-sm"><span>{% trans 'Generate New Codes' %}</span></a></p>8 <p>{{ device.name }} <a href="{% url_with_token 'device-print' device_id=device.id %}" class="btn-sm"><span>{% trans 'Generate New Codes' %}</span></a></p>
9 {% endfor %}9 {% endfor %}
10</div>10</div>
11{% endif %}11{% endif %}
1212
=== modified file 'src/webui/templates/device/print-codes.html'
--- src/webui/templates/device/print-codes.html 2014-12-09 21:41:56 +0000
+++ src/webui/templates/device/print-codes.html 2017-04-24 17:17:28 +0000
@@ -1,5 +1,5 @@
1{% extends "base.html" %}1{% extends "base.html" %}
2{% load i18n %}2{% load i18n url_with_token %}
33
4{% comment %}4{% comment %}
5Copyright 2012 Canonical Ltd. This software is licensed under the GNU5Copyright 2012 Canonical Ltd. This software is licensed under the GNU
@@ -30,9 +30,13 @@
30 <button class="btn cta" id="printbtn" onclick="window.print()" data-qa-id="print_codes">30 <button class="btn cta" id="printbtn" onclick="window.print()" data-qa-id="print_codes">
31 <span>{% trans "Print Codes" %}</span>31 <span>{% trans "Print Codes" %}</span>
32 </button>32 </button>
33 {% if token and not needs_renewal %}
34 <a class="cta secondary" href="{% url_with_token 'server-decide' %}" data-qa-id="continue_to_rp">{% trans "Continue" %}</a>
35 {% else %}
33 <a class="cta secondary" href="{% url 'device-list' %}" data-qa-id="cancel_print_codes">{% trans "Go back" %}</a>36 <a class="cta secondary" href="{% url 'device-list' %}" data-qa-id="cancel_print_codes">{% trans "Go back" %}</a>
37 {% endif %}
34 {% if generation_enabled %}38 {% if generation_enabled %}
35 <a class="btn cta secondary print-new-codes" href="{% url 'device-generate' device_id %}" data-qa-id="generate_codes">39 <a class="btn cta secondary print-new-codes" href="{% url_with_token 'device-generate' device_id=device_id %}" data-qa-id="generate_codes">
36 <span>{% trans "Generate new codes" %}</span>40 <span>{% trans "Generate new codes" %}</span>
37 </a>41 </a>
38 {% endif %}42 {% endif %}
3943
=== modified file 'src/webui/tests/test_views_account.py'
--- src/webui/tests/test_views_account.py 2017-02-26 13:52:43 +0000
+++ src/webui/tests/test_views_account.py 2017-04-24 17:17:28 +0000
@@ -38,7 +38,7 @@
38 MISSING_BACKUP_DEVICE,38 MISSING_BACKUP_DEVICE,
39 AuthenticatedTestCase,39 AuthenticatedTestCase,
40 SSOBaseTestCase,40 SSOBaseTestCase,
41 assert_exausted_warning,41 assert_exhausted_warning,
42)42)
43from identityprovider.views.testing import MockLaunchpad43from identityprovider.views.testing import MockLaunchpad
44from webui.constants import EMAIL_EXISTS_ERROR44from webui.constants import EMAIL_EXISTS_ERROR
@@ -639,10 +639,10 @@
639639
640 if (self.twofactor_enabled and self.devices_count > 0 and640 if (self.twofactor_enabled and self.devices_count > 0 and
641 self.paper_device_exhausted):641 self.paper_device_exhausted):
642 assert_exausted_warning(self, devices, response)642 assert_exhausted_warning(self, devices, response)
643 else:643 else:
644 with self.assertRaises(AssertionError):644 with self.assertRaises(AssertionError):
645 assert_exausted_warning(self, devices, response)645 assert_exhausted_warning(self, devices, response)
646646
647 def test_device_preferences_with_twofactor_disabled(self):647 def test_device_preferences_with_twofactor_disabled(self):
648 name = 'webui.views.account.is_twofactor_enabled'648 name = 'webui.views.account.is_twofactor_enabled'
649649
=== modified file 'src/webui/tests/test_views_devices.py'
--- src/webui/tests/test_views_devices.py 2017-01-26 17:26:43 +0000
+++ src/webui/tests/test_views_devices.py 2017-04-24 17:17:28 +0000
@@ -25,7 +25,7 @@
25 MISSING_BACKUP_DEVICE,25 MISSING_BACKUP_DEVICE,
26 AuthenticatedTestCase,26 AuthenticatedTestCase,
27 SSOBaseTransactionTestCase,27 SSOBaseTransactionTestCase,
28 assert_exausted_warning,28 assert_exhausted_warning,
29 test_concurrently,29 test_concurrently,
30)30)
3131
@@ -152,7 +152,7 @@
152152
153 response = self.client.get(self.url)153 response = self.client.get(self.url)
154 with self.assertRaises(AssertionError):154 with self.assertRaises(AssertionError):
155 assert_exausted_warning(self, [device], response)155 assert_exhausted_warning(self, [device], response)
156156
157 def test_codes_exhausted_warning_exhausted(self):157 def test_codes_exhausted_warning_exhausted(self):
158 counter = (settings.TWOFACTOR_PAPER_CODES -158 counter = (settings.TWOFACTOR_PAPER_CODES -
@@ -161,7 +161,17 @@
161 account=self.account, device_type='paper', counter=counter)161 account=self.account, device_type='paper', counter=counter)
162162
163 response = self.client.get(self.url)163 response = self.client.get(self.url)
164 assert_exausted_warning(self, [device], response)164 assert_exhausted_warning(self, [device], response)
165
166 def test_codes_exhausted_warning_exhausted_with_login_token(self):
167 counter = (settings.TWOFACTOR_PAPER_CODES -
168 settings.TWOFACTOR_PAPER_CODES_WARN_RENEWAL + 1)
169 device = self.factory.make_device(
170 account=self.account, device_type='paper', counter=counter)
171
172 url = reverse('device-list', kwargs=dict(token='a' * 16))
173 response = self.client.get(url)
174 assert_exhausted_warning(self, [device], response)
165175
166 def test_device_list_no_devices(self):176 def test_device_list_no_devices(self):
167 assert self.account.devices.count() == 0177 assert self.account.devices.count() == 0
@@ -582,6 +592,18 @@
582 self.assertEqual(len(strike), 0)592 self.assertEqual(len(strike), 0)
583 self.assertEqual(elem.text.strip(), code)593 self.assertEqual(elem.text.strip(), code)
584594
595 def test_device_print_with_login_token(self):
596 counter = (settings.TWOFACTOR_PAPER_CODES -
597 settings.TWOFACTOR_PAPER_CODES_WARN_RENEWAL + 1)
598 device = self.factory.make_device(
599 account=self.account, device_type='paper', counter=counter)
600
601 url = reverse('device-print', kwargs=dict(device_id=device.id,
602 token='a' * 16))
603 response = self.client.get(url)
604
605 self.assertContains(response, 'Go back')
606
585 def test_twofactor_disabled(self):607 def test_twofactor_disabled(self):
586 self.assert_twofactor_disabled(self.print_url)608 self.assert_twofactor_disabled(self.print_url)
587 self.assert_twofactor_disabled(self.generate_url)609 self.assert_twofactor_disabled(self.generate_url)
588610
=== modified file 'src/webui/tests/test_views_ui.py'
--- src/webui/tests/test_views_ui.py 2017-04-13 21:09:42 +0000
+++ src/webui/tests/test_views_ui.py 2017-04-24 17:17:28 +0000
@@ -1594,6 +1594,9 @@
15941594
1595class TwoFactorLoginTestCase(BaseTwoFactorLoginTestCase):1595class TwoFactorLoginTestCase(BaseTwoFactorLoginTestCase):
15961596
1597 url = reverse('twofactor', kwargs=dict(token='a' * 16))
1598 logout_link = 'a[href="/aaaaaaaaaaaaaaaa/+logout"]'
1599
1597 def setUp(self):1600 def setUp(self):
1598 super(TwoFactorLoginTestCase, self).setUp()1601 super(TwoFactorLoginTestCase, self).setUp()
1599 self.mock_site = self.patch(1602 self.mock_site = self.patch(
@@ -1640,7 +1643,7 @@
16401643
1641 def test_when_user_requires_twofactor_redirected(self):1644 def test_when_user_requires_twofactor_redirected(self):
1642 self.mock_site.return_value = False1645 self.mock_site.return_value = False
1643 r = self.do_login()1646 r = self.do_login(token='a' * 16)
1644 self.assertRedirects(r, self.url)1647 self.assertRedirects(r, self.url)
16451648
1646 def test_when_user_requires_twofactor_redirected_with_token(self):1649 def test_when_user_requires_twofactor_redirected_with_token(self):
@@ -1705,6 +1708,39 @@
1705 self.assertRedirects(r, reverse('account-edit'))1708 self.assertRedirects(r, reverse('account-edit'))
1706 mock_auth.assert_called_once_with(self.account, '123456')1709 mock_auth.assert_called_once_with(self.account, '123456')
17071710
1711 def test_twofactor_step_redirected_to_device_list_near_exhaustion(self):
1712 counter = (settings.TWOFACTOR_PAPER_CODES -
1713 settings.TWOFACTOR_PAPER_CODES_WARN_RENEWAL + 1)
1714 self.factory.make_device(
1715 account=self.account, device_type='paper', counter=counter)
1716 mock_auth = self.patch(
1717 'webui.views.ui.twofactor.authenticate', return_value=True)
1718
1719 self.client.login(username=self.email, password=self.password)
1720 r = self.do_twofactor(oath_token='123456')
1721
1722 mock_auth.assert_called_once_with(self.account, '123456')
1723 url = reverse('device-list', kwargs=dict(token='a' * 16))
1724 self.assertRedirects(r, url)
1725
1726 def test_twofactor_step_redirected_to_next_near_exhaustion_of_one(self):
1727 """If we have paper devices not near exhaustion, don't force refresh"""
1728 counter = (settings.TWOFACTOR_PAPER_CODES -
1729 settings.TWOFACTOR_PAPER_CODES_WARN_RENEWAL + 1)
1730 self.factory.make_device(
1731 account=self.account, device_type='paper', counter=counter)
1732 self.factory.make_device(
1733 account=self.account, device_type='paper')
1734 mock_auth = self.patch(
1735 'webui.views.ui.twofactor.authenticate', return_value=True)
1736
1737 self.client.login(username=self.email, password=self.password)
1738 r = self.do_twofactor(next_url=reverse('account-edit'),
1739 oath_token='123456')
1740
1741 self.assertRedirects(r, reverse('account-edit'))
1742 mock_auth.assert_called_once_with(self.account, '123456')
1743
1708 def test_twofactor_step_illformed_token_displays_error(self):1744 def test_twofactor_step_illformed_token_displays_error(self):
1709 self.client.login(username=self.email, password=self.password)1745 self.client.login(username=self.email, password=self.password)
1710 response = self.do_twofactor(next_url=reverse('account-edit'),1746 response = self.do_twofactor(next_url=reverse('account-edit'),
17111747
=== modified file 'src/webui/urls.py'
--- src/webui/urls.py 2017-02-26 01:27:07 +0000
+++ src/webui/urls.py 2017-04-24 17:17:28 +0000
@@ -12,6 +12,7 @@
12 'authtoken': '(?P<authtoken>%s)' % AUTHTOKEN_PATTERN,12 'authtoken': '(?P<authtoken>%s)' % AUTHTOKEN_PATTERN,
13 'email_address': '(?P<email_address>.+)',13 'email_address': '(?P<email_address>.+)',
14 'fingerprint': '(?P<fingerprint>.+)',14 'fingerprint': '(?P<fingerprint>.+)',
15 'device_id': '(?P<device_id>.+)',
15}16}
1617
17urlpatterns = patterns(18urlpatterns = patterns(
@@ -135,16 +136,21 @@
135urlpatterns += patterns(136urlpatterns += patterns(
136 'webui.views.devices',137 'webui.views.devices',
137 url(r'^device-list$', 'device_list', name='device-list'),138 url(r'^device-list$', 'device_list', name='device-list'),
139 url(r'^%(token)s/device-list$' % repls, 'device_list', name='device-list'),
138 url(r'^device-addition$', 'device_addition', name='device-addition'),140 url(r'^device-addition$', 'device_addition', name='device-addition'),
139 url(r'^device-removal/(?P<device_id>.+)$', 'device_removal',141 url(r'^device-removal/%(device_id)s$' % repls, 'device_removal',
140 name='device-removal'),142 name='device-removal'),
141 url(r'^device-rename/(?P<device_id>.+)$', 'device_rename',143 url(r'^device-rename/%(device_id)s$' % repls, 'device_rename',
142 name='device-rename'),144 name='device-rename'),
143 url(r'^\+device-help$', 'device_help', name='device-help'),145 url(r'^\+device-help$', 'device_help', name='device-help'),
144 url(r'^device-print/(?P<device_id>.+)$', 'device_print',146 url(r'^device-print/%(device_id)s$' % repls, 'device_print',
145 name='device-print'),147 name='device-print'),
146 url(r'^device-generate/(?P<device_id>.+)$', 'device_generate',148 url(r'^%(token)s/device-print/%(device_id)s$' % repls, 'device_print',
149 name='device-print'),
150 url(r'^device-generate/%(device_id)s$' % repls, 'device_generate',
147 name='device-generate'),151 name='device-generate'),
152 url(r'^%(token)s/device-generate/%(device_id)s$' % repls,
153 'device_generate', name='device-generate'),
148)154)
149155
150urlpatterns += patterns(156urlpatterns += patterns(
151157
=== modified file 'src/webui/views/devices.py'
--- src/webui/views/devices.py 2017-01-26 17:26:43 +0000
+++ src/webui/views/devices.py 2017-04-24 17:17:28 +0000
@@ -95,13 +95,14 @@
95@sso_login_required95@sso_login_required
96@require_twofactor_enabled96@require_twofactor_enabled
97@allow_only('GET')97@allow_only('GET')
98def device_list(request):98def device_list(request, token=None):
99 paper_renewals = list(request.user.paper_devices_needing_renewal)99 paper_renewals = list(request.user.paper_devices_needing_renewal)
100 context = context_with_current_section(100 context = context_with_current_section(
101 request, device_addition_path=reverse(DEVICE_ADDITION),101 request, device_addition_path=reverse(DEVICE_ADDITION),
102 devices=request.user.devices.all(),102 devices=request.user.devices.all(),
103 need_backup_device_warning=request.user.need_backup_device_warning,103 need_backup_device_warning=request.user.need_backup_device_warning,
104 paper_devices_needing_renewal=paper_renewals104 paper_devices_needing_renewal=paper_renewals,
105 token=token
105 )106 )
106 return render(request, 'device/list.html', context)107 return render(request, 'device/list.html', context)
107108
@@ -220,7 +221,7 @@
220@sso_login_required(require_twofactor=True, require_twofactor_freshness=True)221@sso_login_required(require_twofactor=True, require_twofactor_freshness=True)
221@require_twofactor_enabled222@require_twofactor_enabled
222@allow_only('GET')223@allow_only('GET')
223def device_print(request, device_id):224def device_print(request, device_id, token=None):
224 device = get_object_or_404(225 device = get_object_or_404(
225 AuthenticationDevice, id=device_id, account=request.user,226 AuthenticationDevice, id=device_id, account=request.user,
226 device_type='paper')227 device_type='paper')
@@ -229,6 +230,8 @@
229 remaining_codes = settings.TWOFACTOR_PAPER_CODES - details.position230 remaining_codes = settings.TWOFACTOR_PAPER_CODES - details.position
230 generation_enabled = (231 generation_enabled = (
231 remaining_codes <= settings.TWOFACTOR_PAPER_CODES_ALLOW_GENERATION)232 remaining_codes <= settings.TWOFACTOR_PAPER_CODES_ALLOW_GENERATION)
233 needs_renewal = (
234 remaining_codes < settings.TWOFACTOR_PAPER_CODES_WARN_RENEWAL)
232235
233 if generation_enabled:236 if generation_enabled:
234 messages.warning(request, DEVICE_GENERATION_WARNING)237 messages.warning(request, DEVICE_GENERATION_WARNING)
@@ -239,7 +242,10 @@
239 counter=details.position,242 counter=details.position,
240 device_id=device.id,243 device_id=device.id,
241 generation_enabled=generation_enabled,244 generation_enabled=generation_enabled,
245 needs_renewal=needs_renewal,
242 )246 )
247 if token is not None:
248 context['token'] = token
243 return TemplateResponse(request, 'device/print-codes.html', context)249 return TemplateResponse(request, 'device/print-codes.html', context)
244250
245251
@@ -258,7 +264,7 @@
258@sso_login_required(require_twofactor=True, require_twofactor_freshness=True)264@sso_login_required(require_twofactor=True, require_twofactor_freshness=True)
259@require_twofactor_enabled265@require_twofactor_enabled
260@allow_only('GET', 'POST')266@allow_only('GET', 'POST')
261def device_generate(request, device_id):267def device_generate(request, device_id, token=None):
262 device = get_object_or_404(268 device = get_object_or_404(
263 AuthenticationDevice, id=device_id, account=request.user,269 AuthenticationDevice, id=device_id, account=request.user,
264 device_type='paper')270 device_type='paper')
@@ -271,12 +277,16 @@
271 request,277 request,
272 codes=details.codes,278 codes=details.codes,
273 device_id=device.id,279 device_id=device.id,
280 token=token,
274 )281 )
275 return TemplateResponse(request, 'device/generate-codes.html', context)282 return TemplateResponse(request, 'device/generate-codes.html', context)
276283
277 device.counter = details.start284 device.counter = details.start
278 device.save()285 device.save()
279 return HttpResponseRedirect(reverse('device-print', args=(device.id,)))286 kwargs = {'device_id': device.id}
287 if token:
288 kwargs['token'] = token
289 return HttpResponseRedirect(reverse('device-print', kwargs=kwargs))
280290
281291
282@sso_login_required(require_twofactor=True, require_twofactor_freshness=True)292@sso_login_required(require_twofactor=True, require_twofactor_freshness=True)
283293
=== modified file 'src/webui/views/ui.py'
--- src/webui/views/ui.py 2017-04-18 20:14:37 +0000
+++ src/webui/views/ui.py 2017-04-24 17:17:28 +0000
@@ -347,6 +347,15 @@
347 limitlogin().reset_count(request)347 limitlogin().reset_count(request)
348348
349 next_url = request.POST.get('next')349 next_url = request.POST.get('next')
350 paper_devices = request.user.devices.filter(device_type='paper')
351 # We only force renewal if all paper devices are at or near exhaustion
352 needs_renewal = (paper_devices and len(paper_devices) ==
353 len(list(request.user.paper_devices_needing_renewal)))
354 if token and needs_renewal:
355 url = reverse('device-list', kwargs=dict(token=token))
356 if next_url:
357 url += "?next=" + urlquote(next_url)
358 return HttpResponseRedirect(url)
350 if next_url and is_safe_redirect_url(next_url):359 if next_url and is_safe_redirect_url(next_url):
351 return HttpResponseRedirect(next_url)360 return HttpResponseRedirect(next_url)
352 elif token:361 elif token: