diff --git a/README.md b/README.md index 5a4749e..432db5c 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,9 @@ See [OAuth.md](docs/OAuth.md). See [user_deletion.md](docs/user_deletion.md). +# Multi-factor authentication + +See [mfa.md](docs/mfa.md). # Troubleshooting diff --git a/bid_main/email.py b/bid_main/email.py index 0bf5491..8252261 100644 --- a/bid_main/email.py +++ b/bid_main/email.py @@ -282,3 +282,42 @@ def construct_password_changed(user): subject = "Security alert: password changed" return email_body_txt, subject + + +def construct_mfa_new_device(user, device_type): + context = { + "device_type": device_type, + "support_email": settings.SUPPORT_EMAIL, + "user": user, + } + email_body_txt = loader.render_to_string( + "bid_main/emails/mfa_new_device.txt", context + ) + subject = "Security alert: a new multi-factor authentication device added" + + return email_body_txt, subject + + +def construct_mfa_disabled(user): + context = { + "user": user, + } + email_body_txt = loader.render_to_string( + "bid_main/emails/mfa_disabled.txt", context + ) + subject = "Security alert: multi-factor authentication disabled" + + return email_body_txt, subject + + +def construct_mfa_recovery_used(user): + context = { + "support_email": settings.SUPPORT_EMAIL, + "user": user, + } + email_body_txt = loader.render_to_string( + "bid_main/emails/mfa_recovery_used.txt", context + ) + subject = "Security alert: recovery code used" + + return email_body_txt, subject diff --git a/bid_main/forms.py b/bid_main/forms.py index fc04797..4a1ac29 100644 --- a/bid_main/forms.py +++ b/bid_main/forms.py @@ -316,7 +316,7 @@ class PasswordChangeForm(BootstrapModelFormMixin, auth_forms.PasswordChangeForm) def save(self, *args, **kwargs): user = super().save(*args, **kwargs) if user.has_confirmed_email: - bid_main.tasks.send_password_changed_email(user_pk=user.pk) + bid_main.tasks.send_mail_password_changed(user_pk=user.pk) return user @@ -335,7 +335,7 @@ class SetPasswordForm(auth_forms.SetPasswordForm): def save(self, *args, **kwargs): user = super().save(*args, **kwargs) if user.has_confirmed_email: - bid_main.tasks.send_password_changed_email(user_pk=user.pk) + bid_main.tasks.send_mail_password_changed(user_pk=user.pk) return user diff --git a/bid_main/models.py b/bid_main/models.py index f2f1d04..137b421 100644 --- a/bid_main/models.py +++ b/bid_main/models.py @@ -1,28 +1,30 @@ +from collections import defaultdict from typing import Optional, Set import itertools import logging import os.path import re -from django import urls from django.conf import settings from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager from django.contrib.auth.models import PermissionsMixin from django.contrib.sessions.models import Session -from django.core import validators from django.core.exceptions import ValidationError +from django.core import validators from django.core.mail import send_mail from django.db import models, transaction from django.db.models import Q +from django import urls from django.templatetags.static import static -from django.utils import timezone from django.utils.deconstruct import deconstructible +from django.utils import timezone from django.utils.translation import gettext_lazy as _ import oauth2_provider.models as oa2_models import user_agents from . import fields from . import hashers +from mfa.models import EncryptedRecoveryDevice, EncryptedTOTPDevice, U2fDevice, devices_for_user import bid_main.file_utils import bid_main.utils @@ -541,6 +543,19 @@ class User(AbstractBaseUser, PermissionsMixin): return bid_main.file_utils.get_absolute_url(static(settings.AVATAR_DEFAULT_FILENAME)) return bid_main.file_utils.get_absolute_url(self.avatar.storage.url(default_thumbnail_path)) + def mfa_devices_per_type(self): + if not hasattr(self, '_mfa_devices'): + devices_per_type = defaultdict(list) + for device in devices_for_user(self): + if isinstance(device, EncryptedRecoveryDevice): + devices_per_type['recovery'].append(device) + if isinstance(device, EncryptedTOTPDevice): + devices_per_type['totp'].append(device) + if isinstance(device, U2fDevice): + devices_per_type['u2f'].append(device) + self._mfa_devices = devices_per_type + return self._mfa_devices + class SettingValueField(models.CharField): def __init__(self, *args, **kwargs): # noqa: D107 diff --git a/bid_main/signals.py b/bid_main/signals.py index 1e9b443..25389c2 100644 --- a/bid_main/signals.py +++ b/bid_main/signals.py @@ -7,6 +7,7 @@ from django.db.models.signals import m2m_changed, post_delete from django.dispatch import receiver from . import models +from mfa.signals import recovery_used import bid_main.utils as utils import bid_main.file_utils import bid_main.tasks @@ -38,7 +39,7 @@ def process_new_login(sender, request, user, **kwargs): fields.update({"last_login_ip", "current_login_ip"}) if user.has_confirmed_email: - bid_main.tasks.send_new_user_session_email( + bid_main.tasks.send_mail_new_user_session( user_pk=user.pk, session_data={ 'device': str(user_session.device or 'Unknown'), @@ -79,3 +80,10 @@ def delete_orphaned_avatar_files(sender, instance, **kwargs): return bid_main.file_utils.delete_avatar_files(instance.avatar.name) + + +@receiver(recovery_used) +def send_mail_mfa_recovery_used(sender, **kwargs): + user = kwargs['device'].user + if user.confirmed_email_at: + bid_main.tasks.send_mail_mfa_recovery_used(user.pk) diff --git a/bid_main/tasks.py b/bid_main/tasks.py index 1e8e86c..d36f5e4 100644 --- a/bid_main/tasks.py +++ b/bid_main/tasks.py @@ -12,7 +12,7 @@ log = logging.getLogger(__name__) @background(schedule={'action': TaskSchedule.RESCHEDULE_EXISTING}) -def send_new_user_session_email(user_pk, session_data): +def send_mail_new_user_session(user_pk, session_data): user = User.objects.get(pk=user_pk) log.info("sending a new user session email for account %s", user.pk) @@ -29,7 +29,7 @@ def send_new_user_session_email(user_pk, session_data): @background(schedule={'action': TaskSchedule.RESCHEDULE_EXISTING}) -def send_password_changed_email(user_pk): +def send_mail_password_changed(user_pk): user = User.objects.get(pk=user_pk) log.info("sending a password change email for account %s", user.pk) @@ -43,3 +43,54 @@ def send_password_changed_email(user_pk): from_email=None, # just use the configured default From-address. recipient_list=[email], ) + + +@background(schedule={'action': TaskSchedule.RESCHEDULE_EXISTING}) +def send_mail_mfa_new_device(user_pk, device_type): + user = User.objects.get(pk=user_pk) + log.info("sending a new mfa device email for account %s", user.pk) + + # sending only a text/plain email to reduce the room for look-alike phishing emails + email_body_txt, subject = bid_main.email.construct_mfa_new_device(user, device_type) + + email = user.email + send_mail( + subject=subject, + message=email_body_txt, + from_email=None, # just use the configured default From-address. + recipient_list=[email], + ) + + +@background(schedule={'action': TaskSchedule.RESCHEDULE_EXISTING}) +def send_mail_mfa_disabled(user_pk): + user = User.objects.get(pk=user_pk) + log.info("sending an mfa disabled email for account %s", user.pk) + + # sending only a text/plain email to reduce the room for look-alike phishing emails + email_body_txt, subject = bid_main.email.construct_mfa_disabled(user) + + email = user.email + send_mail( + subject=subject, + message=email_body_txt, + from_email=None, # just use the configured default From-address. + recipient_list=[email], + ) + + +@background(schedule={'action': TaskSchedule.RESCHEDULE_EXISTING}) +def send_mail_mfa_recovery_used(user_pk): + user = User.objects.get(pk=user_pk) + log.info("sending an mfa recovery used email for account %s", user.pk) + + # sending only a text/plain email to reduce the room for look-alike phishing emails + email_body_txt, subject = bid_main.email.construct_mfa_recovery_used(user) + + email = user.email + send_mail( + subject=subject, + message=email_body_txt, + from_email=None, # just use the configured default From-address. + recipient_list=[email], + ) diff --git a/bid_main/templates/bid_main/components/mfa_form.html b/bid_main/templates/bid_main/components/mfa_form.html new file mode 100644 index 0000000..891e2ee --- /dev/null +++ b/bid_main/templates/bid_main/components/mfa_form.html @@ -0,0 +1,42 @@ +{% load add_form_classes from forms %} +{% load common static %} + +
+
+

Multi-factor Authentication

+
+ {% with form=form|add_form_classes %} +
{% csrf_token %} +
+ {% if mfa_device_type == 'recovery' %} +

Use a recovery code

+ + {% elif mfa_device_type == 'totp' %} + {% if devices.totp|length == 1 %} + + {% else %} +
+ {% for device in devices.totp %} + + {% endfor %} +
+ {% endif %} + {% endif %} + {% include "components/forms/field.html" with field=form.otp_token %} + {% include "components/forms/field.html" with field=form.otp_trust_agent with_help_text=True %} +
+ {{ form.non_field_errors }} + +
+ {% endwith %} + {% if mfa_alternatives %} + + {% endif %} +
diff --git a/bid_main/templates/bid_main/components/u2f_form.html b/bid_main/templates/bid_main/components/u2f_form.html new file mode 100644 index 0000000..6677635 --- /dev/null +++ b/bid_main/templates/bid_main/components/u2f_form.html @@ -0,0 +1,43 @@ +{% load add_form_classes from forms %} +{% load common static %} + +
+
+

Multi-factor Authentication

+
+ {% with form=form|add_form_classes %} +
{% csrf_token %} +
+

Please use a security key you have configured. Tick the checkbox below before using the key if you want to remember this device.

