Merge ~roadmr/canonical-identity-provider:2fa-always-generate-paper-backup into canonical-identity-provider:master

Proposed by Daniel Manrique
Status: Merged
Approved by: Daniel Manrique
Approved revision: 8c0ea6cb0e9e83819cdcc13d6ecd5c7eac16ed37
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~roadmr/canonical-identity-provider:2fa-always-generate-paper-backup
Merge into: canonical-identity-provider:master
Diff against target: 218 lines (+129/-10)
4 files modified
src/webui/templates/device/new-paper-device-confirmation.html (+6/-0)
src/webui/templates/device/print-codes.html (+2/-0)
src/webui/tests/test_views_devices.py (+87/-7)
src/webui/views/devices.py (+34/-3)
Reviewer Review Type Date Requested Status
Hasan Ammar (community) Approve
Review via email: mp+383650@code.launchpad.net

Commit message

Automatically generate and show a paper backup device when a non-paper device is added and no backup device existed.

This is controllable with a feature flag.

Description of the change

As discussed with design team, this has an "ugly view" that should be tweaked before going to production. Feature flag can be enabled on staging for testing, or the feature can be tested locally as well.

QA tips:

- Enable flag in staging
- Add a device on an account with no paper backup devices (e.g. delete your backup first or create a new account).
- After verifying your new device you will be inconditionally shown a set of just-generated backup codes with a prompt to print or store them.

Possibly pending things:

- We're adding two devices so two flash messages are shown, this could be consolidated into one if the current behavior is ugly.
- Ugly view can be invoked separately on any existing paper device (aids with easier QA) like so:

https://login.ubuntu.com/device-print/some-numeric-id?source=auto

To post a comment you must log in.
Revision history for this message
Hasan Ammar (hasanammar) wrote :

