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
5 changed files with 68 additions and 19 deletions
Showing only changes of commit d57b7a651f - Show all commits

View File

@ -0,0 +1,18 @@
{% extends 'layout.html' %}
{% load pipeline static %}
{% load add_form_classes from forms %}
{% block page_title %}
Delete {{ object.name }}
{% endblock %}
{% block body %}
<div class="bid box">
<h2>Delete {{ object.name }}?</h2>
<form method="post">{% csrf_token %}
{% with form=form|add_form_classes %}
{{ form }}
{% endwith %}
<button type="submit" class="btn-danger">Delete</button>
<a class="btn" href="{% url 'bid_main:mfa' %}">Cancel</a>
</form>
{% endblock %}

View File

@ -18,5 +18,6 @@ Disable Multi-factor Authentication
{% endwith %} {% endwith %}
{% endwith %} {% endwith %}
<button type="submit" class="btn-danger">Disable</button> <button type="submit" class="btn-danger">Disable</button>
<a class="btn" href="{% url 'bid_main:mfa' %}">Cancel</a>
</form> </form>
{% endblock %} {% endblock %}

View File

@ -13,7 +13,7 @@ Multi-factor Authentication Setup
You can disable MFA at any time, but you have to sign-in using your authentication device or a recovery code. You can disable MFA at any time, but you have to sign-in using your authentication device or a recovery code.
</p> </p>
<div> <div>
<a class="btn btn-danger" href="{% url 'bid_main:disable_mfa' %}">Disable</a> <a class="btn btn-danger" href="{% url 'bid_main:mfa_disable' %}">Disable</a>
</div> </div>
{% else %} {% else %}
<p> <p>
@ -32,12 +32,12 @@ Multi-factor Authentication Setup
{% if devices %} {% if devices %}
<ul> <ul>
{% for d in devices %} {% for d in devices %}
<li>{{ d.name }} <button class="btn-danger"><i class="i-trash"></i></button></li> <li>{{ d.name }} <a class="btn btn-danger" href="{% url 'bid_main:mfa_delete_device' d.persistent_id %}"><i class="i-trash"></i></a></li>
{% endfor %} {% endfor %}
</ul> </ul>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
<a href="{% url 'bid_main:totp_mfa' %}" 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>
{% if user_can_setup_recovery %} {% if user_can_setup_recovery %}
@ -58,7 +58,7 @@ Multi-factor Authentication Setup
{% else %} {% else %}
<a href="?display_recovery_codes=1" class="btn">Display</a> <a href="?display_recovery_codes=1" class="btn">Display</a>
{% endif %} {% endif %}
<form action="{% url 'bid_main:invalidate_recovery_mfa' %}" method="post" class="d-inline-flex">{% csrf_token %} <form action="{% url 'bid_main:mfa_invalidate_recovery' %}" method="post" class="d-inline-flex">{% csrf_token %}
<button class="btn-danger" type="submit">Invalidate</button> <button class="btn-danger" type="submit">Invalidate</button>
</form> </form>
{# populated on display_recovery_codes=1 #} {# populated on display_recovery_codes=1 #}
@ -72,7 +72,7 @@ Multi-factor Authentication Setup
{% endwith %} {% endwith %}
</div> </div>
{% endif %} {% endif %}
<form action="{% url 'bid_main:generate_recovery_mfa' %}" method="post">{% csrf_token %} <form action="{% url 'bid_main:mfa_generate_recovery' %}" method="post">{% csrf_token %}
<button type="submit">{% if recovery %}Regenerate{% else %}Generate{% endif %} recovery codes</button> <button type="submit">{% if recovery %}Regenerate{% else %}Generate{% endif %} recovery codes</button>
</form> </form>
{% endwith %} {% endwith %}

View File

@ -153,23 +153,29 @@ urlpatterns = [
), ),
path( path(
'mfa/disable/', 'mfa/disable/',
mfa.DisableMfaView.as_view(), mfa.DisableView.as_view(),
name='disable_mfa', name='mfa_disable',
), ),
path( path(
'mfa/generate-recovery/', 'mfa/generate-recovery/',
mfa.GenerateRecoveryMfaView.as_view(), mfa.GenerateRecoveryView.as_view(),
name='generate_recovery_mfa', name='mfa_generate_recovery',
), ),
path( path(
'mfa/invalidate-recovery/', 'mfa/invalidate-recovery/',
mfa.InvalidateRecoveryMfaView.as_view(), mfa.InvalidateRecoveryView.as_view(),
name='invalidate_recovery_mfa', name='mfa_invalidate_recovery',
), ),
path( path(
'mfa/totp/', 'mfa/totp/',
mfa.TotpMfaView.as_view(), mfa.TotpView.as_view(),
name='totp_mfa', name='mfa_totp',
),
path(
# using `path` converter because persistent_id contains a slash
'mfa/delete-device/<path:persistent_id>/',
mfa.DeleteDeviceView.as_view(),
name='mfa_delete_device',
), ),
] ]

