From 9976d02b5c17e84db73a718490baf876b204177d Mon Sep 17 00:00:00 2001
From: Fredrik Jonsson
Date: Mon, 23 Mar 2026 17:07:12 +0100
Subject: [PATCH 01/13] Implementing passkeys as new login method for Hypha.
---
hypha/apply/users/forms.py | 6 +-
hypha/apply/users/middleware.py | 6 +-
hypha/apply/users/migrations/0028_passkeys.py | 45 +++
hypha/apply/users/models.py | 28 ++
hypha/apply/users/passkey_views.py | 264 ++++++++++++++++++
.../apply/users/templates/users/account.html | 23 +-
.../users}/includes/org_login_button.html | 0
.../users/includes/passkey_login_button.html | 15 +
.../includes/password_login_button.html | 0
.../includes/passwordless_login_button.html | 0
.../templates/users}/includes/user_menu.html | 2 +-
hypha/apply/users/templates/users/login.html | 17 +-
.../users/templates/users/partials/list.html | 58 ++++
.../users/passwordless_login_signup.html | 17 +-
hypha/apply/users/urls.py | 42 +++
hypha/settings/base.py | 11 +
hypha/static_src/javascript/passkeys.js | 207 ++++++++++++++
hypha/templates/base-apply.html | 2 +-
pyproject.toml | 1 +
requirements/dev.txt | 51 ++++
requirements/prod.txt | 51 ++++
uv.lock | 76 ++++-
22 files changed, 906 insertions(+), 16 deletions(-)
create mode 100644 hypha/apply/users/migrations/0028_passkeys.py
create mode 100644 hypha/apply/users/passkey_views.py
rename hypha/{templates => apply/users/templates/users}/includes/org_login_button.html (100%)
create mode 100644 hypha/apply/users/templates/users/includes/passkey_login_button.html
rename hypha/{templates => apply/users/templates/users}/includes/password_login_button.html (100%)
rename hypha/{templates => apply/users/templates/users}/includes/passwordless_login_button.html (100%)
rename hypha/{templates => apply/users/templates/users}/includes/user_menu.html (97%)
create mode 100644 hypha/apply/users/templates/users/partials/list.html
create mode 100644 hypha/static_src/javascript/passkeys.js
diff --git a/hypha/apply/users/forms.py b/hypha/apply/users/forms.py
index 1b98f022d3..9ac0ad8e39 100644
--- a/hypha/apply/users/forms.py
+++ b/hypha/apply/users/forms.py
@@ -36,6 +36,8 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.user_settings = AuthSettings.load(request_or_site=self.request)
self.extra_text = self.user_settings.extra_text
+ # Enable passkey autofill (conditional mediation) on the username field
+ self.fields["username"].widget.attrs["autocomplete"] = "username webauthn"
if self.user_settings.consent_show:
self.fields["consent"] = forms.BooleanField(
label=self.user_settings.consent_text,
@@ -55,7 +57,9 @@ class PasswordlessAuthForm(forms.Form):
label=_("Email address"),
required=True,
max_length=254,
- widget=forms.EmailInput(attrs={"autofocus": True, "autocomplete": "email"}),
+ widget=forms.EmailInput(
+ attrs={"autofocus": True, "autocomplete": "username webauthn"}
+ ),
)
if settings.SESSION_COOKIE_AGE <= settings.SESSION_COOKIE_AGE_LONG:
diff --git a/hypha/apply/users/middleware.py b/hypha/apply/users/middleware.py
index 07414f48fb..e632226ce4 100644
--- a/hypha/apply/users/middleware.py
+++ b/hypha/apply/users/middleware.py
@@ -103,7 +103,11 @@ def __call__(self, request):
# code to execute before the view
user = request.user
if user.is_authenticated:
- if user.social_auth.exists() or user.is_verified():
+ if (
+ user.social_auth.exists()
+ or user.is_verified()
+ or request.session.get("passkey_authenticated")
+ ):
return self._accept(request)
# Allow rounds and lab detail pages
diff --git a/hypha/apply/users/migrations/0028_passkeys.py b/hypha/apply/users/migrations/0028_passkeys.py
new file mode 100644
index 0000000000..95c73c1f6e
--- /dev/null
+++ b/hypha/apply/users/migrations/0028_passkeys.py
@@ -0,0 +1,45 @@
+# Generated by Django 5.2.12 on 2026-03-23 15:16
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("users", "0027_remove_drupal_id_field"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Passkey",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("name", models.CharField(blank=True, max_length=255)),
+ ("credential_id", models.TextField(unique=True)),
+ ("public_key", models.TextField()),
+ ("sign_count", models.PositiveBigIntegerField(default=0)),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ ("last_used_at", models.DateTimeField(blank=True, null=True)),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="passkeys",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "ordering": ["-created_at"],
+ },
+ ),
+ ]
diff --git a/hypha/apply/users/models.py b/hypha/apply/users/models.py
index 32d7471ccc..1b8d06e831 100644
--- a/hypha/apply/users/models.py
+++ b/hypha/apply/users/models.py
@@ -406,3 +406,31 @@ def __str__(self):
class Meta:
ordering = ("modified",)
verbose_name_plural = "Confirm Access Tokens"
+
+
+class Passkey(models.Model):
+ """Stores a WebAuthn passkey credential for a user.
+
+ credential_id and public_key are stored as base64url-encoded strings,
+ matching the convention used by django-two-factor-auth's WebAuthn plugin.
+ """
+
+ user = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.CASCADE,
+ related_name="passkeys",
+ )
+ name = models.CharField(max_length=255, blank=True)
+ # base64url-encoded credential id (unique per authenticator)
+ credential_id = models.TextField(unique=True)
+ # base64url-encoded CASE public key
+ public_key = models.TextField()
+ sign_count = models.PositiveBigIntegerField(default=0)
+ created_at = models.DateTimeField(auto_now_add=True)
+ last_used_at = models.DateTimeField(null=True, blank=True)
+
+ class Meta:
+ ordering = ["-created_at"]
+
+ def __str__(self):
+ return self.name or f"Passkey {self.pk}"
diff --git a/hypha/apply/users/passkey_views.py b/hypha/apply/users/passkey_views.py
new file mode 100644
index 0000000000..74c6a4b59c
--- /dev/null
+++ b/hypha/apply/users/passkey_views.py
@@ -0,0 +1,264 @@
+import base64
+import json
+
+from django.conf import settings
+from django.contrib.auth import get_user_model, login
+from django.contrib.auth.decorators import login_required
+from django.core.exceptions import PermissionDenied
+from django.http import JsonResponse
+from django.shortcuts import get_object_or_404, render
+from django.utils import timezone
+from django.utils.decorators import method_decorator
+from django.views import View
+from django.views.decorators.csrf import csrf_protect
+from webauthn import (
+ generate_authentication_options,
+ generate_registration_options,
+ options_to_json,
+ verify_authentication_response,
+ verify_registration_response,
+)
+from webauthn.helpers import base64url_to_bytes, bytes_to_base64url
+from webauthn.helpers.structs import (
+ AuthenticationCredential,
+ AuthenticatorAssertionResponse,
+ AuthenticatorAttestationResponse,
+ AuthenticatorSelectionCriteria,
+ PublicKeyCredentialDescriptor,
+ RegistrationCredential,
+ ResidentKeyRequirement,
+ UserVerificationRequirement,
+)
+
+from .models import Passkey
+
+User = get_user_model()
+
+SESSION_CHALLENGE_KEY = "webauthn_challenge"
+
+
+def _get_rp_id(request):
+ rp_id = getattr(settings, "WEBAUTHN_RP_ID", None)
+ if rp_id:
+ return rp_id
+ return request.get_host().split(":")[0]
+
+
+def _get_rp_name():
+ return getattr(settings, "WEBAUTHN_RP_NAME", None) or settings.ORG_LONG_NAME
+
+
+def _get_origin(request):
+ origin = getattr(settings, "WEBAUTHN_ORIGIN", None)
+ if origin:
+ return origin
+ scheme = "https" if request.is_secure() else "http"
+ return f"{scheme}://{request.get_host()}"
+
+
+def _store_challenge(request, challenge: bytes):
+ request.session[SESSION_CHALLENGE_KEY] = base64.b64encode(challenge).decode()
+
+
+def _load_challenge(request) -> bytes:
+ encoded = request.session.pop(SESSION_CHALLENGE_KEY, None)
+ if not encoded:
+ raise PermissionDenied("No active WebAuthn challenge.")
+ return base64.b64decode(encoded)
+
+
+# ---------------------------------------------------------------------------
+# Registration — requires an authenticated user
+# ---------------------------------------------------------------------------
+
+
+@method_decorator(login_required, name="dispatch")
+@method_decorator(csrf_protect, name="dispatch")
+class PasskeyRegisterBeginView(View):
+ def post(self, request):
+ user = request.user
+ existing = [
+ PublicKeyCredentialDescriptor(id=base64url_to_bytes(pk.credential_id))
+ for pk in user.passkeys.all()
+ ]
+ options = generate_registration_options(
+ rp_id=_get_rp_id(request),
+ rp_name=_get_rp_name(),
+ user_id=str(user.pk).encode(),
+ user_name=user.email,
+ user_display_name=user.get_full_name() or user.email,
+ authenticator_selection=AuthenticatorSelectionCriteria(
+ resident_key=ResidentKeyRequirement.REQUIRED,
+ user_verification=UserVerificationRequirement.REQUIRED,
+ ),
+ exclude_credentials=existing,
+ )
+ _store_challenge(request, options.challenge)
+ return JsonResponse(json.loads(options_to_json(options)))
+
+
+@method_decorator(login_required, name="dispatch")
+@method_decorator(csrf_protect, name="dispatch")
+class PasskeyRegisterCompleteView(View):
+ def post(self, request):
+ try:
+ data = json.loads(request.body)
+ except (json.JSONDecodeError, ValueError):
+ return JsonResponse({"error": "Invalid JSON"}, status=400)
+
+ try:
+ challenge = _load_challenge(request)
+ except PermissionDenied as e:
+ return JsonResponse({"error": str(e)}, status=400)
+
+ try:
+ credential = RegistrationCredential(
+ id=data["id"],
+ raw_id=base64url_to_bytes(data["rawId"]),
+ response=AuthenticatorAttestationResponse(
+ client_data_json=base64url_to_bytes(
+ data["response"]["clientDataJSON"]
+ ),
+ attestation_object=base64url_to_bytes(
+ data["response"]["attestationObject"]
+ ),
+ transports=data["response"].get("transports", []),
+ ),
+ )
+ verification = verify_registration_response(
+ credential=credential,
+ expected_challenge=challenge,
+ expected_rp_id=_get_rp_id(request),
+ expected_origin=_get_origin(request),
+ require_user_verification=True,
+ )
+ except Exception as exc:
+ return JsonResponse({"error": str(exc)}, status=400)
+
+ name = (data.get("name") or "").strip() or timezone.now().strftime(
+ "Passkey %Y-%m-%d"
+ )
+ Passkey.objects.create(
+ user=request.user,
+ name=name,
+ credential_id=bytes_to_base64url(verification.credential_id),
+ public_key=bytes_to_base64url(verification.credential_public_key),
+ sign_count=verification.sign_count,
+ )
+ return JsonResponse({"status": "ok"})
+
+
+# ---------------------------------------------------------------------------
+# Authentication — public (no session required)
+# ---------------------------------------------------------------------------
+
+
+@method_decorator(csrf_protect, name="dispatch")
+class PasskeyAuthBeginView(View):
+ def post(self, request):
+ options = generate_authentication_options(
+ rp_id=_get_rp_id(request),
+ user_verification=UserVerificationRequirement.REQUIRED,
+ )
+ _store_challenge(request, options.challenge)
+ return JsonResponse(json.loads(options_to_json(options)))
+
+
+@method_decorator(csrf_protect, name="dispatch")
+class PasskeyAuthCompleteView(View):
+ def post(self, request):
+ try:
+ data = json.loads(request.body)
+ except (json.JSONDecodeError, ValueError):
+ return JsonResponse({"error": "Invalid JSON"}, status=400)
+
+ try:
+ challenge = _load_challenge(request)
+ except PermissionDenied as e:
+ return JsonResponse({"error": str(e)}, status=400)
+
+ credential_id_b64 = bytes_to_base64url(base64url_to_bytes(data["rawId"]))
+ try:
+ passkey = Passkey.objects.select_related("user").get(
+ credential_id=credential_id_b64
+ )
+ except Passkey.DoesNotExist:
+ return JsonResponse({"error": "Unknown credential"}, status=400)
+
+ try:
+ user_handle = data["response"].get("userHandle")
+ credential = AuthenticationCredential(
+ id=data["id"],
+ raw_id=base64url_to_bytes(data["rawId"]),
+ response=AuthenticatorAssertionResponse(
+ client_data_json=base64url_to_bytes(
+ data["response"]["clientDataJSON"]
+ ),
+ authenticator_data=base64url_to_bytes(
+ data["response"]["authenticatorData"]
+ ),
+ signature=base64url_to_bytes(data["response"]["signature"]),
+ user_handle=base64url_to_bytes(user_handle)
+ if user_handle
+ else None,
+ ),
+ )
+ verification = verify_authentication_response(
+ credential=credential,
+ expected_challenge=challenge,
+ expected_rp_id=_get_rp_id(request),
+ expected_origin=_get_origin(request),
+ credential_public_key=base64url_to_bytes(passkey.public_key),
+ credential_current_sign_count=passkey.sign_count,
+ require_user_verification=True,
+ )
+ except Exception as exc:
+ return JsonResponse({"error": str(exc)}, status=400)
+
+ passkey.sign_count = verification.new_sign_count
+ passkey.last_used_at = timezone.now()
+ passkey.save(update_fields=["sign_count", "last_used_at"])
+
+ user = passkey.user
+ login(request, user, backend="django.contrib.auth.backends.ModelBackend")
+ request.session["passkey_authenticated"] = True
+
+ next_url = request.POST.get("next") or data.get("next") or "/"
+ return JsonResponse({"status": "ok", "redirect_url": next_url})
+
+
+# ---------------------------------------------------------------------------
+# Passkey management — account page
+# ---------------------------------------------------------------------------
+
+
+@method_decorator(login_required, name="dispatch")
+class PasskeyListView(View):
+ template_name = "users/partials/list.html"
+
+ def get(self, request):
+ passkeys = request.user.passkeys.all()
+ return render(request, self.template_name, {"passkeys": passkeys})
+
+
+@method_decorator(login_required, name="dispatch")
+@method_decorator(csrf_protect, name="dispatch")
+class PasskeyDeleteView(View):
+ def post(self, request, pk):
+ passkey = get_object_or_404(Passkey, pk=pk, user=request.user)
+ passkey.delete()
+ passkeys = request.user.passkeys.all()
+ return render(request, "users/partials/list.html", {"passkeys": passkeys})
+
+
+@method_decorator(login_required, name="dispatch")
+@method_decorator(csrf_protect, name="dispatch")
+class PasskeyRenameView(View):
+ def post(self, request, pk):
+ passkey = get_object_or_404(Passkey, pk=pk, user=request.user)
+ name = request.POST.get("name", "").strip()
+ if name:
+ passkey.name = name
+ passkey.save(update_fields=["name"])
+ passkeys = request.user.passkeys.all()
+ return render(request, "users/partials/list.html", {"passkeys": passkeys})
diff --git a/hypha/apply/users/templates/users/account.html b/hypha/apply/users/templates/users/account.html
index 60b80fceef..ab5e279865 100644
--- a/hypha/apply/users/templates/users/account.html
+++ b/hypha/apply/users/templates/users/account.html
@@ -1,5 +1,5 @@
{% extends 'base-apply.html' %}
-{% load i18n users_tags wagtailcore_tags heroicons %}
+{% load i18n users_tags wagtailcore_tags heroicons static %}
{% block title %}{% trans "Account" %}{% endblock %}
@@ -93,6 +93,23 @@
{% endif %}
+ {% trans "Passkeys" %}
+
+ {% trans "Passkeys use your device's biometrics or PIN to sign in securely without a password." %}
+
+
+ {# Hidden inputs supply the API URLs to passkeys.js #}
+
+
+
+
+
{# Remove the comment block tags below when such need arises. e.g. adding new providers #}
{% comment %}
{% can_use_oauth as show_oauth_link %}
@@ -108,3 +125,7 @@ Manage OAuth
{% endblock %}
+
+{% block extra_js %}
+
+{% endblock %}
diff --git a/hypha/templates/includes/org_login_button.html b/hypha/apply/users/templates/users/includes/org_login_button.html
similarity index 100%
rename from hypha/templates/includes/org_login_button.html
rename to hypha/apply/users/templates/users/includes/org_login_button.html
diff --git a/hypha/apply/users/templates/users/includes/passkey_login_button.html b/hypha/apply/users/templates/users/includes/passkey_login_button.html
new file mode 100644
index 0000000000..e4c880ecf7
--- /dev/null
+++ b/hypha/apply/users/templates/users/includes/passkey_login_button.html
@@ -0,0 +1,15 @@
+{% load i18n heroicons %}
+
+ {% heroicon_micro "key" class="inline size-4" aria_hidden=true %}
+ {% trans "Sign in with passkey" %}
+
+
+
+
diff --git a/hypha/templates/includes/password_login_button.html b/hypha/apply/users/templates/users/includes/password_login_button.html
similarity index 100%
rename from hypha/templates/includes/password_login_button.html
rename to hypha/apply/users/templates/users/includes/password_login_button.html
diff --git a/hypha/templates/includes/passwordless_login_button.html b/hypha/apply/users/templates/users/includes/passwordless_login_button.html
similarity index 100%
rename from hypha/templates/includes/passwordless_login_button.html
rename to hypha/apply/users/templates/users/includes/passwordless_login_button.html
diff --git a/hypha/templates/includes/user_menu.html b/hypha/apply/users/templates/users/includes/user_menu.html
similarity index 97%
rename from hypha/templates/includes/user_menu.html
rename to hypha/apply/users/templates/users/includes/user_menu.html
index 466f59a28c..35f94d8c3c 100644
--- a/hypha/templates/includes/user_menu.html
+++ b/hypha/apply/users/templates/users/includes/user_menu.html
@@ -95,7 +95,7 @@
{% else %}
{% heroicon_micro "user" class="inline align-text-bottom size-4 me-1" aria_hidden=true %}
{% trans "Log in" %} {% if ENABLE_PUBLIC_SIGNUP %} {% trans " or Sign up" %} {% endif %}
diff --git a/hypha/apply/users/templates/users/login.html b/hypha/apply/users/templates/users/login.html
index 7f69b8ceae..413c60e7e8 100644
--- a/hypha/apply/users/templates/users/login.html
+++ b/hypha/apply/users/templates/users/login.html
@@ -1,5 +1,5 @@
{% extends "base-apply.html" %}
-{% load i18n wagtailcore_tags heroicons %}
+{% load i18n wagtailcore_tags heroicons static %}
{% block title %}{% trans "Log in" %}{% endblock %}
@@ -75,9 +75,10 @@ {% blocktrans %}Log in to {{ ORG_SHORT_NAME }}{% endblocktr
{% if GOOGLE_OAUTH2 %}
- {% include "includes/org_login_button.html" %}
+ {% include "users/includes/org_login_button.html" %}
{% endif %}
- {% include "includes/passwordless_login_button.html" %}
+ {% include "users/includes/passwordless_login_button.html" %}
+ {% include "users/includes/passkey_login_button.html" %}
{% else %}
@@ -124,8 +125,14 @@ {% blocktrans %}Log in to {{ ORG_SHORT_NAME }}{% endblocktr
- {# Fix copy of dynamic fields label #}
+{% endblock %}
+
+{% block extra_js %}
+
-
-
{% endblock %}
diff --git a/hypha/apply/users/templates/users/partials/list.html b/hypha/apply/users/templates/users/partials/list.html
new file mode 100644
index 0000000000..6fd320a48c
--- /dev/null
+++ b/hypha/apply/users/templates/users/partials/list.html
@@ -0,0 +1,58 @@
+{% load i18n heroicons %}
+
+ {% if passkeys %}
+
+ {% for passkey in passkeys %}
+
+ {% heroicon_micro "key" class="shrink-0 size-4 text-fg-muted" aria_hidden=true %}
+
+ {{ passkey.created_at|date:"Y-m-d" }}
+
+
+ {% endfor %}
+
+ {% else %}
+
{% trans "No passkeys registered." %}
+ {% endif %}
+
+
+
+ {% heroicon_micro "plus" class="size-3" aria_hidden=true %}
+ {% trans "Add passkey" %}
+
+
+
+
diff --git a/hypha/apply/users/templates/users/passwordless_login_signup.html b/hypha/apply/users/templates/users/passwordless_login_signup.html
index 27fe7752f4..5570b4dec4 100644
--- a/hypha/apply/users/templates/users/passwordless_login_signup.html
+++ b/hypha/apply/users/templates/users/passwordless_login_signup.html
@@ -1,5 +1,5 @@
{% extends base_template %}
-{% load i18n wagtailcore_tags heroicons %}
+{% load i18n wagtailcore_tags heroicons static %}
{% block title %}
{% trans "Log in" %}{% if ENABLE_PUBLIC_SIGNUP %} {% trans "or" %} {% trans "Sign up" %}{% endif %}
@@ -51,12 +51,21 @@
{% if GOOGLE_OAUTH2 %}
- {% include "includes/org_login_button.html" %}
+ {% include "users/includes/org_login_button.html" %}
{% endif %}
-
- {% include "includes/password_login_button.html" %}
+ {% include "users/includes/password_login_button.html" %}
+ {% include "users/includes/passkey_login_button.html" %}
{% endblock %}
+
+{% block extra_js %}
+
+
+{% endblock %}
diff --git a/hypha/apply/users/urls.py b/hypha/apply/users/urls.py
index 23d51f75f2..1d52bc3131 100644
--- a/hypha/apply/users/urls.py
+++ b/hypha/apply/users/urls.py
@@ -6,6 +6,15 @@
from hypha.elevate.views import elevate as elevate_view
+from .passkey_views import (
+ PasskeyAuthBeginView,
+ PasskeyAuthCompleteView,
+ PasskeyDeleteView,
+ PasskeyListView,
+ PasskeyRegisterBeginView,
+ PasskeyRegisterCompleteView,
+ PasskeyRenameView,
+)
from .views import (
AccountView,
ActivationView,
@@ -39,6 +48,17 @@
),
path("login/", LoginView.as_view(), name="login"),
path("logout/", auth_views.LogoutView.as_view(next_page="/"), name="logout"),
+ # Passkey authentication — public (no login required)
+ path(
+ "passkeys/auth/begin/",
+ PasskeyAuthBeginView.as_view(),
+ name="passkey_auth_begin",
+ ),
+ path(
+ "passkeys/auth/complete/",
+ PasskeyAuthCompleteView.as_view(),
+ name="passkey_auth_complete",
+ ),
]
account_urls = [
@@ -115,6 +135,28 @@
name="activate",
),
path("hijack/", hijack_view, name="hijack"),
+ # Passkey management
+ path("passkeys/", PasskeyListView.as_view(), name="passkey_list"),
+ path(
+ "passkeys/register/begin/",
+ PasskeyRegisterBeginView.as_view(),
+ name="passkey_register_begin",
+ ),
+ path(
+ "passkeys/register/complete/",
+ PasskeyRegisterCompleteView.as_view(),
+ name="passkey_register_complete",
+ ),
+ path(
+ "passkeys//delete/",
+ PasskeyDeleteView.as_view(),
+ name="passkey_delete",
+ ),
+ path(
+ "passkeys//rename/",
+ PasskeyRenameView.as_view(),
+ name="passkey_rename",
+ ),
path("activate/", create_password, name="activate_password"),
path("oauth", oauth, name="oauth"),
# 2FA
diff --git a/hypha/settings/base.py b/hypha/settings/base.py
index e4feb55237..97523ba55a 100644
--- a/hypha/settings/base.py
+++ b/hypha/settings/base.py
@@ -33,6 +33,17 @@
# IF Hypha should enforce 2FA for all users.
ENFORCE_TWO_FACTOR = env.bool("ENFORCE_TWO_FACTOR", False)
+# WebAuthn / Passkey settings.
+# WEBAUTHN_RP_ID: the relying party domain, e.g. "example.com" (no port, no scheme).
+# Defaults to the request host if not set.
+# WEBAUTHN_ORIGIN: the full origin, e.g. "https://example.com".
+# Defaults to the request origin if not set.
+# WEBAUTHN_RP_NAME: display name shown in the browser passkey UI.
+# Defaults to ORG_LONG_NAME.
+WEBAUTHN_RP_ID = env.str("WEBAUTHN_RP_ID", None)
+WEBAUTHN_RP_NAME = env.str("WEBAUTHN_RP_NAME", None)
+WEBAUTHN_ORIGIN = env.str("WEBAUTHN_ORIGIN", None)
+
# Set the allowed file extension for all uploads fields.
FILE_ALLOWED_EXTENSIONS = [
"doc",
diff --git a/hypha/static_src/javascript/passkeys.js b/hypha/static_src/javascript/passkeys.js
new file mode 100644
index 0000000000..1211c4f9a8
--- /dev/null
+++ b/hypha/static_src/javascript/passkeys.js
@@ -0,0 +1,207 @@
+/**
+ * WebAuthn passkey support using native browser APIs.
+ *
+ * Uses the stable native APIs available in all major browsers since March 2025:
+ * - PublicKeyCredential.parseCreationOptionsFromJSON()
+ * - PublicKeyCredential.parseRequestOptionsFromJSON()
+ * - PublicKeyCredential.prototype.toJSON()
+ *
+ * Availability is checked via isUserVerifyingPlatformAuthenticatorAvailable()
+ * (not just window.PublicKeyCredential) so macOS Touch ID, Windows Hello,
+ * iOS Face ID etc. are properly detected.
+ */
+
+window.hypha = window.hypha || {};
+
+window.hypha.passkeys = (function () {
+ function getCsrfToken() {
+ const el = document.querySelector("[name=csrfmiddlewaretoken]");
+ if (el) return el.value;
+ const cookie = document.cookie
+ .split(";")
+ .find((c) => c.trim().startsWith("csrftoken="));
+ return cookie ? cookie.split("=")[1] : "";
+ }
+
+ function jsonPost(url, body) {
+ return fetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRFToken": getCsrfToken(),
+ },
+ body: JSON.stringify(body),
+ });
+ }
+
+ /**
+ * Returns true when the current device has a platform authenticator
+ * (Touch ID, Windows Hello, Face ID, …) and the browser supports passkeys.
+ */
+ async function isPlatformAuthenticatorAvailable() {
+ if (
+ !window.PublicKeyCredential ||
+ !PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable
+ )
+ return false;
+ return PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
+ }
+
+ /**
+ * Show passkey-related UI elements only when the platform supports them.
+ * Call this on DOMContentLoaded — elements with data-passkey-ui are hidden
+ * by default and revealed here.
+ */
+ async function initUI() {
+ const [platformOk, conditionalOk] = await Promise.all([
+ isPlatformAuthenticatorAvailable().catch(() => false),
+ (
+ window.PublicKeyCredential?.isConditionalMediationAvailable?.() ??
+ Promise.resolve(false)
+ ).catch(() => false),
+ ]);
+
+ if (platformOk) {
+ document
+ .querySelectorAll("[data-passkey-ui]")
+ .forEach((el) => el.removeAttribute("hidden"));
+ }
+
+ if (conditionalOk) {
+ _startConditionalMediation();
+ }
+ }
+
+ /**
+ * Register a new passkey for the currently authenticated user.
+ * Called from the account page "Add passkey" button.
+ */
+ async function register(triggerEl) {
+ const beginUrl = document.getElementById(
+ "passkey-register-begin-url"
+ )?.value;
+ const completeUrl = document.getElementById(
+ "passkey-register-complete-url"
+ )?.value;
+ if (!beginUrl || !completeUrl) return;
+
+ const name = prompt("Name this passkey (e.g. 'MacBook Touch ID'):", "");
+ if (name === null) return; // user cancelled
+
+ const errorEl = document.getElementById("passkey-error");
+
+ try {
+ if (triggerEl) triggerEl.disabled = true;
+
+ // Step 1: fetch registration options from server
+ const beginResp = await jsonPost(beginUrl, {});
+ if (!beginResp.ok) {
+ const err = await beginResp.json();
+ throw new Error(err.error || "Server error");
+ }
+ const options = await beginResp.json();
+
+ // Step 2: trigger native OS passkey creation UI (Touch ID / Windows Hello / …)
+ const credential = await navigator.credentials.create({
+ publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options),
+ });
+
+ // Step 3: send the signed response to the server
+ const completeResp = await jsonPost(completeUrl, {
+ ...credential.toJSON(),
+ name: name.trim(),
+ });
+ if (!completeResp.ok) {
+ const err = await completeResp.json();
+ throw new Error(err.error || "Registration failed");
+ }
+
+ // Reload to show the new passkey in the list
+ window.location.reload();
+ } catch (err) {
+ // NotAllowedError means the user dismissed the native OS dialog — not an error.
+ if (err.name !== "NotAllowedError" && errorEl) {
+ errorEl.textContent = err.message;
+ errorEl.hidden = false;
+ }
+ } finally {
+ if (triggerEl) triggerEl.disabled = false;
+ }
+ }
+
+ /**
+ * Authenticate with a passkey via an explicit button click on the login page.
+ */
+ async function authenticate() {
+ const beginUrl = document.getElementById("passkey-auth-begin-url")?.value;
+ const completeUrl = document.getElementById(
+ "passkey-auth-complete-url"
+ )?.value;
+ if (!beginUrl || !completeUrl) return;
+
+ const errorEl = document.getElementById("passkey-auth-error");
+
+ try {
+ const beginResp = await jsonPost(beginUrl, {});
+ if (!beginResp.ok) throw new Error("Failed to begin authentication");
+ const authOptions = await beginResp.json();
+
+ // Triggers native OS passkey selection UI
+ const credential = await navigator.credentials.get({
+ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(authOptions),
+ });
+
+ const completeResp = await jsonPost(completeUrl, credential.toJSON());
+ if (!completeResp.ok) {
+ const err = await completeResp.json();
+ throw new Error(err.error || "Authentication failed");
+ }
+ const data = await completeResp.json();
+ window.location.href = data.redirect_url || "/";
+ } catch (err) {
+ // NotAllowedError / AbortError = user dismissed the native UI.
+ if (err.name !== "NotAllowedError" && err.name !== "AbortError") {
+ if (errorEl) {
+ errorEl.textContent = err.message;
+ errorEl.hidden = false;
+ }
+ }
+ }
+ }
+
+ /**
+ * Internal: start conditional mediation (passkey autofill on the login page).
+ * The email input needs autocomplete="username webauthn" for this to work.
+ */
+ async function _startConditionalMediation() {
+ const beginUrl = document.getElementById("passkey-auth-begin-url")?.value;
+ const completeUrl = document.getElementById(
+ "passkey-auth-complete-url"
+ )?.value;
+ if (!beginUrl || !completeUrl) return;
+
+ try {
+ const beginResp = await jsonPost(beginUrl, {});
+ if (!beginResp.ok) return;
+ const authOptions = await beginResp.json();
+
+ // mediation:"conditional" shows registered passkeys in the browser
+ // autofill dropdown next to the email field — no explicit user gesture needed.
+ const credential = await navigator.credentials.get({
+ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(authOptions),
+ mediation: "conditional",
+ });
+
+ if (!credential) return;
+
+ const completeResp = await jsonPost(completeUrl, credential.toJSON());
+ if (!completeResp.ok) return;
+ const data = await completeResp.json();
+ window.location.href = data.redirect_url || "/";
+ } catch (_err) {
+ // Expected: aborted when user submits the password form normally.
+ }
+ }
+
+ return { initUI, register, authenticate };
+})();
diff --git a/hypha/templates/base-apply.html b/hypha/templates/base-apply.html
index 89b3bd160c..b53581de0e 100644
--- a/hypha/templates/base-apply.html
+++ b/hypha/templates/base-apply.html
@@ -44,7 +44,7 @@
{% endif %}
{% if request.path != '/auth/' and request.path != '/login/' %}
- {% include "includes/user_menu.html" %}
+ {% include "users/includes/user_menu.html" %}
{% endif %}
Date: Mon, 23 Mar 2026 18:38:09 +0100
Subject: [PATCH 02/13] Add some url checks. Show last_used_at for each
passkey. Inline form to set passkey name.
---
hypha/apply/users/passkey_views.py | 29 +++++++++-----
.../users/includes/passkey_login_button.html | 7 +++-
.../users/templates/users/partials/list.html | 32 ++++++++++-----
hypha/static_src/javascript/passkeys.js | 40 +++++++++++++------
4 files changed, 74 insertions(+), 34 deletions(-)
diff --git a/hypha/apply/users/passkey_views.py b/hypha/apply/users/passkey_views.py
index 74c6a4b59c..0d7d803f13 100644
--- a/hypha/apply/users/passkey_views.py
+++ b/hypha/apply/users/passkey_views.py
@@ -2,15 +2,16 @@
import json
from django.conf import settings
-from django.contrib.auth import get_user_model, login
+from django.contrib.auth import login
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, render
from django.utils import timezone
from django.utils.decorators import method_decorator
+from django.utils.http import url_has_allowed_host_and_scheme
from django.views import View
-from django.views.decorators.csrf import csrf_protect
+from django_ratelimit.decorators import ratelimit
from webauthn import (
generate_authentication_options,
generate_registration_options,
@@ -32,8 +33,6 @@
from .models import Passkey
-User = get_user_model()
-
SESSION_CHALLENGE_KEY = "webauthn_challenge"
@@ -73,7 +72,6 @@ def _load_challenge(request) -> bytes:
@method_decorator(login_required, name="dispatch")
-@method_decorator(csrf_protect, name="dispatch")
class PasskeyRegisterBeginView(View):
def post(self, request):
user = request.user
@@ -98,7 +96,6 @@ def post(self, request):
@method_decorator(login_required, name="dispatch")
-@method_decorator(csrf_protect, name="dispatch")
class PasskeyRegisterCompleteView(View):
def post(self, request):
try:
@@ -153,7 +150,10 @@ def post(self, request):
# ---------------------------------------------------------------------------
-@method_decorator(csrf_protect, name="dispatch")
+@method_decorator(
+ ratelimit(key="ip", rate=settings.DEFAULT_RATE_LIMIT, method="POST"),
+ name="dispatch",
+)
class PasskeyAuthBeginView(View):
def post(self, request):
options = generate_authentication_options(
@@ -164,7 +164,10 @@ def post(self, request):
return JsonResponse(json.loads(options_to_json(options)))
-@method_decorator(csrf_protect, name="dispatch")
+@method_decorator(
+ ratelimit(key="ip", rate=settings.DEFAULT_RATE_LIMIT, method="POST"),
+ name="dispatch",
+)
class PasskeyAuthCompleteView(View):
def post(self, request):
try:
@@ -223,7 +226,13 @@ def post(self, request):
login(request, user, backend="django.contrib.auth.backends.ModelBackend")
request.session["passkey_authenticated"] = True
- next_url = request.POST.get("next") or data.get("next") or "/"
+ next_url = data.get("next") or "/"
+ if not url_has_allowed_host_and_scheme(
+ next_url,
+ allowed_hosts={request.get_host()},
+ require_https=request.is_secure(),
+ ):
+ next_url = settings.LOGIN_REDIRECT_URL
return JsonResponse({"status": "ok", "redirect_url": next_url})
@@ -242,7 +251,6 @@ def get(self, request):
@method_decorator(login_required, name="dispatch")
-@method_decorator(csrf_protect, name="dispatch")
class PasskeyDeleteView(View):
def post(self, request, pk):
passkey = get_object_or_404(Passkey, pk=pk, user=request.user)
@@ -252,7 +260,6 @@ def post(self, request, pk):
@method_decorator(login_required, name="dispatch")
-@method_decorator(csrf_protect, name="dispatch")
class PasskeyRenameView(View):
def post(self, request, pk):
passkey = get_object_or_404(Passkey, pk=pk, user=request.user)
diff --git a/hypha/apply/users/templates/users/includes/passkey_login_button.html b/hypha/apply/users/templates/users/includes/passkey_login_button.html
index e4c880ecf7..72c2d81ab0 100644
--- a/hypha/apply/users/templates/users/includes/passkey_login_button.html
+++ b/hypha/apply/users/templates/users/includes/passkey_login_button.html
@@ -10,6 +10,11 @@
{% heroicon_micro "key" class="inline size-4" aria_hidden=true %}
{% trans "Sign in with passkey" %}
-
+
+
diff --git a/hypha/apply/users/templates/users/partials/list.html b/hypha/apply/users/templates/users/partials/list.html
index 6fd320a48c..91de6e4f26 100644
--- a/hypha/apply/users/templates/users/partials/list.html
+++ b/hypha/apply/users/templates/users/partials/list.html
@@ -24,7 +24,13 @@
{% heroicon_micro "check" class="size-3" aria_hidden=true %}
- {{ passkey.created_at|date:"Y-m-d" }}
+
+ {% if passkey.last_used_at %}
+ {% blocktrans with when=passkey.last_used_at|timesince %}Used {{ when }} ago{% endblocktrans %}
+ {% else %}
+ {% trans "Never used" %}
+ {% endif %}
+
diff --git a/hypha/static_src/javascript/passkeys.js b/hypha/static_src/javascript/passkeys.js
index 1211c4f9a8..8925b766b5 100644
--- a/hypha/static_src/javascript/passkeys.js
+++ b/hypha/static_src/javascript/passkeys.js
@@ -74,9 +74,10 @@ window.hypha.passkeys = (function () {
/**
* Register a new passkey for the currently authenticated user.
- * Called from the account page "Add passkey" button.
+ * Called from the account page "Add passkey" form (onsubmit).
+ * @param {HTMLFormElement} formEl The element containing a [name=name] input.
*/
- async function register(triggerEl) {
+ async function register(formEl) {
const beginUrl = document.getElementById(
"passkey-register-begin-url"
)?.value;
@@ -85,19 +86,21 @@ window.hypha.passkeys = (function () {
)?.value;
if (!beginUrl || !completeUrl) return;
- const name = prompt("Name this passkey (e.g. 'MacBook Touch ID'):", "");
- if (name === null) return; // user cancelled
-
+ const nameInput = formEl?.querySelector("[name=name]");
const errorEl = document.getElementById("passkey-error");
+ const submitBtn = formEl?.querySelector("[type=submit]");
try {
- if (triggerEl) triggerEl.disabled = true;
+ if (submitBtn) submitBtn.disabled = true;
+ if (errorEl) errorEl.hidden = true;
// Step 1: fetch registration options from server
const beginResp = await jsonPost(beginUrl, {});
if (!beginResp.ok) {
const err = await beginResp.json();
- throw new Error(err.error || "Server error");
+ throw new Error(
+ err.error || formEl?.dataset.errorServer || "Server error"
+ );
}
const options = await beginResp.json();
@@ -109,11 +112,13 @@ window.hypha.passkeys = (function () {
// Step 3: send the signed response to the server
const completeResp = await jsonPost(completeUrl, {
...credential.toJSON(),
- name: name.trim(),
+ name: nameInput?.value.trim() || "",
});
if (!completeResp.ok) {
const err = await completeResp.json();
- throw new Error(err.error || "Registration failed");
+ throw new Error(
+ err.error || formEl?.dataset.errorRegister || "Registration failed"
+ );
}
// Reload to show the new passkey in the list
@@ -125,7 +130,7 @@ window.hypha.passkeys = (function () {
errorEl.hidden = false;
}
} finally {
- if (triggerEl) triggerEl.disabled = false;
+ if (submitBtn) submitBtn.disabled = false;
}
}
@@ -139,11 +144,15 @@ window.hypha.passkeys = (function () {
)?.value;
if (!beginUrl || !completeUrl) return;
+ const nextUrl = document.getElementById("passkey-next-url")?.value || "";
const errorEl = document.getElementById("passkey-auth-error");
try {
const beginResp = await jsonPost(beginUrl, {});
- if (!beginResp.ok) throw new Error("Failed to begin authentication");
+ if (!beginResp.ok)
+ throw new Error(
+ errorEl?.dataset.errorBegin || "Failed to begin authentication"
+ );
const authOptions = await beginResp.json();
// Triggers native OS passkey selection UI
@@ -151,10 +160,15 @@ window.hypha.passkeys = (function () {
publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(authOptions),
});
- const completeResp = await jsonPost(completeUrl, credential.toJSON());
+ const completeResp = await jsonPost(completeUrl, {
+ ...credential.toJSON(),
+ next: nextUrl,
+ });
if (!completeResp.ok) {
const err = await completeResp.json();
- throw new Error(err.error || "Authentication failed");
+ throw new Error(
+ err.error || errorEl?.dataset.errorAuth || "Authentication failed"
+ );
}
const data = await completeResp.json();
window.location.href = data.redirect_url || "/";
From d9175cf9751f8d0e25b1ff6d47de17ead3a1c704 Mon Sep 17 00:00:00 2001
From: Fredrik Jonsson
Date: Mon, 23 Mar 2026 18:56:17 +0100
Subject: [PATCH 03/13] Some ux improvments to the user account buttons.
---
hypha/apply/users/templates/users/account.html | 6 +++---
hypha/apply/users/templates/users/partials/list.html | 7 +++++--
2 files changed, 8 insertions(+), 5 deletions(-)
diff --git a/hypha/apply/users/templates/users/account.html b/hypha/apply/users/templates/users/account.html
index ab5e279865..3be5d929d4 100644
--- a/hypha/apply/users/templates/users/account.html
+++ b/hypha/apply/users/templates/users/account.html
@@ -86,10 +86,10 @@
{% if default_device %}
- {% trans "Backup codes" %}
- {% trans "Disable 2FA" %}
+ {% trans "Backup codes" %}
+ {% trans "Disable 2FA" %}
{% else %}
- {% trans "Enable 2FA" %}
+ {% trans "Enable 2FA" %}
{% endif %}
diff --git a/hypha/apply/users/templates/users/partials/list.html b/hypha/apply/users/templates/users/partials/list.html
index 91de6e4f26..9eed27fc20 100644
--- a/hypha/apply/users/templates/users/partials/list.html
+++ b/hypha/apply/users/templates/users/partials/list.html
@@ -6,6 +6,7 @@
{% heroicon_micro "key" class="shrink-0 size-4 text-fg-muted" aria_hidden=true %}
-
- {% heroicon_micro "check" class="size-3" aria_hidden=true %}
+
+ {% heroicon_micro "check" class="size-4" aria_hidden=true %}
From 18927999b8ec4cc3ee30ed1948fb7a81c0c973cf Mon Sep 17 00:00:00 2001
From: Fredrik Jonsson
Date: Mon, 23 Mar 2026 19:20:04 +0100
Subject: [PATCH 04/13] Tighting up the passkeys.js.
---
hypha/apply/users/passkey_views.py | 14 +++++++++-----
1 file changed, 9 insertions(+), 5 deletions(-)
diff --git a/hypha/apply/users/passkey_views.py b/hypha/apply/users/passkey_views.py
index 0d7d803f13..1db84d3011 100644
--- a/hypha/apply/users/passkey_views.py
+++ b/hypha/apply/users/passkey_views.py
@@ -129,8 +129,8 @@ def post(self, request):
expected_origin=_get_origin(request),
require_user_verification=True,
)
- except Exception as exc:
- return JsonResponse({"error": str(exc)}, status=400)
+ except Exception:
+ return JsonResponse({"error": "Verification failed"}, status=400)
name = (data.get("name") or "").strip() or timezone.now().strftime(
"Passkey %Y-%m-%d"
@@ -180,7 +180,11 @@ def post(self, request):
except PermissionDenied as e:
return JsonResponse({"error": str(e)}, status=400)
- credential_id_b64 = bytes_to_base64url(base64url_to_bytes(data["rawId"]))
+ try:
+ credential_id_b64 = bytes_to_base64url(base64url_to_bytes(data["rawId"]))
+ except (KeyError, Exception):
+ return JsonResponse({"error": "Invalid credential"}, status=400)
+
try:
passkey = Passkey.objects.select_related("user").get(
credential_id=credential_id_b64
@@ -215,8 +219,8 @@ def post(self, request):
credential_current_sign_count=passkey.sign_count,
require_user_verification=True,
)
- except Exception as exc:
- return JsonResponse({"error": str(exc)}, status=400)
+ except Exception:
+ return JsonResponse({"error": "Verification failed"}, status=400)
passkey.sign_count = verification.new_sign_count
passkey.last_used_at = timezone.now()
From 49c994cd5b162dadc55759f01b451de3a807d872 Mon Sep 17 00:00:00 2001
From: Fredrik Jonsson
Date: Mon, 23 Mar 2026 19:59:33 +0100
Subject: [PATCH 05/13] More generic errors and pass through next on passkey
login.
---
hypha/apply/users/passkey_views.py | 10 +++++-----
hypha/static_src/javascript/passkeys.js | 6 +++++-
2 files changed, 10 insertions(+), 6 deletions(-)
diff --git a/hypha/apply/users/passkey_views.py b/hypha/apply/users/passkey_views.py
index 1db84d3011..eb6fa20ae1 100644
--- a/hypha/apply/users/passkey_views.py
+++ b/hypha/apply/users/passkey_views.py
@@ -105,8 +105,8 @@ def post(self, request):
try:
challenge = _load_challenge(request)
- except PermissionDenied as e:
- return JsonResponse({"error": str(e)}, status=400)
+ except PermissionDenied:
+ return JsonResponse({"error": "No active WebAuthn challenge"}, status=400)
try:
credential = RegistrationCredential(
@@ -177,12 +177,12 @@ def post(self, request):
try:
challenge = _load_challenge(request)
- except PermissionDenied as e:
- return JsonResponse({"error": str(e)}, status=400)
+ except PermissionDenied:
+ return JsonResponse({"error": "No active WebAuthn challenge"}, status=400)
try:
credential_id_b64 = bytes_to_base64url(base64url_to_bytes(data["rawId"]))
- except (KeyError, Exception):
+ except Exception:
return JsonResponse({"error": "Invalid credential"}, status=400)
try:
diff --git a/hypha/static_src/javascript/passkeys.js b/hypha/static_src/javascript/passkeys.js
index 8925b766b5..b574c21d02 100644
--- a/hypha/static_src/javascript/passkeys.js
+++ b/hypha/static_src/javascript/passkeys.js
@@ -208,7 +208,11 @@ window.hypha.passkeys = (function () {
if (!credential) return;
- const completeResp = await jsonPost(completeUrl, credential.toJSON());
+ const nextUrl = document.getElementById("passkey-next-url")?.value || "";
+ const completeResp = await jsonPost(completeUrl, {
+ ...credential.toJSON(),
+ next: nextUrl,
+ });
if (!completeResp.ok) return;
const data = await completeResp.json();
window.location.href = data.redirect_url || "/";
From 28914fc56d08a362d8982e5fd6d9a0e5ae7b8833 Mon Sep 17 00:00:00 2001
From: Fredrik Jonsson
Date: Mon, 23 Mar 2026 20:56:45 +0100
Subject: [PATCH 06/13] Add MAX_PASSKEYS_PER_USER. Add ratelimit to more views.
---
hypha/apply/users/passkey_views.py | 19 ++++++++++++++++++-
1 file changed, 18 insertions(+), 1 deletion(-)
diff --git a/hypha/apply/users/passkey_views.py b/hypha/apply/users/passkey_views.py
index eb6fa20ae1..a8b56959a1 100644
--- a/hypha/apply/users/passkey_views.py
+++ b/hypha/apply/users/passkey_views.py
@@ -71,13 +71,26 @@ def _load_challenge(request) -> bytes:
# ---------------------------------------------------------------------------
+MAX_PASSKEYS_PER_USER = 10
+
+
@method_decorator(login_required, name="dispatch")
+@method_decorator(
+ ratelimit(key="user", rate=settings.DEFAULT_RATE_LIMIT, method="POST"),
+ name="dispatch",
+)
class PasskeyRegisterBeginView(View):
def post(self, request):
user = request.user
+ existing_passkeys = list(user.passkeys.all())
+ if len(existing_passkeys) >= MAX_PASSKEYS_PER_USER:
+ return JsonResponse(
+ {"error": f"Maximum of {MAX_PASSKEYS_PER_USER} passkeys allowed"},
+ status=400,
+ )
existing = [
PublicKeyCredentialDescriptor(id=base64url_to_bytes(pk.credential_id))
- for pk in user.passkeys.all()
+ for pk in existing_passkeys
]
options = generate_registration_options(
rp_id=_get_rp_id(request),
@@ -96,6 +109,10 @@ def post(self, request):
@method_decorator(login_required, name="dispatch")
+@method_decorator(
+ ratelimit(key="user", rate=settings.DEFAULT_RATE_LIMIT, method="POST"),
+ name="dispatch",
+)
class PasskeyRegisterCompleteView(View):
def post(self, request):
try:
From 5f92b6c9abbba4d6ac18b4b31e3e551769182cfe Mon Sep 17 00:00:00 2001
From: Fredrik Jonsson
Date: Mon, 23 Mar 2026 21:23:15 +0100
Subject: [PATCH 07/13] Make sure it works for Linux desktop users with
hardware keys etc. as well.
---
hypha/static_src/javascript/passkeys.js | 32 ++++++++-----------------
1 file changed, 10 insertions(+), 22 deletions(-)
diff --git a/hypha/static_src/javascript/passkeys.js b/hypha/static_src/javascript/passkeys.js
index b574c21d02..e5eeaeade1 100644
--- a/hypha/static_src/javascript/passkeys.js
+++ b/hypha/static_src/javascript/passkeys.js
@@ -34,34 +34,22 @@ window.hypha.passkeys = (function () {
});
}
- /**
- * Returns true when the current device has a platform authenticator
- * (Touch ID, Windows Hello, Face ID, …) and the browser supports passkeys.
- */
- async function isPlatformAuthenticatorAvailable() {
- if (
- !window.PublicKeyCredential ||
- !PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable
- )
- return false;
- return PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
- }
-
/**
* Show passkey-related UI elements only when the platform supports them.
* Call this on DOMContentLoaded — elements with data-passkey-ui are hidden
* by default and revealed here.
*/
async function initUI() {
- const [platformOk, conditionalOk] = await Promise.all([
- isPlatformAuthenticatorAvailable().catch(() => false),
- (
- window.PublicKeyCredential?.isConditionalMediationAvailable?.() ??
- Promise.resolve(false)
- ).catch(() => false),
- ]);
-
- if (platformOk) {
+ const webAuthnAvailable = !!(
+ window.PublicKeyCredential && navigator.credentials
+ );
+
+ const conditionalOk = await (
+ window.PublicKeyCredential?.isConditionalMediationAvailable?.() ??
+ Promise.resolve(false)
+ ).catch(() => false);
+
+ if (webAuthnAvailable) {
document
.querySelectorAll("[data-passkey-ui]")
.forEach((el) => el.removeAttribute("hidden"));
From c5f5a6b202ba03b1283de41e0bd76620e04b938e Mon Sep 17 00:00:00 2001
From: Fredrik Jonsson
Date: Mon, 23 Mar 2026 22:09:39 +0100
Subject: [PATCH 08/13] Use CharField insgead of TextField and fix a comment.
---
hypha/apply/users/migrations/0028_passkeys.py | 6 +++---
hypha/apply/users/models.py | 6 +++---
hypha/static_src/javascript/passkeys.js | 6 +++---
3 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/hypha/apply/users/migrations/0028_passkeys.py b/hypha/apply/users/migrations/0028_passkeys.py
index 95c73c1f6e..448194b290 100644
--- a/hypha/apply/users/migrations/0028_passkeys.py
+++ b/hypha/apply/users/migrations/0028_passkeys.py
@@ -1,4 +1,4 @@
-# Generated by Django 5.2.12 on 2026-03-23 15:16
+# Generated by Django 5.2.12 on 2026-03-23 21:07
import django.db.models.deletion
from django.conf import settings
@@ -24,8 +24,8 @@ class Migration(migrations.Migration):
),
),
("name", models.CharField(blank=True, max_length=255)),
- ("credential_id", models.TextField(unique=True)),
- ("public_key", models.TextField()),
+ ("credential_id", models.CharField(max_length=2048, unique=True)),
+ ("public_key", models.CharField(max_length=1024)),
("sign_count", models.PositiveBigIntegerField(default=0)),
("created_at", models.DateTimeField(auto_now_add=True)),
("last_used_at", models.DateTimeField(blank=True, null=True)),
diff --git a/hypha/apply/users/models.py b/hypha/apply/users/models.py
index 1b8d06e831..6bd69ea5b6 100644
--- a/hypha/apply/users/models.py
+++ b/hypha/apply/users/models.py
@@ -422,9 +422,9 @@ class Passkey(models.Model):
)
name = models.CharField(max_length=255, blank=True)
# base64url-encoded credential id (unique per authenticator)
- credential_id = models.TextField(unique=True)
- # base64url-encoded CASE public key
- public_key = models.TextField()
+ credential_id = models.CharField(max_length=2048, unique=True)
+ # base64url-encoded public key
+ public_key = models.CharField(max_length=1024)
sign_count = models.PositiveBigIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
last_used_at = models.DateTimeField(null=True, blank=True)
diff --git a/hypha/static_src/javascript/passkeys.js b/hypha/static_src/javascript/passkeys.js
index e5eeaeade1..72334d4e3f 100644
--- a/hypha/static_src/javascript/passkeys.js
+++ b/hypha/static_src/javascript/passkeys.js
@@ -6,9 +6,9 @@
* - PublicKeyCredential.parseRequestOptionsFromJSON()
* - PublicKeyCredential.prototype.toJSON()
*
- * Availability is checked via isUserVerifyingPlatformAuthenticatorAvailable()
- * (not just window.PublicKeyCredential) so macOS Touch ID, Windows Hello,
- * iOS Face ID etc. are properly detected.
+ * Availability is checked via window.PublicKeyCredential && navigator.credentials,
+ * which covers platform authenticators (Touch ID, Windows Hello, Face ID) as well
+ * as roaming authenticators (security keys) and cross-device auth (QR code).
*/
window.hypha = window.hypha || {};
From dd64d08733c1dc2e7d9c35943e753f3df9153487 Mon Sep 17 00:00:00 2001
From: Fredrik Jonsson
Date: Mon, 23 Mar 2026 22:14:58 +0100
Subject: [PATCH 09/13] Multi-tab challenge collision fix.
---
hypha/apply/users/passkey_views.py | 19 ++++++++++---------
1 file changed, 10 insertions(+), 9 deletions(-)
diff --git a/hypha/apply/users/passkey_views.py b/hypha/apply/users/passkey_views.py
index a8b56959a1..3b0cffeb6f 100644
--- a/hypha/apply/users/passkey_views.py
+++ b/hypha/apply/users/passkey_views.py
@@ -33,7 +33,8 @@
from .models import Passkey
-SESSION_CHALLENGE_KEY = "webauthn_challenge"
+SESSION_CHALLENGE_KEY_REGISTER = "webauthn_challenge_register"
+SESSION_CHALLENGE_KEY_AUTH = "webauthn_challenge_auth"
def _get_rp_id(request):
@@ -55,12 +56,12 @@ def _get_origin(request):
return f"{scheme}://{request.get_host()}"
-def _store_challenge(request, challenge: bytes):
- request.session[SESSION_CHALLENGE_KEY] = base64.b64encode(challenge).decode()
+def _store_challenge(request, challenge: bytes, key: str):
+ request.session[key] = base64.b64encode(challenge).decode()
-def _load_challenge(request) -> bytes:
- encoded = request.session.pop(SESSION_CHALLENGE_KEY, None)
+def _load_challenge(request, key: str) -> bytes:
+ encoded = request.session.pop(key, None)
if not encoded:
raise PermissionDenied("No active WebAuthn challenge.")
return base64.b64decode(encoded)
@@ -104,7 +105,7 @@ def post(self, request):
),
exclude_credentials=existing,
)
- _store_challenge(request, options.challenge)
+ _store_challenge(request, options.challenge, SESSION_CHALLENGE_KEY_REGISTER)
return JsonResponse(json.loads(options_to_json(options)))
@@ -121,7 +122,7 @@ def post(self, request):
return JsonResponse({"error": "Invalid JSON"}, status=400)
try:
- challenge = _load_challenge(request)
+ challenge = _load_challenge(request, SESSION_CHALLENGE_KEY_REGISTER)
except PermissionDenied:
return JsonResponse({"error": "No active WebAuthn challenge"}, status=400)
@@ -177,7 +178,7 @@ def post(self, request):
rp_id=_get_rp_id(request),
user_verification=UserVerificationRequirement.REQUIRED,
)
- _store_challenge(request, options.challenge)
+ _store_challenge(request, options.challenge, SESSION_CHALLENGE_KEY_AUTH)
return JsonResponse(json.loads(options_to_json(options)))
@@ -193,7 +194,7 @@ def post(self, request):
return JsonResponse({"error": "Invalid JSON"}, status=400)
try:
- challenge = _load_challenge(request)
+ challenge = _load_challenge(request, SESSION_CHALLENGE_KEY_AUTH)
except PermissionDenied:
return JsonResponse({"error": "No active WebAuthn challenge"}, status=400)
From 923a01e00b17427f2488a8a206176ea1510eb910 Mon Sep 17 00:00:00 2001
From: Fredrik Jonsson
Date: Mon, 23 Mar 2026 22:25:12 +0100
Subject: [PATCH 10/13] Store the transports info.
---
hypha/apply/users/migrations/0028_passkeys.py | 3 ++-
hypha/apply/users/models.py | 1 +
hypha/apply/users/passkey_views.py | 7 ++++++-
3 files changed, 9 insertions(+), 2 deletions(-)
diff --git a/hypha/apply/users/migrations/0028_passkeys.py b/hypha/apply/users/migrations/0028_passkeys.py
index 448194b290..409247990d 100644
--- a/hypha/apply/users/migrations/0028_passkeys.py
+++ b/hypha/apply/users/migrations/0028_passkeys.py
@@ -1,4 +1,4 @@
-# Generated by Django 5.2.12 on 2026-03-23 21:07
+# Generated by Django 5.2.12 on 2026-03-23 21:24
import django.db.models.deletion
from django.conf import settings
@@ -27,6 +27,7 @@ class Migration(migrations.Migration):
("credential_id", models.CharField(max_length=2048, unique=True)),
("public_key", models.CharField(max_length=1024)),
("sign_count", models.PositiveBigIntegerField(default=0)),
+ ("transports", models.JSONField(blank=True, default=list)),
("created_at", models.DateTimeField(auto_now_add=True)),
("last_used_at", models.DateTimeField(blank=True, null=True)),
(
diff --git a/hypha/apply/users/models.py b/hypha/apply/users/models.py
index 6bd69ea5b6..443da862b0 100644
--- a/hypha/apply/users/models.py
+++ b/hypha/apply/users/models.py
@@ -426,6 +426,7 @@ class Passkey(models.Model):
# base64url-encoded public key
public_key = models.CharField(max_length=1024)
sign_count = models.PositiveBigIntegerField(default=0)
+ transports = models.JSONField(default=list, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
last_used_at = models.DateTimeField(null=True, blank=True)
diff --git a/hypha/apply/users/passkey_views.py b/hypha/apply/users/passkey_views.py
index 3b0cffeb6f..59aae4bff2 100644
--- a/hypha/apply/users/passkey_views.py
+++ b/hypha/apply/users/passkey_views.py
@@ -25,6 +25,7 @@
AuthenticatorAssertionResponse,
AuthenticatorAttestationResponse,
AuthenticatorSelectionCriteria,
+ AuthenticatorTransport,
PublicKeyCredentialDescriptor,
RegistrationCredential,
ResidentKeyRequirement,
@@ -90,7 +91,10 @@ def post(self, request):
status=400,
)
existing = [
- PublicKeyCredentialDescriptor(id=base64url_to_bytes(pk.credential_id))
+ PublicKeyCredentialDescriptor(
+ id=base64url_to_bytes(pk.credential_id),
+ transports=[AuthenticatorTransport(t) for t in pk.transports] or None,
+ )
for pk in existing_passkeys
]
options = generate_registration_options(
@@ -159,6 +163,7 @@ def post(self, request):
credential_id=bytes_to_base64url(verification.credential_id),
public_key=bytes_to_base64url(verification.credential_public_key),
sign_count=verification.sign_count,
+ transports=data["response"].get("transports", []),
)
return JsonResponse({"status": "ok"})
From 022f179a8b7aa9e3c67f3d91414af3405a1bc38d Mon Sep 17 00:00:00 2001
From: Fredrik Jonsson
Date: Mon, 23 Mar 2026 22:59:39 +0100
Subject: [PATCH 11/13] Fix login bug.
---
hypha/apply/users/passkey_views.py | 9 +++++----
hypha/static_src/javascript/passkeys.js | 10 ++++++++++
2 files changed, 15 insertions(+), 4 deletions(-)
diff --git a/hypha/apply/users/passkey_views.py b/hypha/apply/users/passkey_views.py
index 59aae4bff2..ec3c234a59 100644
--- a/hypha/apply/users/passkey_views.py
+++ b/hypha/apply/users/passkey_views.py
@@ -6,7 +6,7 @@
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.http import JsonResponse
-from django.shortcuts import get_object_or_404, render
+from django.shortcuts import get_object_or_404, render, resolve_url
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.http import url_has_allowed_host_and_scheme
@@ -250,16 +250,17 @@ def post(self, request):
passkey.save(update_fields=["sign_count", "last_used_at"])
user = passkey.user
- login(request, user, backend="django.contrib.auth.backends.ModelBackend")
+ user.backend = settings.CUSTOM_AUTH_BACKEND
+ login(request, user)
request.session["passkey_authenticated"] = True
- next_url = data.get("next") or "/"
+ next_url = data.get("next") or resolve_url(settings.LOGIN_REDIRECT_URL)
if not url_has_allowed_host_and_scheme(
next_url,
allowed_hosts={request.get_host()},
require_https=request.is_secure(),
):
- next_url = settings.LOGIN_REDIRECT_URL
+ next_url = resolve_url(settings.LOGIN_REDIRECT_URL)
return JsonResponse({"status": "ok", "redirect_url": next_url})
diff --git a/hypha/static_src/javascript/passkeys.js b/hypha/static_src/javascript/passkeys.js
index 72334d4e3f..e4e842cc50 100644
--- a/hypha/static_src/javascript/passkeys.js
+++ b/hypha/static_src/javascript/passkeys.js
@@ -14,6 +14,7 @@
window.hypha = window.hypha || {};
window.hypha.passkeys = (function () {
+ let _conditionalAbortController = null;
function getCsrfToken() {
const el = document.querySelector("[name=csrfmiddlewaretoken]");
if (el) return el.value;
@@ -126,6 +127,12 @@ window.hypha.passkeys = (function () {
* Authenticate with a passkey via an explicit button click on the login page.
*/
async function authenticate() {
+ // Abort any in-progress conditional mediation before starting explicit auth.
+ if (_conditionalAbortController) {
+ _conditionalAbortController.abort();
+ _conditionalAbortController = null;
+ }
+
const beginUrl = document.getElementById("passkey-auth-begin-url")?.value;
const completeUrl = document.getElementById(
"passkey-auth-complete-url"
@@ -182,6 +189,8 @@ window.hypha.passkeys = (function () {
)?.value;
if (!beginUrl || !completeUrl) return;
+ _conditionalAbortController = new AbortController();
+
try {
const beginResp = await jsonPost(beginUrl, {});
if (!beginResp.ok) return;
@@ -192,6 +201,7 @@ window.hypha.passkeys = (function () {
const credential = await navigator.credentials.get({
publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(authOptions),
mediation: "conditional",
+ signal: _conditionalAbortController.signal,
});
if (!credential) return;
From ff0259cbc86cb1db23925ad153632fad00d84d0b Mon Sep 17 00:00:00 2001
From: Fredrik Jonsson
Date: Tue, 24 Mar 2026 09:19:20 +0100
Subject: [PATCH 12/13] Remove shrink-0 class, not needed.
---
hypha/apply/users/templates/users/partials/list.html | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/hypha/apply/users/templates/users/partials/list.html b/hypha/apply/users/templates/users/partials/list.html
index 9eed27fc20..93bd2d4072 100644
--- a/hypha/apply/users/templates/users/partials/list.html
+++ b/hypha/apply/users/templates/users/partials/list.html
@@ -4,7 +4,7 @@
{% for passkey in passkeys %}
- {% heroicon_micro "key" class="shrink-0 size-4 text-fg-muted" aria_hidden=true %}
+ {% heroicon_micro "key" class="size-4 text-fg-muted" aria_hidden=true %}
-
+
{% if passkey.last_used_at %}
{% blocktrans with when=passkey.last_used_at|timesince %}Used {{ when }} ago{% endblocktrans %}
{% else %}
From 1fc67c63295c13e1ced7c9452bda6d76afd08157 Mon Sep 17 00:00:00 2001
From: Fredrik Jonsson
Date: Tue, 24 Mar 2026 10:10:32 +0100
Subject: [PATCH 13/13] Improve some strings.
---
hypha/apply/users/templates/users/account.html | 2 +-
hypha/settings/base.py | 3 ++-
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/hypha/apply/users/templates/users/account.html b/hypha/apply/users/templates/users/account.html
index 3be5d929d4..220e92c4c5 100644
--- a/hypha/apply/users/templates/users/account.html
+++ b/hypha/apply/users/templates/users/account.html
@@ -95,7 +95,7 @@
{% trans "Passkeys" %}
- {% trans "Passkeys use your device's biometrics or PIN to sign in securely without a password." %}
+ {% trans "With passkeys you can use your fingerprint, face, or screen lock to login securely without a password." %}
{# Hidden inputs supply the API URLs to passkeys.js #}
diff --git a/hypha/settings/base.py b/hypha/settings/base.py
index 97523ba55a..6b7c6eb88b 100644
--- a/hypha/settings/base.py
+++ b/hypha/settings/base.py
@@ -30,7 +30,8 @@
# DEFAULT_RATE_LIMIT is used by login, password, 2FA, etc
DEFAULT_RATE_LIMIT = env.str("DEFAULT_RATE_LIMIT", "5/m")
-# IF Hypha should enforce 2FA for all users.
+# If Hypha should enforce 2FA for all users.
+# Users that login with passkeys are excluded since that is even more secure.
ENFORCE_TWO_FACTOR = env.bool("ENFORCE_TWO_FACTOR", False)
# WebAuthn / Passkey settings.