Add pluggable ticket secret generators (#1809)

This commit is contained in:
Raphael Michel
2020-10-19 15:00:55 +02:00
committed by GitHub
parent 6e20f33ef5
commit 22bba28bea
43 changed files with 890 additions and 69 deletions

View File

@@ -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'),
)

View File

@@ -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)

View File

@@ -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')

View File

@@ -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)

View File

@@ -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

View File

@@ -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'
)

View File

@@ -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)

View File

@@ -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',

View File

@@ -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),
),
]

View File

@@ -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',

View File

@@ -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')),
],
),
]

View File

@@ -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),
),
]

View File

@@ -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.

View File

@@ -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:

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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)

202
src/pretix/base/secrets.py Normal file
View File

@@ -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()

View File

@@ -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:

View File

@@ -374,6 +374,10 @@ DEFAULTS = {
'default': 'classic',
'type': str,
},
'ticket_secret_generator': {
'default': 'random',
'type': str,
},
'reservation_time': {
'default': '30',
'type': int,

View File

@@ -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=[]
)

View File

@@ -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()

View File

@@ -62,6 +62,10 @@
<legend>{% trans "Download time" %}</legend>
{% bootstrap_field form.ticket_download_date layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Ticket codes" %}</legend>
{% bootstrap_field form.ticket_secret_generator layout="control" %}
</fieldset>
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">

View File

@@ -40,13 +40,14 @@ from pretix.base.i18n import language
from pretix.base.models import (
CachedCombinedTicket, CachedFile, CachedTicket, Checkin, Invoice,
InvoiceAddress, Item, ItemVariation, LogEntry, Order, QuestionAnswer,
Quota, generate_position_secret, generate_secret,
Quota, generate_secret,
)
from pretix.base.models.orders import (
CancellationRequest, OrderFee, OrderPayment, OrderPosition, OrderRefund,
)
from pretix.base.models.tax import EU_COUNTRIES, cc_to_vat_prefix
from pretix.base.payment import PaymentException
from pretix.base.secrets import assign_ticket_secret
from pretix.base.services import tickets
from pretix.base.services.cancelevent import cancel_event
from pretix.base.services.export import export
@@ -1644,8 +1645,9 @@ class OrderContactChange(OrderView):
changed = True
self.order.secret = generate_secret()
for op in self.order.all_positions.all():
op.secret = generate_position_secret()
op.save()
assign_ticket_secret(
self.request.event, position=op, force_invalidate=True, save=True
)
tickets.invalidate_cache.apply_async(kwargs={'event': self.request.event.pk, 'order': self.order.pk})
self.order.log_action('pretix.event.order.secret.changed', user=self.request.user)

View File

@@ -732,7 +732,7 @@ $(function () {
$("button[data-toggle=qrcode]").click(function (e) {
e.preventDefault();
var $current = $(".qr-code-overlay[data-qrcode=" + $(this).attr("data-qrcode") + "]");
var $current = $(".qr-code-overlay[data-qrcode='" + $(this).attr("data-qrcode") + "']");
if ($current.length) {
$(".qr-code-overlay").attr("data-qrcode", "").slideUp(200);
return false;

View File

@@ -68,3 +68,5 @@ arabic-reshaper==2.0.15 # Support for Aabic in reportlab
packaging
tlds>=2020041600
text-unidecode==1.*
protobuf==3.13.*
cryptography

View File

@@ -1,7 +1,7 @@
[flake8]
ignore = N802,W503,E402,C901,E722,W504,E252,N812,N806
max-line-length = 160
exclude = migrations,.ropeproject,static,mt940.py,_static,build,make_testdata.py,*/testutils/settings.py,tests/settings.py,pretix/base/models/__init__.py
exclude = migrations,.ropeproject,static,mt940.py,_static,build,make_testdata.py,*/testutils/settings.py,tests/settings.py,pretix/base/models/__init__.py,pretix/base/secretgenerators/pretix_sig1_pb2.py
max-complexity = 11
[isort]

View File

@@ -154,7 +154,9 @@ setup(
'arabic-reshaper==2.0.15', # Support for Arabic in reportlab
'packaging',
'tlds>=2020041600',
'text-unidecode==1.*'
'text-unidecode==1.*',
'protobuf==3.13.*',
'cryptography',
],
extras_require={
'dev': [

View File

@@ -1,3 +1,4 @@
import copy
from datetime import datetime
from decimal import Decimal
from unittest import mock
@@ -153,8 +154,10 @@ def test_event_list_filter(token_client, organizer, event):
@pytest.mark.django_db
def test_event_get(token_client, organizer, event):
resp = token_client.get('/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug))
res = copy.copy(TEST_EVENT_RES)
res["valid_keys"] = {"pretix_sig1": []}
assert resp.status_code == 200
assert TEST_EVENT_RES == resp.data
assert res == resp.data
@pytest.mark.django_db
@@ -646,15 +649,6 @@ def test_event_update_plugins(token_client, organizer, event, free_item, free_qu
assert resp.content.decode() == '{"plugins":["Unknown plugin: \'pretix.plugins.test\'."]}'
@pytest.mark.django_db
def test_event_detail(token_client, organizer, event, team):
team.all_events = True
team.save()
resp = token_client.get('/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug))
assert resp.status_code == 200
assert TEST_EVENT_RES == resp.data
@pytest.mark.django_db
def test_event_delete(token_client, organizer, event):
resp = token_client.delete('/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug))

View File

@@ -4552,3 +4552,18 @@ def test_orderposition_price_calculation_reverse_charge(token_client, organizer,
'tax_rule': taxrule.pk,
'tax': Decimal('0.00')
}
@pytest.mark.django_db
def test_revoked_secret_list(token_client, organizer, event):
r = event.revoked_secrets.create(secret="abcd")
res = {
"id": r.id,
"secret": "abcd",
"created": r.created.isoformat().replace("+00:00", "Z")
}
resp = token_client.get('/api/v1/organizers/{}/events/{}/revokedsecrets/'.format(
organizer.slug, event.slug,
))
assert resp.status_code == 200
assert [res] == resp.data['results']

View File

@@ -25,6 +25,8 @@ event_urls = [
event_permission_sub_urls = [
('get', 'can_change_event_settings', 'settings/', 200),
('patch', 'can_change_event_settings', 'settings/', 200),
('get', 'can_view_orders', 'revokedsecrets/', 200),
('get', 'can_view_orders', 'revokedsecrets/1/', 404),
('get', 'can_view_orders', 'orders/', 200),
('get', 'can_view_orders', 'orderpositions/', 200),
('delete', 'can_change_orders', 'orderpositions/1/', 404),

View File

@@ -675,6 +675,24 @@ class OrderCancelTests(TestCase):
assert self.order.invoices.count() == 3
assert not self.order.invoices.last().is_cancellation
@classscope(attr='o')
def test_cancel_paid_with_fee_change_secret(self):
self.event.settings.ticket_secret_generator = "pretix_sig1"
s = self.op1.secret
self.order.fees.create(fee_type=OrderFee.FEE_TYPE_SHIPPING, value=2.5)
self.order.status = Order.STATUS_PAID
self.order.total = 48.5
self.order.save()
self.order.payments.create(state=OrderPayment.PAYMENT_STATE_CONFIRMED, amount=48.5)
self.op1.voucher = self.event.vouchers.create(item=self.ticket, redeemed=1)
self.op1.save()
cancel_order(self.order.pk, cancellation_fee=2.5)
self.order.refresh_from_db()
assert self.order.status == Order.STATUS_PAID
self.op1.refresh_from_db()
assert self.op1.canceled
assert self.op1.secret != s
@classscope(attr='o')
def test_auto_refund_possible(self):
p1 = self.order.payments.create(
@@ -857,6 +875,7 @@ class OrderChangeManagerTests(TestCase):
def test_change_subevent_success(self):
self.event.has_subevents = True
self.event.save()
s = self.op1.secret
se1 = self.event.subevents.create(name="Foo", date_from=now())
se2 = self.event.subevents.create(name="Bar", date_from=now())
SubEventItem.objects.create(subevent=se2, item=self.ticket, price=12)
@@ -872,6 +891,30 @@ class OrderChangeManagerTests(TestCase):
assert self.op1.subevent == se2
assert self.op1.price == Decimal('23.00')
assert self.order.total == self.op1.price + self.op2.price
assert self.op1.secret == s
@classscope(attr='o')
def test_change_subevent_success_change_secret(self):
self.event.settings.ticket_secret_generator = "pretix_sig1"
self.event.has_subevents = True
self.event.save()
se1 = self.event.subevents.create(name="Foo", date_from=now())
se2 = self.event.subevents.create(name="Bar", date_from=now())
SubEventItem.objects.create(subevent=se2, item=self.ticket, price=12)
s = self.op1.secret
self.op1.subevent = se1
self.op1.save()
self.quota.subevent = se2
self.quota.save()
self.ocm.change_subevent(self.op1, se2)
self.ocm.commit()
self.op1.refresh_from_db()
self.order.refresh_from_db()
assert self.op1.subevent == se2
assert self.op1.price == Decimal('23.00')
assert self.order.total == self.op1.price + self.op2.price
assert self.op1.secret != s
@classscope(attr='o')
def test_change_subevent_with_price_success(self):
@@ -919,7 +962,9 @@ class OrderChangeManagerTests(TestCase):
self.ocm.change_item(self.op1, self.shirt, None)
@classscope(attr='o')
def test_change_item_keep_price(self):
def test_change_new_secret_by_scheme(self):
self.event.settings.ticket_secret_generator = "pretix_sig1"
s = self.op1.secret
p = self.op1.price
self.ocm.change_item(self.op1, self.shirt, None)
self.ocm.commit()
@@ -929,6 +974,21 @@ class OrderChangeManagerTests(TestCase):
assert self.op1.price == p
assert self.op1.tax_value == Decimal('3.67')
assert self.op1.tax_rule == self.shirt.tax_rule
assert self.op1.secret != s
@classscope(attr='o')
def test_change_item_keep_price(self):
p = self.op1.price
s = self.op1.secret
self.ocm.change_item(self.op1, self.shirt, None)
self.ocm.commit()
self.op1.refresh_from_db()
self.order.refresh_from_db()
assert self.op1.item == self.shirt
assert self.op1.price == p
assert self.op1.tax_value == Decimal('3.67')
assert self.op1.tax_rule == self.shirt.tax_rule
assert self.op1.secret == s
@classscope(attr='o')
def test_change_item_change_price_before_voucher(self):
@@ -1011,6 +1071,7 @@ class OrderChangeManagerTests(TestCase):
@classscope(attr='o')
def test_cancel_success(self):
s = self.op1.secret
self.ocm.cancel(self.op1)
self.ocm.commit()
self.order.refresh_from_db()
@@ -1018,6 +1079,20 @@ class OrderChangeManagerTests(TestCase):
assert self.order.total == self.op2.price
self.op1.refresh_from_db()
assert self.op1.canceled
assert self.op1.secret == s
@classscope(attr='o')
def test_cancel_success_changed_secret(self):
self.event.settings.ticket_secret_generator = "pretix_sig1"
s = self.op1.secret
self.ocm.cancel(self.op1)
self.ocm.commit()
self.order.refresh_from_db()
assert self.order.positions.count() == 1
assert self.order.total == self.op2.price
self.op1.refresh_from_db()
assert self.op1.canceled
assert self.op1.secret != s
@classscope(attr='o')
def test_cancel_with_addon(self):
@@ -2278,6 +2353,9 @@ class OrderChangeManagerTests(TestCase):
@classscope(attr='o')
def test_clear_out_order(self):
self.event.settings.ticket_secret_generator = "pretix_sig1"
op = self.order.positions.first()
s = op.secret
self.order.status = Order.STATUS_PAID
self.order.save()
self.order.payments.create(amount=self.order.total, state=OrderPayment.PAYMENT_STATE_CONFIRMED,
@@ -2290,6 +2368,27 @@ class OrderChangeManagerTests(TestCase):
self.order.refresh_from_db()
assert self.order.total == Decimal('0.00')
assert self.order.status == Order.STATUS_CANCELED
assert op.secret == s
@classscope(attr='o')
def test_clear_out_order_change_secrets(self):
self.event.settings.ticket_secret_generator = "pretix_sig1"
op = self.order.positions.first()
s = op.secret
self.order.status = Order.STATUS_PAID
self.order.save()
self.order.payments.create(amount=self.order.total, state=OrderPayment.PAYMENT_STATE_CONFIRMED,
provider='manual')
cancel_order(self.order, cancellation_fee=Decimal('5.00'))
self.order.refresh_from_db()
assert self.order.total == Decimal('5.00')
self.ocm.cancel_fee(self.order.fees.get())
self.ocm.commit()
self.order.refresh_from_db()
assert self.order.total == Decimal('0.00')
assert self.order.status == Order.STATUS_CANCELED
op.refresh_from_db()
assert op.secret != s
@classscope(attr='o')
def test_auto_change_payment_fee(self):