# backend/auth/webauthn_mfa.py # WebAuthn support for MFA (ROADMAP #64): security keys / platform biometrics. # Thin wrapper over the `webauthn` library with an in-memory challenge store. # # Credentials are persisted in users.json under "webauthn_credentials": # [{ "credential_id": , "public_key": , "sign_count": int, # "transports": [...], "label": str, "registered_at": iso }] from __future__ import annotations import logging import os import secrets import time from typing import Any 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, ResidentKeyRequirement, UserVerificationRequirement, ) logger = logging.getLogger("obsigate.auth.webauthn") # Challenge lifetime: clients have 3 minutes to complete the ceremony. CHALLENGE_TTL_SECONDS = 180 # How many outstanding challenges to keep per key. BUG-070: a single slot made # the flow fragile — a double-click on "add key" (or any retry) overwrote the # pending challenge and the in-flight ceremony failed with # "Client data challenge was not expected challenge". The verifier now accepts # any recent challenge for the key. MAX_PENDING_PER_KEY = 5 # In-memory pending challenges: key -> [(challenge_bytes, expires_at), ...] # (newest last) _pending: dict[str, list[tuple[bytes, float]]] = {} def rp_id() -> str: return os.environ.get("OBSIGATE_WEBAUTHN_RP_ID", "localhost") def rp_name() -> str: return os.environ.get("OBSIGATE_WEBAUTHN_RP_NAME", "ObsiGate") def expected_origins() -> list[str]: raw = os.environ.get("OBSIGATE_WEBAUTHN_ORIGINS", "http://localhost") return [o.strip() for o in raw.split(",") if o.strip()] def resolve_relying_party(request: Any = None) -> tuple[str, list[str]]: """Resolve the WebAuthn (rp_id, expected_origins) for a ceremony. BUG-070: the previous defaults (rp_id ``localhost``, origins ``http://localhost``) rejected every real-world access URL — any port (``http://localhost:2020``), ``127.0.0.1``, a LAN host or a public domain failed verification with "Unexpected client data origin". Explicit configuration still wins: when ``OBSIGATE_WEBAUTHN_RP_ID`` / ``OBSIGATE_WEBAUTHN_ORIGINS`` are set they are used unchanged. Otherwise the values are derived from the incoming request (exact ``Host``, port included, since the browser origin carries non-default ports). Behind a reverse proxy the external host/proto come from ``X-Forwarded-Host`` / ``X-Forwarded-Proto``, honored only when ``OBSIGATE_TRUST_PROXY=true`` (same rule as ``get_client_ip``). """ env_rp = os.environ.get("OBSIGATE_WEBAUTHN_RP_ID") env_raw = os.environ.get("OBSIGATE_WEBAUTHN_ORIGINS") if request is None: return (env_rp or "localhost", [o.strip() for o in env_raw.split(",") if o.strip()] if env_raw else ["http://localhost"]) from backend.services.net import is_trusted_proxy if is_trusted_proxy(): fwd_host = request.headers.get("x-forwarded-host", "") host = fwd_host.split(",")[0].strip() or request.headers.get("host", "") fwd_proto = request.headers.get("x-forwarded-proto", "") scheme = fwd_proto.split(",")[0].strip() or request.url.scheme else: host = request.headers.get("host", "") scheme = request.url.scheme if not host: url = request.url host = url.netloc or url.hostname or "" scheme = scheme or url.scheme or "http" rp = env_rp or _hostname_only(host) or "localhost" if env_raw: origins = [o.strip() for o in env_raw.split(",") if o.strip()] else: origins = [f"{scheme or 'http'}://{host}"] if host else ["http://localhost"] return rp, origins def _hostname_only(host: str) -> str: """Strip the port (and IPv6 brackets) from a Host header value.""" host = host.strip() if host.startswith("["): # [::1]:8080 or [::1] end = host.find("]") return host[1:end] if end > 0 else host if host.count(":") == 1: name, _, port = host.partition(":") return name if port.isdigit() else host return host def _prune_expired() -> None: now = time.time() for key in list(_pending): remaining = [(c, exp) for c, exp in _pending[key] if exp >= now] if remaining: _pending[key] = remaining else: _pending.pop(key, None) def _store_challenge(key: str) -> bytes: _prune_expired() challenge = secrets.token_bytes(32) slot = _pending.setdefault(key, []) slot.append((challenge, time.time() + CHALLENGE_TTL_SECONDS)) del slot[:-MAX_PENDING_PER_KEY] # keep only the most recent ones return challenge def _take_challenge(key: str) -> bytes | None: """Pop the newest challenge (single-use). Returns None if missing/expired.""" _prune_expired() slot = _pending.get(key) if not slot: return None challenge, _ = slot.pop() if not slot: _pending.pop(key, None) return challenge def _take_all_challenges(key: str) -> list[bytes]: """Pop every outstanding challenge for *key* (newest last).""" _prune_expired() slot = _pending.pop(key, None) return [c for c, _ in slot] if slot else [] def clear_pending(username: str) -> None: """Drop all pending challenges for a user (e.g. after enable/disable).""" for key in [k for k in _pending if k.startswith(f"{username}:")]: _pending.pop(key, None) # ── Registration (enrol a key in settings) ───────────────────────────── def begin_registration(username: str, display_name: str, rp_id_override: str | None = None, origins_override: list[str] | None = None) -> dict: _ = origins_override # origins only matter at verification time options = generate_registration_options( rp_id=rp_id_override or rp_id(), rp_name=rp_name(), user_name=username, user_display_name=display_name or username, challenge=_store_challenge(f"{username}:register"), authenticator_selection=AuthenticatorSelectionCriteria( resident_key=ResidentKeyRequirement.PREFERRED, user_verification=UserVerificationRequirement.PREFERRED, ), ) return _finalize_options(options) def _verify_with_any_challenge(key: str, verify_one: Any, empty_message: str) -> Any: """Run *verify_one(challenge)* against every outstanding challenge. Returns the first success; re-raises the last error when all fail. BUG-070: lets an in-flight ceremony survive a re-requested options call (double-click / retry) that stored a newer challenge afterwards. """ challenges = _take_all_challenges(key) if not challenges: raise ValueError(empty_message) last_error: Exception | None = None for challenge in challenges: try: return verify_one(challenge) except Exception as e: # try the next candidate challenge last_error = e assert last_error is not None raise last_error def complete_registration(username: str, credential_json: dict[str, Any], label: str = "", rp_id_override: str | None = None, origins_override: list[str] | None = None) -> dict: credential = parse_registration_credential_json(credential_json) effective_rp = rp_id_override or rp_id() effective_origins = origins_override or expected_origins() def _verify(challenge: bytes) -> Any: return verify_registration_response( credential=credential, expected_challenge=challenge, expected_rp_id=effective_rp, expected_origin=effective_origins, ) verification = _verify_with_any_challenge( f"{username}:register", _verify, "Session d'enregistrement expirée — recommencez") transports = credential.response.transports or [] label = (label or str(credential_json.get("label") or "")).strip() or "Security key" record = { "credential_id": bytes_to_base64url(verification.credential_id), "public_key": bytes_to_base64url(verification.credential_public_key), "sign_count": int(verification.sign_count), "transports": [str(t) for t in transports], "label": label[:60], } return record # ── Authentication (assertion at login) ──────────────────────────────── def begin_authentication(username: str, credentials: list[dict], rp_id_override: str | None = None, origins_override: list[str] | None = None) -> dict | None: if not credentials: return None _ = origins_override # origins only matter at verification time from webauthn.helpers.structs import PublicKeyCredentialDescriptor allow = [ PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"])) for c in credentials ] options = generate_authentication_options( rp_id=rp_id_override or rp_id(), challenge=_store_challenge(f"{username}:login"), allow_credentials=allow, ) return _finalize_options(options) def complete_authentication( username: str, credential_json: dict[str, Any], stored: dict, rp_id_override: str | None = None, origins_override: list[str] | None = None, ) -> int: """Verify an assertion. Returns the new sign_count. Raises on failure.""" credential = parse_authentication_credential_json(credential_json) effective_rp = rp_id_override or rp_id() effective_origins = origins_override or expected_origins() def _verify(challenge: bytes) -> Any: return verify_authentication_response( credential=credential, expected_challenge=challenge, expected_rp_id=effective_rp, expected_origin=effective_origins, credential_public_key=base64url_to_bytes(stored["public_key"]), credential_current_sign_count=int(stored.get("sign_count", 0)), ) verification = _verify_with_any_challenge( f"{username}:login", _verify, "Session expirée — rechargez la page") return int(verification.new_sign_count) def credentials_for_api(credentials: list[dict]) -> list[dict]: """Sanitized view for the settings UI (no public keys).""" return [ { "credential_id": c.get("credential_id"), "label": c.get("label", "Security key"), "transports": c.get("transports", []), "registered_at": c.get("registered_at"), } for c in credentials ] def _finalize_options(options) -> dict: import json return json.loads(options_to_json(options))