Skip to content

Commit 65fbaac

Browse files
authored
When validating TOTP in TOTPDeviceForm, allow a drift in clocks of +/-1 instead of just -1 (#458)
When validating the TOTP in the TOTPDeviceForm, loop over offsets of [-tolerance, tolerance] inclusive instead of [-tolerance, tolerance). Also added a unit test that checks offets of -1, 0 and 1 are valid and -2 and +2 are not.
1 parent e97dc8b commit 65fbaac

3 files changed

Lines changed: 69 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
### Changed
88
- Suppressed default_app_config warning on Django 3.2+
99
- qrcode dependency limit upped to 7.99 and django-phonenumber-field to 7
10+
- When validating a TOTP after scanning the QR code, allow a time drift of +/-1 instead of just -1
1011

1112
## 1.13.1
1213

tests/test_totpdeviceform.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
from binascii import unhexlify
2+
from unittest.mock import patch
3+
4+
import django_otp.oath
5+
from django.test import TestCase
6+
7+
from two_factor.forms import TOTPDeviceForm
8+
9+
# Use this as the Unix time for all TOTPs. It is chosen arbitrarily
10+
# as 3 Jan 2022
11+
TEST_TIME = 1641194517
12+
13+
14+
@patch('django_otp.oath.time', return_value=TEST_TIME)
15+
class TOTPDeviceFormTest(TestCase):
16+
"""
17+
This class tests how the TOTPDeviceForm validator handles drift between its clock
18+
and the TOTP device.
19+
20+
If there is a drift in the range [-tolerance, +tolerance], (tolerance is hardcoded
21+
to 1), then the TOTP should be accepted. Outside this range, it should be rejected.
22+
"""
23+
24+
key = '12345678901234567890'
25+
26+
def setUp(self):
27+
super().setUp()
28+
self.bin_key = unhexlify(TOTPDeviceFormTest.key.encode())
29+
self.empty_form = TOTPDeviceForm(TOTPDeviceFormTest.key, None)
30+
31+
def totp_with_offset(self, offset):
32+
return django_otp.oath.totp(self.bin_key, self.empty_form.step,
33+
self.empty_form.t0, self.empty_form.digits, self.empty_form.drift + offset)
34+
35+
def test_offset_0(self, mock_test):
36+
device_totp = self.totp_with_offset(0)
37+
form = TOTPDeviceForm(TOTPDeviceFormTest.key, None,
38+
data={'token': device_totp})
39+
self.assertTrue(form.is_valid())
40+
41+
def test_offset_minus1(self, mock_test):
42+
device_totp = self.totp_with_offset(-1)
43+
form = TOTPDeviceForm(TOTPDeviceFormTest.key, None,
44+
data={'token': device_totp})
45+
self.assertTrue(form.is_valid())
46+
47+
def test_offset_plus1(self, mock_test):
48+
device_totp = self.totp_with_offset(1)
49+
form = TOTPDeviceForm(TOTPDeviceFormTest.key, None,
50+
data={'token': device_totp})
51+
self.assertTrue(form.is_valid())
52+
53+
def test_offset_minus2(self, mock_test):
54+
device_totp = self.totp_with_offset(-2)
55+
form = TOTPDeviceForm(TOTPDeviceFormTest.key, None,
56+
data={'token': device_totp})
57+
self.assertFalse(form.is_valid())
58+
self.assertEqual(form.errors['token'][0],
59+
TOTPDeviceForm.error_messages['invalid_token'])
60+
61+
def test_offset_plus2(self, mock_test):
62+
device_totp = self.totp_with_offset(2)
63+
form = TOTPDeviceForm(TOTPDeviceFormTest.key, None,
64+
data={'token': device_totp})
65+
self.assertFalse(form.is_valid())
66+
self.assertEqual(form.errors['token'][0],
67+
TOTPDeviceForm.error_messages['invalid_token'])

two_factor/forms.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ def clean_token(self):
125125
if 'valid_t0' in self.metadata:
126126
t0s.append(int(time()) - self.metadata['valid_t0'])
127127
for t0 in t0s:
128-
for offset in range(-self.tolerance, self.tolerance):
128+
for offset in range(-self.tolerance, self.tolerance + 1):
129129
if totp(key, self.step, t0, self.digits, self.drift + offset) == token:
130130
self.drift = offset
131131
self.metadata['valid_t0'] = int(time()) - t0

0 commit comments

Comments
 (0)