backup codes

- add hotp_secret to user model
- view to create backup codes in user prefs
- check backup code if otp doesn't work
- increment hotp count if used
- show correct errors if code wrong
This commit is contained in:
Hugh Rundle 2022-09-18 19:52:53 +10:00
parent 9616abb6bd
commit 9b74c26742
7 changed files with 89 additions and 17 deletions

View file

@ -79,7 +79,9 @@ class PasswordResetForm(CustomForm):
class Confirm2FAForm(CustomForm): class Confirm2FAForm(CustomForm):
otp = forms.CharField(max_length=6, min_length=6, widget=forms.TextInput) otp = forms.CharField(
max_length=6, min_length=6, widget=forms.TextInput(attrs={"autofocus": True})
)
class Meta: class Meta:
model = models.User model = models.User
@ -91,16 +93,23 @@ class Confirm2FAForm(CustomForm):
totp = pyotp.TOTP(self.instance.otp_secret) totp = pyotp.TOTP(self.instance.otp_secret)
if not totp.verify(otp): if not totp.verify(otp):
# maybe it's a backup code?
hotp = pyotp.HOTP(self.instance.otp_secret)
hotp_count = (
self.instance.hotp_count if self.instance.hotp_count is not None else 0
)
if not hotp.verify(otp, hotp_count): if self.instance.hotp_secret:
self.add_error("otp", _("Code does not match")) # maybe it's a backup code?
hotp = pyotp.HOTP(self.instance.hotp_secret)
hotp_count = (
self.instance.hotp_count
if self.instance.hotp_count is not None
else 0
)
# TODO: backup codes if not hotp.verify(otp, hotp_count):
# increment the user hotp_count if it was an HOTP self.add_error("otp", _("Incorrect code"))
# self.instance.hotp_count = hotp_count + 1
# self.instance.save(broadcast=False, update_fields=["hotp_count"]) # increment the user hotp_count
else:
self.instance.hotp_count = hotp_count + 1
self.instance.save(broadcast=False, update_fields=["hotp_count"])
else:
self.add_error("otp", _("Incorrect code"))

View file

