Skip to content

Commit d926fb3

Browse files
authored
Prevent replay attacks for TOTP logins (baserow#4298)
1 parent 06dd5c1 commit d926fb3

File tree

6 files changed

+205
-59
lines changed

6 files changed

+205
-59
lines changed
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Generated by Django 5.0.14 on 2026-01-07 10:53
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
dependencies = [
10+
("core", "0109_userfile_deleted_at"),
11+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name="TOTPUsedCode",
17+
fields=[
18+
(
19+
"id",
20+
models.AutoField(
21+
auto_created=True,
22+
primary_key=True,
23+
serialize=False,
24+
verbose_name="ID",
25+
),
26+
),
27+
("used_at", models.DateTimeField()),
28+
(
29+
"code",
30+
models.CharField(help_text="Hash of the used code", max_length=64),
31+
),
32+
(
33+
"user",
34+
models.ForeignKey(
35+
on_delete=django.db.models.deletion.CASCADE,
36+
related_name="totp_used_codes",
37+
to=settings.AUTH_USER_MODEL,
38+
),
39+
),
40+
],
41+
options={
42+
"indexes": [
43+
models.Index(
44+
fields=["user", "code"], name="totp_usedcode_user_code_idx"
45+
)
46+
],
47+
},
48+
),
49+
]

backend/src/baserow/core/two_factor_auth/models.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,24 @@ def is_enabled(self):
5757
return self.enabled
5858

5959

