From 0e1751eb57a5add94acca6564c3d36100820986c Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sun, 11 Sep 2022 16:38:15 +1000 Subject: [PATCH] prep for 2fa login check - new 2fa checker page to be inserted between initial login and completion of login - new views and forms for above --- bookwyrm/forms/edit_user.py | 15 ++- bookwyrm/templates/preferences/2fa.html | 2 +- bookwyrm/templates/two_factor_login.html | 104 ++++++++++++++++++ bookwyrm/urls.py | 5 + bookwyrm/views/__init__.py | 2 +- bookwyrm/views/preferences/two_factor_auth.py | 28 ++++- 6 files changed, 148 insertions(+), 8 deletions(-) create mode 100644 bookwyrm/templates/two_factor_login.html diff --git a/bookwyrm/forms/edit_user.py b/bookwyrm/forms/edit_user.py index a7effaf08..51be68a51 100644 --- a/bookwyrm/forms/edit_user.py +++ b/bookwyrm/forms/edit_user.py @@ -123,6 +123,7 @@ class ConfirmPasswordForm(CustomForm): class Confirm2FAForm(CustomForm): otp = forms.CharField(max_length=6, min_length=6, widget=forms.TextInput) + # IDK if we need this? class Meta: model = models.User fields = ["otp_secret"] @@ -133,4 +134,16 @@ class Confirm2FAForm(CustomForm): totp = pyotp.TOTP(self.instance.otp_secret) if not totp.verify(otp): - self.add_error("otp", _("Code does not match")) + # 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): + self.add_error("otp", _("Code does not match")) + + # TODO: backup codes + # increment the user hotp_count if it was an HOTP + # self.instance.hotp_count = hotp_count + 1 + # self.instance.save(broadcast=False, update_fields=["hotp_count"]) diff --git a/bookwyrm/templates/preferences/2fa.html b/bookwyrm/templates/preferences/2fa.html index 798716ac4..e5e9684ce 100644 --- a/bookwyrm/templates/preferences/2fa.html +++ b/bookwyrm/templates/preferences/2fa.html @@ -29,7 +29,7 @@ {{ qrcode | safe }} -
+
{{ form.otp }} {% include 'snippets/form_errors.html' with errors_list=form.current_password.errors id="desc_current_password" %} diff --git a/bookwyrm/templates/two_factor_login.html b/bookwyrm/templates/two_factor_login.html new file mode 100644 index 000000000..7d31a714b --- /dev/null +++ b/bookwyrm/templates/two_factor_login.html @@ -0,0 +1,104 @@ +{% load layout %} +{% load sass_tags %} +{% load i18n %} +{% load static %} + + + + + {% block title %}BookWyrm{% endblock %} - {{ site.name }} + + + + + +
+
+
+
+ {% block header %} +

+ {% trans "2FA check" %} +

+ {% endblock %} +
+ {% if error %} +
+ + + {{ error }} + +
+ {% endif %} +
+
+ {% csrf_token %} +
+ + {{ form.otp }} + {% include 'snippets/form_errors.html' with errors_list=form.current_password.errors id="desc_current_password" %} +
+ +
+
+
+
+
+ + + + diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py index 7b6e36cde..4e09cb550 100644 --- a/bookwyrm/urls.py +++ b/bookwyrm/urls.py @@ -494,6 +494,11 @@ urlpatterns = [ views.Disable2FA.as_view(), name="disable-2fa", ), + re_path( + r"^login-2FA-check/?$", + views.LoginWith2FA.as_view(), + name="login-with-2fa", + ), re_path(r"^preferences/export/?$", views.Export.as_view(), name="prefs-export"), re_path(r"^preferences/delete/?$", views.DeleteUser.as_view(), name="prefs-delete"), re_path(r"^preferences/block/?$", views.Block.as_view(), name="prefs-block"), diff --git a/bookwyrm/views/__init__.py b/bookwyrm/views/__init__.py index 1b9d74181..9c0c7adea 100644 --- a/bookwyrm/views/__init__.py +++ b/bookwyrm/views/__init__.py @@ -32,7 +32,7 @@ from .preferences.edit_user import EditUser from .preferences.export import Export from .preferences.delete_user import DeleteUser from .preferences.block import Block, unblock -from .preferences.two_factor_auth import Edit2FA, Confirm2FA, Disable2FA +from .preferences.two_factor_auth import Edit2FA, Confirm2FA, Disable2FA, LoginWith2FA # books from .books.books import ( diff --git a/bookwyrm/views/preferences/two_factor_auth.py b/bookwyrm/views/preferences/two_factor_auth.py index 2441bae97..bbe7628e2 100644 --- a/bookwyrm/views/preferences/two_factor_auth.py +++ b/bookwyrm/views/preferences/two_factor_auth.py @@ -1,11 +1,8 @@ """ class views for 2FA management """ -import base64 -import io -from pipes import Template -from turtle import fillcolor import pyotp import qrcode import qrcode.image.svg +import time from django.contrib.auth import login from django.contrib.auth.decorators import login_required @@ -13,7 +10,7 @@ from django.template.response import TemplateResponse from django.shortcuts import redirect from django.utils.decorators import method_decorator from django.views import View -from django.views.decorators.debug import sensitive_variables, sensitive_post_parameters +from django.views.decorators.debug import sensitive_post_parameters from bookwyrm import forms from bookwyrm.settings import DOMAIN @@ -91,3 +88,24 @@ class Disable2FA(View): request.user.save(broadcast=False, update_fields=["two_factor_auth"]) data = {"form": forms.ConfirmPasswordForm(), "success": True} return TemplateResponse(request, "preferences/2fa.html", data) + + +class LoginWith2FA(View): + """Check 2FA code matches before allowing login""" + + def get(self, request): + """Load 2FA checking page""" + form = forms.Confirm2FAForm(request.GET, instance=request.user) + return TemplateResponse(request, "two_factor_login.html", {"form": form}) + + def post(self, request): + """Check 2FA code and allow/disallow login""" + form = forms.Confirm2FAForm(request.POST, instance=request.user) + + if not form.is_valid(): + time.sleep(2) # make life harder for bots + data = {"form": form, "error": "Code does not match, try again"} + return TemplateResponse(request, "two_factor_login.html", data) + + # TODO: actually log the user in - we will be bypassing normal login + return redirect("/")