From aeb75164459aeb13c22932debf802f81d38bdb21 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Tue, 22 Sep 2026 20:53:58 -0400 Subject: [PATCH] fix: activation WebAuthn impossible BUG-070 (rp_id/origines derives requete, challenges multiples) --- .env.example | 5 +- CHANGELOG.md | 25 ++++- README.fr.md | 6 +- README.md | 6 +- VERSION | 2 +- backend/auth/router.py | 34 ++++-- backend/auth/webauthn_mfa.py | 193 ++++++++++++++++++++++++++++------- desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/ISSUES_TODOLIST.md | 2 + docs/ROADMAP.md | 2 +- package.json | 2 +- tests/test_webauthn.py | 132 +++++++++++++++++++++++- 14 files changed, 353 insertions(+), 62 deletions(-) diff --git a/.env.example b/.env.example index e6da4a4..7d10136 100644 --- a/.env.example +++ b/.env.example @@ -51,7 +51,10 @@ OBSIGATE_ADMIN_PASSWORD=chab30 # OBSIGATE_PDF_MAX_SIZE_MB=50 # PDFs plus volumineux = texte non indexé # OBSIGATE_PDF_EXTRACT_TIMEOUT=30 # secondes avant abandon de l'extraction -# WebAuthn / MFA (ROADMAP #64) — nécessaire hors localhost +# WebAuthn / MFA (ROADMAP #64) — par défaut rp_id/origines sont dérivés de la +# requête (hôte exact, port inclus) : rien à configurer en accès direct. +# À renseigner uniquement pour un accès via reverse-proxy sous un autre nom +# (avec OBSIGATE_TRUST_PROXY=true pour X-Forwarded-Host/Proto) : # OBSIGATE_WEBAUTHN_RP_ID=obsigate.example.com # OBSIGATE_WEBAUTHN_RP_NAME=ObsiGate # OBSIGATE_WEBAUTHN_ORIGINS=https://obsigate.example.com diff --git a/CHANGELOG.md b/CHANGELOG.md index e631037..44f431f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.16.2**. +> [Unreleased](#unreleased). La dernière version livrée est **2.16.3**. --- @@ -14,6 +14,29 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.16.3] — 2026-09-22 + +### Corrigé + +- **BUG-070 - Activation clé physique WebAuthn impossible (« Validation du + credential WebAuthn échouée »)** : deux causes. (1) Les valeurs par défaut + (`rp_id localhost`, origines `http://localhost` sans port) rejetaient toute + URL réelle — logs : `Unexpected client data origin "http://localhost:2020", + expected one of ['http://localhost']`. `rp_id`/origines sont désormais + dérivés de la requête (hôte exact, port inclus ; `X-Forwarded-Host/Proto` + si `OBSIGATE_TRUST_PROXY=true`), la config explicite restant prioritaire + (`backend/auth/webauthn_mfa.py::resolve_relying_party`, appliqué aux 4 + endpoints d'enregistrement et de login). (2) Challenge à usage unique + fragile au double-clic/retry (`challenge was not expected`) : les 5 + derniers challenges sont conservés et la vérification accepte le challenge + correspondant à la cérémonie en cours. `.env.example` documente le nouveau + comportement. Vérifié au navigateur avec authentificateur virtuel + (Playwright CDP, instance Docker) : enregistrement 200 + clé listée, puis + clé de test retirée. Tests : `tests/test_webauthn.py` (+8 : résolution RP, + forwarded, retry, roundtrip sans config). + +--- + ## [2.16.2] — 2026-09-22 ### Corrigé diff --git a/README.fr.md b/README.fr.md index 1540ae8..e29b259 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.16.2-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.16.3-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -927,8 +927,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.16.2). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.16.3). --- -*Projet : ObsiGate | Version : 2.16.2 | Dernière mise à jour : Juin 2026* +*Projet : ObsiGate | Version : 2.16.3 | Dernière mise à jour : Juin 2026* diff --git a/README.md b/README.md index 67db440..8ab6158 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.16.2-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.16.3-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1096,8 +1096,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.16.2). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.16.3). --- -*Project: ObsiGate | Version: 2.16.2 | Last updated: May 2026* +*Project: ObsiGate | Version: 2.16.3 | Last updated: May 2026* diff --git a/VERSION b/VERSION index 43c85e7..37b36c1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.16.2 +2.16.3 diff --git a/backend/auth/router.py b/backend/auth/router.py index 56709c5..581e075 100644 --- a/backend/auth/router.py +++ b/backend/auth/router.py @@ -576,18 +576,25 @@ class WebauthnRemoveRequest(BaseModel): @router.post("/mfa/webauthn/register/options") -async def mfa_webauthn_register_options(current_user=Depends(require_auth)): +async def mfa_webauthn_register_options(request: Request, + current_user=Depends(require_auth)): """Start WebAuthn key enrolment — returns publicKey creation options for the browser.""" - from .webauthn_mfa import begin_registration + from .webauthn_mfa import begin_registration, resolve_relying_party + # BUG-070: rp_id/origins derive from the request (exact host incl. port) + # unless explicitly configured — the old localhost defaults rejected + # every real access URL ("Unexpected client data origin"). + rp, _ = resolve_relying_party(request) options = begin_registration(current_user["username"], - current_user.get("display_name", "")) + current_user.get("display_name", ""), + rp_id_override=rp) return {"options": options} @router.post("/mfa/webauthn/register") async def mfa_webauthn_register( req: WebauthnRegisterRequest, + request: Request, current_user=Depends(require_auth), ): """Verify the created credential, store it, and enable MFA if not already on. @@ -597,14 +604,17 @@ async def mfa_webauthn_register( from datetime import datetime, timezone from .user_store import get_user, update_user - from .webauthn_mfa import complete_registration + from .webauthn_mfa import complete_registration, resolve_relying_party user = get_user(current_user["username"]) if user is None: raise HTTPException(404, "Utilisateur introuvable") + rp, origins = resolve_relying_party(request) try: record = complete_registration(current_user["username"], req.credential, - label=req.label) + label=req.label, + rp_id_override=rp, + origins_override=origins) except ValueError as e: raise HTTPException(400, str(e)) except Exception as e: @@ -683,7 +693,7 @@ async def mfa_webauthn_remove( @router.post("/mfa/webauthn/options") -async def mfa_webauthn_login_options(body: dict = Body(...)): +async def mfa_webauthn_login_options(request: Request, body: dict = Body(...)): """Unauthenticated: begin the login assertion for a user with registered keys. Enumeration-safe: always 200 — returns null options (caller falls back to @@ -695,8 +705,9 @@ async def mfa_webauthn_login_options(body: dict = Body(...)): if not user or not user.get("mfa_enabled") or not creds: return {"mfa_method": "totp", "options": None} - from .webauthn_mfa import begin_authentication - options = begin_authentication(username, creds) + from .webauthn_mfa import begin_authentication, resolve_relying_party + rp, _ = resolve_relying_party(request) + options = begin_authentication(username, creds, rp_id_override=rp) if options is None: return {"mfa_method": "totp", "options": None} return {"mfa_method": "webauthn", "options": options} @@ -710,7 +721,7 @@ async def mfa_webauthn_verify( ): """Unauthenticated: verify the WebAuthn assertion and issue JWT tokens.""" from .user_store import get_user, update_user - from .webauthn_mfa import complete_authentication + from .webauthn_mfa import complete_authentication, resolve_relying_party client_ip = _enforce_mfa_rate_limit(request, body.username) @@ -721,13 +732,16 @@ async def mfa_webauthn_verify( if not user.get("mfa_enabled"): raise HTTPException(400, "MFA non activé pour cet utilisateur") + rp, origins = resolve_relying_party(request) creds = user.get("webauthn_credentials", []) try: credential_id = body.credential.get("id", "") stored = next((c for c in creds if c.get("credential_id") == credential_id), None) if stored is None: raise ValueError("Credential non enregistré") - new_count = complete_authentication(body.username, body.credential, stored) + new_count = complete_authentication(body.username, body.credential, stored, + rp_id_override=rp, + origins_override=origins) except ValueError as e: _record_mfa_failure(client_ip, body.username) raise HTTPException(401, str(e)) diff --git a/backend/auth/webauthn_mfa.py b/backend/auth/webauthn_mfa.py index b802f8b..d2a7d27 100644 --- a/backend/auth/webauthn_mfa.py +++ b/backend/auth/webauthn_mfa.py @@ -38,8 +38,16 @@ 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]] = {} +# 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: @@ -55,24 +63,100 @@ def expected_origins() -> list[str]: 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 [k for k, (_, exp) in _pending.items() if exp < now]: - _pending.pop(key, None) + 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) - _pending[key] = (challenge, time.time() + CHALLENGE_TTL_SECONDS) + 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 a challenge (single-use). Returns None if missing/expired.""" + """Pop the newest challenge (single-use). Returns None if missing/expired.""" _prune_expired() - entry = _pending.pop(key, None) - return entry[0] if entry else None + 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: @@ -83,9 +167,12 @@ def clear_pending(username: str) -> None: # ── Registration (enrol a key in settings) ───────────────────────────── -def begin_registration(username: str, display_name: str) -> dict: +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(), + rp_id=rp_id_override or rp_id(), rp_name=rp_name(), user_name=username, user_display_name=display_name or username, @@ -98,19 +185,44 @@ def begin_registration(username: str, display_name: str) -> dict: 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") +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) - verification = verify_registration_response( - credential=credential, - expected_challenge=challenge, - expected_rp_id=rp_id(), - expected_origin=expected_origins(), - ) + 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" @@ -126,9 +238,12 @@ def complete_registration(username: str, credential_json: dict[str, Any], # ── Authentication (assertion at login) ──────────────────────────────── -def begin_authentication(username: str, credentials: list[dict]) -> dict | None: +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 = [ @@ -136,7 +251,7 @@ def begin_authentication(username: str, credentials: list[dict]) -> dict | None: for c in credentials ] options = generate_authentication_options( - rp_id=rp_id(), + rp_id=rp_id_override or rp_id(), challenge=_store_challenge(f"{username}:login"), allow_credentials=allow, ) @@ -147,21 +262,27 @@ 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 ValueError on failure.""" - challenge = _take_challenge(f"{username}:login") - if challenge is None: - raise ValueError("Session expirée — rechargez la page") - + """Verify an assertion. Returns the new sign_count. Raises on failure.""" 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)), - ) + 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) diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index e40e182..6e888bb 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.16.2" +version = "2.16.3" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index cfa1ebd..8e0c74b 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.16.2" +version = "2.16.3" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index e7e6985..a20dc06 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.16.2", + "version": "2.16.3", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index 55d9df5..e990fca 100644 --- a/docs/ISSUES_TODOLIST.md +++ b/docs/ISSUES_TODOLIST.md @@ -178,6 +178,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | *BUG-067* | [🔵 MINEUR] Guide d'utilisation : l'entrée « 📱 Mobile » du sommaire ne fait rien (section absente) | 🟢 corrigé | P3 | 📱 frontend | IA | `frontend/index.html` | Ouvrir le Guide → cliquer « 📱 Mobile » dans le sommaire : rien ne se passe | L'ancre `#help-mobile-editor` était présente dans la TOC mais aucune section `id="help-mobile-editor"` n'existait (l'édition mobile n'était qu'un h3 de `help-edition`). Fix #105 : section dédiée créée avec ancre + entrée de nav cohérente. | Vérifié par test statique `tests/test_guide.py::test_nav_anchors_resolve` | | *BUG-068* | Configuration — section « 🔒 Sécurité du compte » inachevée : boutons hors thème, QR code invisible, fiabilité des fonctions à valider | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `frontend/js/auth.js`, `frontend/style.css`, `backend/auth/router.py` | Configuration → 🔒 Sécurité du compte | `frontend/style.css` (+`config-btn-primary`/`danger` thème), `backend/auth/router.py` (`qr_data_url` segno local), `frontend/js/auth.js` (QR local + fallback, recovery WebAuthn, carte mot de passe, escapeHtml labels), locales FR/EN, `backend/requirements.txt` (+segno) ; tests `tests/test_mfa.py` (+1) + `tests/frontend/mfa-settings.test.mjs` (nouveau, 9) | pytest 1241 passed / 6 skipped, ruff 0, mypy 0, frontend unit + validate-imports verts | | *BUG-069* | Login 2FA bloqué sans erreur : après user+pwd corrects, la page de login reste affichée et le challenge MFA n'apparaît jamais | 🟢 corrigé | P0 | 📱 frontend | IA | `frontend/js/auth.js`, `frontend/index.html` | Activer 2FA → logout → login (bon user+pwd) | `frontend/js/auth.js` (`showMfaChallenge` → `.login-card` + erreur `mfa.challenge_unavailable` si montage impossible), locales FR/EN ; tests `tests/frontend/mfa-settings.test.mjs` (+2) | Reproduit au navigateur avant correctif (challenge jamais affiché), vérifié après : challenge affiché, code erroné → erreur, code valide (200) → app ; frontend mfa-settings 11/11, unit + validate-imports verts | +| *BUG-070* | Activation clé physique WebAuthn impossible : « Validation du credential WebAuthn échouée » à chaque tentative | 🟢 corrigé | P0 | ⚙️ backend | IA | `backend/auth/webauthn_mfa.py`, `backend/auth/router.py` | Config → Sécurité → Ajouter une clé → cérémonie navigateur → 400 | `resolve_relying_party()` (rp_id/origines dérivés de la requête, config explicite prioritaire, forwarded si TRUST_PROXY) sur les 4 endpoints ; challenges multiples (5 derniers) acceptés ; `.env.example` ; tests `tests/test_webauthn.py` (+8) | Logs : origin `http://localhost:2020` rejetée + challenge mismatch au retry. Vérifié navigateur (authentificateur virtuel CDP) : register 200 + clé listée, clé de test retirée (admin de nouveau TOTP seul) ; pytest 1249 passed, ruff/mypy 0 | | | | | | | | | | | | | ### TODOs techniques (améliorations / nouvelles tâches) @@ -253,6 +254,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | 2026-09-18 | #105 (ajustements) | Amélioration | `frontend/index.html`, `frontend/js/config.js`, `frontend/sw.js`, `frontend/locales/{fr,en}.json`, `backend/guide_export.py`, `backend/pdf_export.py`, `Dockerfile`, `scripts/build_guide_diagrams.py`, `scripts/render_guide_diagram.mjs`, `scripts/guide_content.py`, `backend/assets/guide_diagrams/df7366a40db6a5a2.png`, `tests/test_guide.py`, `docs/features/guide-coverage-105.md`, `CHANGELOG.md` | **#105 (retour utilisateur)** : 1) boutons de téléchargement du guide passés en icônes seules (tooltips i18n conservés) ; 2) le diagramme Mermaid de la section Architecture est désormais rendu en **vraie image** dans le PDF (pipeline de pré-rendu PNG Chromium+mermaid v11, PNG commité sous `backend/assets/guide_diagrams/.png`, résolu par `diagram_png_for()` ; le Markdown garde le fenced mermaid) ; 3) emoji du PDF rendus **en couleur** au lieu de rectangles : `fonts-noto-color-emoji` ajouté au Dockerfile + `"Noto Color Emoji"` en fin de pile de polices PDF. Vérifié : pytest 1218 (test_guide ×13), ruff/mypy 0, validate-imports 38, unit 10/10 ; PDF live conteneur 2020 : 24 pages, 0 glyphes tofu, diagramme 3568x1174 embarqué. | 🟢 livré | 2026-09-22 | BUG-068 | Correction | `backend/auth/router.py`, `backend/requirements.txt`, `frontend/js/auth.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_mfa.py`, `tests/frontend/mfa-settings.test.mjs` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-068** : section « 🔒 Sécurité du compte » finalisée. (1) Boutons hors thème : `config-btn-primary`/`config-btn-danger` n'existaient pas en CSS → définis depuis les variables du thème (+ états disabled). (2) QR invisible : l'image tierce était bloquée par la CSP (`img-src 'self' data: blob:`) et exposait le secret TOTP → QR SVG `data:` généré en local par le backend (`qr_data_url`, segno) avec repli saisie manuelle. (3) Codes de récupération perdus à la 1re activation WebAuthn → `_showRecoveryCodes(codes, targetId)` avec repli `webauthn-flow-area`. (4) Carte « Mot de passe » ajoutée (endpoint `change-password` existant, jusque-là sans UI) + échappement des libellés de clés WebAuthn. Vérifié : pytest 1241 passed / 6 skipped, ruff 0, mypy 0 (78 fichiers), `mfa-settings.test.mjs` 9/9, unit 10/10, validate-imports 39 modules. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-23 | BUG-069 | Correction | `frontend/js/auth.js`, `frontend/locales/{fr,en}.json`, `tests/frontend/mfa-settings.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-069** : login 2FA bloqué sans erreur — après user+pwd corrects, `showMfaChallenge` cherchait `.login-box` (inexistant dans `index.html`, marquage réel `#login-screen > .login-card`) et faisait un `return` silencieux : page de login figée, aucune erreur. Correctif : montage dans `.login-card` (repli `#login-screen`) + erreur visible `mfa.challenge_unavailable` (FR/EN) si le point de montage manque. **Reproduit au navigateur** (Playwright, instance Docker `obsigate-test`, compte jetable avec TOTP) : avant → challenge jamais affiché ; après → challenge affiché, code erroné → erreur, code valide (verify 200) → app. Tests : `mfa-settings.test.mjs` 11/11 (+2 ancrage DOM), unit 10/10, validate-imports 39 modules. | 🟢 corrigé (en attente vérif utilisateur) | +| 2026-09-23 | BUG-070 | Correction | `backend/auth/webauthn_mfa.py`, `backend/auth/router.py`, `.env.example`, `tests/test_webauthn.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-070** : activation WebAuthn rejetée en 400. (1) Défauts `localhost` sans port → `resolve_relying_party()` dérive rp_id/origines de la requête (config explicite prioritaire, forwarded sous TRUST_PROXY), appliqué aux endpoints register + login. (2) Challenge single-use → 5 derniers conservés, vérification contre le challenge de la cérémonie en cours. **Vérifié au navigateur** (authentificateur virtuel CDP, instance Docker) : register 200, clé listée, clé de test retirée. Tests : `test_webauthn.py` 19/19 (+8), suite complète 1249 passed / 6 skipped, ruff/mypy 0. | 🟢 corrigé (en attente vérif utilisateur) | --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f1fa0db..ea599d6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.16.2 | **Dernière mise à jour :** 2026-09-22 +> **Version :** 2.16.3 | **Dernière mise à jour :** 2026-09-22 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** diff --git a/package.json b/package.json index 98c1d2c..b37730b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.16.2", + "version": "2.16.3", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "main": "patch.js", "directories": { diff --git a/tests/test_webauthn.py b/tests/test_webauthn.py index bc0dae2..7e3b284 100644 --- a/tests/test_webauthn.py +++ b/tests/test_webauthn.py @@ -134,8 +134,8 @@ class TestWebauthnModule: w._pending.clear() w._store_challenge("u2:register") key = "u2:register" - ch, _ = w._pending[key] - w._pending[key] = (ch, _t.time() - 1) + ch, _ = w._pending[key][0] + w._pending[key] = [(ch, _t.time() - 1)] assert w._take_challenge(key) is None def test_full_registration_and_authentication_roundtrip(self): @@ -337,3 +337,131 @@ class TestWebauthnApi: assert r2.status_code == 200 st = wa_client.get("/api/auth/mfa/status", headers=headers).json() assert st["mfa_enabled"] is False + + +# ── BUG-070: relying party derived from the request ───────────────────── +# +# The old defaults (rp_id "localhost", origins ["http://localhost"]) rejected +# every real access URL: "Unexpected client data origin +# "http://localhost:2020", expected one of ['http://localhost']". + +def _fake_request(host, scheme="http", forwarded_host=None, forwarded_proto=None): + from fastapi import Request + + headers = [(b"host", host.encode())] + if forwarded_host is not None: + headers.append((b"x-forwarded-host", forwarded_host.encode())) + if forwarded_proto is not None: + headers.append((b"x-forwarded-proto", forwarded_proto.encode())) + return Request({ + "type": "http", "method": "POST", "path": "/", + "headers": headers, "scheme": scheme, + "server": ("testserver", 80), "client": ("127.0.0.1", 5000), + }) + + +class TestRelyingPartyResolution: + def test_defaults_without_request(self, monkeypatch): + import backend.auth.webauthn_mfa as w + + monkeypatch.delenv("OBSIGATE_WEBAUTHN_RP_ID", raising=False) + monkeypatch.delenv("OBSIGATE_WEBAUTHN_ORIGINS", raising=False) + assert w.resolve_relying_party(None) == ("localhost", ["http://localhost"]) + + def test_derives_host_with_port(self, monkeypatch): + """Exact BUG-070 report: http://localhost:2020 was rejected.""" + import backend.auth.webauthn_mfa as w + + monkeypatch.delenv("OBSIGATE_WEBAUTHN_RP_ID", raising=False) + monkeypatch.delenv("OBSIGATE_WEBAUTHN_ORIGINS", raising=False) + rp, origins = w.resolve_relying_party(_fake_request("localhost:2020")) + assert rp == "localhost" + assert origins == ["http://localhost:2020"] + + def test_derives_ip_host(self, monkeypatch): + import backend.auth.webauthn_mfa as w + + monkeypatch.delenv("OBSIGATE_WEBAUTHN_RP_ID", raising=False) + monkeypatch.delenv("OBSIGATE_WEBAUTHN_ORIGINS", raising=False) + rp, origins = w.resolve_relying_party(_fake_request("127.0.0.1:2020")) + assert rp == "127.0.0.1" + assert origins == ["http://127.0.0.1:2020"] + + def test_explicit_env_wins_over_request(self, monkeypatch): + import backend.auth.webauthn_mfa as w + + monkeypatch.setenv("OBSIGATE_WEBAUTHN_RP_ID", "obs.example.com") + monkeypatch.setenv("OBSIGATE_WEBAUTHN_ORIGINS", + "https://obs.example.com, https://www.obs.example.com") + rp, origins = w.resolve_relying_party(_fake_request("localhost:2020")) + assert rp == "obs.example.com" + assert origins == ["https://obs.example.com", + "https://www.obs.example.com"] + + def test_forwarded_headers_require_trust(self, monkeypatch): + import backend.auth.webauthn_mfa as w + + monkeypatch.delenv("OBSIGATE_WEBAUTHN_RP_ID", raising=False) + monkeypatch.delenv("OBSIGATE_WEBAUTHN_ORIGINS", raising=False) + monkeypatch.setenv("OBSIGATE_TRUST_PROXY", "false") + req = _fake_request("internal:8080", scheme="http", + forwarded_host="obs.example.com", + forwarded_proto="https") + assert w.resolve_relying_party(req) == ("internal", ["http://internal:8080"]) + + monkeypatch.setenv("OBSIGATE_TRUST_PROXY", "true") + assert w.resolve_relying_party(req) == ("obs.example.com", + ["https://obs.example.com"]) + + def test_hostname_only(self): + import backend.auth.webauthn_mfa as w + + assert w._hostname_only("example.com:2020") == "example.com" + assert w._hostname_only("example.com") == "example.com" + assert w._hostname_only("[::1]:8080") == "::1" + assert w._hostname_only("127.0.0.1:2020") == "127.0.0.1" + + def test_retry_after_reoptions_still_verifies(self): + """A re-requested options call (double-click) must not kill the + in-flight ceremony: "challenge was not expected challenge".""" + import backend.auth.webauthn_mfa as w + + w._pending.clear() + auth = VirtualAuthenticator() + first = w._store_challenge("bob:register") + w._store_challenge("bob:register") # second options call overwrites + cred = auth.make_registration({"challenge": _b64url(first)}) + rec = w.complete_registration("bob", cred, rp_id_override="localhost", + origins_override=["http://localhost"]) + assert rec["credential_id"] == cred["id"] + + def test_register_flow_without_env_config(self, wa_client, monkeypatch): + """Full register + login roundtrip with no WEBAUTHN env at all: the + relying party derives from the request (TestClient host).""" + import backend.auth.webauthn_mfa as w + + monkeypatch.delenv("OBSIGATE_WEBAUTHN_RP_ID", raising=False) + monkeypatch.delenv("OBSIGATE_WEBAUTHN_ORIGINS", raising=False) + w._pending.clear() + + headers = _login_headers(wa_client) + r = wa_client.post("/api/auth/mfa/webauthn/register/options", headers=headers) + assert r.status_code == 200 + options = r.json()["options"] + assert options["rp"]["id"] == "testserver" + + auth = VirtualAuthenticator() + auth.RP_ID = "testserver" + auth.ORIGIN = "http://testserver" + cred = auth.make_registration(options) + r2 = wa_client.post("/api/auth/mfa/webauthn/register", headers=headers, + json={"credential": cred, "label": "Key"}) + assert r2.status_code == 200, r2.text + + opts_r = wa_client.post("/api/auth/mfa/webauthn/options", + json={"username": "testuser"}) + assertion = auth.make_assertion(opts_r.json()["options"]) + v = wa_client.post("/api/auth/mfa/webauthn/verify", + json={"username": "testuser", "credential": assertion}) + assert v.status_code == 200, v.text + assert "access_token" in v.json()