Files
ObsiGate/backend/auth/webauthn_mfa.py
T

185 lines
6.0 KiB
Python

# 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": <b64url>, "public_key": <b64url>, "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
# In-memory pending challenges: key -> (challenge_bytes, expires_at)
_pending: dict[str, 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 _prune_expired() -> None:
now = time.time()
for key in [k for k, (_, exp) in _pending.items() if exp < now]:
_pending.pop(key, None)
def _store_challenge(key: str) -> bytes:
_prune_expired()
challenge = secrets.token_bytes(32)
_pending[key] = (challenge, time.time() + CHALLENGE_TTL_SECONDS)
return challenge
def _take_challenge(key: str) -> bytes | None:
"""Pop a challenge (single-use). Returns None if missing/expired."""
_prune_expired()
entry = _pending.pop(key, None)
return entry[0] if entry else None
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) -> dict:
options = generate_registration_options(
rp_id=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 complete_registration(username: str, credential_json: dict[str, Any],
label: str = "") -> dict:
challenge = _take_challenge(f"{username}:register")
if challenge is None:
raise ValueError("Session d'enregistrement expirée — recommencez")
credential = parse_registration_credential_json(credential_json)
verification = verify_registration_response(
credential=credential,
expected_challenge=challenge,
expected_rp_id=rp_id(),
expected_origin=expected_origins(),
)
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]) -> dict | None:
if not credentials:
return None
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(),
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,
) -> int:
"""Verify an assertion. Returns the new sign_count. Raises ValueError on failure."""
challenge = _take_challenge(f"{username}:login")
if challenge is None:
raise ValueError("Session expirée — rechargez la page")
credential = parse_authentication_credential_json(credential_json)
verification = verify_authentication_response(
credential=credential,
expected_challenge=challenge,
expected_rp_id=rp_id(),
expected_origin=expected_origins(),
credential_public_key=base64url_to_bytes(stored["public_key"]),
credential_current_sign_count=int(stored.get("sign_count", 0)),
)
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))