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> <button>Configure a new TOTP device</button>
</div> </div>
{% if user_can_setup_recovery %}
<div class="bid box mt-3"> <div class="bid box mt-3">
<h3>Recovery codes</h3> <h3>Recovery codes</h3>
<p> <p>
@ -47,17 +48,34 @@ Multi-factor Authentication Setup
Each code can be used only once. 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. You can generate a new set of recovery codes at any time, any remaining old codes will become invalidated.
</p> </p>
{% with devices=devices_per_category.recovery %} {% with recovery=devices_per_category.recovery.0 %}
{% for d in devices %} {% if recovery %}
<div class="mb-3"> <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 {{ code_count }} recovery code{{ code_count|pluralize }} remaining
<button>Download</button> {% if recovery_codes %}
<button class="btn-danger">Invalidate</button> <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 %} {% endwith %}
</div> </div>
{% endfor %} {% endif %}
<button>{% if devices %}Regenerate{% else %}Generate{% endif %} recovery codes</button> <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 %} {% endwith %}
</div> </div>
{% endif %}
{% endblock %} {% endblock %}

View File

@ -156,6 +156,16 @@ urlpatterns = [
mfa.DisableMfaView.as_view(), mfa.DisableMfaView.as_view(),
name='disable_mfa', 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: # Only enable this on a dev server:

View File

@ -1,12 +1,15 @@
from collections import defaultdict from collections import defaultdict
from os import urandom
from django_otp import devices_for_user from django_otp import devices_for_user
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 django.db import transaction 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 import TemplateView
from django.views.generic.base import View
from django.views.generic.edit import FormView from django.views.generic.edit import FormView
from . import mixins from . import mixins
@ -14,18 +17,31 @@ from bid_main.forms import DisableMfaForm
class MfaView(mixins.MfaRequiredIfConfiguredMixin, TemplateView): 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" template_name = "bid_main/mfa/setup.html"
def get_context_data(self, **kwargs): 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) devices_per_category = defaultdict(list)
recovery_codes = []
user_can_setup_recovery = False
for device in devices: for device in devices:
if isinstance(device, StaticDevice): if isinstance(device, StaticDevice):
devices_per_category['recovery'].append(device) 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): if isinstance(device, TOTPDevice):
devices_per_category['totp'].append(device) devices_per_category['totp'].append(device)
user_can_setup_recovery = True
return { return {
'devices_per_category': devices_per_category, '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, 'user_has_mfa_configured': len(devices) > 0,
} }
@ -35,8 +51,29 @@ class DisableMfaView(mixins.MfaRequiredMixin, FormView):
template_name = "bid_main/mfa/disable.html" template_name = "bid_main/mfa/disable.html"
success_url = reverse_lazy('bid_main:mfa') success_url = reverse_lazy('bid_main:mfa')
@transaction.atomic
def form_valid(self, form): def form_valid(self, form):
with transaction.atomic():
for device in devices_for_user(self.request.user, confirmed=None): for device in devices_for_user(self.request.user, confirmed=None):
device.delete() device.delete()
return super().form_valid(form) 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')