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

View File

@ -40,6 +40,7 @@ Multi-factor Authentication Setup
<button>Configure a new TOTP device</button>
</div>
{% if user_can_setup_recovery %}
<div class="bid box mt-3">
<h3>Recovery codes</h3>
<p>
@ -47,17 +48,34 @@ Multi-factor Authentication Setup
Each code can be used only once.
You can generate a new set of recovery codes at any time, any remaining old codes will become invalidated.
</p>
{% with devices=devices_per_category.recovery %}
{% for d in devices %}
{% with recovery=devices_per_category.recovery.0 %}
{% if recovery %}
<div class="mb-3">
{% with code_count=d.token_set.count %}
{% with code_count=recovery.token_set.count %}
{{ code_count }} recovery code{{ code_count|pluralize }} remaining
<button>Download</button>
<button class="btn-danger">Invalidate</button>
{% if recovery_codes %}
<a href="?display_recovery_codes=" class="btn">Hide</a>
{% else %}
<a href="?display_recovery_codes=1" class="btn">Display</a>
{% endif %}
<form action="{% url 'bid_main:invalidate_recovery_mfa' %}" method="post" class="d-inline-flex">{% csrf_token %}
<button class="btn-danger" type="submit">Invalidate</button>
</form>
{# populated on display_recovery_codes=1 #}
{% if recovery_codes %}
<ul>
{% for code in recovery_codes %}
<li>{{ code }}</li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
</div>
{% endfor %}
<button>{% if devices %}Regenerate{% else %}Generate{% endif %} recovery codes</button>
{% endif %}
<form action="{% url 'bid_main:generate_recovery_mfa' %}" method="post">{% csrf_token %}
<button type="submit">{% if recovery %}Regenerate{% else %}Generate{% endif %} recovery codes</button>
</form>
{% endwith %}
</div>
{% endif %}
{% endblock %}

View File

@ -156,6 +156,16 @@ urlpatterns = [
mfa.DisableMfaView.as_view(),
name='disable_mfa',
),
path(
'mfa/generate-recovery/',
mfa.GenerateRecoveryMfaView.as_view(),
name='generate_recovery_mfa',
),
path(
'mfa/invalidate-recovery/',
mfa.InvalidateRecoveryMfaView.as_view(),
name='invalidate_recovery_mfa',
),
]
# Only enable this on a dev server:

View File

@ -1,12 +1,15 @@
from collections import defaultdict
from os import urandom
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.urls import reverse_lazy
from django.http import 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 FormView
from . import mixins
@ -14,18 +17,31 @@ from bid_main.forms import DisableMfaForm
class MfaView(mixins.MfaRequiredIfConfiguredMixin, TemplateView):
"""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):
devices = list(devices_for_user(self.request.user))
user = self.request.user
devices = list(devices_for_user(user))
devices_per_category = defaultdict(list)
recovery_codes = []
user_can_setup_recovery = False
for device in devices:
if isinstance(device, StaticDevice):
devices_per_category['recovery'].append(device)
if self.request.GET.get('display_recovery_codes', None):
recovery_codes = [t.token for t in device.token_set.all()]
if isinstance(device, TOTPDevice):
devices_per_category['totp'].append(device)
user_can_setup_recovery = True
return {
'devices_per_category': devices_per_category,
'recovery_codes': recovery_codes,
'user_can_setup_recovery': user_can_setup_recovery,
'user_has_mfa_configured': len(devices) > 0,
}
@ -35,8 +51,29 @@ class DisableMfaView(mixins.MfaRequiredMixin, FormView):
template_name = "bid_main/mfa/disable.html"
success_url = reverse_lazy('bid_main:mfa')
@transaction.atomic
def form_valid(self, form):
with transaction.atomic():
for device in devices_for_user(self.request.user, confirmed=None):
device.delete()
for device in devices_for_user(self.request.user, confirmed=None):
device.delete()
return super().form_valid(form)
class GenerateRecoveryMfaView(mixins.MfaRequiredIfConfiguredMixin, View):
@transaction.atomic
def post(self, request, *args, **kwargs):
user = self.request.user
if not list(filter(lambda d: not isinstance(d, StaticDevice), 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")
user.staticdevice_set.all().delete()
device = StaticDevice.objects.create(name='recovery', user=user)
for _ in range(10):
device.token_set.create(token=urandom(8).hex().upper())
return redirect(reverse('bid_main:mfa') + '?display_recovery_codes=1')
class InvalidateRecoveryMfaView(mixins.MfaRequiredIfConfiguredMixin, View):
def post(self, request, *args, **kwargs):
user = self.request.user
user.staticdevice_set.all().delete()
return redirect('bid_main:mfa')