Metadata-Version: 2.4
Name: django-altcha-widget
Version: 1.0.0
Summary: Django form field and widget for the ALTCHA proof-of-work CAPTCHA.
Author-email: Hervé Le Roy <hleroy@hleroy.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/hleroy/django-altcha-widget
Project-URL: Repository, https://github.com/hleroy/django-altcha-widget.git
Project-URL: Issues, https://github.com/hleroy/django-altcha-widget/issues
Project-URL: Changelog, https://github.com/hleroy/django-altcha-widget/blob/main/CHANGELOG.md
Keywords: captcha,django,widget,form,altcha,proof-of-work,privacy
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Web Environment
Classifier: Framework :: Django
Classifier: Framework :: Django :: 6.0
Classifier: Framework :: Django :: 6.1
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.12
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: src/django_altcha_widget/static/django_altcha_widget/altcha/LICENSE.txt
Requires-Dist: Django>=6.0
Requires-Dist: altcha<3.0.0,>=2.1.0
Provides-Extra: argon2
Requires-Dist: argon2-cffi; extra == "argon2"
Dynamic: license-file

# django-altcha-widget

[![PyPI](https://img.shields.io/pypi/v/django-altcha-widget.svg)](https://pypi.org/project/django-altcha-widget/)
[![Python versions](https://img.shields.io/pypi/pyversions/django-altcha-widget.svg)](https://pypi.org/project/django-altcha-widget/)
[![Django versions](https://img.shields.io/pypi/frameworkversions/django/django-altcha-widget.svg)](https://pypi.org/project/django-altcha-widget/)
[![Tests](https://github.com/hleroy/django-altcha-widget/actions/workflows/run-unit-tests.yml/badge.svg)](https://github.com/hleroy/django-altcha-widget/actions/workflows/run-unit-tests.yml)
[![License](https://img.shields.io/pypi/l/django-altcha-widget.svg)](https://github.com/hleroy/django-altcha-widget/blob/main/LICENSE)

A Django form field and widget for [ALTCHA](https://altcha.org), the
privacy-friendly proof-of-work CAPTCHA.

It runs **fully self-hosted**: the ALTCHA JavaScript is vendored into the
package, and the challenge is generated by your own server. No request ever
reaches an external service, and there is no npm install, no bundler
configuration and no CDN. It is **secure by default**, with built-in
**protection against replay attacks** ensuring each challenge is only ever
validated once, and it works under a **strict Content-Security-Policy** — no
`'unsafe-inline'` styles, no `blob:` workers — with nothing to configure.

Requires **Python 3.12+** and **Django 6.0+**.

## Contents

- [Installation](#installation)
- [Usage](#usage)
- [Configuration Options](#configuration-options)
- [Content Security Policy (CSP)](#content-security-policy-csp)
- [Replay Attack Protection](#replay-attack-protection)
- [Settings](#settings)
- [Logging](#logging)
- [Contributing](#contributing)
- [License](#license)

## Installation

1. **Install the package:**

   ```bash
   pip install django-altcha-widget
   ```

2. **Add to `INSTALLED_APPS`** in your project's `settings.py`:

   ```python
   INSTALLED_APPS = [
       # Other installed apps
       "django_altcha_widget",
   ]
   ```

3. **Set your secret HMAC key**, used to sign ALTCHA challenges. Treat it like a
   password:

   ```python
   ALTCHA_HMAC_KEY = "5f4dcc3b5aa765d61d8327deb882cf992b95990a9151374abd8ff8c5a7a0fe08"
   ```

   > [!NOTE]
   > Generate one with
   > ``python -c "import secrets; print(secrets.token_hex(64))"``

4. **Collect the static files**, as you would for any Django app:

   ```bash
   python manage.py collectstatic
   ```

That is the whole installation. The
[ALTCHA](https://github.com/altcha-org/altcha) JavaScript is vendored in the
package, so there is no npm dependency, no bundler entry point to edit and no
CDN.

## Usage

Add the field to your form:

```python
from django import forms
from django_altcha_widget import AltchaField


class MyForm(forms.Form):
    captcha = AltchaField()
```

There is nothing to add to your template: the widget emits the stylesheet and
script tags itself, so a plain `{{ form }}` is enough. The same tags are also
exposed through Django's
[form media](https://docs.djangoproject.com/en/dev/topics/forms/media/), so a
base template already rendering `{{ form.media }}` gets a second, harmless copy
rather than moving them into the `<head>`.

## Configuration Options

`AltchaField` accepts the options documented in
[Altcha's widget integration guide](https://altcha.org/docs/v2/widget-integration/):

```python
from django import forms
from django_altcha_widget import AltchaField


class MyForm(forms.Form):
    captcha = AltchaField(
        display="floating",  # Enables floating behavior
        debug=True,  # Enables debug mode (for development)
        # Additional options supported by Altcha
    )
```

The options ALTCHA takes as HTML attributes — `auto`, `challenge`,
`configuration`, `display`, `language`, `theme`, `type` and `workers` — are
rendered as attributes of the `<altcha-widget>` element. Every other option is
collected into the JSON-encoded `configuration` attribute.

Two arguments are refused outright:

- **`name`**, with a `TypeError`. It is the name the CAPTCHA value is submitted
  under, so it always comes from the form field itself.
- **`required=False`**, with a `ValueError`, rather than being honoured or
  silently ignored: anything submitting the form without the field would pass
  unchallenged, so an optional CAPTCHA is not a weaker one but no CAPTCHA at
  all. Leave the field out of the forms that do not need one, or set
  [`ALTCHA_VERIFICATION_ENABLED`](#altcha_verification_enabled) to `False` to
  stop verifying.

### Register a URL to provide the challenge

By default the challenge is generated by the `AltchaField` and embedded in the
rendered HTML as JSON, using the `challenge` option. That same option also
accepts a URL for the widget's JavaScript to fetch instead.

`django_altcha_widget` ships a ready-to-use view for that. Register it:

```python
from django.urls import path
from django_altcha_widget import AltchaChallengeView

urlpatterns += [
    path("altcha/challenge/", AltchaChallengeView.as_view(), name="altcha_challenge"),
]
```

and point the field at it:

```python
from django.urls import reverse_lazy
from django import forms
from django_altcha_widget import AltchaField


class MyForm(forms.Form):
    captcha = AltchaField(
        challenge=reverse_lazy("altcha_challenge"),
    )
```

> [!NOTE]
> Challenge generation can be customized when registering the view, for example
> ``AltchaChallengeView.as_view(algorithm="ARGON2ID", cost=3)``.

Fetching the challenge also keeps it out of the form's HTML, which is what makes
it safe to serve that page from a cache — see
[Replay Attack Protection](#replay-attack-protection). The view is served
`no-store`, so the challenge itself is never cached.

## Content Security Policy (CSP)

**A strict CSP works out of the box.** There is nothing to enable:

```
Content-Security-Policy: script-src 'self'; style-src 'self'; worker-src 'self'
```

No `'unsafe-inline'`, no `blob:`, no third-party origin to allowlist: everything
the widget loads is served from your own static files.

> [!NOTE]
> The `ARGON2ID` and `SCRYPT` algorithms are implemented in WebAssembly and
> additionally require `script-src 'wasm-unsafe-eval'`.
> The default `PBKDF2/SHA-256` algorithm does not.

## Replay Attack Protection

django-altcha-widget **automatically protects against replay attacks**: a
challenge that validates is claimed in a cache, and any later attempt to reuse
it is rejected. The claim is a single atomic cache operation, so submitting one
payload many times at once does not let any copy slip through, and it is held
for as long as the challenge keeps verifying — including challenges issued with
an expiry of their own through `AltchaChallengeView`.

This is enabled by default and needs no configuration for single-process
deployments.

> [!IMPORTANT]
> Replay protection uses Django's `default` cache backend, which is
> `LocMemCache` unless you configure it otherwise. This in-memory cache is
> **not shared across workers**. If you run multiple workers (e.g., with gunicorn
> or uwsgi), configure a shared cache backend such as Redis or Memcached.

> [!IMPORTANT]
> **Do not cache a page that embeds a challenge.** By default the challenge is
> generated afresh on every render and inlined into the form's HTML, so any
> cache in front of that page serves one challenge to many visitors: the first
> to submit burns it, and everyone else is rejected as a replay. If the view
> rendering the form is wrapped in `@cache_page`, sits behind
> `UpdateCacheMiddleware`, or is cached by a CDN or reverse proxy, either
> exclude it with
> [`@never_cache`](https://docs.djangoproject.com/en/dev/topics/http/decorators/#django.views.decorators.cache.never_cache)
> or have the widget fetch the challenge from a URL instead — see
> [Register a URL to provide the challenge](#register-a-url-to-provide-the-challenge).
> `AltchaChallengeView` sets the no-store headers for you.

## Settings

### ALTCHA_HMAC_KEY

**Required.** The key used to HMAC-sign ALTCHA challenges; it **must be kept
secret**. Forging a challenge is exactly as hard as guessing it, so keys shorter
than 32 characters are rejected with an `ImproperlyConfigured` error.

### ALTCHA_CACHE_ALIAS

Django cache alias used for replay attack protection. Defaults to `"default"`,
which needs no further configuration if that cache is already a shared backend
(Redis, Memcached, database).

To use a dedicated cache, define one and point to it:

```python
CACHES = {
    "altcha": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
        "LOCATION": "redis://127.0.0.1:6379",
    }
}
ALTCHA_CACHE_ALIAS = "altcha"
```

[Django's database cache](https://docs.djangoproject.com/en/dev/topics/cache/#database-caching)
is a simple alternative if you would rather not run Redis or Memcached — use
`"BACKEND": "django.core.cache.backends.db.DatabaseCache"` with the table name
as `LOCATION`, then create it with `python manage.py createcachetable`.

### ALTCHA_CHALLENGE_EXPIRE

Challenge expiration duration in milliseconds. Defaults to 20 minutes as per
[Altcha security recommendations](https://altcha.org/docs/v2/security-recommendations/).

### ALTCHA_ALGORITHM

Key derivation function used for the Proof-of-Work challenges. Defaults to
`"PBKDF2/SHA-256"`. Supported values are `"PBKDF2/SHA-256"`,
`"PBKDF2/SHA-384"`, `"PBKDF2/SHA-512"`, `"SHA-256"`, `"SHA-384"`, `"SHA-512"`,
`"ARGON2ID"` and `"SCRYPT"`. See
[Altcha's Proof-of-Work documentation](https://altcha.org/docs/v2/proof-of-work-captcha/)
for the trade-offs between them.

> [!NOTE]
> `"ARGON2ID"` requires the `argon2-cffi` package, installable with
> `pip install 'django-altcha-widget[argon2]'`.

### ALTCHA_COST

Algorithm-specific cost: the number of iterations for `PBKDF2` and `SHA`, the
time cost for `ARGON2ID` and `SCRYPT`. Defaults to `5000`, the value recommended
upstream for `PBKDF2/SHA-256`.

### ALTCHA_TRANSLATIONS

The [Altcha translations](https://altcha.org/docs/v2/widget-integration/#internationalization-i18n)
to load, as a language code or `"all"` for the combined bundle covering every
language. Defaults to `None`, which loads none and leaves the widget in English.

Serving a single language is much lighter than the combined bundle — **1.4 KB
gzipped instead of 18.2 KB**:

```python
ALTCHA_TRANSLATIONS = "fr-fr"
```

Every per-language file ALTCHA ships is vendored, along with the combined
`"all"` bundle, so any of those works without installing anything. A trailing
`.js` is tolerated: `"fr-fr"` and `"fr-fr.js"` name the same file.

The four **regional** bundles ALTCHA also publishes — `"africa"`, `"americas"`,
`"asia"` and `"europe"` — are **not** vendored. Naming one loads nothing under a
plain static files storage, and raises `ValueError: Missing staticfiles manifest
entry` under `ManifestStaticFilesStorage`. Use `"all"` or a language code
instead.

### ALTCHA_VERIFICATION_ENABLED

Set to `False` to skip Altcha validation altogether. Defaults to `True`.

## Logging

Logs are emitted through the standard Python `logging` module under the logger
name `django_altcha_widget`. Nothing is logged under normal operation; logging
fires only on validation failures and misconfiguration:

- **WARNING** on invalid or missing CAPTCHA tokens submitted to a form.
- **WARNING** on replay attempts (a challenge reused after it has already been validated).
- **ERROR** when `ALTCHA_HMAC_KEY` is not configured.
- **Exception with traceback** when verification or payload decoding raises
  unexpectedly.

Payloads, challenge values, and the HMAC key are never included in log
messages.

To see them, add the logger to your project's `LOGGING` setting:

```python
LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
        },
    },
    "loggers": {
        "django_altcha_widget": {
            "handlers": ["console"],
            "level": "WARNING",
        },
    },
}
```

Set `"level": "ERROR"` to see only misconfiguration and unexpected failures,
or `"level": "DEBUG"` to see additional diagnostic messages during development.

## Contributing

Issues and pull requests are welcome. See
[DEVNOTES.md](https://github.com/hleroy/django-altcha-widget/blob/main/DEVNOTES.md)
for how to set up a development environment and how the test matrix, the
vendored assets and the release pipeline work. Please run `just check` and
`just test` before opening a pull request.

## License

This project is licensed under the **MIT License**.
See the [LICENSE](https://github.com/hleroy/django-altcha-widget/blob/main/LICENSE)
file for details.

It began as a fork of [django-altcha](https://github.com/aboutcode-org/django-altcha),
Copyright (c) nexB Inc. and others, also MIT licensed. It is published as a
separate package and shares no release history with it; installing both in the
same environment is not supported.

The ALTCHA JavaScript library is Copyright (c) 2023-2026 Daniel Regeci, BAU
Software s.r.o., MIT licensed. It is vendored in this package and redistributed
under that license, whose text travels with it at
`src/django_altcha_widget/static/django_altcha_widget/altcha/LICENSE.txt`. The
exact version and provenance are recorded alongside it in `VENDOR.json`.
