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..409247990d --- /dev/null +++ b/hypha/apply/users/migrations/0028_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", "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.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)), + ( + "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..443da862b0 100644 --- a/hypha/apply/users/models.py +++ b/hypha/apply/users/models.py @@ -406,3 +406,32 @@ 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.CharField(max_length=2048, unique=True) + # 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) + + 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..ec3c234a59 --- /dev/null +++ b/hypha/apply/users/passkey_views.py @@ -0,0 +1,299 @@ +import base64 +import json + +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.http import JsonResponse +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 +from django.views import View +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.structs import ( + AuthenticationCredential, + AuthenticatorAssertionResponse, + AuthenticatorAttestationResponse, + AuthenticatorSelectionCriteria, + AuthenticatorTransport, + PublicKeyCredentialDescriptor, + RegistrationCredential, + ResidentKeyRequirement, + UserVerificationRequirement, +) + +from .models import Passkey + +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 + + +@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), + transports=[AuthenticatorTransport(t) for t in pk.transports] or None, + ) + 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))) + + +@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: + 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: + return JsonResponse({"error": "Verification failed"}, 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, + transports=data["response"].get("transports", []), + ) + return JsonResponse({"status": "ok"}) + + +# --------------------------------------------------------------------------- +# Authentication — public (no session required) +# --------------------------------------------------------------------------- + + +@method_decorator( + ratelimit(key="ip", rate=settings.DEFAULT_RATE_LIMIT, method="POST"), + name="dispatch", +) +class PasskeyAuthBeginView(View): + def post(self, request): + options = generate_authentication_options( + 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))) + + +@method_decorator( + ratelimit(key="ip", rate=settings.DEFAULT_RATE_LIMIT, method="POST"), + 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, 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"])) + except Exception: + return JsonResponse({"error": "Invalid credential"}, status=400) + + 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: + return JsonResponse({"error": "Verification failed"}, 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 + user.backend = settings.CUSTOM_AUTH_BACKEND + login(request, user) + request.session["passkey_authenticated"] = True + + 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 +# --------------------------------------------------------------------------- + + +@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") +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") +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..220e92c4c5 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,30 @@

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

+ + {# 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..72c2d81ab0 --- /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 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..93bd2d4072 --- /dev/null +++ b/hypha/apply/users/templates/users/partials/list.html @@ -0,0 +1,75 @@ +{% load i18n heroicons %} +
+ {% if passkeys %} +
    + {% for passkey in passkeys %} +
  • + {% heroicon_micro "key" class="size-4 text-fg-muted" aria_hidden=true %} +
    + {% csrf_token %} + + +
    + + {% if passkey.last_used_at %} + {% blocktrans with when=passkey.last_used_at|timesince %}Used {{ when }} ago{% endblocktrans %} + {% else %} + {% trans "Never used" %} + {% endif %} + +
    + {% 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..6b7c6eb88b 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. +# 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..e4e842cc50 --- /dev/null +++ b/hypha/static_src/javascript/passkeys.js @@ -0,0 +1,223 @@ +/** + * 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 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), + }); + } + + /** + * 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 = document.getElementById( + "passkey-register-begin-url" + )?.value; + const completeUrl = document.getElementById( + "passkey-register-complete-url" + )?.value; + 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" && 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 beginUrl = document.getElementById("passkey-auth-begin-url")?.value; + const completeUrl = document.getElementById( + "passkey-auth-complete-url" + )?.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( + 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, + }); + if (!completeResp.ok) { + const err = await completeResp.json(); + throw new Error( + err.error || errorEl?.dataset.errorAuth || "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; + + _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 = 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 || "/"; + } 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 %}