-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add optional ALTCHA integration for DRF public form submissions #21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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/<slug>/`) accepts payloads | ||
| without anti-bot proof. | ||
| - Frontend clients already consume `GET /api/fobi-form-fields/<slug>/`; 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/<slug>/` 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/<slug>/`) | ||
| 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/<slug>/` 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: Consider making the ALTCHA label configurable or translatable instead of hardcoding it. Since this label may be shown in the UI, consider sourcing it from configuration (like Suggested implementation: from rest_framework.response import Response
from django.utils.translation import gettext_lazy as _ form_structure["fields"].append({
"name": get_field_name(),
"type": "AltchaField",
"widget": "AltchaWidget",
"label": _("ALTCHA"),
"required": True,
"help_text": "",
})
|
||
| "required": True, | ||
| "help_text": "", | ||
| }) | ||
|
|
||
| return Response(form_structure) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
|
Comment on lines
+10
to
+19
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue (bug_risk): This assumes |
||
| ) | ||
| 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) | ||
|
Comment on lines
+47
to
+51
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue (bug_risk): Accessing the cache alias directly can raise if the alias is misconfigured, breaking verification. If |
||
|
|
||
| return True, None | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion: Align the documented payload field name with the configurable
UNFOLD_FOBI_ALTCHA_FIELD_NAMEsetting.Step 3 hardcodes the field name as "altcha", while earlier you introduce
UNFOLD_FOBI_ALTCHA_FIELD_NAME(default "altcha"). This could confuse users who override the setting. Please reference the setting here and note that "altcha" is the default (e.g., “includes the base64 payload in the field specified byUNFOLD_FOBI_ALTCHA_FIELD_NAME(default: "altcha")”).