mirror of
https://github.com/pretix/pretix.git
synced 2026-09-14 16:24:43 +00:00
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:
@@ -97,8 +97,9 @@ Example::
|
|||||||
Defaults to ``off``.
|
Defaults to ``off``.
|
||||||
|
|
||||||
``obligatory_2fa``
|
``obligatory_2fa``
|
||||||
Enables or disables obligatory usage of Two-Factor Authentication for users of the pretix backend.
|
Enables or disables obligatory usage of two-factor authentication for users of the pretix backend.
|
||||||
Defaults to ``False``
|
Can be ``True`` to make two-factor authentication obligatory for all users or ``staff`` to make it only
|
||||||
|
obligatory to users with admin permissions. Defaults to ``False``.
|
||||||
|
|
||||||
``trust_x_forwarded_for``
|
``trust_x_forwarded_for``
|
||||||
Specifies whether the ``X-Forwarded-For`` header can be trusted. Only set to ``on`` if you have a reverse
|
Specifies whether the ``X-Forwarded-For`` header can be trusted. Only set to ``on`` if you have a reverse
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ id integer Internal ID of
|
|||||||
name string Team name
|
name string Team name
|
||||||
all_events boolean Whether this team has access to all events
|
all_events boolean Whether this team has access to all events
|
||||||
limit_events list List of event slugs this team has access to
|
limit_events list List of event slugs this team has access to
|
||||||
|
require_2fa boolean Whether members of this team are required to use
|
||||||
|
two-factor authentication
|
||||||
can_create_events boolean
|
can_create_events boolean
|
||||||
can_change_teams boolean
|
can_change_teams boolean
|
||||||
can_change_organizer_settings boolean
|
can_change_organizer_settings boolean
|
||||||
@@ -122,6 +124,7 @@ Team endpoints
|
|||||||
"name": "Admin team",
|
"name": "Admin team",
|
||||||
"all_events": true,
|
"all_events": true,
|
||||||
"limit_events": [],
|
"limit_events": [],
|
||||||
|
"require_2fa": true,
|
||||||
"can_create_events": true,
|
"can_create_events": true,
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
@@ -159,6 +162,7 @@ Team endpoints
|
|||||||
"name": "Admin team",
|
"name": "Admin team",
|
||||||
"all_events": true,
|
"all_events": true,
|
||||||
"limit_events": [],
|
"limit_events": [],
|
||||||
|
"require_2fa": true,
|
||||||
"can_create_events": true,
|
"can_create_events": true,
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
@@ -186,6 +190,7 @@ Team endpoints
|
|||||||
"name": "Admin team",
|
"name": "Admin team",
|
||||||
"all_events": true,
|
"all_events": true,
|
||||||
"limit_events": [],
|
"limit_events": [],
|
||||||
|
"require_2fa": true,
|
||||||
"can_create_events": true,
|
"can_create_events": true,
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
@@ -203,6 +208,7 @@ Team endpoints
|
|||||||
"name": "Admin team",
|
"name": "Admin team",
|
||||||
"all_events": true,
|
"all_events": true,
|
||||||
"limit_events": [],
|
"limit_events": [],
|
||||||
|
"require_2fa": true,
|
||||||
"can_create_events": true,
|
"can_create_events": true,
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
@@ -246,6 +252,7 @@ Team endpoints
|
|||||||
"name": "Admin team",
|
"name": "Admin team",
|
||||||
"all_events": true,
|
"all_events": true,
|
||||||
"limit_events": [],
|
"limit_events": [],
|
||||||
|
"require_2fa": true,
|
||||||
"can_create_events": true,
|
"can_create_events": true,
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ from pretix.base.models import Device, Event, User
|
|||||||
from pretix.base.models.auth import SuperuserPermissionSet
|
from pretix.base.models.auth import SuperuserPermissionSet
|
||||||
from pretix.base.models.organizer import TeamAPIToken
|
from pretix.base.models.organizer import TeamAPIToken
|
||||||
from pretix.helpers.security import (
|
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
|
return False
|
||||||
except SessionReauthRequired:
|
except SessionReauthRequired:
|
||||||
return False
|
return False
|
||||||
|
except Session2FASetupRequired:
|
||||||
|
return False
|
||||||
|
except SessionPasswordChangeRequired:
|
||||||
|
return False
|
||||||
|
|
||||||
perm_holder = (request.auth if isinstance(request.auth, (Device, TeamAPIToken))
|
perm_holder = (request.auth if isinstance(request.auth, (Device, TeamAPIToken))
|
||||||
else request.user)
|
else request.user)
|
||||||
@@ -144,6 +149,10 @@ class ProfilePermission(BasePermission):
|
|||||||
return False
|
return False
|
||||||
except SessionReauthRequired:
|
except SessionReauthRequired:
|
||||||
return False
|
return False
|
||||||
|
except Session2FASetupRequired:
|
||||||
|
return False
|
||||||
|
except SessionPasswordChangeRequired:
|
||||||
|
return False
|
||||||
|
|
||||||
if isinstance(request.auth, OAuthAccessToken):
|
if isinstance(request.auth, OAuthAccessToken):
|
||||||
if not (request.auth.allow_scopes(['read']) or request.auth.allow_scopes(['profile'])) and request.method in SAFE_METHODS:
|
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
|
return False
|
||||||
except SessionReauthRequired:
|
except SessionReauthRequired:
|
||||||
return False
|
return False
|
||||||
|
except Session2FASetupRequired:
|
||||||
|
return False
|
||||||
|
except SessionPasswordChangeRequired:
|
||||||
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ class TeamSerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Team
|
model = Team
|
||||||
fields = (
|
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_organizer_settings', 'can_manage_gift_cards', 'can_change_event_settings',
|
||||||
'can_change_items', 'can_view_orders', 'can_change_orders', 'can_view_vouchers',
|
'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'
|
'can_change_vouchers', 'can_checkin_orders', 'can_manage_customers', 'can_manage_reusable_media'
|
||||||
|
|||||||
@@ -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"))
|
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)"))
|
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)
|
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(
|
can_create_events = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ class TeamForm(forms.ModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Team
|
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_change_teams', 'can_change_organizer_settings',
|
||||||
'can_manage_gift_cards', 'can_manage_customers',
|
'can_manage_gift_cards', 'can_manage_customers',
|
||||||
'can_manage_reusable_media',
|
'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.base.models.auth import SuperuserPermissionSet, User
|
||||||
from pretix.helpers.http import redirect_to_url
|
from pretix.helpers.http import redirect_to_url
|
||||||
from pretix.helpers.security import (
|
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.totp",
|
||||||
"user.settings.2fa.confirm.webauthn",
|
"user.settings.2fa.confirm.webauthn",
|
||||||
"user.settings.2fa.delete",
|
"user.settings.2fa.delete",
|
||||||
|
"user.settings.2fa.leaveteams",
|
||||||
"auth.logout",
|
"auth.logout",
|
||||||
"user.reauth"
|
"user.reauth"
|
||||||
)
|
)
|
||||||
@@ -135,13 +137,12 @@ class PermissionMiddleware:
|
|||||||
except SessionReauthRequired:
|
except SessionReauthRequired:
|
||||||
if url_name not in ('user.reauth', 'auth.logout'):
|
if url_name not in ('user.reauth', 'auth.logout'):
|
||||||
return redirect_to_url(reverse('control:user.reauth') + '?next=' + quote(request.get_full_path()))
|
return redirect_to_url(reverse('control:user.reauth') + '?next=' + quote(request.get_full_path()))
|
||||||
|
except SessionPasswordChangeRequired:
|
||||||
if request.user.needs_password_change and url_name not in self.EXCEPTIONS_FORCED_PW_CHANGE:
|
if url_name not in self.EXCEPTIONS_FORCED_PW_CHANGE:
|
||||||
return redirect_to_url(reverse('control:user.settings') + '?next=' + quote(request.get_full_path()))
|
return redirect_to_url(reverse('control:user.settings') + '?next=' + quote(request.get_full_path()))
|
||||||
|
except Session2FASetupRequired:
|
||||||
if not request.user.require_2fa and settings.PRETIX_OBLIGATORY_2FA \
|
if url_name not in self.EXCEPTIONS_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'))
|
||||||
return redirect_to_url(reverse('control:user.settings.2fa'))
|
|
||||||
|
|
||||||
if 'event' in url.kwargs and 'organizer' in url.kwargs:
|
if 'event' in url.kwargs and 'organizer' in url.kwargs:
|
||||||
if url.kwargs['organizer'] == '-' and url.kwargs['event'] == '-':
|
if url.kwargs['organizer'] == '-' and url.kwargs['event'] == '-':
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{% trans "General information" %}</legend>
|
<legend>{% trans "General information" %}</legend>
|
||||||
{% bootstrap_field form.name layout="control" %}
|
{% bootstrap_field form.name layout="control" %}
|
||||||
|
{% bootstrap_field form.require_2fa layout="control" %}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{% trans "Organizer permissions" %}</legend>
|
<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.
|
smartphone or a hardware token generator and that changes on a regular basis.
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
</p>
|
</p>
|
||||||
{% if settings.PRETIX_OBLIGATORY_2FA %}
|
{% if obligatory and not user.require_2fa %}
|
||||||
<div class="panel panel-warning">
|
<div class="panel panel-warning">
|
||||||
<div class="panel-heading">
|
<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>
|
||||||
<div class="panel-body">
|
<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>
|
<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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if user.require_2fa %}
|
{% if user.require_2fa %}
|
||||||
<div class="panel panel-success">
|
<div class="panel panel-success">
|
||||||
@@ -35,7 +67,18 @@
|
|||||||
<h3 class="panel-title">{% trans "Two-factor status" %}</h3>
|
<h3 class="panel-title">{% trans "Two-factor status" %}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body">
|
<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">
|
<a href="{% url "control:user.settings.2fa.disable" %}" class="btn btn-primary pull-right flip">
|
||||||
{% trans "Disable" %}
|
{% trans "Disable" %}
|
||||||
</a>
|
</a>
|
||||||
@@ -73,7 +116,7 @@
|
|||||||
{% for d in devices %}
|
{% for d in devices %}
|
||||||
<li class="list-group-item">
|
<li class="list-group-item">
|
||||||
<a class="btn btn-danger btn-xs pull-right flip"
|
<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
|
Delete
|
||||||
</a>
|
</a>
|
||||||
{% if d.devicetype == "totp" %}
|
{% if d.devicetype == "totp" %}
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ urlpatterns = [
|
|||||||
re_path(r'^settings/oauth/apps/(?P<pk>\d+)/roll$', oauth.OAuthApplicationRollView.as_view(),
|
re_path(r'^settings/oauth/apps/(?P<pk>\d+)/roll$', oauth.OAuthApplicationRollView.as_view(),
|
||||||
name='user.settings.oauth.app.roll'),
|
name='user.settings.oauth.app.roll'),
|
||||||
re_path(r'^settings/2fa/$', user.User2FAMainView.as_view(), name='user.settings.2fa'),
|
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/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/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'),
|
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 import messages
|
||||||
from django.contrib.auth import update_session_auth_hash
|
from django.contrib.auth import update_session_auth_hash
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.db import transaction
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.crypto import get_random_string
|
from django.utils.crypto import get_random_string
|
||||||
@@ -323,6 +324,15 @@ class User2FAMainView(RecentAuthenticationRequiredMixin, TemplateView):
|
|||||||
obj.devicetype = 'webauthn'
|
obj.devicetype = 'webauthn'
|
||||||
ctx['devices'] += objs
|
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
|
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):
|
class User2FAEnableView(RecentAuthenticationRequiredMixin, TemplateView):
|
||||||
template_name = 'pretixcontrol/user/2fa_enable.html'
|
template_name = 'pretixcontrol/user/2fa_enable.html'
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ class SessionReauthRequired(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Session2FASetupRequired(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SessionPasswordChangeRequired(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def get_user_agent_hash(request):
|
def get_user_agent_hash(request):
|
||||||
return hashlib.sha256(request.headers['User-Agent'].encode()).hexdigest()
|
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['pinned_country'] = country
|
||||||
|
|
||||||
request.session['pretix_auth_last_used'] = int(time.time())
|
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
|
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_PASSWORD_RESET = config.getboolean('pretix', 'password_reset', fallback=True)
|
||||||
PRETIX_LONG_SESSIONS = config.getboolean('pretix', 'long_sessions', fallback=True)
|
PRETIX_LONG_SESSIONS = config.getboolean('pretix', 'long_sessions', fallback=True)
|
||||||
PRETIX_ADMIN_AUDIT_COMMENTS = config.getboolean('pretix', 'audit_comments', fallback=False)
|
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_RELATIVE = 3600 * 3
|
||||||
PRETIX_SESSION_TIMEOUT_ABSOLUTE = 3600 * 12
|
PRETIX_SESSION_TIMEOUT_ABSOLUTE = 3600 * 12
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import time
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from django.test import Client
|
from django.test import Client, override_settings
|
||||||
from tests.base import extract_form_fields
|
from tests.base import extract_form_fields
|
||||||
|
|
||||||
from pretix.base.models import Organizer
|
from pretix.base.models import Organizer
|
||||||
@@ -66,6 +66,25 @@ def test_session_auth_relative_timeout(client, user, team):
|
|||||||
assert resp.status_code == 403
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_session_auth_password_change_required(client, user, team):
|
||||||
|
client.login(email=user.email, password='dummy')
|
||||||
|
user.needs_password_change = True
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
resp = client.get('/api/v1/organizers/')
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(PRETIX_OBLIGATORY_2FA=True)
|
||||||
|
def test_session_auth_2fa_setup_required(client, user, team):
|
||||||
|
client.login(email=user.email, password='dummy')
|
||||||
|
|
||||||
|
resp = client.get('/api/v1/organizers/')
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_session_auth_csrf(user, team):
|
def test_session_auth_csrf(user, team):
|
||||||
team.members.add(user)
|
team.members.add(user)
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ TEST_TEAM_RES = {
|
|||||||
'can_change_teams': True, 'can_change_organizer_settings': True, 'can_manage_gift_cards': True,
|
'can_change_teams': True, 'can_change_organizer_settings': True, 'can_manage_gift_cards': True,
|
||||||
'can_manage_customers': True, 'can_manage_reusable_media': True,
|
'can_manage_customers': True, 'can_manage_reusable_media': True,
|
||||||
'can_change_event_settings': True, 'can_change_items': True, 'can_view_orders': True, 'can_change_orders': True,
|
'can_change_event_settings': True, 'can_change_items': True, 'can_view_orders': True, 'can_change_orders': True,
|
||||||
'can_view_vouchers': True, 'can_change_vouchers': True, 'can_checkin_orders': False
|
'can_view_vouchers': True, 'can_change_vouchers': True, 'can_checkin_orders': False,
|
||||||
|
'require_2fa': False,
|
||||||
}
|
}
|
||||||
|
|
||||||
SECOND_TEAM_RES = {
|
SECOND_TEAM_RES = {
|
||||||
@@ -50,7 +51,8 @@ SECOND_TEAM_RES = {
|
|||||||
'can_manage_customers': False, 'can_manage_reusable_media': False,
|
'can_manage_customers': False, 'can_manage_reusable_media': False,
|
||||||
'can_change_teams': False, 'can_change_organizer_settings': False, 'can_manage_gift_cards': False,
|
'can_change_teams': False, 'can_change_organizer_settings': False, 'can_manage_gift_cards': False,
|
||||||
'can_change_event_settings': False, 'can_change_items': False, 'can_view_orders': False, 'can_change_orders': False,
|
'can_change_event_settings': False, 'can_change_items': False, 'can_view_orders': False, 'can_change_orders': False,
|
||||||
'can_view_vouchers': False, 'can_change_vouchers': False, 'can_checkin_orders': False
|
'can_view_vouchers': False, 'can_change_vouchers': False, 'can_checkin_orders': False,
|
||||||
|
'require_2fa': False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ from webauthn.authentication.verify_authentication_response import (
|
|||||||
VerifiedAuthentication,
|
VerifiedAuthentication,
|
||||||
)
|
)
|
||||||
|
|
||||||
from pretix.base.models import U2FDevice, User
|
from pretix.base.models import Organizer, Team, U2FDevice, User
|
||||||
from pretix.helpers import security
|
from pretix.helpers import security
|
||||||
|
|
||||||
|
|
||||||
@@ -935,18 +935,19 @@ def test_staff_session_require_staff(user, client):
|
|||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
@override_settings(PRETIX_OBLIGATORY_2FA=True)
|
|
||||||
class Obligatory2FATest(TestCase):
|
class Obligatory2FATest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.user = User.objects.create_user('demo@demo.dummy', 'demo')
|
self.user = User.objects.create_user('demo@demo.dummy', 'demo')
|
||||||
self.client.login(email='demo@demo.dummy', password='demo')
|
self.client.login(email='demo@demo.dummy', password='demo')
|
||||||
|
|
||||||
|
@override_settings(PRETIX_OBLIGATORY_2FA=True)
|
||||||
def test_enabled_2fa_not_setup(self):
|
def test_enabled_2fa_not_setup(self):
|
||||||
response = self.client.get('/control/events/')
|
response = self.client.get('/control/events/')
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
assert response.url == '/control/settings/2fa/'
|
assert response.url == '/control/settings/2fa/'
|
||||||
|
|
||||||
|
@override_settings(PRETIX_OBLIGATORY_2FA=True)
|
||||||
def test_enabled_2fa_setup_not_enabled(self):
|
def test_enabled_2fa_setup_not_enabled(self):
|
||||||
U2FDevice.objects.create(user=self.user, name='test', json_data="{}", confirmed=True)
|
U2FDevice.objects.create(user=self.user, name='test', json_data="{}", confirmed=True)
|
||||||
self.user.require_2fa = False
|
self.user.require_2fa = False
|
||||||
@@ -956,6 +957,7 @@ class Obligatory2FATest(TestCase):
|
|||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
assert response.url == '/control/settings/2fa/'
|
assert response.url == '/control/settings/2fa/'
|
||||||
|
|
||||||
|
@override_settings(PRETIX_OBLIGATORY_2FA=True)
|
||||||
def test_enabled_2fa_setup_enabled(self):
|
def test_enabled_2fa_setup_enabled(self):
|
||||||
U2FDevice.objects.create(user=self.user, name='test', json_data="{}", confirmed=True)
|
U2FDevice.objects.create(user=self.user, name='test', json_data="{}", confirmed=True)
|
||||||
self.user.require_2fa = True
|
self.user.require_2fa = True
|
||||||
@@ -964,6 +966,50 @@ class Obligatory2FATest(TestCase):
|
|||||||
response = self.client.get('/control/events/')
|
response = self.client.get('/control/events/')
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
@override_settings(PRETIX_OBLIGATORY_2FA="staff")
|
||||||
|
def test_staff_only(self):
|
||||||
|
self.user.require_2fa = False
|
||||||
|
self.user.save()
|
||||||
|
response = self.client.get('/control/events/')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
self.user.is_staff = True
|
||||||
|
self.user.save()
|
||||||
|
|
||||||
|
response = self.client.get('/control/events/')
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert response.url == '/control/settings/2fa/'
|
||||||
|
|
||||||
|
@override_settings(PRETIX_OBLIGATORY_2FA=False)
|
||||||
|
def test_by_team(self):
|
||||||
|
session = self.client.session
|
||||||
|
session['pretix_auth_long_session'] = True
|
||||||
|
session['pretix_auth_login_time'] = int(time.time())
|
||||||
|
session['pretix_auth_last_used'] = int(time.time())
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
organizer = Organizer.objects.create(name='Dummy', slug='dummy')
|
||||||
|
team = Team.objects.create(organizer=organizer, can_change_teams=True, name='Admin team')
|
||||||
|
team.members.add(self.user)
|
||||||
|
self.user.require_2fa = False
|
||||||
|
self.user.save()
|
||||||
|
response = self.client.get('/control/events/')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
team.require_2fa = True
|
||||||
|
team.save()
|
||||||
|
|
||||||
|
response = self.client.get('/control/events/')
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert response.url == '/control/settings/2fa/'
|
||||||
|
|
||||||
|
response = self.client.post('/control/settings/2fa/leaveteams')
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert team.members.count() == 0
|
||||||
|
|
||||||
|
response = self.client.get('/control/events/')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
class PasswordChangeRequiredTest(TestCase):
|
class PasswordChangeRequiredTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user