+ {% include "components/forms/field.html" with field=form.otp_trust_agent with_help_text=True %} + {% include "components/forms/field.html" with field=form.response %} + {% include "components/forms/field.html" with field=form.signature %} + {% include "components/forms/field.html" with field=form.state %} +
+ {{ form.non_field_errors }} +
+
+ {% endwith %} + {% if mfa_alternatives %} + + {% endif %} +
+ + diff --git a/bid_main/templates/bid_main/emails/mfa_disabled.txt b/bid_main/templates/bid_main/emails/mfa_disabled.txt new file mode 100644 index 0000000..fe955b3 --- /dev/null +++ b/bid_main/templates/bid_main/emails/mfa_disabled.txt @@ -0,0 +1,9 @@ +{% autoescape off %} +Dear {{ user.full_name|default:user.email }}! + +Multi-factor authentication has been disabled for your Blender ID account {{ user.email }} + +-- +Kind regards, +The Blender Web Team +{% endautoescape %} diff --git a/bid_main/templates/bid_main/emails/mfa_new_device.txt b/bid_main/templates/bid_main/emails/mfa_new_device.txt new file mode 100644 index 0000000..1ea9638 --- /dev/null +++ b/bid_main/templates/bid_main/emails/mfa_new_device.txt @@ -0,0 +1,11 @@ +{% autoescape off %} +Dear {{ user.full_name|default:user.email }}! + +A new {{ device_type }} multi-factor authenticator has been added to your Blender ID account {{ user.email }} + +If this wasn't done by you, please reset your password immediately and contact {{ support_email }} for support. + +-- +Kind regards, +The Blender Web Team +{% endautoescape %} diff --git a/bid_main/templates/bid_main/emails/mfa_recovery_used.txt b/bid_main/templates/bid_main/emails/mfa_recovery_used.txt new file mode 100644 index 0000000..a16f82f --- /dev/null +++ b/bid_main/templates/bid_main/emails/mfa_recovery_used.txt @@ -0,0 +1,11 @@ +{% autoescape off %} +Dear {{ user.full_name|default:user.email }}! + +A recovery code was used to pass multi-factor authentication for your Blender ID account {{ user.email }} + +If this wasn't done by you, please reset your password immediately, re-generate your MFA recovery codes, and contact {{ support_email }} for support. + +-- +Kind regards, +The Blender Web Team +{% endautoescape %} diff --git a/bid_main/templates/bid_main/index.html b/bid_main/templates/bid_main/index.html index d070372..0a5b919 100644 --- a/bid_main/templates/bid_main/index.html +++ b/bid_main/templates/bid_main/index.html @@ -139,6 +139,11 @@ Profile Active Sessions + {% if show_mfa %} + + Multi-factor Authentication + + {% endif %}
diff --git a/bid_main/templates/bid_main/login.html b/bid_main/templates/bid_main/login.html index 8665e5c..13b6bb6 100644 --- a/bid_main/templates/bid_main/login.html +++ b/bid_main/templates/bid_main/login.html @@ -3,5 +3,13 @@ {% block page_title %}Sign in{% endblock %} {% block form %} +{% if form_type == 'login' %} {% include 'bid_main/components/login_form.html' %} +{% elif form_type == 'mfa' %} + {% include 'bid_main/components/mfa_form.html' %} +{% elif form_type == 'u2f' %} + {% include 'bid_main/components/u2f_form.html' %} +{% else %} +
Something went wrong
+{% endif %} {% endblock form %} diff --git a/bid_main/templates/bid_main/mfa/delete_device.html b/bid_main/templates/bid_main/mfa/delete_device.html new file mode 100644 index 0000000..5837909 --- /dev/null +++ b/bid_main/templates/bid_main/mfa/delete_device.html @@ -0,0 +1,20 @@ +{% extends 'layout.html' %} +{% load pipeline static %} +{% load add_form_classes from forms %} +{% block page_title %} +Delete {{ object.name }} +{% endblock %} + +{% block body %} +
+

Delete {{ object.name }}?

+
{% csrf_token %} + {% with form=form|add_form_classes %} + {{ form }} + {% endwith %} + +
+{% endblock %} diff --git a/bid_main/templates/bid_main/mfa/disable.html b/bid_main/templates/bid_main/mfa/disable.html new file mode 100644 index 0000000..ef04bf6 --- /dev/null +++ b/bid_main/templates/bid_main/mfa/disable.html @@ -0,0 +1,23 @@ +{% extends 'layout.html' %} +{% load pipeline static %} +{% load add_form_classes from forms %} +{% block page_title %} +Disable Multi-factor Authentication +{% endblock %} + +{% block body %} +
+

+ You are going to disable multi-factor authentication (MFA). + You can always configure MFA again. +

+
{% csrf_token %} + {% with form=form|add_form_classes %} + {% include "components/forms/field.html" with field=form.disable_mfa_confirm %} + {% endwith %} +
+ + Cancel +
+
+{% endblock %} diff --git a/bid_main/templates/bid_main/mfa/setup.html b/bid_main/templates/bid_main/mfa/setup.html new file mode 100644 index 0000000..3a1e499 --- /dev/null +++ b/bid_main/templates/bid_main/mfa/setup.html @@ -0,0 +1,116 @@ +{% extends 'layout.html' %} +{% load humanize pipeline static %} +{% block page_title %} +Multi-factor Authentication Setup +{% endblock %} + +{% block body %} +
+

Multi-factor Authentication (MFA) Setup

+ {% if user_has_mfa_configured %} +

+ You have configured MFA for your account. + You can disable MFA at any time, but you have to pass the verification using your authentication device or a recovery code. +

+ {% if show_missing_recovery_codes_warning %} +

+ Please make sure that you do not lock yourself out: + generate and store recovery codes as a backup verification method. + If you lose your authenticator device or a security key you can use a recovery code to login and reconfigure your MFA methods. +

+ {% endif %} +

+ Every time you sign-in on a new device you will be asked to pass the MFA verification. + If you use the "remember this device" option, you won't be prompted for MFA verification for that device in the next {{ agent_trust_days }} days. + Verification also expires after {{ agent_inactivity_days }} days of inactivity. +

+
+ Disable MFA +
+ {% else %} +

+ MFA makes your account more secure against account takeover attacks. + You can read more in a guide from Electronic Frontier Foundation. +

+

+ If you have privileged access (admin or moderator) on any of Blender websites, you should setup MFA to avoid potential harm done to other community members through misuse of your account. +

+ {% endif %} +
+ +
+

Time-based one-time password (TOTP)

+

+ Also known as authenticator application. +

+

+ If you don't have an authenticator application, you can choose one from a list of TOTP applications. +

+
    + {% for d in devices_per_type.totp %} +
  • + {{ d.name }} + {% if d.last_used_at %}(Last used {{ d.last_used_at|naturaltime }}){% endif %} +
  • + {% endfor %} +
+ Configure a new TOTP device +
+ +
+

Security keys (U2F, WebAuthn, FIDO2)

+

+ Hardware security keys, e.g. Yubikeys. +

+

+ Blender ID supports these keys only as a second factor and does not provide a passwordless sign-in. +

+
    + {% for d in devices_per_type.u2f %} +
  • + {{ d.name }} + {% if d.last_used_at %}(Last used {{ d.last_used_at|naturaltime }}){% endif %} +
  • + {% endfor %} +
+ Configure a new security key +
+ +{% if user_can_setup_recovery %} +
+

Recovery codes

+

+ Store your recovery codes safely (e.g. in a password manager or use a printed copy) and don't share them. + Each code can be used only once. + You can generate a new set of recovery codes at any time, any remaining old codes will be invalidated. +

+ {% with recovery=devices_per_type.recovery.0 %} + {% if recovery %} +
+ {% with code_count=recovery_codes|length %} + {{ code_count }} recovery code{{ code_count|pluralize }} remaining + {% if display_recovery_codes %} + Hide + {% else %} + Display + {% endif %} +
{% csrf_token %} + +
+ {% if display_recovery_codes %} +
    + {% for code in recovery_codes %} +
  • {{ code }}
  • + {% endfor %} +
+ {% endif %} + {% endwith %} +
+ {% endif %} +
{% csrf_token %} + +
+ {% endwith %} +
+{% endif %} +{% endblock %} diff --git a/bid_main/templates/bid_main/mfa/totp_register.html b/bid_main/templates/bid_main/mfa/totp_register.html new file mode 100644 index 0000000..0d1bb94 --- /dev/null +++ b/bid_main/templates/bid_main/mfa/totp_register.html @@ -0,0 +1,54 @@ +{% extends 'layout.html' %} +{% load pipeline static %} +{% load add_form_classes from forms %} +{% block page_title %} +Multi-factor Authentication Setup +{% endblock %} + +{% block body %} +
+

New TOTP device

+
+
+
+ QR code +
show secret key for manual entry{{ manual_secret_key }}
+
+
+
+
    +
  1. + Open your authenticator app and add a new entry by scanning the QR code from this page. + If you can't scan the QR code, you can enter the secret key manually. +
  2. +
  3. Enter a 6-digit code shown in the authenticator.
  4. +
  5. Pick any device name for your authenticator that you would recognize (e.g. "FreeOTP on my phone") and submit the form.
  6. + {% if first_device %} +
  7. Since this is your first MFA device, you will be promted to enter a new code once more to sign-in using MFA.
  8. + {% endif %} +
+
+
+
+
+ {% with form=form|add_form_classes %} +
{% csrf_token %} + {% include "components/forms/field.html" with field=form.name %} + {% include "components/forms/field.html" with field=form.code %} + {% include "components/forms/field.html" with field=form.key %} + {% include "components/forms/field.html" with field=form.signature %} +
+ + Cancel +
+ {% if form.non_field_errors %} +
+ something went wrong +
+ {% endif %} +
+ {% endwith %} +
+
+
+{% endblock %} diff --git a/bid_main/templates/bid_main/mfa/u2f_register.html b/bid_main/templates/bid_main/mfa/u2f_register.html new file mode 100644 index 0000000..7ce4c36 --- /dev/null +++ b/bid_main/templates/bid_main/mfa/u2f_register.html @@ -0,0 +1,56 @@ +{% extends 'layout.html' %} +{% load pipeline static %} +{% load add_form_classes from forms %} +{% block page_title %} +Multi-factor Authentication Setup +{% endblock %} + +{% block body %} +
+

New U2F device

+
+
+

Please watch setup video if you are not familiar with yubikeys.

+ {% if first_device %} +

Since this is your first MFA device, you will be promted to use your security key immediately after setup to sign-in using MFA.

+ {% endif %} +
+
+
+
+ {% with form=form|add_form_classes %} +
{% csrf_token %} + {% include "components/forms/field.html" with field=form.credential %} + {% include "components/forms/field.html" with field=form.name %} + {% include "components/forms/field.html" with field=form.signature %} + {% include "components/forms/field.html" with field=form.state %} +
+ + Cancel +
+ {% if form.non_field_errors %} +
+ something went wrong +
+ {% endif %} +
+ {% endwith %} +
+
+
+ + +{% endblock %} diff --git a/bid_main/templates/components/forms/field.html b/bid_main/templates/components/forms/field.html index 5d05c08..6854628 100644 --- a/bid_main/templates/components/forms/field.html +++ b/bid_main/templates/components/forms/field.html @@ -4,12 +4,13 @@ {{ field }} {% if not field.is_hidden %} {% include 'components/forms/label.html' with label_class="form-check-label" %} - {% if with_help_text and field.help_text %} - {{ form.new_password1.help_text|safe }} {% endif %} +
+ + {% if with_help_text and field.help_text %} + {{ field.help_text|safe }} {% endif %}
{{ field.errors }}
-
{% else %}
{% if not field.is_hidden %} @@ -18,7 +19,7 @@ {{ field }}
{{ field.errors }}
{% if with_help_text and field.help_text %} - {{ form.new_password1.help_text|safe }} + {{ field.help_text|safe }} {% endif %}
{% endif %} diff --git a/bid_main/templatetags/common.py b/bid_main/templatetags/common.py new file mode 100644 index 0000000..b779b89 --- /dev/null +++ b/bid_main/templatetags/common.py @@ -0,0 +1,12 @@ +from django import template + +register = template.Library() + + +# Credit: https://stackoverflow.com/questions/46026268/ +@register.simple_tag(takes_context=True) +def query_transform(context, **kwargs): + query = context['request'].GET.copy() + for k, v in kwargs.items(): + query[k] = v + return query.urlencode() diff --git a/bid_main/tests/test_mfa.py b/bid_main/tests/test_mfa.py new file mode 100644 index 0000000..8bee3aa --- /dev/null +++ b/bid_main/tests/test_mfa.py @@ -0,0 +1,245 @@ +from unittest.mock import patch +import re + +from django.test.client import Client +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from bid_main.tests.factories import UserFactory +from mfa.models import EncryptedTOTPDevice, devices_for_user + + +@patch( + 'django.contrib.auth.base_user.AbstractBaseUser.check_password', + new=lambda _, pwd: pwd == 'hunter2', +) +@patch( + 'django_otp.oath.TOTP.verify', + new=lambda _, token, *args, **kwargs: int(token) == 123456, +) +class TestMfa(TestCase): + def setUp(self): + self.user = UserFactory( + confirmed_email_at=timezone.now(), + privacy_policy_agreed=timezone.now(), + ) + + def test_no_mfa(self): + client = Client() + response = client.post( + '/login', + {'username': self.user.email, 'password': 'hunter2'}, + follow=True, + ) + # showing account page, after a redirect + self.assertEqual(response.status_code, 200) + self.assertContains(response, '

