Metadata-Version: 2.4
Name: django-visualeyes
Version: 0.3.0
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")),
    # ...
]
```

Add the session middleware, after `AuthenticationMiddleware` (it keeps
VisualEyes sessions honest — see "Session lifetime & sign-out"):

```python
MIDDLEWARE = [
    # ...
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "visualeyes.middleware.VisualEyesSessionMiddleware",
]
```

Run the migrations (the app stores an optional per-user VisualEyes flag and a
handle → session mapping — see "Per-user enablement" and "Session lifetime &
sign-out" below):

```bash
python manage.py migrate visualeyes
```

This exposes:

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

`ve/logout-hook` is the signed server-to-server back-channel VisualEyes calls
on "sign out everywhere". It is safe to expose without registering it —
unsigned requests are refused — but it does nothing until you tell VisualEyes
its absolute URL.

### 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. |
| `VISUALEYES_SESSION_POLICY_ENFORCE` | no    | `True`                                      | Master switch for the session-lifetime contract. `False` ignores the `session` object in a verify response entirely — sessions live for your `SESSION_COOKIE_AGE`, exactly as in 0.2.x. |
| `VISUALEYES_RECHECK_FAIL`        | no       | `"open"`                                    | What the middleware does when a recheck can't be completed: `"open"` keeps the session and retries (with a hard grace cap), `"closed"` ends it. |
| `VISUALEYES_EVERY_VISIT_IDLE`    | no       | `900`                                       | Idle timeout, seconds, for `every_visit` (bank mode) sessions. `0` disables the idle check. |

`manage.py check` warns about the common mistakes: missing credentials
(`visualeyes.W001`), a non-HTTPS API base (`W002`), an admin toggle that can't
render (`W003`), a `VISUALEYES_RECHECK_FAIL` value that is neither `"open"` nor
`"closed"` (`W004`), and session policies being enforced without
`VisualEyesSessionMiddleware` installed (`W005`).

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

## Per-user enablement & the admin "VisualEyes" toggle

Since 0.2.0 each account can have VisualEyes sign-in enabled or disabled
independently of password-based authentication — one, the other, both, or
neither. The flag lives in the `VisualEyesUser` model; **accounts with no row
count as enabled** (exactly the pre-0.2.0 behaviour, so upgrading changes
nothing until an admin disables someone).

`VisualEyesUserAdmin` drops into the Django admin in place of the stock
`UserAdmin` and adds a "VisualEyes: Enabled/Disabled" radio row directly below
Username on both the add-user and change-user forms — styled like Django's own
"Password-based authentication" row. Selecting VisualEyes **Enabled** and
Password-based authentication **Disabled** creates a passwordless account with
no password-field errors.

On Django **< 5.1** (which has no "Password-based authentication" toggle) the
VisualEyes row still renders, but the admin add form keeps its required
password fields — create the user, then remove the password programmatically
if you want a VE-only account; the full passwordless-add UX needs 5.1+.

```python
# any installed app's admin.py
from django.contrib import admin
from django.contrib.auth import get_user_model

from visualeyes.admin import VisualEyesUserAdmin

User = get_user_model()
admin.site.unregister(User)
admin.site.register(User, VisualEyesUserAdmin)
```

The flag is enforced in `ve_start` (a disabled account gets the same response
as an unknown one — no enumeration signal) and re-checked in `ve_callback`
(in case the admin flips it while a challenge is in flight). Programmatic
access: `visualeyes.models.user_ve_enabled(user)` /
`set_user_ve_enabled(user, enabled)`.

## Pointing VisualEyes-only users at the button

Optionally, replace the login form so that a *password* attempt against a
VisualEyes-only account (active, no usable password, VisualEyes enabled) gets
"This account signs in with VisualEyes… use the button" instead of the stock
"enter a correct username and password" dead end:

```python
from visualeyes.forms import VisualEyesAwareAuthenticationForm

path("accounts/login/", auth_views.LoginView.as_view(
    authentication_form=VisualEyesAwareAuthenticationForm), name="login"),
