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 %} + + + + 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 %} +
    + {% csrf_token %} + + +
    + {{ passkey.created_at|date:"Y-m-d" }} +
    + {% csrf_token %} + +
    +
  • + {% endfor %} +
+ {% else %} +

{% trans "No passkeys registered." %}

+ {% endif %} + +
+ +
+ +
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 %} - + + 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 %} +
{% trans "No passkeys registered." %}

{% 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 %} -
  • 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.