Account

') + + def test_setup_totp(self): + client = Client() + response = client.post( + '/login', + {'username': self.user.email, 'password': 'hunter2'}, + follow=True, + ) + self.assertEqual(response.status_code, 200) + response = client.get(reverse('bid_main:mfa_totp')) + match = re.search( + r'input type="hidden" name="key" value="([^"]+)"', str(response.content) + ) + key = match.group(1) + match = re.search( + r'input type="hidden" name="signature" value="([^"]+)"', str(response.content) + ) + signature = match.group(1) + response = client.post( + reverse('bid_main:mfa_totp'), + {'name': 'test totp device', 'code': '123456', 'key': key, 'signature': signature}, + follow=True + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(devices_for_user(self.user)[0].name, 'test totp device') + + # emulating a different browser + client = Client() + response = client.post( + '/login', + {'username': self.user.email, 'password': 'hunter2'}, + follow=True, + ) + self.assertEqual(response.status_code, 200) + # haven't reached the account page + self.assertNotContains(response, '

Account

') + self.assertContains(response, 'autocomplete="one-time-code"') + match = re.search( + r'input type="hidden" name="otp_device" value="([^"]+)"', str(response.content) + ) + otp_device = match.group(1) + + response = client.post( + '/login', + {'otp_device': otp_device, 'otp_token': '123456'}, + follow=True, + ) + # have reached the account page + self.assertEqual(response.status_code, 200) + self.assertContains(response, '

Account

') + + def test_recovery_codes(self): + # shortcut: create a totp device via a model + EncryptedTOTPDevice.objects.create(encrypted_key='00', key='', name='totp', user=self.user) + + client = Client() + response = client.post( + '/login', + {'username': self.user.email, 'password': 'hunter2'}, + follow=True, + ) + match = re.search( + r'input type="hidden" name="otp_device" value="([^"]+)"', str(response.content) + ) + otp_device = match.group(1) + response = client.post( + '/login', + {'otp_device': otp_device, 'otp_token': '123456'}, + follow=True, + ) + self.assertContains(response, '

Account

') + + response = client.post(reverse('bid_main:mfa_generate_recovery'), follow=True) + self.assertContains(response, '10 recovery codes remaining') + + # hope that we don't add any other code elements + match = re.search( + r'([0-9A-F]{16})', str(response.content) + ) + recovery_code = match.group(1) + + client = Client() + response = client.post( + '/login', + {'username': self.user.email, 'password': 'hunter2'}, + follow=True, + ) + response = client.get('/login?mfa_device_type=recovery') + match = re.search( + r'input type="hidden" name="otp_device" value="([^"]+)"', str(response.content) + ) + otp_device = match.group(1) + response = client.post( + '/login?mfa_device_type=recovery', + {'otp_device': otp_device, 'otp_token': recovery_code}, + ) + self.assertEqual(response.status_code, 302) + response = client.get(reverse('bid_main:mfa')) + self.assertContains(response, '9 recovery codes remaining') + + # test that can't reuse the same code, repeating the steps + client = Client() + response = client.post( + '/login', + {'username': self.user.email, 'password': 'hunter2'}, + follow=True, + ) + response = client.get('/login?mfa_device_type=recovery') + match = re.search( + r'input type="hidden" name="otp_device" value="([^"]+)"', str(response.content) + ) + otp_device = match.group(1) + response = client.post( + '/login?mfa_device_type=recovery', + {'otp_device': otp_device, 'otp_token': recovery_code}, + ) + self.assertEqual(response.status_code, 200) + + def test_disable_mfa(self): + # shortcut: create a totp device via a model + EncryptedTOTPDevice.objects.create(encrypted_key='00', key='', name='totp', user=self.user) + + client = Client() + response = client.post( + '/login', + {'username': self.user.email, 'password': 'hunter2'}, + follow=True, + ) + match = re.search( + r'input type="hidden" name="otp_device" value="([^"]+)"', str(response.content) + ) + otp_device = match.group(1) + response = client.post( + '/login', + {'otp_device': otp_device, 'otp_token': '123456'}, + follow=True, + ) + + client.post(reverse('bid_main:mfa_disable'), {'disable_mfa_confirm': 'True'}) + + # no mfa requried now + client = Client() + response = client.post( + '/login', + {'username': self.user.email, 'password': 'hunter2'}, + follow=True, + ) + self.assertContains(response, '

Account

') + + def test_remember_device(self): + # shortcut: create a totp device via a model + EncryptedTOTPDevice.objects.create(encrypted_key='00', key='', name='totp', user=self.user) + + client = Client() + response = client.post( + '/login', + {'username': self.user.email, 'password': 'hunter2'}, + follow=True, + ) + match = re.search( + r'input type="hidden" name="otp_device" value="([^"]+)"', str(response.content) + ) + otp_device = match.group(1) + response = client.post( + '/login', + {'otp_device': otp_device, 'otp_token': '123456', 'otp_trust_agent': 'True'}, + follow=True, + ) + + client.get('/logout', follow=True) + + # no mfa requried now, same client + response = client.post( + '/login', + {'username': self.user.email, 'password': 'hunter2'}, + follow=True, + ) + self.assertContains(response, '

Account

') + + def test_dont_remember_device(self): + # shortcut: create a totp device via a model + EncryptedTOTPDevice.objects.create(encrypted_key='00', key='', name='totp', user=self.user) + + client = Client() + response = client.post( + '/login', + {'username': self.user.email, 'password': 'hunter2'}, + follow=True, + ) + match = re.search( + r'input type="hidden" name="otp_device" value="([^"]+)"', str(response.content) + ) + otp_device = match.group(1) + response = client.post( + '/login', + {'otp_device': otp_device, 'otp_token': '123456', 'otp_trust_agent': ''}, + follow=True, + ) + + client.get('/logout', follow=True) + + # no mfa requried now, same client + response = client.post( + '/login', + {'username': self.user.email, 'password': 'hunter2'}, + follow=True, + ) + self.assertNotContains(response, '

Account