```

**Trade-off, opt in deliberately:** the tailored message confirms that the
typed name is a real account. Fine on internal, login-gated portals; on a
public site where account existence is sensitive, keep the stock form.

## Session lifetime & sign-out

Before 0.3.0 a VisualEyes login produced an ordinary Django session that lived
for `SESSION_COOKIE_AGE` (two weeks by default) no matter what the account
needed. Since 0.3.0 the `/api/v1/verify` response may carry an additive
`session` object saying how long *this* login may last, and the package honours
it:

```json
"session": {
  "policy": "every_visit",
  "max_age": 0,
  "handle": "vesh_0f3a…",
  "recheck_url": "https://aqa.com/api/v1/session/check",
  "recheck_after": 900
}
```

| `policy`         | `max_age`  | What the package does |
|------------------|------------|-----------------------|
| `every_visit`    | `0`        | **Bank mode.** `set_expiry(0)` — a non-persistent cookie that dies with the browser — plus an idle timeout (`VISUALEYES_EVERY_VISIT_IDLE`, default 15 min). No remember-me. |
| `bounded`        | seconds    | `set_expiry(max_age)` **and** an absolute deadline stored on the session, so the cap is measured from login and cannot be slid forward by `SESSION_SAVE_EVERY_REQUEST`. |
| `until_logout`   | `null`     | Your project's own session lifetime applies, but the session is revalidated with VisualEyes every `recheck_after` seconds and ends as soon as the handle stops being active. |
| *(no `session`)* | —          | `client_managed`: nothing changes, `SESSION_COOKIE_AGE` governs. Exactly the 0.2.x behaviour. |

Nothing is required of your views: `ve_callback` applies the policy at login
and `VisualEyesSessionMiddleware` maintains it afterwards. If the server sends
something the package cannot honour — an unknown policy, a `bounded` with no
usable `max_age`, an `until_logout` it cannot arrange rechecks for — it falls
back to a browser-session cookie and logs a warning. For a session lifetime,
the safe direction to be wrong in is *shorter*.

### What the middleware costs

One recheck per session per `recheck_after`, and nothing else. A request only
reaches the network if the session is a VisualEyes session **and** its own
recheck deadline has passed; every other request is a few dict lookups. Page
loads in between never call out, whatever their number.

When a recheck cannot be completed — VisualEyes unreachable, a 5xx, or a `200`
that never mentions the handle — `VISUALEYES_RECHECK_FAIL` decides:

* `"open"` (default): keep the session and retry, at most once a minute rather
  than on every request. This is *not* indefinite: one further `recheck_after`
  past the missed deadline is the whole grace period, after which the session
  ends anyway.
* `"closed"`: end the session immediately. Use it when an unrevocable session
  is worse than a false logout.

### Registering the sign-out hook

To have VisualEyes push "sign out everywhere" to your site, give it the
absolute URL of the hook. Build it from the URL name rather than hardcoding a
path:

```python
from django.urls import reverse

# In a request:
url = request.build_absolute_uri(reverse("visualeyes:ve_logout_hook"))

# Or from a management command / settings, where there is no request:
from django.contrib.sites.models import Site
url = "https://%s%s" % (
    Site.objects.get_current().domain,
    reverse("visualeyes:ve_logout_hook"),
)
# -> https://example.com/accounts/ve/logout-hook
```

Send that URL to VisualEyes (client settings on aqa.com). VisualEyes then POSTs
`{"type": "logout", "user": …, "alias": …, "handles": [...], "ts": …}` to it,
signed with **your client secret** using the same HMAC scheme as outbound
calls. The view checks the signature in constant time inside a ±120s window,
destroys the mapped sessions and replies `200 {"ok": true, "ended": <n>}`.
Handles it does not recognise are not an error — the reply looks the same
either way, so the response cannot be used to probe which handles exist here.

The hook is only a *fast path*: a site that never registers it still loses
revoked sessions at the next recheck. Register it if you want sign-out to be
immediate.

### Logging out

Your logout view needs no changes. The package hooks Django's
`user_logged_out` signal and reports the closed session to
`POST /api/v1/session/end` so VisualEyes stops listing it as live. That call is
strictly best effort: it never retries, never raises, and never delays the
logout it is reporting.

### Caveats

* The handle → session mapping (`VisualEyesSession`) is what lets a push logout
  find sessions, so the hook needs a **server-side session backend**. With
  `SESSION_ENGINE = "…signed_cookies"` there is nothing on the server to
  destroy; rechecks still work, push logout does not.
* Rows are cleaned up as they are used — on logout, on a failed recheck, on a
  push logout, and when a fresh login cycles the session key — so no periodic
  job is needed.
* Turn the whole thing off with `VISUALEYES_SESSION_POLICY_ENFORCE = False`:
  the `session` object is then ignored and 0.2.x behaviour returns exactly.

## 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) and checks the per-user VisualEyes flag. Unknown or disabled ⇒ the
   same 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, applies any session-lifetime policy the
   response carried, 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.
- `session_check` (the recheck) **never** retries: it runs inside an ordinary
  page request, so a stalled connection must not add retry delay to someone's
  page load. `VISUALEYES_RECHECK_FAIL` decides what a failure means, and the
  next request tries again.
- `session_end` **never** retries and its result is ignored — it is advisory,
  and a logout must not wait on it.
- A `recheck_url` that is not on the configured `VISUALEYES_API_BASE` origin is
  refused and the default endpoint used instead: that URL arrives over the
  network, and signing a request to whatever host it names would hand that host
  your client id and a valid signature.
- 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).
