From adec5503b2ef97d6c1144876cff2db80023cb25a Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Mon, 2 Mar 2026 00:11:57 +0300 Subject: [PATCH] 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. --- Dockerfiles/app.Dockerfile | 20 +- Dockerfiles/beat.Dockerfile | 21 +- Dockerfiles/stock_updater.Dockerfile | 21 +- Dockerfiles/worker.Dockerfile | 20 +- docker-compose.yml | 7 + engine/core/admin.py | 14 +- .../migrations/0057_encrypt_address_fields.py | 186 ++++++++++++++++++ engine/core/models.py | 20 +- engine/core/views.py | 10 + engine/core/viewsets.py | 11 +- engine/vibes_auth/admin.py | 3 +- .../0010_encrypt_user_pii_and_token_expiry.py | 136 +++++++++++++ engine/vibes_auth/models.py | 41 +++- engine/vibes_auth/viewsets.py | 22 ++- schon/fields.py | 43 ++++ schon/graphql_validators.py | 56 ++++++ schon/settings/base.py | 19 +- schon/settings/constance.py | 14 +- schon/settings/drf.py | 8 + scripts/Unix/generate-environment-file.sh | 2 + scripts/Unix/migrate-media.sh | 48 +++++ scripts/Windows/generate-environment-file.ps1 | 2 + scripts/Windows/migrate-media.ps1 | 51 +++++ 23 files changed, 703 insertions(+), 72 deletions(-) create mode 100644 engine/core/migrations/0057_encrypt_address_fields.py create mode 100644 engine/vibes_auth/migrations/0010_encrypt_user_pii_and_token_expiry.py create mode 100644 schon/fields.py create mode 100644 schon/graphql_validators.py create mode 100644 scripts/Unix/migrate-media.sh create mode 100644 scripts/Windows/migrate-media.ps1 diff --git a/Dockerfiles/app.Dockerfile b/Dockerfiles/app.Dockerfile index aec7b78c..fb3d5487 100644 --- a/Dockerfiles/app.Dockerfile +++ b/Dockerfiles/app.Dockerfile @@ -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 . . -ENTRYPOINT ["/usr/bin/bash", "app-entrypoint.sh"] \ No newline at end of file +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"] diff --git a/Dockerfiles/beat.Dockerfile b/Dockerfiles/beat.Dockerfile index 9c5313d6..7fb675a3 100644 --- a/Dockerfiles/beat.Dockerfile +++ b/Dockerfiles/beat.Dockerfile @@ -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 . . -ENTRYPOINT ["/usr/bin/bash", "beat-entrypoint.sh"] \ No newline at end of file +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"] diff --git a/Dockerfiles/stock_updater.Dockerfile b/Dockerfiles/stock_updater.Dockerfile index 95b5bc86..eb71f811 100644 --- a/Dockerfiles/stock_updater.Dockerfile +++ b/Dockerfiles/stock_updater.Dockerfile @@ -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 . . -ENTRYPOINT ["/usr/bin/bash", "stock-updater-entrypoint.sh"] \ No newline at end of file +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"] diff --git a/Dockerfiles/worker.Dockerfile b/Dockerfiles/worker.Dockerfile index f8d40bb8..66be7d2d 100644 --- a/Dockerfiles/worker.Dockerfile +++ b/Dockerfiles/worker.Dockerfile @@ -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 . . -ENTRYPOINT ["/usr/bin/bash", "worker-entrypoint.sh"] \ No newline at end of file +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"] diff --git a/docker-compose.yml b/docker-compose.yml index 9b254912..0ea90efe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/engine/core/admin.py b/engine/core/admin.py index 684fc11f..b6b0ee2c 100644 --- a/engine/core/admin.py +++ b/engine/core/admin.py @@ -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", diff --git a/engine/core/migrations/0057_encrypt_address_fields.py b/engine/core/migrations/0057_encrypt_address_fields.py new file mode 100644 index 00000000..d94c1301 --- /dev/null +++ b/engine/core/migrations/0057_encrypt_address_fields.py @@ -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), + ] diff --git a/engine/core/models.py b/engine/core/models.py index 267ca857..efc0b830 100644 --- a/engine/core/models.py +++ b/engine/core/models.py @@ -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"), diff --git a/engine/core/views.py b/engine/core/views.py index da435ed9..aa13f590 100644 --- a/engine/core/views.py +++ b/engine/core/views.py @@ -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 diff --git a/engine/core/viewsets.py b/engine/core/viewsets.py index 4e2a501a..54ddc458 100644 --- a/engine/core/viewsets.py +++ b/engine/core/viewsets.py @@ -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} diff --git a/engine/vibes_auth/admin.py b/engine/vibes_auth/admin.py index 9d03f2dd..b6d66805 100644 --- a/engine/vibes_auth/admin.py +++ b/engine/vibes_auth/admin.py @@ -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", diff --git a/engine/vibes_auth/migrations/0010_encrypt_user_pii_and_token_expiry.py b/engine/vibes_auth/migrations/0010_encrypt_user_pii_and_token_expiry.py new file mode 100644 index 00000000..2b8694ac --- /dev/null +++ b/engine/vibes_auth/migrations/0010_encrypt_user_pii_and_token_expiry.py @@ -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), + ] diff --git a/engine/vibes_auth/models.py b/engine/vibes_auth/models.py index 9c2d2495..a55e9f46 100644 --- a/engine/vibes_auth/models.py +++ b/engine/vibes_auth/models.py @@ -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 diff --git a/engine/vibes_auth/viewsets.py b/engine/vibes_auth/viewsets.py index 9942a514..9c7a072c 100644 --- a/engine/vibes_auth/viewsets.py +++ b/engine/vibes_auth/viewsets.py @@ -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() diff --git a/schon/fields.py b/schon/fields.py new file mode 100644 index 00000000..8e5dc8d5 --- /dev/null +++ b/schon/fields.py @@ -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 JSONB→dict 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 diff --git a/schon/graphql_validators.py b/schon/graphql_validators.py new file mode 100644 index 00000000..6761d700 --- /dev/null +++ b/schon/graphql_validators.py @@ -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}." + ) + ) diff --git a/schon/settings/base.py b/schon/settings/base.py index 31682fbc..6aebe2df 100644 --- a/schon/settings/base.py +++ b/schon/settings/base.py @@ -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) diff --git a/schon/settings/constance.py b/schon/settings/constance.py index b350d25b..78b5dcec 100644 --- a/schon/settings/constance.py +++ b/schon/settings/constance.py @@ -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 ### diff --git a/schon/settings/drf.py b/schon/settings/drf.py index 2a52c554..10bd9f87 100644 --- a/schon/settings/drf.py +++ b/schon/settings/drf.py @@ -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, }, diff --git a/scripts/Unix/generate-environment-file.sh b/scripts/Unix/generate-environment-file.sh index 9a1a740c..9bc4b733 100755 --- a/scripts/Unix/generate-environment-file.sh +++ b/scripts/Unix/generate-environment-file.sh @@ -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}" diff --git a/scripts/Unix/migrate-media.sh b/scripts/Unix/migrate-media.sh new file mode 100644 index 00000000..fd426cca --- /dev/null +++ b/scripts/Unix/migrate-media.sh @@ -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" diff --git a/scripts/Windows/generate-environment-file.ps1 b/scripts/Windows/generate-environment-file.ps1 index fa158e71..df3f3a38 100644 --- a/scripts/Windows/generate-environment-file.ps1 +++ b/scripts/Windows/generate-environment-file.ps1 @@ -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""" diff --git a/scripts/Windows/migrate-media.ps1 b/scripts/Windows/migrate-media.ps1 new file mode 100644 index 00000000..ac9b7756 --- /dev/null +++ b/scripts/Windows/migrate-media.ps1 @@ -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