') diff --git a/bid_main/tests/test_password_change.py b/bid_main/tests/test_password_change.py index 6d37da7..3a5686d 100644 --- a/bid_main/tests/test_password_change.py +++ b/bid_main/tests/test_password_change.py @@ -12,8 +12,8 @@ import bid_main.tasks @patch( - 'bid_main.tasks.send_password_changed_email', - new=bid_main.tasks.send_password_changed_email.task_function, + 'bid_main.tasks.send_mail_password_changed', + new=bid_main.tasks.send_mail_password_changed.task_function, ) @patch( 'django.contrib.auth.base_user.AbstractBaseUser.check_password', diff --git a/bid_main/tests/test_user_sessions.py b/bid_main/tests/test_user_sessions.py index d4034d4..086a22e 100644 --- a/bid_main/tests/test_user_sessions.py +++ b/bid_main/tests/test_user_sessions.py @@ -50,8 +50,8 @@ class TestActiveSessions(TestCase): class TestNewUserSessionEmail(TestCase): @patch( - 'bid_main.tasks.send_new_user_session_email', - new=bid_main.tasks.send_new_user_session_email.task_function, + 'bid_main.tasks.send_mail_new_user_session', + new=bid_main.tasks.send_mail_new_user_session.task_function, ) @patch( 'django.contrib.auth.base_user.AbstractBaseUser.check_password', diff --git a/bid_main/urls.py b/bid_main/urls.py index 3fdee9d..4fe7168 100644 --- a/bid_main/urls.py +++ b/bid_main/urls.py @@ -3,7 +3,7 @@ from django.urls import reverse_lazy, path, re_path from django.contrib.auth import views as auth_views from . import forms -from .views import normal_pages, registration_email, json_api, developer_applications +from .views import mfa, normal_pages, registration_email, json_api, developer_applications app_name = "bid_main" urlpatterns = [ @@ -146,6 +146,42 @@ urlpatterns = [ normal_pages.TerminateSessionView.as_view(), name='terminate_session', ), + path( + 'mfa/', + mfa.MfaView.as_view(), + name='mfa', + ), + path( + 'mfa/disable/', + mfa.DisableView.as_view(), + name='mfa_disable', + ), + path( + 'mfa/generate-recovery/', + mfa.GenerateRecoveryView.as_view(), + name='mfa_generate_recovery', + ), + path( + 'mfa/invalidate-recovery/', + mfa.InvalidateRecoveryView.as_view(), + name='mfa_invalidate_recovery', + ), + path( + 'mfa/totp/', + mfa.TotpRegisterView.as_view(), + name='mfa_totp', + ), + path( + 'mfa/u2f/', + mfa.U2fRegisterView.as_view(), + name='mfa_u2f', + ), + path( + # using `path` converter because persistent_id contains a slash + 'mfa/delete-device//', + mfa.DeleteDeviceView.as_view(), + name='mfa_delete_device', + ), ] # Only enable this on a dev server: diff --git a/bid_main/views/developer_applications.py b/bid_main/views/developer_applications.py index 80ce134..f33f5cb 100644 --- a/bid_main/views/developer_applications.py +++ b/bid_main/views/developer_applications.py @@ -1,14 +1,14 @@ """Pages for displaying and editing OAuth applications.""" -from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.messages.views import SuccessMessageMixin from django.forms import inlineformset_factory from django.urls import reverse from django.views.generic import ListView from django.views.generic.edit import UpdateView -import bid_main.forms +from bid_main.views import mixins import bid_api.forms import bid_api.models +import bid_main.forms import bid_main.models WebhookFormSet = inlineformset_factory( @@ -28,14 +28,21 @@ class _OwnedOAuth2ApplicationsMixin: return self.request.user.bid_main_oauth2application -class ListApplicationsView(LoginRequiredMixin, _OwnedOAuth2ApplicationsMixin, ListView): +class ListApplicationsView( + mixins.MfaRequiredIfConfiguredMixin, + _OwnedOAuth2ApplicationsMixin, + ListView, +): """List all OAuth 2 applications that currently logged in user is allowed to manage.""" template_name = 'bid_main/developer_applications.html' class EditApplicationView( - LoginRequiredMixin, SuccessMessageMixin, _OwnedOAuth2ApplicationsMixin, UpdateView + mixins.MfaRequiredIfConfiguredMixin, + SuccessMessageMixin, + _OwnedOAuth2ApplicationsMixin, + UpdateView, ): """Edit an OAuth 2 application, if allowed.""" diff --git a/bid_main/views/json_api.py b/bid_main/views/json_api.py index bde6a8c..d67db52 100644 --- a/bid_main/views/json_api.py +++ b/bid_main/views/json_api.py @@ -7,17 +7,17 @@ than via bearer tokens. import logging -from django.contrib.auth.mixins import LoginRequiredMixin from django.http import JsonResponse from django.shortcuts import get_object_or_404 from django.views.generic import View from .. import models +from bid_main.views import mixins log = logging.getLogger(__name__) -class BadgeTogglePrivateView(LoginRequiredMixin, View): +class BadgeTogglePrivateView(mixins.MfaRequiredIfConfiguredMixin, View): """JSON endpoint that toggles 'is_private' flag for badges.""" def post(self, request, *args, **kwargs) -> JsonResponse: diff --git a/bid_main/views/mfa.py b/bid_main/views/mfa.py new file mode 100644 index 0000000..cb9e635 --- /dev/null +++ b/bid_main/views/mfa.py @@ -0,0 +1,193 @@ +from base64 import b32encode, b64encode +from binascii import unhexlify +from io import BytesIO +import json + +from django.conf import settings +from django.db import transaction +from django.http import Http404, HttpResponseBadRequest +from django.shortcuts import redirect +from django.urls import reverse, reverse_lazy +from django.views.generic import TemplateView +from django.views.generic.base import View +from django.views.generic.edit import DeleteView, FormView +from django_otp.models import Device +from django_otp.util import random_hex +from fido2.webauthn import AttestedCredentialData +import qrcode + +from . import mixins +from mfa.fido2 import register_begin +from mfa.forms import DisableMfaForm, TotpRegisterForm, U2fRegisterForm +from mfa.models import EncryptedRecoveryDevice, EncryptedTOTPDevice, U2fDevice, devices_for_user +import bid_main.tasks + + +class MfaView(mixins.MfaRequiredIfConfiguredMixin, TemplateView): + """Mfa setup. + + Important in current implementation: + Don't allow to setup recovery codes unless the user has already configured some other method. + Otherwise MfaRequiredIfConfiguredMixin locks the user out immediately, not giving a chance + to copy the recovery codes. + """ + template_name = "bid_main/mfa/setup.html" + + def get_context_data(self, **kwargs): + user = self.request.user + recovery_codes = [] + show_missing_recovery_codes_warning = False + user_can_setup_recovery = False + devices_per_type = user.mfa_devices_per_type() + if 'recovery' in devices_per_type: + recovery_device = devices_per_type['recovery'][0] + recovery_codes = [t.encrypted_token for t in recovery_device.encryptedtoken_set.all()] + if devices_per_type.keys() - {'recovery'}: + user_can_setup_recovery = True + if user_can_setup_recovery and 'recovery' not in devices_per_type: + show_missing_recovery_codes_warning = True + + return { + 'agent_inactivity_days': settings.AGENT_INACTIVITY_DAYS, + 'agent_trust_days': settings.AGENT_TRUST_DAYS, + 'devices_per_type': devices_per_type, + 'display_recovery_codes': self.request.GET.get('display_recovery_codes'), + 'recovery_codes': recovery_codes, + 'show_missing_recovery_codes_warning': show_missing_recovery_codes_warning, + 'user_can_setup_recovery': user_can_setup_recovery, + 'user_has_mfa_configured': bool(devices_per_type), + } + + +class DisableView(mixins.MfaRequiredMixin, FormView): + form_class = DisableMfaForm + success_url = reverse_lazy('bid_main:mfa') + template_name = "bid_main/mfa/disable.html" + + @transaction.atomic + def form_valid(self, form): + for device in devices_for_user(self.request.user): + device.delete() + if self.request.user.confirmed_email_at: + bid_main.tasks.send_mail_mfa_disabled(self.request.user.pk) + return super().form_valid(form) + + +class GenerateRecoveryView(mixins.MfaRequiredIfConfiguredMixin, View): + @transaction.atomic + def post(self, request, *args, **kwargs): + user = self.request.user + if not list( + filter(lambda d: not isinstance(d, EncryptedRecoveryDevice), devices_for_user(user)) + ): + # Forbid setting up recovery codes unless the user already has some other method + return HttpResponseBadRequest("can't setup recovery codes before other methods") + EncryptedRecoveryDevice.objects.filter(user=user).delete() + device = EncryptedRecoveryDevice.objects.create(name='recovery', user=user) + for _ in range(10): + # https://pages.nist.gov/800-63-3/sp800-63b.html#5122-look-up-secret-verifiers + # don't use less than 64 bits + device.encryptedtoken_set.create(encrypted_token=random_hex(8).upper()) + return redirect(reverse('bid_main:mfa') + '?display_recovery_codes=1#recovery-codes') + + +class InvalidateRecoveryView(mixins.MfaRequiredMixin, View): + def post(self, request, *args, **kwargs): + user = self.request.user + EncryptedRecoveryDevice.objects.filter(user=user).delete() + return redirect('bid_main:mfa') + + +class TotpRegisterView(mixins.MfaRequiredIfConfiguredMixin, FormView): + form_class = TotpRegisterForm + success_url = reverse_lazy('bid_main:mfa') + template_name = "bid_main/mfa/totp_register.html" + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + key = self.request.POST.get('key', random_hex(20)) + kwargs['initial']['key'] = key + kwargs['user'] = self.request.user + return kwargs + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + key = context['form'].initial['key'] + b32key = b32encode(unhexlify(key)).decode('utf-8') + context['manual_secret_key'] = b32key + device = EncryptedTOTPDevice(encrypted_key=key, user=self.request.user) + context['config_url'] = device.config_url + image = qrcode.make( + device.config_url, + border=1, + error_correction=qrcode.constants.ERROR_CORRECT_H, + ) + buf = BytesIO() + image.save(buf) + context['qrcode'] = b64encode(buf.getvalue()).decode('utf-8') + context['first_device'] = not devices_for_user(self.request.user) + return context + + @transaction.atomic + def form_valid(self, form): + form.save() + if self.request.user.confirmed_email_at: + bid_main.tasks.send_mail_mfa_new_device(self.request.user.pk, 'totp') + return super().form_valid(form) + + +class U2fRegisterView(mixins.MfaRequiredIfConfiguredMixin, FormView): + form_class = U2fRegisterForm + success_url = reverse_lazy('bid_main:mfa') + template_name = "bid_main/mfa/u2f_register.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['first_device'] = not devices_for_user(self.request.user) + return context + + def get_form_kwargs(self): + credentials = [ + AttestedCredentialData(d.credential) + for d in U2fDevice.objects.filter(user=self.request.user).all() + ] + rp_id = self.request.get_host().split(':')[0] # remove port, required by webauthn + credential_creation_options, state = register_begin( + rp_id, self.request.user, credentials, + ) + kwargs = super().get_form_kwargs() + kwargs['credential_creation_options'] = json.dumps(dict(credential_creation_options)) + kwargs['rp_id'] = rp_id + kwargs['state'] = dict(state) + kwargs['user'] = self.request.user + return kwargs + + @transaction.atomic + def form_valid(self, form): + form.save() + if self.request.user.confirmed_email_at: + bid_main.tasks.send_mail_mfa_new_device(self.request.user.pk, 'u2f') + return super().form_valid(form) + + +class DeleteDeviceView(mixins.MfaRequiredMixin, DeleteView): + model = Device + template_name = "bid_main/mfa/delete_device.html" + success_url = reverse_lazy('bid_main:mfa') + + def get(self, request, *args, **kwargs): + for device in devices_for_user(self.request.user): + if ( + device.persistent_id != kwargs['persistent_id'] + and not isinstance(device, EncryptedRecoveryDevice) + ): + # there are other non-recovery devices, it's fine to delete this one + return super().get(request, *args, **kwargs) + # this is the last non-recovery device, we are effectively disabling mfa + return redirect('bid_main:mfa_disable') + + def get_object(self, queryset=None): + device = Device.from_persistent_id(self.kwargs['persistent_id']) + if not device or self.request.user != device.user: + raise Http404() + return device diff --git a/bid_main/views/mixins.py b/bid_main/views/mixins.py index 0ec2986..da4be64 100644 --- a/bid_main/views/mixins.py +++ b/bid_main/views/mixins.py @@ -2,7 +2,9 @@ import logging import urllib.parse +from django.contrib.auth.mixins import AccessMixin from django.urls import reverse_lazy +from otp_agents.decorators import otp_required log = logging.getLogger(__name__) @@ -35,3 +37,23 @@ class RedirectToPrivacyAgreeMixin: redirect_to = f"{self.privacy_policy_agree_url}?{next_url_qs}" log.debug("Directing user to %s", redirect_to) return redirect_to + + +class MfaRequiredIfConfiguredMixin(AccessMixin): + """Use this mixin instead of LoginRequiredMixin for bid_main.views.""" + + def dispatch(self, request, *args, **kwargs): + if not request.user.is_authenticated: + return self.handle_no_permission() + decorator = otp_required(accept_trusted_agent=True, if_configured=True) + return decorator(super().dispatch)(request, *args, **kwargs) + + +class MfaRequiredMixin(AccessMixin): + """This mixin ensures an mfa check within the session.""" + + def dispatch(self, request, *args, **kwargs): + if not request.user.is_authenticated: + return self.handle_no_permission() + decorator = otp_required(accept_trusted_agent=False, if_configured=False) + return decorator(super().dispatch)(request, *args, **kwargs) diff --git a/bid_main/views/normal_pages.py b/bid_main/views/normal_pages.py index acef774..e13ab7f 100644 --- a/bid_main/views/normal_pages.py +++ b/bid_main/views/normal_pages.py @@ -4,12 +4,12 @@ No error handlers, no usually-one-off things like registration and email confirmation. """ +import json import logging import urllib.parse from django.conf import settings from django.contrib.auth import views as auth_views, logout, get_user_model -from django.contrib.auth.mixins import LoginRequiredMixin from django.core.exceptions import ValidationError from django.utils.translation import gettext as _ from django.db import transaction, IntegrityError @@ -20,24 +20,30 @@ from django.urls import reverse_lazy from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.cache import never_cache -from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.csrf import csrf_protect from django.views.decorators.debug import sensitive_post_parameters from django.views.generic import TemplateView, FormView from django.views.generic.base import View from django.views.generic.edit import UpdateView +from django_otp import user_has_device +from fido2.webauthn import AttestedCredentialData +import django_otp.views import loginas.utils import oauth2_provider.models as oauth2_models from .. import forms, email from . import mixins from bid_main.email import send_verify_address +from bid_main.templatetags.common import query_transform +from mfa.fido2 import authenticate_begin +from mfa.forms import MfaAuthenticateForm, U2fAuthenticateForm import bid_main.file_utils User = get_user_model() log = logging.getLogger(__name__) -class IndexView(LoginRequiredMixin, mixins.PageIdMixin, TemplateView): +class IndexView(mixins.MfaRequiredIfConfiguredMixin, mixins.PageIdMixin, TemplateView): page_id = "index" template_name = "bid_main/index.html" login_url = reverse_lazy("bid_main:login") @@ -61,34 +67,96 @@ class IndexView(LoginRequiredMixin, mixins.PageIdMixin, TemplateView): name for name, roles in self.BID_APP_TO_ROLES.items() if roles.intersection(role_names) } + show_mfa = ( + user.mfa_devices_per_type + or (user.email.endswith('@blender.org') and user.confirmed_email_at) + ) + return { **super().get_context_data(**kwargs), "apps": apps, "cloud_needs_renewal": ( "cloud_has_subscription" in role_names and "cloud_subscriber" not in role_names ), - "show_confirm_address": not user.has_confirmed_email, "private_badge_ids": {role.id for role in user.private_badges.all()}, + "show_confirm_address": not user.has_confirmed_email, + "show_mfa": show_mfa, } -class LoginView(mixins.RedirectToPrivacyAgreeMixin, mixins.PageIdMixin, auth_views.LoginView): - """Shows the login view.""" +class LoginView(mixins.RedirectToPrivacyAgreeMixin, mixins.PageIdMixin, django_otp.views.LoginView): + """Shows the login view. + This view also handles MFA forms and OAuth redirects. + + django_otp introduces additional indirection, it works as follows: + - django.contrib.auth.views.LoginView.get_form_class allows to define the form dynamically + and django_otp makes use of that to switch between otp_authentication_form and otp_token_form + - we overwrite otp_authentication_form to exclude the otp_device and otp_token fields, because + we don't have mandatory MFA for everyone + On top of that we have additional logic in get_form for injecting U2fAuthenticateForm if a user + has a u2f device. + + Because get_form is called on both GET and POST requests we put our branching logic there, + following the example of django_otp. + Then get_context_data checks the form class and supplies the data needed by a corresponding UI. + """ + + otp_authentication_form = forms.AuthenticationForm + otp_token_form = MfaAuthenticateForm page_id = "login" - template_name = "bid_main/login.html" - authentication_form = forms.AuthenticationForm redirect_authenticated_user = True success_url_allowed_hosts = settings.NEXT_REDIR_AFTER_LOGIN_ALLOWED_HOSTS + template_name = "bid_main/login.html" authorize_url = reverse_lazy("oauth2_provider:authorize") + def _mfa_device_type(self): + default_type = None + available_types = self.request.user.mfa_devices_per_type() + if "u2f" in available_types: + default_type = "u2f" + elif "totp" in available_types: + default_type = "totp" + return self.request.GET.get("mfa_device_type", default_type) + + def _mfa_alternatives(self): + r = {"request": self.request} + alternatives = [ + { + "href": "?" + query_transform(r, mfa_device_type="u2f"), + "label": _("Use U2F security key"), + "type": "u2f", + }, + { + "href": "?" + query_transform(r, mfa_device_type="totp"), + "label": _("Use TOTP authenticator"), + "type": "totp", + }, + { + "href": "?" + query_transform(r, mfa_device_type="recovery"), + "label": _("Use recovery code"), + "type": "recovery", + }, + ] + available_types = self.request.user.mfa_devices_per_type() + mfa_device_type = self._mfa_device_type() + return [ + item for item in alternatives + if item["type"] in available_types and item["type"] != mfa_device_type + ] + @method_decorator(sensitive_post_parameters()) - @method_decorator(csrf_exempt) + @method_decorator(csrf_protect) @method_decorator(never_cache) def dispatch(self, request, *args, **kwargs): - """Don't check CSRF token when already authenticated.""" - if self.redirect_authenticated_user and self.request.user.is_authenticated: + """This is a tweaked implementation of django.contrib.auth.view.LoginView.dispatch method. + + It accounts for the second step MfaAuthenticateForm and doesn't try to redirect too soon. + """ + user_is_verified = self.request.agent.is_trusted or not user_has_device(self.request.user) + ready_to_redirect = self.request.user.is_authenticated and user_is_verified + if self.redirect_authenticated_user and ready_to_redirect: redirect_to = self.get_success_url() if redirect_to == self.request.path: raise ValueError( @@ -96,13 +164,57 @@ class LoginView(mixins.RedirectToPrivacyAgreeMixin, mixins.PageIdMixin, auth_vie "your LOGIN_REDIRECT_URL doesn't point to a login page." ) return HttpResponseRedirect(redirect_to) - return super().dispatch(request, *args, **kwargs) + # not using a simple super() because we need to jump higher in view inheritance hierarchy + # and avoid execution of django.contrib.auth.view.LoginView.dispatch + return super(FormView, self).dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs) -> dict: ctx = super().get_context_data(**kwargs) self.find_oauth_flow(ctx) + ctx["form_type"] = "login" + + form = self.get_form() + if isinstance(form, U2fAuthenticateForm): + ctx["devices"] = self.request.user.mfa_devices_per_type() + ctx["form_type"] = "u2f" + ctx["mfa_alternatives"] = self._mfa_alternatives() + ctx["mfa_device_type"] = "u2f" + elif isinstance(form, MfaAuthenticateForm): + ctx["devices"] = self.request.user.mfa_devices_per_type() + ctx["form_type"] = "mfa" + ctx["mfa_alternatives"] = self._mfa_alternatives() + ctx["mfa_device_type"] = self._mfa_device_type() + return ctx + def _get_u2f_form(self, devices): + credentials = [AttestedCredentialData(d.credential) for d in devices] + rp_id = self.request.get_host().split(":")[0] # remove port, required by webauthn + request_options, state = authenticate_begin(rp_id, credentials) + kwargs = self.get_form_kwargs() + kwargs["credentials"] = credentials + kwargs["request_options"] = json.dumps(dict(request_options)) + kwargs["rp_id"] = rp_id + kwargs["state"] = dict(state) + return U2fAuthenticateForm(**kwargs) + + def get_form(self): + if self.request.user.is_authenticated: + u2f_devices = self.request.user.mfa_devices_per_type().get("u2f") + mfa_device_type = self._mfa_device_type() + if u2f_devices and (not mfa_device_type or mfa_device_type == "u2f"): + return self._get_u2f_form(u2f_devices) + # this will switch between MfaAuthenticateForm and AuthenticationForm + # as defined in django_otp.views.LoginView.authentication_form implementation + return super().get_form() + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + # this will affect only MfaAuthenticateForm, but making an explicit check here is cumbersome + if self.request.user.is_authenticated and self._mfa_device_type() == "recovery": + kwargs["use_recovery"] = True + return kwargs + def find_oauth_flow(self, ctx: dict): """Figure out if this is an OAuth flow, and for which OAuth Client.""" @@ -242,7 +354,7 @@ class LogoutView(auth_views.LogoutView): return next_url -class ProfileView(LoginRequiredMixin, UpdateView): +class ProfileView(mixins.MfaRequiredIfConfiguredMixin, UpdateView): form_class = forms.UserProfileForm model = User template_name = "bid_main/profile.html" @@ -283,7 +395,11 @@ class ProfileView(LoginRequiredMixin, UpdateView): return success_resp -class SwitchUserView(mixins.RedirectToPrivacyAgreeMixin, LoginRequiredMixin, auth_views.LoginView): +class SwitchUserView( + mixins.RedirectToPrivacyAgreeMixin, + mixins.MfaRequiredIfConfiguredMixin, + auth_views.LoginView, +): template_name = "bid_main/switch_user.html" form_class = forms.AuthenticationForm success_url_allowed_hosts = settings.NEXT_REDIR_AFTER_LOGIN_ALLOWED_HOSTS @@ -315,7 +431,12 @@ class GetAppsMixin: return app_model.objects.filter(id__in=app_ids).order_by("name") -class ApplicationTokenView(mixins.PageIdMixin, LoginRequiredMixin, GetAppsMixin, FormView): +class ApplicationTokenView( + mixins.PageIdMixin, + mixins.MfaRequiredIfConfiguredMixin, + GetAppsMixin, + FormView, +): page_id = "auth_tokens" template_name = "bid_main/auth_tokens.html" form_class = forms.AppRevokeTokensForm @@ -345,7 +466,7 @@ class ApplicationTokenView(mixins.PageIdMixin, LoginRequiredMixin, GetAppsMixin, return super().form_valid(form) -class PrivacyPolicyAgreeView(mixins.PageIdMixin, LoginRequiredMixin, FormView): +class PrivacyPolicyAgreeView(mixins.PageIdMixin, mixins.MfaRequiredIfConfiguredMixin, FormView): page_id = "privacy_policy_agree" template_name = "bid_main/privacy_policy_agree.html" form_class = forms.PrivacyPolicyAgreeForm @@ -386,7 +507,7 @@ class PrivacyPolicyAgreeView(mixins.PageIdMixin, LoginRequiredMixin, FormView): class DeleteUserView( - mixins.RedirectToPrivacyAgreeMixin, LoginRequiredMixin, GetAppsMixin, FormView + mixins.RedirectToPrivacyAgreeMixin, mixins.MfaRequiredIfConfiguredMixin, GetAppsMixin, FormView ): template_name = "bid_main/delete_user.html" form_class = forms.DeleteForm @@ -432,7 +553,7 @@ class DeleteUserView( return render(self.request, "bid_main/delete_user/confirm.html", context=ctx) -class ActiveSessionsView(LoginRequiredMixin, TemplateView): +class ActiveSessionsView(mixins.MfaRequiredIfConfiguredMixin, TemplateView): template_name = "bid_main/active_sessions.html" def get_context_data(self, **kwargs): @@ -446,7 +567,7 @@ class ActiveSessionsView(LoginRequiredMixin, TemplateView): } -class TerminateSessionView(LoginRequiredMixin, View): +class TerminateSessionView(mixins.MfaRequiredIfConfiguredMixin, View): def post(self, request, *args, **kwargs): user_session_pk = kwargs.get('pk') if user_session := self.request.user.sessions.filter(pk=user_session_pk).first(): diff --git a/bid_main/views/registration_email.py b/bid_main/views/registration_email.py index 4c46dde..bf02692 100644 --- a/bid_main/views/registration_email.py +++ b/bid_main/views/registration_email.py @@ -3,7 +3,6 @@ import logging from django.contrib.auth import views as auth_views from django.contrib.auth.forms import PasswordResetForm -from django.contrib.auth.mixins import LoginRequiredMixin from django.core.exceptions import ValidationError from django.db import transaction, IntegrityError from django.http import HttpResponseBadRequest, JsonResponse @@ -14,6 +13,7 @@ from django.utils import timezone from django.views.generic import CreateView, TemplateView, FormView, View from .. import forms, email +from . import mixins from ..models import User log = logging.getLogger(__name__) @@ -91,7 +91,7 @@ class InitialSetPasswordView(auth_views.PasswordResetConfirmView): form_class = forms.SetInitialPasswordForm -class ConfirmEmailView(LoginRequiredMixin, FormView): +class ConfirmEmailView(mixins.MfaRequiredIfConfiguredMixin, FormView): template_name = "bid_main/confirm_email/start.html" form_class = forms.ConfirmEmailStartForm log = logging.getLogger(f"{__name__}.ConfirmEmailView") @@ -127,7 +127,7 @@ class ConfirmEmailView(LoginRequiredMixin, FormView): return redirect("bid_main:confirm-email-sent") -class CancelEmailChangeView(LoginRequiredMixin, View): +class CancelEmailChangeView(mixins.MfaRequiredIfConfiguredMixin, View): """Cancel the user's email change and redirect to the profile page.""" log = logging.getLogger(f"{__name__}.CancelEmailChangeView") @@ -142,11 +142,11 @@ class CancelEmailChangeView(LoginRequiredMixin, View): return redirect("bid_main:index") -class ConfirmEmailSentView(LoginRequiredMixin, TemplateView): +class ConfirmEmailSentView(mixins.MfaRequiredIfConfiguredMixin, TemplateView): template_name = "bid_main/confirm_email/sent.html" -class ConfirmEmailPollView(LoginRequiredMixin, View): +class ConfirmEmailPollView(mixins.MfaRequiredIfConfiguredMixin, View): """Returns JSON indicating when the email address has last been confirmed. The timestamp is returned as ISO 8601 to allow future periodic checks @@ -167,7 +167,7 @@ class ConfirmEmailPollView(LoginRequiredMixin, View): return JsonResponse({"confirmed": timestamp}) -class ConfirmEmailVerifiedView(LoginRequiredMixin, TemplateView): +class ConfirmEmailVerifiedView(mixins.MfaRequiredIfConfiguredMixin, TemplateView): """Render explanation on GET, handle confirmation on POST. We only perform the actual database change on a POST, since that protects diff --git a/blenderid/oauth2_urls.py b/blenderid/oauth2_urls.py index 995bc27..b2e8894 100644 --- a/blenderid/oauth2_urls.py +++ b/blenderid/oauth2_urls.py @@ -8,13 +8,20 @@ I've also removed the "management" URLs, as we use the admin interface for that. from django.urls import re_path from django.views.generic import RedirectView - from oauth2_provider import views as default_oauth2_views from oauth2_provider import urls as default_oauth2_urls +from otp_agents.decorators import otp_required + app_name = "oauth2_provider" urlpatterns = ( - re_path(r"^authorize/?$", default_oauth2_views.AuthorizationView.as_view(), name="authorize"), + re_path( + r"^authorize/?$", + otp_required(accept_trusted_agent=True, if_configured=True)( + default_oauth2_views.AuthorizationView.as_view() + ), + name="authorize", + ), re_path(r"^token/?$", default_oauth2_views.TokenView.as_view(), name="token"), re_path( r"^revoke/?$", diff --git a/blenderid/settings.py b/blenderid/settings.py index 2d3aee8..e2c6128 100644 --- a/blenderid/settings.py +++ b/blenderid/settings.py @@ -38,9 +38,25 @@ PREFERRED_SCHEME = "https" # Update this to something unique for your machine. SECRET_KEY = os.getenv('SECRET_KEY', 'default-dev-secret') +# NACL_FIELDS_KEY is used to encrypt MFA shared secrets +# !!!!!!!!!!!! +# !!!DANGER!!! its loss or bad rotation will lock out all users with MFA!!! +# !!!!!!!!!!!! +# generate a prod key with ./manage.py createkey (you would need to comment out `raise` below) +# and put it as a string in the .env file, +# .encode('ascii') takes care of making the variable populated with a byte array +NACL_FIELDS_KEY = os.getenv( + 'NACL_FIELDS_KEY', 'N5W|iA&lZ7iGsy92=qlr>~heUoH4ZX16pCEGG*R+' +).encode('ascii') + # SECURITY WARNING: don't run with debug turned on in production! DEBUG = bool(os.getenv('DEBUG', False)) +if not DEBUG and NACL_FIELDS_KEY == b'N5W|iA&lZ7iGsy92=qlr>~heUoH4ZX16pCEGG*R+': + raise Exception('please override NACL_FIELDS_KEY in .env') +if not DEBUG and SECRET_KEY == 'default-dev-secret': + raise Exception('please override SECRET_KEY in .env') + TESTING = sys.argv[1:2] == ['test'] ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', 'id.local').split(',') @@ -58,15 +74,21 @@ INSTALLED_APPS = [ "django.contrib.staticfiles", "django.contrib.sites", "django.contrib.flatpages", + "django_agent_trust", + "django_otp", + "django_otp.plugins.otp_static", + "django_otp.plugins.otp_totp", "oauth2_provider", "pipeline", "sorl.thumbnail", "django_admin_select2", "loginas", + "nacl_encrypted_fields", "bid_main", "bid_api", "bid_addon_support", "background_task", + "mfa", ] MIDDLEWARE = [ @@ -75,6 +97,8 @@ MIDDLEWARE = [ "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "django_agent_trust.middleware.AgentMiddleware", + "django_otp.middleware.OTPMiddleware", "bid_main.middleware.user_session_middleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", @@ -86,6 +110,9 @@ AUTHENTICATION_BACKENDS = [ "django.contrib.auth.backends.ModelBackend", ] +FIDO2_RP_NAME = "Blender ID" +OTP_TOTP_ISSUER = "id.blender.org" + ROOT_URLCONF = "blenderid.urls" TEMPLATES = [ @@ -264,6 +291,10 @@ NEXT_REDIR_AFTER_LOGIN_ALLOWED_HOSTS = { "blender.community", } +AGENT_COOKIE_SECURE = True +AGENT_TRUST_DAYS = 30 +AGENT_INACTIVITY_DAYS = 7 + CSRF_COOKIE_SECURE = True CSRF_FAILURE_VIEW = "bid_main.views.errors.csrf_failure" CSRF_TRUSTED_ORIGINS = ['https://*.blender.org'] @@ -355,8 +386,9 @@ if TESTING: # For Debug Toolbar, extend with whatever address you use to connect # to your dev server. if DEBUG: + AGENT_COOKIE_SECURE = False CSRF_COOKIE_SECURE = False - INSTALLED_APPS += ['debug_toolbar'] + INSTALLED_APPS += ['debug_toolbar', 'sslserver'] INTERNAL_IPS = ["127.0.0.1"] MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware'] SESSION_COOKIE_SECURE = False @@ -396,3 +428,6 @@ if os.environ.get('ADMINS') is not None: ADMINS = [[_.strip() for _ in adm.split(':')] for adm in os.environ.get('ADMINS').split(',')] EMAIL_SUBJECT_PREFIX = f'[{ALLOWED_HOSTS[0]}]' SERVER_EMAIL = f'django@{ALLOWED_HOSTS[0]}' + + +SUPPORT_EMAIL = 'blenderid@blender.org' diff --git a/docs/mfa.md b/docs/mfa.md new file mode 100644 index 0000000..cd7a0a2 --- /dev/null +++ b/docs/mfa.md @@ -0,0 +1,42 @@ +--- +gitea: none +include_toc: true +--- + +# Multi-factor authentication (MFA) + +## Supported configurations + +- TOTP authenticators (having multiple is supported) +- Yubikey (having multiple keys per account is supported) +- TOTP + recovery codes +- Yubikey + recovery codes +- Yubikey + TOTP + recovery codes + +TODO: one-time recovery code via email + +## Implementation details + +Dependencies: +- django-otp and its device plugins: + for implementing verification and throttling logic +- django=trusted-agents and django-otp-agents: + for otp_required decorator that supports accept_trusted_agent to implement persistence +- django-nacl-fields and pynacl: + for transparent handling of encrypted secret fields + +Settings: +- AGENT_COOKIE_SECURE +- AGENT_INACTIVITY_DAYS +- AGENT_TRUST_DAYS +- NACL_FIELDS_KEY +- OTP_TOTP_ISSUER + +### Considered alternatives + +django-allauth: + +- seems to be less modular/pluggable, and requires a more careful consideration of compatibility +with our existing code base, e.g. our current OAuth flows implementation; +- is more opinionated, but does a lot of things correctly, is actively developed, and may be +considered in the future. diff --git a/mfa/__init__.py b/mfa/__init__.py new file mode 100644 index 0000000..37a3d40 --- /dev/null +++ b/mfa/__init__.py @@ -0,0 +1 @@ +default_app_config = "%s.apps.MfaConfig" % __name__ diff --git a/mfa/apps.py b/mfa/apps.py new file mode 100644 index 0000000..6804758 --- /dev/null +++ b/mfa/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class MfaConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = "mfa" diff --git a/mfa/fido2.py b/mfa/fido2.py new file mode 100644 index 0000000..a6ac2e8 --- /dev/null +++ b/mfa/fido2.py @@ -0,0 +1,51 @@ +from django.conf import settings +from fido2.server import Fido2Server +from fido2.webauthn import ( + PublicKeyCredentialRpEntity, + PublicKeyCredentialUserEntity, + ResidentKeyRequirement, + UserVerificationRequirement, +) +import fido2.features + +# needed to automatically convert between bytes and base64url +fido2.features.webauthn_json_mapping.enabled = True + + +def get_fido2server(rp_id): + return Fido2Server( + PublicKeyCredentialRpEntity( + id=rp_id, + name=settings.FIDO2_RP_NAME, + ) + ) + + +def register_begin(rp_id, user, credentials): + return get_fido2server(rp_id).register_begin( + PublicKeyCredentialUserEntity( + display_name=user.email, + id=user.pk.to_bytes(8, 'big'), + name=user.email, + ), + credentials, + resident_key_requirement=ResidentKeyRequirement.DISCOURAGED, + user_verification=UserVerificationRequirement.DISCOURAGED, + ) + + +def register_complete(rp_id, state, credential): + return get_fido2server(rp_id).register_complete(state, credential) + + +def authenticate_begin(rp_id, credentials): + return get_fido2server(rp_id).authenticate_begin( + credentials=credentials, + user_verification=UserVerificationRequirement.PREFERRED, + ) + + +def authenticate_complete(rp_id, state, credentials, response): + return get_fido2server(rp_id).authenticate_complete( + state, credentials, response, + ) diff --git a/mfa/forms.py b/mfa/forms.py new file mode 100644 index 0000000..1047587 --- /dev/null +++ b/mfa/forms.py @@ -0,0 +1,235 @@ +from binascii import unhexlify + +from django import forms +from django.conf import settings +from django.core.signing import BadSignature, TimestampSigner +from django.core.validators import RegexValidator +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from django_otp.oath import TOTP +from otp_agents.forms import OTPAgentFormMixin +from otp_agents.views import OTPTokenForm + +from mfa.fido2 import authenticate_complete, register_complete +from mfa.models import EncryptedTOTPDevice, U2fDevice + + +def _sign(payload): + signer = TimestampSigner() + return signer.sign(payload) + + +def _verify_signature(payload, signature, max_age=3600): + signer = TimestampSigner() + try: + return payload == signer.unsign(signature, max_age=max_age) + except BadSignature: + return False + + +class MfaAuthenticateForm(OTPTokenForm): + """Restyle the form widgets to do less work in the template. + + This form inherits from otp_agents form that takes care of agent_trust. + Even though the LoginView itself skips one layer and inherits directly from + django_otp.views.LoginView. + """ + + def __init__(self, *args, **kwargs): + self.use_recovery = kwargs.pop('use_recovery', False) + super().__init__(*args, **kwargs) + + otp_token = self.fields["otp_token"] + otp_token.label = _('Code') + otp_token.required = True + if self.use_recovery: + otp_token.validators = [RegexValidator(r'^[A-F0-9]{16}$')] + otp_token.widget = forms.TextInput( + attrs={ + "autocomplete": "one-time-code", + "maxlength": 16, + "pattern": "[A-F0-9]{16}", + "placeholder": "123456890ABCDEF", + }, + ) + else: + otp_token.validators = [RegexValidator(r'^[0-9]{6}$')] + otp_token.widget = forms.TextInput( + attrs={ + "autocomplete": "one-time-code", + "inputmode": "numeric", + "maxlength": 6, + "pattern": "[0-9]{6}", + "placeholder": "123456", + }, + ) + + otp_trust_agent = self.fields["otp_trust_agent"] + otp_trust_agent.help_text = _( + f"We won't ask for MFA on this device in the next {settings.AGENT_TRUST_DAYS} days. " + f"Use only on your private device." + ) + otp_trust_agent.initial = False + otp_trust_agent.label = _("Remember this device") + otp_trust_agent.widget = forms.CheckboxInput( + attrs={ + "autocomplete": "off", + }, + ) + + +class U2fAuthenticateForm(OTPAgentFormMixin, forms.Form): + otp_trust_agent = forms.BooleanField( + help_text=_( + f"We won't ask for MFA on this device in the next {settings.AGENT_TRUST_DAYS} days. " + f"Use only on your private device." + ), + initial=False, + label=_("Remember this device"), + required=False, + widget=forms.CheckboxInput( + attrs={ + "autocomplete": "off", + }, + ) + ) + signature = forms.CharField(widget=forms.HiddenInput) + state = forms.JSONField(widget=forms.HiddenInput) + response = forms.JSONField(widget=forms.HiddenInput) + + def __init__(self, *args, **kwargs): + self.credentials = kwargs.pop('credentials', None) + request = kwargs.pop('request') # important for compatibility with other login forms + request_options = kwargs.pop('request_options', None) + self.rp_id = kwargs.pop('rp_id') + state = kwargs.pop('state') + self.user = request.user + kwargs['initial']['signature'] = _sign(f"{self.user.email}_{state['challenge']}") + kwargs['initial']['state'] = state + super().__init__(*args, **kwargs) + self.fields['response'].widget.attrs['request-options'] = request_options + + def get_user(self): + """For compatibility with other login forms.""" + return self.user + + def clean(self): + super().clean() + signature = self.cleaned_data.get('signature') + state = self.cleaned_data.get('state') + if not _verify_signature(f"{self.user.email}_{state['challenge']}", signature): + raise forms.ValidationError(_('Invalid signature')) + credential = authenticate_complete( + self.rp_id, + state, + self.credentials, + self.cleaned_data['response'], + ) + device = U2fDevice.objects.filter(credential=credential, user=self.user).first() + device.last_used_at = timezone.now() + device.save() + self.user.otp_device = device + self.clean_agent() + return self.cleaned_data + + +class DisableMfaForm(forms.Form): + disable_mfa_confirm = forms.BooleanField( + help_text="Confirming disabling of multi-factor authentication", + initial=False, + label=_('Confirm'), + required=True, + widget=forms.CheckboxInput( + attrs={ + "autocomplete": "off", + }, + ), + ) + + +class TotpRegisterForm(forms.Form): + code = forms.CharField( + validators=[RegexValidator(r'^[0-9]{6}$')], + widget=forms.TextInput( + attrs={ + "autocomplete": "one-time-code", + "inputmode": "numeric", + "maxlength": 6, + "pattern": "[0-9]{6}", + "placeholder": "123456", + }, + ), + ) + key = forms.CharField(widget=forms.HiddenInput) + name = forms.CharField( + max_length=EncryptedTOTPDevice._meta.get_field('name').max_length, + widget=forms.TextInput( + attrs={"placeholder": "device name (for your convenience)"}, + ), + ) + signature = forms.CharField(widget=forms.HiddenInput) + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop('user', None) + kwargs['initial']['signature'] = _sign(f"{self.user.email}_{kwargs['initial']['key']}") + super().__init__(*args, **kwargs) + + def clean(self): + super().clean() + code = self.data.get('code') + key = self.data.get('key') + signature = self.cleaned_data.get('signature') + if not _verify_signature(f'{self.user.email}_{key}', signature): + raise forms.ValidationError(_('Invalid signature')) + self.cleaned_data['key'] = key + if not TOTP(unhexlify(key)).verify(int(code), tolerance=1): + self.add_error('code', _('Invalid code')) + return self.cleaned_data + + def save(self): + key = self.cleaned_data.get('key') + name = self.cleaned_data.get('name') + EncryptedTOTPDevice.objects.create(encrypted_key=key, key='', name=name, user=self.user) + + +class U2fRegisterForm(forms.Form): + credential = forms.JSONField(widget=forms.HiddenInput) + name = forms.CharField( + max_length=U2fDevice._meta.get_field('name').max_length, + widget=forms.TextInput( + attrs={"placeholder": "device name (for your convenience)"}, + ), + ) + signature = forms.CharField(widget=forms.HiddenInput) + state = forms.JSONField(widget=forms.HiddenInput) + + def __init__(self, *args, **kwargs): + credential_creation_options = kwargs.pop('credential_creation_options', None) + self.rp_id = kwargs.pop('rp_id', None) + state = kwargs.pop('state', None) + self.user = kwargs.pop('user', None) + kwargs['initial']['signature'] = _sign(f"{self.user.email}_{state['challenge']}") + kwargs['initial']['state'] = state + super().__init__(*args, **kwargs) + self.fields['credential'].widget.attrs['creation-options'] = credential_creation_options + + def clean(self): + super().clean() + signature = self.cleaned_data.get('signature') + state = self.cleaned_data.get('state') + if not _verify_signature(f"{self.user.email}_{state['challenge']}", signature): + raise forms.ValidationError(_('Invalid signature')) + return self.cleaned_data + + def save(self): + credential = self.cleaned_data.get('credential') + name = self.cleaned_data.get('name') + state = self.cleaned_data.get('state') + auth_data = None + try: + auth_data = register_complete(self.rp_id, state, credential) + except Exception: + raise forms.ValidationError(_('Verification failed')) + U2fDevice.objects.create( + user=self.user, credential=auth_data.credential_data, name=name, + ) diff --git a/mfa/migrations/0001_initial.py b/mfa/migrations/0001_initial.py new file mode 100644 index 0000000..f3d92ba --- /dev/null +++ b/mfa/migrations/0001_initial.py @@ -0,0 +1,70 @@ +# Generated by Django 4.2.13 on 2024-08-13 11:33 + +from django.db import migrations, models +import django.db.models.deletion +import nacl_encrypted_fields.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('otp_static', '0003_add_timestamps'), + ('otp_totp', '0003_add_timestamps'), + ] + + operations = [ + migrations.CreateModel( + name='EncryptedTOTPDevice', + fields=[ + ( + 'totpdevice_ptr', + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to='otp_totp.totpdevice', + ), + ), + ('encrypted_key', nacl_encrypted_fields.fields.NaClCharField(max_length=255)), + ], + options={ + 'abstract': False, + }, + bases=('otp_totp.totpdevice',), + ), + migrations.CreateModel( + name='EncryptedRecoveryDevice', + fields=[], + options={ + 'abstract': False, + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('otp_static.staticdevice',), + ), + migrations.CreateModel( + name='EncryptedStaticToken', + fields=[ + ( + 'id', + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name='ID' + ), + ), + ('encrypted_token', nacl_encrypted_fields.fields.NaClCharField(max_length=255)), + ( + 'device', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='encryptedtoken_set', + to='mfa.encryptedrecoverydevice', + ), + ), + ], + ), + ] diff --git a/mfa/migrations/0002_u2fdevice.py b/mfa/migrations/0002_u2fdevice.py new file mode 100644 index 0000000..4dabc97 --- /dev/null +++ b/mfa/migrations/0002_u2fdevice.py @@ -0,0 +1,81 @@ +# Generated by Django 4.2.13 on 2024-08-19 14:38 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('mfa', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='U2fDevice', + fields=[ + ( + 'id', + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name='ID' + ), + ), + ( + 'name', + models.CharField( + help_text='The human-readable name of this device.', max_length=64 + ), + ), + ( + 'confirmed', + models.BooleanField(default=True, help_text='Is this device ready for use?'), + ), + ( + 'throttling_failure_timestamp', + models.DateTimeField( + blank=True, + default=None, + help_text='A timestamp of the last failed verification attempt. Null if last attempt succeeded.', + null=True, + ), + ), + ( + 'throttling_failure_count', + models.PositiveIntegerField( + default=0, help_text='Number of successive failed attempts.' + ), + ), + ( + 'created_at', + models.DateTimeField( + auto_now_add=True, + help_text='The date and time when this device was initially created in the system.', + null=True, + ), + ), + ( + 'last_used_at', + models.DateTimeField( + blank=True, + help_text='The most recent date and time this device was used.', + null=True, + ), + ), + ('credential', models.BinaryField()), + ( + 'user', + models.ForeignKey( + help_text='The user that this device belongs to.', + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + 'verbose_name': 'U2F device', + 'abstract': False, + }, + ), + ] diff --git a/mfa/migrations/__init__.py b/mfa/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mfa/models.py b/mfa/models.py new file mode 100644 index 0000000..233d865 --- /dev/null +++ b/mfa/models.py @@ -0,0 +1,96 @@ +"""MFA device models. + +In a perfect world this code would have not be needed, but django_otp seems to be the best match for +our codebase at this point, despite the fact that it doesn't handle secrets correctly (stores them +in plain text). + +It seems like it's simpler to patch this one shortcoming by defining the following models and reuse +the upstream implementation as much as possible. +""" + +from binascii import unhexlify + +from django.db import models, transaction +from django_otp.models import Device, ThrottlingMixin, TimestampMixin +from django_otp.plugins.otp_static.models import StaticDevice +from django_otp.plugins.otp_totp.models import TOTPDevice +from nacl_encrypted_fields.fields import NaClCharField + +from mfa.signals import recovery_used + + +class EncryptedRecoveryDevice(StaticDevice): + """Using a proxy model and pretending that upstream StaticToken does not exist.""" + class Meta(StaticDevice.Meta): + abstract = False + proxy = True + + @transaction.atomic + def verify_token(self, token): + """Copy-pasted verbatim from StaticDevice, replacing token_set with encryptedtoken_set. + + ORM filter is rewritten to a loop, because looking up encrypted values doesn't work. + Also added a signal for email notification. + """ + verify_allowed, _ = self.verify_is_allowed() + if verify_allowed: + match = None + for t in self.encryptedtoken_set.all(): + if t.encrypted_token == token: + match = t + if match is not None: + match.delete() + self.throttle_reset(commit=False) + self.set_last_used_timestamp(commit=False) + self.save() + recovery_used.send(EncryptedRecoveryDevice, device=self) + else: + self.throttle_increment() + else: + match = None + + return match is not None + + +class EncryptedStaticToken(models.Model): + device = models.ForeignKey( + EncryptedRecoveryDevice, related_name='encryptedtoken_set', on_delete=models.CASCADE + ) + encrypted_token = NaClCharField(max_length=255) + + +class EncryptedTOTPDevice(TOTPDevice): + """Using multi-table inheritance to introduce an encrypted field. + + A separate table is a bit ugly, but the only alternative I see at this point is a full + copy-paste, since the upstream key field is too small to accommodate an encrypted value. + + Performance overhead of an additional JOIN should be negligible for this table's access pattern: + TOTP setup and verification. + """ + encrypted_key = NaClCharField(max_length=255) + + @property + def bin_key(self): + """This override is sufficient to make verify_token use the new field.""" + return unhexlify(self.encrypted_key.encode()) + + +class U2fDevice(TimestampMixin, ThrottlingMixin, Device): + credential = models.BinaryField() + + class Meta(Device.Meta): + verbose_name = "U2F device" + + +def devices_for_user(user): + """ A more specific replacement for upstream devices_for_user. + + Can't use the upstream devices_for_user because using multi-table model inheritance shows up as + duplicate devices. + """ + return [ + *EncryptedRecoveryDevice.objects.filter(user=user).all(), + *EncryptedTOTPDevice.objects.filter(user=user).all(), + *U2fDevice.objects.filter(user=user).all(), + ] diff --git a/mfa/signals.py b/mfa/signals.py new file mode 100644 index 0000000..81ab83a --- /dev/null +++ b/mfa/signals.py @@ -0,0 +1,3 @@ +from django.dispatch import Signal + +recovery_used = Signal() diff --git a/mfa/static/mfa/js/webauthn-json.js b/mfa/static/mfa/js/webauthn-json.js new file mode 100644 index 0000000..ad7de9b --- /dev/null +++ b/mfa/static/mfa/js/webauthn-json.js @@ -0,0 +1,289 @@ +// https://github.com/github/webauthn-json +// via https://github.com/pennersr/django-allauth/blob/main/allauth/mfa/static/mfa/js/webauthn-json.js +// +// The MIT License (MIT) +// +// Copyright (c) 2019 GitHub, Inc. +// Copyright (c) 2010-2021 Raymond Penners and contributors +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +(() => { + const __defProp = Object.defineProperty + const __export = (target, all) => { + for (const name in all) { __defProp(target, name, { get: all[name], enumerable: true }) } + } + const __async = (__this, __arguments, generator) => { + return new Promise((resolve, reject) => { + const fulfilled = (value) => { + try { + step(generator.next(value)) + } catch (e) { + reject(e) + } + } + const rejected = (value) => { + try { + step(generator.throw(value)) + } catch (e) { + reject(e) + } + } + var step = (x) => x.done ? resolve(x.value) : Promise.resolve(x.value).then(fulfilled, rejected) + step((generator = generator.apply(__this, __arguments)).next()) + }) + } + + // src/webauthn-json/index.ts + const webauthn_json_exports = {} + __export(webauthn_json_exports, { + create: () => create, + get: () => get, + schema: () => schema, + supported: () => supported + }) + + // src/webauthn-json/base64url.ts + function base64urlToBuffer (baseurl64String) { + const padding = '=='.slice(0, (4 - baseurl64String.length % 4) % 4) + const base64String = baseurl64String.replace(/-/g, '+').replace(/_/g, '/') + padding + const str = atob(base64String) + const buffer = new ArrayBuffer(str.length) + const byteView = new Uint8Array(buffer) + for (let i = 0; i < str.length; i++) { + byteView[i] = str.charCodeAt(i) + } + return buffer + } + function bufferToBase64url (buffer) { + const byteView = new Uint8Array(buffer) + let str = '' + for (const charCode of byteView) { + str += String.fromCharCode(charCode) + } + const base64String = btoa(str) + const base64urlString = base64String.replace(/\+/g, '-').replace( + /\//g, + '_' + ).replace(/=/g, '') + return base64urlString + } + + // src/webauthn-json/convert.ts + const copyValue = 'copy' + const convertValue = 'convert' + function convert (conversionFn, schema2, input) { + if (schema2 === copyValue) { + return input + } + if (schema2 === convertValue) { + return conversionFn(input) + } + if (schema2 instanceof Array) { + return input.map((v) => convert(conversionFn, schema2[0], v)) + } + if (schema2 instanceof Object) { + const output = {} + for (const [key, schemaField] of Object.entries(schema2)) { + if (schemaField.derive) { + const v = schemaField.derive(input) + if (v !== void 0) { + input[key] = v + } + } + if (!(key in input)) { + if (schemaField.required) { + throw new Error(`Missing key: ${key}`) + } + continue + } + if (input[key] == null) { + output[key] = null + continue + } + output[key] = convert( + conversionFn, + schemaField.schema, + input[key] + ) + } + return output + } + } + function derived (schema2, derive) { + return { + required: true, + schema: schema2, + derive + } + } + function required (schema2) { + return { + required: true, + schema: schema2 + } + } + function optional (schema2) { + return { + required: false, + schema: schema2 + } + } + + // src/webauthn-json/basic/schema.ts + const publicKeyCredentialDescriptorSchema = { + type: required(copyValue), + id: required(convertValue), + transports: optional(copyValue) + } + const simplifiedExtensionsSchema = { + appid: optional(copyValue), + appidExclude: optional(copyValue), + credProps: optional(copyValue) + } + const simplifiedClientExtensionResultsSchema = { + appid: optional(copyValue), + appidExclude: optional(copyValue), + credProps: optional(copyValue) + } + const credentialCreationOptions = { + publicKey: required({ + rp: required(copyValue), + user: required({ + id: required(convertValue), + name: required(copyValue), + displayName: required(copyValue) + }), + challenge: required(convertValue), + pubKeyCredParams: required(copyValue), + timeout: optional(copyValue), + excludeCredentials: optional([publicKeyCredentialDescriptorSchema]), + authenticatorSelection: optional(copyValue), + attestation: optional(copyValue), + extensions: optional(simplifiedExtensionsSchema) + }), + signal: optional(copyValue) + } + const publicKeyCredentialWithAttestation = { + type: required(copyValue), + id: required(copyValue), + rawId: required(convertValue), + authenticatorAttachment: optional(copyValue), + response: required({ + clientDataJSON: required(convertValue), + attestationObject: required(convertValue), + transports: derived( + copyValue, + (response) => { + let _a + return ((_a = response.getTransports) == null ? void 0 : _a.call(response)) || [] + } + ) + }), + clientExtensionResults: derived( + simplifiedClientExtensionResultsSchema, + (pkc) => pkc.getClientExtensionResults() + ) + } + const credentialRequestOptions = { + mediation: optional(copyValue), + publicKey: required({ + challenge: required(convertValue), + timeout: optional(copyValue), + rpId: optional(copyValue), + allowCredentials: optional([publicKeyCredentialDescriptorSchema]), + userVerification: optional(copyValue), + extensions: optional(simplifiedExtensionsSchema) + }), + signal: optional(copyValue) + } + const publicKeyCredentialWithAssertion = { + type: required(copyValue), + id: required(copyValue), + rawId: required(convertValue), + authenticatorAttachment: optional(copyValue), + response: required({ + clientDataJSON: required(convertValue), + authenticatorData: required(convertValue), + signature: required(convertValue), + userHandle: required(convertValue) + }), + clientExtensionResults: derived( + simplifiedClientExtensionResultsSchema, + (pkc) => pkc.getClientExtensionResults() + ) + } + var schema = { + credentialCreationOptions, + publicKeyCredentialWithAttestation, + credentialRequestOptions, + publicKeyCredentialWithAssertion + } + + // src/webauthn-json/basic/api.ts + function createRequestFromJSON (requestJSON) { + return convert(base64urlToBuffer, credentialCreationOptions, requestJSON) + } + function createResponseToJSON (credential) { + return convert( + bufferToBase64url, + publicKeyCredentialWithAttestation, + credential + ) + } + function create (requestJSON) { + return __async(this, null, function * () { + const credential = yield navigator.credentials.create( + createRequestFromJSON(requestJSON) + ) + return createResponseToJSON(credential) + }) + } + function getRequestFromJSON (requestJSON) { + return convert(base64urlToBuffer, credentialRequestOptions, requestJSON) + } + function getResponseToJSON (credential) { + return convert( + bufferToBase64url, + publicKeyCredentialWithAssertion, + credential + ) + } + function get (requestJSON) { + return __async(this, null, function * () { + const credential = yield navigator.credentials.get( + getRequestFromJSON(requestJSON) + ) + return getResponseToJSON(credential) + }) + } + + // src/webauthn-json/basic/supported.ts + function supported () { + return !!(navigator.credentials && navigator.credentials.create && navigator.credentials.get && window.PublicKeyCredential) + } + + // src/webauthn-json/browser-global.ts + globalThis.webauthnJSON = webauthn_json_exports +})() +// # sourceMappingURL=webauthn-json.browser-global.js.map diff --git a/requirements.txt b/requirements.txt index ab603c5..18d931a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,16 +8,21 @@ colorama==0.4.6 ; python_version >= "3.8" and python_version < "4" and platform_ cryptography==41.0.0 ; python_version >= "3.8" and python_version < "4" csscompressor==0.9.5 ; python_version >= "3.8" and python_version < "4" deprecated==1.2.14 ; python_version >= "3.8" and python_version < "4" -dj-database-url==2.2.0 +django==4.2.13 ; python_version >= "3.8" and python_version < "4" django-admin-select2==1.0.1 ; python_version >= "3.8" and python_version < "4" +django-agent-trust==1.1.0 django-background-tasks-updated @ git+https://projects.blender.org/infrastructure/django-background-tasks.git@1.2.10 +django[bcrypt]==4.2.13 ; python_version >= "3.8" and python_version < "4" django-compat==1.0.15 ; python_version >= "3.8" and python_version < "4" django-loginas==0.3.11 ; python_version >= "3.8" and python_version < "4" +django-nacl-fields==4.1.0 django-oauth-toolkit @ git+https://projects.blender.org/Oleg-Komarov/django-oauth-toolkit.git@0b056a99ca943771615b859f48aaff0e12357f22 ; python_version >= "3.8" and python_version < "4" +django-otp==1.5.1 +django-otp-agents==1.0.1 django-pipeline==3.1.0 ; python_version >= "3.8" and python_version < "4" -django==4.2.13 ; python_version >= "3.8" and python_version < "4" -django[bcrypt]==4.2.13 ; python_version >= "3.8" and python_version < "4" +dj-database-url==2.2.0 docutils==0.14 ; python_version >= "3.8" and python_version < "4" +fido2==1.1.3 htmlmin==0.1.12 ; python_version >= "3.8" and python_version < "4" idna==2.8 ; python_version >= "3.8" and python_version < "4" importlib-metadata==3.6.0 ; python_version >= "3.8" and python_version < "4" @@ -37,10 +42,13 @@ pycparser==2.19 ; python_version >= "3.8" and python_version < "4" pygments==2.17.2 ; python_version >= "3.8" and python_version < "4" pyinstrument==4.6.0 ; python_version >= "3.8" and python_version < "4" pymdown-extensions==10.7 ; python_version >= "3.8" and python_version < "4" +pynacl==1.5.0 +pypng==0.20220715.0 pypugjs==5.9.12 ; python_version >= "3.8" and python_version < "4" python-dateutil==2.8.1 ; python_version >= "3.8" and python_version < "4" pytz==2019.3 ; python_version >= "3.8" and python_version < "4" pyyaml==5.1.2 ; python_version >= "3.8" and python_version < "4" +qrcode==7.4.2 requests==2.30.0 ; python_version >= "3.8" and python_version < "4" sentry-sdk==1.4.3 ; python_version >= "3.8" and python_version < "4" six==1.12.0 ; python_version >= "3.8" and python_version < "4" diff --git a/requirements_dev.txt b/requirements_dev.txt index 1e9d19f..c6ba08e 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -2,6 +2,7 @@ Faker==20.1.0 charset-normalizer==3.3.2 django-debug-toolbar==4.4.6 +django-sslserver==0.22 factory-boy==3.3.0 flake8==6.1.0 freezegun==1.3.1