Initial mfa support (for internal users) #93591

Merged
Oleg-Komarov merged 46 commits from mfa into main 2024-08-29 11:44:06 +02:00
45 changed files with 2130 additions and 54 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,42 @@
{% load add_form_classes from forms %}
{% load common static %}
<div class="bid box">
<div>
<h2>Multi-factor Authentication</h2>
</div>
{% with form=form|add_form_classes %}
<form role="login" action="" method="POST">{% csrf_token %}
<fieldset class="mb-4">
{% if mfa_device_type == 'recovery' %}
<p>Use a recovery code</p>
<input type="hidden" name="otp_device" value="{{ devices.recovery.0.persistent_id }}" />
{% elif mfa_device_type == 'totp' %}
{% if devices.totp|length == 1 %}
<input type="hidden" name="otp_device" value="{{ devices.totp.0.persistent_id }}" />
{% else %}
<div class="form-check-inline mb-3">
{% for device in devices.totp %}
<label class="btn form-check-input">
<input type="radio" name="otp_device" value="{{ device.persistent_id }}" required="required" />
{{ device.name }}
</label>
{% endfor %}
</div>
{% 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 %}
</fieldset>
{{ form.non_field_errors }}
<button class="btn btn-block btn-accent">Continue</button>
</form>
{% endwith %}
{% if mfa_alternatives %}
<div class="bid-links">
{% for item in mfa_alternatives %}
<a href="{{ item.href }}">{{ item.label }}</a>
{% endfor %}
</div>
{% endif %}
</div>

View File

@ -0,0 +1,43 @@
{% load add_form_classes from forms %}
{% load common static %}
<div class="bid box">
<div>
<h2>Multi-factor Authentication</h2>
</div>
{% with form=form|add_form_classes %}
<form method="POST" id="u2f-authenticate-form">{% csrf_token %}
<fieldset class="mb-4">
<p>Please use a security key you have configured. Tick the checkbox below before using the key if you want to remember this device.</p>
{% 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 %}
</fieldset>
{{ form.non_field_errors }}
<div id="webauthn-error"></div>
</form>
{% endwith %}
{% if mfa_alternatives %}
<div class="bid-links">
{% for item in mfa_alternatives %}
<a href="{{ item.href }}">{{ item.label }}</a>
{% endfor %}
</div>
{% endif %}
</div>
<script src="{% static 'mfa/js/webauthn-json.js' %}"></script>
<script>
const form = document.getElementById('u2f-authenticate-form');
const responseInput = document.getElementById('id_response');
const requestOptions = JSON.parse(responseInput.getAttribute('request-options'));
webauthnJSON.get(requestOptions).then(
(credential) => {
responseInput.value = JSON.stringify(credential);
form.submit();
},
(error) => {
document.getElementById('webauthn-error').innerText = 'Something went wrong: ' + error;
}
);
</script>

View File

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

View File

@ -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.
Oleg-Komarov marked this conversation as resolved Outdated

might be out of scope, but setting an ADMIN_EMAIL (like in DevFund) or SUPPORT_EMAIL (not to be confused with builtin settings.ADMINS) configuration variable and passing it to the templates that need it is more maintainable than hard-coding it in multiple files.

might be out of scope, but setting an `ADMIN_EMAIL` (like in DevFund) or `SUPPORT_EMAIL` (not to be confused with builtin `settings.ADMINS`) configuration variable and passing it to the templates that need it is more maintainable than hard-coding it in multiple files.
--
Kind regards,
The Blender Web Team
{% endautoescape %}

View File

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

View File

@ -139,6 +139,11 @@ Profile
<a class="btn" href="{% url 'bid_main:active_sessions' %}">
<span>Active Sessions</span>
</a>
{% if show_mfa %}
<a class="btn" href="{% url 'bid_main:mfa' %}">
<span>Multi-factor Authentication</span>
</a>
{% endif %}
</div>
<div class="btn-row-fluid mt-3">
<a class="btn" href="{% url 'bid_main:password_change' %}">

View File

@ -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 %}
<div class="bix box">Something went wrong</div>
{% endif %}
{% endblock form %}

View File

@ -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 %}
<div class="bid box">
<h2>Delete {{ object.name }}?</h2>
<form method="post">{% csrf_token %}
{% with form=form|add_form_classes %}
{{ form }}
{% endwith %}
<div class="d-inline-flex">
<button type="submit" class="btn btn-danger">Delete</button>
<a class="btn btn-secondary ml-3" href="{% url 'bid_main:mfa' %}">Cancel</a>
</div>
</form>
{% endblock %}

View File

