mirror of
https://github.com/pretix/pretix.git
synced 2026-08-12 11:07:02 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f66f97bee1 | ||
|
|
998a62add2 | ||
|
|
614408f5ae | ||
|
|
1383e967df | ||
|
|
c743e9fd3f | ||
|
|
a71efa6747 | ||
|
|
4fed47fb9b | ||
|
|
c143d50290 | ||
|
|
88cd715ece | ||
|
|
3513de6a45 | ||
|
|
fd6d3934c0 |
@@ -24,7 +24,7 @@ jobs:
|
|||||||
name: Packaging
|
name: Packaging
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.11"]
|
python-version: ["3.13"]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ jobs:
|
|||||||
name: Check gettext syntax
|
name: Check gettext syntax
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Python 3.11
|
- name: Set up Python 3.13
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: 3.11
|
python-version: 3.13
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pip
|
path: ~/.cache/pip
|
||||||
@@ -49,10 +49,10 @@ jobs:
|
|||||||
name: Spellcheck
|
name: Spellcheck
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Python 3.11
|
- name: Set up Python 3.13
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: 3.11
|
python-version: 3.13
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pip
|
path: ~/.cache/pip
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Python 3.11
|
- name: Set up Python 3.13
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: 3.11
|
python-version: 3.13
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pip
|
path: ~/.cache/pip
|
||||||
@@ -44,10 +44,10 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Python 3.11
|
- name: Set up Python 3.13
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: 3.11
|
python-version: 3.13
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pip
|
path: ~/.cache/pip
|
||||||
@@ -64,10 +64,10 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Python 3.11
|
- name: Set up Python 3.13
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: 3.11
|
python-version: 3.13
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: pip3 install licenseheaders
|
run: pip3 install licenseheaders
|
||||||
- name: Run licenseheaders
|
- name: Run licenseheaders
|
||||||
|
|||||||
@@ -23,13 +23,15 @@ jobs:
|
|||||||
name: Tests
|
name: Tests
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.10", "3.11", "3.13"]
|
python-version: ["3.11", "3.13", "3.14"]
|
||||||
database: [sqlite, postgres]
|
database: [sqlite, postgres]
|
||||||
exclude:
|
exclude:
|
||||||
- database: sqlite
|
- database: sqlite
|
||||||
python-version: "3.10"
|
python-version: "3.10"
|
||||||
- database: sqlite
|
- database: sqlite
|
||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
|
- database: sqlite
|
||||||
|
python-version: "3.12"
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15
|
image: postgres:15
|
||||||
@@ -81,4 +83,4 @@ jobs:
|
|||||||
file: src/coverage.xml
|
file: src/coverage.xml
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
fail_ci_if_error: false
|
fail_ci_if_error: false
|
||||||
if: matrix.database == 'postgres' && matrix.python-version == '3.11'
|
if: matrix.database == 'postgres' && matrix.python-version == '3.13'
|
||||||
|
|||||||
+8
-7
@@ -3,7 +3,7 @@ name = "pretix"
|
|||||||
dynamic = ["version"]
|
dynamic = ["version"]
|
||||||
description = "Reinventing presales, one ticket at a time"
|
description = "Reinventing presales, one ticket at a time"
|
||||||
readme = "README.rst"
|
readme = "README.rst"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.11"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
keywords = ["tickets", "web", "shop", "ecommerce"]
|
keywords = ["tickets", "web", "shop", "ecommerce"]
|
||||||
authors = [
|
authors = [
|
||||||
@@ -19,10 +19,11 @@ classifiers = [
|
|||||||
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
||||||
"Environment :: Web Environment",
|
"Environment :: Web Environment",
|
||||||
"License :: OSI Approved :: GNU Affero General Public License v3",
|
"License :: OSI Approved :: GNU Affero General Public License v3",
|
||||||
"Programming Language :: Python :: 3.9",
|
|
||||||
"Programming Language :: Python :: 3.10",
|
|
||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
"Framework :: Django :: 4.2",
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Programming Language :: Python :: 3.14",
|
||||||
|
"Framework :: Django :: 5.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
@@ -36,7 +37,7 @@ dependencies = [
|
|||||||
"css-inline==0.20.*",
|
"css-inline==0.20.*",
|
||||||
"defusedcsv>=1.1.0",
|
"defusedcsv>=1.1.0",
|
||||||
"dnspython==2.*",
|
"dnspython==2.*",
|
||||||
"Django[argon2]==4.2.*,>=4.2.26",
|
"Django[argon2]==5.2.*",
|
||||||
"django-bootstrap3==26.1",
|
"django-bootstrap3==26.1",
|
||||||
"django-compressor==4.6.0",
|
"django-compressor==4.6.0",
|
||||||
"django-countries==8.2.*",
|
"django-countries==8.2.*",
|
||||||
@@ -59,7 +60,7 @@ dependencies = [
|
|||||||
"dnspython==2.8.*",
|
"dnspython==2.8.*",
|
||||||
"drf_ujson2==1.7.*",
|
"drf_ujson2==1.7.*",
|
||||||
"geoip2==5.*",
|
"geoip2==5.*",
|
||||||
"importlib_metadata==8.*", # Polyfill, we can probably drop this once we require Python 3.10+
|
"importlib_metadata==9.*", # Polyfill, we can probably drop this once we require Python 3.10+
|
||||||
"isoweek",
|
"isoweek",
|
||||||
"jsonschema",
|
"jsonschema",
|
||||||
"kombu==5.6.*",
|
"kombu==5.6.*",
|
||||||
@@ -92,7 +93,7 @@ dependencies = [
|
|||||||
"redis==7.1.*",
|
"redis==7.1.*",
|
||||||
"reportlab==4.4.*",
|
"reportlab==4.4.*",
|
||||||
"requests==2.32.*",
|
"requests==2.32.*",
|
||||||
"sentry-sdk==2.54.*",
|
"sentry-sdk==2.56.*",
|
||||||
"sepaxml==2.7.*",
|
"sepaxml==2.7.*",
|
||||||
"stripe==7.9.*",
|
"stripe==7.9.*",
|
||||||
"text-unidecode==1.*",
|
"text-unidecode==1.*",
|
||||||
|
|||||||
@@ -19,7 +19,10 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
# <https://www.gnu.org/licenses/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
|
import smtplib
|
||||||
|
import socket
|
||||||
from itertools import groupby
|
from itertools import groupby
|
||||||
from smtplib import SMTPResponseException
|
from smtplib import SMTPResponseException
|
||||||
from typing import TypeVar
|
from typing import TypeVar
|
||||||
@@ -237,3 +240,80 @@ def base_renderers(sender, **kwargs):
|
|||||||
|
|
||||||
def get_email_context(**kwargs):
|
def get_email_context(**kwargs):
|
||||||
return PlaceholderContext(**kwargs).render_all()
|
return PlaceholderContext(**kwargs).render_all()
|
||||||
|
|
||||||
|
|
||||||
|
def create_connection(address, timeout=socket.getdefaulttimeout(),
|
||||||
|
source_address=None, *, all_errors=False):
|
||||||
|
# Taken from the python stdlib, extended with a check for local ips
|
||||||
|
|
||||||
|
host, port = address
|
||||||
|
exceptions = []
|
||||||
|
for res in socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM):
|
||||||
|
af, socktype, proto, canonname, sa = res
|
||||||
|
|
||||||
|
if not settings.get("MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS", False):
|
||||||
|
ip_addr = ipaddress.ip_address(sa[0])
|
||||||
|
if ip_addr.is_multicast:
|
||||||
|
raise socket.error(f"Request to multicast address {sa[0]} blocked")
|
||||||
|
if ip_addr.is_loopback or ip_addr.is_link_local:
|
||||||
|
raise socket.error(f"Request to local address {sa[0]} blocked")
|
||||||
|
if ip_addr.is_private:
|
||||||
|
raise socket.error(f"Request to private address {sa[0]} blocked")
|
||||||
|
|
||||||
|
sock = None
|
||||||
|
try:
|
||||||
|
sock = socket.socket(af, socktype, proto)
|
||||||
|
if timeout is not socket.getdefaulttimeout():
|
||||||
|
sock.settimeout(timeout)
|
||||||
|
if source_address:
|
||||||
|
sock.bind(source_address)
|
||||||
|
sock.connect(sa)
|
||||||
|
# Break explicitly a reference cycle
|
||||||
|
exceptions.clear()
|
||||||
|
return sock
|
||||||
|
|
||||||
|
except socket.error as exc:
|
||||||
|
if not all_errors:
|
||||||
|
exceptions.clear() # raise only the last error
|
||||||
|
exceptions.append(exc)
|
||||||
|
if sock is not None:
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
if len(exceptions):
|
||||||
|
try:
|
||||||
|
if not all_errors:
|
||||||
|
raise exceptions[0]
|
||||||
|
raise ExceptionGroup("create_connection failed", exceptions)
|
||||||
|
finally:
|
||||||
|
# Break explicitly a reference cycle
|
||||||
|
exceptions.clear()
|
||||||
|
else:
|
||||||
|
raise socket.error("getaddrinfo returns an empty list")
|
||||||
|
|
||||||
|
|
||||||
|
class CheckPrivateNetworkMixin:
|
||||||
|
# _get_socket taken 1:1 from smtplib, just with a call to our own create_connection
|
||||||
|
def _get_socket(self, host, port, timeout):
|
||||||
|
# This makes it simpler for SMTP_SSL to use the SMTP connect code
|
||||||
|
# and just alter the socket connection bit.
|
||||||
|
if timeout is not None and not timeout:
|
||||||
|
raise ValueError('Non-blocking socket (timeout=0) is not supported')
|
||||||
|
if self.debuglevel > 0:
|
||||||
|
self._print_debug('connect: to', (host, port), self.source_address)
|
||||||
|
return create_connection((host, port), timeout, self.source_address)
|
||||||
|
|
||||||
|
|
||||||
|
class SMTP(CheckPrivateNetworkMixin, smtplib.SMTP):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# SMTP used here instead of mixin, because smtp.SMTP_SSL._get_socket calls super()._get_socket and then wraps this socket
|
||||||
|
# super()._get_socket needs to be our version from the mixin
|
||||||
|
class SMTP_SSL(smtplib.SMTP_SSL, SMTP): # noqa: N801
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CheckPrivateNetworkSmtpBackend(EmailBackend):
|
||||||
|
@property
|
||||||
|
def connection_class(self):
|
||||||
|
return SMTP_SSL if self.use_ssl else SMTP
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ import pycountry
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.gis.geoip2 import GeoIP2
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.core.validators import (
|
from django.core.validators import (
|
||||||
@@ -102,6 +101,7 @@ from pretix.helpers.countries import (
|
|||||||
from pretix.helpers.escapejson import escapejson_attr
|
from pretix.helpers.escapejson import escapejson_attr
|
||||||
from pretix.helpers.http import get_client_ip
|
from pretix.helpers.http import get_client_ip
|
||||||
from pretix.helpers.i18n import get_format_without_seconds
|
from pretix.helpers.i18n import get_format_without_seconds
|
||||||
|
from pretix.helpers.security import get_geoip
|
||||||
from pretix.presale.signals import question_form_fields
|
from pretix.presale.signals import question_form_fields
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -393,7 +393,7 @@ class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
|
|||||||
|
|
||||||
def guess_country_from_request(request, event):
|
def guess_country_from_request(request, event):
|
||||||
if settings.HAS_GEOIP:
|
if settings.HAS_GEOIP:
|
||||||
g = GeoIP2()
|
g = get_geoip()
|
||||||
try:
|
try:
|
||||||
res = g.country(get_client_ip(request))
|
res = g.country(get_client_ip(request))
|
||||||
if res['country_code'] and len(res['country_code']) == 2:
|
if res['country_code'] and len(res['country_code']) == 2:
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ from django.core.management.commands.makemigrations import Command as Parent
|
|||||||
|
|
||||||
from ._migrations import monkeypatch_migrations
|
from ._migrations import monkeypatch_migrations
|
||||||
|
|
||||||
monkeypatch_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
class Command(Parent):
|
class Command(Parent):
|
||||||
pass
|
|
||||||
|
def handle(self, *args, **kwargs):
|
||||||
|
monkeypatch_migrations()
|
||||||
|
return super().handle(*args, **kwargs)
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ class Command(BaseCommand):
|
|||||||
if not periodic_task.receivers or periodic_task.sender_receivers_cache.get(self) is NO_RECEIVERS:
|
if not periodic_task.receivers or periodic_task.sender_receivers_cache.get(self) is NO_RECEIVERS:
|
||||||
return
|
return
|
||||||
|
|
||||||
for receiver in periodic_task._live_receivers(self):
|
for receiver in periodic_task._live_receivers(self)[0]:
|
||||||
name = f'{receiver.__module__}.{receiver.__name__}'
|
name = f'{receiver.__module__}.{receiver.__name__}'
|
||||||
if options['list_tasks']:
|
if options['list_tasks']:
|
||||||
print(name)
|
print(name)
|
||||||
|
|||||||
@@ -41,16 +41,20 @@ class Migration(migrations.Migration):
|
|||||||
name='datetime',
|
name='datetime',
|
||||||
field=models.DateTimeField(),
|
field=models.DateTimeField(),
|
||||||
),
|
),
|
||||||
migrations.AlterIndexTogether(
|
migrations.AddIndex(
|
||||||
name='logentry',
|
'logentry',
|
||||||
index_together={('datetime', 'id')},
|
models.Index(fields=('datetime', 'id'), name="pretixbase__datetim_b1fe5a_idx"),
|
||||||
),
|
),
|
||||||
migrations.AlterIndexTogether(
|
migrations.AddIndex(
|
||||||
name='order',
|
'order',
|
||||||
index_together={('datetime', 'id'), ('last_modified', 'id')},
|
models.Index(fields=["datetime", "id"], name="pretixbase__datetim_66aff0_idx"),
|
||||||
),
|
),
|
||||||
migrations.AlterIndexTogether(
|
migrations.AddIndex(
|
||||||
name='transaction',
|
'order',
|
||||||
index_together={('datetime', 'id')},
|
models.Index(fields=["last_modified", "id"], name="pretixbase__last_mo_4ebf8b_idx"),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
'transaction',
|
||||||
|
models.Index(fields=('datetime', 'id'), name="pretixbase__datetim_b20405_idx"),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -61,7 +61,10 @@ class Migration(migrations.Migration):
|
|||||||
options={
|
options={
|
||||||
'ordering': ('identifier', 'type', 'organizer'),
|
'ordering': ('identifier', 'type', 'organizer'),
|
||||||
'unique_together': {('identifier', 'type', 'organizer')},
|
'unique_together': {('identifier', 'type', 'organizer')},
|
||||||
'index_together': {('identifier', 'type', 'organizer'), ('updated', 'id')},
|
'indexes': [
|
||||||
|
models.Index(fields=('identifier', 'type', 'organizer'), name='reusable_medium_organizer_index'),
|
||||||
|
models.Index(fields=('updated', 'id'), name="pretixbase__updated_093277_idx")
|
||||||
|
],
|
||||||
},
|
},
|
||||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,31 +9,6 @@ class Migration(migrations.Migration):
|
|||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.RenameIndex(
|
|
||||||
model_name="logentry",
|
|
||||||
new_name="pretixbase__datetim_b1fe5a_idx",
|
|
||||||
old_fields=("datetime", "id"),
|
|
||||||
),
|
|
||||||
migrations.RenameIndex(
|
|
||||||
model_name="order",
|
|
||||||
new_name="pretixbase__datetim_66aff0_idx",
|
|
||||||
old_fields=("datetime", "id"),
|
|
||||||
),
|
|
||||||
migrations.RenameIndex(
|
|
||||||
model_name="order",
|
|
||||||
new_name="pretixbase__last_mo_4ebf8b_idx",
|
|
||||||
old_fields=("last_modified", "id"),
|
|
||||||
),
|
|
||||||
migrations.RenameIndex(
|
|
||||||
model_name="reusablemedium",
|
|
||||||
new_name="pretixbase__updated_093277_idx",
|
|
||||||
old_fields=("updated", "id"),
|
|
||||||
),
|
|
||||||
migrations.RenameIndex(
|
|
||||||
model_name="transaction",
|
|
||||||
new_name="pretixbase__datetim_b20405_idx",
|
|
||||||
old_fields=("datetime", "id"),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="attendeeprofile",
|
model_name="attendeeprofile",
|
||||||
name="id",
|
name="id",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Generated by Django 4.2.10 on 2024-04-02 15:16
|
# Generated by Django 4.2.10 on 2024-04-02 15:16
|
||||||
|
|
||||||
from django.db import migrations
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
@@ -10,8 +10,8 @@ class Migration(migrations.Migration):
|
|||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterIndexTogether(
|
migrations.RemoveIndex(
|
||||||
name="reusablemedium",
|
"reusablemedium",
|
||||||
index_together=set(),
|
'reusable_medium_organizer_index',
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ class LogEntry(models.Model):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ('-datetime', '-id')
|
ordering = ('-datetime', '-id')
|
||||||
indexes = [models.Index(fields=["datetime", "id"])]
|
indexes = [models.Index(fields=["datetime", "id"], name="pretixbase__datetim_b1fe5a_idx")]
|
||||||
|
|
||||||
def display(self):
|
def display(self):
|
||||||
from pretix.base.logentrytype_registry import log_entry_types
|
from pretix.base.logentrytype_registry import log_entry_types
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ class ReusableMedium(LoggedModel):
|
|||||||
class Meta:
|
class Meta:
|
||||||
unique_together = (("identifier", "type", "organizer"),)
|
unique_together = (("identifier", "type", "organizer"),)
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=("updated", "id")),
|
models.Index(fields=("updated", "id"), name="pretixbase__updated_093277_idx"),
|
||||||
]
|
]
|
||||||
ordering = "identifier", "type", "organizer"
|
ordering = "identifier", "type", "organizer"
|
||||||
|
|
||||||
|
|||||||
@@ -336,8 +336,8 @@ class Order(LockModel, LoggedModel):
|
|||||||
verbose_name_plural = _("Orders")
|
verbose_name_plural = _("Orders")
|
||||||
ordering = ("-datetime", "-pk")
|
ordering = ("-datetime", "-pk")
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=["datetime", "id"]),
|
models.Index(fields=["datetime", "id"], name="pretixbase__datetim_66aff0_idx"),
|
||||||
models.Index(fields=["last_modified", "id"]),
|
models.Index(fields=["last_modified", "id"], name="pretixbase__last_mo_4ebf8b_idx"),
|
||||||
]
|
]
|
||||||
constraints = [
|
constraints = [
|
||||||
models.UniqueConstraint(fields=["organizer", "code"], name="order_organizer_code_uniq"),
|
models.UniqueConstraint(fields=["organizer", "code"], name="order_organizer_code_uniq"),
|
||||||
@@ -3080,7 +3080,7 @@ class Transaction(models.Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
ordering = 'datetime', 'pk'
|
ordering = 'datetime', 'pk'
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['datetime', 'id'])
|
models.Index(fields=['datetime', 'id'], name="pretixbase__datetim_b20405_idx")
|
||||||
]
|
]
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ def primary_font_kwargs():
|
|||||||
|
|
||||||
choices = [('Open Sans', 'Open Sans')]
|
choices = [('Open Sans', 'Open Sans')]
|
||||||
choices += sorted([
|
choices += sorted([
|
||||||
(a, {"title": a, "data": v}) for a, v in get_fonts(pdf_support_required=False).items()
|
(a, FontSelect.FontOption(title=a, data=v)) for a, v in get_fonts(pdf_support_required=False).items()
|
||||||
], key=lambda a: a[0])
|
], key=lambda a: a[0])
|
||||||
return {
|
return {
|
||||||
'choices': choices,
|
'choices': choices,
|
||||||
@@ -4148,6 +4148,14 @@ def validate_event_settings(event, settings_dict):
|
|||||||
)
|
)
|
||||||
]}
|
]}
|
||||||
)
|
)
|
||||||
|
if (
|
||||||
|
settings_dict.get('invoice_address_from_vat_id') and
|
||||||
|
settings_dict.get('invoice_address_from_country') and
|
||||||
|
settings_dict.get('invoice_address_from_country') not in VAT_ID_COUNTRIES
|
||||||
|
):
|
||||||
|
raise ValidationError({
|
||||||
|
'invoice_address_from_vat_id': _('VAT-ID is not supported for "{}".').format(settings_dict.get('invoice_address_from_country'))
|
||||||
|
})
|
||||||
|
|
||||||
payment_term_last = settings_dict.get('payment_term_last')
|
payment_term_last = settings_dict.get('payment_term_last')
|
||||||
if payment_term_last and event.presale_end:
|
if payment_term_last and event.presale_end:
|
||||||
|
|||||||
+60
-19
@@ -32,6 +32,7 @@
|
|||||||
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
# License for the specific language governing permissions and limitations under the License.
|
# License for the specific language governing permissions and limitations under the License.
|
||||||
|
|
||||||
|
import logging
|
||||||
import warnings
|
import warnings
|
||||||
from typing import Any, Callable, Generic, List, Tuple, TypeVar
|
from typing import Any, Callable, Generic, List, Tuple, TypeVar
|
||||||
|
|
||||||
@@ -48,6 +49,8 @@ from .plugins import (
|
|||||||
PLUGIN_LEVEL_ORGANIZER,
|
PLUGIN_LEVEL_ORGANIZER,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
app_cache = {}
|
app_cache = {}
|
||||||
T = TypeVar('T')
|
T = TypeVar('T')
|
||||||
|
|
||||||
@@ -60,23 +63,25 @@ def _populate_app_cache():
|
|||||||
|
|
||||||
def get_defining_app(o):
|
def get_defining_app(o):
|
||||||
# If sentry packed this in a wrapper, unpack that
|
# If sentry packed this in a wrapper, unpack that
|
||||||
if "sentry" in o.__module__:
|
module = getattr(o, "__module__", None)
|
||||||
|
if module and "sentry" in module:
|
||||||
o = o.__wrapped__
|
o = o.__wrapped__
|
||||||
|
|
||||||
if hasattr(o, "__mocked_app"):
|
if hasattr(o, "__mocked_app"):
|
||||||
return o.__mocked_app
|
return o.__mocked_app
|
||||||
|
|
||||||
# Find the Django application this belongs to
|
# Find the Django application this belongs to
|
||||||
searchpath = o.__module__
|
searchpath = module or getattr(o.__class__, "__module__", None) or ""
|
||||||
|
|
||||||
# Core modules are always active
|
# Core modules are always active
|
||||||
if any(searchpath.startswith(cm) for cm in settings.CORE_MODULES):
|
if searchpath and any(searchpath.startswith(cm) for cm in settings.CORE_MODULES):
|
||||||
return 'CORE'
|
return 'CORE'
|
||||||
|
|
||||||
if not app_cache:
|
if not app_cache:
|
||||||
_populate_app_cache()
|
_populate_app_cache()
|
||||||
|
|
||||||
while True:
|
app = None
|
||||||
|
while searchpath:
|
||||||
app = app_cache.get(searchpath)
|
app = app_cache.get(searchpath)
|
||||||
if "." not in searchpath or app:
|
if "." not in searchpath or app:
|
||||||
break
|
break
|
||||||
@@ -157,7 +162,7 @@ class PluginSignal(Generic[T], django.dispatch.Signal):
|
|||||||
if not app_cache:
|
if not app_cache:
|
||||||
_populate_app_cache()
|
_populate_app_cache()
|
||||||
|
|
||||||
for receiver in self._sorted_receivers(sender):
|
for receiver in self._live_receivers(sender)[0]:
|
||||||
if self._is_receiver_active(sender, receiver):
|
if self._is_receiver_active(sender, receiver):
|
||||||
response = receiver(signal=self, sender=sender, **named)
|
response = receiver(signal=self, sender=sender, **named)
|
||||||
responses.append((receiver, response))
|
responses.append((receiver, response))
|
||||||
@@ -179,7 +184,7 @@ class PluginSignal(Generic[T], django.dispatch.Signal):
|
|||||||
if not app_cache:
|
if not app_cache:
|
||||||
_populate_app_cache()
|
_populate_app_cache()
|
||||||
|
|
||||||
for receiver in self._sorted_receivers(sender):
|
for receiver in self._live_receivers(sender)[0]:
|
||||||
if self._is_receiver_active(sender, receiver):
|
if self._is_receiver_active(sender, receiver):
|
||||||
named[chain_kwarg_name] = response
|
named[chain_kwarg_name] = response
|
||||||
response = receiver(signal=self, sender=sender, **named)
|
response = receiver(signal=self, sender=sender, **named)
|
||||||
@@ -204,7 +209,7 @@ class PluginSignal(Generic[T], django.dispatch.Signal):
|
|||||||
if not app_cache:
|
if not app_cache:
|
||||||
_populate_app_cache()
|
_populate_app_cache()
|
||||||
|
|
||||||
for receiver in self._sorted_receivers(sender):
|
for receiver in self._live_receivers(sender)[0]:
|
||||||
if self._is_receiver_active(sender, receiver):
|
if self._is_receiver_active(sender, receiver):
|
||||||
try:
|
try:
|
||||||
response = receiver(signal=self, sender=sender, **named)
|
response = receiver(signal=self, sender=sender, **named)
|
||||||
@@ -214,17 +219,35 @@ class PluginSignal(Generic[T], django.dispatch.Signal):
|
|||||||
responses.append((receiver, response))
|
responses.append((receiver, response))
|
||||||
return responses
|
return responses
|
||||||
|
|
||||||
def _sorted_receivers(self, sender):
|
def asend(self, sender: T, **named):
|
||||||
orig_list = self._live_receivers(sender)
|
raise NotImplementedError() # NOQA
|
||||||
|
|
||||||
|
def asend_robust(self, sender: T, **named):
|
||||||
|
raise NotImplementedError() # NOQA
|
||||||
|
|
||||||
|
def _live_receivers(self, sender):
|
||||||
|
orig_list, orig_async_list = super()._live_receivers(sender)
|
||||||
|
|
||||||
|
if orig_async_list:
|
||||||
|
logger.error('Async receivers are not supported.')
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def _getattr_fallback_to_class(obj, key):
|
||||||
|
return getattr(obj, key, getattr(obj.__class__, key))
|
||||||
|
|
||||||
|
def _is_core_module(receiver):
|
||||||
|
m = _getattr_fallback_to_class(receiver, "__module__")
|
||||||
|
return any(m.startswith(c) for c in settings.CORE_MODULES)
|
||||||
|
|
||||||
sorted_list = sorted(
|
sorted_list = sorted(
|
||||||
orig_list,
|
orig_list,
|
||||||
key=lambda receiver: (
|
key=lambda receiver: (
|
||||||
0 if any(receiver.__module__.startswith(m) for m in settings.CORE_MODULES) else 1,
|
0 if _is_core_module(receiver) else 1,
|
||||||
receiver.__module__,
|
_getattr_fallback_to_class(receiver, "__module__"),
|
||||||
receiver.__name__,
|
_getattr_fallback_to_class(receiver, "__name__"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return sorted_list
|
return sorted_list, []
|
||||||
|
|
||||||
|
|
||||||
class EventPluginSignal(PluginSignal[Event]):
|
class EventPluginSignal(PluginSignal[Event]):
|
||||||
@@ -300,23 +323,41 @@ class GlobalSignal(django.dispatch.Signal):
|
|||||||
if not self.receivers or self.sender_receivers_cache.get(sender) is NO_RECEIVERS:
|
if not self.receivers or self.sender_receivers_cache.get(sender) is NO_RECEIVERS:
|
||||||
return response
|
return response
|
||||||
|
|
||||||
for receiver in self._live_receivers(sender):
|
for receiver in self._live_receivers(sender)[0]:
|
||||||
named[chain_kwarg_name] = response
|
named[chain_kwarg_name] = response
|
||||||
response = receiver(signal=self, sender=sender, **named)
|
response = receiver(signal=self, sender=sender, **named)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
def asend(self, sender: T, **named):
|
||||||
|
raise NotImplementedError() # NOQA
|
||||||
|
|
||||||
|
def asend_robust(self, sender: T, **named):
|
||||||
|
raise NotImplementedError() # NOQA
|
||||||
|
|
||||||
def _live_receivers(self, sender):
|
def _live_receivers(self, sender):
|
||||||
# Ensure consistent sorting of receivers
|
# Ensure consistent sorting of receivers
|
||||||
orig_list = super()._live_receivers(sender)
|
orig_list, orig_async_list = super()._live_receivers(sender)
|
||||||
|
|
||||||
|
if orig_async_list:
|
||||||
|
logger.error('Async receivers are not supported.')
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def _getattr_fallback_to_class(obj, key):
|
||||||
|
return getattr(obj, key, getattr(obj.__class__, key))
|
||||||
|
|
||||||
|
def _is_core_module(receiver):
|
||||||
|
m = _getattr_fallback_to_class(receiver, "__module__")
|
||||||
|
return any(m.startswith(c) for c in settings.CORE_MODULES)
|
||||||
|
|
||||||
sorted_list = sorted(
|
sorted_list = sorted(
|
||||||
orig_list,
|
orig_list,
|
||||||
key=lambda receiver: (
|
key=lambda receiver: (
|
||||||
0 if any(receiver.__module__.startswith(m) for m in settings.CORE_MODULES) else 1,
|
0 if _is_core_module(receiver) else 1,
|
||||||
receiver.__module__,
|
_getattr_fallback_to_class(receiver, "__module__"),
|
||||||
receiver.__name__,
|
_getattr_fallback_to_class(receiver, "__name__"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return sorted_list
|
return sorted_list, []
|
||||||
|
|
||||||
|
|
||||||
class DeprecatedSignal(GlobalSignal):
|
class DeprecatedSignal(GlobalSignal):
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -420,6 +421,11 @@ class SplitDateTimeField(forms.SplitDateTimeField):
|
|||||||
class FontSelect(forms.RadioSelect):
|
class FontSelect(forms.RadioSelect):
|
||||||
option_template_name = 'pretixcontrol/font_option.html'
|
option_template_name = 'pretixcontrol/font_option.html'
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FontOption:
|
||||||
|
title: str
|
||||||
|
data: str
|
||||||
|
|
||||||
|
|
||||||
class ItemMultipleChoiceField(SafeModelMultipleChoiceField):
|
class ItemMultipleChoiceField(SafeModelMultipleChoiceField):
|
||||||
def label_from_instance(self, obj):
|
def label_from_instance(self, obj):
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ from pretix.base.forms import (
|
|||||||
from pretix.base.models import Event, Organizer, TaxRule, Team
|
from pretix.base.models import Event, Organizer, TaxRule, Team
|
||||||
from pretix.base.models.event import EventFooterLink, EventMetaValue, SubEvent
|
from pretix.base.models.event import EventFooterLink, EventMetaValue, SubEvent
|
||||||
from pretix.base.models.organizer import TeamQuerySet
|
from pretix.base.models.organizer import TeamQuerySet
|
||||||
from pretix.base.models.tax import TAX_CODE_LISTS
|
from pretix.base.models.tax import TAX_CODE_LISTS, VAT_ID_COUNTRIES
|
||||||
from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
|
from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
|
||||||
from pretix.base.services.placeholders import FormPlaceholderMixin
|
from pretix.base.services.placeholders import FormPlaceholderMixin
|
||||||
from pretix.base.settings import (
|
from pretix.base.settings import (
|
||||||
@@ -73,8 +73,8 @@ from pretix.base.settings import (
|
|||||||
)
|
)
|
||||||
from pretix.base.validators import multimail_validate
|
from pretix.base.validators import multimail_validate
|
||||||
from pretix.control.forms import (
|
from pretix.control.forms import (
|
||||||
MultipleLanguagesWidget, SalesChannelCheckboxSelectMultiple, SlugWidget,
|
FontSelect, MultipleLanguagesWidget, SalesChannelCheckboxSelectMultiple,
|
||||||
SplitDateTimeField, SplitDateTimePickerWidget,
|
SlugWidget, SplitDateTimeField, SplitDateTimePickerWidget,
|
||||||
)
|
)
|
||||||
from pretix.control.forms.widgets import Select2
|
from pretix.control.forms.widgets import Select2
|
||||||
from pretix.helpers.countries import CachedCountries
|
from pretix.helpers.countries import CachedCountries
|
||||||
@@ -531,6 +531,13 @@ class EventUpdateForm(I18nModelForm):
|
|||||||
|
|
||||||
class EventSettingsValidationMixin:
|
class EventSettingsValidationMixin:
|
||||||
|
|
||||||
|
def clean_invoice_address_from_vat_id(self):
|
||||||
|
value = self.cleaned_data.get('invoice_address_from_vat_id')
|
||||||
|
country = self.cleaned_data.get('invoice_address_from_country')
|
||||||
|
if value and country and country not in VAT_ID_COUNTRIES:
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
data = super().clean()
|
data = super().clean()
|
||||||
settings_dict = self.obj.settings.freeze()
|
settings_dict = self.obj.settings.freeze()
|
||||||
@@ -722,7 +729,7 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett
|
|||||||
del self.fields['event_list_filters']
|
del self.fields['event_list_filters']
|
||||||
del self.fields['event_calendar_future_only']
|
del self.fields['event_calendar_future_only']
|
||||||
self.fields['primary_font'].choices = [('Open Sans', 'Open Sans')] + sorted([
|
self.fields['primary_font'].choices = [('Open Sans', 'Open Sans')] + sorted([
|
||||||
(a, {"title": a, "data": v}) for a, v in get_fonts(self.event, pdf_support_required=False).items()
|
(a, FontSelect.FontOption(title=a, data=v)) for a, v in get_fonts(self.event, pdf_support_required=False).items()
|
||||||
], key=lambda a: a[0])
|
], key=lambda a: a[0])
|
||||||
|
|
||||||
# create "virtual" fields for better UX when editing <name>_asked and <name>_required fields
|
# create "virtual" fields for better UX when editing <name>_asked and <name>_required fields
|
||||||
|
|||||||
@@ -363,7 +363,7 @@ def get_global_navigation(request):
|
|||||||
'icon': 'dashboard',
|
'icon': 'dashboard',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
if request.user.is_in_any_teams:
|
if request.user.is_in_any_teams or request.user.is_staff:
|
||||||
nav += [
|
nav += [
|
||||||
{
|
{
|
||||||
'label': _('Events'),
|
'label': _('Events'),
|
||||||
|
|||||||
@@ -6,38 +6,9 @@
|
|||||||
<h1>{% trans "Add a two-factor authentication device" %}</h1>
|
<h1>{% trans "Add a two-factor authentication device" %}</h1>
|
||||||
<form action="" method="post" class="form-horizontal">
|
<form action="" method="post" class="form-horizontal">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="flow_token" value="{{ flow_token }}">
|
|
||||||
{% bootstrap_form_errors form %}
|
{% bootstrap_form_errors form %}
|
||||||
{% bootstrap_field form.name layout='horizontal' %}
|
{% bootstrap_field form.name layout='horizontal' %}
|
||||||
|
{% bootstrap_field form.devicetype layout='horizontal' %}
|
||||||
<div class="form-group">
|
|
||||||
<label class="col-md-3 control-label">{% trans "Device type" %}</label>
|
|
||||||
<div class="col-md-9">
|
|
||||||
<div class="big-radio radio">
|
|
||||||
<label>
|
|
||||||
<input type="radio" value="totp" name="{{ form.devicetype.html_name }}" {% if form.devicetype.value == "totp" %}checked{% endif %}>
|
|
||||||
<strong>{% trans "Smartphone with the Authenticator application" %}</strong><br>
|
|
||||||
<div class="help-block">
|
|
||||||
{% blocktrans trimmed %}
|
|
||||||
Use your smartphone with any Time-based One-Time-Password app like freeOTP, Google Authenticator or Proton Authenticator.
|
|
||||||
{% endblocktrans %}
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="big-radio radio">
|
|
||||||
<label>
|
|
||||||
<input type="radio" value="webauthn" name="{{ form.devicetype.html_name }}" {% if form.devicetype.value == "webauthn" %}checked{% endif %}>
|
|
||||||
<strong>{% trans "WebAuthn-compatible hardware token" %}</strong><br>
|
|
||||||
<div class="help-block">
|
|
||||||
{% blocktrans trimmed %}
|
|
||||||
Use a hardware token like the Yubikey, or biometric authentication on iOS, macOS and Android.
|
|
||||||
{% endblocktrans %}
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group submit-group">
|
<div class="form-group submit-group">
|
||||||
<button type="submit" class="btn btn-primary btn-save">
|
<button type="submit" class="btn btn-primary btn-save">
|
||||||
{% trans "Continue" %}
|
{% trans "Continue" %}
|
||||||
|
|||||||
@@ -28,6 +28,11 @@
|
|||||||
{% trans "iOS (iTunes)" %}
|
{% trans "iOS (iTunes)" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://m.google.com/authenticator">
|
||||||
|
{% trans "Blackberry (Link via Google)" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -69,11 +74,14 @@
|
|||||||
{% trans "Enter the displayed code here:" %}
|
{% trans "Enter the displayed code here:" %}
|
||||||
<form class="form form-inline" method="post" action="">
|
<form class="form form-inline" method="post" action="">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="flow_token" value="{{ flow_token }}">
|
|
||||||
<input type="number" name="token" class="form-control" required="required">
|
<input type="number" name="token" class="form-control" required="required">
|
||||||
<button class="btn btn-primary" type="submit">
|
<button class="btn btn-primary" type="submit">
|
||||||
{% trans "Continue" %}
|
{% trans "Continue" %}
|
||||||
</button><br>
|
</button><br>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="activate" checked="checked" value="on">
|
||||||
|
{% trans "Require second factor for future logins" %}
|
||||||
|
</label>
|
||||||
</form>
|
</form>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|||||||
@@ -12,9 +12,13 @@
|
|||||||
</p>
|
</p>
|
||||||
<form class="form form-inline" method="post" action="" id="webauthn-form">
|
<form class="form form-inline" method="post" action="" id="webauthn-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="flow_token" value="{{ flow_token }}">
|
|
||||||
<input type="hidden" id="webauthn-response" name="token" class="form-control" required="required">
|
<input type="hidden" id="webauthn-response" name="token" class="form-control" required="required">
|
||||||
|
<p>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="activate" checked="checked" value="on">
|
||||||
|
{% trans "Require second factor for future logins" %}
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
<button class="btn btn-primary sr-only" type="submit"></button>
|
<button class="btn btn-primary sr-only" type="submit"></button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
<h1>{% trans "Delete a two-factor authentication device" %}</h1>
|
<h1>{% trans "Delete a two-factor authentication device" %}</h1>
|
||||||
<form action="" method="post" class="form-horizontal">
|
<form action="" method="post" class="form-horizontal">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="flow_token" value="{{ flow_token }}">
|
|
||||||
<p>{% blocktrans trimmed with device=device.name %}
|
<p>{% blocktrans trimmed with device=device.name %}
|
||||||
Are you sure you want to delete the authentication device "{{ device }}"?
|
Are you sure you want to delete the authentication device "{{ device }}"?
|
||||||
{% endblocktrans %}</p>
|
{% endblocktrans %}</p>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
<h1>{% trans "Disable two-factor authentication" %}</h1>
|
<h1>{% trans "Disable two-factor authentication" %}</h1>
|
||||||
<form action="" method="post" class="form-horizontal">
|
<form action="" method="post" class="form-horizontal">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="flow_token" value="{{ flow_token }}">
|
|
||||||
<p>
|
<p>
|
||||||
{% trans "Do you really want to disable two-factor authentication?" %}
|
{% trans "Do you really want to disable two-factor authentication?" %}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,58 +1,23 @@
|
|||||||
{% extends "pretixcontrol/base.html" %}
|
{% extends "pretixcontrol/base.html" %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load bootstrap3 %}
|
{% load bootstrap3 %}
|
||||||
{% load icon %}
|
|
||||||
{% block title %}{% trans "Enable two-factor authentication" %}{% endblock %}
|
{% block title %}{% trans "Enable two-factor authentication" %}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>{% trans "Enable two-factor authentication" %}</h1>
|
<h1>{% trans "Enable two-factor authentication" %}</h1>
|
||||||
<form action="" method="post" class="form-horizontal">
|
<form action="" method="post" class="form-horizontal">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="flow_token" value="{{ flow_token }}">
|
|
||||||
<p>
|
<p>
|
||||||
{% trans "Do you really want to enable two-factor authentication?" %}
|
{% trans "Do you really want to enable two-factor authentication?" %}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{% trans "You will no longer be able to log in to pretix without one of your configured devices." %}
|
{% trans "You will no longer be able to log in to pretix without one of your configured devices." %}
|
||||||
|
{% trans "Please make sure to print out or copy the emergency tokens and store them in a safe place." %}
|
||||||
</p>
|
</p>
|
||||||
{% if new_emergency_tokens %}
|
|
||||||
<div class="panel panel-default">
|
|
||||||
<div class="panel-heading">
|
|
||||||
<h3 class="panel-title">{% trans "Your emergency codes" %}</h3>
|
|
||||||
</div>
|
|
||||||
<div class="panel-body">
|
|
||||||
<p>
|
|
||||||
{% blocktrans trimmed %}
|
|
||||||
If you lose access to your devices, you can use one of your emergency tokens to log in.
|
|
||||||
We recommend to store them in a safe place, e.g. printed out or in a password manager.
|
|
||||||
Every token can be used at most once.
|
|
||||||
{% endblocktrans %}
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
{% for code in new_emergency_tokens %}
|
|
||||||
<li>{{ code }}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
<p>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" required>
|
|
||||||
{% trans "I stored my emergency tokens in a safe place." %}
|
|
||||||
</label>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p>
|
|
||||||
{% icon "info-circle" %}
|
|
||||||
{% blocktrans trimmed with generation_date_time=static_tokens_device.created_at %}
|
|
||||||
You generated your emergency tokens on {{ generation_date_time }}.
|
|
||||||
{% endblocktrans %}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
<div class="form-group submit-group">
|
<div class="form-group submit-group">
|
||||||
<a href="{% url "control:user.settings.2fa" %}" class="btn btn-default btn-cancel">
|
<a href="{% url "control:user.settings.2fa" %}" class="btn btn-default btn-cancel">
|
||||||
{% trans "Cancel" %}
|
{% trans "Cancel" %}
|
||||||
</a>
|
</a>
|
||||||
<button type="submit" class="btn btn-primary btn-save">
|
<button type="submit" class="btn btn-danger btn-save">
|
||||||
{% trans "Enable" %}
|
{% trans "Enable" %}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
<h1>{% trans "Leave teams that require two-factor authentication" %}</h1>
|
<h1>{% trans "Leave teams that require two-factor authentication" %}</h1>
|
||||||
<form action="" method="post" class="form-horizontal">
|
<form action="" method="post" class="form-horizontal">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="flow_token" value="{{ flow_token }}">
|
|
||||||
<p>
|
<p>
|
||||||
<strong>{% trans "Do you really want to leave the following teams?" %}</strong>
|
<strong>{% trans "Do you really want to leave the following teams?" %}</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
{% extends "pretixcontrol/base.html" %}
|
{% extends "pretixcontrol/base.html" %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load icon %}
|
|
||||||
{% load bootstrap3 %}
|
{% load bootstrap3 %}
|
||||||
{% block title %}{% trans "Two-factor authentication" %}{% endblock %}
|
{% block title %}{% trans "Two-factor authentication" %}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@@ -121,7 +120,7 @@
|
|||||||
Delete
|
Delete
|
||||||
</a>
|
</a>
|
||||||
{% if d.devicetype == "totp" %}
|
{% if d.devicetype == "totp" %}
|
||||||
<span class="fa fa-mobile fa-lg"></span>
|
<span class="fa fa-mobile"></span>
|
||||||
{% elif d.devicetype == "webauthn" %}
|
{% elif d.devicetype == "webauthn" %}
|
||||||
<span class="fa fa-usb"></span>
|
<span class="fa fa-usb"></span>
|
||||||
{% elif d.devicetype == "u2f" %}
|
{% elif d.devicetype == "u2f" %}
|
||||||
@@ -153,30 +152,19 @@
|
|||||||
</p>
|
</p>
|
||||||
{% if static_tokens_device %}
|
{% if static_tokens_device %}
|
||||||
<p>
|
<p>
|
||||||
{% icon "info-circle" %}
|
|
||||||
{% blocktrans trimmed with generation_date_time=static_tokens_device.created_at %}
|
{% blocktrans trimmed with generation_date_time=static_tokens_device.created_at %}
|
||||||
You generated your emergency tokens on {{ generation_date_time }}.
|
You generated your emergency tokens on {{ generation_date_time }}.
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
</p>
|
</p>
|
||||||
<a href="{% url "control:user.settings.2fa.regenemergency" %}" class="btn btn-default">
|
|
||||||
<span class="fa fa-refresh"></span>
|
|
||||||
{% trans "Generate new emergency tokens" %}
|
|
||||||
</a>
|
|
||||||
{% elif user.require_2fa %}
|
|
||||||
<p>
|
|
||||||
{% icon "warning" %}
|
|
||||||
<strong>{% trans "You don't have any emergency tokens yet." %}</strong>
|
|
||||||
</p>
|
|
||||||
<a href="{% url "control:user.settings.2fa.regenemergency" %}" class="btn btn-default">
|
|
||||||
<span class="fa fa-refresh"></span>
|
|
||||||
{% trans "Generate emergency tokens" %}
|
|
||||||
</a>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="help-block">
|
<p>
|
||||||
{% icon "info-circle" %}
|
{% trans "You don't have any emergency tokens yet." %}
|
||||||
{% trans "Emergency tokens will be generated when you enable two-factor authentication." %}
|
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<a href="{% url "control:user.settings.2fa.regenemergency" %}" class="btn btn-default">
|
||||||
|
<span class="fa fa-refresh"></span>
|
||||||
|
{% trans "Generate new emergency tokens" %}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
<h1>{% trans "Regenerate emergency codes" %}</h1>
|
<h1>{% trans "Regenerate emergency codes" %}</h1>
|
||||||
<form action="" method="post" class="form-horizontal">
|
<form action="" method="post" class="form-horizontal">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="flow_token" value="{{ flow_token }}">
|
|
||||||
<p>
|
<p>
|
||||||
{% trans "Do you really want to regenerate your emergency codes?" %}
|
{% trans "Do you really want to regenerate your emergency codes?" %}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
{% trans "Change login email address" %}
|
{% trans "Change login email address" %}
|
||||||
</h1>
|
</h1>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="flow_token" value="{{ flow_token }}">
|
|
||||||
{% bootstrap_form_errors form %}
|
{% bootstrap_form_errors form %}
|
||||||
<p class="text-muted">
|
<p class="text-muted">
|
||||||
{% trans "This changes the email address used to login to your account, as well as where we send email notifications." %}
|
{% trans "This changes the email address used to login to your account, as well as where we send email notifications." %}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
</h1>
|
</h1>
|
||||||
<br>
|
<br>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="flow_token" value="{{ flow_token }}">
|
|
||||||
{% bootstrap_form_errors form %}
|
{% bootstrap_form_errors form %}
|
||||||
{% bootstrap_field form.email %}
|
{% bootstrap_field form.email %}
|
||||||
{% bootstrap_field form.old_pw %}
|
{% bootstrap_field form.old_pw %}
|
||||||
|
|||||||
@@ -641,7 +641,7 @@ def user_index(request):
|
|||||||
|
|
||||||
ctx = {
|
ctx = {
|
||||||
'widgets': rearrange(widgets),
|
'widgets': rearrange(widgets),
|
||||||
'can_create_event': request.user.teams.with_organizer_permission("organizer.events:create").exists(),
|
'can_create_event': request.user.teams.with_organizer_permission("organizer.events:create").exists() or request.user.is_staff,
|
||||||
'upcoming': widgets_for_event_qs(
|
'upcoming': widgets_for_event_qs(
|
||||||
request,
|
request,
|
||||||
annotated_event_query(request, lazy=True).filter(
|
annotated_event_query(request, lazy=True).filter(
|
||||||
|
|||||||
@@ -89,31 +89,13 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
class RecentAuthenticationRequiredMixin:
|
class RecentAuthenticationRequiredMixin:
|
||||||
max_time = 900
|
max_time = 900
|
||||||
max_form_time = 900
|
|
||||||
|
|
||||||
@method_decorator(never_cache)
|
@method_decorator(never_cache)
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
auth_is_recent = time.time() - request.session.get('pretix_auth_login_time', 0) < self.max_time
|
tdelta = time.time() - request.session.get('pretix_auth_login_time', 0)
|
||||||
allowed_by_token = (
|
if tdelta > self.max_time:
|
||||||
request.session.pop('pretix_reauthed_flow_token', None) == request.POST.get('flow_token', '')
|
|
||||||
and request.session.pop('pretix_reauthed_flow_allowed_url', None) == request.get_full_path()
|
|
||||||
and time.time() - request.session.pop('pretix_reauthed_flow_start_time', 0) < self.max_form_time
|
|
||||||
)
|
|
||||||
if auth_is_recent or allowed_by_token:
|
|
||||||
return super().dispatch(request, *args, **kwargs)
|
|
||||||
else:
|
|
||||||
return redirect(reverse('control:user.reauth') + '?next=' + quote(request.get_full_path()))
|
return redirect(reverse('control:user.reauth') + '?next=' + quote(request.get_full_path()))
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
def get_flow_token(self):
|
|
||||||
self.request.session['pretix_reauthed_flow_allowed_url'] = self.request.get_full_path()
|
|
||||||
self.request.session['pretix_reauthed_flow_token'] = get_random_string(22)
|
|
||||||
self.request.session['pretix_reauthed_flow_start_time'] = time.time()
|
|
||||||
return self.request.session['pretix_reauthed_flow_token']
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
|
||||||
ctx = super().get_context_data()
|
|
||||||
ctx['flow_token'] = self.get_flow_token()
|
|
||||||
return ctx
|
|
||||||
|
|
||||||
|
|
||||||
class ReauthView(TemplateView):
|
class ReauthView(TemplateView):
|
||||||
@@ -301,7 +283,6 @@ class UserHistoryView(ListView):
|
|||||||
|
|
||||||
|
|
||||||
class User2FAMainView(RecentAuthenticationRequiredMixin, TemplateView):
|
class User2FAMainView(RecentAuthenticationRequiredMixin, TemplateView):
|
||||||
max_time = 7200
|
|
||||||
template_name = 'pretixcontrol/user/2fa_main.html'
|
template_name = 'pretixcontrol/user/2fa_main.html'
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
@@ -484,15 +465,25 @@ class User2FADeviceConfirmWebAuthnView(RecentAuthenticationRequiredMixin, Templa
|
|||||||
notices = [
|
notices = [
|
||||||
_('A new two-factor authentication device has been added to your account.')
|
_('A new two-factor authentication device has been added to your account.')
|
||||||
]
|
]
|
||||||
|
activate = request.POST.get('activate', '')
|
||||||
|
if activate == 'on' and not self.request.user.require_2fa:
|
||||||
|
self.request.user.require_2fa = True
|
||||||
|
self.request.user.save()
|
||||||
|
self.request.user.log_action('pretix.user.settings.2fa.enabled', user=self.request.user)
|
||||||
|
notices.append(
|
||||||
|
_('Two-factor authentication has been enabled.')
|
||||||
|
)
|
||||||
self.request.user.send_security_notice(notices)
|
self.request.user.send_security_notice(notices)
|
||||||
self.request.user.update_session_token()
|
self.request.user.update_session_token()
|
||||||
update_session_auth_hash(self.request, self.request.user)
|
update_session_auth_hash(self.request, self.request.user)
|
||||||
|
|
||||||
messages.success(request, str(_('The device has been verified and can now be used.')))
|
note = ''
|
||||||
if self.request.user.require_2fa:
|
if not self.request.user.require_2fa:
|
||||||
return redirect(reverse('control:user.settings.2fa'))
|
note = ' ' + str(_('Please note that you still need to enable two-factor authentication for your '
|
||||||
else:
|
'account using the buttons below to make a second factor required for logging '
|
||||||
return redirect(reverse('control:user.settings.2fa.enable'))
|
'into your account.'))
|
||||||
|
messages.success(request, str(_('The device has been verified and can now be used.')) + note)
|
||||||
|
return redirect(reverse('control:user.settings.2fa'))
|
||||||
except Exception:
|
except Exception:
|
||||||
messages.error(request, _('The registration could not be completed. Please try again.'))
|
messages.error(request, _('The registration could not be completed. Please try again.'))
|
||||||
logger.exception('WebAuthn registration failed')
|
logger.exception('WebAuthn registration failed')
|
||||||
@@ -503,7 +494,6 @@ class User2FADeviceConfirmWebAuthnView(RecentAuthenticationRequiredMixin, Templa
|
|||||||
|
|
||||||
class User2FADeviceConfirmTOTPView(RecentAuthenticationRequiredMixin, TemplateView):
|
class User2FADeviceConfirmTOTPView(RecentAuthenticationRequiredMixin, TemplateView):
|
||||||
template_name = 'pretixcontrol/user/2fa_confirm_totp.html'
|
template_name = 'pretixcontrol/user/2fa_confirm_totp.html'
|
||||||
max_form_time = 7200 # this should have effectively no timeout, as the user might need to download the 2fa app first
|
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def device(self):
|
def device(self):
|
||||||
@@ -524,6 +514,7 @@ class User2FADeviceConfirmTOTPView(RecentAuthenticationRequiredMixin, TemplateVi
|
|||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
token = request.POST.get('token', '')
|
token = request.POST.get('token', '')
|
||||||
|
activate = request.POST.get('activate', '')
|
||||||
if self.device.verify_token(token):
|
if self.device.verify_token(token):
|
||||||
self.device.confirmed = True
|
self.device.confirmed = True
|
||||||
self.device.save()
|
self.device.save()
|
||||||
@@ -535,15 +526,24 @@ class User2FADeviceConfirmTOTPView(RecentAuthenticationRequiredMixin, TemplateVi
|
|||||||
notices = [
|
notices = [
|
||||||
_('A new two-factor authentication device has been added to your account.')
|
_('A new two-factor authentication device has been added to your account.')
|
||||||
]
|
]
|
||||||
|
if activate == 'on' and not self.request.user.require_2fa:
|
||||||
|
self.request.user.require_2fa = True
|
||||||
|
self.request.user.save()
|
||||||
|
self.request.user.log_action('pretix.user.settings.2fa.enabled', user=self.request.user)
|
||||||
|
notices.append(
|
||||||
|
_('Two-factor authentication has been enabled.')
|
||||||
|
)
|
||||||
self.request.user.send_security_notice(notices)
|
self.request.user.send_security_notice(notices)
|
||||||
self.request.user.update_session_token()
|
self.request.user.update_session_token()
|
||||||
update_session_auth_hash(self.request, self.request.user)
|
update_session_auth_hash(self.request, self.request.user)
|
||||||
|
|
||||||
messages.success(request, str(_('The device has been verified and can now be used.')))
|
note = ''
|
||||||
if self.request.user.require_2fa:
|
if not self.request.user.require_2fa:
|
||||||
return redirect(reverse('control:user.settings.2fa'))
|
note = ' ' + str(_('Please note that you still need to enable two-factor authentication for your '
|
||||||
else:
|
'account using the buttons below to make a second factor required for logging '
|
||||||
return redirect(reverse('control:user.settings.2fa.enable'))
|
'into your account.'))
|
||||||
|
messages.success(request, str(_('The device has been verified and can now be used.')) + note)
|
||||||
|
return redirect(reverse('control:user.settings.2fa'))
|
||||||
else:
|
else:
|
||||||
messages.error(request, _('The code you entered was not valid. If this problem persists, please check '
|
messages.error(request, _('The code you entered was not valid. If this problem persists, please check '
|
||||||
'that the date and time of your phone are configured correctly.'))
|
'that the date and time of your phone are configured correctly.'))
|
||||||
@@ -576,7 +576,6 @@ class User2FALeaveTeamsView(RecentAuthenticationRequiredMixin, TemplateView):
|
|||||||
|
|
||||||
class User2FAEnableView(RecentAuthenticationRequiredMixin, TemplateView):
|
class User2FAEnableView(RecentAuthenticationRequiredMixin, TemplateView):
|
||||||
template_name = 'pretixcontrol/user/2fa_enable.html'
|
template_name = 'pretixcontrol/user/2fa_enable.html'
|
||||||
max_form_time = 7200 # this should have effectively no timeout, as the user might take some time to print out their emergency codes, and they would become invalid in case of a timeout
|
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
if not any(dt.objects.filter(user=self.request.user, confirmed=True) for dt in REAL_DEVICE_TYPES):
|
if not any(dt.objects.filter(user=self.request.user, confirmed=True) for dt in REAL_DEVICE_TYPES):
|
||||||
@@ -585,39 +584,14 @@ class User2FAEnableView(RecentAuthenticationRequiredMixin, TemplateView):
|
|||||||
return redirect(reverse('control:user.settings.2fa'))
|
return redirect(reverse('control:user.settings.2fa'))
|
||||||
return super().dispatch(request, *args, **kwargs)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
|
||||||
new_tokens = None
|
|
||||||
try:
|
|
||||||
static_tokens_device = StaticDevice.objects.get(user=self.request.user, name='emergency')
|
|
||||||
except StaticDevice.MultipleObjectsReturned:
|
|
||||||
static_tokens_device = StaticDevice.objects.filter(
|
|
||||||
user=self.request.user, name='emergency'
|
|
||||||
).first()
|
|
||||||
except StaticDevice.DoesNotExist:
|
|
||||||
static_tokens_device = None
|
|
||||||
|
|
||||||
new_tokens = [get_random_string(length=12, allowed_chars='1234567890') for _ in range(10)]
|
|
||||||
request.session['pretix_2fa_new_emergency_tokens'] = new_tokens
|
|
||||||
return super().get(request, *args, new_emergency_tokens=new_tokens, static_tokens_device=static_tokens_device, **kwargs)
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
notices = [
|
|
||||||
_('Two-factor authentication has been enabled.')
|
|
||||||
]
|
|
||||||
if 'pretix_2fa_new_emergency_tokens' in request.session:
|
|
||||||
d = StaticDevice.objects.create(user=self.request.user, name='emergency')
|
|
||||||
for code in request.session['pretix_2fa_new_emergency_tokens']:
|
|
||||||
d.token_set.create(token=code)
|
|
||||||
self.request.user.log_action('pretix.user.settings.2fa.regenemergency', user=self.request.user)
|
|
||||||
notices += [
|
|
||||||
_('Your two-factor emergency codes have been regenerated.')
|
|
||||||
]
|
|
||||||
del request.session['pretix_2fa_new_emergency_tokens']
|
|
||||||
self.request.user.require_2fa = True
|
self.request.user.require_2fa = True
|
||||||
self.request.user.save()
|
self.request.user.save()
|
||||||
self.request.user.log_action('pretix.user.settings.2fa.enabled', user=self.request.user)
|
self.request.user.log_action('pretix.user.settings.2fa.enabled', user=self.request.user)
|
||||||
messages.success(request, _('Two-factor authentication is now enabled for your account.'))
|
messages.success(request, _('Two-factor authentication is now enabled for your account.'))
|
||||||
self.request.user.send_security_notice(notices)
|
self.request.user.send_security_notice([
|
||||||
|
_('Two-factor authentication has been enabled.')
|
||||||
|
])
|
||||||
self.request.user.update_session_token()
|
self.request.user.update_session_token()
|
||||||
update_session_auth_hash(self.request, self.request.user)
|
update_session_auth_hash(self.request, self.request.user)
|
||||||
return redirect(reverse('control:user.settings.2fa'))
|
return redirect(reverse('control:user.settings.2fa'))
|
||||||
|
|||||||
@@ -19,12 +19,26 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
# <https://www.gnu.org/licenses/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
|
import ipaddress
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
import types
|
import types
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from http import cookies
|
from http import cookies
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from requests.adapters import HTTPAdapter
|
from requests.adapters import HTTPAdapter
|
||||||
|
from urllib3.connection import HTTPConnection, HTTPSConnection
|
||||||
|
from urllib3.connectionpool import HTTPConnectionPool, HTTPSConnectionPool
|
||||||
|
from urllib3.exceptions import (
|
||||||
|
ConnectTimeoutError, HTTPError, LocationParseError, NameResolutionError,
|
||||||
|
NewConnectionError,
|
||||||
|
)
|
||||||
|
from urllib3.util.connection import (
|
||||||
|
_TYPE_SOCKET_OPTIONS, _set_socket_options, allowed_gai_family,
|
||||||
|
)
|
||||||
|
from urllib3.util.timeout import _DEFAULT_TIMEOUT
|
||||||
|
|
||||||
|
|
||||||
def monkeypatch_vobject_performance():
|
def monkeypatch_vobject_performance():
|
||||||
@@ -89,6 +103,123 @@ def monkeypatch_requests_timeout():
|
|||||||
HTTPAdapter.send = httpadapter_send
|
HTTPAdapter.send = httpadapter_send
|
||||||
|
|
||||||
|
|
||||||
|
def monkeypatch_urllib3_ssrf_protection():
|
||||||
|
"""
|
||||||
|
pretix allows HTTP requests to untrusted URLs, e.g. through webhooks or external API URLs. This is dangerous since
|
||||||
|
it can allow access to private networks that should not be reachable by users ("server-side request forgery", SSRF).
|
||||||
|
Validating URLs at submission is not sufficient, since with DNS rebinding an attacker can make a domain name pass
|
||||||
|
validation and then resolve to a private IP address on actual execution. Unfortunately, there seems no clean solution
|
||||||
|
to this in Python land, so we monkeypatch urllib3's connection management to check the IP address to be external
|
||||||
|
*after* the DNS resolution.
|
||||||
|
|
||||||
|
This does not work when a global http(s) proxy is used, but in that scenario the proxy can perform the validation.
|
||||||
|
"""
|
||||||
|
if getattr(settings, "ALLOW_HTTP_TO_PRIVATE_NETWORKS", False):
|
||||||
|
# Settings are not supposed to change during runtime, so we can optimize performance and complexity by skipping
|
||||||
|
# this if not needed.
|
||||||
|
return
|
||||||
|
|
||||||
|
def create_connection(
|
||||||
|
address: tuple[str, int],
|
||||||
|
timeout=_DEFAULT_TIMEOUT,
|
||||||
|
source_address: tuple[str, int] | None = None,
|
||||||
|
socket_options: _TYPE_SOCKET_OPTIONS | None = None,
|
||||||
|
) -> socket.socket:
|
||||||
|
# This is copied from urllib3.util.connection v2.3.0
|
||||||
|
host, port = address
|
||||||
|
if host.startswith("["):
|
||||||
|
host = host.strip("[]")
|
||||||
|
err = None
|
||||||
|
|
||||||
|
# Using the value from allowed_gai_family() in the context of getaddrinfo lets
|
||||||
|
# us select whether to work with IPv4 DNS records, IPv6 records, or both.
|
||||||
|
# The original create_connection function always returns all records.
|
||||||
|
family = allowed_gai_family()
|
||||||
|
|
||||||
|
try:
|
||||||
|
host.encode("idna")
|
||||||
|
except UnicodeError:
|
||||||
|
raise LocationParseError(f"'{host}', label empty or too long") from None
|
||||||
|
|
||||||
|
for res in socket.getaddrinfo(host, port, family, socket.SOCK_STREAM):
|
||||||
|
af, socktype, proto, canonname, sa = res
|
||||||
|
|
||||||
|
if not getattr(settings, "ALLOW_HTTP_TO_PRIVATE_NETWORKS", False):
|
||||||
|
ip_addr = ipaddress.ip_address(sa[0])
|
||||||
|
if ip_addr.is_multicast:
|
||||||
|
raise HTTPError(f"Request to multicast address {sa[0]} blocked")
|
||||||
|
if ip_addr.is_loopback or ip_addr.is_link_local:
|
||||||
|
raise HTTPError(f"Request to local address {sa[0]} blocked")
|
||||||
|
if ip_addr.is_private:
|
||||||
|
raise HTTPError(f"Request to private address {sa[0]} blocked")
|
||||||
|
|
||||||
|
sock = None
|
||||||
|
try:
|
||||||
|
sock = socket.socket(af, socktype, proto)
|
||||||
|
|
||||||
|
# If provided, set socket level options before connecting.
|
||||||
|
_set_socket_options(sock, socket_options)
|
||||||
|
|
||||||
|
if timeout is not _DEFAULT_TIMEOUT:
|
||||||
|
sock.settimeout(timeout)
|
||||||
|
if source_address:
|
||||||
|
sock.bind(source_address)
|
||||||
|
sock.connect(sa)
|
||||||
|
# Break explicitly a reference cycle
|
||||||
|
err = None
|
||||||
|
return sock
|
||||||
|
|
||||||
|
except OSError as _:
|
||||||
|
err = _
|
||||||
|
if sock is not None:
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
if err is not None:
|
||||||
|
try:
|
||||||
|
raise err
|
||||||
|
finally:
|
||||||
|
# Break explicitly a reference cycle
|
||||||
|
err = None
|
||||||
|
else:
|
||||||
|
raise OSError("getaddrinfo returns an empty list")
|
||||||
|
|
||||||
|
class ProtectionMixin:
|
||||||
|
def _new_conn(self) -> socket.socket:
|
||||||
|
# This is 1:1 the version from urllib3.connection.HTTPConnection._new_conn v2.3.0
|
||||||
|
# just with a call to our own create_connection
|
||||||
|
try:
|
||||||
|
sock = create_connection(
|
||||||
|
(self._dns_host, self.port),
|
||||||
|
self.timeout,
|
||||||
|
source_address=self.source_address,
|
||||||
|
socket_options=self.socket_options,
|
||||||
|
)
|
||||||
|
except socket.gaierror as e:
|
||||||
|
raise NameResolutionError(self.host, self, e) from e
|
||||||
|
except socket.timeout as e:
|
||||||
|
raise ConnectTimeoutError(
|
||||||
|
self,
|
||||||
|
f"Connection to {self.host} timed out. (connect timeout={self.timeout})",
|
||||||
|
) from e
|
||||||
|
|
||||||
|
except OSError as e:
|
||||||
|
raise NewConnectionError(
|
||||||
|
self, f"Failed to establish a new connection: {e}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
sys.audit("http.client.connect", self, self.host, self.port)
|
||||||
|
return sock
|
||||||
|
|
||||||
|
class ProtectedHTTPConnection(ProtectionMixin, HTTPConnection):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ProtectedHTTPSConnection(ProtectionMixin, HTTPSConnection):
|
||||||
|
pass
|
||||||
|
|
||||||
|
HTTPConnectionPool.ConnectionCls = ProtectedHTTPConnection
|
||||||
|
HTTPSConnectionPool.ConnectionCls = ProtectedHTTPSConnection
|
||||||
|
|
||||||
|
|
||||||
def monkeypatch_cookie_morsel():
|
def monkeypatch_cookie_morsel():
|
||||||
# See https://code.djangoproject.com/ticket/34613
|
# See https://code.djangoproject.com/ticket/34613
|
||||||
cookies.Morsel._flags.add("partitioned")
|
cookies.Morsel._flags.add("partitioned")
|
||||||
@@ -99,4 +230,5 @@ def monkeypatch_all_at_ready():
|
|||||||
monkeypatch_vobject_performance()
|
monkeypatch_vobject_performance()
|
||||||
monkeypatch_pillow_safer()
|
monkeypatch_pillow_safer()
|
||||||
monkeypatch_requests_timeout()
|
monkeypatch_requests_timeout()
|
||||||
|
monkeypatch_urllib3_ssrf_protection()
|
||||||
monkeypatch_cookie_morsel()
|
monkeypatch_cookie_morsel()
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import time
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import login as auth_login
|
from django.contrib.auth import login as auth_login
|
||||||
from django.contrib.gis.geoip2 import GeoIP2
|
from django.contrib.gis import geoip2
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@@ -63,14 +63,20 @@ def get_user_agent_hash(request):
|
|||||||
_geoip = None
|
_geoip = None
|
||||||
|
|
||||||
|
|
||||||
def _get_country(request):
|
def get_geoip() -> geoip2.GeoIP2:
|
||||||
|
# See https://code.djangoproject.com/ticket/36988#ticket
|
||||||
global _geoip
|
global _geoip
|
||||||
|
|
||||||
if not _geoip:
|
geoip2.SUPPORTED_DATABASE_TYPES.add("Geoacumen-Country")
|
||||||
_geoip = GeoIP2()
|
|
||||||
|
|
||||||
|
if not _geoip:
|
||||||
|
_geoip = geoip2.GeoIP2()
|
||||||
|
return _geoip
|
||||||
|
|
||||||
|
|
||||||
|
def _get_country(request):
|
||||||
try:
|
try:
|
||||||
res = _geoip.country(get_client_ip(request))
|
res = get_geoip().country(get_client_ip(request))
|
||||||
except AddressNotFoundError:
|
except AddressNotFoundError:
|
||||||
return None
|
return None
|
||||||
return res['country_code']
|
return res['country_code']
|
||||||
|
|||||||
@@ -576,7 +576,7 @@ def filter_subevents_with_plugins(subevents, sales_channel=None):
|
|||||||
if not app_cache:
|
if not app_cache:
|
||||||
_populate_app_cache()
|
_populate_app_cache()
|
||||||
|
|
||||||
for receiver in filter_subevents._live_receivers(None):
|
for receiver in filter_subevents._live_receivers(None)[0]:
|
||||||
app = get_defining_app(receiver)
|
app = get_defining_app(receiver)
|
||||||
event_state = {}
|
event_state = {}
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ from django.views.decorators.cache import cache_page
|
|||||||
from django.views.decorators.gzip import gzip_page
|
from django.views.decorators.gzip import gzip_page
|
||||||
from django.views.decorators.http import condition
|
from django.views.decorators.http import condition
|
||||||
from django.views.i18n import (
|
from django.views.i18n import (
|
||||||
JavaScriptCatalog, get_formats, js_catalog_template,
|
JavaScriptCatalog, builtin_template_path, get_formats,
|
||||||
)
|
)
|
||||||
from lxml import html
|
from lxml import html
|
||||||
|
|
||||||
@@ -170,7 +170,8 @@ def generate_widget_js(version, lang):
|
|||||||
'September', 'October', 'November', 'December'
|
'September', 'October', 'November', 'December'
|
||||||
)
|
)
|
||||||
catalog = dict((k, v) for k, v in catalog.items() if k.startswith('widget\u0004') or k in str_wl)
|
catalog = dict((k, v) for k, v in catalog.items() if k.startswith('widget\u0004') or k in str_wl)
|
||||||
template = Engine().from_string(js_catalog_template)
|
with builtin_template_path("i18n_catalog.js").open(encoding="utf-8") as fh:
|
||||||
|
template = Engine().from_string(fh.read())
|
||||||
context = Context({
|
context = Context({
|
||||||
'catalog_str': indent(json.dumps(
|
'catalog_str': indent(json.dumps(
|
||||||
catalog, sort_keys=True, indent=2)) if catalog else None,
|
catalog, sort_keys=True, indent=2)) if catalog else None,
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ CSRF_TRUSTED_ORIGINS = [urlparse(SITE_URL).scheme + '://' + urlparse(SITE_URL).h
|
|||||||
|
|
||||||
TRUST_X_FORWARDED_FOR = config.getboolean('pretix', 'trust_x_forwarded_for', fallback=False)
|
TRUST_X_FORWARDED_FOR = config.getboolean('pretix', 'trust_x_forwarded_for', fallback=False)
|
||||||
USE_X_FORWARDED_HOST = config.getboolean('pretix', 'trust_x_forwarded_host', fallback=False)
|
USE_X_FORWARDED_HOST = config.getboolean('pretix', 'trust_x_forwarded_host', fallback=False)
|
||||||
|
ALLOW_HTTP_TO_PRIVATE_NETWORKS = config.getboolean('pretix', 'allow_http_to_private_networks', fallback=False)
|
||||||
|
|
||||||
|
|
||||||
REQUEST_ID_HEADER = config.get('pretix', 'request_id_header', fallback=False)
|
REQUEST_ID_HEADER = config.get('pretix', 'request_id_header', fallback=False)
|
||||||
@@ -248,7 +249,8 @@ EMAIL_HOST_PASSWORD = config.get('mail', 'password', fallback='')
|
|||||||
EMAIL_USE_TLS = config.getboolean('mail', 'tls', fallback=False)
|
EMAIL_USE_TLS = config.getboolean('mail', 'tls', fallback=False)
|
||||||
EMAIL_USE_SSL = config.getboolean('mail', 'ssl', fallback=False)
|
EMAIL_USE_SSL = config.getboolean('mail', 'ssl', fallback=False)
|
||||||
EMAIL_SUBJECT_PREFIX = '[pretix] '
|
EMAIL_SUBJECT_PREFIX = '[pretix] '
|
||||||
EMAIL_BACKEND = EMAIL_CUSTOM_SMTP_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
||||||
|
EMAIL_CUSTOM_SMTP_BACKEND = 'pretixbase.email.CheckPrivateNetworkSmtpBackend'
|
||||||
EMAIL_TIMEOUT = 60
|
EMAIL_TIMEOUT = 60
|
||||||
|
|
||||||
ADMINS = [('Admin', n) for n in config.get('mail', 'admins', fallback='').split(",") if n]
|
ADMINS = [('Admin', n) for n in config.get('mail', 'admins', fallback='').split(",") if n]
|
||||||
@@ -534,6 +536,7 @@ X_FRAME_OPTIONS = 'DENY'
|
|||||||
|
|
||||||
# URL settings
|
# URL settings
|
||||||
ROOT_URLCONF = 'pretix.multidomain.maindomain_urlconf'
|
ROOT_URLCONF = 'pretix.multidomain.maindomain_urlconf'
|
||||||
|
FORMS_URLFIELD_ASSUME_HTTPS = True # transitional for django 6.0
|
||||||
|
|
||||||
WSGI_APPLICATION = 'pretix.wsgi.application'
|
WSGI_APPLICATION = 'pretix.wsgi.application'
|
||||||
|
|
||||||
|
|||||||
+1
-3
@@ -23,9 +23,7 @@ filterwarnings =
|
|||||||
error
|
error
|
||||||
ignore:.*invalid escape sequence.*:
|
ignore:.*invalid escape sequence.*:
|
||||||
ignore:The 'warn' method is deprecated:DeprecationWarning
|
ignore:The 'warn' method is deprecated:DeprecationWarning
|
||||||
ignore::django.utils.deprecation.RemovedInDjango51Warning:django.core.files.storage
|
ignore::django.utils.deprecation.RemovedInDjango60Warning:
|
||||||
ignore:.*index_together.*:django.utils.deprecation.RemovedInDjango51Warning:
|
|
||||||
ignore:.*get_storage_class.*:django.utils.deprecation.RemovedInDjango51Warning:compressor
|
|
||||||
ignore:.*This signal will soon be only available for plugins that declare to be organizer-level.*:DeprecationWarning:
|
ignore:.*This signal will soon be only available for plugins that declare to be organizer-level.*:DeprecationWarning:
|
||||||
ignore::DeprecationWarning:mt940
|
ignore::DeprecationWarning:mt940
|
||||||
ignore::DeprecationWarning:cbor2
|
ignore::DeprecationWarning:cbor2
|
||||||
|
|||||||
@@ -35,8 +35,11 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import socket
|
||||||
|
from contextlib import contextmanager
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -591,3 +594,117 @@ def test_attached_ical_localization(env, order):
|
|||||||
assert len(djmail.outbox) == 1
|
assert len(djmail.outbox) == 1
|
||||||
assert len(djmail.outbox[0].attachments) == 1
|
assert len(djmail.outbox[0].attachments) == 1
|
||||||
assert description in djmail.outbox[0].attachments[0][1]
|
assert description in djmail.outbox[0].attachments[0][1]
|
||||||
|
|
||||||
|
|
||||||
|
PRIVATE_IPS_RES = [
|
||||||
|
[(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('10.0.0.3', 443))],
|
||||||
|
[(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('0.0.0.0', 443))],
|
||||||
|
[(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('127.1.1.1', 443))],
|
||||||
|
[(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('192.168.5.3', 443))],
|
||||||
|
[(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('224.0.0.1', 443))],
|
||||||
|
[(socket.AF_INET6, socket.SOCK_STREAM, 6, '', ('::1', 443, 0, 0))],
|
||||||
|
[(socket.AF_INET6, socket.SOCK_STREAM, 6, '', ('fe80::1', 443, 0, 0))],
|
||||||
|
[(socket.AF_INET6, socket.SOCK_STREAM, 6, '', ('ff00::1', 443, 0, 0))],
|
||||||
|
[(socket.AF_INET6, socket.SOCK_STREAM, 6, '', ('fc00::1', 443, 0, 0))],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def test_mail_connection(res, should_connect, use_ssl):
|
||||||
|
with (
|
||||||
|
mock.patch('socket.socket') as mock_socket,
|
||||||
|
mock.patch('socket.getaddrinfo', return_value=res),
|
||||||
|
mock.patch('smtplib.SMTP.getreply', return_value=(220, "")),
|
||||||
|
mock.patch('smtplib.SMTP.sendmail'),
|
||||||
|
mock.patch('ssl.SSLContext.wrap_socket') as mock_ssl
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
if should_connect:
|
||||||
|
mock_socket.assert_called_once()
|
||||||
|
mock_socket.return_value.connect.assert_called_once_with(res[0][-1])
|
||||||
|
if use_ssl:
|
||||||
|
mock_ssl.assert_called_once()
|
||||||
|
else:
|
||||||
|
mock_socket.assert_not_called()
|
||||||
|
mock_socket.return_value.connect.assert_not_called()
|
||||||
|
mock_ssl.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("res", PRIVATE_IPS_RES)
|
||||||
|
@pytest.mark.parametrize("use_ssl", [
|
||||||
|
True, False
|
||||||
|
])
|
||||||
|
def test_private_smtp_ip(res, use_ssl, settings):
|
||||||
|
settings.EMAIL_CUSTOM_SMTP_BACKEND = 'pretix.base.email.CheckPrivateNetworkSmtpBackend'
|
||||||
|
settings.MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS = False
|
||||||
|
with test_mail_connection(res=res, should_connect=False, use_ssl=use_ssl), pytest.raises(match="Request to .* blocked"):
|
||||||
|
connection = djmail.get_connection(backend=settings.EMAIL_CUSTOM_SMTP_BACKEND,
|
||||||
|
host="localhost",
|
||||||
|
use_ssl=use_ssl)
|
||||||
|
connection.open()
|
||||||
|
|
||||||
|
settings.MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS = True
|
||||||
|
with test_mail_connection(res=res, should_connect=True, use_ssl=use_ssl):
|
||||||
|
connection = djmail.get_connection(backend=settings.EMAIL_CUSTOM_SMTP_BACKEND,
|
||||||
|
host="localhost",
|
||||||
|
use_ssl=use_ssl)
|
||||||
|
connection.open()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("use_ssl", [
|
||||||
|
True, False
|
||||||
|
])
|
||||||
|
@pytest.mark.parametrize("allow_private", [
|
||||||
|
True, False
|
||||||
|
])
|
||||||
|
def test_public_smtp_ip(use_ssl, allow_private, settings):
|
||||||
|
settings.EMAIL_CUSTOM_SMTP_BACKEND = 'pretix.base.email.CheckPrivateNetworkSmtpBackend'
|
||||||
|
settings.MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS = allow_private
|
||||||
|
|
||||||
|
with test_mail_connection(res=[(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('8.8.8.8', 443))], should_connect=True, use_ssl=use_ssl):
|
||||||
|
connection = djmail.get_connection(backend=settings.EMAIL_CUSTOM_SMTP_BACKEND,
|
||||||
|
host="localhost",
|
||||||
|
use_ssl=use_ssl)
|
||||||
|
connection.open()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@pytest.mark.parametrize("use_ssl", [
|
||||||
|
True, False
|
||||||
|
])
|
||||||
|
@pytest.mark.parametrize("allow_private_networks", [
|
||||||
|
True, False
|
||||||
|
])
|
||||||
|
@pytest.mark.parametrize("res", PRIVATE_IPS_RES)
|
||||||
|
def test_send_mail_private_ip(res, use_ssl, allow_private_networks, env):
|
||||||
|
settings.EMAIL_CUSTOM_SMTP_BACKEND = 'pretix.base.email.CheckPrivateNetworkSmtpBackend'
|
||||||
|
settings.MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS = allow_private_networks
|
||||||
|
|
||||||
|
event, user, organizer = env
|
||||||
|
event.settings.smtp_use_custom = True
|
||||||
|
event.settings.smtp_host = "example.com"
|
||||||
|
event.settings.smtp_use_ssl = use_ssl
|
||||||
|
event.settings.smtp_use_tls = False
|
||||||
|
|
||||||
|
def send_mail():
|
||||||
|
m = OutgoingMail.objects.create(
|
||||||
|
to=['recipient@example.com'],
|
||||||
|
subject='Test',
|
||||||
|
body_plain='Test',
|
||||||
|
sender='sender@example.com',
|
||||||
|
event=event
|
||||||
|
)
|
||||||
|
assert m.status == OutgoingMail.STATUS_QUEUED
|
||||||
|
mail_send_task.apply(kwargs={
|
||||||
|
'outgoing_mail': m.pk,
|
||||||
|
}, max_retries=0)
|
||||||
|
m.refresh_from_db()
|
||||||
|
return m
|
||||||
|
|
||||||
|
with test_mail_connection(res=res, should_connect=allow_private_networks, use_ssl=use_ssl):
|
||||||
|
m = send_mail()
|
||||||
|
if allow_private_networks:
|
||||||
|
assert m.status == OutgoingMail.STATUS_SENT
|
||||||
|
else:
|
||||||
|
assert m.status == OutgoingMail.STATUS_FAILED
|
||||||
|
|||||||
@@ -19,14 +19,13 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
# <https://www.gnu.org/licenses/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
from pytz import UTC
|
|
||||||
|
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
Device, Event, Item, Organizer, Quota, SeatingPlan,
|
Device, Event, Item, Organizer, Quota, SeatingPlan,
|
||||||
@@ -53,7 +52,7 @@ def organizer():
|
|||||||
def event(organizer):
|
def event(organizer):
|
||||||
e = Event.objects.create(
|
e = Event.objects.create(
|
||||||
organizer=organizer, name='Dummy', slug='dummy',
|
organizer=organizer, name='Dummy', slug='dummy',
|
||||||
date_from=datetime(2017, 12, 27, 10, 0, 0, tzinfo=UTC),
|
date_from=datetime(2017, 12, 27, 10, 0, 0, tzinfo=timezone.utc),
|
||||||
presale_end=now() + timedelta(days=300),
|
presale_end=now() + timedelta(days=300),
|
||||||
plugins='pretix.plugins.banktransfer,pretix.plugins.ticketoutputpdf',
|
plugins='pretix.plugins.banktransfer,pretix.plugins.ticketoutputpdf',
|
||||||
is_public=True, live=True
|
is_public=True, live=True
|
||||||
@@ -109,8 +108,8 @@ def customer(event, membership_type):
|
|||||||
def membership(event, membership_type, customer):
|
def membership(event, membership_type, customer):
|
||||||
return customer.memberships.create(
|
return customer.memberships.create(
|
||||||
membership_type=membership_type,
|
membership_type=membership_type,
|
||||||
date_start=datetime(2017, 1, 1, 0, 0, tzinfo=UTC),
|
date_start=datetime(2017, 1, 1, 0, 0, tzinfo=timezone.utc),
|
||||||
date_end=datetime(2099, 1, 1, 0, 0, tzinfo=UTC),
|
date_end=datetime(2099, 1, 1, 0, 0, tzinfo=timezone.utc),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
#
|
||||||
|
# This file is part of pretix (Community Edition).
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||||
|
# Copyright (C) 2020-today pretix GmbH and contributors
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||||
|
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||||
|
#
|
||||||
|
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||||
|
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||||
|
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||||
|
# this file, see <https://pretix.eu/about/en/license>.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||||
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
|
# <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
from socket import AF_INET, SOCK_STREAM
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
from django.test import override_settings
|
||||||
|
from dns.inet import AF_INET6
|
||||||
|
from urllib3.exceptions import HTTPError
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_blocked():
|
||||||
|
with pytest.raises(HTTPError, match="Request to local address.*"):
|
||||||
|
requests.get("http://localhost", timeout=0.1)
|
||||||
|
with pytest.raises(HTTPError, match="Request to local address.*"):
|
||||||
|
requests.get("https://localhost", timeout=0.1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_private_ip_blocked():
|
||||||
|
with pytest.raises(HTTPError, match="Request to private address.*"):
|
||||||
|
requests.get("http://10.0.0.1", timeout=0.1)
|
||||||
|
with pytest.raises(HTTPError, match="Request to private address.*"):
|
||||||
|
requests.get("https://10.0.0.1", timeout=0.1)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@pytest.mark.parametrize("res", [
|
||||||
|
[(AF_INET, SOCK_STREAM, 6, '', ('10.0.0.3', 443))],
|
||||||
|
[(AF_INET, SOCK_STREAM, 6, '', ('0.0.0.0', 443))],
|
||||||
|
[(AF_INET, SOCK_STREAM, 6, '', ('127.1.1.1', 443))],
|
||||||
|
[(AF_INET, SOCK_STREAM, 6, '', ('192.168.5.3', 443))],
|
||||||
|
[(AF_INET, SOCK_STREAM, 6, '', ('224.0.0.1', 443))],
|
||||||
|
[(AF_INET6, SOCK_STREAM, 6, '', ('::1', 443, 0, 0))],
|
||||||
|
[(AF_INET6, SOCK_STREAM, 6, '', ('fe80::1', 443, 0, 0))],
|
||||||
|
[(AF_INET6, SOCK_STREAM, 6, '', ('ff00::1', 443, 0, 0))],
|
||||||
|
[(AF_INET6, SOCK_STREAM, 6, '', ('fc00::1', 443, 0, 0))],
|
||||||
|
])
|
||||||
|
def test_dns_resolving_to_local_blocked(res):
|
||||||
|
with mock.patch('socket.getaddrinfo') as mock_addr:
|
||||||
|
mock_addr.return_value = res
|
||||||
|
with pytest.raises(HTTPError, match="Request to (multicast|private|local) address.*"):
|
||||||
|
requests.get("https://example.org", timeout=0.1)
|
||||||
|
with pytest.raises(HTTPError, match="Request to (multicast|private|local) address.*"):
|
||||||
|
requests.get("http://example.org", timeout=0.1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dns_remote_allowed():
|
||||||
|
class SocketOk(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def side_effect(*args, **kwargs):
|
||||||
|
raise SocketOk
|
||||||
|
|
||||||
|
with mock.patch('socket.getaddrinfo') as mock_addr, mock.patch('socket.socket') as mock_socket:
|
||||||
|
mock_addr.return_value = [(AF_INET, SOCK_STREAM, 6, '', ('8.8.8.8', 443))]
|
||||||
|
mock_socket.side_effect = side_effect
|
||||||
|
with pytest.raises(SocketOk):
|
||||||
|
requests.get("https://example.org", timeout=0.1)
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ALLOW_HTTP_TO_PRIVATE_NETWORKS=True)
|
||||||
|
def test_local_is_allowed():
|
||||||
|
class SocketOk(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def side_effect(*args, **kwargs):
|
||||||
|
raise SocketOk
|
||||||
|
|
||||||
|
with mock.patch('socket.getaddrinfo') as mock_addr, mock.patch('socket.socket') as mock_socket:
|
||||||
|
mock_addr.return_value = [(AF_INET, SOCK_STREAM, 6, '', ('10.0.0.1', 443))]
|
||||||
|
mock_socket.side_effect = side_effect
|
||||||
|
with pytest.raises(SocketOk):
|
||||||
|
requests.get("https://example.org", timeout=0.1)
|
||||||
Reference in New Issue
Block a user