60+
class TOTPUsedCode(models.Model):
61+
user = models.ForeignKey(
62+
"auth.User",
63+
on_delete=models.CASCADE,
64+
related_name="totp_used_codes",
65+
)
66+
used_at = models.DateTimeField()
67+
code = models.CharField(max_length=64, help_text="Hash of the used code")
68+
69+
class Meta:
70+
indexes = [
71+
models.Index(
72+
fields=["user", "code"],
73+
name="totp_usedcode_user_code_idx",
74+
),
75+
]
76+
77+
6078
class TwoFactorAuthRecoveryCode(models.Model):
6179
user = models.ForeignKey(
6280
"auth.User",

backend/src/baserow/core/two_factor_auth/registries.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
)
2727
from baserow.core.two_factor_auth.models import (
2828
TOTPAuthProviderModel,
29+
TOTPUsedCode,
2930
TwoFactorAuthProviderModel,
3031
TwoFactorAuthRecoveryCode,
3132
)
@@ -134,6 +135,12 @@ def configure(
134135
backup_codes_plaintext = self.generate_backup_codes()
135136
self.store_backup_codes(provider, backup_codes_plaintext)
136137

138+
TOTPUsedCode.objects.create(
139+
user=provider.user,
140+
used_at=datetime.now(tz=timezone.utc),
141+
code=hashlib.sha256(code.encode("utf-8")).hexdigest(),
142+
)
143+
137144
provider._backup_codes = backup_codes_plaintext
138145
return provider
139146
else:
@@ -210,19 +217,36 @@ def verify(self, **kwargs) -> bool:
210217
recovery_code.delete()
211218
return True
212219

213-
provider = TwoFactorAuthProviderModel.objects.filter(user__email=email).first()
220+
provider = (
221+
TwoFactorAuthProviderModel.objects.select_for_update(of=("self",))
222+
.filter(user__email=email)
223+
.first()
224+
)
214225
if not provider:
215226
raise VerificationFailed
216227

217228
totp = pyotp.TOTP(provider.specific.secret)
229+
current_ts = datetime.now(tz=timezone.utc)
230+
hashed_code = hashlib.sha256(code.encode("utf-8")).hexdigest()
231+
232+
code_already_used = TOTPUsedCode.objects.filter(
233+
user=provider.user, code=hashed_code
234+
).exists()
218235

219-
if totp.verify(code):
236+
if not code_already_used and totp.verify(code):
237+
TOTPUsedCode.objects.filter(user=provider.user).delete()
238+
TOTPUsedCode.objects.create(
239+
user=provider.user,
240+
used_at=current_ts,
241+
code=hashed_code,
242+
)
220243
return True
221244
else:
222245
raise VerificationFailed
223246

224247
def disable(self, provider, user):
225248
TwoFactorAuthRecoveryCode.objects.filter(user=user).delete()
249+
TOTPUsedCode.objects.filter(user=user).delete()
226250
provider.delete()
227251

228252

backend/tests/baserow/api/two_factor_auth/test_two_factor_views.py

Lines changed: 50 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import pyotp
66
import pytest
7+
from freezegun import freeze_time
78
from rest_framework.status import (
89
HTTP_200_OK,
910
HTTP_204_NO_CONTENT,
@@ -435,53 +436,55 @@ def test_verify_totp_view_missing_email(api_client, data_fixture):
435436
@pytest.mark.django_db
436437
def test_verify_totp_code_view(api_client, data_fixture):
437438
user, token = data_fixture.create_user_and_token()
438-
provider = data_fixture.configure_totp(user)
439-
440-
response = api_client.post(
441-
reverse("api:user:token_auth"),
442-
{"email": user.email, "password": "password"},
443-
format="json",
444-
)
445-
response_json = response.json()
446-
two_fa_token = response_json["token"]
447-
448-
totp = pyotp.TOTP(provider.secret)
449-
valid_code = totp.now()
450-
451-
url = reverse("api:two_factor_auth:verify")
452-
response = api_client.post(
453-
url,
454-
{
455-
"type": "totp",
456-
"email": user.email,
457-
"code": valid_code,
458-
},
459-
format="json",
460-
HTTP_AUTHORIZATION=f"Bearer {two_fa_token}",
461-
)
462-
463-
response_json = response.json()
464-
assert response.status_code == HTTP_200_OK, response_json
465-
assert response_json == {
466-
"access_token": AnyStr(),
467-
"active_licenses": {"instance_wide": {}, "per_workspace": {}},
468-
"permissions": AnyList(),
469-
"refresh_token": AnyStr(),
470-
"token": AnyStr(),
471-
"user": {
472-
"completed_guided_tours": [],
473-
"completed_onboarding": False,
474-
"email_notification_frequency": "instant",
475-
"email_verified": False,
476-
"first_name": user.first_name,
477-
"id": user.id,
478-
"is_staff": False,
479-
"language": "en",
480-
"username": user.email,
481-
},
482-
"user_notifications": {"unread_count": 0},
483-
"user_session": AnyStr(),
484-
}
439+
with freeze_time("2020-02-01 00:00"):
440+
provider = data_fixture.configure_totp(user)
441+
442+
with freeze_time("2020-02-01 00:01"):
443+
response = api_client.post(
444+
reverse("api:user:token_auth"),
445+
{"email": user.email, "password": "password"},
446+
format="json",
447+
)
448+
response_json = response.json()
449+
two_fa_token = response_json["token"]
450+
451+
totp = pyotp.TOTP(provider.secret)
452+
valid_code = totp.now()
453+
454+
url = reverse("api:two_factor_auth:verify")
455+
response = api_client.post(
456+
url,
457+
{
458+
"type": "totp",
459+
"email": user.email,
460+
"code": valid_code,
461+
},
462+
format="json",
463+
HTTP_AUTHORIZATION=f"Bearer {two_fa_token}",
464+
)
465+
466+
response_json = response.json()
467+
assert response.status_code == HTTP_200_OK, response_json
468+
assert response_json == {
469+
"access_token": AnyStr(),
470+
"active_licenses": {"instance_wide": {}, "per_workspace": {}},
471+
"permissions": AnyList(),
472+
"refresh_token": AnyStr(),
473+
"token": AnyStr(),
474+
"user": {
475+
"completed_guided_tours": [],
476+
"completed_onboarding": False,
477+
"email_notification_frequency": "instant",
478+
"email_verified": False,
479+
"first_name": user.first_name,
480+
"id": user.id,
481+
"is_staff": False,
482+
"language": "en",
483+
"username": user.email,
484+
},
485+
"user_notifications": {"unread_count": 0},
486+
"user_session": AnyStr(),
487+
}
485488

486489

487490
@pytest.mark.django_db

backend/tests/baserow/core/two_factor_auth/test_two_factor_handler.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import pyotp
44
import pytest
5+
from freezegun import freeze_time
56

67
from baserow.core.two_factor_auth.exceptions import (
78
TwoFactorAuthCannotBeConfigured,
@@ -144,9 +145,13 @@ def test_verify_no_provider(data_fixture):
144145

145146
@pytest.mark.django_db
146147
def test_verify(data_fixture):
147-
user = data_fixture.create_user(password="password")
148-
provider = data_fixture.configure_totp(user)
149-
totp = pyotp.TOTP(provider.secret)
150-
code = totp.now()
151-
152-
assert TwoFactorAuthHandler().verify("totp", email=user.email, code=code) is True
148+
with freeze_time("2020-02-01 00:00"):
149+
user = data_fixture.create_user(password="password")
150+
provider = data_fixture.configure_totp(user)
151+
totp = pyotp.TOTP(provider.secret)
152+
153+
with freeze_time("2020-02-01 00:05"):
154+
code = totp.now()
155+
assert (
156+
TwoFactorAuthHandler().verify("totp", email=user.email, code=code) is True
157+
)

backend/tests/baserow/core/two_factor_auth/test_two_factor_registries.py

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
)
1212
from baserow.core.two_factor_auth.models import (
1313
TOTPAuthProviderModel,
14+
TOTPUsedCode,
1415
TwoFactorAuthRecoveryCode,
1516
)
1617
from baserow.core.two_factor_auth.registries import TOTPAuthProviderType
@@ -66,6 +67,7 @@ def test_totp_configure_finish_configuration(data_fixture):
6667
assert provider.provisioning_qr_code == ""
6768
assert TOTPAuthProviderModel.objects.filter(user=user).count() == 1
6869
assert TwoFactorAuthRecoveryCode.objects.filter(user=user).count() == 8
70+
assert TOTPUsedCode.objects.filter(user=user).count() == 1
6971

7072

7173
@pytest.mark.django_db
@@ -123,22 +125,62 @@ def test_generate_backup_codes():
123125
@pytest.mark.django_db
124126
def test_verify_with_code(data_fixture):
125127
user = data_fixture.create_user()
126-
provider = data_fixture.configure_totp(user)
128+
with freeze_time("2020-02-01 00:00"):
129+
provider = data_fixture.configure_totp(user)
130+
assert TOTPUsedCode.objects.filter(user=user).count() == 1
131+
127132
totp = pyotp.TOTP(provider.secret)
128-
code = totp.now()
129133

130-
assert TOTPAuthProviderType().verify(email=user.email, code=code)
134+
with freeze_time("2020-02-01 00:05"):
135+
code = totp.now()
136+
assert TOTPAuthProviderType().verify(email=user.email, code=code)
137+
assert TOTPUsedCode.objects.filter(user=user).count() == 1
131138

132139

133140
@pytest.mark.django_db
134-
def test_verify_with_code_fails(data_fixture):
141+
def test_verify_with_code_fails_wrong_code(data_fixture):
135142
user = data_fixture.create_user()
136143
data_fixture.configure_totp(user)
137144

138145
with pytest.raises(VerificationFailed):
139146
TOTPAuthProviderType().verify(email=user.email, code="1234567")
140147

141148

149+
@pytest.mark.django_db
150+
def test_verify_with_code_code_cannot_be_reused(data_fixture):
151+
user = data_fixture.create_user()
152+
user_2 = data_fixture.create_user()
153+
with freeze_time("2020-02-01 00:00"):
154+
provider = data_fixture.configure_totp(user)
155+
provider_2 = data_fixture.configure_totp(user_2)
156+
provider_2.secret = provider.secret
157+
provider_2.save()
158+
totp = pyotp.TOTP(provider.secret)
159+
160+
with freeze_time("2020-02-01 00:05"):
161+
code = totp.now()
162+
163+
# first time the code is valid
164+
assert TOTPAuthProviderType().verify(email=user.email, code=code)
165+
166+
with pytest.raises(VerificationFailed):
167+
TOTPAuthProviderType().verify(email=user.email, code=code)
168+
169+
# another user with the same secret would not be
170+
# affected
171+
assert TOTPAuthProviderType().verify(email=user_2.email, code=code)
172+
173+
with freeze_time("2020-02-01 00:10"):
174+
code = totp.now()
175+
176+
# user can use a new code from a different time window
177+
assert TOTPAuthProviderType().verify(email=user.email, code=code)
178+
179+
# older used codes are automatically deleted upon successful
180+
# verification
181+
assert TOTPUsedCode.objects.filter(user=user).count() == 1
182+
183+
142184
@pytest.mark.django_db
143185
def test_verify_with_backup_code(data_fixture):
144186
user = data_fixture.create_user()
@@ -169,9 +211,14 @@ def test_verify_no_provider(data_fixture):
169211
@pytest.mark.django_db
170212
def test_totp_disable(data_fixture):
171213
user = data_fixture.create_user()
214+
user_2 = data_fixture.create_user()
172215
provider = data_fixture.configure_totp(user)
216+
data_fixture.configure_totp(user_2)
217+
assert TOTPUsedCode.objects.count() == 2
173218

174219
TOTPAuthProviderType().disable(provider, user)
175220

176221
assert TOTPAuthProviderModel.objects.filter(user=user).count() == 0
177222
assert TwoFactorAuthRecoveryCode.objects.filter(user=user).count() == 0
223+
assert TOTPUsedCode.objects.count() == 1
224+
assert TOTPUsedCode.objects.filter(user=user_2).count() == 1

0 commit comments

Comments
 (0)