This release has been yanked by its maintainers, and will be ignored by installers, except when explicitly specified.
Consider using release 0.3.0 instead.
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")),
# ...
]
Run the migrations (the app stores an optional per-user VisualEyes flag — see "Per-user enablement" 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 |
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.
{% 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.
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, 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.- 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.2.1.tar.gz.
File metadata
- Download URL: django_visualeyes-0.2.1.tar.gz
- Upload date:
- Size: 41.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.2.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b417f6d840a1d785bcb83d5d03279b27a9906af99a85d7f3a7d752530f57e806
|
|
| MD5 |
b8a05cf898162e7af4e98a0c6c7032ed
|
|
| BLAKE2b-256 |
b33c06a9a6759f8358abeb92d039a4b7562ab38ef3f3d9d02bafbffeed235d72
|
File details
Details for the file django_visualeyes-0.2.1-py3-none-any.whl.
File metadata
- Download URL: django_visualeyes-0.2.1-py3-none-any.whl
- Upload date:
- Size: 38.1 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 |
6c6392dac48eba3fdfd0f91e54643e008191c4ab284739c5c9f2bc4e4eb25c4b
|
|
| MD5 |
592a604a283f3c2ca29e03cd8742377e
|
|
| BLAKE2b-256 |
787fb205b245ebb789ecfeeeac8c931154e9da536c797c01dc1a2e03d1e3cc87
|