feat(auth): v2.1.0 - MFA TOTP avec QR code, recovery codes
CI / lint (push) Failing after 16s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 21s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s

- 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
This commit is contained in:
2026-09-06 18:42:32 -04:00
parent 7b0dacabdd
commit 83063d3a18
11 changed files with 1569 additions and 67 deletions
+65
View File
@@ -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
+215 -10
View File
@@ -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")
+92 -2
View File
@@ -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,
+1
View File
@@ -13,3 +13,4 @@ snowballstemmer>=2.2.0
weasyprint>=60.0
httpx>=0.27.0
pypdf>=4.0
pyotp>=2.10.0