mirror of
https://github.com/bookwyrm-social/bookwyrm.git
synced 2024-12-23 08:36:32 +00:00
commit
c375e842ad
20 changed files with 827 additions and 5 deletions
|
@ -8,7 +8,6 @@ from bookwyrm import models
|
|||
from bookwyrm.models.fields import ClearableFileInputWithWarning
|
||||
from .custom_form import CustomForm
|
||||
|
||||
|
||||
# pylint: disable=missing-class-docstring
|
||||
class EditUserForm(CustomForm):
|
||||
class Meta:
|
||||
|
@ -99,3 +98,21 @@ 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"))
|
||||
|
|
|
@ -4,6 +4,8 @@ from django.contrib.auth.password_validation import validate_password
|
|||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import pyotp
|
||||
|
||||
from bookwyrm import models
|
||||
from .custom_form import CustomForm
|
||||
|
||||
|
@ -74,3 +76,40 @@ class PasswordResetForm(CustomForm):
|
|||
validate_password(new_password)
|
||||
except ValidationError as err:
|
||||
self.add_error("password", err)
|
||||
|
||||
|
||||
class Confirm2FAForm(CustomForm):
|
||||
otp = forms.CharField(
|
||||
max_length=6, min_length=6, widget=forms.TextInput(attrs={"autofocus": True})
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["otp_secret", "hotp_count"]
|
||||
|
||||
def clean_otp(self):
|
||||
"""Check otp matches"""
|
||||
otp = self.data.get("otp")
|
||||
totp = pyotp.TOTP(self.instance.otp_secret)
|
||||
|
||||
if not totp.verify(otp):
|
||||
|
||||
if self.instance.hotp_secret:
|
||||
# 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
|
||||
)
|
||||
|
||||
if not hotp.verify(otp, hotp_count):
|
||||
self.add_error("otp", _("Incorrect code"))
|
||||
|
||||
# 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"))
|
||||
|
|
33
bookwyrm/migrations/0159_auto_20220924_0634.py
Normal file
33
bookwyrm/migrations/0159_auto_20220924_0634.py
Normal file
|
@ -0,0 +1,33 @@
|
|||
# Generated by Django 3.2.15 on 2022-09-24 06:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0158_auto_20220919_1634"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="hotp_count",
|
||||
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(
|
||||
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),
|
||||
),
|
||||
]
|
|
@ -175,6 +175,12 @@ 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)
|
||||
hotp_secret = models.CharField(max_length=32, default=None, blank=True, null=True)
|
||||
hotp_count = models.IntegerField(default=0, blank=True, null=True)
|
||||
|
||||
@property
|
||||
def active_follower_requests(self):
|
||||
"""Follow requests from active users"""
|
||||
|
|
|
@ -358,3 +358,5 @@ else:
|
|||
OTEL_EXPORTER_OTLP_ENDPOINT = env("OTEL_EXPORTER_OTLP_ENDPOINT", None)
|
||||
OTEL_EXPORTER_OTLP_HEADERS = env("OTEL_EXPORTER_OTLP_HEADERS", None)
|
||||
OTEL_SERVICE_NAME = env("OTEL_SERVICE_NAME", None)
|
||||
|
||||
TWO_FACTOR_LOGIN_MAX_SECONDS = 60
|
||||
|
|
78
bookwyrm/templates/preferences/2fa.html
Normal file
78
bookwyrm/templates/preferences/2fa.html
Normal file
|
@ -0,0 +1,78 @@
|
|||
{% extends 'preferences/layout.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Two Factor Authentication" %}{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
{% trans "Two Factor Authentication" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block panel %}
|
||||
<div class="block">
|
||||
{% if success %}
|
||||
<div class="notification is-success is-light">
|
||||
<span class="icon icon-check" aria-hidden="true"></span>
|
||||
<span>
|
||||
{% trans "Successfully updated 2FA settings" %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if backup_codes %}
|
||||
<div class="block">
|
||||
<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>{% trans "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 %}
|
||||
<form name="confirm-2fa" action="{% url 'conf-2fa' %}" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<p>{% trans "Scan the QR code with your authentication app and then enter the code from your app below to confirm your app is set up." %}</p>
|
||||
<div class="columns">
|
||||
<section class="column is-narrow">
|
||||
<figure class="m-4">{{ qrcode | safe }}</figure>
|
||||
<div class="field">
|
||||
<label class="label" for="id_otp">{% trans "Enter the code from your app:" %}</label>
|
||||
{{ form.otp }}
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.otp.errors id="desc_otp" %}
|
||||
</div>
|
||||
<button class="button is-primary" type="submit">{% trans "Confirm" %}</button>
|
||||
</section>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<p>
|
||||
{% trans "You can make your account more secure by using Two Factor Authentication (2FA). This will require you to enter a one-time code using a phone app like <em>Authy</em>, <em>Google Authenticator</em> or <em>Microsoft Authenticator</em> each time you log in." %}
|
||||
</p>
|
||||
<p> {% trans "Confirm your password to begin setting up 2FA." %}</p>
|
||||
<div class="columns">
|
||||
<div class="column is-one-third">
|
||||
<form name="confirm-password" action="{% url 'prefs-2fa' %}" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label class="label" for="id_password">{% trans "Password:" %}</label>
|
||||
{{ form.password }}
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.password.errors id="desc_password" %}
|
||||
</div>
|
||||
<button class="button is-primary" type="submit">{% trans "Set up 2FA" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
23
bookwyrm/templates/preferences/disable-2fa.html
Normal file
23
bookwyrm/templates/preferences/disable-2fa.html
Normal file
|
@ -0,0 +1,23 @@
|
|||
{% extends 'preferences/layout.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Disable 2FA" %}{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
{% trans "Disable 2FA" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block panel %}
|
||||
<div class="block">
|
||||
<h2 class="title is-4">{% trans "Disable Two Factor Authentication" %}</h2>
|
||||
<p class="notification is-danger is-light">
|
||||
{% trans "Disabling 2FA will allow anyone with your username and password to log in to your account." %}
|
||||
</p>
|
||||
|
||||
<form name="disable-2fa" action="{% url 'disable-2fa' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<a class="button" href="{% url 'prefs-2fa' %}">{% trans "Cancel" %}</a>
|
||||
<button type="submit" class="button is-danger">{% trans "Turn off 2FA" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -19,6 +19,10 @@
|
|||
{% url 'prefs-password' as url %}
|
||||
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Change Password" %}</a>
|
||||
</li>
|
||||
<li>
|
||||
{% url 'prefs-2fa' as url %}
|
||||
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Two Factor Authentication" %}</a>
|
||||
</li>
|
||||
<li>
|
||||
{% url 'prefs-delete' as url %}
|
||||
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Delete Account" %}</a>
|
||||
|
|
49
bookwyrm/templates/snippets/2fa_footer.html
Normal file
49
bookwyrm/templates/snippets/2fa_footer.html
Normal file
|
@ -0,0 +1,49 @@
|
|||
{% load i18n %}
|
||||
|
||||
<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>
|
49
bookwyrm/templates/two_factor_auth/two_factor_login.html
Normal file
49
bookwyrm/templates/two_factor_auth/two_factor_login.html
Normal file
|
@ -0,0 +1,49 @@
|
|||
{% 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>
|
||||
<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.otp.errors id="desc_otp" %}
|
||||
</div>
|
||||
<button class="button is-primary" type="submit">{% trans "Confirm and Log In" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'snippets/2fa_footer.html' %}
|
||||
</body>
|
||||
</html>
|
45
bookwyrm/templates/two_factor_auth/two_factor_prompt.html
Normal file
45
bookwyrm/templates/two_factor_auth/two_factor_prompt.html
Normal file
|
@ -0,0 +1,45 @@
|
|||
{% 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 hero is-halfheight is-vcentered">
|
||||
<div class="column is-one-third m-4">
|
||||
<header class="block">
|
||||
{% block header %}
|
||||
<h1 class="title">
|
||||
{% trans "2FA is available" %}
|
||||
</h1>
|
||||
{% endblock %}
|
||||
</header>
|
||||
<div class="is-centered p-2">
|
||||
<p class="block">{% trans "You can secure your account by setting up two factor authentication in your user preferences. This will require a one-time code from your phone in addition to your password each time you log in." %}</p>
|
||||
<div class="block has-text-centered">
|
||||
<a class="button" href="/">{% trans "No thanks" %}</a>
|
||||
<a class="button is-primary" href="/preferences/2fa">{% trans "Set up 2FA" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'snippets/2fa_footer.html' %}
|
||||
</body>
|
||||
</html>
|
|
@ -2,6 +2,7 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
from django.template.response import TemplateResponse
|
||||
from django.test import TestCase
|
||||
from django.test.client import RequestFactory
|
||||
|
@ -28,10 +29,25 @@ class LoginViews(TestCase):
|
|||
"password",
|
||||
local=True,
|
||||
localname="mouse",
|
||||
two_factor_auth=False,
|
||||
)
|
||||
self.rat = models.User.objects.create_user(
|
||||
"rat@your.domain.here",
|
||||
"rat@rat.com",
|
||||
"password",
|
||||
local=True,
|
||||
localname="rat",
|
||||
)
|
||||
self.badger = models.User.objects.create_user(
|
||||
"badger@your.domain.here",
|
||||
"badger@badger.com",
|
||||
"password",
|
||||
local=True,
|
||||
localname="badger",
|
||||
two_factor_auth=True,
|
||||
)
|
||||
self.anonymous_user = AnonymousUser
|
||||
self.anonymous_user.is_authenticated = False
|
||||
|
||||
models.SiteSettings.objects.create(id=1, require_confirm_email=False)
|
||||
|
||||
def test_login_get(self, *_):
|
||||
|
@ -109,3 +125,34 @@ class LoginViews(TestCase):
|
|||
result.context_data["login_form"].non_field_errors,
|
||||
"Username or password are incorrect",
|
||||
)
|
||||
|
||||
def test_login_post_no_2fa_set(self, *_):
|
||||
"""test user with 2FA null value is redirected to 2FA prompt page"""
|
||||
view = views.Login.as_view()
|
||||
form = forms.LoginForm()
|
||||
form.data["localname"] = "rat"
|
||||
form.data["password"] = "password"
|
||||
request = self.factory.post("", form.data)
|
||||
request.user = self.anonymous_user
|
||||
|
||||
with patch("bookwyrm.views.landing.login.login"):
|
||||
result = view(request)
|
||||
self.assertEqual(result.url, "/2fa-prompt")
|
||||
self.assertEqual(result.status_code, 302)
|
||||
|
||||
def test_login_post_with_2fa(self, *_):
|
||||
"""test user with 2FA turned on is redirected to 2FA login page"""
|
||||
view = views.Login.as_view()
|
||||
form = forms.LoginForm()
|
||||
form.data["localname"] = "badger"
|
||||
form.data["password"] = "password"
|
||||
request = self.factory.post("", form.data)
|
||||
request.user = self.anonymous_user
|
||||
middleware = SessionMiddleware(request)
|
||||
middleware.process_request(request)
|
||||
request.session.save()
|
||||
|
||||
with patch("bookwyrm.views.landing.login.login"):
|
||||
result = view(request)
|
||||
self.assertEqual(result.url, "/2fa-check")
|
||||
self.assertEqual(result.status_code, 302)
|
||||
|
|
195
bookwyrm/tests/views/preferences/test_two_factor_auth.py
Normal file
195
bookwyrm/tests/views/preferences/test_two_factor_auth.py
Normal file
|
@ -0,0 +1,195 @@
|
|||
""" test for app two factor auth functionality """
|
||||
from unittest.mock import patch
|
||||
import time
|
||||
import pyotp
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
from django.template.response import TemplateResponse
|
||||
from django.test import TestCase
|
||||
from django.test.client import RequestFactory
|
||||
|
||||
from bookwyrm import forms, models, views
|
||||
|
||||
# pylint: disable=too-many-public-methods
|
||||
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
|
||||
@patch("bookwyrm.activitystreams.populate_stream_task.delay")
|
||||
class TwoFactorViews(TestCase):
|
||||
"""Two Factor Authentication management"""
|
||||
|
||||
def setUp(self):
|
||||
"""we need basic test data and mocks"""
|
||||
self.factory = RequestFactory()
|
||||
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
|
||||
"bookwyrm.activitystreams.populate_stream_task.delay"
|
||||
), patch("bookwyrm.lists_stream.populate_lists_task.delay"):
|
||||
self.local_user = models.User.objects.create_user(
|
||||
"mouse@your.domain.here",
|
||||
"mouse@mouse.com",
|
||||
"password",
|
||||
local=True,
|
||||
localname="mouse",
|
||||
two_factor_auth=True,
|
||||
otp_secret="UEWMVJHO23G5XLMVSOCL6TNTSSACJH2X",
|
||||
hotp_secret="DRMNMOU7ZRKH5YPW7PADOEYUF7MRIH46",
|
||||
hotp_count=0,
|
||||
)
|
||||
self.anonymous_user = AnonymousUser
|
||||
self.anonymous_user.is_authenticated = False
|
||||
|
||||
def test_get_edit_2fa(self, *_):
|
||||
"""there are so many views, this just makes sure it LOADS"""
|
||||
view = views.Edit2FA.as_view()
|
||||
request = self.factory.get("")
|
||||
request.user = self.local_user
|
||||
result = view(request)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
def test_get_edit_2fa_logged_out(self, *_):
|
||||
"""there are so many views, this just makes sure it LOADS"""
|
||||
view = views.Edit2FA.as_view()
|
||||
request = self.factory.get("")
|
||||
request.user = self.anonymous_user
|
||||
result = view(request)
|
||||
self.assertEqual(result.status_code, 302)
|
||||
|
||||
def test_post_edit_2fa(self, *_):
|
||||
"""there are so many views, this just makes sure it LOADS"""
|
||||
view = views.Edit2FA.as_view()
|
||||
form = forms.ConfirmPasswordForm()
|
||||
form.data["password"] = "password"
|
||||
request = self.factory.post("", form.data)
|
||||
request.user = self.local_user
|
||||
|
||||
with patch("bookwyrm.views.preferences.two_factor_auth.Edit2FA"):
|
||||
result = view(request)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
def test_post_confirm_2fa(self, *_):
|
||||
"""check 2FA login works"""
|
||||
view = views.Confirm2FA.as_view()
|
||||
form = forms.Confirm2FAForm()
|
||||
totp = pyotp.TOTP("UEWMVJHO23G5XLMVSOCL6TNTSSACJH2X")
|
||||
form.data["otp"] = totp.now()
|
||||
request = self.factory.post("", form.data)
|
||||
request.user = self.local_user
|
||||
|
||||
with patch("bookwyrm.views.preferences.two_factor_auth.Confirm2FA"):
|
||||
result = view(request)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
def test_get_disable_2fa(self, *_):
|
||||
"""there are so many views, this just makes sure it LOADS"""
|
||||
view = views.Disable2FA.as_view()
|
||||
request = self.factory.get("")
|
||||
request.user = self.local_user
|
||||
result = view(request)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
def test_post_disable_2fa(self, *_):
|
||||
"""check 2FA login works"""
|
||||
view = views.Disable2FA.as_view()
|
||||
request = self.factory.post("")
|
||||
request.user = self.local_user
|
||||
|
||||
with patch("bookwyrm.views.preferences.two_factor_auth.Disable2FA"):
|
||||
result = view(request)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
def test_get_login_with_2fa(self, *_):
|
||||
"""there are so many views, this just makes sure it LOADS"""
|
||||
view = views.LoginWith2FA.as_view()
|
||||
request = self.factory.get("")
|
||||
request.user = self.local_user
|
||||
result = view(request)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
def test_post_login_with_2fa(self, *_):
|
||||
"""check 2FA login works"""
|
||||
view = views.LoginWith2FA.as_view()
|
||||
form = forms.Confirm2FAForm()
|
||||
totp = pyotp.TOTP("UEWMVJHO23G5XLMVSOCL6TNTSSACJH2X")
|
||||
|
||||
form.data["otp"] = totp.now()
|
||||
request = self.factory.post("", form.data)
|
||||
request.user = self.local_user
|
||||
|
||||
middleware = SessionMiddleware(request)
|
||||
middleware.process_request(request)
|
||||
request.session["2fa_auth_time"] = time.time()
|
||||
request.session["2fa_user"] = self.local_user.username
|
||||
request.session.save()
|
||||
|
||||
with patch("bookwyrm.views.preferences.two_factor_auth.LoginWith2FA"), patch(
|
||||
"bookwyrm.views.preferences.two_factor_auth.login"
|
||||
):
|
||||
result = view(request)
|
||||
self.assertEqual(result.url, "/")
|
||||
self.assertEqual(result.status_code, 302)
|
||||
self.assertTrue(request.user.is_authenticated)
|
||||
|
||||
def test_post_login_with_2fa_wrong_code(self, *_):
|
||||
"""check 2FA login fails"""
|
||||
view = views.LoginWith2FA.as_view()
|
||||
form = forms.Confirm2FAForm()
|
||||
form.data["otp"] = "111111"
|
||||
request = self.factory.post("", form.data)
|
||||
request.user = self.local_user
|
||||
|
||||
middleware = SessionMiddleware(request)
|
||||
middleware.process_request(request)
|
||||
request.session["2fa_auth_time"] = time.time()
|
||||
request.session["2fa_user"] = self.local_user.username
|
||||
request.session.save()
|
||||
|
||||
with patch("bookwyrm.views.preferences.two_factor_auth.LoginWith2FA"):
|
||||
result = view(request)
|
||||
self.assertEqual(result.status_code, 200)
|
||||
self.assertEqual(
|
||||
result.context_data["form"]["otp"].errors[0],
|
||||
"Incorrect code",
|
||||
)
|
||||
|
||||
def test_post_login_with_2fa_expired(self, *_):
|
||||
"""check 2FA login fails"""
|
||||
view = views.LoginWith2FA.as_view()
|
||||
form = forms.Confirm2FAForm()
|
||||
totp = pyotp.TOTP("UEWMVJHO23G5XLMVSOCL6TNTSSACJH2X")
|
||||
|
||||
form.data["otp"] = totp.now()
|
||||
request = self.factory.post("", form.data)
|
||||
request.user = self.local_user
|
||||
|
||||
middleware = SessionMiddleware(request)
|
||||
middleware.process_request(request)
|
||||
request.session["2fa_user"] = self.local_user.username
|
||||
request.session["2fa_auth_time"] = "1663977030"
|
||||
|
||||
with patch("bookwyrm.views.preferences.two_factor_auth.LoginWith2FA"):
|
||||
result = view(request)
|
||||
self.assertEqual(result.url, "/")
|
||||
self.assertEqual(result.status_code, 302)
|
||||
|
||||
def test_get_generate_backup_codes(self, *_):
|
||||
"""there are so many views, this just makes sure it LOADS"""
|
||||
view = views.GenerateBackupCodes.as_view()
|
||||
request = self.factory.get("")
|
||||
request.user = self.local_user
|
||||
result = view(request)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
def test_get_prompt_2fa(self, *_):
|
||||
"""there are so many views, this just makes sure it LOADS"""
|
||||
view = views.Prompt2FA.as_view()
|
||||
request = self.factory.get("")
|
||||
request.user = self.local_user
|
||||
result = view(request)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.status_code, 200)
|
|
@ -479,6 +479,36 @@ urlpatterns = [
|
|||
views.ChangePassword.as_view(),
|
||||
name="prefs-password",
|
||||
),
|
||||
re_path(
|
||||
r"^preferences/2fa/?$",
|
||||
views.Edit2FA.as_view(),
|
||||
name="prefs-2fa",
|
||||
),
|
||||
re_path(
|
||||
r"^preferences/2fa-backup-codes/?$",
|
||||
views.GenerateBackupCodes.as_view(),
|
||||
name="generate-2fa-backup-codes",
|
||||
),
|
||||
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"^2fa-check/?$",
|
||||
views.LoginWith2FA.as_view(),
|
||||
name="login-with-2fa",
|
||||
),
|
||||
re_path(
|
||||
r"^2fa-prompt/?$",
|
||||
views.Prompt2FA.as_view(),
|
||||
name="prompt-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"),
|
||||
|
|
|
@ -32,6 +32,14 @@ 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,
|
||||
GenerateBackupCodes,
|
||||
LoginWith2FA,
|
||||
Prompt2FA,
|
||||
)
|
||||
|
||||
# books
|
||||
from .books.books import (
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
""" class views for login/register views """
|
||||
import time
|
||||
|
||||
from django.contrib.auth import authenticate, login, logout
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.shortcuts import redirect
|
||||
|
@ -29,6 +31,7 @@ class Login(View):
|
|||
}
|
||||
return TemplateResponse(request, "landing/login.html", data)
|
||||
|
||||
# pylint: disable=too-many-return-statements
|
||||
@sensitive_variables("password")
|
||||
@method_decorator(sensitive_post_parameters("password"))
|
||||
def post(self, request):
|
||||
|
@ -51,11 +54,26 @@ class Login(View):
|
|||
# perform authentication
|
||||
user = authenticate(request, username=username, password=password)
|
||||
if user is not None:
|
||||
# successful login
|
||||
# if 2fa is set, don't log them in until they enter the right code
|
||||
if user.two_factor_auth:
|
||||
request.session["2fa_user"] = user.username
|
||||
request.session["2fa_auth_time"] = time.time()
|
||||
return redirect("login-with-2fa")
|
||||
|
||||
# otherwise, successful login
|
||||
login(request, user)
|
||||
user.update_active_date()
|
||||
if request.POST.get("first_login"):
|
||||
return set_language(user, redirect("get-started-profile"))
|
||||
|
||||
if user.two_factor_auth is None:
|
||||
# set to false so this page doesn't pop up again
|
||||
user.two_factor_auth = False
|
||||
user.save(broadcast=False, update_fields=["two_factor_auth"])
|
||||
|
||||
# show the 2fa prompt page
|
||||
return set_language(user, redirect("prompt-2fa"))
|
||||
|
||||
return set_language(user, redirect("/"))
|
||||
|
||||
# maybe the user is pending email confirmation
|
||||
|
|
177
bookwyrm/views/preferences/two_factor_auth.py
Normal file
177
bookwyrm/views/preferences/two_factor_auth.py
Normal file
|
@ -0,0 +1,177 @@
|
|||
""" class views for 2FA management """
|
||||
from datetime import datetime, timedelta
|
||||
import pyotp
|
||||
import qrcode
|
||||
import qrcode.image.svg
|
||||
|
||||
from django.contrib.auth import login
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.http import HttpResponseBadRequest
|
||||
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_post_parameters
|
||||
|
||||
from bookwyrm import forms, models
|
||||
from bookwyrm.settings import DOMAIN, TWO_FACTOR_LOGIN_MAX_SECONDS
|
||||
from bookwyrm.views.helpers import set_language
|
||||
|
||||
# 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)
|
||||
if not form.is_valid():
|
||||
data = {"form": form}
|
||||
return TemplateResponse(request, "preferences/2fa.html", data)
|
||||
qr_form = forms.Confirm2FAForm()
|
||||
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_code = qrcode.QRCode(image_factory=qrcode.image.svg.SvgPathImage)
|
||||
qr_code.add_data(provisioning_url)
|
||||
qr_code.make(fit=True)
|
||||
img = qr_code.make_image(attrib={"fill": "black"})
|
||||
return str(img.to_string(), "utf-8") # to_string() returns a byte string
|
||||
|
||||
|
||||
@method_decorator(login_required, name="dispatch")
|
||||
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)
|
||||
|
||||
if not form.is_valid():
|
||||
data = {
|
||||
"password_confirmed": True,
|
||||
"qrcode": Edit2FA.create_qr_code(self, request.user),
|
||||
"form": form,
|
||||
}
|
||||
return TemplateResponse(request, "preferences/2fa.html", data)
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
@method_decorator(login_required, name="dispatch")
|
||||
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)
|
||||
|
||||
|
||||
class LoginWith2FA(View):
|
||||
"""Check 2FA code matches before allowing login"""
|
||||
|
||||
def get(self, request):
|
||||
"""Display 2FA form"""
|
||||
data = {"form": forms.Confirm2FAForm()}
|
||||
return TemplateResponse(request, "two_factor_auth/two_factor_login.html", data)
|
||||
|
||||
def post(self, request):
|
||||
"""Check 2FA code and allow/disallow login"""
|
||||
try:
|
||||
user = models.User.objects.get(username=request.session.get("2fa_user"))
|
||||
except ObjectDoesNotExist:
|
||||
request.session["2fa_auth_time"] = 0
|
||||
return HttpResponseBadRequest("Invalid user")
|
||||
|
||||
session_time = (
|
||||
int(request.session["2fa_auth_time"])
|
||||
if request.session["2fa_auth_time"]
|
||||
else 0
|
||||
)
|
||||
elapsed_time = datetime.now() - datetime.fromtimestamp(session_time)
|
||||
form = forms.Confirm2FAForm(request.POST, instance=user)
|
||||
# don't allow the login credentials to last too long before completing login
|
||||
if elapsed_time > timedelta(seconds=TWO_FACTOR_LOGIN_MAX_SECONDS):
|
||||
request.session["2fa_user"] = None
|
||||
request.session["2fa_auth_time"] = 0
|
||||
return redirect("/")
|
||||
if not form.is_valid():
|
||||
data = {"form": form, "2fa_user": user}
|
||||
return TemplateResponse(
|
||||
request, "two_factor_auth/two_factor_login.html", data
|
||||
)
|
||||
|
||||
# log the user in - we are bypassing standard login
|
||||
login(request, user)
|
||||
user.update_active_date()
|
||||
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):
|
||||
"""Alert user to the existence of 2FA"""
|
||||
|
||||
def get(self, request):
|
||||
"""Alert user to the existence of 2FA"""
|
||||
return TemplateResponse(request, "two_factor_auth/two_factor_prompt.html")
|
|
@ -7,7 +7,7 @@ upstream web {
|
|||
server {
|
||||
listen 80;
|
||||
|
||||
location ~ ^/(login|password-reset|resend-link) {
|
||||
location ~ ^/(login[^-]|password-reset|resend-link|2fa-check) {
|
||||
limit_req zone=loginlimit;
|
||||
|
||||
proxy_pass http://web;
|
||||
|
|
|
@ -41,7 +41,7 @@ server {
|
|||
# root /var/www/certbot;
|
||||
# }
|
||||
#
|
||||
# location ~ ^/(login|password-reset|resend-link) {
|
||||
# location ~ ^/(login[^-]|password-reset|resend-link|2fa-check) {
|
||||
# limit_req zone=loginlimit;
|
||||
#
|
||||
# proxy_pass http://web;
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue