diff --git a/hypha/apply/users/forms.py b/hypha/apply/users/forms.py index 9a5a97031b..8c30835fbc 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/0030_passkeys.py b/hypha/apply/users/migrations/0030_passkeys.py new file mode 100644 index 0000000000..703ff3eff6 --- /dev/null +++ b/hypha/apply/users/migrations/0030_passkeys.py @@ -0,0 +1,46 @@ +# Generated by Django 5.2.12 on 2026-03-23 21:24 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0029_alter_confirmaccesstoken_options_and_more"), + ] + + 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.CharField(max_length=2048, unique=True)), + ("public_key", models.CharField(max_length=2048)), + ("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)), + ( + "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 2653b1ea94..a61ffc3269 100644 --- a/hypha/apply/users/models.py +++ b/hypha/apply/users/models.py @@ -470,3 +470,32 @@ class Meta: ordering = ("modified",) verbose_name = _("Confirm Access Token") 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.CharField(max_length=2048, unique=True) + # base64url-encoded public key + public_key = models.CharField(max_length=2048) + 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) + + 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..fb6facca73 --- /dev/null +++ b/hypha/apply/users/passkey_views.py @@ -0,0 +1,338 @@ +import base64 +import json +import logging + +from django.conf import settings +from django.contrib.auth import login +from django.contrib.auth.decorators import login_required +from django.core.exceptions import PermissionDenied +from django.db import transaction +from django.http import JsonResponse +from django.shortcuts import get_object_or_404, render, resolve_url +from django.utils import timezone +from django.utils.http import url_has_allowed_host_and_scheme +from django.utils.translation import gettext_lazy as _ +from django.views.decorators.http import require_GET, require_POST +from django_ratelimit.decorators import ratelimit +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.exceptions import InvalidAuthenticationResponse +from webauthn.helpers.structs import ( + AuthenticationCredential, + AuthenticatorAssertionResponse, + AuthenticatorAttestationResponse, + AuthenticatorSelectionCriteria, + PublicKeyCredentialDescriptor, + RegistrationCredential, + ResidentKeyRequirement, + UserVerificationRequirement, +) + +from .models import Passkey + +logger = logging.getLogger(__name__) + +SESSION_CHALLENGE_KEY_REGISTER = "webauthn_challenge_register" +SESSION_CHALLENGE_KEY_AUTH = "webauthn_challenge_auth" + + +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, key: str): + request.session[key] = base64.b64encode(challenge).decode() + + +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) + + +# --------------------------------------------------------------------------- +# Registration — requires an authenticated user +# --------------------------------------------------------------------------- + + +MAX_PASSKEYS_PER_USER = 10 + + +@login_required +@require_POST +@ratelimit(key="user", rate=settings.DEFAULT_RATE_LIMIT, method="POST") +def passkey_register_begin(request): + user = request.user + existing_passkeys = list(user.passkeys.all()) + if len(existing_passkeys) >= MAX_PASSKEYS_PER_USER: + return JsonResponse( + { + "error": _("Maximum of {max} passkeys allowed").format( + max=MAX_PASSKEYS_PER_USER + ) + }, + status=400, + ) + existing = [ + PublicKeyCredentialDescriptor( + id=base64url_to_bytes(pk.credential_id), + ) + for pk in existing_passkeys + ] + 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, SESSION_CHALLENGE_KEY_REGISTER) + return JsonResponse(json.loads(options_to_json(options))) + + +@login_required +@require_POST +@ratelimit(key="user", rate=settings.DEFAULT_RATE_LIMIT, method="POST") +def passkey_register_complete(request): + try: + data = json.loads(request.body) + except (json.JSONDecodeError, ValueError): + return JsonResponse({"error": _("Invalid JSON")}, status=400) + + try: + challenge = _load_challenge(request, SESSION_CHALLENGE_KEY_REGISTER) + except PermissionDenied: + return JsonResponse({"error": _("No active WebAuthn challenge")}, 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: + logger.warning( + "Passkey registration verification failed for user %s", + request.user.pk, + exc_info=True, + ) + return JsonResponse({"error": _("Verification failed")}, status=400) + + name = (data.get("name") or "").strip()[:128] or timezone.now().strftime( + "Passkey %Y-%m-%d" + ) + try: + 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, + transports=data["response"].get("transports", []), + ) + except Exception: + logger.warning( + "Failed to save passkey for user %s", request.user.pk, exc_info=True + ) + return JsonResponse({"error": _("Could not save passkey")}, status=500) + logger.info("Passkey registered for user %s (name=%r)", request.user.pk, name) + return JsonResponse({"status": "ok"}) + + +# --------------------------------------------------------------------------- +# Authentication — public (no session required) +# --------------------------------------------------------------------------- + + +@require_POST +@ratelimit(key="ip", rate=settings.DEFAULT_RATE_LIMIT, method="POST") +def passkey_auth_begin(request): + options = generate_authentication_options( + rp_id=_get_rp_id(request), + user_verification=UserVerificationRequirement.REQUIRED, + ) + _store_challenge(request, options.challenge, SESSION_CHALLENGE_KEY_AUTH) + return JsonResponse(json.loads(options_to_json(options))) + + +@require_POST +@ratelimit(key="ip", rate=settings.DEFAULT_RATE_LIMIT, method="POST") +def passkey_auth_complete(request): + try: + data = json.loads(request.body) + except (json.JSONDecodeError, ValueError): + return JsonResponse({"error": _("Invalid JSON")}, status=400) + + try: + challenge = _load_challenge(request, SESSION_CHALLENGE_KEY_AUTH) + except PermissionDenied: + return JsonResponse({"error": _("No active WebAuthn challenge")}, status=400) + + try: + credential_id_b64 = bytes_to_base64url(base64url_to_bytes(data["rawId"])) + raw_user_handle = data["response"].get("userHandle") + user_handle_bytes = ( + base64url_to_bytes(raw_user_handle) if raw_user_handle else None + ) + except Exception: + return JsonResponse({"error": _("Invalid credential")}, status=400) + + try: + with transaction.atomic(): + passkey = ( + Passkey.objects.select_related("user") + .select_for_update() + .get(credential_id=credential_id_b64) + ) + + if user_handle_bytes is not None: + if user_handle_bytes != str(passkey.user.pk).encode(): + raise InvalidAuthenticationResponse("User handle mismatch") + + 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=user_handle_bytes, + ), + ) + 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, + ) + + 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 + except Passkey.DoesNotExist: + return JsonResponse({"error": _("Unknown credential")}, status=400) + except InvalidAuthenticationResponse as exc: + if "sign count" in str(exc).lower(): + logger.error( + "Passkey sign count regression — possible cloned authenticator" + " (credential=%s): %s", + credential_id_b64, + exc, + ) + else: + logger.warning( + "Passkey authentication verification failed for credential %s: %s", + credential_id_b64, + exc, + ) + return JsonResponse({"error": _("Verification failed")}, status=400) + except Exception: + logger.warning( + "Passkey authentication verification failed for credential %s", + credential_id_b64, + exc_info=True, + ) + return JsonResponse({"error": _("Verification failed")}, status=400) + user.backend = settings.CUSTOM_AUTH_BACKEND + login(request, user) + request.session["passkey_authenticated"] = True + + if data.get("remember_me"): + request.session.set_expiry(settings.SESSION_COOKIE_AGE_LONG) + + 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 = resolve_url(settings.LOGIN_REDIRECT_URL) + return JsonResponse({"status": "ok", "redirect_url": next_url}) + + +# --------------------------------------------------------------------------- +# Passkey management — account page +# --------------------------------------------------------------------------- + + +@login_required +@require_GET +def passkey_list(request): + passkeys = request.user.passkeys.all() + return render(request, "users/partials/passkey-list.html", {"passkeys": passkeys}) + + +@login_required +@require_POST +def passkey_delete(request, pk): + passkey = get_object_or_404(Passkey, pk=pk, user=request.user) + logger.info( + "Passkey deleted by user %s (passkey=%s, name=%r)", + request.user.pk, + pk, + passkey.name, + ) + passkey.delete() + passkeys = request.user.passkeys.all() + return render(request, "users/partials/passkey-list.html", {"passkeys": passkeys}) + + +@login_required +@require_POST +def passkey_rename(request, pk): + passkey = get_object_or_404(Passkey, pk=pk, user=request.user) + name = request.POST.get("name", "").strip()[:128] + if name: + passkey.name = name + passkey.save(update_fields=["name"]) + passkeys = request.user.passkeys.all() + return render(request, "users/partials/passkey-list.html", {"passkeys": passkeys}) diff --git a/hypha/apply/users/templates/users/account.html b/hypha/apply/users/templates/users/account.html index b3561c7feb..cd7e1e18d6 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 %} @@ -86,13 +86,26 @@

{% if default_device %} - {% trans "Backup codes" %} - {% trans "Disable 2FA" %} + {% trans "Backup codes" %} + {% trans "Disable 2FA" %} {% else %} - {% trans "Enable 2FA" %} + {% trans "Enable 2FA" %} {% endif %}

+

{% trans "Passkeys" %}

+

+ {% trans "With passkeys you can use your fingerprint, face, or screen lock to login securely without a password." %} +

+ +
+
+
+ {# 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 +121,7 @@

{% trans "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..2efeada156 --- /dev/null +++ b/hypha/apply/users/templates/users/includes/passkey_login_button.html @@ -0,0 +1,20 @@ +{% 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 6e1f049280..462fd66d76 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 0bab347af5..868e983ce6 100644 --- a/hypha/apply/users/templates/users/login.html +++ b/hypha/apply/users/templates/users/login.html @@ -1,10 +1,10 @@ {% extends "base-apply.html" %} -{% load i18n wagtailcore_tags heroicons %} +{% load i18n wagtailcore_tags heroicons static %} {% block title %}{% trans "Log in" %}{% endblock %} {% block content %} -
+
{% if wizard.steps.current == 'token' %} {% if device.method == 'call' %} @@ -90,10 +90,11 @@

{% translate "OR" %}
+ {% include "users/includes/passwordless_login_button.html" %} + {% include "users/includes/passkey_login_button.html" %} {% if GOOGLE_OAUTH2 %} - {% include "includes/org_login_button.html" %} + {% include "users/includes/org_login_button.html" %} {% endif %} - {% include "includes/passwordless_login_button.html" %}
{% else %} @@ -142,6 +143,12 @@

{% block extra_js %} {{ block.super }} + + {# Fix copy of dynamic fields label #} + +{% endblock %} diff --git a/hypha/apply/users/tests/test_passkey_views.py b/hypha/apply/users/tests/test_passkey_views.py new file mode 100644 index 0000000000..09901a0e11 --- /dev/null +++ b/hypha/apply/users/tests/test_passkey_views.py @@ -0,0 +1,627 @@ +"""Tests for WebAuthn passkey views (passkey_views.py).""" + +import base64 +import json +from unittest.mock import MagicMock, patch + +from django.conf import settings +from django.test import TestCase, override_settings +from django.urls import reverse +from webauthn.helpers import bytes_to_base64url +from webauthn.helpers.exceptions import InvalidAuthenticationResponse + +from ..models import Passkey +from ..passkey_views import ( + MAX_PASSKEYS_PER_USER, + SESSION_CHALLENGE_KEY_AUTH, + SESSION_CHALLENGE_KEY_REGISTER, +) +from .factories import UserFactory + +AUTH_BEGIN_URL = reverse("users:passkey_auth_begin") +AUTH_COMPLETE_URL = reverse("users:passkey_auth_complete") +REGISTER_BEGIN_URL = reverse("users:passkey_register_begin") +REGISTER_COMPLETE_URL = reverse("users:passkey_register_complete") +PASSKEY_LIST_URL = reverse("users:passkey_list") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def make_passkey( + user, credential_id=None, public_key=None, name="Test Passkey", **kwargs +): + """Create a Passkey for a user with sensible defaults.""" + kwargs.setdefault("sign_count", 0) + kwargs.setdefault("transports", ["internal"]) + return Passkey.objects.create( + user=user, + name=name, + # base64url of b"test-cred-id" and b"test-pubkey" + credential_id=credential_id or bytes_to_base64url(b"test-cred-id"), + public_key=public_key or bytes_to_base64url(b"test-pubkey"), + **kwargs, + ) + + +def set_challenge(client, key, challenge_bytes=b"test-challenge"): + """Store a base64-encoded WebAuthn challenge in the test client session.""" + session = client.session + session[key] = base64.b64encode(challenge_bytes).decode() + session.save() + + +# --------------------------------------------------------------------------- +# Registration begin +# --------------------------------------------------------------------------- + + +@override_settings(RATELIMIT_ENABLE=False) +class TestPasskeyRegisterBegin(TestCase): + def setUp(self): + self.user = UserFactory() + self.client.force_login(self.user) + + def test_requires_login(self): + self.client.logout() + response = self.client.post(REGISTER_BEGIN_URL) + self.assertEqual(response.status_code, 302) + + def test_requires_post(self): + response = self.client.get(REGISTER_BEGIN_URL) + self.assertEqual(response.status_code, 405) + + def test_returns_registration_options(self): + response = self.client.post(REGISTER_BEGIN_URL) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn("challenge", data) + self.assertIn("rp", data) + self.assertIn("user", data) + + def test_stores_challenge_in_session(self): + self.client.post(REGISTER_BEGIN_URL) + self.assertIn(SESSION_CHALLENGE_KEY_REGISTER, self.client.session) + + def test_returns_400_when_max_passkeys_reached(self): + for i in range(MAX_PASSKEYS_PER_USER): + make_passkey( + self.user, credential_id=bytes_to_base64url(f"cred{i}".encode()) + ) + response = self.client.post(REGISTER_BEGIN_URL) + self.assertEqual(response.status_code, 400) + self.assertIn("error", response.json()) + + +# --------------------------------------------------------------------------- +# Registration complete +# --------------------------------------------------------------------------- + + +@override_settings(RATELIMIT_ENABLE=False) +class TestPasskeyRegisterComplete(TestCase): + CHALLENGE = b"test-challenge-register" + + def setUp(self): + self.user = UserFactory() + self.client.force_login(self.user) + + def _set_challenge(self): + set_challenge(self.client, SESSION_CHALLENGE_KEY_REGISTER, self.CHALLENGE) + + def _payload(self, name="My Key"): + return { + "id": bytes_to_base64url(b"new-cred"), + "rawId": bytes_to_base64url(b"new-cred"), + "name": name, + "response": { + "clientDataJSON": bytes_to_base64url(b"clientdata"), + "attestationObject": bytes_to_base64url(b"attestation"), + "transports": ["internal"], + }, + "type": "public-key", + } + + def test_requires_login(self): + self.client.logout() + self._set_challenge() + response = self.client.post( + REGISTER_COMPLETE_URL, + data=json.dumps(self._payload()), + content_type="application/json", + ) + self.assertEqual(response.status_code, 302) + + def test_invalid_json_returns_400(self): + self._set_challenge() + response = self.client.post( + REGISTER_COMPLETE_URL, + data="not-valid-json", + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + self.assertIn("error", response.json()) + + def test_missing_challenge_returns_400(self): + # No challenge placed in session + response = self.client.post( + REGISTER_COMPLETE_URL, + data=json.dumps(self._payload()), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + self.assertIn("error", response.json()) + + @patch("hypha.apply.users.passkey_views.verify_registration_response") + def test_successful_registration_saves_passkey(self, mock_verify): + mock_verify.return_value = MagicMock( + credential_id=b"saved-cred-id", + credential_public_key=b"saved-pubkey", + sign_count=0, + ) + self._set_challenge() + response = self.client.post( + REGISTER_COMPLETE_URL, + data=json.dumps(self._payload(name="My Key")), + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["status"], "ok") + self.assertEqual(self.user.passkeys.count(), 1) + self.assertEqual(self.user.passkeys.first().name, "My Key") + + @patch("hypha.apply.users.passkey_views.verify_registration_response") + def test_name_is_truncated_to_128_chars(self, mock_verify): + mock_verify.return_value = MagicMock( + credential_id=b"cred", + credential_public_key=b"pubkey", + sign_count=0, + ) + self._set_challenge() + long_name = "x" * 200 + response = self.client.post( + REGISTER_COMPLETE_URL, + data=json.dumps(self._payload(name=long_name)), + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(self.user.passkeys.first().name, "x" * 128) + + @patch("hypha.apply.users.passkey_views.verify_registration_response") + def test_empty_name_gets_date_default(self, mock_verify): + mock_verify.return_value = MagicMock( + credential_id=b"cred", + credential_public_key=b"pubkey", + sign_count=0, + ) + self._set_challenge() + response = self.client.post( + REGISTER_COMPLETE_URL, + data=json.dumps(self._payload(name="")), + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + passkey = self.user.passkeys.first() + self.assertTrue(passkey.name.startswith("Passkey ")) + + @patch("hypha.apply.users.passkey_views.verify_registration_response") + def test_verification_failure_returns_400_and_saves_nothing(self, mock_verify): + mock_verify.side_effect = Exception("crypto error") + self._set_challenge() + response = self.client.post( + REGISTER_COMPLETE_URL, + data=json.dumps(self._payload()), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + self.assertIn("error", response.json()) + self.assertEqual(self.user.passkeys.count(), 0) + + def test_challenge_is_consumed_and_cannot_be_replayed(self): + """The registration challenge must be single-use.""" + self._set_challenge() + # First call consumes the challenge (will fail verification, that's OK) + self.client.post( + REGISTER_COMPLETE_URL, + data=json.dumps(self._payload()), + content_type="application/json", + ) + # Second call with the same payload must fail with "no challenge" error + response = self.client.post( + REGISTER_COMPLETE_URL, + data=json.dumps(self._payload()), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + self.assertIn("challenge", response.json()["error"].lower()) + + +# --------------------------------------------------------------------------- +# Authentication begin +# --------------------------------------------------------------------------- + + +@override_settings(RATELIMIT_ENABLE=False) +class TestPasskeyAuthBegin(TestCase): + def test_requires_post(self): + response = self.client.get(AUTH_BEGIN_URL) + self.assertEqual(response.status_code, 405) + + def test_returns_authentication_options(self): + response = self.client.post(AUTH_BEGIN_URL) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn("challenge", data) + self.assertIn("rpId", data) + + def test_stores_challenge_in_session(self): + self.client.post(AUTH_BEGIN_URL) + self.assertIn(SESSION_CHALLENGE_KEY_AUTH, self.client.session) + + def test_accessible_without_login(self): + response = self.client.post(AUTH_BEGIN_URL) + self.assertEqual(response.status_code, 200) + + +# --------------------------------------------------------------------------- +# Authentication complete +# --------------------------------------------------------------------------- + +CRED_ID = bytes_to_base64url(b"auth-cred-id") +PUBKEY = bytes_to_base64url(b"auth-pubkey") + + +@override_settings(RATELIMIT_ENABLE=False) +class TestPasskeyAuthComplete(TestCase): + CHALLENGE = b"test-challenge-auth" + + def setUp(self): + self.user = UserFactory() + self.passkey = make_passkey( + self.user, + credential_id=CRED_ID, + public_key=PUBKEY, + sign_count=0, + ) + + def _set_challenge(self): + set_challenge(self.client, SESSION_CHALLENGE_KEY_AUTH, self.CHALLENGE) + + def _payload(self, **overrides): + payload = { + "id": CRED_ID, + "rawId": CRED_ID, + "response": { + "clientDataJSON": bytes_to_base64url(b"clientdata"), + "authenticatorData": bytes_to_base64url(b"authdata"), + "signature": bytes_to_base64url(b"sig"), + }, + "type": "public-key", + } + payload.update(overrides) + return payload + + def test_requires_post(self): + response = self.client.get(AUTH_COMPLETE_URL) + self.assertEqual(response.status_code, 405) + + def test_invalid_json_returns_400(self): + self._set_challenge() + response = self.client.post( + AUTH_COMPLETE_URL, + data="not-json", + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + self.assertIn("error", response.json()) + + def test_missing_challenge_returns_400(self): + response = self.client.post( + AUTH_COMPLETE_URL, + data=json.dumps(self._payload()), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + self.assertIn("error", response.json()) + + def test_unknown_credential_returns_400(self): + self._set_challenge() + unknown_id = bytes_to_base64url(b"no-such-cred") + response = self.client.post( + AUTH_COMPLETE_URL, + data=json.dumps(self._payload(id=unknown_id, rawId=unknown_id)), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + self.assertIn("error", response.json()) + + @patch("hypha.apply.users.passkey_views.verify_authentication_response") + def test_successful_auth_logs_in_user(self, mock_verify): + mock_verify.return_value = MagicMock(new_sign_count=1) + self._set_challenge() + response = self.client.post( + AUTH_COMPLETE_URL, + data=json.dumps(self._payload()), + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["status"], "ok") + self.assertIn("redirect_url", response.json()) + self.assertEqual(int(self.client.session["_auth_user_id"]), self.user.pk) + + @patch("hypha.apply.users.passkey_views.verify_authentication_response") + def test_successful_auth_sets_passkey_session_flag(self, mock_verify): + mock_verify.return_value = MagicMock(new_sign_count=1) + self._set_challenge() + self.client.post( + AUTH_COMPLETE_URL, + data=json.dumps(self._payload()), + content_type="application/json", + ) + self.assertTrue(self.client.session.get("passkey_authenticated")) + + @patch("hypha.apply.users.passkey_views.verify_authentication_response") + def test_successful_auth_updates_sign_count(self, mock_verify): + mock_verify.return_value = MagicMock(new_sign_count=42) + self._set_challenge() + self.client.post( + AUTH_COMPLETE_URL, + data=json.dumps(self._payload()), + content_type="application/json", + ) + self.passkey.refresh_from_db() + self.assertEqual(self.passkey.sign_count, 42) + + @patch("hypha.apply.users.passkey_views.verify_authentication_response") + def test_successful_auth_updates_last_used_at(self, mock_verify): + mock_verify.return_value = MagicMock(new_sign_count=1) + self._set_challenge() + self.client.post( + AUTH_COMPLETE_URL, + data=json.dumps(self._payload()), + content_type="application/json", + ) + self.passkey.refresh_from_db() + self.assertIsNotNone(self.passkey.last_used_at) + + @patch("hypha.apply.users.passkey_views.verify_authentication_response") + def test_open_redirect_falls_back_to_login_redirect(self, mock_verify): + mock_verify.return_value = MagicMock(new_sign_count=1) + self._set_challenge() + payload = self._payload() + payload["next"] = "https://evil.example.com/steal" + response = self.client.post( + AUTH_COMPLETE_URL, + data=json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + redirect_url = response.json()["redirect_url"] + self.assertNotIn("evil.example.com", redirect_url) + + @patch("hypha.apply.users.passkey_views.verify_authentication_response") + def test_valid_next_url_is_passed_through(self, mock_verify): + mock_verify.return_value = MagicMock(new_sign_count=1) + self._set_challenge() + payload = self._payload() + payload["next"] = "/apply/" + response = self.client.post( + AUTH_COMPLETE_URL, + data=json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["redirect_url"], "/apply/") + + def test_user_handle_mismatch_returns_400(self): + """A userHandle that doesn't match the passkey owner must be rejected.""" + other_user = UserFactory() + # Encode other_user's pk as the handle — view decodes and compares against + # str(passkey.user.pk).encode(), which is this user's pk. + wrong_handle = bytes_to_base64url(str(other_user.pk).encode()) + self._set_challenge() + payload = self._payload() + payload["response"]["userHandle"] = wrong_handle + response = self.client.post( + AUTH_COMPLETE_URL, + data=json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + self.assertIn("error", response.json()) + + @patch("hypha.apply.users.passkey_views.verify_authentication_response") + def test_verification_failure_returns_400(self, mock_verify): + mock_verify.side_effect = InvalidAuthenticationResponse("bad signature") + self._set_challenge() + response = self.client.post( + AUTH_COMPLETE_URL, + data=json.dumps(self._payload()), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + self.assertIn("error", response.json()) + + def test_challenge_is_consumed_and_cannot_be_replayed(self): + """The auth challenge must be single-use (popped from session).""" + self._set_challenge() + # First call pops the challenge + self.client.post( + AUTH_COMPLETE_URL, + data=json.dumps(self._payload()), + content_type="application/json", + ) + # Second call must fail because challenge is gone + response = self.client.post( + AUTH_COMPLETE_URL, + data=json.dumps(self._payload()), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + self.assertIn("challenge", response.json()["error"].lower()) + + +# --------------------------------------------------------------------------- +# Passkey list +# --------------------------------------------------------------------------- + + +class TestPasskeyList(TestCase): + def setUp(self): + self.user = UserFactory() + self.client.force_login(self.user) + + def test_requires_login(self): + self.client.logout() + response = self.client.get(PASSKEY_LIST_URL) + self.assertEqual(response.status_code, 302) + + def test_requires_get(self): + response = self.client.post(PASSKEY_LIST_URL) + self.assertEqual(response.status_code, 405) + + def test_returns_200_with_no_passkeys(self): + response = self.client.get(PASSKEY_LIST_URL) + self.assertEqual(response.status_code, 200) + + def test_shows_own_passkeys(self): + make_passkey(self.user, name="My MacBook") + response = self.client.get(PASSKEY_LIST_URL) + self.assertContains(response, "My MacBook") + + def test_does_not_show_other_users_passkeys(self): + other = UserFactory() + make_passkey(other, name="Someone Elses Key") + response = self.client.get(PASSKEY_LIST_URL) + self.assertNotContains(response, "Someone Elses Key") + + +# --------------------------------------------------------------------------- +# Passkey delete +# --------------------------------------------------------------------------- + + +class TestPasskeyDelete(TestCase): + def setUp(self): + self.user = UserFactory() + self.client.force_login(self.user) + + def test_requires_login(self): + self.client.logout() + passkey = make_passkey(self.user) + url = reverse("users:passkey_delete", args=[passkey.pk]) + response = self.client.post(url) + self.assertEqual(response.status_code, 302) + + def test_deletes_own_passkey(self): + passkey = make_passkey(self.user) + url = reverse("users:passkey_delete", args=[passkey.pk]) + response = self.client.post(url) + self.assertEqual(response.status_code, 200) + self.assertFalse(Passkey.objects.filter(pk=passkey.pk).exists()) + + def test_cannot_delete_other_users_passkey(self): + other = UserFactory() + passkey = make_passkey(other) + url = reverse("users:passkey_delete", args=[passkey.pk]) + response = self.client.post(url) + self.assertEqual(response.status_code, 404) + self.assertTrue(Passkey.objects.filter(pk=passkey.pk).exists()) + + def test_returns_updated_passkey_list_partial(self): + passkey = make_passkey(self.user) + url = reverse("users:passkey_delete", args=[passkey.pk]) + response = self.client.post(url) + self.assertTemplateUsed(response, "users/partials/passkey-list.html") + + +# --------------------------------------------------------------------------- +# Passkey rename +# --------------------------------------------------------------------------- + + +class TestPasskeyRename(TestCase): + def setUp(self): + self.user = UserFactory() + self.client.force_login(self.user) + + def test_requires_login(self): + self.client.logout() + passkey = make_passkey(self.user) + url = reverse("users:passkey_rename", args=[passkey.pk]) + response = self.client.post(url, {"name": "New Name"}) + self.assertEqual(response.status_code, 302) + + def test_renames_own_passkey(self): + passkey = make_passkey(self.user, name="Old Name") + url = reverse("users:passkey_rename", args=[passkey.pk]) + response = self.client.post(url, {"name": "New Name"}) + self.assertEqual(response.status_code, 200) + passkey.refresh_from_db() + self.assertEqual(passkey.name, "New Name") + + def test_cannot_rename_other_users_passkey(self): + other = UserFactory() + passkey = make_passkey(other, name="Original") + url = reverse("users:passkey_rename", args=[passkey.pk]) + response = self.client.post(url, {"name": "Hacked Name"}) + self.assertEqual(response.status_code, 404) + passkey.refresh_from_db() + self.assertEqual(passkey.name, "Original") + + def test_whitespace_only_name_is_ignored(self): + passkey = make_passkey(self.user, name="Original") + url = reverse("users:passkey_rename", args=[passkey.pk]) + response = self.client.post(url, {"name": " "}) + self.assertEqual(response.status_code, 200) + passkey.refresh_from_db() + self.assertEqual(passkey.name, "Original") + + def test_name_is_trimmed(self): + passkey = make_passkey(self.user) + url = reverse("users:passkey_rename", args=[passkey.pk]) + self.client.post(url, {"name": " Trimmed "}) + passkey.refresh_from_db() + self.assertEqual(passkey.name, "Trimmed") + + def test_name_is_truncated_to_128_chars(self): + passkey = make_passkey(self.user) + url = reverse("users:passkey_rename", args=[passkey.pk]) + self.client.post(url, {"name": "x" * 200}) + passkey.refresh_from_db() + self.assertEqual(passkey.name, "x" * 128) + + def test_returns_updated_passkey_list_partial(self): + passkey = make_passkey(self.user) + url = reverse("users:passkey_rename", args=[passkey.pk]) + response = self.client.post(url, {"name": "Updated"}) + self.assertTemplateUsed(response, "users/partials/passkey-list.html") + + +# --------------------------------------------------------------------------- +# Middleware — passkey_authenticated session flag bypasses ENFORCE_TWO_FACTOR +# --------------------------------------------------------------------------- + + +@override_settings(ENFORCE_TWO_FACTOR=True) +class TestPasskeyMiddlewareBypass(TestCase): + def test_passkey_session_flag_bypasses_2fa_enforcement(self): + user = UserFactory() + self.client.force_login(user) + session = self.client.session + session["passkey_authenticated"] = True + session.save() + + # The middleware should let the request through — account page is always accessible. + response = self.client.get(reverse("users:account"), follow=True) + self.assertNotContains(response, "Permission Denied") + + def test_without_passkey_flag_unverified_user_is_blocked(self): + user = UserFactory() + self.client.force_login(user) + # No passkey_authenticated flag and no OTP device + + response = self.client.get(settings.LOGIN_REDIRECT_URL, follow=True) + self.assertContains(response, "Permission Denied") diff --git a/hypha/apply/users/urls.py b/hypha/apply/users/urls.py index 23d51f75f2..47c0907a65 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 ( + passkey_auth_begin, + passkey_auth_complete, + passkey_delete, + passkey_list, + passkey_register_begin, + passkey_register_complete, + passkey_rename, +) 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/", + passkey_auth_begin, + name="passkey_auth_begin", + ), + path( + "passkeys/auth/complete/", + passkey_auth_complete, + name="passkey_auth_complete", + ), ] account_urls = [ @@ -115,6 +135,28 @@ name="activate", ), path("hijack/", hijack_view, name="hijack"), + # Passkey management + path("passkeys/", passkey_list, name="passkey_list"), + path( + "passkeys/register/begin/", + passkey_register_begin, + name="passkey_register_begin", + ), + path( + "passkeys/register/complete/", + passkey_register_complete, + name="passkey_register_complete", + ), + path( + "passkeys//delete/", + passkey_delete, + name="passkey_delete", + ), + path( + "passkeys//rename/", + passkey_rename, + 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 161a6dfbaa..8eda2bb0ba 100644 --- a/hypha/settings/base.py +++ b/hypha/settings/base.py @@ -30,9 +30,21 @@ # 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. +# WEBAUTHN_RP_ID: the relying party domain, e.g. "example.com" (no port, no scheme). +# Defaults to the request host if not set. OBS! Do not use default in production! +# 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..be99503a2e --- /dev/null +++ b/hypha/static_src/javascript/passkeys.js @@ -0,0 +1,246 @@ +/** + * 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 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 || {}; + +window.hypha.passkeys = (function () { + let _conditionalAbortController = null; + function getCsrfToken() { + const hxheaders = document.body.getAttribute("hx-headers") || "{}"; + const headers = JSON.parse(hxheaders); + return headers["X-CSRFToken"] || ""; + } + + function getRememberMe() { + const el = + document.getElementById("id_auth-remember_me") || + document.getElementById("id_remember_me"); + return el ? el.checked : false; + } + + function jsonPost(url, body) { + return fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": getCsrfToken(), + }, + body: JSON.stringify(body), + }); + } + + /** + * 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 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")); + } + + if (conditionalOk) { + _startConditionalMediation(); + } + } + + /** + * Register a new passkey for the currently authenticated user. + * Called from the account page "Add passkey" form (onsubmit). + * @param {HTMLFormElement} formEl The
element containing a [name=name] input. + */ + async function register(formEl) { + const beginUrl = formEl?.dataset.beginUrl; + const completeUrl = formEl?.dataset.completeUrl; + if (!beginUrl || !completeUrl) return; + + const nameInput = formEl?.querySelector("[name=name]"); + const errorEl = document.getElementById("passkey-error"); + const submitBtn = formEl?.querySelector("[type=submit]"); + + try { + 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 || formEl?.dataset.errorServer || "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: nameInput?.value.trim() || "", + }); + if (!completeResp.ok) { + const err = await completeResp.json(); + throw new Error( + err.error || formEl?.dataset.errorRegister || "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") { + // user cancelled — do nothing + } else if (err.name === "InvalidStateError") { + if (errorEl) { + errorEl.textContent = + formEl?.dataset.errorDuplicate || + "This authenticator already has a passkey registered. Try a different authenticator or device."; + errorEl.hidden = false; + } + } else if (errorEl) { + errorEl.textContent = err.message; + errorEl.hidden = false; + } + } finally { + if (submitBtn) submitBtn.disabled = false; + } + } + + /** + * 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 btn = document.getElementById("btn-passkey-login"); + const beginUrl = btn?.dataset.beginUrl; + const completeUrl = btn?.dataset.completeUrl; + if (!beginUrl || !completeUrl) return; + + const nextUrl = btn?.dataset.nextUrl || ""; + const errorEl = document.getElementById("passkey-auth-error"); + + try { + const beginResp = await jsonPost(beginUrl, {}); + if (!beginResp.ok) + throw new Error( + errorEl?.dataset.errorBegin || "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(), + next: nextUrl, + remember_me: getRememberMe(), + }); + if (!completeResp.ok) { + const err = await completeResp.json(); + throw new Error( + err.error || errorEl?.dataset.errorAuth || "Authentication failed" + ); + } + const data = await completeResp.json(); + const redirectUrl = new URL( + data.redirect_url || "/", + window.location.origin + ); + window.location.href = + redirectUrl.origin === window.location.origin + ? redirectUrl.pathname + redirectUrl.search + redirectUrl.hash + : "/"; + } 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 btn = document.getElementById("btn-passkey-login"); + const beginUrl = btn?.dataset.beginUrl; + const completeUrl = btn?.dataset.completeUrl; + if (!beginUrl || !completeUrl) return; + + _conditionalAbortController = new AbortController(); + + 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", + signal: _conditionalAbortController.signal, + }); + + if (!credential) return; + + const nextUrl = btn?.dataset.nextUrl || ""; + const completeResp = await jsonPost(completeUrl, { + ...credential.toJSON(), + next: nextUrl, + remember_me: getRememberMe(), + }); + if (!completeResp.ok) return; + const data = await completeResp.json(); + const redirectUrl = new URL( + data.redirect_url || "/", + window.location.origin + ); + window.location.href = + redirectUrl.origin === window.location.origin + ? redirectUrl.pathname + redirectUrl.search + redirectUrl.hash + : "/"; + } 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 %}