Merge pull request #2294 from hughrun/otp

Enable optional 2FA
This commit is contained in:
Mouse Reeve 2022-10-20 20:40:00 -07:00 committed by GitHub
commit c375e842ad
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 827 additions and 5 deletions

View file

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

View file

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

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

View file

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

View file

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

View 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 %}

View 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 %}

View file

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

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

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

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

View file

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

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

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