mirror of
https://github.com/pretix/pretix.git
synced 2026-05-04 15:04:03 +00:00
* Add option to restrict anonymous access to order URLs By default, users who place orders while logged in can still access their order URLs without authentication. This raises potential security risks, particularly if order confirmation emails are forwarded. This commit introduces an organiser-level setting to disable anonymous access for such orders. When enabled, unauthenticated attempts to access URLs starting with `/order/`, which are intended for the customer, are redirected to the login page. Upon successful authentication, the user is redirected back to the original order URL. It is important to note that this change does not impact routes intended for attendees (e.g., `/ticket/*`), which remain accessible without authentication. * Change name of setting for future clarity Co-authored-by: Raphael Michel <mail@raphaelmichel.de> * Update message wording Co-authored-by: Raphael Michel <mail@raphaelmichel.de> * Eliminate database query Co-authored-by: Raphael Michel <mail@raphaelmichel.de> * Rename feature flag to fix breaking tests * Refactor order access verification code into `OrderDetailsMixin` * Add test for logged-in customer accessing another customer's order * Refactor order access conditions to remove nesting * Handle case where customer is not yet verified * Add additional information to help message * Fix multidomain issue Co-authored-by: Raphael Michel <mail@raphaelmichel.de> * Merge order/position variants into single tests * Add docstring explaining return type of `order` property * Apply suggestion from @raphaelm * Fix indentation --------- Co-authored-by: Raphael Michel <mail@raphaelmichel.de> Co-authored-by: Raphael Michel <michel@rami.io>
509 lines
20 KiB
Python
509 lines
20 KiB
Python
#
|
|
# This file is part of pretix (Community Edition).
|
|
#
|
|
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
|
# Copyright (C) 2020-today pretix GmbH and contributors
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
|
# Public License as published by the Free Software Foundation in version 3 of the License.
|
|
#
|
|
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
|
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
|
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
|
# this file, see <https://pretix.eu/about/en/license>.
|
|
#
|
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
# details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
|
# <https://www.gnu.org/licenses/>.
|
|
#
|
|
import logging
|
|
from decimal import Decimal
|
|
|
|
from django.conf import settings
|
|
from django.core.exceptions import ObjectDoesNotExist
|
|
from django.db import transaction
|
|
from django.db.models import Q
|
|
from django.utils.crypto import get_random_string
|
|
from django.utils.translation import gettext_lazy as _
|
|
from rest_framework import serializers
|
|
from rest_framework.exceptions import ValidationError
|
|
|
|
from pretix.api.auth.devicesecurity import get_all_security_profiles
|
|
from pretix.api.serializers import AsymmetricField
|
|
from pretix.api.serializers.fields import PluginsField
|
|
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
|
from pretix.api.serializers.order import CompatibleJSONField
|
|
from pretix.api.serializers.settings import SettingsSerializer
|
|
from pretix.base.auth import get_auth_backends
|
|
from pretix.base.i18n import get_language_without_region
|
|
from pretix.base.models import (
|
|
Customer, Device, GiftCard, GiftCardAcceptance, GiftCardTransaction,
|
|
Membership, MembershipType, OrderPosition, Organizer, ReusableMedium,
|
|
SalesChannel, SeatingPlan, Team, TeamAPIToken, TeamInvite, User,
|
|
)
|
|
from pretix.base.models.seating import SeatingPlanLayoutValidator
|
|
from pretix.base.plugins import (
|
|
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
|
|
PLUGIN_LEVEL_ORGANIZER,
|
|
)
|
|
from pretix.base.services.mail import SendMailException, mail
|
|
from pretix.base.settings import validate_organizer_settings
|
|
from pretix.helpers.urls import build_absolute_uri as build_global_uri
|
|
from pretix.multidomain.urlreverse import build_absolute_uri
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class OrganizerSerializer(I18nAwareModelSerializer):
|
|
public_url = serializers.SerializerMethodField('get_organizer_url', read_only=True)
|
|
plugins = PluginsField(required=False, source='*')
|
|
name = serializers.CharField(read_only=True)
|
|
slug = serializers.CharField(read_only=True)
|
|
|
|
def get_organizer_url(self, organizer):
|
|
return build_absolute_uri(organizer, 'presale:organizer.index')
|
|
|
|
class Meta:
|
|
model = Organizer
|
|
fields = ('name', 'slug', 'public_url', 'plugins')
|
|
|
|
def validate_plugins(self, value):
|
|
from pretix.base.plugins import get_all_plugins
|
|
|
|
plugins_available = {
|
|
p.module: p for p in get_all_plugins(organizer=self.instance)
|
|
if not p.name.startswith('.') and getattr(p, 'visible', True)
|
|
}
|
|
settings_holder = self.instance
|
|
|
|
allowed_levels = (PLUGIN_LEVEL_ORGANIZER, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID)
|
|
for plugin in value.get('plugins'):
|
|
if plugin not in plugins_available:
|
|
raise ValidationError(_('Unknown plugin: \'{name}\'.').format(name=plugin))
|
|
if getattr(plugins_available[plugin], 'restricted', False):
|
|
if plugin not in settings_holder.settings.allowed_restricted_plugins:
|
|
raise ValidationError(_('Restricted plugin: \'{name}\'.').format(name=plugin))
|
|
if getattr(plugins_available[plugin], 'level', PLUGIN_LEVEL_EVENT) not in allowed_levels:
|
|
raise ValidationError('Plugin cannot be enabled on this level: \'{name}\'.'.format(name=plugin))
|
|
|
|
return value
|
|
|
|
@transaction.atomic
|
|
def update(self, instance, validated_data):
|
|
plugins = validated_data.pop('plugins', None)
|
|
organizer = super().update(instance, validated_data)
|
|
# Plugins
|
|
if plugins is not None:
|
|
organizer.set_active_plugins(plugins)
|
|
organizer.save()
|
|
return organizer
|
|
|
|
|
|
class SeatingPlanSerializer(I18nAwareModelSerializer):
|
|
layout = CompatibleJSONField(
|
|
validators=[SeatingPlanLayoutValidator()]
|
|
)
|
|
|
|
class Meta:
|
|
model = SeatingPlan
|
|
fields = ('id', 'name', 'layout')
|
|
|
|
|
|
class CustomerSerializer(I18nAwareModelSerializer):
|
|
identifier = serializers.CharField(read_only=True)
|
|
name = serializers.CharField(read_only=True)
|
|
last_login = serializers.DateTimeField(read_only=True)
|
|
date_joined = serializers.DateTimeField(read_only=True)
|
|
last_modified = serializers.DateTimeField(read_only=True)
|
|
locale = serializers.ChoiceField(choices=settings.LANGUAGES, default='en')
|
|
|
|
class Meta:
|
|
model = Customer
|
|
fields = ('identifier', 'external_identifier', 'email', 'phone', 'name', 'name_parts', 'is_active',
|
|
'is_verified', 'last_login', 'date_joined', 'locale', 'last_modified', 'notes')
|
|
|
|
def update(self, instance, validated_data):
|
|
if instance and instance.provider_id:
|
|
validated_data['external_identifier'] = instance.external_identifier
|
|
return super().update(instance, validated_data)
|
|
|
|
def validate(self, data):
|
|
if data.get('name_parts') and not isinstance(data.get('name_parts'), dict):
|
|
raise ValidationError({'name_parts': ['Invalid data type']})
|
|
if data.get('name_parts') and '_scheme' not in data.get('name_parts'):
|
|
data['name_parts']['_scheme'] = self.context['request'].organizer.settings.name_scheme
|
|
return data
|
|
|
|
def validate_email(self, value):
|
|
qs = Customer.objects.filter(organizer=self.context['organizer'], email__iexact=value)
|
|
if self.instance and self.instance.pk:
|
|
qs = qs.exclude(pk=self.instance.pk)
|
|
if qs.exists():
|
|
raise ValidationError(_("An account with this email address is already registered."))
|
|
return value
|
|
|
|
|
|
class CustomerCreateSerializer(CustomerSerializer):
|
|
send_email = serializers.BooleanField(default=False, required=False, allow_null=True)
|
|
password = serializers.CharField(write_only=True, required=False, allow_null=True)
|
|
|
|
class Meta:
|
|
model = Customer
|
|
fields = CustomerSerializer.Meta.fields + ('send_email', 'password')
|
|
|
|
|
|
class MembershipTypeSerializer(I18nAwareModelSerializer):
|
|
|
|
class Meta:
|
|
model = MembershipType
|
|
fields = ('id', 'name', 'transferable', 'allow_parallel_usage', 'max_usages')
|
|
|
|
|
|
class MembershipSerializer(I18nAwareModelSerializer):
|
|
customer = serializers.SlugRelatedField(slug_field='identifier', queryset=Customer.objects.none())
|
|
|
|
class Meta:
|
|
model = Membership
|
|
fields = ('id', 'testmode', 'customer', 'membership_type', 'date_start', 'date_end', 'attendee_name_parts')
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.fields['customer'].queryset = self.context['organizer'].customers.all()
|
|
self.fields['membership_type'].queryset = self.context['organizer'].membership_types.all()
|
|
|
|
def update(self, instance, validated_data):
|
|
validated_data['customer'] = instance.customer # no modifying
|
|
validated_data['testmode'] = instance.testmode # no modifying
|
|
return super().update(instance, validated_data)
|
|
|
|
|
|
class FlexibleTicketRelatedField(serializers.PrimaryKeyRelatedField):
|
|
|
|
def to_internal_value(self, data):
|
|
queryset = self.get_queryset()
|
|
|
|
if isinstance(data, int):
|
|
try:
|
|
return queryset.get(pk=data)
|
|
except ObjectDoesNotExist:
|
|
self.fail('does_not_exist', pk_value=data)
|
|
|
|
elif isinstance(data, str):
|
|
try:
|
|
return queryset.get(
|
|
Q(secret=data)
|
|
| Q(pseudonymization_id=data)
|
|
| Q(pk__in=ReusableMedium.objects.filter(
|
|
organizer=self.context['organizer'],
|
|
type='barcode',
|
|
identifier=data
|
|
))
|
|
)
|
|
except ObjectDoesNotExist:
|
|
self.fail('does_not_exist', pk_value=data)
|
|
|
|
self.fail('incorrect_type', data_type=type(data).__name__)
|
|
|
|
|
|
class SalesChannelSerializer(I18nAwareModelSerializer):
|
|
type = serializers.CharField(default="api")
|
|
|
|
class Meta:
|
|
model = SalesChannel
|
|
fields = ('identifier', 'type', 'label', 'position')
|
|
|
|
def validate_type(self, value):
|
|
if (not self.instance or not self.instance.pk) and value != "api":
|
|
raise ValidationError(
|
|
"You can currently only create channels of type 'api' through the API."
|
|
)
|
|
if value and self.instance and self.instance.pk and self.instance.type != value:
|
|
raise ValidationError(
|
|
"You cannot change the type of a sales channel."
|
|
)
|
|
return value
|
|
|
|
def validate_identifier(self, value):
|
|
if (not self.instance or not self.instance.pk) and not value.startswith("api."):
|
|
raise ValidationError(
|
|
"Your identifier needs to start with 'api.'."
|
|
)
|
|
if value and self.instance and self.instance.pk and self.instance.identifier != value:
|
|
raise ValidationError(
|
|
"You cannot change the identifier of a sales channel."
|
|
)
|
|
return value
|
|
|
|
|
|
class GiftCardSerializer(I18nAwareModelSerializer):
|
|
value = serializers.DecimalField(max_digits=13, decimal_places=2, min_value=Decimal('0.00'))
|
|
owner_ticket = FlexibleTicketRelatedField(required=False, allow_null=True, queryset=OrderPosition.all.none())
|
|
issuer = serializers.SlugRelatedField(slug_field='slug', read_only=True)
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.fields['owner_ticket'].queryset = OrderPosition.objects.filter(order__event__organizer=self.context['organizer'])
|
|
|
|
if 'owner_ticket' in self.context['request'].query_params.getlist('expand'):
|
|
from pretix.api.serializers.media import (
|
|
NestedOrderPositionSerializer,
|
|
)
|
|
|
|
self.fields['owner_ticket'] = AsymmetricField(
|
|
NestedOrderPositionSerializer(read_only=True, context=self.context),
|
|
self.fields['owner_ticket'],
|
|
)
|
|
|
|
def validate(self, data):
|
|
data = super().validate(data)
|
|
if 'secret' in data:
|
|
s = data['secret']
|
|
qs = GiftCard.objects.filter(
|
|
secret=s
|
|
).filter(
|
|
Q(issuer=self.context["organizer"]) |
|
|
Q(issuer__in=GiftCardAcceptance.objects.filter(
|
|
acceptor=self.context["organizer"],
|
|
active=True,
|
|
).values_list('issuer', flat=True))
|
|
)
|
|
if self.instance:
|
|
qs = qs.exclude(pk=self.instance.pk)
|
|
if qs.exists():
|
|
raise ValidationError(
|
|
{'secret': _(
|
|
'A gift card with the same secret already exists in your or an affiliated organizer account.')}
|
|
)
|
|
return data
|
|
|
|
class Meta:
|
|
model = GiftCard
|
|
fields = ('id', 'secret', 'issuance', 'value', 'currency', 'testmode', 'expires', 'conditions', 'owner_ticket',
|
|
'issuer')
|
|
|
|
|
|
class OrderEventSlugField(serializers.RelatedField):
|
|
|
|
def to_representation(self, obj):
|
|
return obj.event.slug
|
|
|
|
|
|
class GiftCardTransactionSerializer(I18nAwareModelSerializer):
|
|
order = serializers.SlugRelatedField(slug_field='code', read_only=True)
|
|
acceptor = serializers.SlugRelatedField(slug_field='slug', read_only=True)
|
|
event = OrderEventSlugField(source='order', read_only=True)
|
|
|
|
class Meta:
|
|
model = GiftCardTransaction
|
|
fields = ('id', 'datetime', 'value', 'event', 'order', 'text', 'info', 'acceptor')
|
|
|
|
|
|
class EventSlugField(serializers.SlugRelatedField):
|
|
def get_queryset(self):
|
|
return self.context['organizer'].events.all()
|
|
|
|
|
|
class TeamSerializer(serializers.ModelSerializer):
|
|
limit_events = EventSlugField(slug_field='slug', many=True)
|
|
|
|
class Meta:
|
|
model = Team
|
|
fields = (
|
|
'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'
|
|
)
|
|
|
|
def validate(self, data):
|
|
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
|
|
full_data.update(data)
|
|
if full_data.get('limit_events') and full_data.get('all_events'):
|
|
raise ValidationError('Do not set both limit_events and all_events.')
|
|
return data
|
|
|
|
|
|
class DeviceSerializer(serializers.ModelSerializer):
|
|
limit_events = EventSlugField(slug_field='slug', many=True)
|
|
device_id = serializers.IntegerField(read_only=True)
|
|
unique_serial = serializers.CharField(read_only=True)
|
|
hardware_brand = serializers.CharField(read_only=True)
|
|
hardware_model = serializers.CharField(read_only=True)
|
|
os_name = serializers.CharField(read_only=True)
|
|
os_version = serializers.CharField(read_only=True)
|
|
software_brand = serializers.CharField(read_only=True)
|
|
software_version = serializers.CharField(read_only=True)
|
|
created = serializers.DateTimeField(read_only=True)
|
|
revoked = serializers.BooleanField(read_only=True)
|
|
initialized = serializers.DateTimeField(read_only=True)
|
|
initialization_token = serializers.DateTimeField(read_only=True)
|
|
security_profile = serializers.ChoiceField(choices=[], required=False, default="full")
|
|
|
|
class Meta:
|
|
model = Device
|
|
fields = (
|
|
'device_id', 'unique_serial', 'initialization_token', 'all_events', 'limit_events',
|
|
'revoked', 'name', 'created', 'initialized', 'hardware_brand', 'hardware_model',
|
|
'os_name', 'os_version', 'software_brand', 'software_version', 'security_profile'
|
|
)
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.fields['security_profile'].choices = [(k, v.verbose_name) for k, v in get_all_security_profiles().items()]
|
|
|
|
|
|
class TeamInviteSerializer(serializers.ModelSerializer):
|
|
class Meta:
|
|
model = TeamInvite
|
|
fields = (
|
|
'id', 'email'
|
|
)
|
|
|
|
def _send_invite(self, instance):
|
|
try:
|
|
mail(
|
|
instance.email,
|
|
_('pretix account invitation'),
|
|
'pretixcontrol/email/invitation.txt',
|
|
{
|
|
'user': self,
|
|
'organizer': self.context['organizer'].name,
|
|
'team': instance.team.name,
|
|
'url': build_global_uri('control:auth.invite', kwargs={
|
|
'token': instance.token
|
|
})
|
|
},
|
|
event=None,
|
|
locale=get_language_without_region() # TODO: expose?
|
|
)
|
|
except SendMailException:
|
|
pass # Already logged
|
|
|
|
def create(self, validated_data):
|
|
if 'email' in validated_data:
|
|
try:
|
|
user = User.objects.get(email__iexact=validated_data['email'])
|
|
except User.DoesNotExist:
|
|
if self.context['team'].invites.filter(email__iexact=validated_data['email']).exists():
|
|
raise ValidationError(_('This user already has been invited for this team.'))
|
|
if 'native' not in get_auth_backends():
|
|
raise ValidationError('Users need to have a pretix account before they can be invited.')
|
|
|
|
invite = self.context['team'].invites.create(email=validated_data['email'])
|
|
self._send_invite(invite)
|
|
invite.team.log_action(
|
|
'pretix.team.invite.created',
|
|
data={
|
|
'email': validated_data['email']
|
|
},
|
|
**self.context['log_kwargs']
|
|
)
|
|
return invite
|
|
else:
|
|
if self.context['team'].members.filter(pk=user.pk).exists():
|
|
raise ValidationError(_('This user already has permissions for this team.'))
|
|
|
|
self.context['team'].members.add(user)
|
|
self.context['team'].log_action(
|
|
'pretix.team.member.added',
|
|
data={
|
|
'email': user.email,
|
|
'user': user.pk,
|
|
},
|
|
**self.context['log_kwargs']
|
|
)
|
|
return TeamInvite(email=user.email)
|
|
else:
|
|
raise ValidationError('No email address given.')
|
|
|
|
|
|
class TeamAPITokenSerializer(serializers.ModelSerializer):
|
|
active = serializers.BooleanField(default=True, read_only=True)
|
|
|
|
class Meta:
|
|
model = TeamAPIToken
|
|
fields = (
|
|
'id', 'name', 'active'
|
|
)
|
|
|
|
|
|
class TeamMemberSerializer(serializers.ModelSerializer):
|
|
class Meta:
|
|
model = User
|
|
fields = (
|
|
'id', 'email', 'fullname', 'require_2fa'
|
|
)
|
|
|
|
|
|
class OrganizerSettingsSerializer(SettingsSerializer):
|
|
default_fields = [
|
|
'customer_accounts',
|
|
'customer_accounts_native',
|
|
'customer_accounts_link_by_email',
|
|
'customer_accounts_require_login_for_order_access',
|
|
'invoice_regenerate_allowed',
|
|
'contact_mail',
|
|
'imprint_url',
|
|
'organizer_info_text',
|
|
'event_list_type',
|
|
'event_list_availability',
|
|
'organizer_homepage_text',
|
|
'organizer_link_back',
|
|
'organizer_logo_image_large',
|
|
'giftcard_length',
|
|
'giftcard_expiry_years',
|
|
'locales',
|
|
'region',
|
|
'event_team_provisioning',
|
|
'primary_color',
|
|
'theme_color_success',
|
|
'theme_color_danger',
|
|
'theme_color_background',
|
|
'theme_round_borders',
|
|
'primary_font',
|
|
'organizer_logo_image_inherit',
|
|
'organizer_logo_image',
|
|
'privacy_url',
|
|
'accessibility_url',
|
|
'accessibility_title',
|
|
'accessibility_text',
|
|
'cookie_consent',
|
|
'cookie_consent_dialog_title',
|
|
'cookie_consent_dialog_text',
|
|
'cookie_consent_dialog_text_secondary',
|
|
'cookie_consent_dialog_button_yes',
|
|
'cookie_consent_dialog_button_no',
|
|
'reusable_media_active',
|
|
'reusable_media_type_barcode',
|
|
'reusable_media_type_barcode_identifier_length',
|
|
'reusable_media_type_nfc_uid',
|
|
'reusable_media_type_nfc_uid_autocreate_giftcard',
|
|
'reusable_media_type_nfc_uid_autocreate_giftcard_currency',
|
|
'reusable_media_type_nfc_mf0aes',
|
|
'reusable_media_type_nfc_mf0aes_autocreate_giftcard',
|
|
'reusable_media_type_nfc_mf0aes_autocreate_giftcard_currency',
|
|
'reusable_media_type_nfc_mf0aes_random_uid',
|
|
]
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
self.organizer = kwargs.pop('organizer')
|
|
super().__init__(*args, **kwargs)
|
|
|
|
def validate(self, data):
|
|
data = super().validate(data)
|
|
settings_dict = self.instance.freeze()
|
|
settings_dict.update(data)
|
|
validate_organizer_settings(self.organizer, settings_dict)
|
|
return data
|
|
|
|
def get_new_filename(self, name: str) -> str:
|
|
nonce = get_random_string(length=8)
|
|
fname = '%s/%s.%s.%s' % (
|
|
self.organizer.slug, name.split('/')[-1], nonce, name.split('.')[-1]
|
|
)
|
|
# TODO: make sure pub is always correct
|
|
return 'pub/' + fname
|