This commit is contained in:
2026-06-02 07:34:09 +02:00
parent d927afbb87
commit b3f0a2eecc
9 changed files with 261 additions and 69 deletions
+13 -10
View File
@@ -2,6 +2,7 @@ import json
import os
import hashlib
import secrets
import bcrypt
from copy import deepcopy
from typing import Any
from app.utils.platform_utils import get_config_dir
@@ -28,7 +29,7 @@ _DEFAULT: dict[str, Any] = {
"admin_salt": "",
"mfa_enabled": False,
"mfa_secret": "",
"active_profile": "Défaut",
"active_profile": "H3",
"profiles": {},
"wg": deepcopy(_WG_DEFAULT),
"ui": {
@@ -94,19 +95,21 @@ class Config:
self._data["admin_salt"] = ""
self._data["admin_password_hash"] = ""
else:
salt = secrets.token_hex(16)
hashed = hashlib.sha256((salt + password).encode()).hexdigest()
self._data["admin_salt"] = salt
self._data["admin_password_hash"] = hashed
hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt(rounds=12))
self._data["admin_salt"] = ""
self._data["admin_password_hash"] = hashed.decode("utf-8")
self.save()
def check_admin_password(self, password: str) -> bool:
salt = self._data.get("admin_salt", "")
stored = self._data.get("admin_password_hash", "")
if not stored:
return True
candidate = hashlib.sha256((salt + password).encode()).hexdigest()
return secrets.compare_digest(candidate, stored)
# Migration transparente : anciens hashes SHA-256 (64 hex chars, pas de $2b$)
if not stored.startswith("$2"):
salt = self._data.get("admin_salt", "")
candidate = hashlib.sha256((salt + password).encode()).hexdigest()
return secrets.compare_digest(candidate, stored)
return bcrypt.checkpw(password.encode("utf-8"), stored.encode("utf-8"))
def has_admin_password(self) -> bool:
return bool(self._data.get("admin_password_hash", ""))
@@ -144,7 +147,7 @@ class Config:
@property
def active_profile(self) -> str:
return self._data.get("active_profile", "Défaut")
return self._data.get("active_profile", "H3")
def list_profiles(self) -> list[str]:
return list(self._data.get("profiles", {}).keys())
@@ -171,7 +174,7 @@ class Config:
return False
del profiles[name]
if self._data.get("active_profile") == name:
self._data["active_profile"] = "Défaut"
self._data["active_profile"] = "H3"
self.save()
return True
+24 -5
View File
@@ -75,7 +75,7 @@ class AdminWindow(QDialog):
| Qt.WindowType.WindowTitleHint
| Qt.WindowType.WindowCloseButtonHint
)
self.setFixedSize(700, 560)
self.setFixedSize(700, 600)
self._build_ui()
self._load_values()
@@ -340,11 +340,11 @@ class AdminWindow(QDialog):
self._row(form, "", btn_gen_secret)
lay.addWidget(grp)
grp2 = QGroupBox("QR Code — Scanner avec Google Authenticator / Aegis")
grp2 = QGroupBox("QR Code — Scanner avec Bitwarden")
v2 = QVBoxLayout(grp2)
self._qr_label = QLabel("(générez un secret pour afficher le QR Code)")
self._qr_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._qr_label.setFixedHeight(180)
self._qr_label.setFixedHeight(230)
v2.addWidget(self._qr_label)
self._mfa_uri_label = QLabel("")
self._mfa_uri_label.setWordWrap(True)
@@ -736,6 +736,8 @@ class AdminWindow(QDialog):
# Actions
# ------------------------------------------------------------------ #
def _import_conf(self):
from PyQt6.QtWidgets import QInputDialog
import os as _os
path, _ = QFileDialog.getOpenFileName(
self, "Importer un fichier WireGuard",
"", "WireGuard Config (*.conf);;Tous les fichiers (*)"
@@ -746,6 +748,19 @@ class AdminWindow(QDialog):
if not values:
QMessageBox.warning(self, "Erreur", "Impossible de lire ce fichier .conf")
return
# Demander le nom du profil (défaut = nom du fichier sans extension)
default_name = _os.path.splitext(_os.path.basename(path))[0]
name, ok = QInputDialog.getText(
self, "Nom du profil",
"Nom du nouveau profil :",
text=default_name,
)
if not ok or not name.strip():
return
name = name.strip()
# Appliquer les valeurs dans les champs
mapping = {
"server_endpoint": self._srv_endpoint,
"server_public_key": self._srv_pubkey,
@@ -762,10 +777,14 @@ class AdminWindow(QDialog):
self._srv_port.setValue(int(values["server_port"]))
if "keepalive" in values:
self._keepalive.setValue(int(values["keepalive"]))
# Sauvegarder en tant que nouveau profil
self._save_values()
self._cfg.save_profile(name)
self._refresh_profile_list()
QMessageBox.information(
self, "Import réussi",
f"Configuration importée depuis :\n{path}"
f"Profil « {name} » créé depuis :\n{path}"
)
def _export_conf(self):
@@ -810,7 +829,7 @@ class AdminWindow(QDialog):
return
pixmap = mfa_core.generate_qr_pixmap(secret)
self._qr_label.setPixmap(
pixmap.scaled(160, 160, Qt.AspectRatioMode.KeepAspectRatio,
pixmap.scaled(210, 210, Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation)
)
self._mfa_uri_label.setText(mfa_core.get_provisioning_uri(secret))
+1 -1
View File
@@ -2,7 +2,7 @@
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QTableWidget,
QTableWidgetItem, QPushButton, QLabel, QHeaderView,
QMessageBox,
QMessageBox, QWidget,
)
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QColor, QFont
+2 -2
View File
@@ -326,14 +326,14 @@ class MainWindow(QMainWindow):
def _reload_profiles(self):
self._profile_combo.blockSignals(True)
self._profile_combo.clear()
self._profile_combo.addItem("Défaut (actif)")
self._profile_combo.addItem(f"{self._cfg.active_profile} (actif)")
for name in self._cfg.list_profiles():
marker = "" if name == self._cfg.active_profile else " "
self._profile_combo.addItem(f"{marker}{name}")
self._profile_combo.blockSignals(False)
def _on_profile_changed(self, text: str):
if "Défaut" in text:
if "(actif)" in text:
return
name = text.lstrip("").strip()
if not name or name == self._cfg.active_profile:
+25 -14
View File
@@ -6,6 +6,9 @@ from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QFont
from app.core import mfa as mfa_core
_DARK = "#1c2833"
_DARK2 = "#17202a"
class MFADialog(QDialog):
def __init__(self, secret: str, parent=None):
@@ -15,6 +18,10 @@ class MFADialog(QDialog):
self.setWindowTitle("WGSecure — Authentification MFA")
self.setFixedWidth(340)
self.setModal(True)
self.setStyleSheet(f"""
QDialog {{ background: {_DARK2}; }}
QLabel {{ color: white; background: transparent; }}
""")
self._build_ui()
self._timer = QTimer(self)
self._timer.timeout.connect(self._tick)
@@ -37,12 +44,12 @@ class MFADialog(QDialog):
sub = QLabel("Entrez le code à 6 chiffres de votre application d'authentification.")
sub.setWordWrap(True)
sub.setAlignment(Qt.AlignmentFlag.AlignCenter)
sub.setStyleSheet("color: #555;")
sub.setStyleSheet("color: rgba(255,255,255,0.6);")
layout.addWidget(sub)
sep = QFrame()
sep.setFrameShape(QFrame.Shape.HLine)
sep.setStyleSheet("color: #ddd;")
sep.setStyleSheet("color: rgba(255,255,255,0.15);")
layout.addWidget(sep)
self._code_input = QLineEdit()
@@ -53,14 +60,15 @@ class MFADialog(QDialog):
f2.setLetterSpacing(QFont.SpacingType.AbsoluteSpacing, 4)
self._code_input.setFont(f2)
self._code_input.setStyleSheet(
"padding: 8px; border: 2px solid #3498db; border-radius: 6px;"
"QLineEdit { padding: 8px; border: 2px solid #2471a3;"
f" border-radius: 6px; background: {_DARK}; color: white; }}"
)
self._code_input.returnPressed.connect(self._verify)
layout.addWidget(self._code_input)
self._error_label = QLabel("")
self._error_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._error_label.setStyleSheet("color: #e74c3c; font-weight: bold;")
self._error_label.setStyleSheet("color: #f1948a; font-weight: bold;")
layout.addWidget(self._error_label)
self._progress = QProgressBar()
@@ -68,30 +76,31 @@ class MFADialog(QDialog):
self._progress.setTextVisible(False)
self._progress.setFixedHeight(6)
self._progress.setStyleSheet(
"QProgressBar { border-radius: 3px; background: #ecf0f1; }"
"QProgressBar::chunk { background: #3498db; border-radius: 3px; }"
f"QProgressBar {{ border-radius: 3px; background: {_DARK}; }}"
"QProgressBar::chunk { background: #5dade2; border-radius: 3px; }"
)
layout.addWidget(self._progress)
self._timer_label = QLabel("")
self._timer_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._timer_label.setStyleSheet("color: #999; font-size: 11px;")
self._timer_label.setStyleSheet("color: #5d6d7e; font-size: 11px;")
layout.addWidget(self._timer_label)
btn_row = QHBoxLayout()
btn_cancel = QPushButton("Annuler")
btn_cancel.setStyleSheet(
"QPushButton { padding: 8px 16px; border-radius: 5px; "
"background: #ecf0f1; } QPushButton:hover { background: #bdc3c7; }"
"QPushButton { padding: 8px 16px; border-radius: 5px; border: none;"
f" background: #2e4057; color: white; }}"
"QPushButton:hover { background: #3d5166; }"
)
btn_cancel.clicked.connect(self.reject)
self._btn_ok = QPushButton("Vérifier")
self._btn_ok.setDefault(True)
self._btn_ok.setStyleSheet(
"QPushButton { padding: 8px 20px; border-radius: 5px; "
"background: #3498db; color: white; font-weight: bold; }"
"QPushButton:hover { background: #2980b9; }"
"QPushButton { padding: 8px 20px; border-radius: 5px; border: none;"
" background: #2471a3; color: white; font-weight: bold; }"
"QPushButton:hover { background: #1a5276; }"
)
self._btn_ok.clicked.connect(self._verify)
btn_row.addWidget(btn_cancel)
@@ -109,7 +118,8 @@ class MFADialog(QDialog):
if len(raw) != 6 or not raw.isdigit():
self._error_label.setText("Entrez exactement 6 chiffres.")
self._code_input.setStyleSheet(
"padding: 8px; border: 2px solid #e74c3c; border-radius: 6px;"
f"QLineEdit {{ padding: 8px; border: 2px solid #f1948a;"
f" border-radius: 6px; background: {_DARK}; color: white; }}"
)
return
if mfa_core.verify_code(self._secret, raw):
@@ -119,7 +129,8 @@ class MFADialog(QDialog):
self._error_label.setText("Code incorrect. Réessayez.")
self._code_input.clear()
self._code_input.setStyleSheet(
"padding: 8px; border: 2px solid #e74c3c; border-radius: 6px;"
f"QLineEdit {{ padding: 8px; border: 2px solid #f1948a;"
f" border-radius: 6px; background: {_DARK}; color: white; }}"
)
def is_verified(self) -> bool:
+58 -7
View File
@@ -56,17 +56,68 @@ def run_command(cmd: list[str], timeout: int = 10) -> tuple[int, str, str]:
return -1, "", str(e)
def run_privileged(cmd: list[str], timeout: int = 10) -> tuple[int, str, str]:
def run_privileged(cmd: list[str], timeout: int = 60) -> tuple[int, str, str]:
if is_windows():
return run_command(cmd, timeout)
if has_root_privileges():
return run_command(cmd, timeout)
# Tente pkexec puis sudo
for elevator in ("pkexec", "sudo"):
code, out, err = run_command([elevator] + cmd, timeout)
if code != -1 or "introuvable" not in err:
return code, out, err
return -1, "", "Élévation de privilèges impossible"
# 1. pkexec — dialogue graphique polkit (GNOME/KDE), timeout long pour la saisie
code, out, err = run_command(["pkexec"] + cmd, timeout)
if code != -1 or "introuvable" not in err:
return code, out, err
# 2. sudo avec programme askpass graphique (pas de TTY dans une app Qt)
askpass = _find_askpass()
if askpass:
env = os.environ.copy()
env["SUDO_ASKPASS"] = askpass
try:
result = subprocess.run(
["sudo", "-A"] + cmd,
capture_output=True, text=True,
timeout=timeout, env=env,
)
return result.returncode, result.stdout.strip(), result.stderr.strip()
except subprocess.TimeoutExpired:
return -1, "", "Timeout élévation sudo"
except Exception as e:
return -1, "", str(e)
# 3. sudo classique (fonctionne si NOPASSWD configuré dans sudoers)
code, out, err = run_command(["sudo", "-n"] + cmd, 10)
if code != -1:
return code, out, err
return (
-1, "",
"Élévation de privilèges impossible.\n"
"Solutions :\n"
" • Installer pkexec (polkit) pour le dialogue graphique\n"
" • Ou ajouter dans /etc/sudoers :\n"
f" {os.environ.get('USER','<user>')} ALL=(ALL) NOPASSWD: /usr/bin/wg-quick"
)
def _find_askpass() -> str:
"""Cherche un programme askpass graphique disponible."""
candidates = [
os.environ.get("SUDO_ASKPASS", ""),
"/usr/lib/openssh/gnome-ssh-askpass",
"/usr/bin/ssh-askpass",
"/usr/bin/x11-ssh-askpass",
"/usr/libexec/ssh-askpass",
]
for p in candidates:
if p and os.path.isfile(p) and os.access(p, os.X_OK):
return p
# Cherche aussi via shutil
import shutil
for name in ("ssh-askpass", "x11-ssh-askpass", "gnome-ssh-askpass"):
found = shutil.which(name)
if found:
return found
return ""
def wg_available() -> bool: