feat(core/auth): enable encryption for sensitive fields and token handling

Add encryption for user PII fields (phone number, name, attributes) and address fields to enhance data security. Introduced timestamped activation tokens for improved validation. Included migrations to encrypt existing plaintext data.

Refactored GraphQL settings to limit query depth and optionally disable introspection for enhanced API defense. Implemented throttling to safeguard API rates.

Improved Dockerfiles for better user management and restored media migration tools for smooth instance upgrades.
This commit is contained in:
Egor Pavlovich Gorbunov 2026-03-02 00:11:57 +03:00
parent c3d23be973
commit adec5503b2
23 changed files with 703 additions and 72 deletions

View file

@ -5,8 +5,7 @@ LABEL authors="fureunoir"
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
LANG=C.UTF-8 \
DEBIAN_FRONTEND=noninteractive \
PATH="/root/.local/bin:$PATH"
DEBIAN_FRONTEND=noninteractive
WORKDIR /app
@ -33,18 +32,16 @@ RUN set -eux; \
rm -rf /var/lib/apt/lists/*; \
pip install --upgrade pip
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
ENV PATH="/root/.local/bin:/root/.cargo/bin:$PATH"
RUN curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh
RUN uv venv /opt/schon-python
ENV VIRTUAL_ENV=/opt/schon-python
ENV UV_PROJECT_ENVIRONMENT=/opt/schon-python
ENV PATH="/opt/schon-python/bin:/root/.local/bin:/root/.cargo/bin:$PATH"
ENV PATH="/opt/schon-python/bin:/usr/local/bin:$PATH"
COPY pyproject.toml pyproject.toml
COPY uv.lock uv.lock
RUN set -eux; \
RUN uv venv /opt/schon-python && \
uv sync --extra worker --extra openai --locked
COPY ./scripts/Docker/app-entrypoint.sh /usr/local/bin/app-entrypoint.sh
@ -52,4 +49,11 @@ RUN chmod +x /usr/local/bin/app-entrypoint.sh
COPY . .
RUN groupadd --system --gid 1000 schon && \
useradd --system --uid 1000 --gid schon --shell /bin/bash --create-home schon && \
mkdir -p /app/static /app/media && \
chown -R schon:schon /app /opt/schon-python
USER schon
ENTRYPOINT ["/usr/bin/bash", "app-entrypoint.sh"]

View file

@ -5,8 +5,7 @@ LABEL authors="fureunoir"
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
LANG=C.UTF-8 \
DEBIAN_FRONTEND=noninteractive \
PATH="/root/.local/bin:$PATH"
DEBIAN_FRONTEND=noninteractive
WORKDIR /app
@ -33,17 +32,16 @@ RUN set -eux; \
rm -rf /var/lib/apt/lists/*; \
pip install --upgrade pip
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
ENV PATH="/root/.local/bin:/root/.cargo/bin:$PATH"
RUN uv venv /opt/schon-python
RUN curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh
ENV VIRTUAL_ENV=/opt/schon-python
ENV UV_PROJECT_ENVIRONMENT=/opt/schon-python
ENV PATH="/opt/schon-python/bin:/root/.local/bin:/root/.cargo/bin:$PATH"
ENV PATH="/opt/schon-python/bin:/usr/local/bin:$PATH"
COPY pyproject.toml pyproject.toml
COPY uv.lock uv.lock
RUN set -eux; \
RUN uv venv /opt/schon-python && \
uv sync --extra worker --extra openai --locked
COPY ./scripts/Docker/beat-entrypoint.sh /usr/local/bin/beat-entrypoint.sh
@ -51,4 +49,11 @@ RUN chmod +x /usr/local/bin/beat-entrypoint.sh
COPY . .
RUN groupadd --system --gid 1000 schon && \
useradd --system --uid 1000 --gid schon --shell /bin/bash --create-home schon && \
mkdir -p /app/media && \
chown -R schon:schon /app /opt/schon-python
USER schon
ENTRYPOINT ["/usr/bin/bash", "beat-entrypoint.sh"]

View file

@ -5,8 +5,7 @@ LABEL authors="fureunoir"
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
LANG=C.UTF-8 \
DEBIAN_FRONTEND=noninteractive \
PATH="/root/.local/bin:$PATH"
DEBIAN_FRONTEND=noninteractive
WORKDIR /app
@ -33,17 +32,16 @@ RUN set -eux; \
rm -rf /var/lib/apt/lists/*; \
pip install --upgrade pip
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
ENV PATH="/root/.local/bin:/root/.cargo/bin:$PATH"
RUN uv venv /opt/schon-python
RUN curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh
ENV VIRTUAL_ENV=/opt/schon-python
ENV UV_PROJECT_ENVIRONMENT=/opt/schon-python
ENV PATH="/opt/schon-python/bin:/root/.local/bin:/root/.cargo/bin:$PATH"
ENV PATH="/opt/schon-python/bin:/usr/local/bin:$PATH"
COPY pyproject.toml pyproject.toml
COPY uv.lock uv.lock
RUN set -eux; \
RUN uv venv /opt/schon-python && \
uv sync --extra worker --extra openai --locked
COPY ./scripts/Docker/stock-updater-entrypoint.sh /usr/local/bin/stock-updater-entrypoint.sh
@ -51,4 +49,11 @@ RUN chmod +x /usr/local/bin/stock-updater-entrypoint.sh
COPY . .
RUN groupadd --system --gid 1000 schon && \
useradd --system --uid 1000 --gid schon --shell /bin/bash --create-home schon && \
mkdir -p /app/media && \
chown -R schon:schon /app /opt/schon-python
USER schon
ENTRYPOINT ["/usr/bin/bash", "stock-updater-entrypoint.sh"]

View file

@ -5,8 +5,7 @@ LABEL authors="fureunoir"
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
LANG=C.UTF-8 \
DEBIAN_FRONTEND=noninteractive \
PATH="/root/.local/bin:$PATH"
DEBIAN_FRONTEND=noninteractive
WORKDIR /app
@ -33,18 +32,16 @@ RUN set -eux; \
rm -rf /var/lib/apt/lists/*; \
pip install --upgrade pip
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
ENV PATH="/root/.local/bin:/root/.cargo/bin:$PATH"
RUN curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh
RUN uv venv /opt/schon-python
ENV VIRTUAL_ENV=/opt/schon-python
ENV UV_PROJECT_ENVIRONMENT=/opt/schon-python
ENV PATH="/opt/schon-python/bin:/root/.local/bin:/root/.cargo/bin:$PATH"
ENV PATH="/opt/schon-python/bin:/usr/local/bin:$PATH"
COPY pyproject.toml pyproject.toml
COPY uv.lock uv.lock
RUN set -eux; \
RUN uv venv /opt/schon-python && \
uv sync --extra worker --extra openai --locked
COPY ./scripts/Docker/worker-entrypoint.sh /usr/local/bin/worker-entrypoint.sh
@ -52,4 +49,11 @@ RUN chmod +x /usr/local/bin/worker-entrypoint.sh
COPY . .
RUN groupadd --system --gid 1000 schon && \
useradd --system --uid 1000 --gid schon --shell /bin/bash --create-home schon && \
mkdir -p /app/media && \
chown -R schon:schon /app /opt/schon-python
USER schon
ENTRYPOINT ["/usr/bin/bash", "worker-entrypoint.sh"]

View file

@ -13,6 +13,8 @@ services:
restart: always
volumes:
- .:/app
- static-data:/app/static
- media-data:/app/media
ports:
- "8000:8000"
env_file:
@ -131,6 +133,7 @@ services:
restart: always
volumes:
- .:/app
- media-data:/app/media
env_file:
- .env
environment:
@ -159,6 +162,7 @@ services:
restart: always
volumes:
- .:/app
- media-data:/app/media
env_file:
- .env
environment:
@ -187,6 +191,7 @@ services:
restart: always
volumes:
- .:/app
- media-data:/app/media
env_file:
- .env
environment:
@ -221,3 +226,5 @@ volumes:
redis-data:
es-data:
prometheus-data:
static-data:
media-data:

View file

@ -1046,16 +1046,10 @@ class AddressAdmin(DjangoQLSearchMixin, FieldsetsMixin, GISModelAdmin):
"country",
"user",
)
list_filter = (
"country",
"region",
)
search_fields = (
"street",
"city",
"postal_code",
"user__email",
)
# country and region are encrypted — DB-level filtering is not possible
list_filter = ()
# street, city, postal_code are encrypted — DB-level search is not possible
search_fields = ("user__email",)
readonly_fields = (
"uuid",
"modified",

View file

@ -0,0 +1,186 @@
import base64
import encrypted_fields.fields
from cryptography.fernet import Fernet, MultiFernet
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from django.db import migrations, models
def _make_fernet(settings):
keys = []
salt_keys = (
settings.SALT_KEY
if isinstance(settings.SALT_KEY, list)
else [settings.SALT_KEY]
)
for secret_key in [settings.SECRET_KEY] + list(
getattr(settings, "SECRET_KEY_FALLBACKS", [])
):
for salt_key in salt_keys:
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt_key.encode("utf-8"),
iterations=100_000,
backend=default_backend(),
)
keys.append(
base64.urlsafe_b64encode(kdf.derive(secret_key.encode("utf-8")))
)
return MultiFernet([Fernet(k) for k in keys]) if len(keys) > 1 else Fernet(keys[0])
def encrypt_address_fields(apps, schema_editor):
import json
from django.conf import settings
f = _make_fernet(settings)
def enc(value):
if value is None:
return None
if not isinstance(value, str):
value = str(value)
return f.encrypt(value.encode("utf-8")).decode("utf-8")
def enc_json(value):
if value is None:
return None
if not isinstance(value, str):
value = json.dumps(value, default=str)
return f.encrypt(value.encode("utf-8")).decode("utf-8")
with schema_editor.connection.cursor() as cursor:
cursor.execute(
"SELECT uuid, address_line, street, district, city, region, "
"postal_code, country, raw_data, api_response FROM core_address"
)
rows = cursor.fetchall()
for (
row_id,
address_line,
street,
district,
city,
region,
postal_code,
country,
raw_data,
api_response,
) in rows:
cursor.execute(
"UPDATE core_address SET "
"address_line=%s, street=%s, district=%s, city=%s, region=%s, "
"postal_code=%s, country=%s, raw_data=%s, api_response=%s "
"WHERE uuid=%s",
[
enc(address_line),
enc(street),
enc(district),
enc(city),
enc(region),
enc(postal_code),
enc(country),
enc_json(raw_data),
enc_json(api_response),
row_id,
],
)
class Migration(migrations.Migration):
dependencies = [
("core", "0056_pastedimage"),
]
operations = [
# Encrypt text fields
migrations.AlterField(
model_name="address",
name="address_line",
field=encrypted_fields.fields.EncryptedTextField(
blank=True,
null=True,
help_text="address line for the customer",
verbose_name="address line",
),
),
migrations.AlterField(
model_name="address",
name="street",
field=encrypted_fields.fields.EncryptedCharField(
max_length=255,
null=True,
verbose_name="street",
),
),
migrations.AlterField(
model_name="address",
name="district",
field=encrypted_fields.fields.EncryptedCharField(
max_length=255,
null=True,
verbose_name="district",
),
),
migrations.AlterField(
model_name="address",
name="city",
field=encrypted_fields.fields.EncryptedCharField(
max_length=100,
null=True,
verbose_name="city",
),
),
migrations.AlterField(
model_name="address",
name="region",
field=encrypted_fields.fields.EncryptedCharField(
max_length=100,
null=True,
verbose_name="region",
),
),
migrations.AlterField(
model_name="address",
name="postal_code",
field=encrypted_fields.fields.EncryptedCharField(
max_length=20,
null=True,
verbose_name="postal code",
),
),
migrations.AlterField(
model_name="address",
name="country",
field=encrypted_fields.fields.EncryptedCharField(
max_length=40,
null=True,
verbose_name="country",
),
),
# JSON fields: JSONB → TEXT (encrypted JSON string)
migrations.AlterField(
model_name="address",
name="raw_data",
field=models.TextField(
blank=True,
null=True,
help_text="full JSON response from geocoder for this address",
),
),
migrations.AlterField(
model_name="address",
name="api_response",
field=models.TextField(
blank=True,
null=True,
help_text="stored JSON response from the geocoding service",
),
),
# Re-encrypt all existing plaintext values
migrations.RunPython(encrypt_address_fields, migrations.RunPython.noop),
]

View file

@ -47,6 +47,7 @@ from django.utils.functional import cached_property
from django.utils.http import urlsafe_base64_encode
from django.utils.translation import gettext_lazy as _
from django_extensions.db.fields import AutoSlugField
from encrypted_fields.fields import EncryptedCharField, EncryptedTextField
from mptt.fields import TreeForeignKey
from mptt.models import MPTTModel
@ -72,6 +73,7 @@ from engine.core.utils.lists import FAILED_STATUSES
from engine.core.utils.markdown import strip_markdown
from engine.core.validators import validate_category_image_dimensions
from engine.payments.models import Transaction
from schon.fields import EncryptedJSONTextField
from schon.utils.misc import create_object
if TYPE_CHECKING:
@ -1133,18 +1135,18 @@ class Address(NiceModel):
is_publicly_visible = False
address_line = TextField(
address_line = EncryptedTextField(
blank=True,
null=True,
help_text=_("address line for the customer"),
verbose_name=_("address line"),
)
street = CharField(_("street"), max_length=255, null=True)
district = CharField(_("district"), max_length=255, null=True)
city = CharField(_("city"), max_length=100, null=True)
region = CharField(_("region"), max_length=100, null=True)
postal_code = CharField(_("postal code"), max_length=20, null=True)
country = CharField(_("country"), max_length=40, null=True)
street = EncryptedCharField(_("street"), max_length=255, null=True)
district = EncryptedCharField(_("district"), max_length=255, null=True)
city = EncryptedCharField(_("city"), max_length=100, null=True)
region = EncryptedCharField(_("region"), max_length=100, null=True)
postal_code = EncryptedCharField(_("postal code"), max_length=20, null=True)
country = EncryptedCharField(_("country"), max_length=40, null=True)
location: PointField = PointField(
geography=True,
@ -1154,13 +1156,13 @@ class Address(NiceModel):
help_text=_("geolocation point: (longitude, latitude)"),
)
raw_data = JSONField(
raw_data = EncryptedJSONTextField(
blank=True,
null=True,
help_text=_("full JSON response from geocoder for this address"),
)
api_response = JSONField(
api_response = EncryptedJSONTextField(
blank=True,
null=True,
help_text=_("stored JSON response from the geocoding service"),

View file

@ -4,6 +4,7 @@ import os
import traceback
from contextlib import suppress
from datetime import date, timedelta
from os import getenv
import requests
from constance import config
@ -35,6 +36,7 @@ from django_ratelimit.decorators import ratelimit
from drf_spectacular.utils import extend_schema_view
from drf_spectacular.views import SpectacularAPIView
from graphene_file_upload.django import FileUploadGraphQLView
from graphql.validation import NoSchemaIntrospectionCustomRule
from rest_framework import status
from rest_framework.parsers import MultiPartParser
from rest_framework.permissions import AllowAny, IsAdminUser
@ -85,6 +87,7 @@ from engine.core.utils.commerce import (
from engine.core.utils.emailing import contact_us_email
from engine.core.utils.languages import get_flag_by_language
from engine.payments.serializers import TransactionProcessSerializer
from schon.graphql_validators import QueryDepthLimitRule
from schon.utils.renderers import camelize
logger = logging.getLogger(__name__)
@ -121,7 +124,14 @@ sitemap_detail.__doc__ = _( # ty:ignore[invalid-assignment]
)
_graphql_validation_rules = [QueryDepthLimitRule]
if getenv("GRAPHQL_INTROSPECTION", "").lower() in ("1", "true", "yes"):
_graphql_validation_rules.append(NoSchemaIntrospectionCustomRule)
class CustomGraphQLView(FileUploadGraphQLView):
validation_rules = tuple(_graphql_validation_rules)
def get_context(self, request):
return request

View file

@ -1221,7 +1221,7 @@ class AddressViewSet(SchonViewSet):
filterset_class = AddressFilter
queryset = Address.objects.all()
serializer_class = AddressSerializer
additional = {"create": "ALLOW", "retrieve": "ALLOW"}
additional = {"create": "ALLOW"}
def get_serializer_class(self):
if self.action == "create":
@ -1239,15 +1239,6 @@ class AddressViewSet(SchonViewSet):
return Address.objects.none()
def retrieve(self, request: Request, *args, **kwargs) -> Response:
try:
address = Address.objects.get(uuid=str(kwargs.get("pk")))
return Response(
status=status.HTTP_200_OK, data=self.get_serializer(address).data
)
except Address.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
def create(self, request: Request, *args, **kwargs) -> Response:
create_serializer = AddressCreateSerializer(
data=request.data, context={"request": request}

View file

@ -110,7 +110,8 @@ class UserAdmin(ActivationActionsMixin, BaseUserAdmin, ModelAdmin):
),
)
list_display = ("email", "phone_number", "is_verified", "is_active", "is_staff")
search_fields = ("email", "phone_number")
# phone_number is encrypted — DB-level search is not possible for it
search_fields = ("email",)
list_filter = (
"is_verified",
"is_active",

View file

@ -0,0 +1,136 @@
import base64
import encrypted_fields.fields
from cryptography.fernet import Fernet, MultiFernet
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from django.db import migrations, models
def _make_fernet(settings):
keys = []
salt_keys = (
settings.SALT_KEY
if isinstance(settings.SALT_KEY, list)
else [settings.SALT_KEY]
)
for secret_key in [settings.SECRET_KEY] + list(
getattr(settings, "SECRET_KEY_FALLBACKS", [])
):
for salt_key in salt_keys:
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt_key.encode("utf-8"),
iterations=100_000,
backend=default_backend(),
)
keys.append(
base64.urlsafe_b64encode(kdf.derive(secret_key.encode("utf-8")))
)
return MultiFernet([Fernet(k) for k in keys]) if len(keys) > 1 else Fernet(keys[0])
def encrypt_user_pii(apps, schema_editor):
import json
from django.conf import settings
f = _make_fernet(settings)
def enc(value):
if value is None:
return None
if not isinstance(value, str):
value = str(value)
return f.encrypt(value.encode("utf-8")).decode("utf-8")
def enc_json(value):
if value is None:
return None
# value may be a Python dict (from JSONB) or a JSON string (after TEXT cast)
if not isinstance(value, str):
value = json.dumps(value, default=str)
return f.encrypt(value.encode("utf-8")).decode("utf-8")
with schema_editor.connection.cursor() as cursor:
cursor.execute(
"SELECT uuid, phone_number, first_name, last_name, attributes "
"FROM vibes_auth_user"
)
rows = cursor.fetchall()
for row_id, phone, first, last, attrs in rows:
cursor.execute(
"UPDATE vibes_auth_user "
"SET phone_number=%s, first_name=%s, last_name=%s, attributes=%s "
"WHERE uuid=%s",
[enc(phone), enc(first), enc(last), enc_json(attrs), row_id],
)
class Migration(migrations.Migration):
dependencies = [
(
"vibes_auth",
"0009_delete_emailimage_remove_emailtemplate_html_content_and_more",
),
]
operations = [
# Add activation token timestamp
migrations.AddField(
model_name="user",
name="activation_token_created",
field=models.DateTimeField(
blank=True,
null=True,
verbose_name="activation token created",
),
),
# Encrypt phone_number (also drops unique constraint)
migrations.AlterField(
model_name="user",
name="phone_number",
field=encrypted_fields.fields.EncryptedCharField(
blank=True,
max_length=20,
null=True,
verbose_name="phone_number",
),
),
# Encrypt first_name
migrations.AlterField(
model_name="user",
name="first_name",
field=encrypted_fields.fields.EncryptedCharField(
blank=True,
max_length=150,
null=True,
verbose_name="first_name",
),
),
# Encrypt last_name
migrations.AlterField(
model_name="user",
name="last_name",
field=encrypted_fields.fields.EncryptedCharField(
blank=True,
max_length=150,
null=True,
verbose_name="last_name",
),
),
# Encrypt attributes (JSONB → TEXT with JSON serialisation)
migrations.AlterField(
model_name="user",
name="attributes",
field=models.TextField(
blank=True,
null=True,
verbose_name="attributes",
),
),
# Re-encrypt existing plaintext values using raw SQL
migrations.RunPython(encrypt_user_pii, migrations.RunPython.noop),
]

View file

@ -23,6 +23,7 @@ from django.templatetags.static import static
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from encrypted_fields.fields import EncryptedCharField
from rest_framework_simplejwt.token_blacklist.models import (
BlacklistedToken as BaseBlacklistedToken,
)
@ -35,6 +36,7 @@ from engine.payments.models import Balance
from engine.vibes_auth.choices import SenderType, ThreadStatus
from engine.vibes_auth.managers import UserManager
from engine.vibes_auth.validators import validate_phone_number
from schon.fields import EncryptedJSONTextField
class User(AbstractUser, NiceModel):
@ -51,10 +53,9 @@ class User(AbstractUser, NiceModel):
return "users/" + str(self.uuid) + "/" + args[0]
email = EmailField(_("email"), unique=True, help_text=_("user email address"))
phone_number = CharField(
phone_number = EncryptedCharField(
_("phone_number"),
max_length=20,
unique=True,
blank=True,
null=True,
help_text=_("user phone number"),
@ -63,8 +64,12 @@ class User(AbstractUser, NiceModel):
],
)
username: None = None
first_name = CharField(_("first_name"), max_length=150, blank=True, null=True)
last_name = CharField(_("last_name"), max_length=150, blank=True, null=True)
first_name = EncryptedCharField(
_("first_name"), max_length=150, blank=True, null=True
)
last_name = EncryptedCharField(
_("last_name"), max_length=150, blank=True, null=True
)
avatar = ImageField(
null=True,
verbose_name=_("avatar"),
@ -90,6 +95,11 @@ class User(AbstractUser, NiceModel):
)
activation_token = UUIDField(default=uuid4, verbose_name=_("activation token"))
activation_token_created = DateTimeField(
null=True,
blank=True,
verbose_name=_("activation token created"),
)
unsubscribe_token = UUIDField(
default=uuid4,
verbose_name=_("unsubscribe token"),
@ -102,7 +112,7 @@ class User(AbstractUser, NiceModel):
blank=False,
max_length=7,
)
attributes = JSONField(
attributes = EncryptedJSONTextField(
verbose_name=_("attributes"), default=dict, blank=True, null=True
)
@ -135,8 +145,25 @@ class User(AbstractUser, NiceModel):
def recently_viewed(self):
return cache.get(f"user_{self.uuid}_rv", [])
def check_token(self, token):
return str(token) == str(self.activation_token)
def save(self, *args, **kwargs):
if self._state.adding and self.activation_token_created is None:
self.activation_token_created = timezone.now()
super().save(*args, **kwargs)
def refresh_activation_token(self) -> None:
"""Generate a fresh activation token and update its timestamp."""
self.activation_token = uuid4()
self.activation_token_created = timezone.now()
def check_token(self, token) -> bool:
from datetime import timedelta
if str(token) != str(self.activation_token):
return False
if self.activation_token_created:
if timezone.now() > self.activation_token_created + timedelta(hours=24):
return False
return True
def __str__(self):
return self.email

View file

@ -26,7 +26,10 @@ from engine.vibes_auth.serializers import (
MergeRecentlyViewedSerializer,
UserSerializer,
)
from engine.vibes_auth.utils.emailing import send_reset_password_email_task
from engine.vibes_auth.utils.emailing import (
send_reset_password_email_task,
send_verification_email_task,
)
logger = logging.getLogger(__name__)
@ -130,6 +133,23 @@ class UserViewSet(
ratelimit(key="ip", rate="5/h" if not settings.DEBUG else "888/h")
)
def create(self, request: Request, *args, **kwargs) -> Response:
email = request.data.get("email")
if email:
with suppress(User.DoesNotExist):
pending = User.objects.get(
email=email, is_active=False, is_verified=False
)
pending.refresh_activation_token()
pending.save()
send_verification_email_task.delay(user_pk=str(pending.uuid))
return Response(
{
"detail": _(
"Account already registered but not yet activated. A new activation email has been sent."
)
},
status=status.HTTP_200_OK,
)
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.save()

43
schon/fields.py Normal file
View file

@ -0,0 +1,43 @@
import json
from encrypted_fields.fields import EncryptedTextField
class EncryptedJSONTextField(EncryptedTextField):
"""
Stores a JSON-serializable value as Fernet-encrypted TEXT.
Unlike EncryptedJSONField (which uses JSONB and breaks with psycopg3's
automatic JSONBdict conversion), this field stores the JSON as a plain
TEXT column, encrypting the full serialised string. The column type in
the DB is text, not jsonb.
"""
def get_internal_type(self) -> str:
return "TextField"
def get_prep_value(self, value):
if value is not None and not isinstance(value, str):
value = json.dumps(value, default=str)
return super().get_prep_value(value)
def from_db_value(self, value, expression, connection):
value = super().from_db_value(value, expression, connection)
if value is None:
return None
if isinstance(value, str):
try:
return json.loads(value)
except (ValueError, TypeError):
pass
return value
def to_python(self, value):
if isinstance(value, (dict, list)):
return value
if isinstance(value, str):
try:
return json.loads(value)
except (ValueError, TypeError):
pass
return value

View file

@ -0,0 +1,56 @@
from graphql import GraphQLError
from graphql.language.ast import (
FieldNode,
FragmentDefinitionNode,
FragmentSpreadNode,
InlineFragmentNode,
OperationDefinitionNode,
)
from graphql.validation import ValidationRule
MAX_QUERY_DEPTH = 8
def _max_field_depth(node, fragments, depth=0):
if not hasattr(node, "selection_set") or not node.selection_set:
return depth
return max(
(
_selection_depth(sel, fragments, depth)
for sel in node.selection_set.selections
),
default=depth,
)
def _selection_depth(node, fragments, depth):
if isinstance(node, FieldNode):
return _max_field_depth(node, fragments, depth + 1)
if isinstance(node, InlineFragmentNode):
return _max_field_depth(node, fragments, depth)
if isinstance(node, FragmentSpreadNode):
fragment = fragments.get(node.name.value)
if fragment:
return _max_field_depth(fragment, fragments, depth)
return depth
class QueryDepthLimitRule(ValidationRule):
"""Prevents DoS via deeply nested GraphQL queries (max depth: 8)."""
def enter_document(self, node, *_args):
fragments = {
defn.name.value: defn
for defn in node.definitions
if isinstance(defn, FragmentDefinitionNode)
}
for defn in node.definitions:
if not isinstance(defn, OperationDefinitionNode):
continue
depth = _max_field_depth(defn, fragments)
if depth > MAX_QUERY_DEPTH:
self.report_error(
GraphQLError(
f"Query depth limit exceeded: max {MAX_QUERY_DEPTH}, got {depth}."
)
)

View file

@ -20,6 +20,7 @@ BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent
INITIALIZED: bool = (BASE_DIR / ".initialized").exists()
SECRET_KEY: str = getenv("SECRET_KEY", "SUPER_SECRET_KEY")
SALT_KEY: str = getenv("SALT_KEY", "schon-default-salt-key-change-in-production")
DEBUG: bool = bool(int(getenv("DEBUG", "1")))
DEBUG_DATABASE: bool = bool(int(getenv("DEBUG_DATABASE", "0")))
DEBUG_CELERY: bool = bool(int(getenv("DEBUG_DATABASE", "0")))
@ -422,8 +423,24 @@ if getenv("SENTRY_DSN"):
if isinstance(data, dict):
# noinspection PyShadowingNames
cleaned: dict[str, Any] = {}
_SENSITIVE_KEYS = {
"password",
"confirm_password",
"phone_number",
"phone",
"email",
"street",
"postal_code",
"postal",
"passport",
"secret",
"token",
"address",
"first_name",
"last_name",
}
for key, value in data.items():
if key.lower() in ("password", "confirm_password"):
if key.lower() in _SENSITIVE_KEYS:
cleaned[key] = "[FILTERED]"
else:
cleaned[key] = scrub_sensitive(value)

View file

@ -16,6 +16,14 @@ CONSTANCE_ADDITIONAL_FIELDS = {
"widget": "engine.core.widgets.JSONTableWidget",
},
],
"password": [
"django.forms.CharField",
{
"required": False,
"widget": "django.forms.PasswordInput",
"widget_attrs": {"render_value": True},
},
],
}
CONSTANCE_CONFIG = OrderedDict(
@ -67,7 +75,11 @@ CONSTANCE_CONFIG = OrderedDict(
),
(
"EMAIL_HOST_PASSWORD",
(getenv("EMAIL_HOST_PASSWORD", "SUPERsecretPASSWORD"), _("SMTP password")),
(
getenv("EMAIL_HOST_PASSWORD", "SUPERsecretPASSWORD"),
_("SMTP password"),
"password",
),
),
("EMAIL_FROM", (getenv("EMAIL_FROM", "Schon"), _("Mail from option"))),
### Features Options ###

View file

@ -26,6 +26,14 @@ REST_FRAMEWORK: dict[str, Any] = {
"DEFAULT_PARSER_CLASSES": ("schon.utils.parsers.CamelCaseParser",),
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.generators.AutoSchema",
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.AllowAny",),
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.AnonRateThrottle",
"rest_framework.throttling.UserRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {
"anon": "60/minute",
"user": "600/minute",
},
"JSON_UNDERSCOREIZE": {
"no_underscore_before_number": False,
},

View file

@ -53,6 +53,7 @@ SCHON_LANGUAGE_CODE=$(prompt_default SCHON_LANGUAGE_CODE "en-gb")
SECRET_KEY=$(prompt_autogen SECRET_KEY 32)
JWT_SIGNING_KEY=$(prompt_autogen JWT_SIGNING_KEY 64)
SALT_KEY=$(prompt_autogen SALT_KEY 32)
ALLOWED_HOSTS=$(prompt_default ALLOWED_HOSTS "schon.wiseless.xyz api.schon.wiseless.xyz")
CSRF_TRUSTED_ORIGINS=$(prompt_default CSRF_TRUSTED_ORIGINS "https://schon.wiseless.xyz https://api.schon.wiseless.xyz https://www.schon.wiseless.xyz")
@ -101,6 +102,7 @@ SCHON_LANGUAGE_CODE="${SCHON_LANGUAGE_CODE}"
SECRET_KEY="${SECRET_KEY}"
JWT_SIGNING_KEY="${JWT_SIGNING_KEY}"
SALT_KEY="${SALT_KEY}"
ALLOWED_HOSTS="${ALLOWED_HOSTS}"
CSRF_TRUSTED_ORIGINS="${CSRF_TRUSTED_ORIGINS}"

View file

@ -0,0 +1,48 @@
#!/usr/bin/env bash
# =============================================================================
# migrate-media.sh
# Migrates user-uploaded media files from host bind-mount (eVibes / early Schon)
# into the new Docker-managed named volume (media-data).
#
# Run this ONCE after upgrading from an eVibes or pre-volume Schon instance.
# =============================================================================
set -euo pipefail
HOST_MEDIA="$(pwd)/media"
echo "Schon — media migration from host bind-mount → named volume"
echo ""
if [ ! -d "$HOST_MEDIA" ]; then
echo "No ./media directory found on host. Nothing to migrate."
exit 0
fi
FILE_COUNT=$(find "$HOST_MEDIA" -type f | wc -l | tr -d ' ')
if [ "$FILE_COUNT" -eq 0 ]; then
echo "Host ./media directory is empty. Nothing to migrate."
exit 0
fi
echo "Found $FILE_COUNT file(s) in $HOST_MEDIA"
echo ""
echo "This will copy them into the Docker named volume 'media-data'."
read -rp "Continue? [y/N] " confirm
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
echo "Migration cancelled."
exit 0
fi
echo ""
echo "Copying files..."
docker compose run --rm \
-v "$HOST_MEDIA":/old_media:ro \
app bash -c "
cp -a /old_media/. /app/media/
COUNT=\$(find /app/media -type f | wc -l)
echo \"Migration complete: \$COUNT file(s) now in media volume.\"
"
echo ""
echo "Done. You can safely ignore the host ./media directory — it is no longer used."
echo "To remove it: rm -rf ./media"

View file

@ -59,6 +59,7 @@ $SCHON_LANGUAGE_CODE = Prompt-Default 'SCHON_LANGUAGE_CODE' 'en-gb'
$SECRET_KEY = Prompt-AutoGen 'SECRET_KEY' 32
$JWT_SIGNING_KEY = Prompt-AutoGen 'JWT_SIGNING_KEY' 64
$SALT_KEY = Prompt-AutoGen 'SALT_KEY' 32
$ALLOWED_HOSTS = Prompt-Default 'ALLOWED_HOSTS' 'schon.wiseless.xyz api.schon.wiseless.xyz'
$CSRF_TRUSTED_ORIGINS = Prompt-Default 'CSRF_TRUSTED_ORIGINS' 'https://schon.wiseless.xyz https://api.schon.wiseless.xyz https://www.schon.wiseless.xyz'
@ -108,6 +109,7 @@ $lines = @(
""
"SECRET_KEY=""$SECRET_KEY"""
"JWT_SIGNING_KEY=""$JWT_SIGNING_KEY"""
"SALT_KEY=""$SALT_KEY"""
""
"ALLOWED_HOSTS=""$ALLOWED_HOSTS"""
"CSRF_TRUSTED_ORIGINS=""$CSRF_TRUSTED_ORIGINS"""

View file

@ -0,0 +1,51 @@
# =============================================================================
# migrate-media.ps1
# Migrates user-uploaded media files from host bind-mount (eVibes / early Schon)
# into the new Docker-managed named volume (media-data).
#
# Run this ONCE after upgrading from an eVibes or pre-volume Schon instance.
# =============================================================================
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$HostMedia = Join-Path (Get-Location) "media"
Write-Host "Schon - media migration from host bind-mount to named volume" -ForegroundColor Cyan
Write-Host ""
if (-not (Test-Path $HostMedia)) {
Write-Host "No .\media directory found on host. Nothing to migrate." -ForegroundColor Yellow
exit 0
}
$FileCount = (Get-ChildItem -Path $HostMedia -Recurse -File).Count
if ($FileCount -eq 0) {
Write-Host "Host .\media directory is empty. Nothing to migrate." -ForegroundColor Yellow
exit 0
}
Write-Host "Found $FileCount file(s) in $HostMedia" -ForegroundColor White
Write-Host ""
Write-Host "This will copy them into the Docker named volume 'media-data'." -ForegroundColor White
$confirm = Read-Host "Continue? [y/N]"
if ($confirm -ne "y" -and $confirm -ne "Y") {
Write-Host "Migration cancelled." -ForegroundColor Yellow
exit 0
}
Write-Host ""
Write-Host "Copying files..." -ForegroundColor White
$HostMediaUnix = $HostMedia -replace '\\', '/' -replace '^([A-Za-z]):', { "/$(($_.Value[0]).ToString().ToLower())" }
docker compose run --rm `
-v "${HostMediaUnix}:/old_media:ro" `
app bash -c @"
cp -a /old_media/. /app/media/
COUNT=`$(find /app/media -type f | wc -l)
echo "Migration complete: `$COUNT file(s) now in media volume."
"@
Write-Host ""
Write-Host "Done. You can safely ignore the host .\media directory - it is no longer used." -ForegroundColor Green
Write-Host "To remove it: Remove-Item -Recurse -Force .\media" -ForegroundColor Gray