Compare commits

...
2 Commits
Author SHA1 Message Date
bruno bca0fdd941 fix: login 2FA bloque sans erreur BUG-069 (challenge montait dans .login-box inexistant -> .login-card + erreur visible)
CI / lint (push) Successful in 1m56s
CI / security (push) Successful in 1m20s
CI / test (push) Successful in 4m17s
CI / build (push) Successful in 1m16s
CI / e2e (push) Successful in 12m35s
2026-09-22 20:38:37 -04:00
bruno 60da957f13 fix: section Securite du compte incomplete BUG-068 (boutons theme, QR local, mot de passe, recovery WebAuthn)
CI / lint (push) Successful in 2m25s
CI / security (push) Successful in 1m20s
CI / test (push) Successful in 3m43s
CI / build (push) Successful in 2m9s
CI / e2e (push) Successful in 12m7s
2026-09-22 20:07:25 -04:00
18 changed files with 439 additions and 29 deletions
+37 -1
View File
@@ -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.2**.
---
@@ -14,6 +14,42 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [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
View File
@@ -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.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.16.2-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.0).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.16.2).
---
*Projet : ObsiGate | Version : 2.16.0 | Dernière mise à jour : Juin 2026*
*Projet : ObsiGate | Version : 2.16.2 | Dernière mise à jour : Juin 2026*
+3 -3
View File
@@ -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.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.16.2-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.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.16.2).
---
*Project: ObsiGate | Version: 2.16.0 | Last updated: May 2026*
*Project: ObsiGate | Version: 2.16.2 | Last updated: May 2026*
+1 -1
View File
@@ -1 +1 @@
2.16.0
2.16.2
+14 -1
View File
@@ -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,
}
+1
View File
@@ -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
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.16.0"
version = "2.16.2"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.16.0"
version = "2.16.2"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -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.2",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+4
View File
@@ -176,6 +176,8 @@ 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 |
| | | | | | | | | | | |
### TODOs techniques (améliorations / nouvelles tâches)
@@ -249,6 +251,8 @@ 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) |
---
+1 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.16.0 | **Dernière mise à jour :** 2026-09-22
> **Version :** 2.16.2 | **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
View File
@@ -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>
+13
View File
@@ -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",
+13
View File
@@ -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",
+70
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.16.0",
"version": "2.16.2",
"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": {
+136
View File
@@ -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.");
}
+16
View File
@@ -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)