diff --git a/doc/admin/config.rst b/doc/admin/config.rst
index bc6169f1bf..5c83dda3e1 100644
--- a/doc/admin/config.rst
+++ b/doc/admin/config.rst
@@ -97,8 +97,9 @@ Example::
Defaults to ``off``.
``obligatory_2fa``
- Enables or disables obligatory usage of Two-Factor Authentication for users of the pretix backend.
- Defaults to ``False``
+ Enables or disables obligatory usage of two-factor authentication for users of the pretix backend.
+ 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``
Specifies whether the ``X-Forwarded-For`` header can be trusted. Only set to ``on`` if you have a reverse
diff --git a/doc/api/resources/teams.rst b/doc/api/resources/teams.rst
index 659d6441e4..f1aba2a5fa 100644
--- a/doc/api/resources/teams.rst
+++ b/doc/api/resources/teams.rst
@@ -22,6 +22,8 @@ id integer Internal ID of
name string Team name
all_events boolean Whether this team has access to all events
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_change_teams boolean
can_change_organizer_settings boolean
@@ -122,6 +124,7 @@ Team endpoints
"name": "Admin team",
"all_events": true,
"limit_events": [],
+ "require_2fa": true,
"can_create_events": true,
...
}
@@ -159,6 +162,7 @@ Team endpoints
"name": "Admin team",
"all_events": true,
"limit_events": [],
+ "require_2fa": true,
"can_create_events": true,
...
}
@@ -186,6 +190,7 @@ Team endpoints
"name": "Admin team",
"all_events": true,
"limit_events": [],
+ "require_2fa": true,
"can_create_events": true,
...
}
@@ -203,6 +208,7 @@ Team endpoints
"name": "Admin team",
"all_events": true,
"limit_events": [],
+ "require_2fa": true,
"can_create_events": true,
...
}
@@ -246,6 +252,7 @@ Team endpoints
"name": "Admin team",
"all_events": true,
"limit_events": [],
+ "require_2fa": true,
"can_create_events": true,
...
}
diff --git a/src/pretix/api/auth/permission.py b/src/pretix/api/auth/permission.py
index dc205312c4..e023a14956 100644
--- a/src/pretix/api/auth/permission.py
+++ b/src/pretix/api/auth/permission.py
@@ -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
diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py
index 5fce6adee8..967d4452e9 100644
--- a/src/pretix/api/serializers/organizer.py
+++ b/src/pretix/api/serializers/organizer.py
@@ -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'
diff --git a/src/pretix/base/migrations/0259_team_require_2fa.py b/src/pretix/base/migrations/0259_team_require_2fa.py
new file mode 100644
index 0000000000..2ba7e5fb86
--- /dev/null
+++ b/src/pretix/base/migrations/0259_team_require_2fa.py
@@ -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),
+ ),
+ ]
diff --git a/src/pretix/base/models/organizer.py b/src/pretix/base/models/organizer.py
index 8edcc4031f..e45855bbe6 100644
--- a/src/pretix/base/models/organizer.py
+++ b/src/pretix/base/models/organizer.py
@@ -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,
diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py
index cfdc1220a7..da5bf0266d 100644
--- a/src/pretix/control/forms/organizer.py
+++ b/src/pretix/control/forms/organizer.py
@@ -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',
diff --git a/src/pretix/control/middleware.py b/src/pretix/control/middleware.py
index 7391def2b9..9a176945d1 100644
--- a/src/pretix/control/middleware.py
+++ b/src/pretix/control/middleware.py
@@ -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'] == '-':
diff --git a/src/pretix/control/templates/pretixcontrol/organizers/team_edit.html b/src/pretix/control/templates/pretixcontrol/organizers/team_edit.html
index 70217e03eb..a5bcb8bd2b 100644
--- a/src/pretix/control/templates/pretixcontrol/organizers/team_edit.html
+++ b/src/pretix/control/templates/pretixcontrol/organizers/team_edit.html
@@ -18,6 +18,7 @@