Skip to content

feat: add optional ALTCHA integration for DRF public form submissions - #21

Merged
metaforx merged 3 commits into
mainfrom
feat/t25-altcha-backend-drf
Apr 10, 2026
Merged

feat: add optional ALTCHA integration for DRF public form submissions#21
metaforx merged 3 commits into
mainfrom
feat/t25-altcha-backend-drf

Conversation

@metaforx

@metaforx metaforx commented Apr 10, 2026

Copy link
Copy Markdown
Owner
  • add altcha support
  • limit to public forms

Summary by Sourcery

Add optional ALTCHA proof-of-work protection for public DRF form submissions and expose it via a dedicated contrib app and API field.

New Features:

  • Introduce an optional unfold_fobi.contrib.altcha app providing ALTCHA challenge generation, verification, and settings-backed configuration.
  • Expose an ALTCHA synthetic field in the DRF form-fields API for public forms when ALTCHA is enabled, along with a public challenge endpoint for frontend clients.

Enhancements:

  • Integrate ALTCHA verification into Fobi's DRF form submission flow via a patch that enforces proof-of-work on public form updates while keeping the base package free of hard ALTCHA dependencies.
  • Extend project documentation with setup and usage instructions for enabling ALTCHA protection and consuming it from Nuxt/DRF-based frontends.

Tests:

  • Add comprehensive tests covering disabled-mode behavior, form-fields ALTCHA exposure, challenge generation and solvability, and verification success, failure, and replay scenarios.

@sourcery-ai

sourcery-ai Bot commented Apr 10, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Adds an optional ALTCHA contrib app that integrates proof-of-work anti-bot checks into DRF-based public form submissions, exposes ALTCHA as a synthetic field in the form-fields API, and wires challenge generation, verification, and replay protection into the existing Fobi DRF update flow while keeping the base package import-safe when ALTCHA is disabled.

Sequence diagram for DRF public form submission with ALTCHA verification

sequenceDiagram
    actor User
    participant FrontendClient
    participant FobiFormFieldsEndpoint as FobiDRFFormFieldsEndpoint
    participant AltchaChallengeEndpoint as AltchaChallengeEndpoint
    participant FobiFormEntryViewSet as FobiDRFFormEntryViewSet
    participant AltchaPatch as AltchaPatchUpdate
    participant AltchaChallengeVerifier as AltchaChallengeVerifier
    participant DjangoCache
    participant FobiHandlers

    User->>FrontendClient: Open public form page
    FrontendClient->>FobiFormFieldsEndpoint: GET /api/fobi-form-fields/slug/
    FobiFormFieldsEndpoint->>FobiFormFieldsEndpoint: Check form_entry.is_public
    FobiFormFieldsEndpoint->>FobiFormFieldsEndpoint: Check app installed and is_enabled
    FobiFormFieldsEndpoint-->>FrontendClient: Fields JSON including AltchaField

    FrontendClient->>AltchaChallengeEndpoint: GET /api/altcha-challenge/
    AltchaChallengeEndpoint->>AltchaChallengeVerifier: create_challenge
    AltchaChallengeVerifier->>AltchaChallengeVerifier: altcha.create_challenge_v1
    AltchaChallengeVerifier-->>AltchaChallengeEndpoint: Challenge dict
    AltchaChallengeEndpoint-->>FrontendClient: Challenge JSON

    FrontendClient->>FrontendClient: Solve challenge client side

    FrontendClient->>FobiFormEntryViewSet: PUT /api/fobi-form-entry/slug/ with form data and altcha payload
    FobiFormEntryViewSet->>AltchaPatch: update_with_altcha
    AltchaPatch->>AltchaPatch: Check conf.is_enabled
    AltchaPatch->>AltchaPatch: instance = get_object, check instance.is_public
    AltchaPatch->>AltchaChallengeVerifier: verify_payload(payload_b64)
    AltchaChallengeVerifier->>AltchaChallengeVerifier: altcha.verify_solution_v1
    AltchaChallengeVerifier->>DjangoCache: Check replay cache
    DjangoCache-->>AltchaChallengeVerifier: Replay status
    AltchaChallengeVerifier-->>AltchaPatch: ok or error

    alt Altcha verification fails
        AltchaPatch-->>FrontendClient: HTTP 400 with detail
    else Altcha verification succeeds
        AltchaPatch->>AltchaPatch: Remove altcha field from request.data
        AltchaPatch->>FobiHandlers: original_update
        FobiHandlers-->>FrontendClient: Form submission response
    end
