Merge lp:~maxiberta/canonical-identity-provider/2fa-readonly 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/2fa-readonly
Merge into: lp:canonical-identity-provider/release
Prerequisite: lp:~maxiberta/canonical-identity-provider/readonly-improvements
Diff against target: 292 lines (+129/-17)
8 files modified
src/identityprovider/forms.py (+6/-2)
src/identityprovider/models/twofactor.py (+7/-7)
src/identityprovider/tests/test_forms.py (+8/-2)
src/identityprovider/tests/test_models_twofactor.py (+70/-1)
src/webui/templates/registration/twofactor.html (+4/-0)
src/webui/tests/test_views_registration.py (+23/-0)
src/webui/tests/test_views_ui.py (+9/-4)
src/webui/views/registration.py (+2/-1)
To merge this branch: bzr merge lp:~maxiberta/canonical-identity-provider/2fa-readonly
Reviewer Review Type Date Requested Status
Daniel Manrique (community) Approve
Review via email: mp+374530@code.launchpad.net

Commit message

Read-only mode 2FA: allow TOTP devices only, disable sync.

Description of the change

2FA is currently bypassed in readonly mode, lowering the security requirements of accounts with 2FA devices. This branch should improve the situation by allowing TOTP devices in readonly mode, while counter based devices (HOTP and text codes) are not allowed to authenticate. A warning message is shown next to the 2FA input when in readonly mode, and non-TOTPs will just be rejected as invalid.

Counter based device sync is disabled in readonly mode, as well.

Screenshots:

Readwrite (unchanged): https://private-fileshare.canonical.com/~maxiberta/Screenshot_20191022_164412.png

Read-only: https://private-fileshare.canonical.com/~maxiberta/Screenshot_20191022_163904.png (note the missing "sync your device" links)

Note this depends on a prerequisite branch with general readonly improvements.

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

LGTM!

