Initial mfa support (for internal users) #93591
@ -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 %}
|
||||||
|
@ -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:
|
||||||
|
@ -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')
|
||||||
|
Loading…
Reference in New Issue
Block a user