Loading

Class diagram for ALTCHA contrib components and their relationships

classDiagram
    class UnfoldFobiAltchaConfig {
        +str default_auto_field
        +str name
        +str label
        +str verbose_name
    }

    class AltchaConf {
        +get_hmac_secret()
        +get_max_number()
        +get_algorithm()
        +get_field_name()
        +get_cache_alias()
        +get_challenge_expiry()
        +is_enabled()
    }

    class AltchaChallenge {
        +create_challenge()
        +verify_payload(payload_b64)
    }

    class AltchaPatch {
        +apply()
        +update_with_altcha(self, request, args, kwargs)
        -original_update
    }

    class AltchaViews {
        +altcha_challenge(request)
    }

    class FobiFormEntryViewSet {
        +update(self, request, args, kwargs)
    }

    class DjangoCacheBackend {
        +get(key)
        +set(key, value, timeout)
    }

    class AltchaLibrary {
        +create_challenge_v1(hmac_key, max_number, algorithm, expires)
        +verify_solution_v1(payload_b64, hmac_key, check_expires)
    }

    UnfoldFobiAltchaConfig --> AltchaConf : configures
    AltchaPatch --> AltchaConf : reads_settings
    AltchaPatch --> AltchaChallenge : uses_verify_payload
    AltchaPatch --> FobiFormEntryViewSet : monkey_patches_update

    AltchaChallenge --> AltchaConf : reads_settings
    AltchaChallenge --> DjangoCacheBackend : replay_protection
    AltchaChallenge --> AltchaLibrary : challenge_and_verification

    AltchaViews --> AltchaChallenge : create_challenge

    AltchaConf --> UnfoldFobiAltchaConfig : enabled_when_installed
Loading

File-Level Changes

Change Details Files
Expose a synthetic ALTCHA field in the DRF form-fields API response for public forms when ALTCHA is enabled.
  • After building the form field list, append an AltchaField entry only when the form entry is public and the ALTCHA contrib app is installed and enabled via configuration.
  • Provide stable field metadata (name, type, widget, label, required) intended for frontend rendering rather than server-side Fobi forms.
src/unfold_fobi/api/views.py
Introduce an optional ALTCHA contrib app that provides challenge generation, payload verification with replay protection, configuration helpers, and a public challenge endpoint.
  • Implement create_challenge using the altcha library with configurable HMAC secret, max number, algorithm, and expiry, returning a dict suitable for frontend consumption.
  • Implement verify_payload to validate a base64 ALTCHA solution using altcha.verify_solution_v1, enforcing expiry checks and one-time use via Django cache.
  • Add a DRF AllowAny, no-cache altcha_challenge view and URL pattern to expose GET /api/altcha-challenge/ for frontends.
  • Provide AppConfig and a conf module that sources all ALTCHA-related settings and defines an is_enabled flag based on INSTALLED_APPS and the HMAC secret.
src/unfold_fobi/contrib/altcha/challenge.py
src/unfold_fobi/contrib/altcha/conf.py
src/unfold_fobi/contrib/altcha/views.py
src/unfold_fobi/contrib/altcha/urls.py
src/unfold_fobi/contrib/altcha/apps.py
Patch the Fobi DRF update view to enforce ALTCHA verification on public form submissions before Fobi processing, while keeping the patch optional and idempotent.
  • Define an apply() function in the ALTCHA contrib app that monkey-patches FobiFormEntryViewSet.update only when ALTCHA is enabled and the fobi DRF integration is importable.
  • In the wrapper, re-check configuration at runtime, enforce ALTCHA only for public forms, verify the ALTCHA payload using verify_payload, return 400 with a message on failure, and strip the ALTCHA field from request.data before delegating to the original update method.
  • Mark the patched method to avoid double-patching across multiple apply() calls.
