Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aeb7516445 | ||
|
|
bca0fdd941 | ||
|
|
60da957f13 |
+4
-1
@@ -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
|
||||
|
||||
+60
-1
@@ -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.0**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.16.3**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,65 @@ 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é
|
||||
|
||||
- **BUG-069 - Login 2FA bloqué sans erreur** : après user+mot de passe corrects
|
||||
sur un compte avec 2FA, la page de login restait affichée sans erreur et le
|
||||
challenge MFA n'apparaissait jamais. Cause : `showMfaChallenge`
|
||||
(`frontend/js/auth.js`) montait le challenge dans `.login-box`, inexistant
|
||||
dans `index.html` (marquage réel : `#login-screen > .login-card`) →
|
||||
`return` silencieux. Correctif : montage dans `.login-card` (repli
|
||||
`#login-screen`) + erreur visible (`mfa.challenge_unavailable`, FR/EN) au
|
||||
lieu d'un retour silencieux si le point de montage manque. Vérifié de bout
|
||||
en bout au navigateur (Playwright, instance Docker) : challenge affiché,
|
||||
code erroné → erreur, code valide → connecté. Tests :
|
||||
`tests/frontend/mfa-settings.test.mjs` (+2 contrôles d'ancrage DOM).
|
||||
|
||||
---
|
||||
|
||||
## [2.16.1] — 2026-09-22
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-068 - Configuration : section « 🔒 Sécurité du compte » inachevée** :
|
||||
boutons `config-btn-primary` / `config-btn-danger` définis depuis les
|
||||
variables du thème (`frontend/style.css`) ; QR code TOTP généré en local par
|
||||
le backend (`POST /api/auth/mfa/totp/setup` → `qr_data_url`, SVG `data:`
|
||||
via `segno`, `backend/requirements.txt`) au lieu de l'image tierce bloquée
|
||||
par la CSP (`img-src 'self' data: blob:`, secret TOTP exposé) ; codes de
|
||||
récupération affichés aussi à la première activation WebAuthn ; carte
|
||||
« Mot de passe » (changement via `POST /api/auth/change-password`) et
|
||||
échappement des libellés de clés WebAuthn. Tests :
|
||||
`tests/test_mfa.py::test_mfa_setup_returns_local_qr_data_url`,
|
||||
`tests/frontend/mfa-settings.test.mjs` (nouveau, 9 contrôles).
|
||||
|
||||
---
|
||||
|
||||
## [2.16.0] — 2026-09-22
|
||||
|
||||
### Modifié
|
||||
|
||||
+3
-3
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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.0).
|
||||
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.0 | Dernière mise à jour : Juin 2026*
|
||||
*Projet : ObsiGate | Version : 2.16.3 | Dernière mise à jour : Juin 2026*
|
||||
|
||||
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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.0).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.16.3).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.16.0 | Last updated: May 2026*
|
||||
*Project: ObsiGate | Version: 2.16.3 | Last updated: May 2026*
|
||||
|
||||
+38
-11
@@ -448,7 +448,9 @@ class MfaEnableRequest(BaseModel):
|
||||
async def mfa_totp_setup(current_user=Depends(require_auth)):
|
||||
"""Generate a TOTP secret and QR URI for MFA setup.
|
||||
|
||||
Returns the secret and otpauth URI — client displays QR code.
|
||||
Returns the secret, the otpauth URI and a ready-to-display QR code
|
||||
(`qr_data_url`, SVG `data:` URI — no third-party service, CSP-safe).
|
||||
|
||||
Does NOT enable MFA yet; call /mfa/totp/enable after first successful verify.
|
||||
"""
|
||||
from .user_store import update_user
|
||||
@@ -458,10 +460,21 @@ async def mfa_totp_setup(current_user=Depends(require_auth)):
|
||||
update_user(current_user["username"], {
|
||||
"mfa_secret_pending": secret,
|
||||
})
|
||||
# BUG-068: the QR code is generated locally (segno, stdlib-free SVG data
|
||||
# URI). The previous client-side https://api.qrserver.com image was blocked
|
||||
# by the CSP (img-src 'self' data: blob:) and leaked the otpauth URI —
|
||||
# including the TOTP secret — to a third party.
|
||||
qr_data_url: str | None = None
|
||||
try:
|
||||
import segno
|
||||
qr_data_url = segno.make(qr_uri).svg_data_uri(scale=5)
|
||||
except Exception:
|
||||
qr_data_url = None
|
||||
return {
|
||||
"secret": secret,
|
||||
"qr_uri": qr_uri,
|
||||
"otpauth_uri": qr_uri,
|
||||
"qr_data_url": qr_data_url,
|
||||
}
|
||||
|
||||
|
||||
@@ -563,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.
|
||||
@@ -584,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:
|
||||
@@ -670,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
|
||||
@@ -682,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}
|
||||
@@ -697,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)
|
||||
|
||||
@@ -708,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))
|
||||
|
||||
+157
-36
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ weasyprint>=60.0
|
||||
httpx>=0.27.0
|
||||
pypdf>=4.0
|
||||
pyotp>=2.10.0
|
||||
segno>=1.5.0
|
||||
webauthn==2.6.0
|
||||
psutil>=5.9
|
||||
pywebpush>=2.3.0
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.16.0"
|
||||
version = "2.16.3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.16.0"
|
||||
version = "2.16.3"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.16.0",
|
||||
"version": "2.16.3",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
@@ -176,6 +176,9 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| *BUG-065* | [🟡 IMPORTANT] Éditeur Excalidraw : l'auto-save recharge la page en pleine édition | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/excalidraw-viewer.js`, `frontend/js/utils.js`, `frontend/excalidraw-editor.html`, `tests/frontend/excalidraw-viewer.test.mjs` | Ouvrir un `.excalidraw` puis modifier un élément : au bout de 2 s la vue se recharge | Chaque modification déclenchait un `PUT save` 2 s plus tard → SSE `index_updated` → `reloadExternalWrite` → `openFile` → **recréation de l'iframe** (refresh visible). Auto-save supprimée : sauvegarde explicite (bouton 💾 / Ctrl+S). `reloadExternalWrite` ignore le fichier si un iframe Excalidraw est ouvert (`iframe[data-excalidraw-vault/path]`). Le badge « Modified » ne réagit plus aux changements d'`appState` (resize/zoom) mais à la signature des éléments. | Vérifié Playwright : plus de refresh, badge stable après bascule plein écran. Test statique (absence de `requestSave`/`saveTimer`). |
|
||||
| *BUG-066* | [🔵 MINEUR] Configuration : icônes manquantes dans la table des matières (« Fichiers cachés », « Partages publics ») | 🟢 corrigé | P3 | 📱 frontend | IA | `frontend/locales/{fr,en}.json` | Ouvrir Configuration → observer le sommaire : les entrées « Fichiers cachés » et « Partages publics » n'ont pas d'icône | `config.section_hidden` → « 🗂️ Fichiers cachés » / « 🗂️ Hidden files », `config.section_shares` → « 📤 Partages publics » (EN avait déjà l'icône). Test : `tests/frontend/unit.test.mjs` (+1 : toutes les entrées du sommaire portent une icône FR/EN) | Les libellés du sommaire utilisent des clés i18n distinctes des titres de section (`auto.f8ba6127`, `config.section_partages-publics`) qui, elles, avaient l'icône |
|
||||
| *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)
|
||||
@@ -249,6 +252,9 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| 2026-09-18 | BUG-066 | Correction | `frontend/locales/fr.json`, `frontend/locales/en.json`, `tests/frontend/unit.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-066** : la table des matières de la page de configuration n'affichait aucune icône pour « Fichiers cachés » et « Partages publics ». Les libellés du sommaire proviennent de clés i18n (`config.section_hidden`, `config.section_shares`) distinctes des titres de section qui, eux, portaient déjà l'icône. Alignement : 🗂️ / 📤 en FR **et** EN. Test de non-régression : `unit.test.mjs` vérifie que **toutes** les entrées `.help-nav-link` du sommaire portent une icône dans les deux langues (17/17). Vérifié : `unit.test.mjs` 10/10, `validate-imports` 38 modules. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-18 | #105, BUG-067 | Documentation + correction | `frontend/index.html`, `frontend/js/config.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `backend/guide_export.py`, `backend/main.py`, `tests/test_guide.py`, `docs/features/guide-coverage-105.md`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **#105** : audit complet de couverture du Guide d'utilisation — 8 nouvelles sections (Architecture + diagramme Mermaid, API & intégrations, Diagrammes Mermaid & Excalidraw, Hors-ligne & synchronisation, Collaboration temps réel, Application desktop, Bibliothèque & signets, Multilingue) et compléments (recherche sémantique, MFA/WebAuthn, notifications push, exports HTML/ePub/ZIP, PDF, vue multi-panneaux, admin). Téléchargement du guide en Markdown et PDF (`GET /api/guide/download?format=md|pdf`, FR/EN, rendu par le moteur d'export existant). Guide plus large en desktop. **BUG-067** : ancre morte `#help-mobile-editor` → section dédiée créée. | 🟢 corrigé (en attente vérif utilisateur)
|
||||
| 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/<sha1>.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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.16.0 | **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)**
|
||||
|
||||
+123
-15
@@ -1,6 +1,6 @@
|
||||
/* ObsiGate — Authentication: API helper, AuthManager, login form, AdminPanel */
|
||||
import { state } from './state.js';
|
||||
import { safeCreateIcons } from './utils.js';
|
||||
import { safeCreateIcons, escapeHtml } from './utils.js';
|
||||
import { showToast, closeHeaderMenu } from './ui.js';
|
||||
import { t, getLocale, setLocale } from './i18n.js';
|
||||
import { showWelcome } from './viewer.js';
|
||||
@@ -266,6 +266,13 @@ const AuthManager = {
|
||||
});
|
||||
},
|
||||
|
||||
async changePassword(currentPassword, newPassword) {
|
||||
return await api("/api/auth/change-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
|
||||
});
|
||||
},
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
const token = this.getToken();
|
||||
@@ -545,8 +552,22 @@ function _startWebauthnLogin(mfaSection, username, rememberMe) {
|
||||
|
||||
|
||||
function showMfaChallenge(username, rememberMe, loginBtn, loginErrorEl, mfaMethod) {
|
||||
const loginBox = document.querySelector(".login-box");
|
||||
if (!loginBox) return;
|
||||
// BUG-069: the challenge used to mount into `.login-box`, which does not
|
||||
// exist in index.html (the login markup is `#login-screen > .login-card >
|
||||
// #login-form`) — querySelector returned null and the function silently
|
||||
// returned, leaving the user stuck on the login page with no error after
|
||||
// entering correct credentials. Mount into the real card, and never fail
|
||||
// silently: surface the problem in the login error box instead.
|
||||
const loginBox = document.querySelector(".login-card")
|
||||
|| document.getElementById("login-screen");
|
||||
if (!loginBox) {
|
||||
const fallback = loginErrorEl || document.getElementById("login-error");
|
||||
if (fallback) {
|
||||
fallback.textContent = t("mfa.challenge_unavailable");
|
||||
fallback.classList.remove("hidden");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide the normal login form
|
||||
const loginForm = document.getElementById("login-form");
|
||||
@@ -1058,11 +1079,79 @@ async function initMfaSettings() {
|
||||
});
|
||||
}
|
||||
|
||||
// Password change (BUG-068: the "Sécurité du compte" section had no way to
|
||||
// change the password although POST /api/auth/change-password exists).
|
||||
_renderPasswordSection(area);
|
||||
|
||||
// WebAuthn security keys section (ROADMAP #64)
|
||||
_renderWebauthnSection(area);
|
||||
}
|
||||
|
||||
|
||||
function _renderPasswordSection(container) {
|
||||
if (!container || document.getElementById("password-settings")) return;
|
||||
const section = document.createElement("div");
|
||||
section.id = "password-settings";
|
||||
section.className = "password-settings";
|
||||
section.innerHTML = `
|
||||
<h4 class="webauthn-title">${t("mfa.password_change_title")}</h4>
|
||||
<p class="mfa-info-text">${t("mfa.password_change_desc")}</p>
|
||||
<div class="form-group">
|
||||
<label>${t("mfa.current_password_label")}</label>
|
||||
<input type="password" id="pwd-current" class="config-input"
|
||||
placeholder="${t('mfa.current_password_placeholder')}" autocomplete="current-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${t("mfa.new_password_label")}</label>
|
||||
<input type="password" id="pwd-new" class="config-input"
|
||||
placeholder="${t('mfa.new_password_placeholder')}" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${t("mfa.new_password_confirm_label")}</label>
|
||||
<input type="password" id="pwd-confirm" class="config-input"
|
||||
placeholder="${t('mfa.new_password_confirm_placeholder')}" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="mfa-recovery-actions">
|
||||
<button class="config-btn-primary" id="pwd-change-btn">${t("mfa.password_change_btn")}</button>
|
||||
</div>
|
||||
<p class="mfa-error hidden" id="pwd-change-error"></p>
|
||||
`;
|
||||
container.appendChild(section);
|
||||
|
||||
section.querySelector("#pwd-change-btn").addEventListener("click", async () => {
|
||||
const errEl = section.querySelector("#pwd-change-error");
|
||||
const current = section.querySelector("#pwd-current").value;
|
||||
const next = section.querySelector("#pwd-new").value;
|
||||
const confirm = section.querySelector("#pwd-confirm").value;
|
||||
const btn = section.querySelector("#pwd-change-btn");
|
||||
errEl.classList.add("hidden");
|
||||
if (!current || !next || !confirm) {
|
||||
errEl.textContent = t("mfa.fill_all_fields");
|
||||
errEl.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
if (next !== confirm) {
|
||||
errEl.textContent = t("mfa.password_mismatch");
|
||||
errEl.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await AuthManager.changePassword(current, next);
|
||||
showToast(t("mfa.password_changed"), "success");
|
||||
section.querySelector("#pwd-current").value = "";
|
||||
section.querySelector("#pwd-new").value = "";
|
||||
section.querySelector("#pwd-confirm").value = "";
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message || String(err);
|
||||
errEl.classList.remove("hidden");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async function _renderWebauthnSection(container) {
|
||||
if (!container || !window.PublicKeyCredential) return;
|
||||
|
||||
@@ -1085,10 +1174,10 @@ async function _renderWebauthnSection(container) {
|
||||
const listHtml = keys.length
|
||||
? `<ul class="webauthn-key-list">${keys.map((k) => `
|
||||
<li class="webauthn-key-item">
|
||||
<span class="webauthn-key-label">🔑 ${k.label || "Security key"}</span>
|
||||
<span class="webauthn-key-meta">${(k.transports || []).join(", ") || "—"}</span>
|
||||
<span class="webauthn-key-label">🔑 ${escapeHtml(k.label || "Security key")}</span>
|
||||
<span class="webauthn-key-meta">${escapeHtml((k.transports || []).join(", ") || "—")}</span>
|
||||
<button class="config-btn-secondary config-btn-sm webauthn-key-remove"
|
||||
data-id="${k.credential_id}">${t("mfa.webauthn_remove")}</button>
|
||||
data-id="${escapeHtml(k.credential_id)}">${t("mfa.webauthn_remove")}</button>
|
||||
</li>`).join("")}</ul>`
|
||||
: `<p class="mfa-info-text">${t("mfa.webauthn_none")}</p>`;
|
||||
|
||||
@@ -1111,7 +1200,10 @@ async function _renderWebauthnSection(container) {
|
||||
const label = prompt(t("mfa.webauthn_label_prompt"), "Ma clé");
|
||||
const result = await AuthManager.webauthnRegister(credential, label || "Security key");
|
||||
if (result.recovery_codes && result.recovery_codes.length) {
|
||||
_showRecoveryCodes(result.recovery_codes);
|
||||
// BUG-068: first-time WebAuthn enable issues recovery codes. There is
|
||||
// no #mfa-setup-flow-area in the "already enabled" view, so render
|
||||
// them into the WebAuthn flow area instead of losing them.
|
||||
_showRecoveryCodes(result.recovery_codes, "webauthn-flow-area");
|
||||
} else {
|
||||
showToast(t("mfa.webauthn_added"), "success");
|
||||
}
|
||||
@@ -1145,16 +1237,28 @@ async function _startMfaSetup() {
|
||||
|
||||
try {
|
||||
const data = await AuthManager.mfaSetup();
|
||||
// BUG-068: the QR code comes from the backend as a local SVG data: URI
|
||||
// (see POST /api/auth/mfa/totp/setup → qr_data_url). The previous
|
||||
// third-party QR image was blocked by the CSP
|
||||
// (img-src 'self' data: blob:) so it never displayed — and it leaked the
|
||||
// otpauth URI (TOTP secret) to a third party. Fall back to the manual
|
||||
// secret when the backend has no QR generator available.
|
||||
const qrImg = data.qr_data_url
|
||||
? `<img id="mfa-qr-img" alt="QR Code" class="mfa-qr-code-img"
|
||||
src="${data.qr_data_url}"
|
||||
onerror="this.style.display='none';document.getElementById('mfa-qr-fallback').style.display='block';">`
|
||||
: "";
|
||||
const fallbackStyle = data.qr_data_url ? "display:none" : "";
|
||||
flowArea.innerHTML = `
|
||||
<div class="mfa-setup-card">
|
||||
<h4>${t("mfa.scan_qr")}</h4>
|
||||
<div class="mfa-qr-container">
|
||||
<img id="mfa-qr-img" alt="QR Code" class="mfa-qr-code"
|
||||
src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(data.otpauth_uri)}">
|
||||
${qrImg}
|
||||
<p class="mfa-info-text" id="mfa-qr-fallback" style="${fallbackStyle}">${t("mfa.qr_unavailable")}</p>
|
||||
</div>
|
||||
<details class="mfa-secret-details">
|
||||
<details class="mfa-secret-details" ${data.qr_data_url ? "" : "open"}>
|
||||
<summary>${t("mfa.manual_entry")}</summary>
|
||||
<code class="mfa-secret-code">${data.secret}</code>
|
||||
<code class="mfa-secret-code">${escapeHtml(data.secret)}</code>
|
||||
</details>
|
||||
<div class="mfa-verify-section">
|
||||
<label>${t("mfa.enter_code")}</label>
|
||||
@@ -1201,12 +1305,16 @@ async function _startMfaSetup() {
|
||||
}
|
||||
|
||||
|
||||
function _showRecoveryCodes(codes) {
|
||||
const flowArea = document.getElementById("mfa-setup-flow-area");
|
||||
const area = document.getElementById("mfa-setup-area");
|
||||
function _showRecoveryCodes(codes, targetId) {
|
||||
// BUG-068: the recovery codes must be visible wherever the enable flow ran.
|
||||
// The TOTP flow owns #mfa-setup-flow-area, but the WebAuthn first-enable
|
||||
// path (#webauthn-flow-area) has none — previously those codes were lost.
|
||||
const flowArea = document.getElementById(targetId || "mfa-setup-flow-area")
|
||||
|| document.getElementById("webauthn-flow-area")
|
||||
|| document.getElementById("mfa-setup-area");
|
||||
if (!flowArea) return;
|
||||
|
||||
const codesHtml = codes.map(c => `<code class="mfa-recovery-code">${c}</code>`).join("\n");
|
||||
const codesHtml = codes.map(c => `<code class="mfa-recovery-code">${escapeHtml(c)}</code>`).join("\n");
|
||||
flowArea.innerHTML = `
|
||||
<div class="mfa-recovery-card">
|
||||
<h4>🔑 ${t("mfa.recovery_codes_title")}</h4>
|
||||
|
||||
@@ -1887,6 +1887,19 @@
|
||||
"mfa.disable_confirm_btn": "Disable 2FA",
|
||||
"mfa.disabled_success": "2FA has been disabled.",
|
||||
"mfa.fill_all_fields": "Please fill in all fields.",
|
||||
"mfa.qr_unavailable": "QR code unavailable — use manual entry below.",
|
||||
"mfa.password_change_title": "Password",
|
||||
"mfa.password_change_desc": "Change your account password (min. 8 characters). All other sessions are invalidated.",
|
||||
"mfa.current_password_label": "Current password",
|
||||
"mfa.current_password_placeholder": "Your current password",
|
||||
"mfa.new_password_label": "New password",
|
||||
"mfa.new_password_placeholder": "Min. 8 characters",
|
||||
"mfa.new_password_confirm_label": "Confirm new password",
|
||||
"mfa.new_password_confirm_placeholder": "Repeat the new password",
|
||||
"mfa.password_change_btn": "Change password",
|
||||
"mfa.password_mismatch": "The two passwords do not match.",
|
||||
"mfa.password_changed": "Password updated.",
|
||||
"mfa.challenge_unavailable": "Verification screen unavailable — please reload the page.",
|
||||
"bookslm.title": "BooksLM",
|
||||
"bookslm.files_indexed": "{count} files indexed",
|
||||
"bookslm.chars_loaded": "{chars} chars loaded",
|
||||
|
||||
@@ -1887,6 +1887,19 @@
|
||||
"mfa.disable_confirm_btn": "Désactiver la 2FA",
|
||||
"mfa.disabled_success": "La 2FA a été désactivée.",
|
||||
"mfa.fill_all_fields": "Veuillez remplir tous les champs.",
|
||||
"mfa.qr_unavailable": "QR code indisponible — utilisez la saisie manuelle ci-dessous.",
|
||||
"mfa.password_change_title": "Mot de passe",
|
||||
"mfa.password_change_desc": "Modifiez le mot de passe de votre compte (min. 8 caractères). Toutes les autres sessions sont invalidées.",
|
||||
"mfa.current_password_label": "Mot de passe actuel",
|
||||
"mfa.current_password_placeholder": "Votre mot de passe actuel",
|
||||
"mfa.new_password_label": "Nouveau mot de passe",
|
||||
"mfa.new_password_placeholder": "Min. 8 caractères",
|
||||
"mfa.new_password_confirm_label": "Confirmer le nouveau mot de passe",
|
||||
"mfa.new_password_confirm_placeholder": "Répétez le nouveau mot de passe",
|
||||
"mfa.password_change_btn": "Changer le mot de passe",
|
||||
"mfa.password_mismatch": "Les deux mots de passe ne correspondent pas.",
|
||||
"mfa.password_changed": "Mot de passe mis à jour.",
|
||||
"mfa.challenge_unavailable": "Écran de vérification indisponible — veuillez recharger la page.",
|
||||
"bookslm.title": "BooksLM",
|
||||
"bookslm.files_indexed": "{count} fichiers indexés",
|
||||
"bookslm.chars_loaded": "{chars} caractères chargés",
|
||||
|
||||
@@ -4347,6 +4347,76 @@ body.resizing-v {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* BUG-068: .config-btn-primary / .config-btn-danger were used by the account
|
||||
security section (frontend/js/auth.js) but never defined — the buttons fell
|
||||
back to the browser default and ignored the theme. Defined here with the
|
||||
same conventions as .config-btn-save / .config-btn-secondary. */
|
||||
.config-btn-primary {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 150ms;
|
||||
}
|
||||
.config-btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.config-btn-primary:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.config-btn-danger {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--danger, #e74c3c);
|
||||
border-radius: 6px;
|
||||
background: var(--danger-bg, #3d1a18);
|
||||
color: var(--danger, #ff7b72);
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 150ms;
|
||||
}
|
||||
.config-btn-danger:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.config-btn-danger:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* BUG-068: local QR code (backend SVG data: URI) + password section share the
|
||||
security-tab card conventions. */
|
||||
.mfa-qr-code-img {
|
||||
max-width: 200px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
padding: 8px;
|
||||
}
|
||||
.password-settings {
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border, #333);
|
||||
}
|
||||
.password-settings .form-group {
|
||||
margin: 8px 0;
|
||||
}
|
||||
.password-settings .form-group label {
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary, #aaa);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.password-settings .config-input {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
/* --- AI keys section: accordion redesign (#104) --- */
|
||||
.ai-keys-header {
|
||||
display: flex;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.16.0",
|
||||
"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": {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — Account security section non-regression tests (BUG-068).
|
||||
*
|
||||
* Static checks on the "🔒 Sécurité du compte" configuration section:
|
||||
* - BUG-068a: the TOTP QR code must NOT depend on the third-party
|
||||
* https://api.qrserver.com service (blocked by the CSP
|
||||
* `img-src 'self' data: blob:`, so the QR never displayed — and the
|
||||
* otpauth URI, TOTP secret included, leaked to a third party). The setup
|
||||
* endpoint returns a local SVG data: URI (qr_data_url) instead.
|
||||
* - BUG-068b: .config-btn-primary / .config-btn-danger are used by
|
||||
* frontend/js/auth.js but were never defined — buttons fell back to the
|
||||
* browser default and ignored the theme. They must exist and derive from
|
||||
* theme variables.
|
||||
* - BUG-068c: recovery codes issued on first-time WebAuthn enable were lost
|
||||
* (no #mfa-setup-flow-area in the "already enabled" view).
|
||||
* - BUG-068d: the section had no password change although
|
||||
* POST /api/auth/change-password exists.
|
||||
*
|
||||
* Usage: node tests/frontend/mfa-settings.test.mjs
|
||||
*/
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
|
||||
const auth = readFileSync(path.join(ROOT, "frontend", "js", "auth.js"), "utf8");
|
||||
const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8");
|
||||
const router = readFileSync(path.join(ROOT, "backend", "auth", "router.py"), "utf8");
|
||||
const indexHtml = readFileSync(path.join(ROOT, "frontend", "index.html"), "utf8");
|
||||
const fr = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "fr.json"), "utf8"));
|
||||
const en = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "en.json"), "utf8"));
|
||||
|
||||
function test(label, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(" ✓ " + label);
|
||||
} catch (err) {
|
||||
console.error(" ✗ " + label + "\n " + err.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── QR code: local data URI, no third-party service ─────────────────────────
|
||||
test("auth.js — no external QR service left (CSP blocks it, secret leaks)", () => {
|
||||
assert.doesNotMatch(auth, /qrserver\.com/, "api.qrserver.com is blocked by img-src and leaks the otpauth URI");
|
||||
assert.doesNotMatch(auth, /https:\/\/api\./, "no third-party https://api.* image may carry the TOTP secret");
|
||||
});
|
||||
|
||||
test("auth.js — setup flow renders the backend qr_data_url with a fallback", () => {
|
||||
assert.match(auth, /qr_data_url/, "the QR <img> must use the backend-provided qr_data_url");
|
||||
assert.match(auth, /mfa-qr-fallback/, "a manual-entry fallback must show when no QR is available");
|
||||
assert.match(auth, /mfa\.qr_unavailable/, "the fallback needs its i18n string");
|
||||
});
|
||||
|
||||
test("backend — /mfa/totp/setup returns a local qr_data_url", () => {
|
||||
assert.match(router, /qr_data_url/, "setup must include qr_data_url in its response");
|
||||
assert.match(router, /svg_data_uri/, "the QR must be generated locally (segno SVG data URI)");
|
||||
assert.match(router, /import segno/, "segno import must stay local with a graceful fallback");
|
||||
});
|
||||
|
||||
// ── Buttons follow the theme ────────────────────────────────────────────────
|
||||
for (const cls of ["config-btn-primary", "config-btn-danger"]) {
|
||||
test(`style.css — .${cls} is defined from theme variables`, () => {
|
||||
const rule = css.match(new RegExp(`\\.${cls}\\s*\\{([^}]*)\\}`));
|
||||
assert.ok(rule, `.${cls} rule not found — buttons fall back to the browser default`);
|
||||
assert.match(rule[1], /var\(--/, `.${cls} must derive from CSS theme variables, not hardcoded colors`);
|
||||
});
|
||||
}
|
||||
|
||||
test("style.css — themed buttons have disabled states", () => {
|
||||
assert.match(css, /\.config-btn-primary:disabled/, ".config-btn-primary needs a disabled state");
|
||||
assert.match(css, /\.config-btn-danger:disabled/, ".config-btn-danger needs a disabled state");
|
||||
});
|
||||
|
||||
// ── Recovery codes are never lost ───────────────────────────────────────────
|
||||
test("auth.js — _showRecoveryCodes falls back to the WebAuthn flow area", () => {
|
||||
const fn = auth.match(/function _showRecoveryCodes\(codes(?:, targetId)?\) \{([\s\S]*?)\n\}/);
|
||||
assert.ok(fn, "_showRecoveryCodes helper not found");
|
||||
assert.match(fn[1], /webauthn-flow-area/, "codes issued on first WebAuthn enable must render without #mfa-setup-flow-area");
|
||||
});
|
||||
|
||||
// ── Password change lives in the security section ───────────────────────────
|
||||
test("auth.js — password change calls POST /api/auth/change-password", () => {
|
||||
assert.match(auth, /\/api\/auth\/change-password/, "changePassword must hit the existing endpoint");
|
||||
assert.match(auth, /_renderPasswordSection/, "the security section must render a password card");
|
||||
});
|
||||
|
||||
test("i18n — password + QR strings exist in FR and EN", () => {
|
||||
for (const key of [
|
||||
"mfa.qr_unavailable",
|
||||
"mfa.password_change_title",
|
||||
"mfa.password_change_btn",
|
||||
"mfa.password_mismatch",
|
||||
"mfa.password_changed",
|
||||
"mfa.challenge_unavailable",
|
||||
]) {
|
||||
assert.ok(fr[key], `fr.json missing ${key}`);
|
||||
assert.ok(en[key], `en.json missing ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── BUG-069: the MFA challenge must mount into a real DOM node ──────────────
|
||||
test("auth.js — challenge mounts into .login-card (exists in index.html)", () => {
|
||||
assert.doesNotMatch(
|
||||
auth,
|
||||
/querySelector\("\.login-box"\)/,
|
||||
"showMfaChallenge queried .login-box, which never existed in index.html → silent return, login stuck with no error",
|
||||
);
|
||||
assert.match(
|
||||
auth,
|
||||
/querySelector\("\.login-card"\)/,
|
||||
"the challenge must mount into the real login container",
|
||||
);
|
||||
assert.ok(
|
||||
indexHtml.includes('class="login-card"'),
|
||||
"index.html must contain the .login-card mount point",
|
||||
);
|
||||
});
|
||||
|
||||
test("auth.js — showMfaChallenge never fails silently", () => {
|
||||
const fn = auth.match(/function showMfaChallenge\(username, rememberMe, loginBtn, loginErrorEl, mfaMethod\) \{([\s\S]*?)\n \/\/ WebAuthn second factor/);
|
||||
assert.ok(fn, "showMfaChallenge helper not found");
|
||||
assert.match(fn[1], /challenge_unavailable/, "a missing mount point must surface an error, not silently return");
|
||||
assert.doesNotMatch(fn[1], /if \(!loginBox\) return;/, "bare silent return is forbidden in the challenge flow");
|
||||
});
|
||||
|
||||
if (process.exitCode) {
|
||||
console.error("\nMFA settings tests FAILED");
|
||||
} else {
|
||||
console.log("\nAll MFA settings tests passed.");
|
||||
}
|
||||
@@ -248,6 +248,22 @@ class TestMfaApiEndpoints:
|
||||
assert "otpauth://totp/" in data["otpauth_uri"]
|
||||
assert len(data["secret"]) >= 16
|
||||
|
||||
def test_mfa_setup_returns_local_qr_data_url(self, mfa_client):
|
||||
"""BUG-068: the setup response carries a CSP-safe local QR code.
|
||||
|
||||
The previous client used an https://api.qrserver.com image, blocked by
|
||||
the CSP (img-src 'self' data: blob:) — the QR never displayed — and
|
||||
leaking the otpauth URI to a third party.
|
||||
"""
|
||||
token, _ = _login(mfa_client)
|
||||
resp = mfa_client.post("/api/auth/mfa/totp/setup", headers=_auth_headers(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
qr_data_url = data.get("qr_data_url")
|
||||
assert qr_data_url, "setup must return a local qr_data_url"
|
||||
assert qr_data_url.startswith("data:image/svg+xml"), qr_data_url[:60]
|
||||
assert "qrserver.com" not in qr_data_url
|
||||
|
||||
def test_mfa_enable_flow(self, mfa_client):
|
||||
token, _ = _login(mfa_client)
|
||||
headers = _auth_headers(token)
|
||||
|
||||
+130
-2
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user