185 lines
6.0 KiB
Python
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))
|