Skip to main content

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

pip install django-visualeyes

Configure

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

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):

# 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"):

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):

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

{% 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:

{# base.html #}
{% block messages %}
  {% for message in messages %}
    <div class="flash flash-{{ message.tags }}">{{ message }}</div>
  {% endfor %}
{% endblock %}
{# 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:

{% 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+.

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

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:

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

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.

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 and COPYING.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

django_visualeyes-0.3.0.tar.gz (60.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

django_visualeyes-0.3.0-py3-none-any.whl (53.5 kB view details)

Uploaded Python 3

File details

Details for the file django_visualeyes-0.3.0.tar.gz.

File metadata

  • Download URL: django_visualeyes-0.3.0.tar.gz
  • Upload date:
  • Size: 60.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.6

File hashes

Hashes for django_visualeyes-0.3.0.tar.gz
Algorithm Hash digest
SHA256 a4307ce5af2597f04a5ede11a6e65aa081cc4dd6f671497b718a94dc32a90b05
MD5 fd46babb4ee4238166edc3208e0df448
BLAKE2b-256 46f22baf7e33195bbdf8a058705c73206a9ee9a3af117c2033b01a789edc4226

See more details on using hashes here.

File details

Details for the file django_visualeyes-0.3.0-py3-none-any.whl.

File metadata

File hashes

Hashes for django_visualeyes-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 9cda3d4cc313738f2435fd7bd8db4b23f60e9116401ac9cddfc70b1950c3a0de
MD5 15c426b43bb232bb7f828b07289115d2
BLAKE2b-256 8e887f26657f463ac1701271652992bd8932a1150b9863018697ddee393eb4f0

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.3.0 This release

2 files

0.2.1

2 files

0.2.0

2 files

0.1.1

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page