feat(security): retire le MFA local, le code d'accès est celui du serveur (v0.8.1)
La v0.8.0 laissait cohabiter deux vérifications du code à 6 chiffres : la fenêtre MFA locale, qui contrôlait un code contre un secret détenu par le poste lui-même — et ne verrouillait donc que sa propre interface — puis la validation par le serveur, seule à décider de l'accès réseau. L'ancienne disparaît, avec le secret TOTP qu'elle stockait en clair dans config.json (effacé au premier lancement) et l'onglet MFA du panneau Administrateur. La saisie passe par une fenêtre dédiée qui relaie le refus exact du serveur et laisse réessayer, la requête partant dans un thread pour qu'un serveur muet ne fige pas l'application. Une ligne « Accès distant » distingue désormais l'état de l'autorisation de celui du tunnel : monté sans code validé, celui-ci ne mène nulle part. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+17
-20
@@ -49,8 +49,6 @@ _DEFAULT: dict[str, Any] = {
|
||||
"version": "0.7.3",
|
||||
"admin_password_hash": "",
|
||||
"admin_salt": "",
|
||||
"mfa_enabled": False,
|
||||
"mfa_secret": "",
|
||||
"active_profile": "H3",
|
||||
"profiles": {},
|
||||
"wg": deepcopy(_WG_DEFAULT),
|
||||
@@ -76,6 +74,7 @@ class Config:
|
||||
with open(self._path, "r", encoding="utf-8") as f:
|
||||
saved = json.load(f)
|
||||
self._data = self._merge(_DEFAULT, saved)
|
||||
self._drop_local_mfa()
|
||||
except Exception:
|
||||
self._data = deepcopy(_DEFAULT)
|
||||
else:
|
||||
@@ -85,6 +84,22 @@ class Config:
|
||||
self._data = deepcopy(_DEFAULT)
|
||||
self._apply_autostart(self._data["ui"]["autostart"])
|
||||
|
||||
def _drop_local_mfa(self) -> None:
|
||||
"""Efface le secret TOTP local hérité des versions ≤ 0.8.0.
|
||||
|
||||
Ce secret ne sert plus : le code à 6 chiffres est désormais validé
|
||||
par le serveur, contre un secret que le poste client ne détient pas.
|
||||
Le laisser en place serait un secret d'authentification en clair
|
||||
dans `config.json`, sans rien qui le justifie — d'où l'effacement
|
||||
au chargement plutôt qu'un simple abandon des clés.
|
||||
"""
|
||||
stale = [k for k in ("mfa_enabled", "mfa_secret") if k in self._data]
|
||||
if not stale:
|
||||
return
|
||||
for key in stale:
|
||||
del self._data[key]
|
||||
self.save()
|
||||
|
||||
def save(self):
|
||||
os.makedirs(os.path.dirname(self._path), exist_ok=True)
|
||||
with open(self._path, "w", encoding="utf-8") as f:
|
||||
@@ -151,24 +166,6 @@ class Config:
|
||||
wg = self._data["wg"]
|
||||
return bool(wg.get("server_endpoint") and wg.get("client_private_key"))
|
||||
|
||||
# ── MFA ───────────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def mfa_enabled(self) -> bool:
|
||||
return bool(self._data.get("mfa_enabled", False))
|
||||
|
||||
@mfa_enabled.setter
|
||||
def mfa_enabled(self, value: bool):
|
||||
self._data["mfa_enabled"] = value
|
||||
|
||||
@property
|
||||
def mfa_secret(self) -> str:
|
||||
return self._data.get("mfa_secret", "")
|
||||
|
||||
@mfa_secret.setter
|
||||
def mfa_secret(self, value: str):
|
||||
self._data["mfa_secret"] = value
|
||||
|
||||
# ── Multi-profils ─────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import pyotp
|
||||
import qrcode
|
||||
import io
|
||||
from PyQt6.QtGui import QPixmap, QImage
|
||||
|
||||
|
||||
def generate_secret() -> str:
|
||||
return pyotp.random_base32()
|
||||
|
||||
|
||||
def get_totp(secret: str) -> pyotp.TOTP:
|
||||
return pyotp.TOTP(secret)
|
||||
|
||||
|
||||
def verify_code(secret: str, code: str) -> bool:
|
||||
if not secret or not code:
|
||||
return False
|
||||
totp = pyotp.TOTP(secret)
|
||||
return totp.verify(code.strip(), valid_window=1)
|
||||
|
||||
|
||||
def get_current_code(secret: str) -> str:
|
||||
return pyotp.TOTP(secret).now()
|
||||
|
||||
|
||||
def time_remaining() -> int:
|
||||
"""Secondes restantes avant rotation du code TOTP."""
|
||||
import time
|
||||
return 30 - (int(time.time()) % 30)
|
||||
|
||||
|
||||
def get_provisioning_uri(secret: str, account: str = "WGSecure", issuer: str = "WGSecure") -> str:
|
||||
totp = pyotp.TOTP(secret)
|
||||
return totp.provisioning_uri(name=account, issuer_name=issuer)
|
||||
|
||||
|
||||
def generate_qr_pixmap(secret: str, account: str = "WGSecure") -> QPixmap:
|
||||
uri = get_provisioning_uri(secret, account)
|
||||
qr = qrcode.QRCode(box_size=6, border=2)
|
||||
qr.add_data(uri)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
buf.seek(0)
|
||||
data = buf.read()
|
||||
qimage = QImage.fromData(data)
|
||||
return QPixmap.fromImage(qimage)
|
||||
Reference in New Issue
Block a user