Initial mfa support (for internal users) #93591

Merged
Oleg-Komarov merged 46 commits from mfa into main 2024-08-29 11:44:06 +02:00
12 changed files with 575 additions and 24 deletions
Showing only changes of commit 89851cc87e - Show all commits

View File

@ -24,7 +24,7 @@ import user_agents
from . import fields from . import fields
from . import hashers from . import hashers
from mfa.models import EncryptedRecoveryDevice, EncryptedTOTPDevice, devices_for_user 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
@ -550,6 +550,8 @@ class User(AbstractBaseUser, PermissionsMixin):
devices_per_category['recovery'].append(device) devices_per_category['recovery'].append(device)
if isinstance(device, EncryptedTOTPDevice): if isinstance(device, EncryptedTOTPDevice):
devices_per_category['totp'].append(device) devices_per_category['totp'].append(device)
if isinstance(device, U2fDevice):
devices_per_category['u2f'].append(device)
return devices_per_category return devices_per_category

View File

@ -57,6 +57,22 @@ Multi-factor Authentication Setup
<a href="{% url 'bid_main:mfa_totp' %}" class="btn">Configure a new TOTP device</a> <a href="{% url 'bid_main:mfa_totp' %}" class="btn">Configure a new TOTP device</a>
</div> </div>
<div class="bid box mt-3">
<h3>Security keys (U2F, WebAuthn, FIDO2)</h3>
<p>
E.g. yubikey.
</p>
<ul>
{% for d in devices_per_category.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 %} {% if user_can_setup_recovery %}
<div class="bid box mt-3"> <div class="bid box mt-3">
<h3 id="recovery-codes">Recovery codes</h3> <h3 id="recovery-codes">Recovery codes</h3>

View File

@ -0,0 +1,47 @@
{% 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-6">
{% with form=form|add_form_classes %}
<form method="post" id="add-u2f-form">{% csrf_token %}
{% with field=form.name %}
{% include "components/forms/field.html" %}
{% endwith %}
{% with field=form.credential %}
{% include "components/forms/field.html" %}
{% endwith %}
<button type="submit" class="btn" id="add-u2f-submit">Add security key</button>
{% 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('add-u2f-form');
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (!form.checkValidity()) {
return false;
}
const credentialInput = document.getElementById('id_credential');
const credentialCreationOptions = JSON.parse(credentialInput.getAttribute('creation_options'));
const credential = await webauthnJSON.create(credentialCreationOptions);
credentialInput.value = JSON.stringify(credential);
form.submit();
});
</script>
{% endblock %}

View File

@ -171,6 +171,11 @@ urlpatterns = [
mfa.TotpView.as_view(), mfa.TotpView.as_view(),
name='mfa_totp', name='mfa_totp',
), ),
path(
'mfa/u2f/',
mfa.U2fView.as_view(),
name='mfa_u2f',
),
path( path(
# using `path` converter because persistent_id contains a slash # using `path` converter because persistent_id contains a slash
'mfa/delete-device/<path:persistent_id>/', 'mfa/delete-device/<path:persistent_id>/',

View File

@ -1,6 +1,7 @@
from base64 import b32encode, b64encode from base64 import b32encode, b64encode
from binascii import unhexlify from binascii import unhexlify
from io import BytesIO from io import BytesIO
import json
from django.conf import settings from django.conf import settings
from django.db import transaction from django.db import transaction
@ -12,11 +13,13 @@ from django.views.generic.base import View
from django.views.generic.edit import DeleteView, FormView from django.views.generic.edit import DeleteView, FormView
from django_otp.models import Device from django_otp.models import Device
from django_otp.util import random_hex from django_otp.util import random_hex
from fido2.webauthn import AttestedCredentialData
import qrcode import qrcode
from . import mixins from . import mixins
from mfa.forms import DisableMfaForm, TotpMfaForm from mfa.fido2 import register_begin
from mfa.models import EncryptedRecoveryDevice, EncryptedTOTPDevice, devices_for_user from mfa.forms import DisableMfaForm, TotpMfaForm, U2fMfaForm
from mfa.models import EncryptedRecoveryDevice, EncryptedTOTPDevice, U2fDevice, devices_for_user
import bid_main.tasks import bid_main.tasks
@ -89,17 +92,14 @@ class InvalidateRecoveryView(mixins.MfaRequiredIfConfiguredMixin, View):
class TotpView(mixins.MfaRequiredIfConfiguredMixin, FormView): class TotpView(mixins.MfaRequiredIfConfiguredMixin, FormView):
form_class = TotpMfaForm
success_url = reverse_lazy('bid_main:mfa') success_url = reverse_lazy('bid_main:mfa')
template_name = "bid_main/mfa/totp.html" template_name = "bid_main/mfa/totp.html"
def get_form(self, *args, **kwargs):
kwargs = self.get_form_kwargs()
key = self.request.POST.get('key', random_hex(20))
kwargs['initial']['key'] = key
return TotpMfaForm(**kwargs)
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() kwargs = super().get_form_kwargs()
key = self.request.POST.get('key', random_hex(20))
kwargs['initial']['key'] = key
kwargs['user'] = self.request.user kwargs['user'] = self.request.user
return kwargs return kwargs
@ -129,6 +129,40 @@ class TotpView(mixins.MfaRequiredIfConfiguredMixin, FormView):
return super().form_valid(form) return super().form_valid(form)
class U2fView(mixins.MfaRequiredIfConfiguredMixin, FormView):
form_class = U2fMfaForm
success_url = reverse_lazy('bid_main:mfa')
template_name = "bid_main/mfa/u2f.html"
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
rp_id = self.request.get_host().split(':')[0] # remove port, required by webauthn
kwargs['rp_id'] = rp_id
kwargs['user'] = self.request.user
Oleg-Komarov marked this conversation as resolved
Review

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

is `context['first_device'] = not devices_for_user(self.request.user)` necessary here as well?
if self.request.method == 'GET':
credentials = [
AttestedCredentialData(d.credential)
for d in U2fDevice.objects.filter(user=self.request.user).all()
]
credential_creation_options, state = register_begin(
rp_id, self.request.user, credentials,
)
self.request.session['u2f_create_state'] = dict(state)
kwargs['credential_creation_options'] = json.dumps(dict(credential_creation_options))
if self.request.method == 'POST':
kwargs['state'] = self.request.session.pop('u2f_create_state', None)
return kwargs
@transaction.atomic
def form_valid(self, form):
form.save()
if self.request.user.confirmed_email_at:
bid_main.tasks.send_new_mfa_device_email(self.request.user.pk, 'u2f')
return super().form_valid(form)
class DeleteDeviceView(mixins.MfaRequiredMixin, DeleteView): class DeleteDeviceView(mixins.MfaRequiredMixin, DeleteView):
model = Device model = Device
template_name = "bid_main/mfa/delete_device.html" template_name = "bid_main/mfa/delete_device.html"

View File

@ -108,6 +108,7 @@ AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend", "django.contrib.auth.backends.ModelBackend",
] ]
FIDO2_RP_NAME = "Blender ID"
OTP_TOTP_ISSUER = "id.blender.org" OTP_TOTP_ISSUER = "id.blender.org"
ROOT_URLCONF = "blenderid.urls" ROOT_URLCONF = "blenderid.urls"

38
mfa/fido2.py Normal file
View File

@ -0,0 +1,38 @@
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)

View File

