Skip to main content
Yanked

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

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

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

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.2.1.tar.gz (41.1 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.2.1-py3-none-any.whl (38.1 kB view details)

Uploaded Python 3

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

Hashes for django_visualeyes-0.2.1.tar.gz
Algorithm Hash digest
SHA256 b417f6d840a1d785bcb83d5d03279b27a9906af99a85d7f3a7d752530f57e806
MD5 b8a05cf898162e7af4e98a0c6c7032ed
BLAKE2b-256 b33c06a9a6759f8358abeb92d039a4b7562ab38ef3f3d9d02bafbffeed235d72

See more details on using hashes here.

File details

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

File metadata

File hashes

Hashes for django_visualeyes-0.2.1-py3-none-any.whl
Algorithm Hash digest
SHA256 6c6392dac48eba3fdfd0f91e54643e008191c4ab284739c5c9f2bc4e4eb25c4b
MD5 592a604a283f3c2ca29e03cd8742377e
BLAKE2b-256 787fb205b245ebb789ecfeeeac8c931154e9da536c797c01dc1a2e03d1e3cc87

See more details on using hashes here.

Release history Release notifications | RSS feed

0.3.0

2 files

This release

0.2.1 This release

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