@ -1,4 +1,4 @@
# Generated by Django 3.2.15 on 2022-09-11 05:25 # Generated by Django 3.2.15 on 2022-09-18 09:36
from django.db import migrations, models from django.db import migrations, models
@ -15,6 +15,11 @@ class Migration(migrations.Migration):
name="hotp_count", name="hotp_count",
field=models.IntegerField(blank=True, default=0, null=True), field=models.IntegerField(blank=True, default=0, null=True),
), ),
migrations.AddField(
model_name="user",
name="hotp_secret",
field=models.CharField(blank=True, default=None, max_length=32, null=True),
),
migrations.AddField( migrations.AddField(
model_name="user", model_name="user",
name="otp_secret", name="otp_secret",

View file

@ -178,6 +178,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
# two factor authentication # two factor authentication
two_factor_auth = models.BooleanField(default=None, blank=True, null=True) two_factor_auth = models.BooleanField(default=None, blank=True, null=True)
otp_secret = models.CharField(max_length=32, default=None, blank=True, null=True) otp_secret = models.CharField(max_length=32, default=None, blank=True, null=True)
hotp_secret = models.CharField(max_length=32, default=None, blank=True, null=True)
hotp_count = models.IntegerField(default=0, blank=True, null=True) hotp_count = models.IntegerField(default=0, blank=True, null=True)
@property @property

View file

@ -17,9 +17,28 @@
</span> </span>
</div> </div>
{% endif %} {% endif %}
{% if request.user.two_factor_auth %} {% if backup_codes %}
<p>Two Factor Authentication is active on your account.</p> <div class="block">
<a class="button is-danger" href="{% url 'disable-2fa' %}">{% trans "Disable 2FA" %}</a> <h3>Backup codes</h3>
<div class="block">
<p>{% trans "Write down or copy and paste these codes somewhere safe." %}</p>
<p>{% trans "You must use them in order, and they will not be displayed again." %}</p>
</div>
<ul class="content" style="list-style: none;">
{% for code in backup_codes %}
<li>{{ code }}</li>
{% endfor%}
</ul>
</div>
{% elif request.user.two_factor_auth %}
<div class="block">
<p>Two Factor Authentication is active on your account.</p>
<a class="button is-danger" href="{% url 'disable-2fa' %}">{% trans "Disable 2FA" %}</a>
</div>
<div class="block">
<p>{% trans "You can generate backup codes to use in case you do not have access to your authentication app. If you generate new codes, any backup codes previously generated will no longer work." %}</p>
<a class="button" href="{% url 'generate-2fa-backup-codes' %}">{% trans "Generate backup codes" %}</a>
</div>
{% elif password_confirmed %} {% elif password_confirmed %}
<form name="confirm-2fa" action="{% url 'conf-2fa' %}" method="post" enctype="multipart/form-data"> <form name="confirm-2fa" action="{% url 'conf-2fa' %}" method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}

View file

@ -484,6 +484,11 @@ urlpatterns = [
views.Edit2FA.as_view(), views.Edit2FA.as_view(),
name="prefs-2fa", name="prefs-2fa",
), ),
re_path(
r"^preferences/2fa-backup-codes/?$",
views.GenerateBackupCodes.as_view(),
name="generate-2fa-backup-codes",
),
re_path( re_path(
r"^preferences/confirm-2fa/?$", r"^preferences/confirm-2fa/?$",
views.Confirm2FA.as_view(), views.Confirm2FA.as_view(),

View file

@ -36,6 +36,7 @@ from .preferences.two_factor_auth import (
Edit2FA, Edit2FA,
Confirm2FA, Confirm2FA,
Disable2FA, Disable2FA,
GenerateBackupCodes,
LoginWith2FA, LoginWith2FA,
Prompt2FA, Prompt2FA,
) )

View file

@ -59,6 +59,7 @@ class Edit2FA(View):
return img.to_string() return img.to_string()
@method_decorator(login_required, name="dispatch")
class Confirm2FA(View): class Confirm2FA(View):
"""confirm user's 2FA settings""" """confirm user's 2FA settings"""
@ -81,6 +82,7 @@ class Confirm2FA(View):
return TemplateResponse(request, "preferences/2fa.html", data) return TemplateResponse(request, "preferences/2fa.html", data)
@method_decorator(login_required, name="dispatch")
class Disable2FA(View): class Disable2FA(View):
"""Turn off 2FA on this user account""" """Turn off 2FA on this user account"""
@ -118,7 +120,7 @@ class LoginWith2FA(View):
if not form.is_valid(): if not form.is_valid():
# make life harder for bots # make life harder for bots
# humans are unlikely to get it wrong more than twice # humans are unlikely to get it wrong more than twice
if not request.session["2fa_attempts"]: if "2fa_attempts" not in request.session:
request.session["2fa_attempts"] = 0 request.session["2fa_attempts"] = 0
request.session["2fa_attempts"] = request.session["2fa_attempts"] + 1 request.session["2fa_attempts"] = request.session["2fa_attempts"] + 1
time.sleep(2 ** request.session["2fa_attempts"]) time.sleep(2 ** request.session["2fa_attempts"])
@ -134,6 +136,36 @@ class LoginWith2FA(View):
return set_language(user, redirect("/")) return set_language(user, redirect("/"))
@method_decorator(login_required, name="dispatch")
class GenerateBackupCodes(View):
"""Generate and display backup 2FA codes"""
def get(self, request):
"""Generate and display backup 2FA codes"""
data = {"backup_codes": self.generate_backup_codes(request.user)}
return TemplateResponse(request, "preferences/2fa.html", data)
def generate_backup_codes(self, user):
"""generate fresh backup codes for 2FA"""
# create fresh hotp secrets and count
hotp_secret = pyotp.random_base32()
user.hotp_count = 0
# save the secret to the user record
user.hotp_secret = hotp_secret
user.save(broadcast=False, update_fields=["hotp_count", "hotp_secret"])
# generate codes
hotp = pyotp.HOTP(hotp_secret)
counter = 0
codes = []
while counter < 10:
codes.append(hotp.at(counter))
counter = counter + 1
return codes
class Prompt2FA(View): class Prompt2FA(View):
"""Alert user to the existence of 2FA""" """Alert user to the existence of 2FA"""