diff --git a/doc/admin/config.rst b/doc/admin/config.rst index c6de356efe..53c465ddc7 100644 --- a/doc/admin/config.rst +++ b/doc/admin/config.rst @@ -99,7 +99,7 @@ Example:: Log violations of the Content Security Policy (CSP). Defaults to ``on``. ``loglevel`` - Set console and file loglevel (``DEBUG``, ``INFO``, ``WARNING``, ``ERROR`` or ``CRITICAL``). Defaults to ``INFO``. + Set console and file log level (``DEBUG``, ``INFO``, ``WARNING``, ``ERROR`` or ``CRITICAL``). Defaults to ``INFO``. Locale settings --------------- diff --git a/doc/api/oauth.rst b/doc/api/oauth.rst index e5d57ec94a..d8b9992146 100644 --- a/doc/api/oauth.rst +++ b/doc/api/oauth.rst @@ -49,7 +49,7 @@ On a failed registration, a query string like ``?error=access_denied`` will be a .. note:: By default, the user is asked to give permission on every call to this URL. If you **only** request the ``profile`` scope, i.e. no access to organizer data, you can pass the ``approval_prompt=auto`` parameter - to skip user interaction on subsequen calls. + to skip user interaction on subsequent calls. Getting an access token ----------------------- diff --git a/doc/api/resources/devices.rst b/doc/api/resources/devices.rst index 8058028ffd..acf99f8a78 100644 --- a/doc/api/resources/devices.rst +++ b/doc/api/resources/devices.rst @@ -216,7 +216,7 @@ Device endpoints } :param organizer: The ``slug`` field of the organizer to modify - :param device_id: The ``device_id`` field of the deviec to modify + :param device_id: The ``device_id`` field of the device to modify :statuscode 200: no error :statuscode 400: The device could not be modified due to invalid submitted data :statuscode 401: Authentication failure diff --git a/doc/api/resources/events.rst b/doc/api/resources/events.rst index 6fb694824a..4f3e5de218 100644 --- a/doc/api/resources/events.rst +++ b/doc/api/resources/events.rst @@ -44,6 +44,9 @@ seat_category_mapping object An object mappi (strings) to items in the event (integers or ``null``). timezone string Event timezone name item_meta_properties object Item-specific meta data parameters and default values. +valid_keys object Cryptographic keys for non-default signature schemes. + For performance reason, value is omitted in lists and + only contained in detail views. Value can be cached. ===================================== ========================== ======================================================= @@ -84,6 +87,10 @@ item_meta_properties object Item-specific m The attribute ``item_meta_properties`` has been added. +.. versionchanged:: 3.12 + + The attribute ``valid_keys`` has been added. + Endpoints --------- @@ -144,7 +151,7 @@ Endpoints "pretix.plugins.stripe" "pretix.plugins.paypal" "pretix.plugins.ticketoutputpdf" - ] + ], } ] } @@ -216,7 +223,12 @@ Endpoints "pretix.plugins.stripe" "pretix.plugins.paypal" "pretix.plugins.ticketoutputpdf" - ] + ], + "valid_keys": { + "pretix_sig1": [ + "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUNvd0JRWURLMlZ3QXlFQTdBRDcvdkZBMzNFc1k0ejJQSHI3aVpQc1o4bjVkaDBhalA4Z3l6Tm1tSXM9Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo=" + ] + } } :param organizer: The ``slug`` field of the organizer to fetch diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index 92b6623f1b..fe939674b3 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -2263,3 +2263,57 @@ Order refund endpoints :statuscode 401: Authentication failure :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. :statuscode 404: The requested order or refund does not exist. + +Revoked ticket secrets +---------------------- + +With some non-default ticket secret generation methods, a list of revoked ticket secrets is required for proper validation. + +.. versionchanged:: 3.12 + + Added revocation lists. + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/revokedsecrets/ + + Returns a list of all revoked secrets within a given event. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/revokedsecrets/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + X-Page-Generated: 2017-12-01T10:00:00Z + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 1234, + "secret": "k24fiuwvu8kxz3y1", + "created": "2017-12-01T10:00:00Z", + } + ] + } + + :query integer page: The page number in case of a multi-page result set, default is 1 + :query string ordering: Manually set the ordering of results. Valid fields to be used are ``secret`` and ``created``. Default: ``-created`` + :query datetime created_since: Only return revocations that have been created since the given date. + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :resheader X-Page-Generated: The server time at the beginning of the operation. If you're using this API to fetch + differences, this is the value you want to use as ``created_since`` in your next call. + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. diff --git a/doc/development/api/general.rst b/doc/development/api/general.rst index 109476ea5b..6e95aa4ad2 100644 --- a/doc/development/api/general.rst +++ b/doc/development/api/general.rst @@ -12,7 +12,8 @@ Core .. automodule:: pretix.base.signals :members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types, - item_copy_data, register_sales_channels, register_global_settings, quota_availability, global_email_filter + item_copy_data, register_sales_channels, register_global_settings, quota_availability, global_email_filter, + register_ticket_secret_generators Order events """""""""""" diff --git a/doc/development/setup.rst b/doc/development/setup.rst index d00d97f8da..73d98dbdb7 100644 --- a/doc/development/setup.rst +++ b/doc/development/setup.rst @@ -117,7 +117,7 @@ for example, to check for any errors in any staged files when committing:: export GIT_WORK_TREE=../ export GIT_DIR=../.git source ../env/bin/activate # Adjust to however you activate your virtual environment - for file in $(git diff --cached --name-only | grep -E '\.py$' | grep -Ev "migrations|mt940\.py|pretix/settings\.py|make_testdata\.py|testutils/settings\.py|tests/settings\.py|pretix/base/models/__init__\.py") + for file in $(git diff --cached --name-only | grep -E '\.py$' | grep -Ev "migrations|mt940\.py|pretix/settings\.py|make_testdata\.py|testutils/settings\.py|tests/settings\.py|pretix/base/models/__init__\.py|.*_pb2\.py") do echo $file git show ":$file" | flake8 - --stdin-display-name="$file" || exit 1 # we only want to lint the staged changes, not any un-staged changes diff --git a/doc/requirements.txt b/doc/requirements.txt index dd7f0c4291..4c166c0325 100644 --- a/doc/requirements.txt +++ b/doc/requirements.txt @@ -3,7 +3,7 @@ sphinx==2.3.* sphinx-rtd-theme sphinxcontrib-httpdomain sphinxcontrib-images -sphinxcontrib-spelling +sphinxcontrib-spelling==4.* pygments-markdown-lexer # See https://github.com/rfk/pyenchant/pull/130 git+https://github.com/raphaelm/pyenchant.git@patch-1#egg=pyenchant diff --git a/doc/spelling_wordlist.txt b/doc/spelling_wordlist.txt index 67f0f50b03..7349b5862d 100644 --- a/doc/spelling_wordlist.txt +++ b/doc/spelling_wordlist.txt @@ -10,6 +10,8 @@ availabilities backend backends banktransfer +barcode +barcodes Bcc boolean booleans @@ -53,6 +55,7 @@ iframe incrementing inofficial invalidations +iOS iterable Jimdo jwt @@ -91,6 +94,7 @@ prepending preprocessor presale pretix +pretixSCAN pretixdroid pretixpresale prometheus diff --git a/doc/user/events/ticket_secrets.rst b/doc/user/events/ticket_secrets.rst new file mode 100644 index 0000000000..49b4b8cbbb --- /dev/null +++ b/doc/user/events/ticket_secrets.rst @@ -0,0 +1,93 @@ +Ticket secret generators +======================== + +pretix allows you to change the way in which ticket secrets (also known as "ticket codes", "barcodes", …) +are generated. This affects the value of the QR code in any tickets issued by pretix, regardless of ticket +format. + +.. note:: This is intended for highly advanced use cases, usually when huge numbers of tickets (> 25k per event) + are involved. **If you don't know whether you need this, you probably don't.** + +Default: Random secrets +----------------------- + +By default, pretix generates a random code for every ticket, consisting of 32 lower case characters and +numbers. The characters ``oO1il`` are avoided to reduce confusion when ticket codes are printed and need to +be typed in manually. + +Choosing random codes has a number of advantages: + +* Ticket codes are short, which makes QR codes easier to scan. At the same time, it is absolutely impossible to + guess or forge a valid ticket code. + +* The code does not need to change if the ticket changes. For example, if an attendee is re-booked to a + different product or date, they can keep their ticket and it is just mapped to the new product in the + database. + +This approach works really well for 99 % or events running with pretix. +The big caveat is that the scanner needs to access a database of all ticket codes in order to know whether a ticket +code is valid and what kind of ticket it represents. + +When scanning online this is no problem at all, since the pretix server always has such a database. In case your local +internet connection is interrupted or the pretix server goes down, though, there needs to be a database locally on the +scanner. + +Therefore, our pretixSCAN apps by default download the database of all valid tickets onto the device itself. This makes +it possible to seamlessly switch into offline mode when the connection is lost and continue scanning with the maximum +possible feature set. + +There are a few situations in which this approach is not ideal: + +* When running a single event with 25k or more valid tickets, downloading all ticket data onto the scanner may just + take too much time and resources. + +* When the risk of losing sensible data by losing one of the scanner devices is not acceptable. + +* When offline mode needs to be used regularly and newly-purchased tickets need to be valid immediately after purchase, + without being able to tolerate a few minutes of delay. + +Signature schemes +----------------- + +The alternative approach that is included with pretix is to choose a signature-based ticket code generation scheme. +These secrets include the most important information that is required for verifying their validity and use modern +cryptography to make sure they cannot be forged. + +Currently, pretix ships with one such scheme ("pretix signature scheme 1") which encodes the product, the product +variation, and the date (if inside an event series) into the ticket code and signs the code with a `EdDSA`_ signature. +This allows to verify whether a ticket is allowed to enter without any database or connection to the server, but has +a few important drawbacks: + +* Whenever the product, variation or date of a ticket changes or the ticket is canceled, the ticket code needs to be + changed and the old code needs to be put on a revocation list. This revocation list again needs to be downloaded by + all scanning devices (but is usually much smaller than the ticket database). The main downside is that the attendee + needs to download their new ticket and can no longer use the old one. + +* Scanning in offline mode is much more limited, since the scanner has no information about previous usages of the + ticket, attendee names, seating information, etc. + +Comparison of scanning behavior +------------------------------- + +=============================================== =================================== =================================== =================================== ================================= ===================================== +Scan mode Online Offline +----------------------------------------------- ----------------------------------- ----------------------------------------------------------------------------------------------------------------------------------------------- +Synchronization setting any Synchronize orders Don't synchronize orders +----------------------------------------------- ----------------------------------- ----------------------------------------------------------------------- ----------------------------------------------------------------------- +Ticket secrets any Random Signed Random Signed +=============================================== =================================== =================================== =================================== ================================= ===================================== +Scenario supported on platforms Android, Desktop, iOS Android, Desktop, iOS Android, Desktop Android, Desktop Android, Desktop +Synchronization speed for large data sets slow slow fast fast +Tickets can be scanned yes yes yes no yes +Ticket is valid after sale immediately next sync (~5 minutes) immediately never immediately +Same ticket can be scanned multiple times no yes, before data is synced yes, before data is synced n/a yes, always +Custom check-in rules yes yes yes (limited directly after sale) n/a yes, but only based on product, + variation and date, not on previous + scans +Name and seat visible on scanner yes yes yes (except directly after sale) n/a no +Order-specific check-in attention flag yes yes yes (except directly after sale) n/a no +Ticket search by order code or name yes yes yes (except directly after sale) no no +Check-in statistics on scanner yes yes mostly accurate no no +=============================================== =================================== =================================== =================================== ================================= ===================================== + +.. _EdDSA: https://en.wikipedia.org/wiki/EdDSA#Ed25519 diff --git a/doc/user/events/tickets.rst b/doc/user/events/tickets.rst index cccc8a8093..29d3602c2b 100644 --- a/doc/user/events/tickets.rst +++ b/doc/user/events/tickets.rst @@ -9,26 +9,33 @@ At "Settings" → "Tickets", you can configure the ticket download options that The top of this page shows a short list of options relevant for all download formats: -Use feature +Allow users to download tickets This can be used to completely enable or disable ticket downloads all over your ticket shop. +Generate tickets for add-on products + By default, tickets can not be downloaded for order positions which are only an add-on to other order positions. If + you enable this, this behavior will be changed and add-on products will get their own tickets as well. If disabled, + you can still print a list of chosen add-ons e.g. on the PDF tickets. + +Generate tickets for all products + By default, tickets will only be generated for products that are marked as admission products. Enable this option to + generate tickets for all products instead. + +Generate tickets for pending orders + By default, ticket download is only possible for paid orders. If you run an event where people usually pay only after + the event, you can check this box to enable ticket download even before. + Download date If you set a date here, no ticket download will be offered before this date. If no date is set, tickets can be downloaded immediately after the payment for an order has been received. -Offer to download tickets separately for add-on products - By default, tickets can not be downloaded for order positions which are only an add-on to other order positions. If - you enable this, this behavior will be changed and add-on products will get their own tickets as well. If disabled, - you can still print a list of chosen add-ons e.g. on the PDF tickets. - -Generate tickets for non-admission products - By default, tickets will only be generated for products that are marked as admission products. Enable this option to - generate tickets for all products instead. - -Offer to download tickets even before an order is paid - By default, ticket download is only possible for paid orders. If you run an event where people usually pay only after - the event, you can check this box to enable ticket download even before. - Below these settings, the detail settings for the various ticket file formats are offered. They differ from format to format and only share the common "Enable" setting that can be used to turn them on. By default, pretix ships with -a PDF output plugin that you can configure through a visual design editor. \ No newline at end of file +a PDF output plugin that you can configure through a visual design editor. + +**Advanced topics:** + +.. toctree:: + :maxdepth: 1 + + ticket_secrets diff --git a/src/pretix/api/auth/devicesecurity.py b/src/pretix/api/auth/devicesecurity.py index 34ebf53455..1495fbdefb 100644 --- a/src/pretix/api/auth/devicesecurity.py +++ b/src/pretix/api/auth/devicesecurity.py @@ -37,6 +37,7 @@ class PretixScanSecurityProfile(AllowListSecurityProfile): ('GET', 'api-v1:checkinlist-status'), ('GET', 'api-v1:checkinlistpos-list'), ('POST', 'api-v1:checkinlistpos-redeem'), + ('GET', 'api-v1:revokedsecrets-list'), ('GET', 'api-v1:order-list'), ('GET', 'api-v1:event.settings'), ) @@ -61,6 +62,7 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile): ('GET', 'api-v1:checkinlist-list'), ('GET', 'api-v1:checkinlist-status'), ('POST', 'api-v1:checkinlistpos-redeem'), + ('GET', 'api-v1:revokedsecrets-list'), ('GET', 'api-v1:event.settings'), ) @@ -98,6 +100,7 @@ class PretixPosSecurityProfile(AllowListSecurityProfile): ('POST', 'plugins:pretix_posbackend:posclosing-list'), ('POST', 'plugins:pretix_posbackend:posdebugdump-list'), ('POST', 'plugins:pretix_posbackend:stripeterminal.token'), + ('GET', 'api-v1:revokedsecrets-list'), ('GET', 'api-v1:event.settings'), ) diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index ed7cc2f340..ba71833a88 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -95,19 +95,41 @@ class TimeZoneField(ChoiceField): ) +class ValidKeysField(Field): + def to_representation(self, value): + return value.cache.get_or_set( + 'ticket_secret_valid_keys', + lambda: self._get(value), + 120 + ) + + def _get(self, value): + return { + 'pretix_sig1': [ + value.settings.ticket_secrets_pretix_sig1_pubkey + ] if value.settings.ticket_secrets_pretix_sig1_pubkey else [] + } + + class EventSerializer(I18nAwareModelSerializer): meta_data = MetaDataField(required=False, source='*') item_meta_properties = MetaPropertyField(required=False, source='*') plugins = PluginsField(required=False, source='*') seat_category_mapping = SeatCategoryMappingField(source='*', required=False) timezone = TimeZoneField(required=False, choices=[(a, a) for a in common_timezones]) + valid_keys = ValidKeysField(source='*', read_only=True) class Meta: model = Event fields = ('name', 'slug', 'live', 'testmode', 'currency', 'date_from', 'date_to', 'date_admission', 'is_public', 'presale_start', 'presale_end', 'location', 'geo_lat', 'geo_lon', 'has_subevents', 'meta_data', 'seating_plan', - 'plugins', 'seat_category_mapping', 'timezone', 'item_meta_properties') + 'plugins', 'seat_category_mapping', 'timezone', 'item_meta_properties', 'valid_keys') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not hasattr(self.context['request'], 'event'): + self.fields.pop('valid_keys') def validate(self, data): data = super().validate(data) diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index c3687998ca..5d5cb9bf1c 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -21,7 +21,7 @@ from pretix.base.models import ( OrderPosition, Question, QuestionAnswer, Seat, SubEvent, TaxRule, Voucher, ) from pretix.base.models.orders import ( - CartPosition, OrderFee, OrderPayment, OrderRefund, + CartPosition, OrderFee, OrderPayment, OrderRefund, RevokedTicketSecret, ) from pretix.base.pdf import get_variables from pretix.base.services.cart import error_messages @@ -1209,3 +1209,10 @@ class OrderRefundCreateSerializer(I18nAwareModelSerializer): order = OrderRefund(order=self.context['order'], payment=p, **validated_data) order.save() return order + + +class RevokedTicketSecretSerializer(I18nAwareModelSerializer): + + class Meta: + model = RevokedTicketSecret + fields = ('id', 'secret', 'created') diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index 6c510b4928..3141cf6606 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -39,6 +39,7 @@ event_router.register(r'vouchers', voucher.VoucherViewSet) event_router.register(r'orders', order.OrderViewSet) event_router.register(r'orderpositions', order.OrderPositionViewSet) event_router.register(r'invoices', order.InvoiceViewSet) +event_router.register(r'revokedsecrets', order.RevokedSecretViewSet, basename='revokedsecrets') event_router.register(r'taxrules', event.TaxRuleViewSet) event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet) event_router.register(r'checkinlists', checkin.CheckinListViewSet) diff --git a/src/pretix/api/views/checkin.py b/src/pretix/api/views/checkin.py index 0385477a8d..20f51e74d5 100644 --- a/src/pretix/api/views/checkin.py +++ b/src/pretix/api/views/checkin.py @@ -278,13 +278,23 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet): else: op = queryset.get(secret=self.kwargs['pk']) except OrderPosition.DoesNotExist: - self.request.event.log_action('pretix.event.checkin.unknown', data={ + revoked_matches = list(self.request.event.revoked_secrets.filter(secret=self.kwargs['pk'])) + if len(revoked_matches) == 0 or not force: + self.request.event.log_action('pretix.event.checkin.unknown', data={ + 'datetime': dt, + 'type': type, + 'list': self.checkinlist.pk, + 'barcode': self.kwargs['pk'] + }, user=self.request.user, auth=self.request.auth) + raise Http404() + + op = revoked_matches[0].position + op.order.log_action('pretix.event.checkin.revoked', data={ 'datetime': dt, 'type': type, 'list': self.checkinlist.pk, 'barcode': self.kwargs['pk'] }, user=self.request.user, auth=self.request.auth) - raise Http404() given_answers = {} if 'answers' in self.request.data: @@ -325,6 +335,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet): 'position': op.id, 'positionid': op.positionid, 'errorcode': e.code, + 'force': force, 'datetime': dt, 'type': type, 'list': self.checkinlist.pk diff --git a/src/pretix/api/views/event.py b/src/pretix/api/views/event.py index b48a1058d1..83d4e07857 100644 --- a/src/pretix/api/views/event.py +++ b/src/pretix/api/views/event.py @@ -89,7 +89,6 @@ class EventViewSet(viewsets.ModelViewSet): ) qs = filter_qs_by_attr(qs, self.request) - return qs.prefetch_related( 'meta_values', 'meta_values__property', 'seat_category_mappings' ) diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index f2b2f93ed6..181171d966 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -26,15 +26,18 @@ from pretix.api.serializers.order import ( InvoiceSerializer, OrderCreateSerializer, OrderPaymentCreateSerializer, OrderPaymentSerializer, OrderPositionSerializer, OrderRefundCreateSerializer, OrderRefundSerializer, OrderSerializer, - PriceCalcSerializer, SimulatedOrderSerializer, + PriceCalcSerializer, RevokedTicketSecretSerializer, + SimulatedOrderSerializer, ) from pretix.base.i18n import language from pretix.base.models import ( CachedCombinedTicket, CachedTicket, Device, Event, Invoice, InvoiceAddress, Order, OrderFee, OrderPayment, OrderPosition, OrderRefund, Quota, SubEvent, - TeamAPIToken, generate_position_secret, generate_secret, + TeamAPIToken, generate_secret, ) +from pretix.base.models.orders import RevokedTicketSecret from pretix.base.payment import PaymentException +from pretix.base.secrets import assign_ticket_secret from pretix.base.services import tickets from pretix.base.services.invoices import ( generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified, @@ -483,8 +486,9 @@ class OrderViewSet(viewsets.ModelViewSet): order = self.get_object() order.secret = generate_secret() for op in order.all_positions.all(): - op.secret = generate_position_secret() - op.save() + assign_ticket_secret( + request.event, op, force_invalidate=True, save=True + ) order.save(update_fields=['secret']) CachedTicket.objects.filter(order_position__order=order).delete() CachedCombinedTicket.objects.filter(order=order).delete() @@ -1298,3 +1302,26 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet): auth=self.request.auth, ) return Response(status=204) + + +with scopes_disabled(): + class RevokedSecretFilter(FilterSet): + created_since = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='gte') + + class Meta: + model = RevokedTicketSecret + fields = [] + + +class RevokedSecretViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = RevokedTicketSecretSerializer + queryset = RevokedTicketSecret.objects.none() + filter_backends = (DjangoFilterBackend, OrderingFilter) + ordering = ('-created',) + ordering_fields = ('created', 'secret') + filterset_class = RevokedSecretFilter + permission = 'can_view_orders' + write_permission = 'can_change_orders' + + def get_queryset(self): + return RevokedTicketSecret.objects.filter(event=self.request.event) diff --git a/src/pretix/base/migrations/0001_squashed_0028_auto_20160816_1242.py b/src/pretix/base/migrations/0001_squashed_0028_auto_20160816_1242.py index 803dcfa8f2..591f884c7a 100644 --- a/src/pretix/base/migrations/0001_squashed_0028_auto_20160816_1242.py +++ b/src/pretix/base/migrations/0001_squashed_0028_auto_20160816_1242.py @@ -482,7 +482,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='orderposition', name='secret', - field=models.CharField(db_index=True, default=pretix.base.models.orders.generate_position_secret, max_length=64), + field=models.CharField(db_index=True, default="invalid", max_length=64), ), migrations.AddField( model_name='order', diff --git a/src/pretix/base/migrations/0010_orderposition_secret.py b/src/pretix/base/migrations/0010_orderposition_secret.py index e9f2da8707..5701b7742a 100644 --- a/src/pretix/base/migrations/0010_orderposition_secret.py +++ b/src/pretix/base/migrations/0010_orderposition_secret.py @@ -17,6 +17,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='orderposition', name='secret', - field=models.CharField(default=pretix.base.models.orders.generate_position_secret, max_length=64), + field=models.CharField(default="invalid", max_length=64), ), ] diff --git a/src/pretix/base/migrations/0025_auto_20160802_2202.py b/src/pretix/base/migrations/0025_auto_20160802_2202.py index 8ac2f36b8f..cb0047c13b 100644 --- a/src/pretix/base/migrations/0025_auto_20160802_2202.py +++ b/src/pretix/base/migrations/0025_auto_20160802_2202.py @@ -38,7 +38,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='orderposition', name='secret', - field=models.CharField(db_index=True, default=pretix.base.models.orders.generate_position_secret, max_length=64), + field=models.CharField(db_index=True, default="invalid", max_length=64), ), migrations.AlterField( model_name='voucher', diff --git a/src/pretix/base/migrations/0165_auto_20201015_1924.py b/src/pretix/base/migrations/0165_auto_20201015_1924.py new file mode 100644 index 0000000000..81d4514184 --- /dev/null +++ b/src/pretix/base/migrations/0165_auto_20201015_1924.py @@ -0,0 +1,29 @@ +# Generated by Django 3.0.10 on 2020-10-15 19:24 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0164_subevent_last_modified'), + ] + + operations = [ + migrations.AlterField( + model_name='orderposition', + name='secret', + field=models.CharField(db_index=True, max_length=64), + ), + migrations.CreateModel( + name='RevokedTicketSecret', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('secret', models.TextField(db_index=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='revoked_secrets', to='pretixbase.Event')), + ('position', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='revoked_secrets', to='pretixbase.OrderPosition')), + ], + ), + ] diff --git a/src/pretix/base/migrations/0166_auto_20201015_2029.py b/src/pretix/base/migrations/0166_auto_20201015_2029.py new file mode 100644 index 0000000000..530ccdc576 --- /dev/null +++ b/src/pretix/base/migrations/0166_auto_20201015_2029.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.10 on 2020-10-15 20:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0165_auto_20201015_1924'), + ] + + operations = [ + migrations.AlterField( + model_name='orderposition', + name='secret', + field=models.CharField(db_index=True, max_length=255), + ), + ] diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index ec40ce71b0..f6cd434702 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -662,7 +662,14 @@ class Event(EventMixin, LoggedModel): s.product = item_map[s.product_id] s.save() + skip_settings = ( + 'ticket_secrets_pretix_sig1_pubkey', + 'ticket_secrets_pretix_sig1_privkey', + ) for s in other.settings._objects.all(): + if s.key in skip_settings: + continue + s.object = self s.pk = None if s.value.startswith('file://'): @@ -754,6 +761,31 @@ class Event(EventMixin, LoggedModel): renderers[pp.identifier] = pp return renderers + @cached_property + def ticket_secret_generators(self) -> dict: + """ + Returns a dictionary of cached initialized ticket secret generators mapped by their identifiers. + """ + from ..signals import register_ticket_secret_generators + + responses = register_ticket_secret_generators.send(self) + renderers = {} + for receiver, response in responses: + if not isinstance(response, list): + response = [response] + for p in response: + pp = p(self) + renderers[pp.identifier] = pp + return renderers + + @property + def ticket_secret_generator(self): + """ + Returns the currently configured ticket secret generator. + """ + tsgs = self.ticket_secret_generators + return tsgs[self.settings.ticket_secret_generator] + def get_data_shredders(self) -> dict: """ Returns a dictionary of initialized data shredders mapped by their identifiers. diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index f02bff993e..2995d76fa2 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -57,8 +57,7 @@ def generate_secret(): def generate_position_secret(): - # Exclude o,0,1,i,l to avoid confusion with bad fonts/printers - return get_random_string(length=settings.ENTROPY['ticket_secret'], allowed_chars='abcdefghjkmnpqrstuvwxyz23456789') + raise TypeError("Function no longer exists, use secret generators") class Order(LockModel, LoggedModel): @@ -1938,7 +1937,7 @@ class OrderPosition(AbstractPosition): max_digits=10, decimal_places=2, verbose_name=_('Tax value') ) - secret = models.CharField(max_length=64, default=generate_position_secret, db_index=True) + secret = models.CharField(max_length=255, null=False, blank=False, db_index=True) web_secret = models.CharField(max_length=32, default=generate_secret, db_index=True) pseudonymization_id = models.CharField( max_length=16, @@ -2031,13 +2030,18 @@ class OrderPosition(AbstractPosition): self.tax_rate = Decimal('0.00') def save(self, *args, **kwargs): + from pretix.base.secrets import assign_ticket_secret + if self.tax_rate is None: self._calculate_tax() self.order.touch() if not self.pk: - while OrderPosition.all.filter(secret=self.secret, - order__event__organizer_id=self.order.event.organizer_id).exists(): - self.secret = generate_position_secret() + while not self.secret or OrderPosition.all.filter( + secret=self.secret, order__event__organizer_id=self.order.event.organizer_id + ).exists(): + assign_ticket_secret( + event=self.order.event, position=self, force_invalidate=True, save=False + ) if not self.pseudonymization_id: self.assign_pseudonymization_id() @@ -2326,6 +2330,18 @@ class CancellationRequest(models.Model): refund_as_giftcard = models.BooleanField(default=False) +class RevokedTicketSecret(models.Model): + event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='revoked_secrets') + position = models.ForeignKey( + OrderPosition, + on_delete=models.SET_NULL, + related_name='revoked_secrets', + null=True, + ) + secret = models.TextField(db_index=True) + created = models.DateTimeField(auto_now_add=True) + + @receiver(post_delete, sender=CachedTicket) def cachedticket_delete(sender, instance, **kwargs): if instance.file: diff --git a/src/pretix/base/pdf.py b/src/pretix/base/pdf.py index 3572f5dad2..a926cf5ee6 100644 --- a/src/pretix/base/pdf.py +++ b/src/pretix/base/pdf.py @@ -48,7 +48,9 @@ DEFAULT_VARIABLES = OrderedDict(( ("secret", { "label": _("Ticket code (barcode content)"), "editor_sample": "tdmruoekvkpbv1o2mv8xccvqcikvr58u", - "evaluate": lambda orderposition, order, event: orderposition.secret + "evaluate": lambda orderposition, order, event: ( + orderposition.secret[:30] + "…" if len(orderposition.secret) > 32 else orderposition.secret + ) }), ("order", { "label": _("Order code"), @@ -427,8 +429,13 @@ class Renderer: elif content == 'pseudonymization_id': content = op.pseudonymization_id + level = 'H' + if len(content) > 32: + level = 'M' + if len(content) > 128: + level = 'L' reqs = float(o['size']) * mm - qrw = QrCodeWidget(content, barLevel='H', barHeight=reqs, barWidth=reqs) + qrw = QrCodeWidget(content, barLevel=level, barHeight=reqs, barWidth=reqs) d = Drawing(reqs, reqs) d.add(qrw) qr_x = float(o['left']) * mm diff --git a/src/pretix/base/secretgenerators/pretix_sig1.proto b/src/pretix/base/secretgenerators/pretix_sig1.proto new file mode 100644 index 0000000000..1e983e75f1 --- /dev/null +++ b/src/pretix/base/secretgenerators/pretix_sig1.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +option java_package = "eu.pretix.libpretixsync.crypto.sig1"; +option java_outer_classname = "TicketProtos"; + +message Ticket { + string seed = 1; + int64 item = 2; + int64 variation = 3; + int64 subevent = 4; +} diff --git a/src/pretix/base/secretgenerators/pretix_sig1_pb2.py b/src/pretix/base/secretgenerators/pretix_sig1_pb2.py new file mode 100644 index 0000000000..3aaadcaa80 --- /dev/null +++ b/src/pretix/base/secretgenerators/pretix_sig1_pb2.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: pretix_sig1.proto + +from google.protobuf import ( + descriptor as _descriptor, message as _message, reflection as _reflection, + symbol_database as _symbol_database, +) + +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor.FileDescriptor( + name='pretix_sig1.proto', + package='', + syntax='proto3', + serialized_options=b'\n\026eu.pretix.secrets.sig1B\014TicketProtos', + create_key=_descriptor._internal_create_key, + serialized_pb=b'\n\x11pretix_sig1.proto\"I\n\x06Ticket\x12\x0c\n\x04seed\x18\x01 \x01(\t\x12\x0c\n\x04item\x18\x02 \x01(\x03\x12\x11\n\tvariation\x18\x03 \x01(\x03\x12\x10\n\x08subevent\x18\x04 \x01(\x03\x42&\n\x16\x65u.pretix.secrets.sig1B\x0cTicketProtosb\x06proto3' +) + + + + +_TICKET = _descriptor.Descriptor( + name='Ticket', + full_name='Ticket', + filename=None, + file=DESCRIPTOR, + containing_type=None, + create_key=_descriptor._internal_create_key, + fields=[ + _descriptor.FieldDescriptor( + name='seed', full_name='Ticket.seed', index=0, + number=1, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=b"".decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='item', full_name='Ticket.item', index=1, + number=2, type=3, cpp_type=2, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='variation', full_name='Ticket.variation', index=2, + number=3, type=3, cpp_type=2, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='subevent', full_name='Ticket.subevent', index=3, + number=4, type=3, cpp_type=2, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=21, + serialized_end=94, +) + +DESCRIPTOR.message_types_by_name['Ticket'] = _TICKET +_sym_db.RegisterFileDescriptor(DESCRIPTOR) + +Ticket = _reflection.GeneratedProtocolMessageType('Ticket', (_message.Message,), { + 'DESCRIPTOR' : _TICKET, + '__module__' : 'pretix_sig1_pb2' + # @@protoc_insertion_point(class_scope:Ticket) + }) +_sym_db.RegisterMessage(Ticket) + + +DESCRIPTOR._options = None +# @@protoc_insertion_point(module_scope) diff --git a/src/pretix/base/secrets.py b/src/pretix/base/secrets.py new file mode 100644 index 0000000000..b4846cabbe --- /dev/null +++ b/src/pretix/base/secrets.py @@ -0,0 +1,202 @@ +import base64 +import struct + +from cryptography.hazmat.backends.openssl.backend import Backend +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.serialization.base import ( + Encoding, NoEncryption, PrivateFormat, PublicFormat, load_pem_private_key, + load_pem_public_key, +) +from django.conf import settings +from django.dispatch import receiver +from django.utils.crypto import get_random_string +from django.utils.translation import ugettext_lazy as _ + +from pretix.base.models import Item, ItemVariation, SubEvent +from pretix.base.secretgenerators import pretix_sig1_pb2 +from pretix.base.signals import register_ticket_secret_generators + + +class BaseTicketSecretGenerator: + """ + This is the base class to be used for all ticket secret generators. + """ + + @property + def verbose_name(self) -> str: + """ + A human-readable name for this generator. This should be short but self-explanatory. + """ + raise NotImplementedError() # NOQA + + @property + def identifier(self) -> str: + """ + A short and unique identifier for this renderer. This should only contain lowercase letters + and in most cases will be the same as your package name. + """ + raise NotImplementedError() # NOQA + + def __init__(self, event): + self.event = event + + @property + def use_revocation_list(self): + """ + If this attribute is set to ``True``, the system will set all no-longer-used secrets on a revocation list. + This is not required for pretix' default method of just using random identifiers as ticket secrets + since all ticket scans will be compared to the database. However, if your secret generation method + is designed to allow offline verification without a ticket database, all invalidated/replaced + secrets as well as all secrets of canceled tickets will need to go to a revocation list. + """ + return False + + def generate_secret(self, item: Item, variation: ItemVariation = None, subevent: SubEvent = None, + current_secret: str = None, force_invalidate=False) -> str: + """ + Generate a new secret for a ticket with product ``item``, variation ``variation``, subevent ``subevent``, + and the current secret ``current_secret`` (if any). + + The result must be a string that should only contain the characters ``A-Za-z0-9+/=``. + + The algorithm is expected to conform to the following rules: + + If ``force_invalidate`` is set to ``True``, the method MUST return a different secret than ``current_secret``, + such that ``current_secret`` can get revoked. + + If ``force_invalidate`` is set to ``False`` and ``item``, ``variation`` and ``subevent`` have the same value + as when ``current_secret`` was generated, then this method MUST return ``current_secret`` unchanged. + + If ``force_invalidate`` is set to ``False`` and ``item``, ``variation`` and ``subevent`` have a different value + as when ``current_secret`` was generated, then this method MAY OR MAY NOT return ``current_secret`` unchanged, + depending on the semantics of the method. + """ + raise NotImplementedError() + + +class RandomTicketSecretGenerator(BaseTicketSecretGenerator): + verbose_name = _('Random (default, works with all pretix apps)') + identifier = 'random' + use_revocation_list = False + + def generate_secret(self, item: Item, variation: ItemVariation = None, subevent: SubEvent = None, + current_secret: str = None, force_invalidate=False): + if current_secret and not force_invalidate: + return current_secret + return get_random_string( + length=settings.ENTROPY['ticket_secret'], + # Exclude o,0,1,i,l to avoid confusion with bad fonts/printers + allowed_chars='abcdefghjkmnpqrstuvwxyz23456789' + ) + + +class Sig1TicketSecretGenerator(BaseTicketSecretGenerator): + """ + Secret generator for signed QR codes. + + QR-code format: + + - 1 Byte with the version of the scheme, currently 0x01 + - 2 Bytes length of the payload (big-endian) => n + - 2 Bytes length of the signature (big-endian) => m + - n Bytes payload (with protobuf encoding) + - m Bytes ECDSA signature of Sign(payload) + + The resulting string is REVERSED, to avoid all secrets of same length beginning with the same 10 + characters, which would make it impossible to search for secrets manually. + """ + verbose_name = _('pretix signature scheme 1 (for very large events, does not work with pretixSCAN on iOS and ' + 'changes semantics of offline scanning – please refer to documentation or support for details)') + identifier = 'pretix_sig1' + use_revocation_list = True + + def _generate_keys(self): + privkey = Ed25519PrivateKey.generate() + pubkey = privkey.public_key() + self.event.settings.ticket_secrets_pretix_sig1_privkey = base64.b64encode(privkey.private_bytes( + Encoding.PEM, PrivateFormat.PKCS8, NoEncryption() + )).decode() + self.event.settings.ticket_secrets_pretix_sig1_pubkey = base64.b64encode(pubkey.public_bytes( + Encoding.PEM, PublicFormat.SubjectPublicKeyInfo + )).decode() + + def _sign_payload(self, payload): + if not self.event.settings.ticket_secrets_pretix_sig1_privkey: + self._generate_keys() + privkey = load_pem_private_key( + base64.b64decode(self.event.settings.ticket_secrets_pretix_sig1_privkey), None, Backend() + ) + signature = privkey.sign(payload) + return ( + bytes([0x01]) + + struct.pack(">H", len(payload)) + + struct.pack(">H", len(signature)) + + payload + + signature + ) + + def _parse(self, secret): + try: + rawbytes = base64.b64decode(secret[::-1]) + if rawbytes[0] != 1: + raise ValueError('Invalid version') + + payload_len = struct.unpack(">H", rawbytes[1:3])[0] + sig_len = struct.unpack(">H", rawbytes[3:5])[0] + payload = rawbytes[5:5 + payload_len] + signature = rawbytes[5 + payload_len:5 + payload_len + sig_len] + pubkey = load_pem_public_key( + base64.b64decode(self.event.settings.ticket_secrets_pretix_sig1_pubkey), Backend() + ) + pubkey.verify(signature, payload) + t = pretix_sig1_pb2.Ticket() + t.ParseFromString(payload) + return t + except: + return None + + def generate_secret(self, item: Item, variation: ItemVariation = None, subevent: SubEvent = None, + current_secret: str = None, force_invalidate=False): + if current_secret and not force_invalidate: + ticket = self._parse(current_secret) + if ticket: + unchanged = ( + ticket.item == item.pk and + ticket.variation == (variation.pk if variation else 0) and + ticket.subevent == (subevent.pk if subevent else 0) + ) + if unchanged: + return current_secret + + t = pretix_sig1_pb2.Ticket() + t.seed = get_random_string(9) + t.item = item.pk + t.variation = variation.pk if variation else 0 + t.subevent = subevent.pk if subevent else 0 + payload = t.SerializeToString() + result = base64.b64encode(self._sign_payload(payload)).decode()[::-1] + return result + + +@receiver(register_ticket_secret_generators, dispatch_uid="ticket_generator_default") +def recv_classic(sender, **kwargs): + return [RandomTicketSecretGenerator, Sig1TicketSecretGenerator] + + +def assign_ticket_secret(event, position, force_invalidate_if_revokation_list_used=False, force_invalidate=False, save=True): + gen = event.ticket_secret_generator + if gen.use_revocation_list and force_invalidate_if_revokation_list_used: + force_invalidate = True + secret = gen.generate_secret( + item=position.item, + variation=position.variation, + subevent=position.subevent, + current_secret=position.secret, + force_invalidate=force_invalidate + ) + changed = position.secret != secret + if position.secret and changed and gen.use_revocation_list: + position.revoked_secrets.create(event=event, secret=position.secret) + position.secret = secret + if save and changed: + position.save() diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 6cf5f2bf20..39382a8718 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -32,13 +32,13 @@ from pretix.base.models import ( from pretix.base.models.event import SubEvent from pretix.base.models.items import ItemBundle from pretix.base.models.orders import ( - InvoiceAddress, OrderFee, OrderRefund, generate_position_secret, - generate_secret, + InvoiceAddress, OrderFee, OrderRefund, generate_secret, ) from pretix.base.models.organizer import TeamAPIToken from pretix.base.models.tax import TaxRule from pretix.base.payment import BasePaymentProvider, PaymentException from pretix.base.reldate import RelativeDateWrapper +from pretix.base.secrets import assign_ticket_secret from pretix.base.services import tickets from pretix.base.services.invoices import ( generate_cancellation, generate_invoice, invoice_qualified, @@ -371,7 +371,10 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device if position.voucher: Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1)) position.canceled = True - position.save(update_fields=['canceled']) + assign_ticket_secret( + event=order.event, position=position, force_invalidate_if_revokation_list_used=True, force_invalidate=False, save=False + ) + position.save(update_fields=['canceled', 'secret']) new_fee = cancellation_fee for fee in order.fees.all(): if keep_fees and fee in keep_fees: @@ -406,6 +409,9 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device order.save(update_fields=['status', 'cancellation_date']) for position in order.positions.all(): + assign_ticket_secret( + event=order.event, position=position, force_invalidate_if_revokation_list_used=True, force_invalidate=False, save=True + ) if position.voucher: Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1)) @@ -1564,6 +1570,9 @@ class OrderChangeManager: invoice_address=self._invoice_address ).gross ) + assign_ticket_secret( + event=self.event, position=op.position, force_invalidate=False, save=False + ) op.position.save() elif isinstance(op, self.SeatOperation): self.order.log_action('pretix.event.order.changed.seat', user=self.user, auth=self.auth, data={ @@ -1575,6 +1584,9 @@ class OrderChangeManager: 'new_seat_id': op.seat.pk if op.seat else None, }) op.position.seat = op.seat + assign_ticket_secret( + event=self.event, position=op.position, force_invalidate=False, save=False + ) op.position.save() elif isinstance(op, self.SubeventOperation): self.order.log_action('pretix.event.order.changed.subevent', user=self.user, auth=self.auth, data={ @@ -1586,7 +1598,9 @@ class OrderChangeManager: 'new_price': op.position.price }) op.position.subevent = op.subevent - op.position.save() + assign_ticket_secret( + event=self.event, position=op.position, force_invalidate=False, save=False + ) if op.position.price_before_voucher is not None and op.position.voucher and not op.position.addon_to_id: op.position.price_before_voucher = max( op.position.price, @@ -1597,6 +1611,7 @@ class OrderChangeManager: invoice_address=self._invoice_address ).gross ) + op.position.save() elif isinstance(op, self.AddFeeOperation): self.order.log_action('pretix.event.order.changed.addfee', user=self.user, auth=self.auth, data={ 'fee': op.fee.pk, @@ -1675,7 +1690,10 @@ class OrderChangeManager: opa.canceled = True if opa.voucher: Voucher.objects.filter(pk=opa.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1)) - opa.save(update_fields=['canceled']) + assign_ticket_secret( + event=self.event, position=op.position, force_invalidate_if_revokation_list_used=True, force_invalidate=False, save=False + ) + opa.save(update_fields=['canceled', 'secret']) self.order.log_action('pretix.event.order.changed.cancel', user=self.user, auth=self.auth, data={ 'position': op.position.pk, 'positionid': op.position.positionid, @@ -1687,7 +1705,10 @@ class OrderChangeManager: op.position.canceled = True if op.position.voucher: Voucher.objects.filter(pk=op.position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1)) - op.position.save(update_fields=['canceled']) + assign_ticket_secret( + event=self.event, position=op.position, force_invalidate_if_revokation_list_used=True, force_invalidate=False, save=False + ) + op.position.save(update_fields=['canceled', 'secret']) elif isinstance(op, self.AddOperation): pos = OrderPosition.objects.create( item=op.item, variation=op.variation, addon_to=op.addon_to, @@ -1709,8 +1730,9 @@ class OrderChangeManager: elif isinstance(op, self.SplitOperation): split_positions.append(op.position) elif isinstance(op, self.RegenerateSecretOperation): - op.position.secret = generate_position_secret() - op.position.save() + assign_ticket_secret( + event=self.event, position=op.position, force_invalidate=True, save=True + ) tickets.invalidate_cache.apply_async(kwargs={'event': self.event.pk, 'order': self.order.pk}) self.order.log_action('pretix.event.order.changed.secret', user=self.user, auth=self.auth, data={ @@ -1743,7 +1765,9 @@ class OrderChangeManager: 'new_order': split_order.code, }) op.order = split_order - op.secret = generate_position_secret() + assign_ticket_secret( + self.event, position=op, force_invalidate=True, + ) op.save() try: diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index fbbdb647fc..de1a4abe8f 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -374,6 +374,10 @@ DEFAULTS = { 'default': 'classic', 'type': str, }, + 'ticket_secret_generator': { + 'default': 'random', + 'type': str, + }, 'reservation_time': { 'default': '30', 'type': int, diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index 9ae3d2697a..4a1b9fce96 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -216,6 +216,16 @@ subclass of pretix.base.invoice.BaseInvoiceRenderer or a list of these As with all event-plugin signals, the ``sender`` keyword argument will contain the event. """ +register_ticket_secret_generators = EventPluginSignal( + providing_args=[] +) +""" +This signal is sent out to get all known ticket secret generators. Receivers should return a +subclass of ``pretix.base.secrets.BaseTicketSecretGenerator`` or a list of these + +As with all event-plugin signals, the ``sender`` keyword argument will contain the event. +""" + register_data_shredders = EventPluginSignal( providing_args=[] ) diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 6fece0375a..56caa71d78 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -1069,6 +1069,20 @@ class TicketSettingsForm(SettingsForm): 'ticket_download_pending', 'ticket_download_require_validated_email', ] + ticket_secret_generator = forms.ChoiceField( + label=_("Ticket code generator"), + help_text=_("For advanced users, usually does not need to be changed."), + required=True, + widget=forms.RadioSelect, + choices=[] + ) + + def __init__(self, *args, **kwargs): + event = kwargs.get('obj') + super().__init__(*args, **kwargs) + self.fields['ticket_secret_generator'].choices = [ + (r.identifier, r.verbose_name) for r in event.ticket_secret_generators.values() + ] def prepare_fields(self): # See clean() diff --git a/src/pretix/control/templates/pretixcontrol/event/tickets.html b/src/pretix/control/templates/pretixcontrol/event/tickets.html index 9734b4c14a..0045f79143 100644 --- a/src/pretix/control/templates/pretixcontrol/event/tickets.html +++ b/src/pretix/control/templates/pretixcontrol/event/tickets.html @@ -62,6 +62,10 @@ {% trans "Download time" %} {% bootstrap_field form.ticket_download_date layout="control" %} +
+ {% trans "Ticket codes" %} + {% bootstrap_field form.ticket_secret_generator layout="control" %} +