mirror of
https://github.com/bookwyrm-social/bookwyrm.git
synced 2024-12-24 00:50:35 +00:00
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:
parent
9616abb6bd
commit
9b74c26742
7 changed files with 89 additions and 17 deletions
|
@ -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"))
|
||||||
|
|
|
@ -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",
|
|
@ -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
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -36,6 +36,7 @@ from .preferences.two_factor_auth import (
|
||||||
Edit2FA,
|
Edit2FA,
|
||||||
Confirm2FA,
|
Confirm2FA,
|
||||||
Disable2FA,
|
Disable2FA,
|
||||||
|
GenerateBackupCodes,
|
||||||
LoginWith2FA,
|
LoginWith2FA,
|
||||||
Prompt2FA,
|
Prompt2FA,
|
||||||
)
|
)
|
||||||
|
|
|
@ -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"""
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue