Allow team admins to require two-factor authentication (#4034)

* Allow team admins to require two-factor authentication

* Add API tests

* Improve logic

* ADd button tooltip
This commit is contained in:
Raphael Michel
2024-04-02 17:15:16 +02:00
committed by GitHub
parent 50838b9cea
commit 4ea4189e6d
18 changed files with 282 additions and 30 deletions

View File

@@ -257,7 +257,7 @@ class TeamForm(forms.ModelForm):
class Meta:
model = Team
fields = ['name', 'all_events', 'limit_events', 'can_create_events',
fields = ['name', 'require_2fa', 'all_events', 'limit_events', 'can_create_events',
'can_change_teams', 'can_change_organizer_settings',
'can_manage_gift_cards', 'can_manage_customers',
'can_manage_reusable_media',

View File

@@ -48,7 +48,8 @@ from pretix.base.models import Event, Organizer
from pretix.base.models.auth import SuperuserPermissionSet, User
from pretix.helpers.http import redirect_to_url
from pretix.helpers.security import (
SessionInvalid, SessionReauthRequired, assert_session_valid,
Session2FASetupRequired, SessionInvalid, SessionPasswordChangeRequired,
SessionReauthRequired, assert_session_valid,
)
@@ -84,6 +85,7 @@ class PermissionMiddleware:
"user.settings.2fa.confirm.totp",
"user.settings.2fa.confirm.webauthn",
"user.settings.2fa.delete",
"user.settings.2fa.leaveteams",
"auth.logout",
"user.reauth"
)
@@ -135,13 +137,12 @@ class PermissionMiddleware:
except SessionReauthRequired:
if url_name not in ('user.reauth', 'auth.logout'):
return redirect_to_url(reverse('control:user.reauth') + '?next=' + quote(request.get_full_path()))
if request.user.needs_password_change and url_name not in self.EXCEPTIONS_FORCED_PW_CHANGE:
return redirect_to_url(reverse('control:user.settings') + '?next=' + quote(request.get_full_path()))
if not request.user.require_2fa and settings.PRETIX_OBLIGATORY_2FA \
and url_name not in self.EXCEPTIONS_2FA and not request.user.needs_password_change:
return redirect_to_url(reverse('control:user.settings.2fa'))
except SessionPasswordChangeRequired:
if url_name not in self.EXCEPTIONS_FORCED_PW_CHANGE:
return redirect_to_url(reverse('control:user.settings') + '?next=' + quote(request.get_full_path()))
except Session2FASetupRequired:
if url_name not in self.EXCEPTIONS_2FA:
return redirect_to_url(reverse('control:user.settings.2fa'))
if 'event' in url.kwargs and 'organizer' in url.kwargs:
if url.kwargs['organizer'] == '-' and url.kwargs['event'] == '-':

View File

@@ -18,6 +18,7 @@
<fieldset>
<legend>{% trans "General information" %}</legend>
{% bootstrap_field form.name layout="control" %}
{% bootstrap_field form.require_2fa layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Organizer permissions" %}</legend>

View File

@@ -0,0 +1,30 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Two-factor authentication" %}{% endblock %}
{% block content %}
<h1>{% trans "Leave teams that require two-factor authentication" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
<p>
<strong>{% trans "Do you really want to leave the following teams?" %}</strong>
</p>
<ul>
{% for t in obligatory_teams %}
<li>
{% blocktrans trimmed with team=t.name organizer=t.organizer.name %}
Team "{{ team }}" of organizer "{{ organizer }}"
{% endblocktrans %}
</li>
{% endfor %}
</ul>
<div class="form-group submit-group">
<a href="{% url "control:user.settings.2fa" %}" class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-danger btn-save">
{% trans "Leave" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -11,23 +11,55 @@
smartphone or a hardware token generator and that changes on a regular basis.
{% endblocktrans %}
</p>
{% if settings.PRETIX_OBLIGATORY_2FA %}
{% if obligatory and not user.require_2fa %}
<div class="panel panel-warning">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Obligatory usage of two-factor authentication" %}</h3>
<h3 class="panel-title">
<span class="fa fa-warning"></span>
{% trans "Obligatory usage of two-factor authentication" %}
</h3>
</div>
<div class="panel-body">
{% if obligatory == "system" %}
<p>
<strong>{% trans "This system enforces the usage of two-factor authentication!" %}</strong>
</p>
{% elif obligatory == "staff" %}
<p>
<strong>{% trans "As an administrator, you need to use two-factor authentication." %}</strong>
</p>
{% elif obligatory == "team" %}
<p>
<strong>{% trans "You are part of one or more organizer teams that require you to use two-factor authentication." %}</strong>
</p>
<ul>
{% for t in obligatory_teams %}
<li>
{% blocktrans trimmed with team=t.name organizer=t.organizer.name %}
Team "{{ team }}" of organizer "{{ organizer }}"
{% endblocktrans %}
</li>
{% endfor %}
</ul>
{% endif %}
<p>
<strong>{% trans "This system enforces the usage of two-factor authentication!" %}</strong>
{% if not devices %}
{% trans "Please set up at least one device below." %}
{% elif not user.require_2fa %}
{% trans "Please activate two-factor authentication using the button below." %}
{% endif %}
{% if obligatory == "team" %}
<a href="{% url "control:user.settings.2fa.leaveteams" %}">
{% blocktrans trimmed count count=obligatory_teams|length %}
Leave team instead
{% plural %}
Leave {{ count }} teams instead
{% endblocktrans %}
</a>
{% endif %}
</p>
{% if not devices %}
<p>{% trans "Please set up at least one device below." %}</p>
{% elif not user.require_2fa %}
<p>{% trans "Please activate two-factor authentication using the button below." %}</p>
{% endif %}
</div>
</div>
{% endif %}
{% if user.require_2fa %}
<div class="panel panel-success">
@@ -35,7 +67,18 @@
<h3 class="panel-title">{% trans "Two-factor status" %}</h3>
</div>
<div class="panel-body">
{% if not settings.PRETIX_OBLIGATORY_2FA %}
{% if obligatory %}
<button disabled class="btn btn-primary pull-right flip" data-toggle="tooltip"
title="{% spaceless %}{% if obligatory == "system" %}
{% trans "This system enforces the usage of two-factor authentication!" %}
{% elif obligatory == "staff" %}
{% trans "As an administrator, you need to use two-factor authentication." %}
{% elif obligatory == "team" %}
{% trans "You are part of one or more organizer teams that require you to use two-factor authentication." %}
{% endif %}{% endspaceless %}">
{% trans "Disable" %}
</button>
{% else %}
<a href="{% url "control:user.settings.2fa.disable" %}" class="btn btn-primary pull-right flip">
{% trans "Disable" %}
</a>
@@ -73,7 +116,7 @@
{% for d in devices %}
<li class="list-group-item">
<a class="btn btn-danger btn-xs pull-right flip"
href="{% url "control:user.settings.2fa.delete" devicetype=d.devicetype device=d.pk %}">
href="{% url "control:user.settings.2fa.delete" devicetype=d.devicetype device=d.pk %}">
Delete
</a>
{% if d.devicetype == "totp" %}

View File

@@ -95,6 +95,7 @@ urlpatterns = [
re_path(r'^settings/oauth/apps/(?P<pk>\d+)/roll$', oauth.OAuthApplicationRollView.as_view(),
name='user.settings.oauth.app.roll'),
re_path(r'^settings/2fa/$', user.User2FAMainView.as_view(), name='user.settings.2fa'),
re_path(r'^settings/2fa/leaveteams$', user.User2FALeaveTeamsView.as_view(), name='user.settings.2fa.leaveteams'),
re_path(r'^settings/2fa/add$', user.User2FADeviceAddView.as_view(), name='user.settings.2fa.add'),
re_path(r'^settings/2fa/enable', user.User2FAEnableView.as_view(), name='user.settings.2fa.enable'),
re_path(r'^settings/2fa/disable', user.User2FADisableView.as_view(), name='user.settings.2fa.disable'),

View File

@@ -44,6 +44,7 @@ from django.conf import settings
from django.contrib import messages
from django.contrib.auth import update_session_auth_hash
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.crypto import get_random_string
@@ -323,6 +324,15 @@ class User2FAMainView(RecentAuthenticationRequiredMixin, TemplateView):
obj.devicetype = 'webauthn'
ctx['devices'] += objs
ctx['obligatory'] = None
if settings.PRETIX_OBLIGATORY_2FA is True:
ctx['obligatory'] = 'system'
elif settings.PRETIX_OBLIGATORY_2FA == "staff" and self.request.user.is_staff:
ctx['obligatory'] = 'staff'
elif teams := self.request.user.teams.filter(require_2fa=True).select_related('organizer'):
ctx['obligatory'] = 'team'
ctx['obligatory_teams'] = teams
return ctx
@@ -557,6 +567,28 @@ class User2FADeviceConfirmTOTPView(RecentAuthenticationRequiredMixin, TemplateVi
}))
class User2FALeaveTeamsView(RecentAuthenticationRequiredMixin, TemplateView):
template_name = 'pretixcontrol/user/2fa_leaveteams.html'
@transaction.atomic
def post(self, request, *args, **kwargs):
for team in self.request.user.teams.filter(require_2fa=True).select_related('organizer'):
team.members.remove(self.request.user)
team.log_action(
'pretix.team.member.removed', user=self.request.user, data={
'email': self.request.user.email,
'user': self.request.user.pk
}
)
messages.success(request, _('You have left all teams that require two-factor authentication.'))
return redirect(reverse('control:user.settings.2fa'))
def get_context_data(self, **kwargs):
ctx = super().get_context_data()
ctx['obligatory_teams'] = self.request.user.teams.filter(require_2fa=True).select_related('organizer')
return ctx
class User2FAEnableView(RecentAuthenticationRequiredMixin, TemplateView):
template_name = 'pretixcontrol/user/2fa_enable.html'