src/unfold_fobi/contrib/altcha/patch.py
Integrate the ALTCHA patch into the existing patch application pipeline while keeping the base package free of hard altcha imports, and ensure behavior stays unchanged when ALTCHA is disabled.
  • Add an internal _apply_altcha helper that attempts to import and apply the ALTCHA patch, swallowing ImportError so projects without the contrib app or dependency still load.
  • Invoke _apply_altcha in apply_all so ALTCHA validation is wired automatically when the contrib app is installed and enabled.
  • Add tests asserting that base unfold_fobi contains no direct altcha imports outside contrib, that existing form-fields and submission flows work without ALTCHA, and that ALTCHA behavior (field presence, challenge shape, verification including replay and secret mismatch) works as expected.
src/unfold_fobi/patches/__init__.py
tests/captcha/test_altcha.py
Document the new ALTCHA integration, configuration, and behavior in the README and internal task spec, including DRF and Nuxt-SSR oriented usage notes.
  • Extend the top-level feature list and add a dedicated README section describing ALTCHA installation, settings, challenge endpoint wiring, and the client flow (form-fields, challenge fetch, payload submit, backend verification).
  • Renumber subsequent README sections to account for the new ALTCHA section.
  • Add an internal task document capturing requirements, scope, and acceptance criteria for ALTCHA backend + DRF field integration and Nuxt SSR flows.
README.md
.agents/tasks/T25_altcha_backend_drf_field.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

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.

Hey - I've found 4 issues, and left some high level feedback:

  • Consider short-circuiting the altcha_challenge view (e.g. 404/400) when conf.is_enabled() is false to avoid hitting altcha.create_challenge_v1 with a missing HMAC secret and to keep the endpoint contract consistent with the rest of the opt-in integration.
  • In verify_payload, using the raw base64 payload (even truncated) as the cache key for replay protection could be fragile or overly long; hashing the payload (e.g. SHA-256) into a fixed-length key would be more robust and cache-backend-friendly.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Consider short-circuiting the `altcha_challenge` view (e.g. 404/400) when `conf.is_enabled()` is false to avoid hitting `altcha.create_challenge_v1` with a missing HMAC secret and to keep the endpoint contract consistent with the rest of the opt-in integration.
- In `verify_payload`, using the raw base64 payload (even truncated) as the cache key for replay protection could be fragile or overly long; hashing the payload (e.g. SHA-256) into a fixed-length key would be more robust and cache-backend-friendly.

## Individual Comments

### Comment 1
<location path="src/unfold_fobi/api/views.py" line_range="192" />
<code_context>
+                "name": get_field_name(),
+                "type": "AltchaField",
+                "widget": "AltchaWidget",
+                "label": "ALTCHA",
+                "required": True,
+                "help_text": "",
</code_context>
<issue_to_address>
**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:

```python
from rest_framework.response import Response
from django.utils.translation import gettext_lazy as _

```

```python
            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.
</issue_to_address>

### Comment 2
<location path="src/unfold_fobi/contrib/altcha/challenge.py" line_range="10-19" />
<code_context>
+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()
+
+
</code_context>
<issue_to_address>
**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.
</issue_to_address>

### Comment 3
<location path="src/unfold_fobi/contrib/altcha/challenge.py" line_range="47-51" />
<code_context>
+        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
</code_context>
<issue_to_address>
**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.
</issue_to_address>

### Comment 4
<location path="README.md" line_range="250-251" />
<code_context>
+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.
+4. Backend verifies the payload before processing the form submission.
</code_context>
<issue_to_address>
**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")”).

```suggestion
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.
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

"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.

Comment on lines +10 to +19
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,

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.

Comment on lines +47 to +51
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)

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.

Comment thread README.md
Comment on lines +250 to +251
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.

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.

@metaforx
metaforx merged commit 1dda649 into main Apr 10, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant