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 |
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.
{% 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 furtherrecheck_afterpast 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. WithSESSION_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: thesessionobject is then ignored and 0.2.x behaviour returns exactly.
How it works
ve_start(POST) — validates the typed username/email against the localUsertable 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 perATTEST_LOCAL_ACCOUNTS) and redirects to the register or challenge URL the service returns, stashing a safenextin the session.- The user completes the photo challenge on VisualEyes, which redirects back to
ve_callback(GET) with a single-use result token in?result=. ve_callbackverifies the token. On pass it binds the echoedalias(an active local username) or else get-or-creates an email account with an unusable password, callslogin(..., backend="visualeyes.backends.VisualEyesBackend"), sets thevia_vesession flag, applies any session-lifetime policy the response carried, and redirects to the safenextorLOGIN_REDIRECT. A duress signal is logged, never surfaced.
Retry / safety notes
start_challengeretries once on connection-level failure — creating a challenge is safe to repeat.verifynever 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_FAILdecides what a failure means, and the next request tries again.session_endnever retries and its result is ignored — it is advisory, and a logout must not wait on it.- A
recheck_urlthat is not on the configuredVISUALEYES_API_BASEorigin 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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a4307ce5af2597f04a5ede11a6e65aa081cc4dd6f671497b718a94dc32a90b05
|
|
| MD5 |
fd46babb4ee4238166edc3208e0df448
|
|
| BLAKE2b-256 |
46f22baf7e33195bbdf8a058705c73206a9ee9a3af117c2033b01a789edc4226
|
File details
Details for the file django_visualeyes-0.3.0-py3-none-any.whl.
File metadata
- Download URL: django_visualeyes-0.3.0-py3-none-any.whl
- Upload date:
- Size: 53.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.2.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9cda3d4cc313738f2435fd7bd8db4b23f60e9116401ac9cddfc70b1950c3a0de
|
|
| MD5 |
15c426b43bb232bb7f828b07289115d2
|
|
| BLAKE2b-256 |
8e887f26657f463ac1701271652992bd8932a1150b9863018697ddee393eb4f0
|