@ -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 %}
<div class="bid box">
<p>
You are going to disable multi-factor authentication (MFA).
You can always configure MFA again.
</p>
<form method="post">{% csrf_token %}
{% with form=form|add_form_classes %}
{% include "components/forms/field.html" with field=form.disable_mfa_confirm %}
{% endwith %}
<div class="d-inline-flex mt-3">
<button type="submit" class="btn btn-danger">Disable</button>
<a class="btn btn-secondary ml-3" href="{% url 'bid_main:mfa' %}">Cancel</a>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,116 @@
{% extends 'layout.html' %}
{% load humanize pipeline static %}
{% block page_title %}
Multi-factor Authentication Setup
{% endblock %}
{% block body %}
<div class="bid box">
<h2>Multi-factor Authentication (MFA) Setup</h2>
{% if user_has_mfa_configured %}
<p>
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.
</p>
{% if show_missing_recovery_codes_warning %}
<p class="text-danger">
Please make sure that you do not lock yourself out:
generate and store <a href="#recovery-codes">recovery codes</a> 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.
</p>
{% endif %}
<p>
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.
</p>
<div>
<a class="btn btn-danger" href="{% url 'bid_main:mfa_disable' %}">Disable MFA</a>
</div>
{% else %}
<p>
MFA makes your account more secure against account takeover attacks.
You can read more in <a href="https://ssd.eff.org/module/how-enable-two-factor-authentication">a guide</a> from Electronic Frontier Foundation.
</p>
<p>
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.
</p>
{% endif %}
</div>
<div class="bid box mt-3">
<h3>Time-based one-time password (TOTP)</h3>
<p>
Also known as authenticator application.
</p>
<p>
If you don't have an authenticator application, you can choose one from a list of <a href="https://en.wikipedia.org/wiki/Comparison_of_OTP_applications">TOTP applications</a>.
</p>
<ul>
{% for d in devices_per_type.totp %}
<li>
{{ d.name }}
{% if d.last_used_at %}(Last used <abbr title="{{ d.last_used_at }}">{{ d.last_used_at|naturaltime }}</abbr>){% endif %}
<a class="btn btn-danger" href="{% url 'bid_main:mfa_delete_device' d.persistent_id %}"><i class="i-trash"></i></a></li>
{% endfor %}
</ul>
<a href="{% url 'bid_main:mfa_totp' %}" class="btn">Configure a new TOTP device</a>
</div>
<div class="bid box mt-3">
<h3>Security keys (U2F, WebAuthn, FIDO2)</h3>
<p>
Hardware security keys, e.g. Yubikeys.
</p>
<p>
Blender ID supports these keys only as a second factor and <strong>does not</strong> provide a passwordless sign-in.
</p>
<ul>
{% for d in devices_per_type.u2f %}
<li>
{{ d.name }}
{% if d.last_used_at %}(Last used <abbr title="{{ d.last_used_at }}">{{ d.last_used_at|naturaltime }}</abbr>){% endif %}
<a class="btn btn-danger" href="{% url 'bid_main:mfa_delete_device' d.persistent_id %}"><i class="i-trash"></i></a></li>
{% endfor %}
</ul>
<a href="{% url 'bid_main:mfa_u2f' %}" class="btn">Configure a new security key</a>
</div>
{% if user_can_setup_recovery %}
<div class="bid box mt-3">
<h3 id="recovery-codes">Recovery codes</h3>
<p>
Oleg-Komarov marked this conversation as resolved
Review

will become invalidated.

"will be invalided" or "will become invalid"

> will become invalidated. "will be invalided" or "will become invalid"
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.
</p>
{% with recovery=devices_per_type.recovery.0 %}
{% if recovery %}
<div class="mb-3">
{% with code_count=recovery_codes|length %}
{{ code_count }} recovery code{{ code_count|pluralize }} remaining
{% if display_recovery_codes %}
<a href="?display_recovery_codes=" class="btn btn-secondary">Hide</a>
{% else %}
<a href="?display_recovery_codes=1#recovery-codes" class="btn btn-secondary">Display</a>
{% endif %}
<form action="{% url 'bid_main:mfa_invalidate_recovery' %}" method="post" class="d-inline-flex">{% csrf_token %}
<button class="btn btn-danger" type="submit">Invalidate</button>
</form>
{% if display_recovery_codes %}
<ul>
{% for code in recovery_codes %}
<li><code>{{ code }}</code></li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
</div>
{% endif %}
<form action="{% url 'bid_main:mfa_generate_recovery' %}" method="post">{% csrf_token %}
<button type="submit" class="btn">{% if recovery %}Regenerate{% else %}Generate{% endif %} recovery codes</button>
</form>
{% endwith %}
</div>
{% endif %}
{% endblock %}

View File

@ -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 %}
<div class="bid box">
<h2>New TOTP device</h2>
<div class="row">
<div class="col-md-6">
<figure>
<img src="data:image/png;base64,{{ qrcode }}" alt="QR code" />
<figcaption class="text-center text-secondary"><details><summary>show secret key for manual entry</summary><code class="text-nowrap">{{ manual_secret_key }}</code></details></figcaption>
</figure>
</div>
<div class="col-md-6">
<ol class="pl-3">
<li>
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.
</li>
<li>Enter a 6-digit code shown in the authenticator.</li>
<li>Pick any device name for your authenticator that you would recognize <span class="text-secondary">(e.g. "FreeOTP on my phone")</span> and submit the form.</li>
{% if first_device %}
<li>Since this is your first MFA device, you will be promted to enter a new code once more to sign-in using MFA.</li>
{% endif %}
</ol>
</div>
</div>
<div class="row">
<div class="col-md-6">
{% with form=form|add_form_classes %}
<form method="post">{% 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 %}
<div class="d-inline-flex">
<button type="submit" class="btn btn-primary">Validate</button>
<a class="btn btn-secondary ml-3" href="{% url 'bid_main:mfa' %}">Cancel</a>
</div>
{% if form.non_field_errors %}
<div class="text-danger mt-3">
something went wrong
</div>
{% endif %}
</form>
{% endwith %}
</div>
</div>
</div>
{% endblock %}

