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 %} + +
+ You are going to disable multi-factor authentication (MFA). + You can always configure MFA again. +
+ +{% 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 %} ++ 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. +
++ 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 %} ++ Also known as authenticator application. +
++ If you don't have an authenticator application, you can choose one from a list of TOTP applications. +
++ Hardware security keys, e.g. Yubikeys. +
++ Blender ID supports these keys only as a second factor and does not provide a passwordless sign-in. +
++ 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 %} +{{ code }}
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 %} +([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, '