forked from CGM_Public/pretix_original
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:
@@ -39,7 +39,8 @@ from pretix.base.models import Device, Event, User
|
||||
from pretix.base.models.auth import SuperuserPermissionSet
|
||||
from pretix.base.models.organizer import TeamAPIToken
|
||||
from pretix.helpers.security import (
|
||||
SessionInvalid, SessionReauthRequired, assert_session_valid,
|
||||
Session2FASetupRequired, SessionInvalid, SessionPasswordChangeRequired,
|
||||
SessionReauthRequired, assert_session_valid,
|
||||
)
|
||||
|
||||
|
||||
@@ -66,6 +67,10 @@ class EventPermission(BasePermission):
|
||||
return False
|
||||
except SessionReauthRequired:
|
||||
return False
|
||||
except Session2FASetupRequired:
|
||||
return False
|
||||
except SessionPasswordChangeRequired:
|
||||
return False
|
||||
|
||||
perm_holder = (request.auth if isinstance(request.auth, (Device, TeamAPIToken))
|
||||
else request.user)
|
||||
@@ -144,6 +149,10 @@ class ProfilePermission(BasePermission):
|
||||
return False
|
||||
except SessionReauthRequired:
|
||||
return False
|
||||
except Session2FASetupRequired:
|
||||
return False
|
||||
except SessionPasswordChangeRequired:
|
||||
return False
|
||||
|
||||
if isinstance(request.auth, OAuthAccessToken):
|
||||
if not (request.auth.allow_scopes(['read']) or request.auth.allow_scopes(['profile'])) and request.method in SAFE_METHODS:
|
||||
@@ -166,5 +175,9 @@ class AnyAuthenticatedClientPermission(BasePermission):
|
||||
return False
|
||||
except SessionReauthRequired:
|
||||
return False
|
||||
except Session2FASetupRequired:
|
||||
return False
|
||||
except SessionPasswordChangeRequired:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -239,7 +239,7 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Team
|
||||
fields = (
|
||||
'id', 'name', 'all_events', 'limit_events', 'can_create_events', 'can_change_teams',
|
||||
'id', 'name', 'require_2fa', 'all_events', 'limit_events', 'can_create_events', 'can_change_teams',
|
||||
'can_change_organizer_settings', 'can_manage_gift_cards', 'can_change_event_settings',
|
||||
'can_change_items', 'can_view_orders', 'can_change_orders', 'can_view_vouchers',
|
||||
'can_change_vouchers', 'can_checkin_orders', 'can_manage_customers', 'can_manage_reusable_media'
|
||||
|
||||
18
src/pretix/base/migrations/0259_team_require_2fa.py
Normal file
18
src/pretix/base/migrations/0259_team_require_2fa.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.10 on 2024-04-02 11:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0258_uniq_indx"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="team",
|
||||
name="require_2fa",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -263,6 +263,12 @@ class Team(LoggedModel):
|
||||
members = models.ManyToManyField(User, related_name="teams", verbose_name=_("Team members"))
|
||||
all_events = models.BooleanField(default=False, verbose_name=_("All events (including newly created ones)"))
|
||||
limit_events = models.ManyToManyField('Event', verbose_name=_("Limit to events"), blank=True)
|
||||
require_2fa = models.BooleanField(
|
||||
default=False, verbose_name=_("Require all members of this team to use two-factor authentication"),
|
||||
help_text=_("If you turn this on, all members of the team will be required to either set up two-factor "
|
||||
"authentication or leave the team. The setting may take a few minutes to become effective for "
|
||||
"all users.")
|
||||
)
|
||||
|
||||
can_create_events = models.BooleanField(
|
||||
default=False,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'] == '-':
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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" %}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -41,6 +41,14 @@ class SessionReauthRequired(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Session2FASetupRequired(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SessionPasswordChangeRequired(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def get_user_agent_hash(request):
|
||||
return hashlib.sha256(request.headers['User-Agent'].encode()).hexdigest()
|
||||
|
||||
@@ -94,4 +102,20 @@ def assert_session_valid(request):
|
||||
request.session['pinned_country'] = country
|
||||
|
||||
request.session['pretix_auth_last_used'] = int(time.time())
|
||||
|
||||
if request.user.needs_password_change:
|
||||
raise SessionPasswordChangeRequired()
|
||||
|
||||
force_2fa = not request.user.require_2fa and (
|
||||
settings.PRETIX_OBLIGATORY_2FA is True or
|
||||
(settings.PRETIX_OBLIGATORY_2FA == "staff" and request.user.is_staff) or
|
||||
cache.get_or_set(
|
||||
f'user_2fa_team_{request.user.pk}',
|
||||
lambda: request.user.teams.filter(require_2fa=True).exists(),
|
||||
timeout=300
|
||||
)
|
||||
)
|
||||
if force_2fa:
|
||||
raise Session2FASetupRequired()
|
||||
|
||||
return True
|
||||
|
||||
@@ -181,7 +181,15 @@ PRETIX_REGISTRATION = config.getboolean('pretix', 'registration', fallback=False
|
||||
PRETIX_PASSWORD_RESET = config.getboolean('pretix', 'password_reset', fallback=True)
|
||||
PRETIX_LONG_SESSIONS = config.getboolean('pretix', 'long_sessions', fallback=True)
|
||||
PRETIX_ADMIN_AUDIT_COMMENTS = config.getboolean('pretix', 'audit_comments', fallback=False)
|
||||
PRETIX_OBLIGATORY_2FA = config.getboolean('pretix', 'obligatory_2fa', fallback=False)
|
||||
|
||||
_obligatory_2fa = config.get('pretix', 'obligatory_2fa', fallback="False")
|
||||
_mapping = {'1': True, 'yes': True, 'true': True, 'on': True, '0': False, 'no': False, 'false': False, 'off': False, 'staff': 'staff'}
|
||||
if _obligatory_2fa.lower() not in _mapping:
|
||||
raise ImproperlyConfigured(
|
||||
f"Value '{_obligatory_2fa}' not allowed for configuration key pretix.obligatory_2fa."
|
||||
)
|
||||
PRETIX_OBLIGATORY_2FA = _mapping[_obligatory_2fa.lower()]
|
||||
|
||||
PRETIX_SESSION_TIMEOUT_RELATIVE = 3600 * 3
|
||||
PRETIX_SESSION_TIMEOUT_ABSOLUTE = 3600 * 12
|
||||
|
||||
|
||||
Reference in New Issue
Block a user