diff --git a/bookwyrm/forms/edit_user.py b/bookwyrm/forms/edit_user.py index a291c6441..ce7bb6d07 100644 --- a/bookwyrm/forms/edit_user.py +++ b/bookwyrm/forms/edit_user.py @@ -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")) diff --git a/bookwyrm/forms/landing.py b/bookwyrm/forms/landing.py index a31e8a7c4..d5f1b196f 100644 --- a/bookwyrm/forms/landing.py +++ b/bookwyrm/forms/landing.py @@ -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")) diff --git a/bookwyrm/migrations/0159_auto_20220924_0634.py b/bookwyrm/migrations/0159_auto_20220924_0634.py new file mode 100644 index 000000000..c223d9061 --- /dev/null +++ b/bookwyrm/migrations/0159_auto_20220924_0634.py @@ -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), + ), + ] diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py index 055941d8c..79f6fafbd 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -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""" diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index 44bf84466..7e6afbc6b 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -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 diff --git a/bookwyrm/templates/preferences/2fa.html b/bookwyrm/templates/preferences/2fa.html new file mode 100644 index 000000000..b0703bc4a --- /dev/null +++ b/bookwyrm/templates/preferences/2fa.html @@ -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 %} +
+ {% if success %} +
+ + + {% trans "Successfully updated 2FA settings" %} + +
+ {% endif %} + {% if backup_codes %} +
+

Backup codes

+
+

{% trans "Write down or copy and paste these codes somewhere safe." %}

+

{% trans "You must use them in order, and they will not be displayed again." %}

+
+ +
+ {% elif request.user.two_factor_auth %} +
+

{% trans "Two Factor Authentication is active on your account." %}

+ {% trans "Disable 2FA" %} +
+
+

{% 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." %}

+ {% trans "Generate backup codes" %} +
+ {% elif password_confirmed %} +
+ {% csrf_token %} +

{% 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." %}

+
+
+
{{ qrcode | safe }}
+
+ + {{ form.otp }} + {% include 'snippets/form_errors.html' with errors_list=form.otp.errors id="desc_otp" %} +
+ +
+
+
+ {% else %} +

+ {% 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 Authy, Google Authenticator or Microsoft Authenticator each time you log in." %} +

+

{% trans "Confirm your password to begin setting up 2FA." %}

+
+
+
+ {% csrf_token %} +
+ + {{ form.password }} + {% include 'snippets/form_errors.html' with errors_list=form.password.errors id="desc_password" %} +
+ +
+
+
+ {% endif %} +
+{% endblock %} diff --git a/bookwyrm/templates/preferences/disable-2fa.html b/bookwyrm/templates/preferences/disable-2fa.html new file mode 100644 index 000000000..dbb980852 --- /dev/null +++ b/bookwyrm/templates/preferences/disable-2fa.html @@ -0,0 +1,23 @@ +{% extends 'preferences/layout.html' %} +{% load i18n %} + +{% block title %}{% trans "Disable 2FA" %}{% endblock %} + +{% block header %} +{% trans "Disable 2FA" %} +{% endblock %} + +{% block panel %} +
+

{% trans "Disable Two Factor Authentication" %}

+

+ {% trans "Disabling 2FA will allow anyone with your username and password to log in to your account." %} +

+ +
+ {% csrf_token %} + {% trans "Cancel" %} + +
+
+{% endblock %} diff --git a/bookwyrm/templates/preferences/layout.html b/bookwyrm/templates/preferences/layout.html index 27d91c480..ca63ec93d 100644 --- a/bookwyrm/templates/preferences/layout.html +++ b/bookwyrm/templates/preferences/layout.html @@ -19,6 +19,10 @@ {% url 'prefs-password' as url %} {% trans "Change Password" %} +
  • + {% url 'prefs-2fa' as url %} + {% trans "Two Factor Authentication" %} +
  • {% url 'prefs-delete' as url %} {% trans "Delete Account" %} diff --git a/bookwyrm/templates/snippets/2fa_footer.html b/bookwyrm/templates/snippets/2fa_footer.html new file mode 100644 index 000000000..22c1fd1c5 --- /dev/null +++ b/bookwyrm/templates/snippets/2fa_footer.html @@ -0,0 +1,49 @@ +{% load i18n %} + + diff --git a/bookwyrm/templates/two_factor_auth/two_factor_login.html b/bookwyrm/templates/two_factor_auth/two_factor_login.html new file mode 100644 index 000000000..9df9d9f38 --- /dev/null +++ b/bookwyrm/templates/two_factor_auth/two_factor_login.html @@ -0,0 +1,49 @@ +{% load layout %} +{% load sass_tags %} +{% load i18n %} +{% load static %} + + + + + {% block title %}BookWyrm{% endblock %} - {{ site.name }} + + + + + +
    +
    +
    +
    + {% block header %} +

    + {% trans "2FA check" %} +

    + {% endblock %} +
    +
    +
    + {% csrf_token %} +
    + + {{ form.otp }} + {% include 'snippets/form_errors.html' with errors_list=form.otp.errors id="desc_otp" %} +
    + +
    +
    +
    +
    +
    + {% include 'snippets/2fa_footer.html' %} + + diff --git a/bookwyrm/templates/two_factor_auth/two_factor_prompt.html b/bookwyrm/templates/two_factor_auth/two_factor_prompt.html new file mode 100644 index 000000000..eb9257895 --- /dev/null +++ b/bookwyrm/templates/two_factor_auth/two_factor_prompt.html @@ -0,0 +1,45 @@ +{% load layout %} +{% load sass_tags %} +{% load i18n %} +{% load static %} + + + + + {% block title %}BookWyrm{% endblock %} - {{ site.name }} + + + + + +
    +
    +
    +
    + {% block header %} +

    + {% trans "2FA is available" %} +

    + {% endblock %} +
    +
    +

    {% 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." %}

    + +
    +
    +
    +
    + {% include 'snippets/2fa_footer.html' %} + + diff --git a/bookwyrm/tests/views/landing/test_login.py b/bookwyrm/tests/views/landing/test_login.py index 24987c8ef..6c1188b09 100644 --- a/bookwyrm/tests/views/landing/test_login.py +++ b/bookwyrm/tests/views/landing/test_login.py @@ -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) diff --git a/bookwyrm/tests/views/preferences/test_two_factor_auth.py b/bookwyrm/tests/views/preferences/test_two_factor_auth.py new file mode 100644 index 000000000..ac6bd654c --- /dev/null +++ b/bookwyrm/tests/views/preferences/test_two_factor_auth.py @@ -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) diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py index 449b1d723..3838619e7 100644 --- a/bookwyrm/urls.py +++ b/bookwyrm/urls.py @@ -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"), diff --git a/bookwyrm/views/__init__.py b/bookwyrm/views/__init__.py index 257adc932..f6ed9f4f5 100644 --- a/bookwyrm/views/__init__.py +++ b/bookwyrm/views/__init__.py @@ -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 ( diff --git a/bookwyrm/views/landing/login.py b/bookwyrm/views/landing/login.py index 98a2b6e61..3b7b10f0d 100644 --- a/bookwyrm/views/landing/login.py +++ b/bookwyrm/views/landing/login.py @@ -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 diff --git a/bookwyrm/views/preferences/two_factor_auth.py b/bookwyrm/views/preferences/two_factor_auth.py new file mode 100644 index 000000000..61003777f --- /dev/null +++ b/bookwyrm/views/preferences/two_factor_auth.py @@ -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") diff --git a/nginx/development b/nginx/development index fbb25c1b2..4a7896249 100644 --- a/nginx/development +++ b/nginx/development @@ -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; diff --git a/nginx/production b/nginx/production index 3a3aeb7dd..b74fe409c 100644 --- a/nginx/production +++ b/nginx/production @@ -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; 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