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
7 changed files with 164 additions and 7 deletions
Showing only changes of commit 13b856b7cc - Show all commits

View File

@ -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

View File

@ -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 %}

View 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 %}

View File

@ -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:

View File

@ -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

DevFund and other services use send_mail_* for the most part: it's easier to parse visually

DevFund and other services use `send_mail_*` for the most part: it's easier to parse visually
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

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

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)

View File

@ -90,6 +90,8 @@ AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
]
OTP_TOTP_ISSUER = "id.blender.org"
ROOT_URLCONF = "blenderid.urls"
TEMPLATES = [

View File

@ -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"