Browse Source

Add protection against TOTP code reuse

pull/551/head
Pēteris Caune 3 years ago
parent
commit
d60d8a43b6
No known key found for this signature in database GPG Key ID: E28D7679E9A9EDE2
4 changed files with 28 additions and 3 deletions
  1. +2
    -0
      hc/accounts/forms.py
  2. +11
    -0
      hc/accounts/tests/test_login_totp.py
  3. +4
    -1
      hc/accounts/views.py
  4. +11
    -2
      hc/api/models.py

+ 2
- 0
hc/accounts/forms.py View File

@ -175,3 +175,5 @@ class TotpForm(forms.Form):
def clean_code(self): def clean_code(self):
if not self.totp.verify(self.cleaned_data["code"], valid_window=1): if not self.totp.verify(self.cleaned_data["code"], valid_window=1):
raise forms.ValidationError("The code you entered was incorrect.") raise forms.ValidationError("The code you entered was incorrect.")
return self.cleaned_data["code"]

+ 11
- 0
hc/accounts/tests/test_login_totp.py View File

@ -84,3 +84,14 @@ class LoginTotpTestCase(BaseTestCase):
r = self.client.post(self.url, {"code": "000000"}) r = self.client.post(self.url, {"code": "000000"})
self.assertContains(r, "Too Many Requests") self.assertContains(r, "Too Many Requests")
@patch("hc.accounts.views.pyotp.totp.TOTP")
def test_it_rejects_used_code(self, mock_TOTP):
mock_TOTP.return_value.verify.return_value = True
obj = TokenBucket(value=f"totpc-{self.alice.id}-000000")
obj.tokens = 0
obj.save()
r = self.client.post(self.url, {"code": "000000"})
self.assertContains(r, "Too Many Requests")

+ 4
- 1
hc/accounts/views.py View File

@ -848,11 +848,14 @@ def login_totp(request):
totp = pyotp.totp.TOTP(user.profile.totp) totp = pyotp.totp.TOTP(user.profile.totp)
if request.method == "POST": if request.method == "POST":
if not TokenBucket.authorize_totp(user):
if not TokenBucket.authorize_totp_attempt(user):
return render(request, "try_later.html") return render(request, "try_later.html")
form = forms.TotpForm(totp, request.POST) form = forms.TotpForm(totp, request.POST)
if form.is_valid(): if form.is_valid():
if not TokenBucket.authorize_totp_code(user, form.cleaned_data["code"]):
return render(request, "try_later.html")
request.session.pop("2fa_user") request.session.pop("2fa_user")
auth_login(request, user, "hc.accounts.backends.EmailBackend") auth_login(request, user, "hc.accounts.backends.EmailBackend")
return _redirect_after_login(request) return _redirect_after_login(request)


+ 11
- 2
hc/api/models.py View File

@ -985,9 +985,18 @@ class TokenBucket(models.Model):
return TokenBucket.authorize(value, 10, 3600 * 24) return TokenBucket.authorize(value, 10, 3600 * 24)
@staticmethod @staticmethod
def authorize_totp(user):
def authorize_totp_attempt(user):
value = "totp-%d" % user.id value = "totp-%d" % user.id
# 96 attempts per 24 hours
# 96 attempts per user per 24 hours
# (or, on average, one attempt per 15 minutes) # (or, on average, one attempt per 15 minutes)
return TokenBucket.authorize(value, 96, 3600 * 24) return TokenBucket.authorize(value, 96, 3600 * 24)
@staticmethod
def authorize_totp_code(user, code):
value = "totpc-%d-%s" % (user.id, code)
# A code has a validity period of 3 * 30 = 90 seconds.
# During that period, allow the code to only be used once,
# so an eavesdropping attacker cannot reuse a code.
return TokenBucket.authorize(value, 1, 90)

Loading…
Cancel
Save