Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions .agents/tasks/T25_altcha_backend_drf_field.md
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.
54 changes: 52 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -202,15 +204,63 @@ 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/<slug>/` 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.
Comment on lines +250 to +251

Copy link
Copy Markdown
Contributor

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_NAME setting.

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 by UNFOLD_FOBI_ALTCHA_FIELD_NAME (default: "altcha")”).

Suggested change
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.
3. Frontend solves the challenge client-side (e.g. using the `altcha` web
component) and includes the base64 payload in the field specified by
`UNFOLD_FOBI_ALTCHA_FIELD_NAME` (default: `"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/<slug>/` to fetch per-form field metadata
(including available field types/widgets/choices) for frontend rendering.
- 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
Expand Down
14 changes: 13 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
14 changes: 14 additions & 0 deletions src/unfold_fobi/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 get_field_name()) or wrapping it in the existing translation/i18n helper so it can be localized consistently with the rest of the app.

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": "",
            })
  1. If django.utils.translation.gettext_lazy is already imported elsewhere in this file (e.g., from django.utils.translation import gettext_lazy as _), remove the duplicate import I added to avoid redundancy.
  2. If your project uses a different i18n helper alias (for example ugettext_lazy or a differently named _), adjust the import and the _("ALTCHA") call to match the existing convention.

"required": True,
"help_text": "",
})

return Response(form_structure)
Empty file.
9 changes: 9 additions & 0 deletions src/unfold_fobi/contrib/altcha/apps.py
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")
53 changes: 53 additions & 0 deletions src/unfold_fobi/contrib/altcha/challenge.py
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): create_challenge does not guard against missing HMAC secret, which could surface as a runtime error.

This assumes conf.get_hmac_secret() always returns a valid key. If the contrib app is installed but UNFOLD_FOBI_ALTCHA_HMAC_SECRET is missing or ALTCHA isn’t actually enabled, altcha.create_challenge_v1 will likely raise and surface as a 500 from this endpoint. Consider either short‑circuiting when ALTCHA is disabled or raising an explicit configuration error so misconfiguration is handled predictably.

)
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:
is_valid, error = altcha.verify_solution_v1(
payload_b64,
conf.get_hmac_secret(),
check_expires=True,
)
except Exception:
return False, "Invalid ALTCHA payload."

if not is_valid:
return False, error 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 UNFOLD_FOBI_ALTCHA_CACHE_ALIAS is misconfigured, caches[alias] will raise and break all ALTCHA verification. Consider wrapping the lookup in a try/except InvalidCacheBackendError (or a broader Exception) and, on failure, skip replay protection but log the misconfiguration so form submissions still work and the issue is visible.


return True, None
37 changes: 37 additions & 0 deletions src/unfold_fobi/contrib/altcha/conf.py
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
)
Loading
Loading