306 lines
11 KiB
Python
306 lines
11 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
|
|
|
|
# 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))
|