@ -8,7 +8,21 @@ from django.utils.translation import gettext_lazy as _
from django_otp.oath import TOTP from django_otp.oath import TOTP
from otp_agents.views import OTPTokenForm from otp_agents.views import OTPTokenForm
from mfa.models import EncryptedTOTPDevice from mfa.fido2 import 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 MfaForm(OTPTokenForm): class MfaForm(OTPTokenForm):
@ -95,9 +109,7 @@ class TotpMfaForm(forms.Form):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user', None) self.user = kwargs.pop('user', None)
kwargs['initial']['signature'] = self.sign( kwargs['initial']['signature'] = _sign(f"{self.user.email}_{kwargs['initial']['key']}")
f"{self.user.email}_{kwargs['initial']['key']}"
)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def clean(self): def clean(self):
@ -105,7 +117,7 @@ class TotpMfaForm(forms.Form):
code = self.data.get('code') code = self.data.get('code')
key = self.data.get('key') key = self.data.get('key')
signature = self.cleaned_data.get('signature') signature = self.cleaned_data.get('signature')
if not self.verify_signature(f'{self.user.email}_{key}', signature): if not _verify_signature(f'{self.user.email}_{key}', signature):
raise forms.ValidationError(_('Invalid signature')) raise forms.ValidationError(_('Invalid signature'))
self.cleaned_data['key'] = key self.cleaned_data['key'] = key
if not TOTP(unhexlify(key)).verify(int(code), tolerance=1): if not TOTP(unhexlify(key)).verify(int(code), tolerance=1):
@ -117,16 +129,32 @@ class TotpMfaForm(forms.Form):
name = self.cleaned_data.get('name') name = self.cleaned_data.get('name')
EncryptedTOTPDevice.objects.create(encrypted_key=key, key='', name=name, user=self.user) EncryptedTOTPDevice.objects.create(encrypted_key=key, key='', name=name, user=self.user)
@classmethod
def sign(cls, payload):
signer = TimestampSigner()
return signer.sign(payload)
@classmethod class U2fMfaForm(forms.Form):
def verify_signature(cls, payload, signature, max_age=3600): credential = forms.JSONField(widget=forms.HiddenInput)
name = forms.CharField(
Oleg-Komarov marked this conversation as resolved
Review

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

might not be necessary at all, since this isn't a `ModelForm`?
max_length=U2fDevice._meta.get_field('name').max_length,
widget=forms.TextInput(
attrs={"placeholder": "device name (for your convenience)"},
),
)
signer = TimestampSigner() def __init__(self, *args, **kwargs):
credential_creation_options = kwargs.pop('credential_creation_options', None)
self.rp_id = kwargs.pop('rp_id', None)
self.state = kwargs.pop('state', None)
self.user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
self.fields['credential'].widget.attrs['creation_options'] = credential_creation_options
def save(self):
credential = self.cleaned_data.get('credential')
name = self.cleaned_data.get('name')
auth_data = None
try: try:
return payload == signer.unsign(signature, max_age=max_age) auth_data = register_complete(self.rp_id, self.state, credential)
except BadSignature: except Exception:
return False raise forms.ValidationError(_('Verification failed'))
U2fDevice.objects.create(
user=self.user, credential=auth_data.credential_data, name=name,
)

View File

@ -0,0 +1,81 @@
# Generated by Django 4.2.13 on 2024-08-19 14:38
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('mfa', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='U2fDevice',
fields=[
(
'id',
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
),
),
(
'name',
models.CharField(
help_text='The human-readable name of this device.', max_length=64
),
),
(
'confirmed',
models.BooleanField(default=True, help_text='Is this device ready for use?'),
),
(
'throttling_failure_timestamp',
models.DateTimeField(
blank=True,
default=None,
help_text='A timestamp of the last failed verification attempt. Null if last attempt succeeded.',
null=True,
),
),
(
'throttling_failure_count',
models.PositiveIntegerField(
default=0, help_text='Number of successive failed attempts.'
),
),
(
'created_at',
models.DateTimeField(
auto_now_add=True,
help_text='The date and time when this device was initially created in the system.',
null=True,
),
),
(
'last_used_at',
models.DateTimeField(
blank=True,
help_text='The most recent date and time this device was used.',
null=True,
),
),
('credential', models.BinaryField()),
(
'user',
models.ForeignKey(
help_text='The user that this device belongs to.',
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
'verbose_name': 'U2F device',
'abstract': False,
},
),
]

View File

