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
66 lines
2.0 KiB
Python
66 lines
2.0 KiB
Python
# 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
|