View File

@ -4,13 +4,14 @@ from collections import defaultdict
from io import BytesIO from io import BytesIO
from django.db import transaction from django.db import transaction
from django.http import HttpResponseBadRequest from django.http import Http404, HttpResponseBadRequest
from django.shortcuts import redirect from django.shortcuts import redirect
from django.urls import reverse, reverse_lazy 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.base import View
from django.views.generic.edit import FormView from django.views.generic.edit import DeleteView, FormView
from django_otp import devices_for_user from django_otp import devices_for_user
from django_otp.models import Device
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, default_key from django_otp.plugins.otp_totp.models import TOTPDevice, default_key
from django_otp.util import random_hex from django_otp.util import random_hex
@ -50,7 +51,7 @@ class MfaView(mixins.MfaRequiredIfConfiguredMixin, TemplateView):
} }
class DisableMfaView(mixins.MfaRequiredMixin, FormView): class DisableView(mixins.MfaRequiredMixin, FormView):
form_class = DisableMfaForm form_class = DisableMfaForm
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')
@ -62,7 +63,7 @@ class DisableMfaView(mixins.MfaRequiredMixin, FormView):
return super().form_valid(form) return super().form_valid(form)
class GenerateRecoveryMfaView(mixins.MfaRequiredIfConfiguredMixin, View): class GenerateRecoveryView(mixins.MfaRequiredIfConfiguredMixin, View):
@transaction.atomic @transaction.atomic
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
user = self.request.user user = self.request.user
@ -76,14 +77,14 @@ class GenerateRecoveryMfaView(mixins.MfaRequiredIfConfiguredMixin, View):
return redirect(reverse('bid_main:mfa') + '?display_recovery_codes=1') return redirect(reverse('bid_main:mfa') + '?display_recovery_codes=1')
class InvalidateRecoveryMfaView(mixins.MfaRequiredIfConfiguredMixin, View): class InvalidateRecoveryView(mixins.MfaRequiredIfConfiguredMixin, View):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
user = self.request.user user = self.request.user
user.staticdevice_set.all().delete() user.staticdevice_set.all().delete()
return redirect('bid_main:mfa') return redirect('bid_main:mfa')
class TotpMfaView(mixins.MfaRequiredIfConfiguredMixin, FormView): class TotpView(mixins.MfaRequiredIfConfiguredMixin, FormView):
template_name = "bid_main/mfa/totp.html" template_name = "bid_main/mfa/totp.html"
success_url = reverse_lazy('bid_main:mfa') success_url = reverse_lazy('bid_main:mfa')
@ -118,3 +119,26 @@ class TotpMfaView(mixins.MfaRequiredIfConfiguredMixin, FormView):
def form_valid(self, form): def form_valid(self, form):
form.save() form.save()
return super().form_valid(form) return super().form_valid(form)
class DeleteDeviceView(mixins.MfaRequiredMixin, DeleteView):
model = Device
template_name = "bid_main/mfa/delete_device.html"
success_url = reverse_lazy('bid_main:mfa')
def get(self, request, *args, **kwargs):
for device in devices_for_user(self.request.user):
if (
device.persistent_id != kwargs['persistent_id']
and not isinstance(device, StaticDevice)
):
# there are other non-recovery devices, it's fine to delete this one
return super().get(request, *args, **kwargs)
# this seems to be the last device, we are effectively disabling mfa
return redirect('bid_main:mfa_disable')
def get_object(self, queryset=None):
device = Device.from_persistent_id(self.kwargs['persistent_id'])
if not device or self.request.user != device.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?
raise Http404()
return device