@ -11,6 +11,7 @@ the upstream implementation as much as possible.
from binascii import unhexlify from binascii import unhexlify
from django.db import models, transaction 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_static.models import StaticDevice
from django_otp.plugins.otp_totp.models import TOTPDevice from django_otp.plugins.otp_totp.models import TOTPDevice
from nacl_encrypted_fields.fields import NaClCharField from nacl_encrypted_fields.fields import NaClCharField
@ -75,6 +76,13 @@ class EncryptedTOTPDevice(TOTPDevice):
return unhexlify(self.encrypted_key.encode()) 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): def devices_for_user(user):
""" A more specific replacement for upstream devices_for_user. """ A more specific replacement for upstream devices_for_user.
@ -84,4 +92,5 @@ def devices_for_user(user):
return [ return [
*EncryptedRecoveryDevice.objects.filter(user=user).all(), *EncryptedRecoveryDevice.objects.filter(user=user).all(),
*EncryptedTOTPDevice.objects.filter(user=user).all(), *EncryptedTOTPDevice.objects.filter(user=user).all(),
*U2fDevice.objects.filter(user=user).all(),
] ]

View File

@ -0,0 +1,289 @@
// https://github.com/github/webauthn-json
// via https://github.com/pennersr/django-allauth/blob/main/allauth/mfa/static/mfa/js/webauthn-json.js
//
// The MIT License (MIT)
//
// Copyright (c) 2019 GitHub, Inc.
// Copyright (c) 2010-2021 Raymond Penners and contributors
//
// Permission is hereby granted, free of charge, to any person
// obtaining a copy of this software and associated documentation
// files (the "Software"), to deal in the Software without
// restriction, including without limitation the rights to use,
// copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice shall be
// included in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
// OTHER DEALINGS IN THE SOFTWARE.
'use strict';
(() => {
const __defProp = Object.defineProperty
const __export = (target, all) => {
for (const name in all) { __defProp(target, name, { get: all[name], enumerable: true }) }
}
const __async = (__this, __arguments, generator) => {
return new Promise((resolve, reject) => {
const fulfilled = (value) => {
try {
step(generator.next(value))
} catch (e) {
reject(e)
}
}
const rejected = (value) => {
try {
step(generator.throw(value))
} catch (e) {
reject(e)
}
}
var step = (x) => x.done ? resolve(x.value) : Promise.resolve(x.value).then(fulfilled, rejected)
step((generator = generator.apply(__this, __arguments)).next())
})
}
// src/webauthn-json/index.ts
const webauthn_json_exports = {}
__export(webauthn_json_exports, {
create: () => create,
get: () => get,
schema: () => schema,
supported: () => supported
})
// src/webauthn-json/base64url.ts
function base64urlToBuffer (baseurl64String) {
const padding = '=='.slice(0, (4 - baseurl64String.length % 4) % 4)
const base64String = baseurl64String.replace(/-/g, '+').replace(/_/g, '/') + padding
const str = atob(base64String)
const buffer = new ArrayBuffer(str.length)
const byteView = new Uint8Array(buffer)
for (let i = 0; i < str.length; i++) {
byteView[i] = str.charCodeAt(i)
}
return buffer
}
function bufferToBase64url (buffer) {
const byteView = new Uint8Array(buffer)
let str = ''
for (const charCode of byteView) {
str += String.fromCharCode(charCode)
}
const base64String = btoa(str)
const base64urlString = base64String.replace(/\+/g, '-').replace(
/\//g,
'_'
).replace(/=/g, '')
return base64urlString
}
// src/webauthn-json/convert.ts
const copyValue = 'copy'
const convertValue = 'convert'
function convert (conversionFn, schema2, input) {
if (schema2 === copyValue) {
return input
}
if (schema2 === convertValue) {
return conversionFn(input)
}
if (schema2 instanceof Array) {
return input.map((v) => convert(conversionFn, schema2[0], v))
}
if (schema2 instanceof Object) {
const output = {}
for (const [key, schemaField] of Object.entries(schema2)) {
if (schemaField.derive) {
const v = schemaField.derive(input)
if (v !== void 0) {
input[key] = v
}
}
if (!(key in input)) {
if (schemaField.required) {
throw new Error(`Missing key: ${key}`)
}
continue
}
if (input[key] == null) {
output[key] = null
continue
}
output[key] = convert(
conversionFn,
schemaField.schema,
input[key]
)
}
return output
}
}
function derived (schema2, derive) {
return {
required: true,
schema: schema2,
derive
}
}
function required (schema2) {
return {
required: true,
schema: schema2
}
}
function optional (schema2) {
return {
required: false,
schema: schema2
}
}
// src/webauthn-json/basic/schema.ts
const publicKeyCredentialDescriptorSchema = {
type: required(copyValue),
id: required(convertValue),
transports: optional(copyValue)
}
const simplifiedExtensionsSchema = {
appid: optional(copyValue),
appidExclude: optional(copyValue),
credProps: optional(copyValue)
}
const simplifiedClientExtensionResultsSchema = {
appid: optional(copyValue),
appidExclude: optional(copyValue),
credProps: optional(copyValue)
}
const credentialCreationOptions = {
publicKey: required({
rp: required(copyValue),
user: required({
id: required(convertValue),
name: required(copyValue),
displayName: required(copyValue)
}),
challenge: required(convertValue),
pubKeyCredParams: required(copyValue),
timeout: optional(copyValue),
excludeCredentials: optional([publicKeyCredentialDescriptorSchema]),
authenticatorSelection: optional(copyValue),
attestation: optional(copyValue),
extensions: optional(simplifiedExtensionsSchema)
}),
signal: optional(copyValue)
}
const publicKeyCredentialWithAttestation = {
type: required(copyValue),
id: required(copyValue),
rawId: required(convertValue),
authenticatorAttachment: optional(copyValue),
response: required({
clientDataJSON: required(convertValue),
attestationObject: required(convertValue),
transports: derived(
copyValue,
(response) => {
let _a
return ((_a = response.getTransports) == null ? void 0 : _a.call(response)) || []
}
)
}),
clientExtensionResults: derived(
simplifiedClientExtensionResultsSchema,
(pkc) => pkc.getClientExtensionResults()
)
}
const credentialRequestOptions = {
mediation: optional(copyValue),
publicKey: required({
challenge: required(convertValue),
timeout: optional(copyValue),
rpId: optional(copyValue),
allowCredentials: optional([publicKeyCredentialDescriptorSchema]),
userVerification: optional(copyValue),
extensions: optional(simplifiedExtensionsSchema)
}),
signal: optional(copyValue)
}
const publicKeyCredentialWithAssertion = {
type: required(copyValue),
id: required(copyValue),
rawId: required(convertValue),
authenticatorAttachment: optional(copyValue),
response: required({
clientDataJSON: required(convertValue),
authenticatorData: required(convertValue),
signature: required(convertValue),
userHandle: required(convertValue)
}),
clientExtensionResults: derived(
simplifiedClientExtensionResultsSchema,
(pkc) => pkc.getClientExtensionResults()
)
}
var schema = {
credentialCreationOptions,
publicKeyCredentialWithAttestation,
credentialRequestOptions,
publicKeyCredentialWithAssertion
}
// src/webauthn-json/basic/api.ts
function createRequestFromJSON (requestJSON) {
return convert(base64urlToBuffer, credentialCreationOptions, requestJSON)
}
function createResponseToJSON (credential) {
return convert(
bufferToBase64url,
publicKeyCredentialWithAttestation,
credential
)
}
function create (requestJSON) {
return __async(this, null, function * () {
const credential = yield navigator.credentials.create(
createRequestFromJSON(requestJSON)
)
return createResponseToJSON(credential)
})
}
function getRequestFromJSON (requestJSON) {
return convert(base64urlToBuffer, credentialRequestOptions, requestJSON)
}
function getResponseToJSON (credential) {
return convert(
bufferToBase64url,
publicKeyCredentialWithAssertion,
credential
)
}
function get (requestJSON) {
return __async(this, null, function * () {
const credential = yield navigator.credentials.get(
getRequestFromJSON(requestJSON)
)
return getResponseToJSON(credential)
})
}
// src/webauthn-json/basic/supported.ts
function supported () {
return !!(navigator.credentials && navigator.credentials.create && navigator.credentials.get && window.PublicKeyCredential)
}
// src/webauthn-json/browser-global.ts
globalThis.webauthnJSON = webauthn_json_exports
})()
// # sourceMappingURL=webauthn-json.browser-global.js.map

View File

@ -22,6 +22,7 @@ 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"
dj-database-url==2.2.0 dj-database-url==2.2.0
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"