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
This commit is contained in:
Hugh Rundle 2022-09-11 16:38:15 +10:00
parent 514762c233
commit 0e1751eb57
6 changed files with 148 additions and 8 deletions

View file

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

View file

@ -29,7 +29,7 @@
{{ qrcode | safe }}
</svg>
</div>
<div class="field ">
<div class="field">
<label class="label" for="id_otp">{% trans "Enter the code from your authenticator app:" %}</label>
{{ form.otp }}
{% include 'snippets/form_errors.html' with errors_list=form.current_password.errors id="desc_current_password" %}

View file

@ -0,0 +1,104 @@
{% load layout %}
{% load sass_tags %}
{% load i18n %}
{% load static %}
<!DOCTYPE html>
<html lang="{% get_lang %}">
<head>
<title>{% block title %}BookWyrm{% endblock %} - {{ site.name }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="{% sass_src site_theme %}" rel="stylesheet" type="text/css" />
</head>
<body>
<nav class="navbar" aria-label="main navigation">
<div class="container">
<div class="navbar-brand">
<a class="navbar-item" href="/">
<img class="image logo" src="{% if site.logo_small %}{% get_media_prefix %}{{ site.logo_small }}{% else %}{% static "images/logo-small.png" %}{% endif %}" alt="{% blocktrans with site_name=site.name %}{{ site_name }} home page{% endblocktrans %}">
</a>
</div>
</div>
</nav>
<div class="container">
<div class="columns is-multiline is-centered">
<div class="column">
<header class="block">
{% block header %}
<h1 class="title">
{% trans "2FA check" %}
</h1>
{% endblock %}
</header>
{% if error %}
<div class="notification is-danger is-light">
<!-- TODO: how do we translate dynamic errors? -->
<span>
{{ error }}
</span>
</div>
{% endif %}
<div class="is-centered">
<form name="confirm-2fa" action="{% url 'login-with-2fa' %}" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="field">
<label class="label" for="id_otp">{% trans "Enter the code from your authenticator app:" %}</label>
{{ form.otp }}
{% include 'snippets/form_errors.html' with errors_list=form.current_password.errors id="desc_current_password" %}
</div>
<button class="button is-primary" type="submit">{% trans "Confirm and Log In" %}</button>
</form>
</div>
</div>
</div>
</div>
<footer class="footer">
<div class="container">
<div class="columns">
<div class="column is-one-fifth">
<p>
<a href="{% url 'about' %}">{% blocktrans with site_name=site.name %}About {{ site_name }}{% endblocktrans %}</a>
</p>
{% if site.admin_email %}
<p>
<a href="mailto:{{ site.admin_email }}">{% trans "Contact site admin" %}</a>
</p>
{% endif %}
<p>
<a href="https://docs.joinbookwyrm.com/">{% trans "Documentation" %}</a>
</p>
{% if request.user.is_authenticated %}
<p id="tour-begin">
<a href="/guided-tour/True">{% trans "Guided Tour" %}</a>
<noscript>(requires JavaScript)</noscript>
</p>
{% endif %}
</div>
<div class="column content is-two-fifth">
{% if site.support_link %}
<p>
<span class="icon icon-heart"></span>
{% blocktrans trimmed with site_name=site.name support_link=site.support_link support_title=site.support_title %}
Support {{ site_name }} on
<a href="{{ support_link }}" target="_blank" rel="nofollow noopener noreferrer">{{ support_title }}</a>
{% endblocktrans %}
</p>
{% endif %}
<p>
{% blocktrans trimmed %}
BookWyrm's source code is freely available. You can contribute or report issues on
<a href="https://github.com/bookwyrm-social/bookwyrm" target="_blank" rel="nofollow noopener noreferrer">GitHub</a>.
{% endblocktrans %}
</p>
</div>
{% if site.footer_item %}
<div class="column">
<p>{{ site.footer_item|safe }}</p>
</div>
{% endif %}
</div>
</div>
</footer>
</body>
</html>

View file

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

View file

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

View file

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