Template
165 lines
4.7 KiB
Python
165 lines
4.7 KiB
Python
"""WebAuthn / Passkey helpers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
from urllib.parse import urlparse
|
|
|
|
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,
|
|
parse_authentication_credential_json,
|
|
parse_registration_credential_json,
|
|
)
|
|
from webauthn.helpers.structs import (
|
|
AuthenticatorSelectionCriteria,
|
|
AuthenticatorTransport,
|
|
PublicKeyCredentialDescriptor,
|
|
ResidentKeyRequirement,
|
|
UserVerificationRequirement,
|
|
)
|
|
|
|
from config import Settings
|
|
|
|
|
|
def rp_id_from_settings(settings: Settings) -> str:
|
|
raw = (settings.webapp_url or "").strip()
|
|
if not raw:
|
|
raise ValueError("WEBAPP_URL не задан — Passkey недоступен")
|
|
host = urlparse(raw).hostname
|
|
if not host:
|
|
raise ValueError("Некорректный WEBAPP_URL")
|
|
return host
|
|
|
|
|
|
def expected_origin(settings: Settings) -> str:
|
|
origin = (settings.webapp_url or "").strip().rstrip("/")
|
|
if not origin:
|
|
raise ValueError("WEBAPP_URL не задан")
|
|
return origin
|
|
|
|
|
|
def challenge_from_client_data(client_data_b64: str) -> str:
|
|
raw = base64url_to_bytes(client_data_b64)
|
|
data = json.loads(raw.decode("utf-8"))
|
|
challenge = data.get("challenge")
|
|
if not challenge:
|
|
raise ValueError("challenge missing")
|
|
return str(challenge)
|
|
|
|
|
|
def build_registration_options(
|
|
settings: Settings,
|
|
*,
|
|
user_id: int,
|
|
user_name: str,
|
|
user_display_name: str,
|
|
existing_credential_ids: list[bytes],
|
|
) -> tuple[dict[str, Any], bytes]:
|
|
options = generate_registration_options(
|
|
rp_id=rp_id_from_settings(settings),
|
|
rp_name="VPN Service",
|
|
user_id=str(user_id).encode("utf-8"),
|
|
user_name=user_name[:64] or f"user-{user_id}",
|
|
user_display_name=user_display_name[:64] or user_name,
|
|
exclude_credentials=[
|
|
PublicKeyCredentialDescriptor(id=cid) for cid in existing_credential_ids
|
|
],
|
|
authenticator_selection=AuthenticatorSelectionCriteria(
|
|
resident_key=ResidentKeyRequirement.PREFERRED,
|
|
user_verification=UserVerificationRequirement.PREFERRED,
|
|
),
|
|
)
|
|
challenge = options.challenge
|
|
return json.loads(options_to_json(options)), challenge
|
|
|
|
|
|
def verify_registration(
|
|
settings: Settings,
|
|
*,
|
|
credential: dict[str, Any],
|
|
expected_challenge: bytes,
|
|
) -> Any:
|
|
parsed = parse_registration_credential_json(json.dumps(credential))
|
|
return verify_registration_response(
|
|
credential=parsed,
|
|
expected_challenge=expected_challenge,
|
|
expected_rp_id=rp_id_from_settings(settings),
|
|
expected_origin=expected_origin(settings),
|
|
)
|
|
|
|
|
|
def build_authentication_options(
|
|
settings: Settings,
|
|
*,
|
|
allow_credential_ids: list[bytes] | None = None,
|
|
) -> tuple[dict[str, Any], bytes]:
|
|
allow = None
|
|
if allow_credential_ids:
|
|
allow = [PublicKeyCredentialDescriptor(id=cid) for cid in allow_credential_ids]
|
|
options = generate_authentication_options(
|
|
rp_id=rp_id_from_settings(settings),
|
|
allow_credentials=allow,
|
|
user_verification=UserVerificationRequirement.PREFERRED,
|
|
)
|
|
return json.loads(options_to_json(options)), options.challenge
|
|
|
|
|
|
def verify_authentication(
|
|
settings: Settings,
|
|
*,
|
|
credential: dict[str, Any],
|
|
expected_challenge: bytes,
|
|
credential_public_key: bytes,
|
|
credential_current_sign_count: int,
|
|
) -> Any:
|
|
parsed = parse_authentication_credential_json(json.dumps(credential))
|
|
return verify_authentication_response(
|
|
credential=parsed,
|
|
expected_challenge=expected_challenge,
|
|
expected_rp_id=rp_id_from_settings(settings),
|
|
expected_origin=expected_origin(settings),
|
|
credential_public_key=credential_public_key,
|
|
credential_current_sign_count=credential_current_sign_count,
|
|
)
|
|
|
|
|
|
def credential_id_b64(raw: bytes) -> str:
|
|
return bytes_to_base64url(raw)
|
|
|
|
|
|
def credential_id_bytes(b64: str) -> bytes:
|
|
return base64url_to_bytes(b64)
|
|
|
|
|
|
def parse_transports(raw: Any) -> list[str]:
|
|
if not raw:
|
|
return []
|
|
if isinstance(raw, list):
|
|
return [str(x) for x in raw]
|
|
try:
|
|
data = json.loads(raw) if isinstance(raw, str) else []
|
|
except json.JSONDecodeError:
|
|
return []
|
|
if isinstance(data, list):
|
|
return [str(x) for x in data]
|
|
return []
|
|
|
|
|
|
def transports_to_enum(names: list[str]) -> list[AuthenticatorTransport]:
|
|
out: list[AuthenticatorTransport] = []
|
|
for name in names:
|
|
try:
|
|
out.append(AuthenticatorTransport(name))
|
|
except ValueError:
|
|
continue
|
|
return out
|