Initial mfa support (for internal users) #93591
@ -1,14 +1,19 @@
|
||||
from binascii import unhexlify
|
||||
import logging
|
||||
import pathlib
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import forms as auth_forms, password_validation, authenticate
|
||||
from django.core.signing import BadSignature, TimestampSigner
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import transaction
|
||||
from django.forms import ValidationError
|
||||
from django.template import defaultfilters
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_otp.oath import TOTP
|
||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||
|
||||
from .models import User, OAuth2Application
|
||||
from .recaptcha import check_recaptcha
|
||||
@ -423,3 +428,60 @@ class DisableMfaForm(forms.Form):
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TotpMfaForm(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=TOTPDevice._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)
|
||||
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 self.verify_signature(self.user, 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')
|
||||
TOTPDevice.objects.create(key=key, name=name, user=self.user)
|
||||
|
||||
@classmethod
|
||||
def sign(cls, user, key):
|
||||
signer = TimestampSigner()
|
||||
return signer.sign(f'{user.email}_{key}')
|
||||
|
||||
@classmethod
|
||||
def verify_signature(cls, user, key, signature, max_age=3600):
|
||||
signer = TimestampSigner()
|
||||
try:
|
||||
return f'{user.email}_{key}' == signer.unsign(signature, max_age=max_age)
|
||||
except BadSignature:
|
||||
return False
|
||||
|
@ -37,7 +37,7 @@ Multi-factor Authentication Setup
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<button>Configure a new TOTP device</button>
|
||||
<a href="{% url 'bid_main:totp_mfa' %}" class="btn">Configure a new TOTP device</a>
|
||||
</div>
|
||||
|
||||
{% if user_can_setup_recovery %}
|
||||
|
45
bid_main/templates/bid_main/mfa/totp.html
Normal file
45
bid_main/templates/bid_main/mfa/totp.html
Normal file
@ -0,0 +1,45 @@
|
||||
{% 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 helptext"><details><summary>show code for manual entry</summary>{{ manual_secret_key }}</details></figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<p>TODO explain what's happening</p>
|
||||
{% with form=form|add_form_classes %}
|
||||
<form method="post">{% csrf_token %}
|
||||
{% with field=form.name %}
|
||||
{% include "components/forms/field.html" %}
|
||||
{% endwith %}
|
||||
{% with field=form.code %}
|
||||
{% include "components/forms/field.html" %}
|
||||
{% endwith %}
|
||||
{% with field=form.key %}
|
||||
{% include "components/forms/field.html" %}
|
||||
{% endwith %}
|
||||
{% with field=form.signature %}
|
||||
{% include "components/forms/field.html" %}
|
||||
{% endwith %}
|
||||
<button type="submit" class="btn">Validate</button>
|
||||
{% if form.non_field_errors %}
|
||||
<div class="text-danger mt-3">
|
||||
something went wrong
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@ -166,6 +166,11 @@ urlpatterns = [
|
||||
mfa.InvalidateRecoveryMfaView.as_view(),
|
||||
name='invalidate_recovery_mfa',
|
||||
),
|
||||
path(
|
||||
'mfa/totp/',
|
||||
mfa.TotpMfaView.as_view(),
|
||||
name='totp_mfa',
|
||||
),
|
||||
]
|
||||
|
||||
# Only enable this on a dev server:
|
||||
|
@ -1,9 +1,8 @@
|
||||
from base64 import b32encode, b64encode
|
||||
from binascii import unhexlify
|
||||
from collections import defaultdict
|
||||
from os import urandom
|
||||
from io import BytesIO
|
||||
|
||||
from django_otp import devices_for_user
|
||||
from django_otp.plugins.otp_static.models import StaticDevice
|
||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||
from django.db import transaction
|
||||
from django.http import HttpResponseBadRequest
|
||||
from django.shortcuts import redirect
|
||||
@ -11,9 +10,14 @@ 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 FormView
|
||||
from django_otp import devices_for_user
|
||||
from django_otp.plugins.otp_static.models import StaticDevice
|
||||
from django_otp.plugins.otp_totp.models import TOTPDevice, default_key
|
||||
from django_otp.util import random_hex
|
||||
import qrcode
|
||||
|
||||
from . import mixins
|
||||
from bid_main.forms import DisableMfaForm
|
||||
from bid_main.forms import DisableMfaForm, TotpMfaForm
|
||||
|
||||
|
||||
class MfaView(mixins.MfaRequiredIfConfiguredMixin, TemplateView):
|
||||
@ -68,7 +72,7 @@ class GenerateRecoveryMfaView(mixins.MfaRequiredIfConfiguredMixin, View):
|
||||
user.staticdevice_set.all().delete()
|
||||
Oleg-Komarov marked this conversation as resolved
Outdated
|
||||
device = StaticDevice.objects.create(name='recovery', user=user)
|
||||
for _ in range(10):
|
||||
device.token_set.create(token=urandom(8).hex().upper())
|
||||
device.token_set.create(token=random_hex(8).upper())
|
||||
return redirect(reverse('bid_main:mfa') + '?display_recovery_codes=1')
|
||||
|
||||
|
||||
@ -77,3 +81,40 @@ class InvalidateRecoveryMfaView(mixins.MfaRequiredIfConfiguredMixin, View):
|
||||
user = self.request.user
|
||||
user.staticdevice_set.all().delete()
|
||||
return redirect('bid_main:mfa')
|
||||
|
||||
|
||||
Oleg-Komarov marked this conversation as resolved
Outdated
Anna Sirota
commented
From this line it's not clear that this is recovery codes that are being deleted From this line it's not clear that this is recovery codes that are being deleted
|
||||
class TotpMfaView(mixins.MfaRequiredIfConfiguredMixin, FormView):
|
||||
template_name = "bid_main/mfa/totp.html"
|
||||
success_url = reverse_lazy('bid_main:mfa')
|
||||
|
||||
def get_form(self, *args, **kwargs):
|
||||
kwargs = self.get_form_kwargs()
|
||||
key = self.request.POST.get('key', default_key())
|
||||
kwargs['initial']['key'] = key
|
||||
kwargs['initial']['signature'] = TotpMfaForm.sign(kwargs['user'], key)
|
||||
return TotpMfaForm(**kwargs)
|
||||
|
||||
def get_form_kwargs(self):
|
||||
Oleg-Komarov marked this conversation as resolved
Outdated
Anna Sirota
commented
same as above same as above
|
||||
kwargs = super().get_form_kwargs()
|
||||
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 = TOTPDevice(key=key, user=self.request.user)
|
||||
context['config_url'] = device.config_url
|
||||
image = qrcode.make(
|
||||
device.config_url,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_H,
|
||||
)
|
||||
buf = BytesIO()
|
||||
image.save(buf)
|
||||
context['qrcode'] = b64encode(buf.getvalue()).decode('utf-8')
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
form.save()
|
||||
return super().form_valid(form)
|
||||
|
@ -90,6 +90,8 @@ AUTHENTICATION_BACKENDS = [
|
||||
"django.contrib.auth.backends.ModelBackend",
|
||||
]
|
||||
|
||||
OTP_TOTP_ISSUER = "id.blender.org"
|
||||
|
||||
ROOT_URLCONF = "blenderid.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
|
@ -41,11 +41,13 @@ pycparser==2.19 ; python_version >= "3.8" and python_version < "4"
|
||||
pygments==2.17.2 ; python_version >= "3.8" and python_version < "4"
|
||||
pyinstrument==4.6.0 ; python_version >= "3.8" and python_version < "4"
|
||||
pymdown-extensions==10.7 ; python_version >= "3.8" and python_version < "4"
|
||||
pypng==0.20220715.0
|
||||
pypugjs==5.9.12 ; python_version >= "3.8" and python_version < "4"
|
||||
python-dateutil==2.8.1 ; python_version >= "3.8" and python_version < "4"
|
||||
python-dotenv==0.21.0
|
||||
pytz==2019.3 ; python_version >= "3.8" and python_version < "4"
|
||||
pyyaml==5.1.2 ; python_version >= "3.8" and python_version < "4"
|
||||
qrcode==7.4.2
|
||||
requests==2.30.0 ; python_version >= "3.8" and python_version < "4"
|
||||
sentry-sdk==1.4.3 ; python_version >= "3.8" and python_version < "4"
|
||||
six==1.12.0 ; python_version >= "3.8" and python_version < "4"
|
||||
|
Loading…
Reference in New Issue
Block a user
DevFund and other services use
send_mail_*
for the most part: it's easier to parse visually