Initial mfa support (for internal users) #93591
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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],
|
||||
)
|
||||
|
42
bid_main/templates/bid_main/components/mfa_form.html
Normal file
42
bid_main/templates/bid_main/components/mfa_form.html
Normal 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>
|
43
bid_main/templates/bid_main/components/u2f_form.html
Normal file
43
bid_main/templates/bid_main/components/u2f_form.html
Normal 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>
|
9
bid_main/templates/bid_main/emails/mfa_disabled.txt
Normal file
9
bid_main/templates/bid_main/emails/mfa_disabled.txt
Normal 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 %}
|
11
bid_main/templates/bid_main/emails/mfa_new_device.txt
Normal file
11
bid_main/templates/bid_main/emails/mfa_new_device.txt
Normal 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
|
||||
|
||||
--
|
||||
Kind regards,
|
||||
The Blender Web Team
|
||||
{% endautoescape %}
|
11
bid_main/templates/bid_main/emails/mfa_recovery_used.txt
Normal file
11
bid_main/templates/bid_main/emails/mfa_recovery_used.txt
Normal 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 %}
|
@ -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' %}">
|
||||
|
@ -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 %}
|
||||
|
20
bid_main/templates/bid_main/mfa/delete_device.html
Normal file
20
bid_main/templates/bid_main/mfa/delete_device.html
Normal 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 %}
|
23
bid_main/templates/bid_main/mfa/disable.html
Normal file
23
bid_main/templates/bid_main/mfa/disable.html
Normal 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 %}
|
116
bid_main/templates/bid_main/mfa/setup.html
Normal file
116
bid_main/templates/bid_main/mfa/setup.html
Normal 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
Anna Sirota
commented
"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 %}
|
54
bid_main/templates/bid_main/mfa/totp_register.html
Normal file
54
bid_main/templates/bid_main/mfa/totp_register.html
Normal 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 %}
|
56
bid_main/templates/bid_main/mfa/u2f_register.html
Normal file
56
bid_main/templates/bid_main/mfa/u2f_register.html
Normal 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 %}
|
@ -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 %}
|
||||
|
12
bid_main/templatetags/common.py
Normal file
12
bid_main/templatetags/common.py
Normal 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
245
bid_main/tests/test_mfa.py
Normal 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>')
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -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:
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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
193
bid_main/views/mfa.py
Normal 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
Anna Sirota
commented
DevFund and other services use 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
Anna Sirota
commented
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
Anna Sirota
commented
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
Anna Sirota
commented
is 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
|
@ -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)
|
||||
|
@ -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():
|
||||
|
@ -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
|
||||
|
@ -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/?$",
|
||||
|
@ -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
42
docs/mfa.md
Normal 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
1
mfa/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
default_app_config = "%s.apps.MfaConfig" % __name__
|
6
mfa/apps.py
Normal file
6
mfa/apps.py
Normal 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
51
mfa/fido2.py
Normal 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
235
mfa/forms.py
Normal 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
Anna Sirota
commented
might not be necessary at all, since this isn't a 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,
|
||||
)
|
70
mfa/migrations/0001_initial.py
Normal file
70
mfa/migrations/0001_initial.py
Normal 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',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
81
mfa/migrations/0002_u2fdevice.py
Normal file
81
mfa/migrations/0002_u2fdevice.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
0
mfa/migrations/__init__.py
Normal file
0
mfa/migrations/__init__.py
Normal file
96
mfa/models.py
Normal file
96
mfa/models.py
Normal 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
3
mfa/signals.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.dispatch import Signal
|
||||
|
||||
recovery_used = Signal()
|
289
mfa/static/mfa/js/webauthn-json.js
Normal file
289
mfa/static/mfa/js/webauthn-json.js
Normal 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
|
@ -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"
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user
might be out of scope, but setting an
ADMIN_EMAIL
(like in DevFund) orSUPPORT_EMAIL
(not to be confused with builtinsettings.ADMINS
) configuration variable and passing it to the templates that need it is more maintainable than hard-coding it in multiple files.