Skip to main content

Reusable 'Sign in with VisualEyes' passwordless photo-login for Django.

Project description

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")),
    # ...
]

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.

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). Unknown ⇒ 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.

Project details


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.1.1.tar.gz (33.4 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.1.1-py3-none-any.whl (31.2 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: django_visualeyes-0.1.1.tar.gz
  • Upload date:
  • Size: 33.4 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.1.1.tar.gz
Algorithm Hash digest
SHA256 da6d495ee868134b9253822cb78b8c4f952e47ad6cb82d9db00640aea370c3bf
MD5 e750d7f4e75b46352ca755c501dfe90b
BLAKE2b-256 89a33d983c831e3d62ddc22d998c653f5290d5143e76f4618ed2a9660255e5bf

See more details on using hashes here.

File details

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

File metadata

File hashes

Hashes for django_visualeyes-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 697849d1dbdb9b4e48912acdb509af8a7b18b5abc726dd8d3d3a6d781b8e363a
MD5 bc9fac25d79cf36674f8103e35c060b1
BLAKE2b-256 c47deabe464431af4a7ad1b64ce4eb439ac41767b7dfe19de3b130879c935619

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page