From 61d7699ebe14cd92d817cc84cbe5d7b1d0a7dc72 Mon Sep 17 00:00:00 2001 From: Marc Widmer Date: Fri, 10 Apr 2026 23:41:08 +0200 Subject: [PATCH 1/3] feat: add optional ALTCHA integration for DRF public form submissions --- .agents/tasks/T25_altcha_backend_drf_field.md | 152 ++++++++++++++++++ README.md | 54 ++++++- src/unfold_fobi/api/views.py | 14 ++ src/unfold_fobi/contrib/altcha/__init__.py | 0 src/unfold_fobi/contrib/altcha/apps.py | 9 ++ src/unfold_fobi/contrib/altcha/challenge.py | 53 ++++++ src/unfold_fobi/contrib/altcha/conf.py | 37 +++++ src/unfold_fobi/contrib/altcha/patch.py | 52 ++++++ src/unfold_fobi/contrib/altcha/urls.py | 11 ++ src/unfold_fobi/contrib/altcha/views.py | 16 ++ src/unfold_fobi/patches/__init__.py | 10 ++ tests/captcha/__init__.py | 0 tests/captcha/test_altcha.py | 147 +++++++++++++++++ 13 files changed, 553 insertions(+), 2 deletions(-) create mode 100644 .agents/tasks/T25_altcha_backend_drf_field.md create mode 100644 src/unfold_fobi/contrib/altcha/__init__.py create mode 100644 src/unfold_fobi/contrib/altcha/apps.py create mode 100644 src/unfold_fobi/contrib/altcha/challenge.py create mode 100644 src/unfold_fobi/contrib/altcha/conf.py create mode 100644 src/unfold_fobi/contrib/altcha/patch.py create mode 100644 src/unfold_fobi/contrib/altcha/urls.py create mode 100644 src/unfold_fobi/contrib/altcha/views.py create mode 100644 tests/captcha/__init__.py create mode 100644 tests/captcha/test_altcha.py diff --git a/.agents/tasks/T25_altcha_backend_drf_field.md b/.agents/tasks/T25_altcha_backend_drf_field.md new file mode 100644 index 0000000..b3adb66 --- /dev/null +++ b/.agents/tasks/T25_altcha_backend_drf_field.md @@ -0,0 +1,152 @@ +# Task T25 - ALTCHA backend integration with DRF field support + +Goal +- Add ALTCHA protection to backend form submission flows. +- Provide ALTCHA to API clients as a DRF-consumable field contract. +- Keep integration optional and safe for projects not using ALTCHA. +- Support Nuxt SSR public-form flows where ALTCHA is rendered on the Nuxt side + and validated in backend DRF before Fobi processing. + +Problem statement +- Current DRF submission flow (`PUT /api/fobi-form-entry//`) accepts payloads + without anti-bot proof. +- Frontend clients already consume `GET /api/fobi-form-fields//`; ALTCHA + must be exposed through this API so clients can render and submit the widget + payload consistently. +- In Nuxt SSR deployments, ALTCHA must stay decoupled from Fobi HTML rendering. + +Check: can/should we use `django-altcha`? +- Can use: **Yes**. + - `django-altcha` is actively maintained and current (`0.10.0`, released + March 10, 2026) and provides challenge generation + replay-protection + primitives for Django projects. +- Should use as core DRF implementation: **No (not directly)**. + - It is primarily a Django forms/widget integration (`AltchaField`, + `AltchaChallengeView`), not a DRF serializer-field integration. + - For this package’s API-first flow, implement a package-owned DRF field and + backend validator, optionally with adapter hooks for django-altcha. + +Suggested Skills +- Primary: `$unfold-dev-advanced`. +- Review: `$unfold-codex-reviewer`. + +Dependencies +- T18/T23 API form-fields endpoint conventions. +- Existing patch mechanism in `src/unfold_fobi/patches/*` (used for DRF update + interception). + +Scope +- Add optional ALTCHA integration module: + - `src/unfold_fobi/contrib/altcha/` +- Add API exposure for ALTCHA field metadata in + `src/unfold_fobi/api/views.py`. +- Add DRF submission validation hook for ALTCHA in the submission path. +- Add settings contract and docs. +- Add tests for enabled/disabled and pass/fail verification cases. +- Document Nuxt SSR integration path (widget render, challenge fetch, payload + submit). + +Non-goals +- No mandatory dependency on ALTCHA for all users. +- No frontend framework implementation details. +- No Sentinel spam-filter integration in initial scope (keep extension points). +- No requirement to add ALTCHA as a persisted Fobi form element for API-first + Nuxt flows. + +Implementation requirements +1. Optional integration boundary +- ALTCHA integration must be opt-in. +- Base `unfold_fobi` must still load when ALTCHA deps/settings are absent. +- Use lazy imports to avoid startup errors when contrib app is disabled. + +2. DRF field contract for clients +- Extend `GET /api/fobi-form-fields//` with a synthetic field entry when + ALTCHA is enabled, e.g.: + - `name`: `"altcha"` + - `type`: `"AltchaField"` + - `widget`: `"AltchaWidget"` + - `required`: `true` + - include challenge endpoint metadata (URL, field name, optional options). +- When ALTCHA is disabled, response remains unchanged. +- This field contract is explicitly intended for Nuxt-side widget rendering and + must not depend on Fobi server-side HTML form rendering. + +3. Challenge endpoint +- Provide a backend endpoint that returns fresh ALTCHA challenge JSON. +- Keep endpoint package-owned and reusable by external frontends. +- Ensure challenge generation parameters are configurable via settings. + +4. Submission verification via DRF field +- Introduce a package DRF validation component (serializer field or equivalent + request validator) for ALTCHA payload. +- Wire validation into form submission flow (`/api/fobi-form-entry//`) + before data persistence. +- Remove/consume ALTCHA payload before passing form data to Fobi handlers to + avoid unknown-field side effects. +- Return deterministic API errors (`400`) when ALTCHA payload is missing/invalid. +- Validation order is strict: + 1. parse ALTCHA payload from request body, + 2. verify challenge/signature/expiry/replay, + 3. only then hand off sanitized payload to Fobi submission processing. + +5. Replay protection and cache behavior +- Enforce one-time payload usage (replay protection). +- Require/document shared cache backend for multi-worker deployments. +- Provide cache alias setting for ALTCHA verification state. + +6. Provider strategy +- Default provider: package-owned DRF-oriented verification using official + ALTCHA Python primitives (`altcha` package). +- Optional adapter path: allow integration points for `django-altcha` challenge + and verification helpers, but do not make it the only path. +- Proposed Fobi plugin (`IntegrationFormFieldPlugin` + `django_altcha.AltchaField`) + is considered optional and secondary: + - useful for classic Django-rendered Fobi forms, + - not the primary integration for Nuxt SSR over DRF, + - must not be required for API submissions to be protected. + +7. Settings contract +- Add explicit settings with safe defaults, e.g.: + - enable/disable flag, + - HMAC secret, + - challenge endpoint path, + - payload field name (default `"altcha"`), + - cache alias for replay protection. + +8. Security and error handling +- Reject expired challenges. +- Reject malformed/non-base64 payloads. +- Log verification failures at appropriate level without leaking secrets. + +Deliverables +- Optional ALTCHA contrib module for backend + DRF. +- Synthetic ALTCHA field support in form-fields API. +- Submission-time ALTCHA validation and replay protection. +- Settings and README documentation. +- Tests covering enabled/disabled and verification outcomes. +- Integration notes for Nuxt: + - widget rendering on frontend, + - challenge endpoint consumption, + - ALTCHA payload included in DRF submit body. + +Acceptance Criteria +- With ALTCHA disabled: + - existing API behavior is unchanged. +- With ALTCHA enabled: + - `GET /api/fobi-form-fields//` includes ALTCHA field metadata. + - challenge endpoint returns valid challenge payloads. + - valid ALTCHA payload allows submission. + - missing/invalid/replayed payload is rejected with `400`. + - ALTCHA validation occurs before Fobi submission handling is invoked. +- Base package remains import-safe without ALTCHA contrib activation. +- `poetry run pytest -q` passes. + +Tests to run +- `poetry run pytest -q` +- Add targeted tests for: + - form-fields response with ALTCHA disabled/enabled, + - challenge endpoint response shape and freshness, + - Nuxt-style API submission body containing ALTCHA payload, + - submission success with valid payload, + - submission failure for missing/invalid/expired/replayed payload, + - disabled-mode import/app-loading safety. diff --git a/README.md b/README.md index 860baef..0c1d80f 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ Unfold-based back offices. queryset scoping, and reusable admin mixins. - Optional django CMS plugin (`unfold_fobi.contrib.cms`) for embedding forms in CMS placeholders, with site-aware form selection. +- Optional ALTCHA protection (`unfold_fobi.contrib.altcha`) for proof-of-work + anti-bot verification on public form submissions. - Fobi AppConfig overrides with `AutoField` pinning and i18n `verbose_name` labels for projects using `BigAutoField`. @@ -202,7 +204,55 @@ is automatically filtered by the current site and the editor's allowed sites. If `djangocms-rest` is installed, the plugin serializes the form reference as `{"form_entry": {"name": "...", "slug": "..."}}` instead of a bare FK integer. -### 4) DRF notes +### 4) Optional ALTCHA protection + +Enable to require proof-of-work verification on public form submissions. +Only public forms (`is_public=True`) are protected; non-public/preview forms +are unaffected. + +Requires the `altcha` Python package (`>=2.0`). + +```bash +pip install altcha +``` + +```python +INSTALLED_APPS += [ + "unfold_fobi.contrib.altcha", +] + +UNFOLD_FOBI_ALTCHA_HMAC_SECRET = "your-secret-key" # required to enable +``` + +Add the challenge endpoint URL: + +```python +urlpatterns += [ + path("api/", include("unfold_fobi.contrib.altcha.urls")), +] +``` + +Optional settings: + +```python +UNFOLD_FOBI_ALTCHA_MAX_NUMBER = 100_000 # difficulty (default: 100000) +UNFOLD_FOBI_ALTCHA_ALGORITHM = "SHA-256" # hash algorithm +UNFOLD_FOBI_ALTCHA_FIELD_NAME = "altcha" # payload field name in PUT body +UNFOLD_FOBI_ALTCHA_CACHE_ALIAS = "default" # cache backend for replay protection +UNFOLD_FOBI_ALTCHA_CHALLENGE_EXPIRY = 300 # challenge TTL in seconds +``` + +**How it works:** + +1. `GET /api/fobi-form-fields//` includes an `AltchaField` entry for + public forms when ALTCHA is enabled. +2. Frontend fetches a challenge from `GET /api/altcha-challenge/`. +3. Frontend solves the challenge client-side (e.g. using the `altcha` web + component) and includes the base64 payload as `"altcha"` in the PUT body. +4. Backend verifies the payload before processing the form submission. + Missing, invalid, expired, or replayed payloads return `400`. + +### 5) DRF notes - Include both Fobi DRF URLs and `unfold_fobi.api.urls`. - Use `GET /api/fobi-form-fields//` to fetch per-form field metadata @@ -210,7 +260,7 @@ If `djangocms-rest` is installed, the plugin serializes the form reference as - Ensure each form has the `db_store` handler enabled for persisted API submissions. -### 5) Fobi AppConfig overrides (recommended for BigAutoField projects) +### 6) Fobi AppConfig overrides (recommended for BigAutoField projects) If your project uses `DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"`, replace the bare fobi entries in `INSTALLED_APPS` with the package-provided diff --git a/src/unfold_fobi/api/views.py b/src/unfold_fobi/api/views.py index fb31914..8b744a9 100644 --- a/src/unfold_fobi/api/views.py +++ b/src/unfold_fobi/api/views.py @@ -180,4 +180,18 @@ def get_form_fields(request, slug): form_structure["fields"].append(field_info) + # Append ALTCHA field metadata for public forms only + if form_entry.is_public and django_apps.is_installed("unfold_fobi.contrib.altcha"): + from unfold_fobi.contrib.altcha.conf import get_field_name, is_enabled + + if is_enabled(): + form_structure["fields"].append({ + "name": get_field_name(), + "type": "AltchaField", + "widget": "AltchaWidget", + "label": "ALTCHA", + "required": True, + "help_text": "", + }) + return Response(form_structure) diff --git a/src/unfold_fobi/contrib/altcha/__init__.py b/src/unfold_fobi/contrib/altcha/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/unfold_fobi/contrib/altcha/apps.py b/src/unfold_fobi/contrib/altcha/apps.py new file mode 100644 index 0000000..6ff4f1f --- /dev/null +++ b/src/unfold_fobi/contrib/altcha/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class UnfoldFobiAltchaConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "unfold_fobi.contrib.altcha" + label = "unfold_fobi_altcha" + verbose_name = _("ALTCHA protection") diff --git a/src/unfold_fobi/contrib/altcha/challenge.py b/src/unfold_fobi/contrib/altcha/challenge.py new file mode 100644 index 0000000..e7e5782 --- /dev/null +++ b/src/unfold_fobi/contrib/altcha/challenge.py @@ -0,0 +1,53 @@ +"""ALTCHA challenge generation and solution verification.""" + +from datetime import datetime, timedelta, timezone + +from django.core.cache import caches + +from . import conf + + +def create_challenge(): + """Generate a fresh ALTCHA challenge dict for the frontend.""" + import altcha + + expires = datetime.now(timezone.utc) + timedelta(seconds=conf.get_challenge_expiry()) + challenge = altcha.create_challenge_v1( + hmac_key=conf.get_hmac_secret(), + max_number=conf.get_max_number(), + algorithm=conf.get_algorithm(), + expires=expires, + ) + return challenge.to_dict() + + +def verify_payload(payload_b64): + """Verify a base64-encoded ALTCHA solution payload. + + Returns ``(True, None)`` on success or ``(False, error_message)`` on failure. + """ + import altcha + + if not payload_b64: + return False, "Missing ALTCHA payload." + + try: + ok, err = altcha.verify_solution_v1( + payload_b64, + conf.get_hmac_secret(), + check_expires=True, + ) + except Exception: + return False, "Invalid ALTCHA payload." + + if not ok: + return False, err or "Invalid ALTCHA payload." + + # Replay protection + cache = caches[conf.get_cache_alias()] + cache_key = f"altcha:replay:{payload_b64[:64]}" + if cache.get(cache_key): + return False, "ALTCHA payload already used." + cache.set(cache_key, 1, conf.get_challenge_expiry() * 2) + + return True, None diff --git a/src/unfold_fobi/contrib/altcha/conf.py b/src/unfold_fobi/contrib/altcha/conf.py new file mode 100644 index 0000000..7776056 --- /dev/null +++ b/src/unfold_fobi/contrib/altcha/conf.py @@ -0,0 +1,37 @@ +"""ALTCHA configuration resolved from Django settings.""" + +from django.conf import settings + + +def get_hmac_secret(): + return getattr(settings, "UNFOLD_FOBI_ALTCHA_HMAC_SECRET", None) + + +def get_max_number(): + return getattr(settings, "UNFOLD_FOBI_ALTCHA_MAX_NUMBER", 100_000) + + +def get_algorithm(): + return getattr(settings, "UNFOLD_FOBI_ALTCHA_ALGORITHM", "SHA-256") + + +def get_field_name(): + return getattr(settings, "UNFOLD_FOBI_ALTCHA_FIELD_NAME", "altcha") + + +def get_cache_alias(): + return getattr(settings, "UNFOLD_FOBI_ALTCHA_CACHE_ALIAS", "default") + + +def get_challenge_expiry(): + return getattr(settings, "UNFOLD_FOBI_ALTCHA_CHALLENGE_EXPIRY", 300) + + +def is_enabled(): + """ALTCHA is enabled when the app is installed and a secret is configured.""" + from django.apps import apps + + return ( + apps.is_installed("unfold_fobi.contrib.altcha") + and get_hmac_secret() is not None + ) diff --git a/src/unfold_fobi/contrib/altcha/patch.py b/src/unfold_fobi/contrib/altcha/patch.py new file mode 100644 index 0000000..f53cb36 --- /dev/null +++ b/src/unfold_fobi/contrib/altcha/patch.py @@ -0,0 +1,52 @@ +"""Patch FobiFormEntryViewSet.update to require ALTCHA verification.""" + +from . import conf + + +def apply(): + """Patch fobi DRF update for ALTCHA validation — idempotent.""" + if not conf.is_enabled(): + return + + try: + from fobi.contrib.apps.drf_integration.views import ( + FobiFormEntryViewSet, + ) + except ImportError: + return + + original_update = FobiFormEntryViewSet.update + + if getattr(original_update, "_unfold_altcha_patched", False): + return + + def update_with_altcha(self, request, *args, **kwargs): + # Re-check at runtime so tests/settings changes are respected + if not conf.is_enabled(): + return original_update(self, request, *args, **kwargs) + + # Only enforce ALTCHA on public forms + instance = self.get_object() + if not instance.is_public: + return original_update(self, request, *args, **kwargs) + + from rest_framework.response import Response + + from .challenge import verify_payload + + field_name = conf.get_field_name() + payload = request.data.get(field_name) + + ok, err = verify_payload(payload) + if not ok: + return Response({"detail": err}, status=400) + + # Remove ALTCHA field before Fobi processing + if hasattr(request.data, "_mutable"): + request.data._mutable = True + request.data.pop(field_name, None) + + return original_update(self, request, *args, **kwargs) + + update_with_altcha._unfold_altcha_patched = True + FobiFormEntryViewSet.update = update_with_altcha diff --git a/src/unfold_fobi/contrib/altcha/urls.py b/src/unfold_fobi/contrib/altcha/urls.py new file mode 100644 index 0000000..dfc18e0 --- /dev/null +++ b/src/unfold_fobi/contrib/altcha/urls.py @@ -0,0 +1,11 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path( + "altcha-challenge/", + views.altcha_challenge, + name="altcha-challenge", + ), +] diff --git a/src/unfold_fobi/contrib/altcha/views.py b/src/unfold_fobi/contrib/altcha/views.py new file mode 100644 index 0000000..f9b8426 --- /dev/null +++ b/src/unfold_fobi/contrib/altcha/views.py @@ -0,0 +1,16 @@ +"""ALTCHA challenge endpoint for frontend clients.""" + +from django.views.decorators.cache import never_cache +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + +from .challenge import create_challenge + + +@api_view(["GET"]) +@never_cache +@permission_classes([AllowAny]) +def altcha_challenge(request): + """Return a fresh ALTCHA challenge for the frontend widget.""" + return Response(create_challenge()) diff --git a/src/unfold_fobi/patches/__init__.py b/src/unfold_fobi/patches/__init__.py index d836c02..151e4ca 100644 --- a/src/unfold_fobi/patches/__init__.py +++ b/src/unfold_fobi/patches/__init__.py @@ -11,6 +11,15 @@ from .mail_sender import apply as apply_mail_sender +def _apply_altcha(): + """Apply ALTCHA patch if the contrib app is enabled.""" + try: + from unfold_fobi.contrib.altcha.patch import apply + apply() + except ImportError: + pass + + def apply_all(): """Apply every fobi patch in the correct order.""" apply_widgets() @@ -18,6 +27,7 @@ def apply_all(): apply_owner_filtering() apply_popup_response() apply_active_dates() + _apply_altcha() __all__ = [ diff --git a/tests/captcha/__init__.py b/tests/captcha/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/captcha/test_altcha.py b/tests/captcha/test_altcha.py new file mode 100644 index 0000000..3e71768 --- /dev/null +++ b/tests/captcha/test_altcha.py @@ -0,0 +1,147 @@ +"""T25: ALTCHA backend integration tests.""" + +import base64 +import json + +import pytest + +ALTCHA_SECRET = "test-altcha-secret-key-for-testing" + + +def _make_valid_payload(secret=ALTCHA_SECRET): + """Create a valid ALTCHA challenge, solve it, return base64 payload.""" + import altcha + + ch = altcha.create_challenge_v1(hmac_key=secret, max_number=100) + sol = altcha.solve_challenge_v1(ch.challenge, ch.salt, ch.algorithm, ch.max_number) + payload_dict = { + "algorithm": ch.algorithm, + "challenge": ch.challenge, + "number": sol.number, + "salt": ch.salt, + "signature": ch.signature, + } + return base64.b64encode(json.dumps(payload_dict).encode()).decode() + + +class TestDisabledMode: + """Without ALTCHA configured, everything works as before.""" + + def test_form_fields_no_altcha_field(self, admin_client, form_entry): + response = admin_client.get(f"/api/fobi-form-fields/{form_entry.slug}/") + assert response.status_code == 200 + names = [f["name"] for f in response.json()["fields"]] + assert "altcha" not in names + + def test_submission_works_without_altcha(self, admin_client, form_entry): + response = admin_client.put( + f"/api/fobi-form-entry/{form_entry.slug}/", + data=json.dumps({"full_name": "Test"}), + content_type="application/json", + ) + assert response.status_code == 200 + + def test_base_package_import_safe(self): + """Base unfold_fobi has no hard altcha dependency.""" + import pathlib + + base_pkg = pathlib.Path(__file__).resolve().parent.parent.parent / "src" / "unfold_fobi" + violations = [ + f"{py.relative_to(base_pkg)}" + for py in base_pkg.rglob("*.py") + if "contrib" not in py.parts + and any(line.startswith(("from altcha", "import altcha")) for line in py.read_text().splitlines()) + ] + assert not violations, f"Hard altcha imports in base package: {violations}" + + +class TestEnabledFormFields: + """With ALTCHA configured, form-fields response includes the field.""" + + def test_form_fields_includes_altcha(self, settings, admin_client, form_entry): + settings.UNFOLD_FOBI_ALTCHA_HMAC_SECRET = ALTCHA_SECRET + settings.INSTALLED_APPS = list(settings.INSTALLED_APPS) + ["unfold_fobi.contrib.altcha"] + + response = admin_client.get(f"/api/fobi-form-fields/{form_entry.slug}/") + assert response.status_code == 200 + names = [f["name"] for f in response.json()["fields"]] + assert "altcha" in names + + altcha_field = [f for f in response.json()["fields"] if f["name"] == "altcha"][0] + assert altcha_field["type"] == "AltchaField" + assert altcha_field["widget"] == "AltchaWidget" + assert altcha_field["required"] is True + + +class TestChallengeGeneration: + """Challenge endpoint returns valid ALTCHA challenges.""" + + def test_challenge_shape(self, settings): + settings.UNFOLD_FOBI_ALTCHA_HMAC_SECRET = ALTCHA_SECRET + from unfold_fobi.contrib.altcha.challenge import create_challenge + + ch = create_challenge() + assert "algorithm" in ch + assert "challenge" in ch + assert "salt" in ch + assert "signature" in ch + assert "maxNumber" in ch + + def test_challenge_solvable(self, settings): + settings.UNFOLD_FOBI_ALTCHA_HMAC_SECRET = ALTCHA_SECRET + import altcha + from unfold_fobi.contrib.altcha.challenge import create_challenge + + ch = create_challenge() + sol = altcha.solve_challenge_v1( + ch["challenge"], ch["salt"], ch["algorithm"], ch["maxNumber"] + ) + assert sol is not None + assert sol.number >= 0 + + +class TestVerification: + """Payload verification: valid, invalid, expired, replayed.""" + + def test_valid_payload_passes(self, settings): + settings.UNFOLD_FOBI_ALTCHA_HMAC_SECRET = ALTCHA_SECRET + from unfold_fobi.contrib.altcha.challenge import verify_payload + + payload = _make_valid_payload() + ok, err = verify_payload(payload) + assert ok is True + assert err is None + + def test_missing_payload_fails(self, settings): + settings.UNFOLD_FOBI_ALTCHA_HMAC_SECRET = ALTCHA_SECRET + from unfold_fobi.contrib.altcha.challenge import verify_payload + + ok, err = verify_payload(None) + assert ok is False + + def test_invalid_payload_fails(self, settings): + settings.UNFOLD_FOBI_ALTCHA_HMAC_SECRET = ALTCHA_SECRET + from unfold_fobi.contrib.altcha.challenge import verify_payload + + ok, err = verify_payload("not-valid-base64-payload") + assert ok is False + + def test_replay_rejected(self, settings): + settings.UNFOLD_FOBI_ALTCHA_HMAC_SECRET = ALTCHA_SECRET + from unfold_fobi.contrib.altcha.challenge import verify_payload + + payload = _make_valid_payload() + ok1, _ = verify_payload(payload) + assert ok1 is True + + ok2, err2 = verify_payload(payload) + assert ok2 is False + assert "already used" in err2 + + def test_wrong_secret_fails(self, settings): + settings.UNFOLD_FOBI_ALTCHA_HMAC_SECRET = "wrong-secret" + from unfold_fobi.contrib.altcha.challenge import verify_payload + + payload = _make_valid_payload() # signed with ALTCHA_SECRET + ok, err = verify_payload(payload) + assert ok is False From 0efe2da3b844018a4862636e0632d6b7bc3c59ec Mon Sep 17 00:00:00 2001 From: Marc Widmer Date: Fri, 10 Apr 2026 23:53:52 +0200 Subject: [PATCH 2/3] refactor: improve ALTCHA payload validation and error handling, update altcha to v2.0 --- pyproject.toml | 1 + src/unfold_fobi/contrib/altcha/challenge.py | 6 +-- src/unfold_fobi/contrib/altcha/patch.py | 6 +-- tests/captcha/test_altcha.py | 57 ++++++++++----------- 4 files changed, 35 insertions(+), 35 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 56ad288..b1b6d87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,7 @@ pytest = ">=8.0" pytest-django = ">=4.8" pytest-playwright = ">=0.5" playwright = ">=1.40" +altcha = ">=2.0" [tool.poetry.group.dev.dependencies] ruff = ">=0.4" diff --git a/src/unfold_fobi/contrib/altcha/challenge.py b/src/unfold_fobi/contrib/altcha/challenge.py index e7e5782..134ca4e 100644 --- a/src/unfold_fobi/contrib/altcha/challenge.py +++ b/src/unfold_fobi/contrib/altcha/challenge.py @@ -32,7 +32,7 @@ def verify_payload(payload_b64): return False, "Missing ALTCHA payload." try: - ok, err = altcha.verify_solution_v1( + is_valid, error = altcha.verify_solution_v1( payload_b64, conf.get_hmac_secret(), check_expires=True, @@ -40,8 +40,8 @@ def verify_payload(payload_b64): except Exception: return False, "Invalid ALTCHA payload." - if not ok: - return False, err or "Invalid ALTCHA payload." + if not is_valid: + return False, error or "Invalid ALTCHA payload." # Replay protection cache = caches[conf.get_cache_alias()] diff --git a/src/unfold_fobi/contrib/altcha/patch.py b/src/unfold_fobi/contrib/altcha/patch.py index f53cb36..2d6477f 100644 --- a/src/unfold_fobi/contrib/altcha/patch.py +++ b/src/unfold_fobi/contrib/altcha/patch.py @@ -37,9 +37,9 @@ def update_with_altcha(self, request, *args, **kwargs): field_name = conf.get_field_name() payload = request.data.get(field_name) - ok, err = verify_payload(payload) - if not ok: - return Response({"detail": err}, status=400) + is_valid, error = verify_payload(payload) + if not is_valid: + return Response({"detail": error}, status=400) # Remove ALTCHA field before Fobi processing if hasattr(request.data, "_mutable"): diff --git a/tests/captcha/test_altcha.py b/tests/captcha/test_altcha.py index 3e71768..93a2b41 100644 --- a/tests/captcha/test_altcha.py +++ b/tests/captcha/test_altcha.py @@ -5,21 +5,21 @@ import pytest +altcha = pytest.importorskip("altcha", reason="altcha package not installed") + ALTCHA_SECRET = "test-altcha-secret-key-for-testing" def _make_valid_payload(secret=ALTCHA_SECRET): """Create a valid ALTCHA challenge, solve it, return base64 payload.""" - import altcha - - ch = altcha.create_challenge_v1(hmac_key=secret, max_number=100) - sol = altcha.solve_challenge_v1(ch.challenge, ch.salt, ch.algorithm, ch.max_number) + challenge = altcha.create_challenge_v1(hmac_key=secret, max_number=100) + solution = altcha.solve_challenge_v1(challenge.challenge, challenge.salt, challenge.algorithm, challenge.max_number) payload_dict = { - "algorithm": ch.algorithm, - "challenge": ch.challenge, - "number": sol.number, - "salt": ch.salt, - "signature": ch.signature, + "algorithm": challenge.algorithm, + "challenge": challenge.challenge, + "number": solution.number, + "salt": challenge.salt, + "signature": challenge.signature, } return base64.b64encode(json.dumps(payload_dict).encode()).decode() @@ -89,15 +89,14 @@ def test_challenge_shape(self, settings): def test_challenge_solvable(self, settings): settings.UNFOLD_FOBI_ALTCHA_HMAC_SECRET = ALTCHA_SECRET - import altcha from unfold_fobi.contrib.altcha.challenge import create_challenge - ch = create_challenge() - sol = altcha.solve_challenge_v1( - ch["challenge"], ch["salt"], ch["algorithm"], ch["maxNumber"] + challenge = create_challenge() + solution = altcha.solve_challenge_v1( + challenge["challenge"], challenge["salt"], challenge["algorithm"], challenge["maxNumber"] ) - assert sol is not None - assert sol.number >= 0 + assert solution is not None + assert solution.number >= 0 class TestVerification: @@ -108,40 +107,40 @@ def test_valid_payload_passes(self, settings): from unfold_fobi.contrib.altcha.challenge import verify_payload payload = _make_valid_payload() - ok, err = verify_payload(payload) - assert ok is True - assert err is None + is_valid, error = verify_payload(payload) + assert is_valid is True + assert error is None def test_missing_payload_fails(self, settings): settings.UNFOLD_FOBI_ALTCHA_HMAC_SECRET = ALTCHA_SECRET from unfold_fobi.contrib.altcha.challenge import verify_payload - ok, err = verify_payload(None) - assert ok is False + is_valid, error = verify_payload(None) + assert is_valid is False def test_invalid_payload_fails(self, settings): settings.UNFOLD_FOBI_ALTCHA_HMAC_SECRET = ALTCHA_SECRET from unfold_fobi.contrib.altcha.challenge import verify_payload - ok, err = verify_payload("not-valid-base64-payload") - assert ok is False + is_valid, error = verify_payload("not-valid-base64-payload") + assert is_valid is False def test_replay_rejected(self, settings): settings.UNFOLD_FOBI_ALTCHA_HMAC_SECRET = ALTCHA_SECRET from unfold_fobi.contrib.altcha.challenge import verify_payload payload = _make_valid_payload() - ok1, _ = verify_payload(payload) - assert ok1 is True + is_valid, _ = verify_payload(payload) + assert is_valid is True - ok2, err2 = verify_payload(payload) - assert ok2 is False - assert "already used" in err2 + is_valid, error = verify_payload(payload) + assert is_valid is False + assert "already used" in error def test_wrong_secret_fails(self, settings): settings.UNFOLD_FOBI_ALTCHA_HMAC_SECRET = "wrong-secret" from unfold_fobi.contrib.altcha.challenge import verify_payload payload = _make_valid_payload() # signed with ALTCHA_SECRET - ok, err = verify_payload(payload) - assert ok is False + is_valid, error = verify_payload(payload) + assert is_valid is False From 64623f1899340ef262273c8b03d6bb25494155cd Mon Sep 17 00:00:00 2001 From: Marc Widmer Date: Fri, 10 Apr 2026 23:56:02 +0200 Subject: [PATCH 3/3] chore: update poetry.lock to include altcha v2.0.0 --- poetry.lock | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 2edabef..0d84327 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,17 @@ # This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. +[[package]] +name = "altcha" +version = "2.0.0" +description = "A library for creating and verifying challenges for ALTCHA." +optional = false +python-versions = ">=3.9" +groups = ["test"] +files = [ + {file = "altcha-2.0.0-py3-none-any.whl", hash = "sha256:51763c2eaec26874a368be5fa9c34c9a9385d84e3c899635412f51944fad3ee4"}, + {file = "altcha-2.0.0.tar.gz", hash = "sha256:51aeb28cc40ba9e467e962dff0a9f8ca78ceea07e7279b13e81808cc90e330ca"}, +] + [[package]] name = "asgiref" version = "3.11.1" @@ -1005,4 +1017,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0" -content-hash = "a83f4c0be4d60d39ce97ec329b093c30be5480779fcb0b6394d3d26fb3ccf933" +content-hash = "9a7192e8c44f1ac559d42212d909acb7d91f552bd054741a885d44cd4989a9ce"