Metadata-Version: 2.4
Name: django-visualeyes
Version: 0.1.1
Summary: Reusable 'Sign in with VisualEyes' passwordless photo-login for Django.
Author: Vercet
License: LGPL-3.0-or-later
Project-URL: Homepage, https://aqa.com
Project-URL: Documentation, https://aqa.com/client/django
Project-URL: Source, https://git.vercet.net/jfarrelly_vercet/django-visualeyes
Keywords: django,authentication,passwordless,visualeyes
Classifier: Framework :: Django
Classifier: License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: COPYING
License-File: COPYING.LESSER
Requires-Dist: Django>=4.2
Dynamic: license-file

# django-visualeyes

Reusable **"Sign in with VisualEyes"** — AQA's passwordless photo login — for any
Django project. Instead of copy-pasting the client, views and template glue into
each site, `pip install django-visualeyes`, add a few settings, and wire three
URLs.

Targets Django **4.2 LTS through 6.x**, Python **3.10+**. Zero runtime
dependencies beyond Django (the API client uses the stdlib `urllib`).

## Install

```bash
pip install django-visualeyes
```

## Configure

Add the app (and, if you use multiple auth backends, the VisualEyes backend):

```python
INSTALLED_APPS = [
    # ...
    "visualeyes",
]

AUTHENTICATION_BACKENDS = [
    "django.contrib.auth.backends.ModelBackend",
    "visualeyes.backends.VisualEyesBackend",   # records VE logins distinctly
]
```

Include the URLs (the app namespaces itself as `visualeyes`):

```python
# project urls.py
urlpatterns = [
    path("accounts/", include("visualeyes.urls")),
    # ...
]
```

This exposes:

| URL                       | name                              | method |
|---------------------------|-----------------------------------|--------|
| `accounts/ve/start`       | `visualeyes:visualeyes_start`     | POST   |
| `accounts/ve/callback`    | `visualeyes:visualeyes_callback`  | GET    |

### Settings

All settings are prefixed `VISUALEYES_`:

| Setting                          | Required | Default                                     | Purpose |
|----------------------------------|----------|---------------------------------------------|---------|
| `VISUALEYES_CLIENT_ID`           | **yes**  | —                                           | API client id (sent as `X-VE-Client-Id`). |
| `VISUALEYES_CLIENT_SECRET`       | **yes**  | —                                           | HMAC signing secret. Keep it out of source control. |
| `VISUALEYES_API_BASE`            | no       | `"https://aqa.com"`                         | Base URL of the VisualEyes service. |
| `VISUALEYES_TIMEOUT`             | no       | `15`                                        | Per-request timeout, seconds. |
| `VISUALEYES_ATTEST_LOCAL_ACCOUNTS` | no     | `False`                                     | `True` → `ve_start` vouches for the account (`local_account: true`) and forwards the typed name; `False` → forwards the account email un-attested. Generalizes the portals' `VE_ALIAS_PROTOCOL`. |
| `VISUALEYES_LOGIN_REDIRECT`      | no       | `settings.LOGIN_REDIRECT_URL` or `"/"`      | Where to land after a successful login (when no safe `next`). |
| `VISUALEYES_CALLBACK_URL_NAME`   | no       | `"visualeyes_callback"`                      | URL name reversed to build the callback URL. |
| `VISUALEYES_SESSION_FLAG`        | no       | `"via_ve"`                                  | Session key set `True` after a VE login. |

A `manage.py check` warning (`visualeyes.W001`) fires if the required
credentials are unset.

## Login template

Load the tag library and drop the button **inside your existing login `<form>`**
(the one with the username field and `{% csrf_token %}`). The button re-submits
that form — including the username — to `ve_start` via `formaction` +
`formnovalidate`, so the blank password field doesn't block it. No JavaScript.

```html
{% load visualeyes %}

<form method="post" action="{% url 'login' %}">
  {% csrf_token %}
  {{ form.username.label_tag }} {{ form.username }}
  {{ form.password.label_tag }} {{ form.password }}
  <button type="submit">Sign in</button>

  {% visualeyes_button %}
  {# custom username field id: {% visualeyes_button username_field_id="id_login" %} #}
</form>
```

The default assumes Django's `id_username`; pass `username_field_id` if your
form differs.

## Showing errors in your login card

The views report failures through Django's `messages` framework
(`messages.error`), so **render messages where your form errors already appear
-- inside your login card**, not at the top of the page. Otherwise a VisualEyes
error lands wherever your base template renders messages (often top-left), away
from the password-error box.

If your `base.html` renders messages site-wide, make that block overridable and
suppress it on the login page:

```html
{# base.html #}
{% block messages %}
  {% for message in messages %}
    <div class="flash flash-{{ message.tags }}">{{ message }}</div>
  {% endfor %}
{% endblock %}
```

```html
{# registration/login.html #}
{% block messages %}{% endblock %}   {# don't render them at the top here #}
{% block content %}
  <div class="card">
    {% for message in messages %}
      <div class="flash flash-error">{{ message }}</div>
    {% endfor %}
    {{ form.non_field_errors }}
    {# ... your login form, including {% visualeyes_button %} ... #}
  </div>
{% endblock %}
```

## Hiding "change password" for passwordless users

VisualEyes-only accounts have no usable password, and a session that
authenticated via VisualEyes shouldn't offer a password change either. A
template tag guards a link:

```html
{% load visualeyes %}
{% visualeyes_can_change_password as can_change %}
{% if can_change %}
  <a href="{% url 'password_change' %}">Change password</a>
{% endif %}
```

It returns `user.has_usable_password and not request.session.via_ve`.

**Django 6.2+** additionally offers a URL-level guard:
`PasswordChangeView.usable_password_url` (and the accompanying
`SetPasswordMixin` support), which redirects users with no usable password away
from the change-password form. Prefer that at the view layer when you're on 6.2+;
the template tag remains the portable option for 4.2–6.1.

## How it works

1. **`ve_start`** (POST) — validates the typed username/email against the local
   `User` table **first** (`Q(username__iexact) | Q(email__iexact)`, active
   only). Unknown ⇒ error + redirect to login with **no** VisualEyes call
   (anti-enumeration). Otherwise it opens a challenge (attested or not per
   `ATTEST_LOCAL_ACCOUNTS`) and redirects to the register or challenge URL the
   service returns, stashing a safe `next` in the session.
2. The user completes the photo challenge on VisualEyes, which redirects back to
   **`ve_callback`** (GET) with a single-use result token in `?result=`.
3. **`ve_callback`** verifies the token. On pass it binds the echoed `alias`
   (an active local username) or else get-or-creates an email account with an
   unusable password, calls `login(..., backend="visualeyes.backends.VisualEyesBackend")`,
   sets the `via_ve` session flag, and redirects to the safe `next` or
   `LOGIN_REDIRECT`. A duress signal is logged, never surfaced.

### Retry / safety notes

- `start_challenge` retries **once** on connection-level failure — creating a
  challenge is safe to repeat.
- `verify` **never** retries — the result token is single-use, so a retry could
  double-consume it. It fails closed.
- HTTP error statuses (4xx/5xx) are **never** retried; they are returned to the
  caller as `(status, body)`.

## Development / tests

The suite **mocks the HTTP layer** — it never contacts the live VisualEyes
service.

```bash
python -m venv .venv && . .venv/bin/activate
pip install -e .
python runtests.py            # or: python runtests.py tests.test_views_start
```

## License

GNU Lesser General Public License v3.0 or later (`LGPL-3.0-or-later`). See [COPYING.LESSER](COPYING.LESSER) and [COPYING](COPYING).
