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
This commit is contained in:
Hugh Rundle 2022-09-11 13:48:52 +10:00
parent fed6bcd375
commit 54daade9f9
7 changed files with 179 additions and 0 deletions

View file

@ -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"))

View file

@ -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),
),
]

View file

@ -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"""

View file

@ -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"),

View file

@ -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 (

View file

@ -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)

View file

@ -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