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