Code looks good. Tested locally and works as expected.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/src/webui/templates/device/new-paper-device-confirmation.html b/src/webui/templates/device/new-paper-device-confirmation.html
2new file mode 100644
3index 0000000..1fed783
4--- /dev/null
5+++ b/src/webui/templates/device/new-paper-device-confirmation.html
6@@ -0,0 +1,6 @@
7+{% extends "device/print-codes.html" %}
8+{% load i18n %}
9+{%block header_intro %}
10+<h2>Automatically generated printable backup codes</h2>
11+<p><b>{% trans "In addition to the device you just added, we have generated the following list of codes as backup in case your primary device becomes lost or unusable." %}</b></p>
12+{% endblock %}
13diff --git a/src/webui/templates/device/print-codes.html b/src/webui/templates/device/print-codes.html
14index 0073c4f..e3d55e7 100644
15--- a/src/webui/templates/device/print-codes.html
16+++ b/src/webui/templates/device/print-codes.html
17@@ -16,7 +16,9 @@ Affero General Public License version 3 (see the file LICENSE).
18 {% block text_title %}{% trans "My account" %}{% endblock %}
19
20 {% block content %}
21+ {% block header_intro %}
22 <h2>{% trans "Printable backup codes" %}</h2>
23+ {% endblock %}
24 <p>{% trans "Print this list of backup codes and keep them safe." %}</p>
25 <p>{% trans "The codes must be used in the order shown below." %}</p>
26 <p>{% trans "Each code can be used only once, please cross it out once you've used it." %}</p>
27diff --git a/src/webui/tests/test_views_devices.py b/src/webui/tests/test_views_devices.py
28index dc764d6..4c1586b 100644
29--- a/src/webui/tests/test_views_devices.py
30+++ b/src/webui/tests/test_views_devices.py
31@@ -349,7 +349,8 @@ class DeviceAdditionViewTestCase(DeviceViewsTestCaseBase):
32
33 def assert_successful_post(
34 self, name='Some device', counter=1, device_type='generic',
35- oath_mode='hotp', hex_key=KEY, otp='755224'):
36+ oath_mode='hotp', hex_key=KEY, otp='755224',
37+ check_redirect=True):
38
39 data = {'type': device_type, 'name': name}
40 expected_type = device_type
41@@ -366,12 +367,14 @@ class DeviceAdditionViewTestCase(DeviceViewsTestCaseBase):
42 self.assertEqual(devices.count(), 1)
43 device = devices[0]
44
45- if device_type == 'paper':
46- redirect_to = reverse(
47- 'device-print', kwargs=dict(device_id=device.id))
48- else:
49- redirect_to = reverse('device-list')
50- self.assertRedirects(response, redirect_to, status_code=303)
51+ if check_redirect:
52+ if device_type == 'paper':
53+ redirect_to = reverse(
54+ 'device-print', kwargs=dict(device_id=device.id))
55+ else:
56+ redirect_to = reverse('device-list')
57+
58+ self.assertRedirects(response, redirect_to, status_code=303)
59
60 self.assertLess(before, self.client.session[twofactor.TWOFACTOR_LOGIN])
61 self.assertLess(self.client.session[twofactor.TWOFACTOR_LOGIN], after)
62@@ -482,6 +485,74 @@ class DeviceAdditionViewTestCase(DeviceViewsTestCaseBase):
63 self.assert_successful_post(
64 name='Printable Backup Codes', counter=0, device_type='paper')
65
66+ # When the auto backup deviceflag is set...
67+ @switches(TWOFACTOR_AUTO_BACKUP_DEVICE=True)
68+ def test_successful_post_generic_no_paper_auto_add(self):
69+ devices = self.account.devices.all()
70+ self.assertEqual(devices.count(), 0)
71+ # Adding the account's first device...
72+ response = self.assert_successful_post(
73+ device_type='generic', check_redirect=False)
74+ devices = self.account.devices.all()
75+ self.assertEqual(devices.filter(device_type="generic:hotp").count(), 1)
76+ # Adds a paper backup device magically
77+ self.assertEqual(devices.filter(device_type="paper").count(), 1)
78+ backup_device = devices.filter(device_type="paper").first()
79+ # And the hex key is different
80+ keys = devices.values_list('key')
81+ self.assertNotEqual(keys[0], keys[1])
82+ redirect_to = (
83+ reverse(
84+ 'device-print',
85+ kwargs=dict(device_id=backup_device.id)) +
86+ "?source=auto"
87+ )
88+ self.assertRedirects(response, redirect_to, status_code=303)
89+
90+ # When the auto backup deviceflag is unset...
91+ @switches(TWOFACTOR_AUTO_BACKUP_DEVICE=False)
92+ def test_successful_post_generic_no_paper_no_auto_add(self):
93+ devices = self.account.devices.all()
94+ self.assertEqual(devices.count(), 0)
95+ # Adding the account's first device...
96+ self.assert_successful_post(device_type='generic')
97+ devices = self.account.devices.all()
98+ self.assertEqual(devices.filter(device_type="generic:hotp").count(), 1)
99+ # Does not add a paper backup device
100+ self.assertEqual(devices.filter(device_type="paper").count(), 0)
101+
102+ # When the auto backup deviceflag is set...
103+ @switches(TWOFACTOR_AUTO_BACKUP_DEVICE=True)
104+ def test_successful_post_generic_paper_no_add(self):
105+ # And a paper backup device already exists...
106+ self.factory.make_device(
107+ account=self.account, device_type='paper')
108+ devices = self.account.devices.all()
109+ self.assertEqual(devices.count(), 1)
110+ # Simulating having authenticated with this existing device...
111+ self.do_2fa_login(devices.first())
112+ # and Adding the account's first real device...
113+ self.assert_successful_post(device_type='generic')
114+ devices = self.account.devices.all()
115+ self.assertEqual(devices.filter(device_type="generic:hotp").count(), 1)
116+ # Does not add additional paper devices.
117+ self.assertEqual(devices.filter(device_type="paper").count(), 1)
118+
119+ # When the auto backup deviceflag is set...
120+ @switches(TWOFACTOR_AUTO_BACKUP_DEVICE=True)
121+ def test_successful_post_paper_main_no_add(self):
122+ devices = self.account.devices.all()
123+ self.assertEqual(devices.count(), 0)
124+ # Adding the account's first device as a paper device...
125+ # (note verification uses counter=0 vs. the default of 1 because
126+ # we don't test the first code for paper devices.
127+ self.assert_successful_post(
128+ device_type='paper', name='Printable Backup Codes', counter=0)
129+ devices = self.account.devices.all()
130+ self.assertEqual(devices.count(), 1)
131+ # Does not add additional paper devices.
132+ self.assertEqual(devices.filter(device_type="paper").count(), 1)
133+
134 def test_unrecognised_type(self):
135 data = {'type': 'foobar', 'hex_key': KEY, 'name': 'Some device',
136 'otp': 'some otp key'}
137@@ -696,6 +767,15 @@ class DevicePrintViewTestCase(DeviceViewsTestCaseBase):
138 for i in range(settings.TWOFACTOR_PAPER_CODES_ALLOW_GENERATION):
139 self.assert_generation_warning_and_button(i)
140
141+ def test_autogenerated_template_parameters(self):
142+ # If ?source=auto is passed as a query parameter...
143+ response = self.client.get(self.print_url + "?source=auto")
144+ # Use a different template to render the response
145+ self.assertTemplateUsed(
146+ response, 'device/new-paper-device-confirmation.html')
147+ # and context should contain NO current section
148+ self.assertIsNone(response.context['current_section'])
149+
150
151 class DeviceAuthenticationFailuretestCase(DeviceViewsTestCaseBase):
152
153diff --git a/src/webui/views/devices.py b/src/webui/views/devices.py
154index 9e78d4b..3f7716b 100644
155--- a/src/webui/views/devices.py
156+++ b/src/webui/views/devices.py
157@@ -176,6 +176,27 @@ def _device_addition_standard(request, device_type):
158 if accepted:
159 _create_device(request, device_name, hex_key, new_device_value,
160 device_type, oath_type)
161+ # If the flag is active and the account has no paper devices,
162+ # force add one.
163+ if gargoyle.is_active('TWOFACTOR_AUTO_BACKUP_DEVICE', request):
164+ paper_devices = request.user.devices.filter(
165+ device_type='paper')
166+ if paper_devices.count() < 1:
167+ backup_hex_key = generate_key(20)
168+ device_name = get_unique_device_name_for_user(
169+ device_types['paper'],
170+ request.user)
171+ backup_device = _create_device(
172+ request, device_name, backup_hex_key, 0, 'paper')
173+ # If a device was auto-added, send to a nice page.
174+ # Very similar to device-print view with some tweaks
175+ # for auto-generated device.
176+ return HttpResponseSeeOther(reverse(
177+ 'device-print',
178+ args=(backup_device.id,),
179+ ) + "?source=auto"
180+ )
181+
182 return HttpResponseSeeOther(reverse(DEVICE_LIST))
183
184 # Otherwise, set the error flag and fall through...
185@@ -226,12 +247,17 @@ def device_print(request, device_id, token=None):
186 AuthenticationDevice, id=device_id, account=request.user,
187 device_type='paper')
188
189+ source = request.GET.get('source', '')
190+
191 details = _codes_for_position(device)
192 remaining_codes = settings.TWOFACTOR_PAPER_CODES - details.position
193+ # don't show generation or renewal link for source=auto
194 generation_enabled = (
195- remaining_codes <= settings.TWOFACTOR_PAPER_CODES_ALLOW_GENERATION)
196+ remaining_codes <= settings.TWOFACTOR_PAPER_CODES_ALLOW_GENERATION
197+ and source != "auto")
198 needs_renewal = (
199- remaining_codes < settings.TWOFACTOR_PAPER_CODES_WARN_RENEWAL)
200+ remaining_codes < settings.TWOFACTOR_PAPER_CODES_WARN_RENEWAL
201+ and source != "auto")
202
203 if generation_enabled:
204 messages.warning(request, DEVICE_GENERATION_WARNING)
205@@ -246,7 +272,12 @@ def device_print(request, device_id, token=None):
206 )
207 if token is not None:
208 context['token'] = token
209- return TemplateResponse(request, 'device/print-codes.html', context)
210+ if source == "auto":
211+ context['current_section'] = None
212+ template = "device/new-paper-device-confirmation.html",
213+ else:
214+ template = "device/print-codes.html"
215+ return TemplateResponse(request, template, context)
216
217
218 def _codes_for_position(device, next_page=False):

Subscribers

People subscribed via source and target branches