Skip to content

Commit 48a661a

Browse files
committed
fix(mfa): Prevent reuse of TOTP codes
1 parent ad89388 commit 48a661a

File tree

3 files changed

+68
-1
lines changed

3 files changed

+68
-1
lines changed

Diff for: ChangeLog.rst

+8
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ Note worthy changes
1010
originated, will be emailed.
1111

1212

13+
Security notice
14+
---------------
15+
16+
- MFA: It was possible to reuse a valid TOTP code within its time window. This
17+
has now been addressed. As a result, a user can now only login once per 30
18+
seconds (``MFA_TOTP_PERIOD``).
19+
20+
1321
Backwards incompatible changes
1422
------------------------------
1523

Diff for: allauth/mfa/tests/test_views.py

+43
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import django
44
from django.conf import settings
5+
from django.core.cache import cache
6+
from django.test import Client
57
from django.urls import reverse
68

79
import pytest
@@ -301,3 +303,44 @@ def test_cannot_deactivate_totp(auth_client, user_with_totp, user_password):
301303
assert resp.context["form"].errors == {
302304
"__all__": [get_adapter().error_messages["cannot_delete_authenticator"]],
303305
}
306+
307+
308+
def test_totp_code_reuse(
309+
user_with_totp, user_password, totp_validation_bypass, enable_cache
310+
):
311+
for code, time_lapse, expect_success in [
312+
# First use of code, SUCCESS
313+
("123", False, True),
314+
# Second use, no time elapsed: FAIL
315+
("123", False, False),
316+
# Different code, no time elapsed: SUCCESS
317+
("456", False, True),
318+
# Again, previous code, no time elapsed: FAIL
319+
("123", False, False),
320+
# Previous code, but time elapsed: SUCCESS
321+
("123", True, True),
322+
]:
323+
if time_lapse:
324+
cache.clear()
325+
client = Client()
326+
resp = client.post(
327+
reverse("account_login"),
328+
{"login": user_with_totp.username, "password": user_password},
329+
)
330+
assert resp.status_code == 302
331+
assert resp["location"] == reverse("mfa_authenticate")
332+
# Note that this bypass only bypasses the actual code check, not the
333+
# re-use check we're testing here.
334+
with totp_validation_bypass():
335+
resp = client.post(
336+
reverse("mfa_authenticate"),
337+
{"code": code},
338+
)
339+
if expect_success:
340+
assert resp.status_code == 302
341+
assert resp["location"] == settings.LOGIN_REDIRECT_URL
342+
else:
343+
assert resp.status_code == 200
344+
assert resp.context["form"].errors == {
345+
"code": [get_adapter().error_messages["incorrect_code"]]
346+
}

Diff for: allauth/mfa/totp.py

+17-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from io import BytesIO
88
from urllib.parse import quote
99

10+
from django.core.cache import cache
1011
from django.utils.http import urlencode
1112

1213
import qrcode
@@ -104,5 +105,20 @@ def deactivate(self):
104105
self.instance.delete()
105106

106107
def validate_code(self, code):
108+
if self._is_code_used(code):
109+
return False
110+
107111
secret = decrypt(self.instance.data["secret"])
108-
return validate_totp_code(secret, code)
112+
valid = validate_totp_code(secret, code)
113+
if valid:
114+
self._mark_code_used(code)
115+
return valid
116+
117+
def _get_used_cache_key(self, code):
118+
return f"allauth.mfa.totp.used?user={self.instance.user_id}&code={code}"
119+
120+
def _is_code_used(self, code):
121+
return cache.get(self._get_used_cache_key(code)) == "y"
122+
123+
def _mark_code_used(self, code):
124+
cache.set(self._get_used_cache_key(code), "y", timeout=app_settings.TOTP_PERIOD)

0 commit comments

Comments
 (0)