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).
|
See [user_deletion.md](docs/user_deletion.md).
|
||||||
|
|
||||||
|
# Multi-factor authentication
|
||||||
|
|
||||||
|
See [mfa.md](docs/mfa.md).
|
||||||
|
|
||||||
# Troubleshooting
|
# Troubleshooting
|
||||||
|
|
||||||
|
@ -282,3 +282,42 @@ def construct_password_changed(user):
|
|||||||
subject = "Security alert: password changed"
|
subject = "Security alert: password changed"
|
||||||
|
|
||||||
return email_body_txt, subject
|
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):
|
def save(self, *args, **kwargs):
|
||||||
user = super().save(*args, **kwargs)
|
user = super().save(*args, **kwargs)
|
||||||
if user.has_confirmed_email:
|
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
|
return user
|
||||||
|
|
||||||
|
|
||||||
@ -335,7 +335,7 @@ class SetPasswordForm(auth_forms.SetPasswordForm):
|
|||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
user = super().save(*args, **kwargs)
|
user = super().save(*args, **kwargs)
|
||||||
if user.has_confirmed_email:
|
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
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,28 +1,30 @@
|
|||||||
|
from collections import defaultdict
|
||||||
from typing import Optional, Set
|
from typing import Optional, Set
|
||||||
import itertools
|
import itertools
|
||||||
import logging
|
import logging
|
||||||
import os.path
|
import os.path
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from django import urls
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
|
from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
|
||||||
from django.contrib.auth.models import PermissionsMixin
|
from django.contrib.auth.models import PermissionsMixin
|
||||||
from django.contrib.sessions.models import Session
|
from django.contrib.sessions.models import Session
|
||||||
from django.core import validators
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core import validators
|
||||||
from django.core.mail import send_mail
|
from django.core.mail import send_mail
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
from django import urls
|
||||||
from django.templatetags.static import static
|
from django.templatetags.static import static
|
||||||
from django.utils import timezone
|
|
||||||
from django.utils.deconstruct import deconstructible
|
from django.utils.deconstruct import deconstructible
|
||||||
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
import oauth2_provider.models as oa2_models
|
import oauth2_provider.models as oa2_models
|
||||||
import user_agents
|
import user_agents
|
||||||
|
|
||||||
from . import fields
|
from . import fields
|
||||||
from . import hashers
|
from . import hashers
|
||||||
|
from mfa.models import EncryptedRecoveryDevice, EncryptedTOTPDevice, U2fDevice, devices_for_user
|
||||||
import bid_main.file_utils
|
import bid_main.file_utils
|
||||||
import bid_main.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(static(settings.AVATAR_DEFAULT_FILENAME))
|
||||||
return bid_main.file_utils.get_absolute_url(self.avatar.storage.url(default_thumbnail_path))
|
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):
|
class SettingValueField(models.CharField):
|
||||||
def __init__(self, *args, **kwargs): # noqa: D107
|
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 django.dispatch import receiver
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
from mfa.signals import recovery_used
|
||||||
import bid_main.utils as utils
|
import bid_main.utils as utils
|
||||||
import bid_main.file_utils
|
import bid_main.file_utils
|
||||||
import bid_main.tasks
|
import bid_main.tasks
|
||||||
@ -38,7 +39,7 @@ def process_new_login(sender, request, user, **kwargs):
|
|||||||
fields.update({"last_login_ip", "current_login_ip"})
|
fields.update({"last_login_ip", "current_login_ip"})
|
||||||
|
|
||||||
if user.has_confirmed_email:
|
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,
|
user_pk=user.pk,
|
||||||
session_data={
|
session_data={
|
||||||
'device': str(user_session.device or 'Unknown'),
|
'device': str(user_session.device or 'Unknown'),
|
||||||
@ -79,3 +80,10 @@ def delete_orphaned_avatar_files(sender, instance, **kwargs):
|
|||||||
return
|
return
|
||||||
|
|
||||||
bid_main.file_utils.delete_avatar_files(instance.avatar.name)
|
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})
|
@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)
|
user = User.objects.get(pk=user_pk)
|
||||||
log.info("sending a new user session email for account %s", 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})
|
@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)
|
user = User.objects.get(pk=user_pk)
|
||||||
log.info("sending a password change email for account %s", 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.
|
from_email=None, # just use the configured default From-address.
|
||||||
recipient_list=[email],
|
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' %}">
|
<a class="btn" href="{% url 'bid_main:active_sessions' %}">
|
||||||
<span>Active Sessions</span>
|
<span>Active Sessions</span>
|
||||||
</a>
|
</a>
|
||||||
|
{% if show_mfa %}
|
||||||
|
<a class="btn" href="{% url 'bid_main:mfa' %}">
|
||||||
|
<span>Multi-factor Authentication</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-row-fluid mt-3">
|
<div class="btn-row-fluid mt-3">
|
||||||
<a class="btn" href="{% url 'bid_main:password_change' %}">
|
<a class="btn" href="{% url 'bid_main:password_change' %}">
|
||||||
|
@ -3,5 +3,13 @@
|
|||||||
{% block page_title %}Sign in{% endblock %}
|
{% block page_title %}Sign in{% endblock %}
|
||||||
|
|
||||||
{% block form %}
|
{% block form %}
|
||||||
|
{% if form_type == 'login' %}
|
||||||
{% include 'bid_main/components/login_form.html' %}
|
{% 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 %}
|
{% 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 }}
|
{{ field }}
|
||||||
{% if not field.is_hidden %}
|
{% if not field.is_hidden %}
|
||||||
{% include 'components/forms/label.html' with label_class="form-check-label" %}
|
{% 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 %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if with_help_text and field.help_text %}
|
||||||
|
<small class="form-text">{{ field.help_text|safe }}</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="text-danger">{{ field.errors }}</div>
|
<div class="text-danger">{{ field.errors }}</div>
|
||||||
</div>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
{% if not field.is_hidden %}
|
{% if not field.is_hidden %}
|
||||||
@ -18,7 +19,7 @@
|
|||||||
{{ field }}
|
{{ field }}
|
||||||
<div class="text-danger">{{ field.errors }}</div>
|
<div class="text-danger">{{ field.errors }}</div>
|
||||||
{% if with_help_text and field.help_text %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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(
|
@patch(
|
||||||
'bid_main.tasks.send_password_changed_email',
|
'bid_main.tasks.send_mail_password_changed',
|
||||||
new=bid_main.tasks.send_password_changed_email.task_function,
|
new=bid_main.tasks.send_mail_password_changed.task_function,
|
||||||
)
|
)
|
||||||
@patch(
|
@patch(
|
||||||
'django.contrib.auth.base_user.AbstractBaseUser.check_password',
|
'django.contrib.auth.base_user.AbstractBaseUser.check_password',
|
||||||
|
@ -50,8 +50,8 @@ class TestActiveSessions(TestCase):
|
|||||||
|
|
||||||
class TestNewUserSessionEmail(TestCase):
|
class TestNewUserSessionEmail(TestCase):
|
||||||
@patch(
|
@patch(
|
||||||
'bid_main.tasks.send_new_user_session_email',
|
'bid_main.tasks.send_mail_new_user_session',
|
||||||
new=bid_main.tasks.send_new_user_session_email.task_function,
|
new=bid_main.tasks.send_mail_new_user_session.task_function,
|
||||||
)
|
)
|
||||||
@patch(
|
@patch(
|
||||||
'django.contrib.auth.base_user.AbstractBaseUser.check_password',
|
'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 django.contrib.auth import views as auth_views
|
||||||
|
|
||||||
from . import forms
|
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"
|
app_name = "bid_main"
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@ -146,6 +146,42 @@ urlpatterns = [
|
|||||||
normal_pages.TerminateSessionView.as_view(),
|
normal_pages.TerminateSessionView.as_view(),
|
||||||
name='terminate_session',
|
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:
|
# Only enable this on a dev server:
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
"""Pages for displaying and editing OAuth applications."""
|
"""Pages for displaying and editing OAuth applications."""
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
|
||||||
from django.contrib.messages.views import SuccessMessageMixin
|
from django.contrib.messages.views import SuccessMessageMixin
|
||||||
from django.forms import inlineformset_factory
|
from django.forms import inlineformset_factory
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.views.generic import ListView
|
from django.views.generic import ListView
|
||||||
from django.views.generic.edit import UpdateView
|
from django.views.generic.edit import UpdateView
|
||||||
|
|
||||||
import bid_main.forms
|
from bid_main.views import mixins
|
||||||
import bid_api.forms
|
import bid_api.forms
|
||||||
import bid_api.models
|
import bid_api.models
|
||||||
|
import bid_main.forms
|
||||||
import bid_main.models
|
import bid_main.models
|
||||||
|
|
||||||
WebhookFormSet = inlineformset_factory(
|
WebhookFormSet = inlineformset_factory(
|
||||||
@ -28,14 +28,21 @@ class _OwnedOAuth2ApplicationsMixin:
|
|||||||
return self.request.user.bid_main_oauth2application
|
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."""
|
"""List all OAuth 2 applications that currently logged in user is allowed to manage."""
|
||||||
|
|
||||||
template_name = 'bid_main/developer_applications.html'
|
template_name = 'bid_main/developer_applications.html'
|
||||||
|
|
||||||
|
|
||||||
class EditApplicationView(
|
class EditApplicationView(
|
||||||
LoginRequiredMixin, SuccessMessageMixin, _OwnedOAuth2ApplicationsMixin, UpdateView
|
mixins.MfaRequiredIfConfiguredMixin,
|
||||||
|
SuccessMessageMixin,
|
||||||
|
_OwnedOAuth2ApplicationsMixin,
|
||||||
|
UpdateView,
|
||||||
):
|
):
|
||||||
"""Edit an OAuth 2 application, if allowed."""
|
"""Edit an OAuth 2 application, if allowed."""
|
||||||
|
|
||||||
|
@ -7,17 +7,17 @@ than via bearer tokens.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.views.generic import View
|
from django.views.generic import View
|
||||||
|
|
||||||
from .. import models
|
from .. import models
|
||||||
|
from bid_main.views import mixins
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class BadgeTogglePrivateView(LoginRequiredMixin, View):
|
class BadgeTogglePrivateView(mixins.MfaRequiredIfConfiguredMixin, View):
|
||||||
"""JSON endpoint that toggles 'is_private' flag for badges."""
|
"""JSON endpoint that toggles 'is_private' flag for badges."""
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs) -> JsonResponse:
|
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 logging
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
|
from django.contrib.auth.mixins import AccessMixin
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
|
from otp_agents.decorators import otp_required
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -35,3 +37,23 @@ class RedirectToPrivacyAgreeMixin:
|
|||||||
redirect_to = f"{self.privacy_policy_agree_url}?{next_url_qs}"
|
redirect_to = f"{self.privacy_policy_agree_url}?{next_url_qs}"
|
||||||
log.debug("Directing user to %s", redirect_to)
|
log.debug("Directing user to %s", redirect_to)
|
||||||
return 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.
|
email confirmation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import views as auth_views, logout, get_user_model
|
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.core.exceptions import ValidationError
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from django.db import transaction, IntegrityError
|
from django.db import transaction, IntegrityError
|
||||||
@ -20,24 +20,30 @@ from django.urls import reverse_lazy
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views.decorators.cache import never_cache
|
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.decorators.debug import sensitive_post_parameters
|
||||||
from django.views.generic import TemplateView, FormView
|
from django.views.generic import TemplateView, FormView
|
||||||
from django.views.generic.base import View
|
from django.views.generic.base import View
|
||||||
from django.views.generic.edit import UpdateView
|
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 loginas.utils
|
||||||
import oauth2_provider.models as oauth2_models
|
import oauth2_provider.models as oauth2_models
|
||||||
|
|
||||||
from .. import forms, email
|
from .. import forms, email
|
||||||
from . import mixins
|
from . import mixins
|
||||||
from bid_main.email import send_verify_address
|
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
|
import bid_main.file_utils
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class IndexView(LoginRequiredMixin, mixins.PageIdMixin, TemplateView):
|
class IndexView(mixins.MfaRequiredIfConfiguredMixin, mixins.PageIdMixin, TemplateView):
|
||||||
page_id = "index"
|
page_id = "index"
|
||||||
template_name = "bid_main/index.html"
|
template_name = "bid_main/index.html"
|
||||||
login_url = reverse_lazy("bid_main:login")
|
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)
|
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 {
|
return {
|
||||||
**super().get_context_data(**kwargs),
|
**super().get_context_data(**kwargs),
|
||||||
"apps": apps,
|
"apps": apps,
|
||||||
"cloud_needs_renewal": (
|
"cloud_needs_renewal": (
|
||||||
"cloud_has_subscription" in role_names and "cloud_subscriber" not in role_names
|
"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()},
|
"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):
|
class LoginView(mixins.RedirectToPrivacyAgreeMixin, mixins.PageIdMixin, django_otp.views.LoginView):
|
||||||
"""Shows the login view."""
|
"""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"
|
page_id = "login"
|
||||||
template_name = "bid_main/login.html"
|
|
||||||
authentication_form = forms.AuthenticationForm
|
|
||||||
redirect_authenticated_user = True
|
redirect_authenticated_user = True
|
||||||
success_url_allowed_hosts = settings.NEXT_REDIR_AFTER_LOGIN_ALLOWED_HOSTS
|
success_url_allowed_hosts = settings.NEXT_REDIR_AFTER_LOGIN_ALLOWED_HOSTS
|
||||||
|
template_name = "bid_main/login.html"
|
||||||
|
|
||||||
authorize_url = reverse_lazy("oauth2_provider:authorize")
|
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(sensitive_post_parameters())
|
||||||
@method_decorator(csrf_exempt)
|
@method_decorator(csrf_protect)
|
||||||
@method_decorator(never_cache)
|
@method_decorator(never_cache)
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
"""Don't check CSRF token when already authenticated."""
|
"""This is a tweaked implementation of django.contrib.auth.view.LoginView.dispatch method.
|
||||||
if self.redirect_authenticated_user and self.request.user.is_authenticated:
|
|
||||||
|
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()
|
redirect_to = self.get_success_url()
|
||||||
if redirect_to == self.request.path:
|
if redirect_to == self.request.path:
|
||||||
raise ValueError(
|
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."
|
"your LOGIN_REDIRECT_URL doesn't point to a login page."
|
||||||
)
|
)
|
||||||
return HttpResponseRedirect(redirect_to)
|
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:
|
def get_context_data(self, **kwargs) -> dict:
|
||||||
ctx = super().get_context_data(**kwargs)
|
ctx = super().get_context_data(**kwargs)
|
||||||
self.find_oauth_flow(ctx)
|
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
|
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):
|
def find_oauth_flow(self, ctx: dict):
|
||||||
"""Figure out if this is an OAuth flow, and for which OAuth Client."""
|
"""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
|
return next_url
|
||||||
|
|
||||||
|
|
||||||
class ProfileView(LoginRequiredMixin, UpdateView):
|
class ProfileView(mixins.MfaRequiredIfConfiguredMixin, UpdateView):
|
||||||
form_class = forms.UserProfileForm
|
form_class = forms.UserProfileForm
|
||||||
model = User
|
model = User
|
||||||
template_name = "bid_main/profile.html"
|
template_name = "bid_main/profile.html"
|
||||||
@ -283,7 +395,11 @@ class ProfileView(LoginRequiredMixin, UpdateView):
|
|||||||
return success_resp
|
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"
|
template_name = "bid_main/switch_user.html"
|
||||||
form_class = forms.AuthenticationForm
|
form_class = forms.AuthenticationForm
|
||||||
success_url_allowed_hosts = settings.NEXT_REDIR_AFTER_LOGIN_ALLOWED_HOSTS
|
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")
|
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"
|
page_id = "auth_tokens"
|
||||||
template_name = "bid_main/auth_tokens.html"
|
template_name = "bid_main/auth_tokens.html"
|
||||||
form_class = forms.AppRevokeTokensForm
|
form_class = forms.AppRevokeTokensForm
|
||||||
@ -345,7 +466,7 @@ class ApplicationTokenView(mixins.PageIdMixin, LoginRequiredMixin, GetAppsMixin,
|
|||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
|
||||||
class PrivacyPolicyAgreeView(mixins.PageIdMixin, LoginRequiredMixin, FormView):
|
class PrivacyPolicyAgreeView(mixins.PageIdMixin, mixins.MfaRequiredIfConfiguredMixin, FormView):
|
||||||
page_id = "privacy_policy_agree"
|
page_id = "privacy_policy_agree"
|
||||||
template_name = "bid_main/privacy_policy_agree.html"
|
template_name = "bid_main/privacy_policy_agree.html"
|
||||||
form_class = forms.PrivacyPolicyAgreeForm
|
form_class = forms.PrivacyPolicyAgreeForm
|
||||||
@ -386,7 +507,7 @@ class PrivacyPolicyAgreeView(mixins.PageIdMixin, LoginRequiredMixin, FormView):
|
|||||||
|
|
||||||
|
|
||||||
class DeleteUserView(
|
class DeleteUserView(
|
||||||
mixins.RedirectToPrivacyAgreeMixin, LoginRequiredMixin, GetAppsMixin, FormView
|
mixins.RedirectToPrivacyAgreeMixin, mixins.MfaRequiredIfConfiguredMixin, GetAppsMixin, FormView
|
||||||
):
|
):
|
||||||
template_name = "bid_main/delete_user.html"
|
template_name = "bid_main/delete_user.html"
|
||||||
form_class = forms.DeleteForm
|
form_class = forms.DeleteForm
|
||||||
@ -432,7 +553,7 @@ class DeleteUserView(
|
|||||||
return render(self.request, "bid_main/delete_user/confirm.html", context=ctx)
|
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"
|
template_name = "bid_main/active_sessions.html"
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
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):
|
def post(self, request, *args, **kwargs):
|
||||||
user_session_pk = kwargs.get('pk')
|
user_session_pk = kwargs.get('pk')
|
||||||
if user_session := self.request.user.sessions.filter(pk=user_session_pk).first():
|
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 import views as auth_views
|
||||||
from django.contrib.auth.forms import PasswordResetForm
|
from django.contrib.auth.forms import PasswordResetForm
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import transaction, IntegrityError
|
from django.db import transaction, IntegrityError
|
||||||
from django.http import HttpResponseBadRequest, JsonResponse
|
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 django.views.generic import CreateView, TemplateView, FormView, View
|
||||||
|
|
||||||
from .. import forms, email
|
from .. import forms, email
|
||||||
|
from . import mixins
|
||||||
from ..models import User
|
from ..models import User
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@ -91,7 +91,7 @@ class InitialSetPasswordView(auth_views.PasswordResetConfirmView):
|
|||||||
form_class = forms.SetInitialPasswordForm
|
form_class = forms.SetInitialPasswordForm
|
||||||
|
|
||||||
|
|
||||||
class ConfirmEmailView(LoginRequiredMixin, FormView):
|
class ConfirmEmailView(mixins.MfaRequiredIfConfiguredMixin, FormView):
|
||||||
template_name = "bid_main/confirm_email/start.html"
|
template_name = "bid_main/confirm_email/start.html"
|
||||||
form_class = forms.ConfirmEmailStartForm
|
form_class = forms.ConfirmEmailStartForm
|
||||||
log = logging.getLogger(f"{__name__}.ConfirmEmailView")
|
log = logging.getLogger(f"{__name__}.ConfirmEmailView")
|
||||||
@ -127,7 +127,7 @@ class ConfirmEmailView(LoginRequiredMixin, FormView):
|
|||||||
return redirect("bid_main:confirm-email-sent")
|
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."""
|
"""Cancel the user's email change and redirect to the profile page."""
|
||||||
|
|
||||||
log = logging.getLogger(f"{__name__}.CancelEmailChangeView")
|
log = logging.getLogger(f"{__name__}.CancelEmailChangeView")
|
||||||
@ -142,11 +142,11 @@ class CancelEmailChangeView(LoginRequiredMixin, View):
|
|||||||
return redirect("bid_main:index")
|
return redirect("bid_main:index")
|
||||||
|
|
||||||
|
|
||||||
class ConfirmEmailSentView(LoginRequiredMixin, TemplateView):
|
class ConfirmEmailSentView(mixins.MfaRequiredIfConfiguredMixin, TemplateView):
|
||||||
template_name = "bid_main/confirm_email/sent.html"
|
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.
|
"""Returns JSON indicating when the email address has last been confirmed.
|
||||||
|
|
||||||
The timestamp is returned as ISO 8601 to allow future periodic checks
|
The timestamp is returned as ISO 8601 to allow future periodic checks
|
||||||
@ -167,7 +167,7 @@ class ConfirmEmailPollView(LoginRequiredMixin, View):
|
|||||||
return JsonResponse({"confirmed": timestamp})
|
return JsonResponse({"confirmed": timestamp})
|
||||||
|
|
||||||
|
|
||||||
class ConfirmEmailVerifiedView(LoginRequiredMixin, TemplateView):
|
class ConfirmEmailVerifiedView(mixins.MfaRequiredIfConfiguredMixin, TemplateView):
|
||||||
"""Render explanation on GET, handle confirmation on POST.
|
"""Render explanation on GET, handle confirmation on POST.
|
||||||
|
|
||||||
We only perform the actual database change on a POST, since that protects
|
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.urls import re_path
|
||||||
from django.views.generic import RedirectView
|
from django.views.generic import RedirectView
|
||||||
|
|
||||||
from oauth2_provider import views as default_oauth2_views
|
from oauth2_provider import views as default_oauth2_views
|
||||||
from oauth2_provider import urls as default_oauth2_urls
|
from oauth2_provider import urls as default_oauth2_urls
|
||||||
|
from otp_agents.decorators import otp_required
|
||||||
|
|
||||||
|
|
||||||
app_name = "oauth2_provider"
|
app_name = "oauth2_provider"
|
||||||
urlpatterns = (
|
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"^token/?$", default_oauth2_views.TokenView.as_view(), name="token"),
|
||||||
re_path(
|
re_path(
|
||||||
r"^revoke/?$",
|
r"^revoke/?$",
|
||||||
|
@ -38,9 +38,25 @@ PREFERRED_SCHEME = "https"
|
|||||||
# Update this to something unique for your machine.
|
# Update this to something unique for your machine.
|
||||||
SECRET_KEY = os.getenv('SECRET_KEY', 'default-dev-secret')
|
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!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = bool(os.getenv('DEBUG', False))
|
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']
|
TESTING = sys.argv[1:2] == ['test']
|
||||||
|
|
||||||
ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', 'id.local').split(',')
|
ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', 'id.local').split(',')
|
||||||
@ -58,15 +74,21 @@ INSTALLED_APPS = [
|
|||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
"django.contrib.sites",
|
"django.contrib.sites",
|
||||||
"django.contrib.flatpages",
|
"django.contrib.flatpages",
|
||||||
|
"django_agent_trust",
|
||||||
|
"django_otp",
|
||||||
|
"django_otp.plugins.otp_static",
|
||||||
|
"django_otp.plugins.otp_totp",
|
||||||
"oauth2_provider",
|
"oauth2_provider",
|
||||||
"pipeline",
|
"pipeline",
|
||||||
"sorl.thumbnail",
|
"sorl.thumbnail",
|
||||||
"django_admin_select2",
|
"django_admin_select2",
|
||||||
"loginas",
|
"loginas",
|
||||||
|
"nacl_encrypted_fields",
|
||||||
"bid_main",
|
"bid_main",
|
||||||
"bid_api",
|
"bid_api",
|
||||||
"bid_addon_support",
|
"bid_addon_support",
|
||||||
"background_task",
|
"background_task",
|
||||||
|
"mfa",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
@ -75,6 +97,8 @@ MIDDLEWARE = [
|
|||||||
"django.middleware.common.CommonMiddleware",
|
"django.middleware.common.CommonMiddleware",
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
|
"django_agent_trust.middleware.AgentMiddleware",
|
||||||
|
"django_otp.middleware.OTPMiddleware",
|
||||||
"bid_main.middleware.user_session_middleware",
|
"bid_main.middleware.user_session_middleware",
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
@ -86,6 +110,9 @@ AUTHENTICATION_BACKENDS = [
|
|||||||
"django.contrib.auth.backends.ModelBackend",
|
"django.contrib.auth.backends.ModelBackend",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
FIDO2_RP_NAME = "Blender ID"
|
||||||
|
OTP_TOTP_ISSUER = "id.blender.org"
|
||||||
|
|
||||||
ROOT_URLCONF = "blenderid.urls"
|
ROOT_URLCONF = "blenderid.urls"
|
||||||
|
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
@ -264,6 +291,10 @@ NEXT_REDIR_AFTER_LOGIN_ALLOWED_HOSTS = {
|
|||||||
"blender.community",
|
"blender.community",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AGENT_COOKIE_SECURE = True
|
||||||
|
AGENT_TRUST_DAYS = 30
|
||||||
|
AGENT_INACTIVITY_DAYS = 7
|
||||||
|
|
||||||
CSRF_COOKIE_SECURE = True
|
CSRF_COOKIE_SECURE = True
|
||||||
CSRF_FAILURE_VIEW = "bid_main.views.errors.csrf_failure"
|
CSRF_FAILURE_VIEW = "bid_main.views.errors.csrf_failure"
|
||||||
CSRF_TRUSTED_ORIGINS = ['https://*.blender.org']
|
CSRF_TRUSTED_ORIGINS = ['https://*.blender.org']
|
||||||
@ -355,8 +386,9 @@ if TESTING:
|
|||||||
# For Debug Toolbar, extend with whatever address you use to connect
|
# For Debug Toolbar, extend with whatever address you use to connect
|
||||||
# to your dev server.
|
# to your dev server.
|
||||||
if DEBUG:
|
if DEBUG:
|
||||||
|
AGENT_COOKIE_SECURE = False
|
||||||
CSRF_COOKIE_SECURE = False
|
CSRF_COOKIE_SECURE = False
|
||||||
INSTALLED_APPS += ['debug_toolbar']
|
INSTALLED_APPS += ['debug_toolbar', 'sslserver']
|
||||||
INTERNAL_IPS = ["127.0.0.1"]
|
INTERNAL_IPS = ["127.0.0.1"]
|
||||||
MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware']
|
MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware']
|
||||||
SESSION_COOKIE_SECURE = False
|
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(',')]
|
ADMINS = [[_.strip() for _ in adm.split(':')] for adm in os.environ.get('ADMINS').split(',')]
|
||||||
EMAIL_SUBJECT_PREFIX = f'[{ALLOWED_HOSTS[0]}]'
|
EMAIL_SUBJECT_PREFIX = f'[{ALLOWED_HOSTS[0]}]'
|
||||||
SERVER_EMAIL = f'django@{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"
|
cryptography==41.0.0 ; python_version >= "3.8" and python_version < "4"
|
||||||
csscompressor==0.9.5 ; 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"
|
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-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-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-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-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-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-pipeline==3.1.0 ; python_version >= "3.8" and python_version < "4"
|
||||||
django==4.2.13 ; python_version >= "3.8" and python_version < "4"
|
dj-database-url==2.2.0
|
||||||
django[bcrypt]==4.2.13 ; python_version >= "3.8" and python_version < "4"
|
|
||||||
docutils==0.14 ; python_version >= "3.8" and python_version < "4"
|
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"
|
htmlmin==0.1.12 ; python_version >= "3.8" and python_version < "4"
|
||||||
idna==2.8 ; 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"
|
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"
|
pygments==2.17.2 ; python_version >= "3.8" and python_version < "4"
|
||||||
pyinstrument==4.6.0 ; 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"
|
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"
|
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"
|
python-dateutil==2.8.1 ; python_version >= "3.8" and python_version < "4"
|
||||||
pytz==2019.3 ; 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"
|
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"
|
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"
|
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"
|
six==1.12.0 ; python_version >= "3.8" and python_version < "4"
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
Faker==20.1.0
|
Faker==20.1.0
|
||||||
charset-normalizer==3.3.2
|
charset-normalizer==3.3.2
|
||||||
django-debug-toolbar==4.4.6
|
django-debug-toolbar==4.4.6
|
||||||
|
django-sslserver==0.22
|
||||||
factory-boy==3.3.0
|
factory-boy==3.3.0
|
||||||
flake8==6.1.0
|
flake8==6.1.0
|
||||||
freezegun==1.3.1
|
freezegun==1.3.1
|
||||||
|
Loading…
Reference in New Issue
Block a user
"will be invalided" or "will become invalid"