Compare commits

..

7 Commits

Author SHA1 Message Date
Raphael Michel
c9692283df Fix negative prices in bundles when tax rate is 0 2024-10-08 17:13:22 +02:00
Raphael Michel
61b25acdd2 Fix email confirm hash in templates 2024-10-07 17:54:40 +02:00
Raphael Michel
6cc9529d9a Authentication: Support for fallback secret keys in get_session_auth_hash (#4481)
* Authentication: Support for fallback secret keys in get_session_auth_hash

* Update src/pretix/presale/utils.py

Co-authored-by: Richard Schreiber <schreiber@rami.io>

---------

Co-authored-by: Richard Schreiber <schreiber@rami.io>
2024-10-07 16:58:37 +02:00
Raphael Michel
cdc5401dc2 Allow to set fallback secret keys (#4482) 2024-10-07 16:31:24 +02:00
Raphael Michel
1334a570e4 Generate email confirmation secret from tagged_secret (#4480) 2024-10-07 13:58:08 +02:00
Raphael Michel
7a66aea2cb Voucher update: Allow to remove seat 2024-10-07 11:42:28 +02:00
dependabot[bot]
ee77a5e447 Bump @rollup/plugin-node-resolve from 15.2.3 to 15.3.0 in /src/pretix/static/npm_dir (#4499)
Bumps [@rollup/plugin-node-resolve](https://github.com/rollup/plugins/tree/HEAD/packages/node-resolve) from 15.2.3 to 15.3.0.
- [Changelog](https://github.com/rollup/plugins/blob/master/packages/node-resolve/CHANGELOG.md)
- [Commits](https://github.com/rollup/plugins/commits/node-resolve-v15.3.0/packages/node-resolve)

---
updated-dependencies:
- dependency-name: "@rollup/plugin-node-resolve"
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-07 11:36:23 +02:00
18 changed files with 141 additions and 72 deletions

View File

@@ -294,6 +294,10 @@ Example::
setting is not provided, pretix will generate a random secret on the first start
and will store it in the filesystem for later usage.
``secret_fallback0`` ... ``secret_fallback9``
Prior versions of the secret to be used by Django for signing and verification purposes that will still
be accepted but no longer be used for new signing.
``debug``
Whether or not to run in debug mode. Default is ``False``.

View File

@@ -66,7 +66,7 @@ To build and run pretix, you will need the following debian packages::
# apt-get install git build-essential python3-dev python3-venv python3 python3-pip \
libxml2-dev libxslt1-dev libffi-dev zlib1g-dev libssl-dev \
gettext libpq-dev libjpeg-dev libopenjp2-7-dev 2to3
gettext libpq-dev libjpeg-dev libopenjp2-7-dev
Config file
-----------

View File

@@ -571,13 +571,23 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
def get_session_auth_hash(self):
"""
Return an HMAC that needs to
Return an HMAC that needs to be the same throughout the session, used e.g. for forced
logout after every password change.
"""
return self._get_session_auth_hash(secret=settings.SECRET_KEY)
def get_session_auth_fallback_hash(self):
for fallback_secret in settings.SECRET_KEY_FALLBACKS:
yield self._get_session_auth_hash(secret=fallback_secret)
def _get_session_auth_hash(self, secret):
"""
"""
key_salt = "pretix.base.models.User.get_session_auth_hash"
payload = self.password
payload += self.email
payload += self.session_token
return salted_hmac(key_salt, payload).hexdigest()
return salted_hmac(key_salt, payload, secret=secret).hexdigest()
def update_session_token(self):
self.session_token = generate_session_token()

View File

@@ -219,13 +219,24 @@ class Customer(LoggedModel):
return is_password_usable(self.password)
def get_session_auth_hash(self):
"""
Return an HMAC that needs to be the same throughout the session, used e.g. for forced
logout after every password change.
"""
return self._get_session_auth_hash(secret=settings.SECRET_KEY)
def get_session_auth_fallback_hash(self):
for fallback_secret in settings.SECRET_KEY_FALLBACKS:
yield self._get_session_auth_hash(secret=fallback_secret)
def _get_session_auth_hash(self, secret):
"""
Return an HMAC of the password field.
"""
key_salt = "pretix.base.models.customers.Customer.get_session_auth_hash"
payload = self.password
payload += self.email
return salted_hmac(key_salt, payload).hexdigest()
return salted_hmac(key_salt, payload, secret=secret).hexdigest()
def get_email_context(self):
from pretix.base.settings import get_name_parts_localized

View File

@@ -40,6 +40,7 @@ import json
import logging
import operator
import string
import warnings
from collections import Counter
from datetime import datetime, time, timedelta
from decimal import Decimal
@@ -381,8 +382,28 @@ class Order(LockModel, LoggedModel):
self.event.cache.delete('complain_testmode_orders')
self.delete()
def email_confirm_secret(self):
return self.tagged_secret("email_confirm", 9)
def email_confirm_hash(self):
return hashlib.sha256(settings.SECRET_KEY.encode() + self.secret.encode()).hexdigest()[:9]
warnings.warn('Use email_confirm_secret() instead of email_confirm_hash().',
DeprecationWarning)
return self.email_confirm_secret()
def check_email_confirm_secret(self, received_secret):
return (
hmac.compare_digest(
self.tagged_secret("email_confirm", 9),
received_secret[:9].lower()
) or any(
# TODO: remove this clause after a while (compatibility with old secrets currently in flight)
hmac.compare_digest(
hashlib.sha256(sk.encode() + self.secret.encode()).hexdigest()[:9],
received_secret
)
for sk in [settings.SECRET_KEY, *settings.SECRET_KEY_FALLBACKS]
)
)
def get_extended_status_display(self):
# Changes in this method should to be replicated in pretixcontrol/orders/fragment_order_status.html

View File

@@ -306,8 +306,11 @@ class TaxRule(LoggedModel):
if rate == Decimal('0.00'):
return TaxedPrice(
net=base_price - subtract_from_gross, gross=base_price - subtract_from_gross, tax=Decimal('0.00'),
rate=rate, name=self.name
net=max(Decimal('0.00'), base_price - subtract_from_gross),
gross=max(Decimal('0.00'), base_price - subtract_from_gross),
tax=Decimal('0.00'),
rate=rate,
name=self.name,
)
if base_price_is == 'auto':

View File

@@ -301,7 +301,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
order.event, 'presale:event.order.open', kwargs={
'order': order.code,
'secret': order.secret,
'hash': order.email_confirm_hash()
'hash': order.email_confirm_secret()
}
)
)

View File

@@ -262,7 +262,7 @@ def base_placeholders(sender, **kwargs):
'presale:event.order.open', kwargs={
'order': order.code,
'secret': order.secret,
'hash': order.email_confirm_hash()
'hash': order.email_confirm_secret()
}
), lambda event: build_absolute_uri(
event,
@@ -443,7 +443,7 @@ def base_placeholders(sender, **kwargs):
'organizer': event.organizer.slug,
'order': order.code,
'secret': order.secret,
'hash': order.email_confirm_hash(),
'hash': order.email_confirm_secret(),
}),
)
for order in orders

View File

@@ -143,7 +143,7 @@
</tr>
</table>
<div class="order-button">
<a href="{% abseventurl event "presale:event.order.open" hash=order.email_confirm_hash order=order.code secret=order.secret %}" class="button">
<a href="{% abseventurl event "presale:event.order.open" hash=order.email_confirm_secret order=order.code secret=order.secret %}" class="button">
{% trans "View order details" %}
</a>
</div>

View File

@@ -239,11 +239,14 @@ class VoucherForm(I18nModelForm):
self.instance.event, self.instance.quota, self.instance.item, self.instance.variation
)
Voucher.clean_voucher_code(data, self.instance.event, self.instance.pk)
if 'seat' in self.fields and data.get('seat'):
self.instance.seat = Voucher.clean_seat_id(
data, self.instance.item, self.instance.quota, self.instance.event, self.instance.pk
)
self.instance.item = self.instance.seat.product
if 'seat' in self.fields:
if data.get('seat'):
self.instance.seat = Voucher.clean_seat_id(
data, self.instance.item, self.instance.quota, self.instance.event, self.instance.pk
)
self.instance.item = self.instance.seat.product
else:
self.instance.seat = None
voucher_form_validation.send(sender=self.instance.event, form=self, data=data)

View File

@@ -100,10 +100,23 @@ def get_customer(request):
request._cached_customer = None
else:
session_hash = session.get(hash_session_key)
session_auth_hash = customer.get_session_auth_hash()
session_hash_verified = session_hash and constant_time_compare(
session_hash,
customer.get_session_auth_hash()
session_auth_hash,
)
if not session_hash_verified:
# If the current secret does not verify the session, try
# with the fallback secrets and stop when a matching one is
# found.
if session_hash and any(
constant_time_compare(session_hash, fallback_auth_hash)
for fallback_auth_hash in customer.get_session_auth_fallback_hash()
):
request.session.cycle_key()
request.session[hash_session_key] = session_auth_hash
session_hash_verified = True
if session_hash_verified:
request._cached_customer = customer
else:

View File

@@ -156,11 +156,10 @@ class OrderOpen(EventViewMixin, OrderDetailMixin, View):
def get(self, request, *args, **kwargs):
if not self.order:
raise Http404(_('Unknown order code or not authorized to access this order.'))
if kwargs.get('hash') == self.order.email_confirm_hash():
if not self.order.email_known_to_work:
self.order.log_action('pretix.event.order.contact.confirmed')
self.order.email_known_to_work = True
self.order.save(update_fields=['email_known_to_work'])
if self.order.check_email_confirm_secret(kwargs.get('hash')) and not self.order.email_known_to_work:
self.order.log_action('pretix.event.order.contact.confirmed')
self.order.email_known_to_work = True
self.order.save(update_fields=['email_known_to_work'])
return redirect(self.get_order_url())

View File

@@ -94,6 +94,13 @@ else:
pass # os.chown is not available on Windows
f.write(SECRET_KEY)
SECRET_KEY_FALLBACKS = []
for i in range(10):
if config.has_option('django', f'secret_fallback{i}'):
SECRET_KEY_FALLBACKS.append(config.get('django', f'secret_fallback{i}'))
# Adjustable settings
debug_fallback = "runserver" in sys.argv or "runserver_plus" in sys.argv

View File

@@ -11,7 +11,7 @@
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@rollup/plugin-babel": "^6.0.4",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-node-resolve": "^15.3.0",
"rollup": "^2.79.1",
"rollup-plugin-vue": "^5.0.1",
"vue": "^2.7.16",
@@ -1734,14 +1734,13 @@
}
},
"node_modules/@rollup/plugin-node-resolve": {
"version": "15.2.3",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.2.3.tgz",
"integrity": "sha512-j/lym8nf5E21LwBT4Df1VD6hRO2L2iwUeUmP7litikRsVp1H6NWx20NEp0Y7su+7XGc476GnXXc4kFeZNGmaSQ==",
"version": "15.3.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.0.tgz",
"integrity": "sha512-9eO5McEICxMzJpDW9OnMYSv4Sta3hmt7VtBFz5zR9273suNOydOyq/FrGeGy+KsTRFm8w0SLVhzig2ILFT63Ag==",
"dependencies": {
"@rollup/pluginutils": "^5.0.1",
"@types/resolve": "1.20.2",
"deepmerge": "^4.2.2",
"is-builtin-module": "^3.2.1",
"is-module": "^1.0.0",
"resolve": "^1.22.1"
},
@@ -2102,17 +2101,6 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/builtin-modules": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz",
"integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==",
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/call-bind": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
@@ -2779,20 +2767,6 @@
"node": ">=8"
}
},
"node_modules/is-builtin-module": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz",
"integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==",
"dependencies": {
"builtin-modules": "^3.3.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-core-module": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz",
@@ -5162,14 +5136,13 @@
}
},
"@rollup/plugin-node-resolve": {
"version": "15.2.3",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.2.3.tgz",
"integrity": "sha512-j/lym8nf5E21LwBT4Df1VD6hRO2L2iwUeUmP7litikRsVp1H6NWx20NEp0Y7su+7XGc476GnXXc4kFeZNGmaSQ==",
"version": "15.3.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.0.tgz",
"integrity": "sha512-9eO5McEICxMzJpDW9OnMYSv4Sta3hmt7VtBFz5zR9273suNOydOyq/FrGeGy+KsTRFm8w0SLVhzig2ILFT63Ag==",
"requires": {
"@rollup/pluginutils": "^5.0.1",
"@types/resolve": "1.20.2",
"deepmerge": "^4.2.2",
"is-builtin-module": "^3.2.1",
"is-module": "^1.0.0",
"resolve": "^1.22.1"
}
@@ -5417,11 +5390,6 @@
"update-browserslist-db": "^1.1.0"
}
},
"builtin-modules": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz",
"integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw=="
},
"call-bind": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
@@ -5920,14 +5888,6 @@
"binary-extensions": "^2.0.0"
}
},
"is-builtin-module": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz",
"integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==",
"requires": {
"builtin-modules": "^3.3.0"
}
},
"is-core-module": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz",

View File

@@ -7,7 +7,7 @@
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@rollup/plugin-babel": "^6.0.4",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-node-resolve": "^15.3.0",
"vue": "^2.7.16",
"rollup": "^2.79.1",
"rollup-plugin-vue": "^5.0.1",

View File

@@ -3654,6 +3654,31 @@ class CartBundleTest(CartTestMixin, TestCase):
assert cp.price == Decimal('0.00')
assert b.price == Decimal('1.50')
@classscope(attr='orga')
def test_voucher_apply_multiple_reduce_beyond_designated_price_no_tax_rules(self):
self.ticket.tax_rule = None
self.ticket.save()
self.trans.tax_rule = None
self.trans.save()
cp = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=21.5, expires=now() + timedelta(minutes=10)
)
b = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.trans, addon_to=cp,
price=1.5, expires=now() + timedelta(minutes=10), is_bundled=True
)
v = Voucher.objects.create(
event=self.event, price_mode='set', value=Decimal('0.00'), max_usages=100
)
self.cm.apply_voucher(v.code)
self.cm.commit()
cp.refresh_from_db()
b.refresh_from_db()
assert cp.price == Decimal('0.00')
assert b.price == Decimal('1.50')
@classscope(attr='orga')
def test_voucher_apply_affect_bundled(self):
cp = CartPosition.objects.create(

View File

@@ -628,7 +628,7 @@ def test_change_email(env, client):
@pytest.mark.django_db
def test_change_pw(env, client):
def test_change_pw(env, client, client2):
with scopes_disabled():
customer = env[0].customers.create(email='john@example.org', is_verified=True)
customer.set_password('foo')
@@ -640,6 +640,12 @@ def test_change_pw(env, client):
})
assert r.status_code == 302
r = client2.post('/bigevents/account/login', {
'email': 'john@example.org',
'password': 'foo',
})
assert r.status_code == 302
r = client.post('/bigevents/account/password', {
'password_current': 'invalid',
'password': 'aYLBRNg4',
@@ -658,6 +664,13 @@ def test_change_pw(env, client):
customer.refresh_from_db()
assert customer.check_password('aYLBRNg4')
r = client.get('/bigevents/account/password')
assert r.status_code == 200
# Client 2 got logged out
r = client2.post('/bigevents/account/password')
assert r.status_code == 302
@pytest.mark.django_db
def test_login_per_org(env, client):

View File

@@ -221,7 +221,7 @@ class OrdersTest(BaseOrdersTest):
assert not self.order.email_known_to_work
response = self.client.get(
'/%s/%s/order/%s/%s/open/%s/' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret, self.order.email_confirm_hash())
'/%s/%s/order/%s/%s/open/%s/' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret, self.order.email_confirm_secret())
)
assert response.status_code == 302
self.order.refresh_from_db()