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.
|
||||
|
||||
--
|
||||
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
|
||||
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)
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class GenerateRecoveryView(mixins.MfaRequiredIfConfiguredMixin, View):
|
||||
@transaction.atomic
|
||||
def post(self, request, *args, **kwargs):
|
||||
user = self.request.user
|
||||
if not list(
|
||||
filter(lambda d: not isinstance(d, EncryptedRecoveryDevice), devices_for_user(user))
|
||||
):
|
||||
# Forbid setting up recovery codes unless the user already has some other method
|
||||
return HttpResponseBadRequest("can't setup recovery codes before other methods")
|
||||
EncryptedRecoveryDevice.objects.filter(user=user).delete()
|
||||
device = EncryptedRecoveryDevice.objects.create(name='recovery', user=user)
|
||||
for _ in range(10):
|
||||
# https://pages.nist.gov/800-63-3/sp800-63b.html#5122-look-up-secret-verifiers
|
||||
# don't use less than 64 bits
|
||||
device.encryptedtoken_set.create(encrypted_token=random_hex(8).upper())
|
||||
return redirect(reverse('bid_main:mfa') + '?display_recovery_codes=1#recovery-codes')
|
||||
|
||||
|
||||
class InvalidateRecoveryView(mixins.MfaRequiredMixin, View):
|
||||
def post(self, request, *args, **kwargs):
|
||||
user = self.request.user
|
||||
EncryptedRecoveryDevice.objects.filter(user=user).delete()
|
||||
return redirect('bid_main:mfa')
|
||||
|
||||
|
||||
class TotpRegisterView(mixins.MfaRequiredIfConfiguredMixin, FormView):
|
||||
form_class = TotpRegisterForm
|
||||
success_url = reverse_lazy('bid_main:mfa')
|
||||
template_name = "bid_main/mfa/totp_register.html"
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
key = self.request.POST.get('key', random_hex(20))
|
||||
kwargs['initial']['key'] = key
|
||||
kwargs['user'] = self.request.user
|
||||
return kwargs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
key = context['form'].initial['key']
|
||||
b32key = b32encode(unhexlify(key)).decode('utf-8')
|
||||
context['manual_secret_key'] = b32key
|
||||
device = EncryptedTOTPDevice(encrypted_key=key, user=self.request.user)
|
||||
context['config_url'] = device.config_url
|
||||
image = qrcode.make(
|
||||
device.config_url,
|
||||
border=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_H,
|
||||
)
|
||||
buf = BytesIO()
|
||||
image.save(buf)
|
||||
context['qrcode'] = b64encode(buf.getvalue()).decode('utf-8')
|
||||
context['first_device'] = not devices_for_user(self.request.user)
|
||||
return context
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
form.save()
|
||||
if self.request.user.confirmed_email_at:
|
||||
bid_main.tasks.send_mail_mfa_new_device(self.request.user.pk, 'totp')
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class U2fRegisterView(mixins.MfaRequiredIfConfiguredMixin, FormView):
|
||||
form_class = U2fRegisterForm
|
||||
success_url = reverse_lazy('bid_main:mfa')
|
||||
template_name = "bid_main/mfa/u2f_register.html"
|
||||
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
"will be invalided" or "will become invalid"