From 83063d3a18b5df700a90afc90f0fa95025f4e418 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sun, 6 Sep 2026 18:42:32 -0400 Subject: [PATCH] feat(auth): v2.1.0 - MFA TOTP avec QR code, recovery codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend/auth/mfa.py: TOTP (pyotp), recovery codes SHA-256 - Login flow: mfa_required → totp/verify → token (ou recovery) - 6 nouveaux endpoints /api/auth/mfa/* - Frontend: QR code setup, 6-digit auto-submit, recovery codes - Settings: section Sécurité avec enable/disable MFA - CSS: mfa-challenge, setup-card, recovery-list, badges - i18n: 36 nouvelles clés EN/FR - pyotp ajouté aux dépendances - 30 tests (TOTP, recovery, API endpoints, login flow) - 439 tests passent au total --- backend/auth/mfa.py | 65 +++++ backend/auth/router.py | 225 ++++++++++++++++- backend/main.py | 94 ++++++- backend/requirements.txt | 1 + frontend/index.html | 23 +- frontend/js/auth.js | 511 ++++++++++++++++++++++++++++++++++++--- frontend/js/config.js | 130 ++++++++-- frontend/locales/en.json | 53 +++- frontend/locales/fr.json | 53 +++- frontend/style.css | 128 ++++++++++ tests/test_mfa.py | 353 +++++++++++++++++++++++++++ 11 files changed, 1569 insertions(+), 67 deletions(-) create mode 100644 backend/auth/mfa.py create mode 100644 tests/test_mfa.py diff --git a/backend/auth/mfa.py b/backend/auth/mfa.py new file mode 100644 index 0000000..b8d5605 --- /dev/null +++ b/backend/auth/mfa.py @@ -0,0 +1,65 @@ +# backend/auth/mfa.py +# Multi-Factor Authentication: TOTP + recovery codes. +# TOTP via pyotp, recovery codes hashed with argon2 for single-use storage. + +import hashlib +import logging +import secrets + +import pyotp + +from .password import hash_password, verify_password + +logger = logging.getLogger("obsigate.auth.mfa") + +TOTP_ISSUER = "ObsiGate" + + +def generate_secret() -> str: + """Generate a new TOTP secret (base32-encoded, 160 bits).""" + return pyotp.random_base32() + + +def generate_qr_uri(secret: str, username: str, issuer: str = TOTP_ISSUER) -> str: + """Generate an otpauth:// URI for QR code generation.""" + totp = pyotp.TOTP(secret) + return totp.provisioning_uri(name=username, issuer_name=issuer) + + +def verify_totp(secret: str, code: str) -> bool: + """Verify a TOTP code with a ±1 window tolerance.""" + totp = pyotp.TOTP(secret) + return totp.verify(code, valid_window=1) + + +def generate_recovery_codes(n: int = 8) -> list[str]: + """Generate n human-readable recovery codes (XXXX-XXXX format).""" + codes = [] + for _ in range(n): + # 8 chars alphanumeric, grouped with dash for readability + raw = secrets.token_hex(4).upper() + code = f"{raw[:4]}-{raw[4:]}" + codes.append(code) + return codes + + +def hash_recovery_code(code: str) -> str: + """Hash a recovery code for storage (SHA-256 for fast comparison). + + We use SHA-256 instead of argon2 here because recovery codes are + high-entropy random strings, not user-chosen passwords. + """ + return hashlib.sha256(code.upper().encode("utf-8")).hexdigest() + + +def verify_recovery_code(code: str, hashed_codes: list[str]) -> int | None: + """Verify a recovery code against stored hashes. + + Returns the index of the matched code (for removal), or None if invalid. + Comparison is case-insensitive. + """ + code_hash = hash_recovery_code(code) + for i, stored_hash in enumerate(hashed_codes): + if secrets.compare_digest(code_hash, stored_hash): + return i + return None diff --git a/backend/auth/router.py b/backend/auth/router.py index 0922558..81c5493 100644 --- a/backend/auth/router.py +++ b/backend/auth/router.py @@ -21,6 +21,14 @@ from .jwt_handler import ( revoke_token, ) from .middleware import is_auth_enabled, require_admin, require_auth +from .mfa import ( + generate_qr_uri, + generate_recovery_codes, + generate_secret, + hash_recovery_code, + verify_recovery_code, + verify_totp, +) from .password import hash_password, verify_password from .user_store import ( create_user, @@ -136,16 +144,31 @@ async def login(body: LoginRequest, response: Response, request: Request): detail += f" ({remaining} tentative(s) restante(s))" raise HTTPException(401, detail) - # Success — clear rate limits and generate tokens - record_login_success(body.username) + # Success — clear rate limits rl_record_success(client_ip) + # If MFA is enabled, don't issue token yet — require TOTP verification + if user.get("mfa_enabled") and user.get("mfa_secret"): + logger.info(f"User '{body.username}' login deferred — MFA required") + return { + "mfa_required": True, + "mfa_method": "totp", + "username": body.username, + "remember_me": body.remember_me, + } + + return _issue_tokens(user, body.username, body.remember_me, response) + + +def _issue_tokens(user: dict, username: str, remember_me: bool, response: Response) -> dict: + """Issue JWT tokens after successful authentication (password or MFA verified).""" + record_login_success(username) + access_token = create_access_token(user) - refresh_token, refresh_jti = create_refresh_token(body.username) + refresh_token, refresh_jti = create_refresh_token(username) - # Set refresh token as HttpOnly cookie (path-restricted to /api/auth/refresh) - max_age = 2592000 if body.remember_me else 604800 # 30d or 7d import os + max_age = 2592000 if remember_me else 604800 # 30d or 7d secure = os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true" response.set_cookie( key="refresh_token", @@ -156,10 +179,7 @@ async def login(body: LoginRequest, response: Response, request: Request): secure=secure, path="/api/auth/refresh", ) - - logger.info(f"User '{body.username}' logged in") - - # Set access token as cookie for same-origin requests (e.g. popout window) + logger.info(f"User '{username}' logged in") response.set_cookie( key="access_token", value=access_token, @@ -169,7 +189,6 @@ async def login(body: LoginRequest, response: Response, request: Request): secure=secure, path="/", ) - return { "access_token": access_token, "token_type": "bearer", # nosec B105 — OAuth2 token_type, pas un mot de passe @@ -304,6 +323,192 @@ async def change_password( return {"message": "Mot de passe mis à jour"} +# ── MFA endpoints ──────────────────────────────────────────────────── + +class MfaVerifyRequest(BaseModel): + username: str + code: str + remember_me: bool = False + + +class MfaRecoveryRequest(BaseModel): + username: str + recovery_code: str + + +class MfaDisableRequest(BaseModel): + password: str + code: str + + +class MfaEnableRequest(BaseModel): + code: str + + +@router.post("/mfa/totp/setup") +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. + Does NOT enable MFA yet; call /mfa/totp/enable after first successful verify. + """ + from .user_store import update_user + secret = generate_secret() + qr_uri = generate_qr_uri(secret, current_user["username"]) + # Store secret temporarily (not yet enabled) + update_user(current_user["username"], { + "mfa_secret_pending": secret, + }) + return { + "secret": secret, + "qr_uri": qr_uri, + "otpauth_uri": qr_uri, + } + + +@router.post("/mfa/totp/enable") +async def mfa_totp_enable( + req: MfaEnableRequest, + current_user=Depends(require_auth), +): + """Enable MFA after verifying the first TOTP code. + + On success: generates recovery codes, enables MFA, returns recovery codes. + """ + from .user_store import get_user, update_user + + user = get_user(current_user["username"]) + secret = user.get("mfa_secret_pending") + if not secret: + raise HTTPException(400, "Aucune configuration MFA en cours. Commencez par /mfa/totp/setup") + + if not verify_totp(secret, req.code): + raise HTTPException(400, "Code TOTP invalide") + + # Generate recovery codes + recovery_codes = generate_recovery_codes() + hashed_codes = [hash_recovery_code(c) for c in recovery_codes] + + # Enable MFA + update_user(current_user["username"], { + "mfa_enabled": True, + "mfa_secret": secret, + "mfa_method": "totp", + "mfa_recovery_codes": hashed_codes, + "mfa_secret_pending": None, # clear pending + }) + + logger.info(f"MFA enabled for user '{current_user['username']}'") + return { + "mfa_enabled": True, + "recovery_codes": recovery_codes, # shown once, client must display/save + } + + +@router.post("/mfa/totp/disable") +async def mfa_totp_disable( + req: MfaDisableRequest, + current_user=Depends(require_auth), +): + """Disable MFA. Requires current password + valid TOTP code.""" + from .user_store import get_user, update_user + + user = get_user(current_user["username"]) + if not user.get("mfa_enabled"): + raise HTTPException(400, "MFA non activé") + + if not verify_password(req.password, user["password_hash"]): + raise HTTPException(400, "Mot de passe incorrect") + + if not verify_totp(user["mfa_secret"], req.code): + raise HTTPException(400, "Code TOTP invalide") + + update_user(current_user["username"], { + "mfa_enabled": False, + "mfa_secret": None, + "mfa_method": None, + "mfa_recovery_codes": [], + }) + + logger.info(f"MFA disabled for user '{current_user['username']}'") + return {"mfa_enabled": False} + + +@router.get("/mfa/status") +async def mfa_status(current_user=Depends(require_auth)): + """Return current user's MFA status.""" + from .user_store import get_user + user = get_user(current_user["username"]) + return { + "mfa_enabled": user.get("mfa_enabled", False), + "mfa_method": user.get("mfa_method"), + } + + +@router.post("/mfa/totp/verify") +async def mfa_totp_verify(body: MfaVerifyRequest, response: Response, request: Request): + """Verify TOTP code during login (second factor). + + Called after login returns mfa_required=true. + On success: issues JWT tokens. + """ + from .user_store import get_user + + user = get_user(body.username) + if not user: + # Timing-safe: simulate work + hash_password("dummy_timing_protection") + raise HTTPException(401, "Identifiants invalides") + + if not user.get("mfa_enabled") or not user.get("mfa_secret"): + raise HTTPException(400, "MFA non activé pour cet utilisateur") + + if not verify_totp(user["mfa_secret"], body.code): + raise HTTPException(401, "Code TOTP invalide") + + # Clear IP rate limit on success + client_ip = request.client.host if request.client else "unknown" + rl_record_success(client_ip) + + return _issue_tokens(user, body.username, body.remember_me, response) + + +@router.post("/mfa/recovery") +async def mfa_recovery_login(body: MfaRecoveryRequest, response: Response, request: Request): + """Login with a recovery code (when TOTP device is unavailable). + + Each recovery code is single-use. + """ + from .user_store import get_user, update_user + + user = get_user(body.username) + if not user: + hash_password("dummy_timing_protection") + raise HTTPException(401, "Identifiants invalides") + + if not user.get("mfa_enabled"): + raise HTTPException(400, "MFA non activé pour cet utilisateur") + + hashed_codes = user.get("mfa_recovery_codes", []) + if not hashed_codes: + raise HTTPException(400, "Aucun code de récupération disponible") + + idx = verify_recovery_code(body.recovery_code, hashed_codes) + if idx is None: + raise HTTPException(401, "Code de récupération invalide") + + # Remove used recovery code (single-use) + hashed_codes.pop(idx) + update_user(body.username, {"mfa_recovery_codes": hashed_codes}) + + # Clear IP rate limit + client_ip = request.client.host if request.client else "unknown" + rl_record_success(client_ip) + + logger.info(f"User '{body.username}' logged in via recovery code") + return _issue_tokens(user, body.username, False, response) + + # ── Admin endpoints ─────────────────────────────────────────────────── @router.get("/admin/users") diff --git a/backend/main.py b/backend/main.py index 9c60da6..29537a4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -686,11 +686,14 @@ from backend.secret_redactor import redact_file_content # Lazy import: WeasyPrint PDF export (requires GTK, may not be available everywhere) try: from backend.pdf_export import build_pdf_html, generate_pdf -except OSError: +except Exception: # pragma: no cover - WeasyPrint/GTK missing generate_pdf = None # type: ignore[assignment] build_pdf_html = None # type: ignore[assignment] - import logging + logging.getLogger("obsigate").warning("PDF export unavailable (WeasyPrint/GTK not found)") + +# Multi-format export (HTML / MD bundle / ePub) — pure Python, no heavy deps. +from backend.export import export_epub, export_html, export_md_bundle, ExportError # noqa: E402 from backend.ai_routes import router as ai_router from backend.saved_searches import delete_saved, get_saved, save_search from backend.share import ( @@ -1445,6 +1448,93 @@ async def api_file_pdf(vault_name: str, path: str = Query(..., description="Rela return Response(content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{safe_name}.pdf"'}) +# --------------------------------------------------------------------------- +# Multi-format export endpoints (HTML / Markdown bundle / ePub) +# --------------------------------------------------------------------------- + +def _resolve_export_target(vault_name: str, path: str, current_user: dict) -> tuple[Path, Path]: + """Resolve a vault + relative path into (vault_root, absolute file path). + + Enforces auth (vault access) and path traversal protection. + """ + if not check_vault_access(vault_name, current_user): + raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") + vault_data = get_vault_data(vault_name) + if not vault_data: + raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") + vault_root = Path(vault_data["path"]) + target = _resolve_safe_path(vault_root, path) + return vault_root, target + + +@app.get("/api/export/html") +async def api_export_html( + vault: str = Query(..., description="Vault name"), + path: str = Query(..., description="Relative path to file"), + current_user=Depends(require_auth), +): + """Export a markdown note as a standalone HTML file.""" + try: + vault_root, target = _resolve_export_target(vault, path, current_user) + html_bytes = export_html(vault_root, target) + except ExportError as e: + raise HTTPException(status_code=400, detail=str(e)) + record_open(current_user.get("username"), vault, path) + safe_name = _safe_export_name(target.stem) + return Response( + content=html_bytes, + media_type="text/html; charset=utf-8", + headers={"Content-Disposition": f'attachment; filename="{safe_name}.html"'}, + ) + + +@app.get("/api/export/md-bundle") +async def api_export_md_bundle( + vault: str = Query(..., description="Vault name"), + path: str = Query(..., description="Relative path to directory or file"), + current_user=Depends(require_auth), +): + """Export a directory (or single file) of markdown as a ZIP bundle.""" + try: + vault_root, target = _resolve_export_target(vault, path, current_user) + zip_bytes = export_md_bundle(vault_root, target) + except ExportError as e: + raise HTTPException(status_code=400, detail=str(e)) + safe_name = _safe_export_name(target.name) + return Response( + content=zip_bytes, + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{safe_name}.zip"'}, + ) + + +@app.get("/api/export/epub") +async def api_export_epub( + vault: str = Query(..., description="Vault name"), + path: str = Query(..., description="Relative path to file"), + current_user=Depends(require_auth), +): + """Export a markdown note as an ePub document.""" + try: + vault_root, target = _resolve_export_target(vault, path, current_user) + epub_bytes = export_epub(vault_root, target) + except ExportError as e: + raise HTTPException(status_code=400, detail=str(e)) + record_open(current_user.get("username"), vault, path) + safe_name = _safe_export_name(target.stem) + return Response( + content=epub_bytes, + media_type="application/epub+zip", + headers={"Content-Disposition": f'attachment; filename="{safe_name}.epub"'}, + ) + + +def _safe_export_name(name: str) -> str: + """ASCII-safe, filename-safe download name (falls back to 'document').""" + cleaned = "".join(c for c in name if c.isascii() and (c.isalnum() or c in " _-.")).strip() + return cleaned or "document" + + @app.put("/api/file/{vault_name}/save", response_model=FileSaveResponse) async def api_file_save( vault_name: str, diff --git a/backend/requirements.txt b/backend/requirements.txt index 823cb2b..2126653 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -13,3 +13,4 @@ snowballstemmer>=2.2.0 weasyprint>=60.0 httpx>=0.27.0 pypdf>=4.0 +pyotp>=2.10.0 diff --git a/frontend/index.html b/frontend/index.html index 1cd87bd..0f144be 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1448,6 +1448,7 @@
  • +
  • @@ -2042,7 +2043,7 @@

    🎨 Thèmes

    Choisissez un thème visuel. Chaque thème - offre un mode sombre et clair. + offre un mode sombre, clair, contraste élevé et sépia.

    Chargement...
    @@ -2080,6 +2081,26 @@
    + +
    +

    🔒 Sécurité du compte

    +

    + Activez l'authentification à deux facteurs (2FA) pour renforcer la sécurité de votre compte. +

    +
    +
    +
    + Authentification 2FA + Désactivée +
    +
    +
    +
    +
    +
    🔐 +

    ${t('mfa.title')}

    +

    ${t('mfa.subtitle')}

    +
    +
    + +
    + + +
    +
    + + +
    + `; + mfaSection.classList.remove("hidden"); + + const codeInput = document.getElementById("mfa-code"); + codeInput.focus(); + + // Auto-submit when 6 digits entered + codeInput.addEventListener("input", () => { + codeInput.value = codeInput.value.replace(/[^0-9]/g, ""); + if (codeInput.value.length === 6) { + document.getElementById("mfa-verify-btn").click(); + } + }); + + // Handle MFA form submit + document.getElementById("mfa-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const code = codeInput.value.trim(); + if (code.length !== 6) return; + + const btn = document.getElementById("mfa-verify-btn"); + const errorEl = document.getElementById("mfa-error"); + btn.disabled = true; + btn.querySelector(".btn-spinner").classList.remove("hidden"); + btn.querySelector(".btn-text").textContent = t('mfa.verifying'); + errorEl.classList.add("hidden"); + + try { + await AuthManager.verifyMfa(username, code, rememberMe); + mfaSection.classList.add("hidden"); + if (loginForm) loginForm.classList.remove("hidden"); + await _onLoginSuccess(); + } catch (err) { + errorEl.textContent = err.message; + errorEl.classList.remove("hidden"); + codeInput.value = ""; + codeInput.focus(); + } finally { + btn.disabled = false; + btn.querySelector(".btn-spinner").classList.add("hidden"); + btn.querySelector(".btn-text").textContent = t('mfa.verify'); + } + }); + + // Switch to recovery code input + document.getElementById("mfa-use-recovery").addEventListener("click", () => { + showRecoveryChallenge(username, rememberMe, loginForm, mfaSection); + }); + + // Back to login + document.getElementById("mfa-back-login").addEventListener("click", () => { + mfaSection.classList.add("hidden"); + if (loginForm) loginForm.classList.remove("hidden"); + }); +} + + +function showRecoveryChallenge(username, rememberMe, loginForm, mfaSection) { + mfaSection.innerHTML = ` +
    🔑
    +

    ${t('mfa.recovery_title')}

    +

    ${t('mfa.recovery_subtitle')}

    +
    +
    + +
    + + +
    +
    + + +
    + `; + + const codeInput = document.getElementById("recovery-code"); + codeInput.focus(); + + // Auto-format: insert dash after 4 chars + codeInput.addEventListener("input", () => { + let v = codeInput.value.replace(/[^a-zA-Z0-9]/g, "").toUpperCase(); + if (v.length > 4) v = v.slice(0, 4) + "-" + v.slice(4, 8); + codeInput.value = v; + }); + + document.getElementById("recovery-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const code = codeInput.value.trim(); + const btn = document.getElementById("recovery-verify-btn"); + const errorEl = document.getElementById("recovery-error"); + btn.disabled = true; + btn.querySelector(".btn-spinner").classList.remove("hidden"); + btn.querySelector(".btn-text").textContent = t('mfa.verifying'); + errorEl.classList.add("hidden"); + + try { + await AuthManager.verifyRecovery(username, code); + mfaSection.classList.add("hidden"); + if (loginForm) loginForm.classList.remove("hidden"); + await _onLoginSuccess(); + } catch (err) { + errorEl.textContent = err.message; + errorEl.classList.remove("hidden"); + codeInput.value = ""; + codeInput.focus(); + } finally { + btn.disabled = false; + btn.querySelector(".btn-spinner").classList.add("hidden"); + btn.querySelector(".btn-text").textContent = t('mfa.verify'); + } + }); + + document.getElementById("recovery-use-totp").addEventListener("click", () => { + showMfaChallenge(username, rememberMe, null, null); + }); + + document.getElementById("recovery-back-login").addEventListener("click", () => { + mfaSection.classList.add("hidden"); + if (loginForm) loginForm.classList.remove("hidden"); + }); +} + + // --------------------------------------------------------------------------- // Login form handler // --------------------------------------------------------------------------- @@ -300,36 +562,14 @@ function initLoginForm() { errorEl.classList.add("hidden"); try { - await AuthManager.login(username, password, rememberMe); - AuthManager.showApp(); - - // Re-sync language from server now that we're authenticated - try { - const resp = await fetch('/api/auth/me', { credentials: 'include' }); - if (resp.ok) { - const user = await resp.json(); - if (user.language && user.language !== getLocale()) { - await setLocale(user.language); - } - } - } catch (e) { /* non-bloquant */ } - - // Load app data after successful login - try { - // Dynamic imports to avoid circular dependency with sidebar.js - const { loadVaults, loadTags } = await import('./sidebar.js'); - await Promise.all([loadVaults(), loadTags()]); - // Start SSE sync now that auth cookie is set (dynamic import to avoid circular dep) - const { IndexUpdateManager } = await import('./sync.js'); - IndexUpdateManager.connect(); - // Sync offline file index now that we're authenticated - const { syncFileIndexFromServer } = await import('./offline.js'); - syncFileIndexFromServer(); - // Show dashboard - showWelcome(); - } catch (err) { - console.error("Failed to load data after login:", err); + const result = await AuthManager.login(username, password, rememberMe); + // Check if MFA is required + if (result && result.mfa_required) { + showMfaChallenge(result.username, rememberMe, btn, errorEl); + return; } + // Normal login success + await _onLoginSuccess(); safeCreateIcons(); } catch (err) { errorEl.textContent = err.message; @@ -576,4 +816,215 @@ const AdminPanel = { }; -export { api, AuthManager, initLoginForm, AdminPanel }; +// --------------------------------------------------------------------------- +// MFA Settings — Setup/Disable UI in the settings panel +// --------------------------------------------------------------------------- + +async function initMfaSettings() { + const area = document.getElementById("mfa-setup-area"); + const badge = document.getElementById("mfa-status-badge"); + if (!area || !badge) return; + + // Check current MFA status + let mfaEnabled = false; + try { + const status = await AuthManager.getMfaStatus(); + mfaEnabled = status.mfa_enabled; + } catch (e) { + // Not logged in or error + return; + } + + if (mfaEnabled) { + badge.textContent = t("mfa.enabled"); + badge.className = "mfa-badge mfa-badge-on"; + area.innerHTML = ` +

    ${t("mfa.enabled_desc")}

    + +
    + `; + document.getElementById("mfa-disable-btn").addEventListener("click", () => { + _showDisableMfaForm(); + }); + } else { + badge.textContent = t("mfa.disabled"); + badge.className = "mfa-badge mfa-badge-off"; + area.innerHTML = ` +

    ${t("mfa.setup_desc")}

    + +
    + `; + document.getElementById("mfa-enable-btn").addEventListener("click", () => { + _startMfaSetup(); + }); + } +} + + +async function _startMfaSetup() { + const flowArea = document.getElementById("mfa-setup-flow-area"); + if (!flowArea) return; + + try { + const data = await AuthManager.mfaSetup(); + flowArea.innerHTML = ` +
    +

    ${t("mfa.scan_qr")}

    +
    + QR Code +
    +
    + ${t("mfa.manual_entry")} + ${data.secret} +
    +
    + + + + +
    +
    + `; + + const codeInput = document.getElementById("mfa-enable-code"); + codeInput.addEventListener("input", () => { + codeInput.value = codeInput.value.replace(/[^0-9]/g, ""); + }); + + document.getElementById("mfa-confirm-btn").addEventListener("click", async () => { + const code = codeInput.value.trim(); + if (code.length !== 6) return; + + const btn = document.getElementById("mfa-confirm-btn"); + const errorEl = document.getElementById("mfa-enable-error"); + btn.disabled = true; + btn.textContent = t("mfa.verifying"); + errorEl.classList.add("hidden"); + + try { + const result = await AuthManager.mfaEnable(code); + // Show recovery codes + _showRecoveryCodes(result.recovery_codes); + } catch (err) { + errorEl.textContent = err.message; + errorEl.classList.remove("hidden"); + codeInput.value = ""; + codeInput.focus(); + } finally { + btn.disabled = false; + btn.textContent = t("mfa.confirm_enable"); + } + }); + } catch (err) { + flowArea.innerHTML = `

    ${err.message}

    `; + } +} + + +function _showRecoveryCodes(codes) { + const flowArea = document.getElementById("mfa-setup-flow-area"); + const area = document.getElementById("mfa-setup-area"); + if (!flowArea) return; + + const codesHtml = codes.map(c => `${c}`).join("\n"); + flowArea.innerHTML = ` +
    +

    🔑 ${t("mfa.recovery_codes_title")}

    +

    ${t("mfa.recovery_codes_warning")}

    +
    + ${codesHtml} +
    +
    + + + +
    +
    + `; + + document.getElementById("mfa-copy-codes").addEventListener("click", () => { + navigator.clipboard.writeText(codes.join("\n")).then(() => { + showToast(t("mfa.codes_copied"), "success"); + }); + }); + + document.getElementById("mfa-download-codes").addEventListener("click", () => { + const blob = new Blob([codes.join("\n")], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "obsigate-recovery-codes.txt"; + a.click(); + URL.revokeObjectURL(url); + }); + + document.getElementById("mfa-codes-done").addEventListener("click", () => { + // Refresh MFA settings display + initMfaSettings(); + }); +} + + +function _showDisableMfaForm() { + const formArea = document.getElementById("mfa-disable-form-area"); + if (!formArea) return; + + formArea.innerHTML = ` +
    +

    ${t("mfa.disable_confirm_title")}

    +

    ${t("mfa.disable_confirm_desc")}

    +
    + + +
    +
    + + +
    +
    + + +
    + +
    + `; + + document.getElementById("mfa-disable-cancel").addEventListener("click", () => { + formArea.innerHTML = ""; + }); + + document.getElementById("mfa-disable-confirm").addEventListener("click", async () => { + const password = document.getElementById("mfa-disable-password").value; + const code = document.getElementById("mfa-disable-code").value.trim(); + const errorEl = document.getElementById("mfa-disable-error"); + const btn = document.getElementById("mfa-disable-confirm"); + + if (!password || code.length !== 6) { + errorEl.textContent = t("mfa.fill_all_fields"); + errorEl.classList.remove("hidden"); + return; + } + + btn.disabled = true; + btn.textContent = t("mfa.verifying"); + errorEl.classList.add("hidden"); + + try { + await AuthManager.mfaDisable(password, code); + showToast(t("mfa.disabled_success"), "success"); + initMfaSettings(); + } catch (err) { + errorEl.textContent = err.message; + errorEl.classList.remove("hidden"); + } finally { + btn.disabled = false; + btn.textContent = t("mfa.disable_confirm_btn"); + } + }); +} + + +export { api, AuthManager, initLoginForm, AdminPanel, initMfaSettings }; diff --git a/frontend/js/config.js b/frontend/js/config.js index c06a0f4..026a9c0 100644 --- a/frontend/js/config.js +++ b/frontend/js/config.js @@ -1,5 +1,5 @@ // config.js — extracted from app.js (3872-4865) -import { api, AuthManager } from './auth.js'; +import { api, AuthManager, initMfaSettings } from './auth.js'; import { state } from './state.js'; import { el, icon, openFile } from './viewer.js'; import { syncVaultSelectors, setSelectedVaultContext, refreshSidebarForContext, loadVaults, loadVaultSettings, loadTags, TagFilterService, refreshSidebarTreePreservingState } from './sidebar.js'; @@ -663,6 +663,9 @@ function initConfigModal() { // Init profile initProfile(); + + // Init MFA settings (security tab) + initMfaSettings(); } function closeConfigModal() { @@ -1414,25 +1417,40 @@ export { function initThemePicker() { import('./themes.js').then(function(mod) { var THEMES = mod.THEMES; + var AVAILABLE_MODES = mod.AVAILABLE_MODES; var applyTheme = mod.applyTheme; var getCurrentTheme = mod.getCurrentTheme; var getCurrentMode = mod.getCurrentMode; + var exportAllThemes = mod.exportAllThemes; + var importTheme = mod.importTheme; + var deleteCustomTheme = mod.deleteCustomTheme; + var loadCustomThemes = mod.loadCustomThemes; var grid = document.getElementById('theme-grid'); if (!grid) return; - var themeKeys = Object.keys(THEMES); + // Mode label map (uses i18n t() when available, fallback to English) + var modeLabels = { + 'dark': function() { return t('theme.dark'); }, + 'light': function() { return t('theme.light'); }, + 'high-contrast': function() { return t('theme.high_contrast'); }, + 'sepia': function() { return t('theme.sepia'); } + }; function renderCards() { + var themeKeys = Object.keys(THEMES); var current = getCurrentTheme(); var mode = getCurrentMode(); grid.innerHTML = ''; + themeKeys.forEach(function(key) { - var t = THEMES[key]; - var v = t.modes[mode]; + var th = THEMES[key]; + // Use dark mode for preview if mode not resolved yet + var v = th.modes[mode] || th.modes.dark || th.modes.light; if (!v) return; var card = document.createElement('div'); card.className = 'theme-card' + (key === current ? ' active' : ''); + var isCustom = th.desc === 'Custom theme'; card.innerHTML = '
    ' + '
    ' + @@ -1440,33 +1458,101 @@ function initThemePicker() { '
    ' + '
    ' + '' + - '
    ' + t.name + '
    ' + - '
    ' + t.desc + '
    '; + '
    ' + th.name + (isCustom ? ' ✦' : '') + '
    ' + + '
    ' + th.desc + '
    '; card.addEventListener('click', function() { applyTheme(key, mode); renderCards(); }); + // Right-click to delete custom themes + if (isCustom) { + card.addEventListener('contextmenu', function(e) { + e.preventDefault(); + if (confirm(t('theme.delete_confirm'))) { + deleteCustomTheme(key); + renderCards(); + } + }); + } grid.appendChild(card); }); - // Mode toggle + + // Mode toggle — 4 modes var toggle = document.createElement('div'); toggle.className = 'theme-mode-toggle'; - var dBtn = elBtn(t('theme.dark'), 'dark', mode); - var lBtn = elBtn(t('theme.light'), 'light', mode); - toggle.appendChild(dBtn); - toggle.appendChild(lBtn); - grid.appendChild(toggle); - } - - function elBtn(label, m, currentMode) { - var btn = document.createElement('button'); - btn.className = 'theme-mode-btn' + (m === currentMode ? ' active' : ''); - btn.textContent = label; - btn.addEventListener('click', function() { - applyTheme(getCurrentTheme(), m); - renderCards(); + AVAILABLE_MODES.forEach(function(m) { + var btn = document.createElement('button'); + btn.className = 'theme-mode-btn' + (m === mode ? ' active' : ''); + btn.textContent = (modeLabels[m] || function() { return m; })(); + btn.addEventListener('click', function() { + applyTheme(getCurrentTheme(), m); + renderCards(); + }); + toggle.appendChild(btn); }); - return btn; + grid.appendChild(toggle); + + // Import/Export buttons + var actions = document.createElement('div'); + actions.className = 'theme-actions'; + + var exportBtn = document.createElement('button'); + exportBtn.className = 'theme-action-btn'; + exportBtn.textContent = t('theme.export_all'); + exportBtn.addEventListener('click', function() { + var json = exportAllThemes(); + var blob = new Blob([json], { type: 'application/json' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; a.download = 'obsigate-themes.json'; a.click(); + URL.revokeObjectURL(url); + }); + + var importBtn = document.createElement('button'); + importBtn.className = 'theme-action-btn'; + importBtn.textContent = t('theme.import'); + importBtn.addEventListener('click', function() { + var input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + input.addEventListener('change', function(e) { + var file = e.target.files[0]; + if (!file) return; + var reader = new FileReader(); + reader.onload = function(ev) { + var content = ev.target.result; + // Support single theme or all-themes format + try { + var data = JSON.parse(content); + if (data.themes) { + // Multi-theme export: import each + var count = 0; + Object.keys(data.themes).forEach(function(k) { + var res = importTheme(JSON.stringify(data.themes[k])); + if (res.ok) count++; + }); + alert(t('theme.import_success', { count: count })); + } else { + var res = importTheme(content); + if (res.ok) { + alert(t('theme.import_success', { count: 1 })); + } else { + alert(t('theme.import_error') + ': ' + res.error); + } + } + } catch(ex) { + alert(t('theme.import_error') + ': ' + ex.message); + } + renderCards(); + }; + reader.readAsText(file); + }); + input.click(); + }); + + actions.appendChild(exportBtn); + actions.appendChild(importBtn); + grid.appendChild(actions); } renderCards(); diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 5e5fbeb..e82dd23 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1377,7 +1377,14 @@ "theme.change": "Change theme", "theme.dark": "Dark", "theme.light": "Light", + "theme.high_contrast": "High Contrast", + "theme.sepia": "Sepia", "theme.title": "Theme", + "theme.export_all": "Export Themes", + "theme.import": "Import Theme", + "theme.import_success": "{count} theme(s) imported", + "theme.import_error": "Import error", + "theme.delete_confirm": "Delete this custom theme?", "themes.name_gradients": "Subtle gradients", "themes.name_terminal": "Pure black & green", "toast.ai_keys_saved": "API keys saved", @@ -1465,6 +1472,14 @@ "viewer.download_pdf": "Download as PDF", "viewer.edit": "Edit", "viewer.error": "Loading error", + "viewer.export": "Export", + "viewer.export_done": "Export complete", + "viewer.export_epub": "Export as ePub", + "viewer.export_error": "Export error:", + "viewer.export_html": "Export as HTML", + "viewer.export_md_bundle": "Export as Markdown bundle (.zip)", + "viewer.export_start": "Exporting...", + "viewer.export_title": "Export document", "viewer.forge_brand": "Forge", "viewer.forge_title": "Forge (new editor)", "viewer.index_start": "Starting index...", @@ -1495,5 +1510,41 @@ "webhook.trigger_dir_create": "Directory creation via API", "webhook.trigger_dir_delete": "Directory deletion via API", "webhook.trigger_dir_rename": "Directory rename via API", - "webhook.trigger_rename": "Rename via API" + "webhook.trigger_rename": "Rename via API", + "settings.security": "🔒 Account Security", + "settings.security_desc": "Enable two-factor authentication (2FA) to strengthen your account security.", + "mfa.title": "Two-Factor Authentication", + "mfa.subtitle": "Enter the 6-digit code from your authenticator app.", + "mfa.verify": "Verify", + "mfa.verifying": "Verifying...", + "mfa.use_recovery": "Use a recovery code", + "mfa.back_to_login": "Back to login", + "mfa.recovery_title": "Recovery Code", + "mfa.recovery_subtitle": "Enter one of your recovery codes (format: XXXX-XXXX).", + "mfa.use_totp": "Use authenticator code", + "mfa.status_label": "Two-Factor Authentication", + "mfa.enabled": "Enabled", + "mfa.disabled": "Disabled", + "mfa.enabled_desc": "Your account is protected by two-factor authentication.", + "mfa.setup_desc": "Add an extra layer of security to your account with TOTP.", + "mfa.enable_btn": "Enable 2FA", + "mfa.disable_btn": "Disable 2FA", + "mfa.scan_qr": "Scan this QR code with your authenticator app", + "mfa.manual_entry": "Manual entry (if you can't scan)", + "mfa.enter_code": "Enter the 6-digit code to confirm", + "mfa.confirm_enable": "Confirm & Enable", + "mfa.recovery_codes_title": "Recovery Codes", + "mfa.recovery_codes_warning": "Save these codes in a secure place. Each code can only be used once.", + "mfa.copy_codes": "Copy", + "mfa.download_codes": "Download", + "mfa.done": "Done", + "mfa.codes_copied": "Recovery codes copied!", + "mfa.disable_confirm_title": "Disable 2FA", + "mfa.disable_confirm_desc": "Enter your password and a valid TOTP code to disable two-factor authentication.", + "mfa.password_label": "Password", + "mfa.password_placeholder": "Your current password", + "mfa.totp_code_label": "TOTP Code", + "mfa.disable_confirm_btn": "Disable 2FA", + "mfa.disabled_success": "2FA has been disabled.", + "mfa.fill_all_fields": "Please fill in all fields." } diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 0e2e44e..c3d0815 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1377,7 +1377,14 @@ "theme.change": "Changer le thème", "theme.dark": "Sombre", "theme.light": "Clair", + "theme.high_contrast": "Contraste élevé", + "theme.sepia": "Sépia", "theme.title": "Thème", + "theme.export_all": "Exporter les thèmes", + "theme.import": "Importer un thème", + "theme.import_success": "{count} thème(s) importé(s)", + "theme.import_error": "Erreur d'importation", + "theme.delete_confirm": "Supprimer ce thème personnalisé ?", "themes.name_gradients": "Degrades subtils", "themes.name_terminal": "Noir pur & vert", "toast.ai_keys_saved": "Clés API sauvegardées", @@ -1465,6 +1472,14 @@ "viewer.download_pdf": "Télécharger en PDF", "viewer.edit": "Éditer", "viewer.error": "Erreur de chargement", + "viewer.export": "Exporter", + "viewer.export_done": "Export terminé", + "viewer.export_epub": "Exporter en ePub", + "viewer.export_error": "Erreur d'export :", + "viewer.export_html": "Exporter en HTML", + "viewer.export_md_bundle": "Exporter en bundle Markdown (.zip)", + "viewer.export_start": "Export en cours...", + "viewer.export_title": "Exporter le document", "viewer.forge_brand": "Forge", "viewer.forge_title": "Forge (nouvel éditeur)", "viewer.index_start": "Démarrage index.", @@ -1495,5 +1510,41 @@ "webhook.trigger_dir_create": "Création de dossier via API", "webhook.trigger_dir_delete": "Suppression de dossier via l'API", "webhook.trigger_dir_rename": "Renommage de dossier via l'API", - "webhook.trigger_rename": "Renommage via l'API" + "webhook.trigger_rename": "Renommage via l'API", + "settings.security": "🔒 Sécurité du compte", + "settings.security_desc": "Activez l'authentification à deux facteurs (2FA) pour renforcer la sécurité de votre compte.", + "mfa.title": "Authentification à deux facteurs", + "mfa.subtitle": "Entrez le code à 6 chiffres de votre application d'authentification.", + "mfa.verify": "Vérifier", + "mfa.verifying": "Vérification...", + "mfa.use_recovery": "Utiliser un code de récupération", + "mfa.back_to_login": "Retour à la connexion", + "mfa.recovery_title": "Code de récupération", + "mfa.recovery_subtitle": "Entrez l'un de vos codes de récupération (format : XXXX-XXXX).", + "mfa.use_totp": "Utiliser le code authenticator", + "mfa.status_label": "Authentification à deux facteurs", + "mfa.enabled": "Activée", + "mfa.disabled": "Désactivée", + "mfa.enabled_desc": "Votre compte est protégé par l'authentification à deux facteurs.", + "mfa.setup_desc": "Ajoutez une couche de sécurité supplémentaire à votre compte avec le TOTP.", + "mfa.enable_btn": "Activer la 2FA", + "mfa.disable_btn": "Désactiver la 2FA", + "mfa.scan_qr": "Scannez ce QR code avec votre application d'authentification", + "mfa.manual_entry": "Saisie manuelle (si vous ne pouvez pas scanner)", + "mfa.enter_code": "Entrez le code à 6 chiffres pour confirmer", + "mfa.confirm_enable": "Confirmer et activer", + "mfa.recovery_codes_title": "Codes de récupération", + "mfa.recovery_codes_warning": "Enregistrez ces codes dans un endroit sûr. Chaque code ne peut être utilisé qu'une seule fois.", + "mfa.copy_codes": "Copier", + "mfa.download_codes": "Télécharger", + "mfa.done": "Terminé", + "mfa.codes_copied": "Codes de récupération copiés !", + "mfa.disable_confirm_title": "Désactiver la 2FA", + "mfa.disable_confirm_desc": "Entrez votre mot de passe et un code TOTP valide pour désactiver l'authentification à deux facteurs.", + "mfa.password_label": "Mot de passe", + "mfa.password_placeholder": "Votre mot de passe actuel", + "mfa.totp_code_label": "Code TOTP", + "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." } diff --git a/frontend/style.css b/frontend/style.css index e35c761..210e3a3 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -2398,6 +2398,34 @@ select { background: var(--accent); color: #fff; } +/* Theme import/export actions */ +.theme-actions { + display: flex; + gap: 8px; + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid var(--border); +} +.theme-action-btn { + padding: 5px 14px; + font-size: 0.7rem; + font-weight: 600; + border: 1px solid var(--border); + background: var(--bg-secondary); + color: var(--text-secondary); + border-radius: 6px; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} +.theme-action-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} +.theme-custom-badge { + font-size: 0.6rem; + color: var(--accent); + margin-left: 2px; +} /* Profile section */ .profile-form { max-width: 420px; } .profile-field { margin-bottom: 14px; } @@ -8878,3 +8906,103 @@ body.popup-mode .content-area { right: 4px; } } + +/* ── MFA (Multi-Factor Authentication) ────────────────────────────────── */ + +.mfa-challenge { + margin-top: 20px; + padding: 24px; + border-radius: 12px; + background: var(--surface, #1a1a2e); + border: 1px solid var(--border, #333); + text-align: center; +} +.mfa-challenge .mfa-icon { font-size: 48px; margin-bottom: 12px; } +.mfa-challenge h3 { margin: 0 0 8px; color: var(--text, #fff); } +.mfa-challenge p { color: var(--muted, #999); margin: 0 0 16px; } +.mfa-code-input { + width: 180px; + font-size: 28px; + text-align: center; + letter-spacing: 12px; + padding: 12px; + border: 2px solid var(--border, #444); + border-radius: 8px; + background: var(--bg, #0d0d1a); + color: var(--text, #fff); + font-family: monospace; + outline: none; +} +.mfa-code-input:focus { border-color: var(--accent, #7C3AED); } +.mfa-code-input-group { display: flex; gap: 8px; justify-content: center; align-items: center; margin-bottom: 12px; } +.mfa-code-input-group span { color: var(--muted, #999); font-size: 24px; } +.mfa-link-btn { + background: none; border: none; color: var(--accent, #7C3AED); + cursor: pointer; font-size: 13px; text-decoration: underline; +} +.mfa-link-btn:hover { opacity: 0.8; } +.mfa-error { color: #e74c3c; font-size: 13px; margin-top: 8px; } +.mfa-recovery-input { width: 200px; font-size: 20px; letter-spacing: 4px; } + +/* MFA Settings (Security tab) */ +.mfa-status-section { padding: 16px 0; } +.mfa-status-row { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; } +.mfa-badge { + display: inline-flex; align-items: center; gap: 6px; + padding: 4px 12px; border-radius: 20px; font-size: 13px; font-weight: 600; +} +.mfa-badge-on { background: #1a3a2a; color: #4ade80; } +.mfa-badge-off { background: #3a2a1a; color: #f59e0b; } +.mfa-status-label { color: var(--text, #fff); font-weight: 500; } + +/* MFA Setup card */ +.mfa-setup-card { + padding: 20px; border-radius: 10px; + background: var(--surface2, #1e1e3a); border: 1px solid var(--border, #333); +} +.mfa-setup-card h4 { margin: 0 0 12px; color: var(--text, #fff); } +.mfa-qr-container { text-align: center; margin: 16px 0; } +.mfa-qr-code img, .mfa-qr-code canvas { max-width: 200px; border-radius: 8px; } +.mfa-secret-details { margin-top: 12px; } +.mfa-secret-code { + font-family: monospace; font-size: 14px; padding: 8px 12px; + background: var(--bg, #0d0d1a); border-radius: 6px; + color: var(--accent, #7C3AED); cursor: pointer; user-select: all; + display: inline-block; +} +.mfa-info-text { color: var(--muted, #999); font-size: 13px; margin: 8px 0; } +.mfa-subtitle { color: var(--muted, #999); font-size: 13px; margin: 0 0 16px; } +.mfa-verify-section { display: flex; gap: 8px; align-items: center; margin-top: 12px; } +.mfa-actions { margin-top: 16px; } + +/* MFA Recovery codes display */ +.mfa-recovery-card { + padding: 20px; border-radius: 10px; + background: var(--surface2, #1e1e3a); border: 1px solid var(--border, #333); + margin-top: 16px; +} +.mfa-recovery-card h4 { margin: 0 0 8px; color: var(--text, #fff); } +.mfa-recovery-list { + display: grid; grid-template-columns: 1fr 1fr; gap: 6px 24px; + margin: 12px 0; list-style: none; padding: 0; +} +.mfa-recovery-code { + font-family: monospace; font-size: 14px; padding: 4px 8px; + background: var(--bg, #0d0d1a); border-radius: 4px; + color: var(--text, #fff); letter-spacing: 1px; +} +.mfa-recovery-actions { display: flex; gap: 8px; margin-top: 12px; } + +/* MFA Disable card */ +.mfa-disable-card { + padding: 16px; border-radius: 10px; + background: var(--surface2, #1e1e3a); border: 1px solid #e74c3c33; + margin-top: 16px; +} +.mfa-disable-card h4 { margin: 0 0 8px; color: #e74c3c; } +.mfa-disable-actions { display: flex; gap: 8px; margin-top: 12px; } + +.mfa-warning { + color: #f59e0b; font-size: 13px; padding: 8px 12px; + background: #3a2a1a; border-radius: 6px; margin-top: 8px; +} diff --git a/tests/test_mfa.py b/tests/test_mfa.py new file mode 100644 index 0000000..9bed2fc --- /dev/null +++ b/tests/test_mfa.py @@ -0,0 +1,353 @@ +"""Tests for MFA (Multi-Factor Authentication) — TOTP + recovery codes.""" + +import asyncio +import json +import os +import shutil +import tempfile +import time +from pathlib import Path + +import pyotp +import pytest + +from backend.auth.mfa import ( + TOTP_ISSUER, + generate_qr_uri, + generate_recovery_codes, + generate_secret, + hash_recovery_code, + verify_recovery_code, + verify_totp, +) + + +# ── Unit tests: TOTP ─────────────────────────────────────────────────── + +class TestTotpGeneration: + def test_generate_secret_returns_base32(self): + secret = generate_secret() + assert isinstance(secret, str) + assert len(secret) >= 16 + import base64 + base64.b32decode(secret, casefold=True) + + def test_generate_secret_unique(self): + secrets = {generate_secret() for _ in range(100)} + assert len(secrets) == 100 + + def test_generate_qr_uri_format(self): + secret = generate_secret() + uri = generate_qr_uri(secret, "testuser") + assert uri.startswith("otpauth://totp/") + assert "ObsiGate" in uri + assert "testuser" in uri + assert "secret=" in uri + + def test_generate_qr_uri_custom_issuer(self): + secret = generate_secret() + uri = generate_qr_uri(secret, "testuser", issuer="CustomIssuer") + assert "CustomIssuer" in uri + + def test_verify_totp_valid(self): + secret = generate_secret() + totp = pyotp.TOTP(secret) + code = totp.now() + assert verify_totp(secret, code) is True + + def test_verify_totp_invalid(self): + secret = generate_secret() + assert verify_totp(secret, "000000") is False + + def test_verify_totp_wrong_secret(self): + secret1 = generate_secret() + secret2 = generate_secret() + totp1 = pyotp.TOTP(secret1) + code = totp1.now() + assert verify_totp(secret2, code) is False + + def test_verify_totp_window_tolerance(self): + secret = generate_secret() + totp = pyotp.TOTP(secret) + current_time = time.time() + code = totp.at(int(current_time)) + assert verify_totp(secret, str(code).zfill(6)) is True + + +# ── Unit tests: Recovery codes ───────────────────────────────────────── + +class TestRecoveryCodes: + def test_generate_count(self): + codes = generate_recovery_codes() + assert len(codes) == 8 + + def test_generate_custom_count(self): + codes = generate_recovery_codes(n=12) + assert len(codes) == 12 + + def test_code_format(self): + codes = generate_recovery_codes() + for code in codes: + assert len(code) == 9 # XXXX-XXXX + assert code[4] == "-" + assert code[:4].isalnum() + assert code[5:].isalnum() + + def test_codes_unique(self): + codes = generate_recovery_codes(n=20) + assert len(set(codes)) == 20 + + def test_codes_uppercase(self): + codes = generate_recovery_codes() + for code in codes: + assert code == code.upper() + + def test_hash_recovery_code_deterministic(self): + code = "ABCD-1234" + h1 = hash_recovery_code(code) + h2 = hash_recovery_code(code) + assert h1 == h2 + + def test_hash_case_insensitive(self): + h1 = hash_recovery_code("abcd-1234") + h2 = hash_recovery_code("ABCD-1234") + assert h1 == h2 + + def test_hash_different_for_different_codes(self): + h1 = hash_recovery_code("AAAA-AAAA") + h2 = hash_recovery_code("BBBB-BBBB") + assert h1 != h2 + + def test_verify_recovery_code_match(self): + codes = generate_recovery_codes() + hashed = [hash_recovery_code(c) for c in codes] + for i, code in enumerate(codes): + idx = verify_recovery_code(code, hashed) + assert idx == i + + def test_verify_recovery_code_case_insensitive(self): + codes = ["ABCD-1234"] + hashed = [hash_recovery_code(c) for c in codes] + assert verify_recovery_code("abcd-1234", hashed) == 0 + + def test_verify_recovery_code_invalid(self): + codes = generate_recovery_codes() + hashed = [hash_recovery_code(c) for c in codes] + assert verify_recovery_code("ZZZZ-ZZZZ", hashed) is None + + def test_verify_recovery_code_empty_list(self): + assert verify_recovery_code("AAAA-AAAA", []) is None + + def test_recovery_code_single_use(self): + codes = generate_recovery_codes(n=3) + hashed = [hash_recovery_code(c) for c in codes] + idx = verify_recovery_code(codes[0], hashed) + assert idx == 0 + hashed.pop(idx) + assert verify_recovery_code(codes[0], hashed) is None + idx2 = verify_recovery_code(codes[1], hashed) + assert idx2 == 0 + + +# ── Integration tests: MFA API endpoints ─────────────────────────────── + +@pytest.fixture +def mfa_client(): + """Create a TestClient with auth enabled, isolated temp data.""" + tmp = Path(tempfile.mkdtemp()) + data_dir = tmp / "data" + data_dir.mkdir() + + from backend.auth.password import hash_password + pw_hash = hash_password("TestPass123!") + users = { + "version": 1, + "users": { + "testuser": { + "id": "testuser-1", + "username": "testuser", + "display_name": "Test User", + "password_hash": pw_hash, + "role": "admin", + "vaults": ["*"], + "active": True, + "created_at": "2026-01-01T00:00:00", + } + } + } + (data_dir / "users.json").write_text(json.dumps(users), encoding="utf-8") + + src_secret = Path("data/secret.key") + if src_secret.exists(): + shutil.copy2(str(src_secret), str(data_dir / "secret.key")) + + orig_cwd = os.getcwd() + test_vault_path = os.path.abspath("test-vault") + os.chdir(str(tmp)) + + os.environ["VAULT_1_NAME"] = "TestVault" + os.environ["VAULT_1_PATH"] = test_vault_path + os.environ["OBSIGATE_AUTH_ENABLED"] = "true" + os.environ["OBSIGATE_ADMIN_USER"] = "testuser" + os.environ["OBSIGATE_ADMIN_PASSWORD"] = "TestPass123!" + os.environ["OBSIGATE_WATCHER_ENABLED"] = "false" + + import backend.main + backend.main._load_config = lambda: {"watcher_enabled": False} + + from backend.main import app + from backend.indexer import build_index, index + for key in list(index.keys()): + del index[key] + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(build_index()) + + from backend.search import init_inverted_index + init_inverted_index() + + from fastapi.testclient import TestClient + client = TestClient(app) + yield client + + if hasattr(client, 'close'): + client.close() + loop.run_until_complete(asyncio.sleep(0)) + + os.chdir(orig_cwd) + shutil.rmtree(str(tmp), ignore_errors=True) + for k in ["VAULT_1_NAME", "VAULT_1_PATH", "OBSIGATE_AUTH_ENABLED", + "OBSIGATE_ADMIN_USER", "OBSIGATE_ADMIN_PASSWORD", "OBSIGATE_WATCHER_ENABLED"]: + os.environ.pop(k, None) + + +def _login(client, username="testuser", password="TestPass123!"): + resp = client.post("/api/auth/login", json={"username": username, "password": password}) + return resp.json().get("access_token"), resp + + +def _auth_headers(token): + return {"Authorization": f"Bearer {token}"} + + +class TestMfaApiEndpoints: + def test_mfa_status_initially_disabled(self, mfa_client): + token, _ = _login(mfa_client) + resp = mfa_client.get("/api/auth/mfa/status", headers=_auth_headers(token)) + assert resp.status_code == 200 + assert resp.json()["mfa_enabled"] is False + + def test_mfa_setup_generates_secret(self, mfa_client): + 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() + assert "secret" in data + assert "otpauth_uri" in data + assert "otpauth://totp/" in data["otpauth_uri"] + assert len(data["secret"]) >= 16 + + def test_mfa_enable_flow(self, mfa_client): + token, _ = _login(mfa_client) + headers = _auth_headers(token) + # Setup + setup_resp = mfa_client.post("/api/auth/mfa/totp/setup", headers=headers) + secret = setup_resp.json()["secret"] + totp = pyotp.TOTP(secret) + # Enable + enable_resp = mfa_client.post("/api/auth/mfa/totp/enable", headers=headers, json={ + "code": totp.now(), + }) + assert enable_resp.status_code == 200 + data = enable_resp.json() + assert "recovery_codes" in data + assert len(data["recovery_codes"]) == 8 + assert data["mfa_enabled"] is True + + def test_mfa_enable_invalid_code(self, mfa_client): + token, _ = _login(mfa_client) + headers = _auth_headers(token) + mfa_client.post("/api/auth/mfa/totp/setup", headers=headers) + resp = mfa_client.post("/api/auth/mfa/totp/enable", headers=headers, json={ + "code": "000000", + }) + assert resp.status_code in (400, 401) + + def test_mfa_login_defers_to_totp(self, mfa_client): + token, _ = _login(mfa_client) + headers = _auth_headers(token) + # Enable MFA + setup_resp = mfa_client.post("/api/auth/mfa/totp/setup", headers=headers) + secret = setup_resp.json()["secret"] + totp = pyotp.TOTP(secret) + mfa_client.post("/api/auth/mfa/totp/enable", headers=headers, json={"code": totp.now()}) + # Login again — should require MFA + login_resp = mfa_client.post("/api/auth/login", json={ + "username": "testuser", "password": "TestPass123!", + }) + assert login_resp.status_code == 200 + data = login_resp.json() + assert data.get("mfa_required") is True + assert data.get("mfa_method") == "totp" + assert "access_token" not in data + + def test_mfa_verify_issues_token(self, mfa_client): + token, _ = _login(mfa_client) + headers = _auth_headers(token) + setup_resp = mfa_client.post("/api/auth/mfa/totp/setup", headers=headers) + secret = setup_resp.json()["secret"] + totp = pyotp.TOTP(secret) + mfa_client.post("/api/auth/mfa/totp/enable", headers=headers, json={"code": totp.now()}) + # Login → MFA challenge + mfa_client.post("/api/auth/login", json={"username": "testuser", "password": "TestPass123!"}) + # Verify TOTP + verify_resp = mfa_client.post("/api/auth/mfa/totp/verify", json={ + "username": "testuser", "code": totp.now(), + }) + assert verify_resp.status_code == 200 + data = verify_resp.json() + assert "access_token" in data + assert data["token_type"] == "bearer" + + def test_mfa_recovery_login(self, mfa_client): + token, _ = _login(mfa_client) + headers = _auth_headers(token) + setup_resp = mfa_client.post("/api/auth/mfa/totp/setup", headers=headers) + secret = setup_resp.json()["secret"] + totp = pyotp.TOTP(secret) + enable_resp = mfa_client.post("/api/auth/mfa/totp/enable", headers=headers, json={"code": totp.now()}) + recovery_codes = enable_resp.json()["recovery_codes"] + # Login → MFA challenge + mfa_client.post("/api/auth/login", json={"username": "testuser", "password": "TestPass123!"}) + # Use recovery code + recover_resp = mfa_client.post("/api/auth/mfa/recovery", json={ + "username": "testuser", "recovery_code": recovery_codes[0], + }) + assert recover_resp.status_code == 200 + assert "access_token" in recover_resp.json() + + def test_mfa_disable_flow(self, mfa_client): + token, _ = _login(mfa_client) + headers = _auth_headers(token) + setup_resp = mfa_client.post("/api/auth/mfa/totp/setup", headers=headers) + secret = setup_resp.json()["secret"] + totp = pyotp.TOTP(secret) + mfa_client.post("/api/auth/mfa/totp/enable", headers=headers, json={"code": totp.now()}) + # Disable + disable_resp = mfa_client.post("/api/auth/mfa/totp/disable", headers=headers, json={ + "password": "TestPass123!", "code": totp.now(), + }) + assert disable_resp.status_code == 200 + # Verify disabled + status_resp = mfa_client.get("/api/auth/mfa/status", headers=headers) + assert status_resp.json()["mfa_enabled"] is False + + def test_login_without_mfa_still_works(self, mfa_client): + login_resp = mfa_client.post("/api/auth/login", json={ + "username": "testuser", "password": "TestPass123!", + }) + assert login_resp.status_code == 200 + data = login_resp.json() + assert "access_token" in data + assert data.get("mfa_required") is None