From 54daade9f9b00f357b108392de93c7bf562a2210 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sun, 11 Sep 2022 13:48:52 +1000 Subject: [PATCH] prepare for 2FA - add and migrate User fields for 2FA - add views for 2FA - add new forms for 2FA - update package list in requirements.txt - add URLs for 2FA views --- bookwyrm/forms/edit_user.py | 35 +++++++ .../migrations/0158_auto_20220911_0008.py | 28 ++++++ bookwyrm/models/user.py | 5 + bookwyrm/urls.py | 15 +++ bookwyrm/views/__init__.py | 1 + bookwyrm/views/preferences/two_factor_auth.py | 93 +++++++++++++++++++ requirements.txt | 2 + 7 files changed, 179 insertions(+) create mode 100644 bookwyrm/migrations/0158_auto_20220911_0008.py create mode 100644 bookwyrm/views/preferences/two_factor_auth.py diff --git a/bookwyrm/forms/edit_user.py b/bookwyrm/forms/edit_user.py index a291c6441..a7effaf08 100644 --- a/bookwyrm/forms/edit_user.py +++ b/bookwyrm/forms/edit_user.py @@ -8,6 +8,7 @@ from bookwyrm import models from bookwyrm.models.fields import ClearableFileInputWithWarning from .custom_form import CustomForm +import pyotp # pylint: disable=missing-class-docstring class EditUserForm(CustomForm): @@ -99,3 +100,37 @@ class ChangePasswordForm(CustomForm): validate_password(new_password) except ValidationError as err: self.add_error("password", err) + + +class ConfirmPasswordForm(CustomForm): + password = forms.CharField(widget=forms.PasswordInput) + + class Meta: + model = models.User + fields = ["password"] + widgets = { + "password": forms.PasswordInput(), + } + + def clean(self): + """Make sure password is correct""" + password = self.data.get("password") + + if not self.instance.check_password(password): + self.add_error("password", _("Incorrect Password")) + + +class Confirm2FAForm(CustomForm): + otp = forms.CharField(max_length=6, min_length=6, widget=forms.TextInput) + + class Meta: + model = models.User + fields = ["otp_secret"] + + def clean(self): + """Check otp matches""" + otp = self.data.get("otp") + totp = pyotp.TOTP(self.instance.otp_secret) + + if not totp.verify(otp): + self.add_error("otp", _("Code does not match")) diff --git a/bookwyrm/migrations/0158_auto_20220911_0008.py b/bookwyrm/migrations/0158_auto_20220911_0008.py new file mode 100644 index 000000000..d5a98d94a --- /dev/null +++ b/bookwyrm/migrations/0158_auto_20220911_0008.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.15 on 2022-09-11 00:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0157_auto_20220909_2338"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="htop_count", + field=models.IntegerField(blank=True, default=0, null=True), + ), + migrations.AddField( + model_name="user", + name="otp_secret", + field=models.CharField(blank=True, default=None, max_length=32, null=True), + ), + migrations.AddField( + model_name="user", + name="two_factor_auth", + field=models.BooleanField(blank=True, default=None, null=True), + ), + ] diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py index 055941d8c..8703d01fc 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -175,6 +175,11 @@ class User(OrderedCollectionPageMixin, AbstractUser): property_fields = [("following_link", "following")] field_tracker = FieldTracker(fields=["name", "avatar"]) + # two factor authentication + two_factor_auth = models.BooleanField(default=None, blank=True, null=True) + otp_secret = models.CharField(max_length=32, default=None, blank=True, null=True) + htop_count = models.IntegerField(default=0, blank=True, null=True) + @property def active_follower_requests(self): """Follow requests from active users""" diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py index 449b1d723..7b6e36cde 100644 --- a/bookwyrm/urls.py +++ b/bookwyrm/urls.py @@ -479,6 +479,21 @@ urlpatterns = [ views.ChangePassword.as_view(), name="prefs-password", ), + re_path( + r"^preferences/2fa/?$", + views.Edit2FA.as_view(), + name="prefs-2fa", + ), + re_path( + r"^preferences/confirm-2fa/?$", + views.Confirm2FA.as_view(), + name="conf-2fa", + ), + re_path( + r"^preferences/disable-2fa/?$", + views.Disable2FA.as_view(), + name="disable-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 257adc932..1b9d74181 100644 --- a/bookwyrm/views/__init__.py +++ b/bookwyrm/views/__init__.py @@ -32,6 +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 # books from .books.books import ( diff --git a/bookwyrm/views/preferences/two_factor_auth.py b/bookwyrm/views/preferences/two_factor_auth.py new file mode 100644 index 000000000..2441bae97 --- /dev/null +++ b/bookwyrm/views/preferences/two_factor_auth.py @@ -0,0 +1,93 @@ +""" 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 + +from django.contrib.auth import login +from django.contrib.auth.decorators import login_required +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 bookwyrm import forms +from bookwyrm.settings import DOMAIN + +# pylint: disable= no-self-use +@method_decorator(login_required, name="dispatch") +class Edit2FA(View): + """change 2FA settings as logged in user""" + + def get(self, request): + """Two Factor auth page""" + data = {"form": forms.ConfirmPasswordForm()} + return TemplateResponse(request, "preferences/2fa.html", data) + + @method_decorator(sensitive_post_parameters("password")) + def post(self, request): + """check the user's password""" + form = forms.ConfirmPasswordForm(request.POST, instance=request.user) + # TODO: display an error + if not form.is_valid(): + data = {"form": form} + return TemplateResponse(request, "preferences/2fa.html", data) + qr_form = forms.Confirm2FAForm(request.POST, instance=request.user) + data = { + "password_confirmed": True, + "qrcode": self.create_qr_code(request.user), + "form": qr_form, + } + return TemplateResponse(request, "preferences/2fa.html", data) + + def create_qr_code(self, user): + """generate and save a qr code for 2FA""" + otp_secret = pyotp.random_base32() + # save the secret to the user record - we'll need it to check codes in future + user.otp_secret = otp_secret + user.save(broadcast=False, update_fields=["otp_secret"]) + # now we create the qr code + provisioning_url = pyotp.totp.TOTP(otp_secret).provisioning_uri( + name=user.localname, issuer_name=DOMAIN + ) + qr = qrcode.QRCode(image_factory=qrcode.image.svg.SvgPathImage) + qr.add_data(provisioning_url) + qr.make(fit=True) + img = qr.make_image(attrib={"fill": "black", "background": "white"}) + return img.to_string() + + +class Confirm2FA(View): + """confirm user's 2FA settings""" + + def post(self, request): + """confirm the 2FA works before requiring it""" + form = forms.Confirm2FAForm(request.POST, instance=request.user) + # TODO: show an error here + if not form.is_valid(): + data = {"form": form} + return redirect("prefs-2fa") + # set the user's 2FA setting on + request.user.two_factor_auth = True + request.user.save(broadcast=False, update_fields=["two_factor_auth"]) + data = {"form": form, "success": True} + return TemplateResponse(request, "preferences/2fa.html", data) + + +class Disable2FA(View): + """Turn off 2FA on this user account""" + + def get(self, request): + """Confirmation page to turn off 2FA""" + return TemplateResponse(request, "preferences/disable-2fa.html") + + def post(self, request): + """Turn off 2FA on this user account""" + request.user.two_factor_auth = False + request.user.save(broadcast=False, update_fields=["two_factor_auth"]) + data = {"form": forms.ConfirmPasswordForm(), "success": True} + return TemplateResponse(request, "preferences/2fa.html", data) diff --git a/requirements.txt b/requirements.txt index 03778264c..dc9df703f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,6 +29,8 @@ opentelemetry-instrumentation-celery==0.30b1 opentelemetry-instrumentation-django==0.30b1 opentelemetry-sdk==1.11.1 protobuf==3.20.* +pyotp==2.6.0 +qrcode==7.3.1 # Dev pytest-django==4.1.0