View File

@ -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 %}
<div class="bid box">
<h2>New U2F device</h2>
<div class="row">
<div class="col-md-12">
<p>Please watch <a href="https://www.youtube.com/watch?v=V6mxPS5O-sY">setup video</a> if you are not familiar with yubikeys.</p>
{% if first_device %}
<p>Since this is your first MFA device, you will be promted to use your security key immediately after setup to sign-in using MFA.</p>
{% endif %}
</div>
</div>
<div class="row">
<div class="col-md-6">
{% with form=form|add_form_classes %}
<form method="post" id="u2f-register-form">{% 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 %}
<div class="d-inline-flex">
<button type="submit" class="btn btn-primary">Add security key</button>
<a class="btn btn-secondary ml-3" href="{% url 'bid_main:mfa' %}">Cancel</a>
</div>
{% if form.non_field_errors %}
<div class="text-danger mt-3">
something went wrong
</div>
{% endif %}
</form>
{% endwith %}
</div>
</div>
</div>
<script src="{% static 'mfa/js/webauthn-json.js' %}"></script>
<script>
const form = document.getElementById('u2f-register-form');
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (!form.checkValidity()) {
return false;
}
const credentialInput = document.getElementById('id_credential');
const credentialCreationOptions = JSON.parse(credentialInput.getAttribute('creation-options'));
const credential = await webauthnJSON.create(credentialCreationOptions);
credentialInput.value = JSON.stringify(credential);
form.submit();
});
</script>
{% endblock %}

View File

@ -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 %}
<small class="form-text">{{ form.new_password1.help_text|safe }}</small>
{% endif %}
</div>
{% if with_help_text and field.help_text %}
<small class="form-text">{{ field.help_text|safe }}</small>
{% endif %}
<div class="text-danger">{{ field.errors }}</div>
</div>
{% else %}
<div class="form-group">
{% if not field.is_hidden %}
@ -18,7 +19,7 @@
{{ field }}
<div class="text-danger">{{ field.errors }}</div>
{% if with_help_text and field.help_text %}
<small class="form-text">{{ form.new_password1.help_text|safe }}</small>
<small class="form-text">{{ field.help_text|safe }}</small>
{% endif %}
</div>
{% endif %}

View File

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

245
bid_main/tests/test_mfa.py Normal file
View File

@ -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, '<h2>Account</h2>')
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, '<h2>Account</h2>')
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, '<h2>Account</h2>')
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, '<h2>Account</h2>')
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'<code>([0-9A-F]{16})</code>', 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, '<h2>Account</h2>')
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, '<h2>Account</h2>')
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, '<h2>Account</h2>')

View File

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

View File

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

View File

@ -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/<path:persistent_id>/',
mfa.DeleteDeviceView.as_view(),
name='mfa_delete_device',
),
]
# Only enable this on a dev server:

View File

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

View File

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

193
bid_main/views/mfa.py Normal file
View File

@ -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)
Oleg-Komarov marked this conversation as resolved Outdated

DevFund and other services use send_mail_* for the most part: it's easier to parse visually

DevFund and other services use `send_mail_*` for the most part: it's easier to parse visually
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()
Oleg-Komarov marked this conversation as resolved Outdated

From this line it's not clear that this is recovery codes that are being deleted

From this line it's not clear that this is recovery codes that are being deleted
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()
Oleg-Komarov marked this conversation as resolved Outdated

same as above

same as above
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"
Oleg-Komarov marked this conversation as resolved
Review

is context['first_device'] = not devices_for_user(self.request.user) necessary here as well?

is `context['first_device'] = not devices_for_user(self.request.user)` necessary here as well?
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

View File

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

View File

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

View File

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

View File

@ -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/?$",

View File

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

42
docs/mfa.md Normal file
View File

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

1
mfa/__init__.py Normal file
View File

@ -0,0 +1 @@
default_app_config = "%s.apps.MfaConfig" % __name__

6
mfa/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class MfaConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = "mfa"

51
mfa/fido2.py Normal file
View File

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

235
mfa/forms.py Normal file
View File

@ -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
Oleg-Komarov marked this conversation as resolved
Review

might not be necessary at all, since this isn't a ModelForm?

might not be necessary at all, since this isn't a `ModelForm`?
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,
)

View File

@ -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',
),
),
],
),
]

View File

@ -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,
},
),
]

View File

96
mfa/models.py Normal file
View File

@ -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(),
]

3
mfa/signals.py Normal file
View File

@ -0,0 +1,3 @@
from django.dispatch import Signal
recovery_used = Signal()

View File

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

View File

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

View File

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