Merge ~roadmr/canonical-identity-provider:2fa-update-last-nag-paper-only into canonical-identity-provider:master

Proposed by Daniel Manrique
Status: Merged
Approved by: Daniel Manrique
Approved revision: 10fc368079e608c655182efc725719d831aa5529
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~roadmr/canonical-identity-provider:2fa-update-last-nag-paper-only
Merge into: canonical-identity-provider:master
Diff against target: 96 lines (+53/-3)
3 files modified
src/identityprovider/models/twofactor.py (+5/-0)
src/webui/tests/test_views_ui.py (+48/-1)
src/webui/views/ui.py (+0/-2)
Reviewer Review Type Date Requested Status
Maximiliano Bertacchini Approve
Review via email: mp+386053@code.launchpad.net

Commit message

Update last_nag only for codes from paper device.

This is described in the spec as the desired behavior, but was incorrectly implemented, updating last_nag for *any* valid 2fa authentication.

With this we'll keep nagging the user on every 2fa request until they do effectively enter a code from a backup device, which resets the counter to 6 weeks from now for the next nag.

Description of the change

How to QA:

- set the account's last_nag to either None or a date 8 weeks in the past.
- Ensure account has a real device and no paper devices.
- Log in, there should be no nag on the 2fa screen, enter a real code.
- (Maybe ensure the account last_nag was not updated at this point)
- Add a paper device
- Log out
- Log in
- a nag should appear in the 2fa screen
- Enter a code from the real device
- Log out
- Log in
- The nag should again appear (since user has never used their backup device as far as we know)
- Enter code from backup device
- log out
- log in
- nag is now gone
- account last_nag should now be 6 weeks in the future for the next nag.

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

LGTM, thanks!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/src/identityprovider/models/twofactor.py b/src/identityprovider/models/twofactor.py
2index a89bf07..c93f23e 100644
3--- a/src/identityprovider/models/twofactor.py
4+++ b/src/identityprovider/models/twofactor.py
5@@ -39,6 +39,11 @@ def authenticate(account, otp):
6 if settings.READ_ONLY_MODE and not device.is_totp():
7 continue
8 if device.authenticate(otp):
9+ # Update account's last_nag only if used device
10+ # was paper-based
11+ if device.device_type == 'paper':
12+ account.backup_device_last_nag = now()
13+ account.save()
14 return True
15 # avoid circular import (identityprovider.login import
16 # identityprovider.models which import from .twofactor import *
17diff --git a/src/webui/tests/test_views_ui.py b/src/webui/tests/test_views_ui.py
18index 93d4e43..a9a5742 100644
19--- a/src/webui/tests/test_views_ui.py
20+++ b/src/webui/tests/test_views_ui.py
21@@ -2181,6 +2181,51 @@ class TwoFactorNagTestCase(BaseTwoFactorLoginTestCase):
22 device.refresh_from_db()
23 self.assertEqual(device.last_use, frozen)
24
25+ def test_check_view_success_non_paper_does_not_update_last_nag(self):
26+ # Test name is incomplete! Successful 2fa on a non-paper device
27+ # does NOT update last_nag but does update the device's last use date.
28+ #
29+ # If the user has been nagged before and needs to be nagged again
30+ # (default)
31+ last_nagged = self.account.backup_device_last_nag
32+ # And they have one paper and one real device
33+ test_key = 'A' * 36
34+ real_device = self.factory.make_device(
35+ account=self.account,
36+ device_type='google', name='Googlerino', key=test_key)
37+ self.make_paper_backup_device()
38+ # when they log in
39+ self.client.login(username=self.email, password=self.password)
40+
41+ # mock accept_hotp with a sneaky method that only accepts the
42+ # device we're interested in testing for last_use updates.
43+ def mock_accept_hotp(*args, **kwargs):
44+ if args[0] == test_key:
45+ return (True, 1)
46+ else:
47+ return (False, 0)
48+
49+ self.patch('identityprovider.models.twofactor.accept_hotp',
50+ side_effect=mock_accept_hotp)
51+ frozen = now()
52+ self.patch('identityprovider.models.twofactor.now',
53+ return_value=frozen)
54+ # and post to the 2fa form with the code from the device under test
55+ # (see mockery)
56+ self.do_twofactor(next_url=reverse('account-edit'),
57+ oath_token='123456')
58+ # Counter was increased on real device, meaning auth with it was
59+ # successful
60+ real_device.refresh_from_db()
61+ self.assertEqual(1, real_device.counter)
62+ # last_nag is not updated because the device used to log in was not
63+ # paper-based.
64+ self.account.refresh_from_db()
65+ self.assertEqual(self.account.backup_device_last_nag, last_nagged)
66+ # (last_use on success is thoroughly tested in
67+ # test_check_view_success_updates_actual_used_device_from_many
68+ # so we don't retest that here
69+
70 def test_check_view_fail_doesnt_update_last_nag_or_device_last_use(self):
71 # If the user has been nagged before and needs to be nagged again
72 # (default - does not affect this test)
73@@ -2318,7 +2363,9 @@ class TwoFactorNagTestCase(BaseTwoFactorLoginTestCase):
74 # And they have a few candidate devices (one paper, one real, and the
75 # one we're going to use to test against)
76 other_paper_device = self.make_paper_backup_device()
77- real_device = self.factory.make_device()
78+ real_device = self.factory.make_device(
79+ account=self.account,
80+ device_type='google', name='Googlerino')
81 test_key = 'A' * 36
82 device = self.make_paper_backup_device(key=test_key)
83 # when they log in
84diff --git a/src/webui/views/ui.py b/src/webui/views/ui.py
85index 7ad463d..50ae47f 100644
86--- a/src/webui/views/ui.py
87+++ b/src/webui/views/ui.py
88@@ -358,8 +358,6 @@ class TwoFactorView(LoginBaseView):
89 try:
90 twofactor.authenticate(account, form.cleaned_data['oath_token'])
91 twofactor.login(request)
92- account.backup_device_last_nag = now()
93- account.save()
94 stats.increment('flows.login', key='success', rpconfig=rpconfig)
95 stats.increment('flows.2fa', key='success', rpconfig=rpconfig)
96 except AuthenticationError as e:

Subscribers

People subscribed via source and target branches