review: Approve
Revision history for this message
Maximiliano Bertacchini (maxiberta) :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'src/identityprovider/forms.py'
--- src/identityprovider/forms.py 2019-06-18 20:13:31 +0000
+++ src/identityprovider/forms.py 2019-10-23 14:40:43 +0000
@@ -1,4 +1,4 @@
1# Copyright 2010 Canonical Ltd. This software is licensed under the1# Copyright 2010-2019 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4import logging4import logging
@@ -114,7 +114,11 @@
114 def __init__(self, *args, **kwargs):114 def __init__(self, *args, **kwargs):
115 user = kwargs.pop('user', None)115 user = kwargs.pop('user', None)
116 super(TwoFactorForm, self).__init__(*args, **kwargs)116 super(TwoFactorForm, self).__init__(*args, **kwargs)
117 self.sync_2fa_available = user and user.has_twofactor_hotp_devices()117 self.sync_2fa_available = (
118 user
119 and user.has_twofactor_hotp_devices()
120 and not settings.READ_ONLY_MODE
121 )
118122
119123
120class TwoFactorSyncForm(forms.Form):124class TwoFactorSyncForm(forms.Form):
121125
=== modified file 'src/identityprovider/models/twofactor.py'
--- src/identityprovider/models/twofactor.py 2019-02-22 15:35:45 +0000
+++ src/identityprovider/models/twofactor.py 2019-10-23 14:40:43 +0000
@@ -36,6 +36,8 @@
36def authenticate(account, otp):36def authenticate(account, otp):
37 """Authenticates oath_token against the devices for an account."""37 """Authenticates oath_token against the devices for an account."""
38 for device in account.devices.order_by('-counter'):38 for device in account.devices.order_by('-counter'):
39 if settings.READ_ONLY_MODE and not device.is_totp():
40 continue
39 if device.authenticate(otp):41 if device.authenticate(otp):
40 return True42 return True
41 # avoid circular import (identityprovider.login import43 # avoid circular import (identityprovider.login import
@@ -46,9 +48,10 @@
4648
47def sync(account, otps):49def sync(account, otps):
48 """Tries to sync one of the devices with the provided otps."""50 """Tries to sync one of the devices with the provided otps."""
49 for device in account.devices.order_by('-counter'):51 if not settings.READ_ONLY_MODE:
50 if not device.is_totp() and device.sync(otps):52 for device in account.devices.order_by('-counter'):
51 return True53 if not device.is_totp() and device.sync(otps):
54 return True
52 # avoid circular import (identityprovider.login import55 # avoid circular import (identityprovider.login import
53 # identityprovider.models which import from .twofactor import *56 # identityprovider.models which import from .twofactor import *
54 from identityprovider.login import AuthenticationError57 from identityprovider.login import AuthenticationError
@@ -56,10 +59,7 @@
5659
5760
58def is_twofactor_enabled(request):61def is_twofactor_enabled(request):
59 if settings.READ_ONLY_MODE:62 return gargoyle.is_active('TWOFACTOR', request)
60 return False
61 else:
62 return gargoyle.is_active('TWOFACTOR', request)
6363
6464
65def login(request):65def login(request):
6666
=== modified file 'src/identityprovider/tests/test_forms.py'
--- src/identityprovider/tests/test_forms.py 2019-10-14 20:57:04 +0000
+++ src/identityprovider/tests/test_forms.py 2019-10-23 14:40:43 +0000
@@ -191,11 +191,11 @@
191 form = self.get_form()191 form = self.get_form()
192 self.assertFalse(form.sync_2fa_available)192 self.assertFalse(form.sync_2fa_available)
193193
194 def test_2fa_sync_unavailable_with_no_htop(self):194 def test_2fa_sync_unavailable_with_no_hotp(self):
195 form = self.get_form(user=self.account)195 form = self.get_form(user=self.account)
196 self.assertFalse(form.sync_2fa_available)196 self.assertFalse(form.sync_2fa_available)
197197
198 def test_2fa_sync_available_with_htop(self):198 def test_2fa_sync_available_with_hotp(self):
199 self.factory.make_device(account=self.account, oath_mode='hotp')199 self.factory.make_device(account=self.account, oath_mode='hotp')
200 form = self.get_form(user=self.account)200 form = self.get_form(user=self.account)
201 self.assertTrue(form.sync_2fa_available)201 self.assertTrue(form.sync_2fa_available)
@@ -205,6 +205,12 @@
205 form = self.get_form(user=self.account)205 form = self.get_form(user=self.account)
206 self.assertFalse(form.sync_2fa_available)206 self.assertFalse(form.sync_2fa_available)
207207
208 def test_2fa_sync_unavailable_with_hotp_when_readonly(self):
209 self.factory.make_device(account=self.account, oath_mode='hotp')
210 with self.settings(READ_ONLY_MODE=True):
211 form = self.get_form(user=self.account)
212 self.assertFalse(form.sync_2fa_available)
213
208214
209class TwoFactorLoginFormTestCase(LoginFormTestCase, TwoFactorFormTestCase):215class TwoFactorLoginFormTestCase(LoginFormTestCase, TwoFactorFormTestCase):
210216
211217
=== modified file 'src/identityprovider/tests/test_models_twofactor.py'
--- src/identityprovider/tests/test_models_twofactor.py 2018-06-21 15:08:26 +0000
+++ src/identityprovider/tests/test_models_twofactor.py 2019-10-23 14:40:43 +0000
@@ -9,6 +9,7 @@
9from identityprovider.models.twofactor import (9from identityprovider.models.twofactor import (
10 TWOFACTOR_LOGIN,10 TWOFACTOR_LOGIN,
11 AuthenticationDevice,11 AuthenticationDevice,
12 authenticate,
12 is_authenticated,13 is_authenticated,
13 is_twofactor_enabled,14 is_twofactor_enabled,
14 is_upgraded,15 is_upgraded,
@@ -151,7 +152,7 @@
151152
152 def test_is_twofactor_enabled_in_readonly_mode(self):153 def test_is_twofactor_enabled_in_readonly_mode(self):
153 with self.settings(READ_ONLY_MODE=True):154 with self.settings(READ_ONLY_MODE=True):
154 self.assertFalse(is_twofactor_enabled(self.request))155 self.assertTrue(is_twofactor_enabled(self.request))
155156
156 @patch('identityprovider.models.twofactor.gargoyle.is_active')157 @patch('identityprovider.models.twofactor.gargoyle.is_active')
157 def test_is_twofactor_enabled_in_readwrite_mode(self, mock_is_active):158 def test_is_twofactor_enabled_in_readwrite_mode(self, mock_is_active):
@@ -324,3 +325,71 @@
324 devices[0].sync.assert_not_called(otps)325 devices[0].sync.assert_not_called(otps)
325 devices[1].sync.assert_not_called(otps)326 devices[1].sync.assert_not_called(otps)
326 devices[2].sync.assert_not_called(otps)327 devices[2].sync.assert_not_called(otps)
328
329 def test_sync_disabled_when_readonly(self):
330 account = Mock()
331 devices = [Mock(), Mock()]
332 otps = ['otp1', 'otp2']
333 account.devices.order_by.return_value = devices
334 devices[0].is_totp.return_value = False
335 devices[0].sync.return_value = False
336 devices[1].is_totp.return_value = False
337 devices[1].sync.return_value = True
338
339 with self.settings(READ_ONLY_MODE=True):
340 self.assertRaises(AuthenticationError, sync, account, otps)
341
342 def test_authenticate_stops_on_first_device_that_succeeds(self):
343 account = Mock()
344 devices = [Mock(), Mock(), Mock()]
345 account.devices.order_by.return_value = devices
346 devices[0].is_totp.return_value = False
347 devices[0].authenticate.return_value = False
348 devices[1].is_totp.return_value = True
349 devices[1].authenticate.return_value = True
350
351 self.assertTrue(authenticate(account, 'otp'))
352
353 devices[0].authenticate.assert_called_once_with('otp')
354 devices[1].authenticate.assert_called_once_with('otp')
355 self.assertFalse(devices[2].called)
356
357 def test_authenticate_raises_error_if_none_of_the_devices_succeeds(self):
358 account = Mock()
359 devices = [Mock(), Mock(), Mock()]
360 account.devices.order_by.return_value = devices
361 devices[0].authenticate.return_value = False
362 devices[0].is_totp.return_value = False
363 devices[1].authenticate.return_value = False
364 devices[1].is_totp.return_value = False
365 devices[2].authenticate.return_value = False
366 devices[2].is_totp.return_value = False
367
368 self.assertRaises(AuthenticationError, authenticate, account, 'otp')
369
370 devices[0].authenticate.assert_called_once_with('otp')
371 devices[1].authenticate.assert_called_once_with('otp')
372 devices[2].authenticate.assert_called_once_with('otp')
373
374 def test_authenticate_totp_enabled_when_readonly(self):
375 account = Mock()
376 devices = [Mock(), Mock()]
377 account.devices.order_by.return_value = devices
378 devices[0].is_totp.return_value = True
379 devices[0].authenticate.return_value = True
380
381 with self.settings(READ_ONLY_MODE=True):
382 self.assertTrue(authenticate(account, 'otp'))
383
384 def test_authenticate_non_totp_disabled_when_readonly(self):
385 account = Mock()
386 devices = [Mock(), Mock()]
387 otp = 'otp'
388 account.devices.order_by.return_value = devices
389 devices[0].is_totp.return_value = False
390 devices[0].authenticate.return_value = True
391 devices[1].is_totp.return_value = True
392 devices[1].authenticate.return_value = False
393
394 with self.settings(READ_ONLY_MODE=True):
395 self.assertRaises(AuthenticationError, authenticate, account, otp)
327396
=== modified file 'src/webui/templates/registration/twofactor.html'
--- src/webui/templates/registration/twofactor.html 2016-12-09 10:16:50 +0000
+++ src/webui/templates/registration/twofactor.html 2019-10-23 14:40:43 +0000
@@ -52,6 +52,10 @@
52 <p id="oathtoken" class="input-row{% if form.oath_token.errors or form.non_field_errors %} haserrors{% endif %}">52 <p id="oathtoken" class="input-row{% if form.oath_token.errors or form.non_field_errors %} haserrors{% endif %}">
5353
54 <label for="id_oath_token">{% trans "Type your verification code:" %}</label>54 <label for="id_oath_token">{% trans "Type your verification code:" %}</label>
55 {% if readonly %}
56 <span class="error">{% trans "Counter-based devices are disabled while the system is in read-only mode." %}</span>
57 <span class="error">{% trans "Please use a time-based device, or try again later." %}</span>
58 {% endif %}
5559
56 {{ form.oath_token }}60 {{ form.oath_token }}
5761
5862
=== modified file 'src/webui/tests/test_views_registration.py'
--- src/webui/tests/test_views_registration.py 2019-10-22 14:03:48 +0000
+++ src/webui/tests/test_views_registration.py 2019-10-23 14:40:43 +0000
@@ -797,3 +797,26 @@
797797
798 # success798 # success
799 self.assertRedirects(response, reverse('account-index'))799 self.assertRedirects(response, reverse('account-index'))
800
801 def test_get_disabled_when_readonly(self):
802 self.factory.make_account(email=self.email)
803 self.do_login()
804
805 with readonly_enabled():
806 response = self.client.get(self.url)
807
808 self.assertEqual(response.status_code, 403)
809 self.assertTemplateUsed(response, 'readonly.html')
810
811 def test_post_disabled_when_readonly(self):
812 account = self.factory.make_account(email=self.email)
813 device = self.factory.make_device(account)
814 data = self.make_data(device, first_counter=10)
815 self.do_login()
816
817 with readonly_enabled():
818 response = self.client.post(self.url, data=data)
819
820 self.assertEqual(response.status_code, 403)
821 self.assertTemplateUsed(response, 'readonly.html')
822 self.assertEqual(device.counter, 0)
800823
=== modified file 'src/webui/tests/test_views_ui.py'
--- src/webui/tests/test_views_ui.py 2019-10-22 14:03:48 +0000
+++ src/webui/tests/test_views_ui.py 2019-10-23 14:40:43 +0000
@@ -1529,13 +1529,18 @@
1529 'webui.views.ui.twofactor.user_requires_twofactor_auth',1529 'webui.views.ui.twofactor.user_requires_twofactor_auth',
1530 return_value=True)1530 return_value=True)
15311531
1532 def test_twofactor_login_disabled_when_readonly(self):1532 def test_twofactor_login_shows_warning_when_readonly(self):
1533 self.mock_site.return_value = False1533 self.mock_site.return_value = False
1534 self.factory.make_device(account=self.account)
15341535
1535 with switches(TWOFACTOR=True), readonly_enabled():1536 with switches(TWOFACTOR=True), readonly_enabled():
1536 self.do_login()1537 self.do_login()
1537 response = self.client.get(self.url)1538 response = self.client.get(self.url)
1538 self.assertEqual(response.status_code, 404)1539
1540 self.assertContains(
1541 response,
1542 'Counter-based devices are disabled '
1543 'while the system is in read-only mode.')
15391544
1540 def test_when_site_does_not_require_twofactor_no_oath_field(self):1545 def test_when_site_does_not_require_twofactor_no_oath_field(self):
1541 self.mock_site.return_value = False1546 self.mock_site.return_value = False
@@ -1718,7 +1723,7 @@
17181723
1719 self.assertContains(response, reverse('sync_2fa'))1724 self.assertContains(response, reverse('sync_2fa'))
17201725
1721 def test_shows_sync_link_when_otp_is_invalid_explicit_and_have_htop(self):1726 def test_shows_sync_link_when_otp_is_invalid_explicit_and_have_hotp(self):
1722 self.factory.make_device(account=self.account, oath_mode='hotp')1727 self.factory.make_device(account=self.account, oath_mode='hotp')
1723 self.client.login(username=self.email, password=self.password)1728 self.client.login(username=self.email, password=self.password)
17241729
@@ -1726,7 +1731,7 @@
17261731
1727 self.assertContains(response, reverse('sync_2fa'))1732 self.assertContains(response, reverse('sync_2fa'))
17281733
1729 def test_hide_sync_link_when_otp_is_invalid_explicit_and_no_htop(self):1734 def test_hide_sync_link_when_otp_is_invalid_explicit_and_no_hotp(self):
1730 self.client.login(username=self.email, password=self.password)1735 self.client.login(username=self.email, password=self.password)
17311736
1732 response = self.do_twofactor(oath_token='123456')1737 response = self.do_twofactor(oath_token='123456')
17331738
=== modified file 'src/webui/views/registration.py'
--- src/webui/views/registration.py 2019-01-09 18:37:18 +0000
+++ src/webui/views/registration.py 2019-10-23 14:40:43 +0000
@@ -1,4 +1,4 @@
1# Copyright 2010, 2012 Canonical Ltd. This software is licensed under1# Copyright 2010-2019 Canonical Ltd. This software is licensed under
2# the GNU Affero General Public License version 3 (see the file2# the GNU Affero General Public License version 3 (see the file
3# LICENSE).3# LICENSE).
44
@@ -310,6 +310,7 @@
310 return self._render_form(request, form, request.POST)310 return self._render_form(request, form, request.POST)
311311
312 @method_decorator(dont_cache)312 @method_decorator(dont_cache)
313 @method_decorator(check_readonly)
313 @method_decorator(limitlogin())314 @method_decorator(limitlogin())
314 # for this page, we need to be logged in but NOT twofactored315 # for this page, we need to be logged in but NOT twofactored
315 @method_decorator(login_required)316 @method_decorator(login_required)