Skip to content

Passkey views

hypha.apply.users.passkey_views

logger module-attribute

logger = getLogger(__name__)

SESSION_CHALLENGE_KEY_REGISTER module-attribute

SESSION_CHALLENGE_KEY_REGISTER = 'webauthn_challenge_register'

SESSION_CHALLENGE_KEY_AUTH module-attribute

SESSION_CHALLENGE_KEY_AUTH = 'webauthn_challenge_auth'

MAX_PASSKEYS_PER_USER module-attribute

MAX_PASSKEYS_PER_USER = 10

passkeys_enabled

passkeys_enabled()

Passkeys require WEBAUTHN_RP_ID in production. In DEBUG (local/dev) we fall back to the request host so the feature can be exercised locally.

Source code in hypha/apply/users/passkey_views.py
def passkeys_enabled() -> bool:
    """Passkeys require WEBAUTHN_RP_ID in production. In DEBUG (local/dev)
    we fall back to the request host so the feature can be exercised locally.
    """
    return bool(getattr(settings, "WEBAUTHN_RP_ID", None)) or settings.DEBUG

passkeys_required

passkeys_required(view_func)
Source code in hypha/apply/users/passkey_views.py
def passkeys_required(view_func):
    @wraps(view_func)
    def _wrapped(request, *args, **kwargs):
        if not passkeys_enabled():
            raise Http404
        return view_func(request, *args, **kwargs)

    return _wrapped

passkey_register_begin

passkey_register_begin(request)
Source code in hypha/apply/users/passkey_views.py
@passkeys_required
@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)))

passkey_register_complete

passkey_register_complete(request)
Source code in hypha/apply/users/passkey_views.py
@passkeys_required
@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)

    transports = _clean_transports(data.get("response", {}).get("transports"))
    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=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=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"})

passkey_auth_begin

passkey_auth_begin(request)
Source code in hypha/apply/users/passkey_views.py
@passkeys_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)))

passkey_auth_complete

passkey_auth_complete(request)
Source code in hypha/apply/users/passkey_views.py
@passkeys_required
@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_list

passkey_list(request)
Source code in hypha/apply/users/passkey_views.py
@passkeys_required
@login_required
@require_GET
def passkey_list(request):
    passkeys = request.user.passkeys.all()
    return render(request, "users/partials/passkey-list.html", {"passkeys": passkeys})

passkey_delete

passkey_delete(request, pk)
Source code in hypha/apply/users/passkey_views.py
@passkeys_required
@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})

passkey_rename

passkey_rename(request, pk)
Source code in hypha/apply/users/passkey_views.py
@passkeys_required
@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})