Hotfix/security (#37)

* vyměnit xml.etree.ElementTree za defusedxml.ElementTree

* zpátky xml.etree.ElementTree, ale přidáno # nosec; jde o false positive

* switch z ukládání hesel v registrech na používání QGIS Auth Manager
This commit is contained in:
2026-05-19 15:22:35 +02:00
committed by GitHub
parent c0d054d22a
commit 88149fbb30
2 changed files with 169 additions and 33 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
import os import os
import csv import csv
import requests import requests
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET # nosec B314
import time import time
from qgis.core import QgsMessageLog, Qgis from qgis.core import QgsMessageLog, Qgis
+168 -32
View File
@@ -1,12 +1,11 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import base64
from qgis.PyQt.QtWidgets import (QDialog, QVBoxLayout, from qgis.PyQt.QtWidgets import (QDialog, QVBoxLayout,
QLineEdit, QDialogButtonBox, QLineEdit, QDialogButtonBox,
QCheckBox, QGroupBox, QPushButton, QCheckBox, QGroupBox, QPushButton,
QListWidget, QListWidgetItem, QHBoxLayout, QListWidget, QListWidgetItem, QHBoxLayout,
QMessageBox, QLabel, QFormLayout) QMessageBox, QLabel, QFormLayout)
from qgis.PyQt.QtCore import Qt, QSettings from qgis.PyQt.QtCore import Qt, QSettings
from qgis.core import QgsTask, QgsApplication, QgsMessageLog, Qgis from qgis.core import QgsTask, QgsApplication, QgsMessageLog, Qgis, QgsAuthMethodConfig
from .amcr_codelists import (OBDOBI, TYP_AKCE, KRAJE, AREAL, ORGANIZACE, from .amcr_codelists import (OBDOBI, TYP_AKCE, KRAJE, AREAL, ORGANIZACE,
OKRESY, KATASTRY, VEDOUCI, PIAN_PRESNOST, TYP_LOKALITY, OKRESY, KATASTRY, VEDOUCI, PIAN_PRESNOST, TYP_LOKALITY,
DRUH_LOKALITY, JISTOTA, LOKALITA_ZACHOVALOST, PRISTUPNOST, DRUH_LOKALITY, JISTOTA, LOKALITA_ZACHOVALOST, PRISTUPNOST,
@@ -352,12 +351,24 @@ class AmcrFilterDialog(QDialog):
class LoginDialog(QDialog): class LoginDialog(QDialog):
""" """
Dialog pro uložení přihlašovacích údajů do AMČR. Dialog for saving AMČR login credentials securely in the QGIS Authentication Manager.
Ukládá do QSettings (username plaintext, heslo base64).
Credentials are encrypted by the platform's native secret storage
(DPAPI on Windows, Keychain on macOS, encrypted SQLite on Linux).
The auth config ID is persisted in QSettings so the session can be
restored automatically after a QGIS restart.
Note on QgsAuthManager quirks (QGIS 4 / Python bindings):
- hasConfigId() is unreliable it checks an in-memory cache that may not
be populated yet. We never use it as a hard gate; we skip it and call
loadAuthenticationConfig() directly instead.
- storeAuthenticationConfig() and loadAuthenticationConfig() both have
SIP_INOUT on their config parameter, so Python bindings return a tuple
(bool, QgsAuthMethodConfig) rather than just bool. Always unpack both.
""" """
KEY_USER = "amcr_viewer/username" SETTINGS_KEY = "amcr_viewer/auth_config_id"
KEY_PASS = "amcr_viewer/password" CONFIG_NAME = "AMČR Viewer"
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -366,14 +377,19 @@ class LoginDialog(QDialog):
layout = QVBoxLayout() layout = QVBoxLayout()
settings = QSettings() # Check whether a config ID is already stored from a previous session.
has_saved = bool(settings.value(self.KEY_USER, "")) # We attempt a lightweight load (full=False) to confirm it is readable,
# since hasConfigId() may return False even for valid configs (cache lag).
existing_id = QSettings().value(self.SETTINGS_KEY, "")
self._has_saved = bool(existing_id) and bool(self._load_username_from_config(existing_id))
if has_saved: if self._has_saved:
info = QLabel("✔ Přihlašovací údaje jsou uloženy. Vyplňte pole níže pro jejich změnu.") info = QLabel("✔ Přihlašovací údaje jsou bezpečně uloženy ve správci autentizace QGIS.\n"
"Vyplňte pole níže pouze pokud je chcete změnit.")
info.setStyleSheet("color: green; font-style: italic;") info.setStyleSheet("color: green; font-style: italic;")
else: else:
info = QLabel("Zadejte přihlašovací údaje k Digitálnímu archivu AMČR.\nVarování: přihlašovací údaje budou nezašifrovaně uloženy v registrech systému.") info = QLabel("Zadejte přihlašovací údaje k Digitálnímu archivu AMČR.\n"
"Budou zašifrovaně uloženy ve správci autentizace QGIS.")
info.setWordWrap(True) info.setWordWrap(True)
layout.addWidget(info) layout.addWidget(info)
layout.addSpacing(8) layout.addSpacing(8)
@@ -382,20 +398,23 @@ class LoginDialog(QDialog):
self.txt_user = QLineEdit() self.txt_user = QLineEdit()
self.txt_user.setPlaceholderText("např. jan.novak@email.cz") self.txt_user.setPlaceholderText("např. jan.novak@email.cz")
# Předvyplnit uložené jméno pro pohodlí # Pre-fill the stored username (not sensitive) for convenience
self.txt_user.setText(settings.value(self.KEY_USER, "")) if self._has_saved:
self.txt_user.setText(self._load_username_from_config(existing_id))
form.addRow("E-mail:", self.txt_user) form.addRow("E-mail:", self.txt_user)
self.txt_pass = QLineEdit() self.txt_pass = QLineEdit()
self.txt_pass.setEchoMode(QLineEdit.EchoMode.Password) self.txt_pass.setEchoMode(QLineEdit.EchoMode.Password)
self.txt_pass.setPlaceholderText("heslo") self.txt_pass.setPlaceholderText(
"ponechte prázdné pro zachování stávajícího hesla" if self._has_saved else "heslo"
)
form.addRow("Heslo:", self.txt_pass) form.addRow("Heslo:", self.txt_pass)
layout.addLayout(form) layout.addLayout(form)
layout.addSpacing(8) layout.addSpacing(8)
if has_saved: if self._has_saved:
btn_forget = QPushButton("Zapomenout uložené přihlašovací údaje") btn_forget = QPushButton("Odebrat uložené přihlašovací údaje")
btn_forget.setStyleSheet("color: #c0392b;") btn_forget.setStyleSheet("color: #c0392b;")
btn_forget.clicked.connect(self._forget_credentials) btn_forget.clicked.connect(self._forget_credentials)
layout.addWidget(btn_forget) layout.addWidget(btn_forget)
@@ -411,35 +430,152 @@ class LoginDialog(QDialog):
self.setLayout(layout) self.setLayout(layout)
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
@staticmethod
def _load_config(config_id: str, full: bool = False):
"""
Attempt to load a QgsAuthMethodConfig by ID.
Returns (ok, cfg). Never raises; returns (False, empty cfg) on any error.
full=True decrypts and includes the password.
"""
try:
auth_mgr = QgsApplication.authManager()
cfg = QgsAuthMethodConfig()
result = auth_mgr.loadAuthenticationConfig(config_id, cfg, full)
# Python bindings return (bool, cfg) due to SIP_INOUT parameter
if isinstance(result, tuple):
return result
return result, cfg
except Exception:
return False, QgsAuthMethodConfig()
def _load_username_from_config(self, config_id: str) -> str:
"""Load just the username from a stored config (no password decryption)."""
ok, cfg = self._load_config(config_id, full=False)
return cfg.config("username", "") if ok else ""
def _ensure_master_password(self) -> bool:
"""
Ensure the Auth Manager is unlocked before writing.
Prompts the user to set or enter the master password if needed.
Returns True if the manager is ready, False if the user cancelled.
"""
auth_mgr = QgsApplication.authManager()
if auth_mgr.isDisabled():
QMessageBox.critical(
self, "Správce autentizace nedostupný",
"Správce autentizace QGIS je zakázán nebo poškozený.\n"
"Zkuste obnovit databázi: Nastavení → Možnosti → Autentizace → Pomůcky."
)
return False
# setMasterPassword(True) shows the QGIS master password dialog if needed
if not auth_mgr.setMasterPassword(True):
return False # User cancelled the master password dialog
return True
# ------------------------------------------------------------------
# Button actions
# ------------------------------------------------------------------
def _save_and_accept(self): def _save_and_accept(self):
username = self.txt_user.text().strip() username = self.txt_user.text().strip()
password = self.txt_pass.text() password = self.txt_pass.text()
if not username or not password: if not username:
QMessageBox.warning(self, "Chybí údaje", "Vyplňte prosím uživatelské jméno i heslo.") QMessageBox.warning(self, "Chybí údaje", "Vyplňte prosím e-mailovou adresu.")
return return
existing_id = QSettings().value(self.SETTINGS_KEY, "")
auth_mgr = QgsApplication.authManager()
# If a config already exists and the password field is blank,
# update only the username and keep the existing encrypted password.
if not password and existing_id:
ok, cfg = self._load_config(existing_id, full=True)
if ok:
if not self._ensure_master_password():
return
cfg.setConfig("username", username)
auth_mgr.updateAuthenticationConfig(cfg)
self.accept()
return
if not password:
QMessageBox.warning(self, "Chybí údaje", "Vyplňte prosím heslo.")
return
if not self._ensure_master_password():
return
cfg = QgsAuthMethodConfig()
cfg.setName(self.CONFIG_NAME)
cfg.setMethod("Basic")
cfg.setConfig("username", username)
cfg.setConfig("password", password) # nosec B106
settings = QSettings() settings = QSettings()
settings.setValue(self.KEY_USER, username)
# base64 není šifrování, ale heslo aspoň neleží v plaintextu v registru # Try to update an existing config first; fall back to creating a new one.
settings.setValue(self.KEY_PASS, base64.b64encode(password.encode()).decode()) # We skip hasConfigId() as it may return False despite the config existing
# (in-memory cache may not be populated yet in QGIS 4).
ok_load, existing_cfg = self._load_config(existing_id, full=False) if existing_id else (False, None)
if ok_load:
cfg.setId(existing_id)
ok, cfg = auth_mgr.updateAuthenticationConfig(cfg)
else:
ok, cfg = auth_mgr.storeAuthenticationConfig(cfg)
config_id = cfg.id() if cfg else ""
if not ok or not config_id:
QMessageBox.critical(
self, "Chyba uložení",
"Přihlašovací údaje se nepodařilo uložit do správce autentizace QGIS.\n"
"Zkuste restartovat QGIS a přihlásit se znovu."
)
return
settings.setValue(self.SETTINGS_KEY, config_id)
self.accept() self.accept()
def _forget_credentials(self): def _forget_credentials(self):
settings = QSettings() settings = QSettings()
settings.remove(self.KEY_USER) existing_id = settings.value(self.SETTINGS_KEY, "")
settings.remove(self.KEY_PASS) if existing_id:
QMessageBox.information(self, "Hotovo", "Přihlašovací údaje byly odstraněny.") QgsApplication.authManager().removeAuthenticationConfig(existing_id)
settings.remove(self.SETTINGS_KEY)
QMessageBox.information(self, "Hotovo", "Uložené přihlašovací údaje byly odebrány.")
self.reject() self.reject()
# ------------------------------------------------------------------
# Public static API call this anywhere in the plugin to get credentials
# ------------------------------------------------------------------
@staticmethod @staticmethod
def get_credentials() -> tuple[str, str]: def get_credentials() -> tuple[str, str]:
"""Vrátí (username, password) z QSettings, nebo ('', '') pokud nejsou uloženy.""" """
Retrieve (username, password) from the QGIS Authentication Manager.
Returns ('', '') if no credentials are stored or the manager is locked.
Note: hasConfigId() is intentionally skipped here it checks an
in-memory cache that may lag behind the actual database contents,
causing false negatives (see class docstring). loadAuthenticationConfig()
is called directly and its return value is used as the authoritative result.
"""
settings = QSettings() settings = QSettings()
username = settings.value(LoginDialog.KEY_USER, "") config_id = settings.value(LoginDialog.SETTINGS_KEY, "")
encoded = settings.value(LoginDialog.KEY_PASS, "")
try: if not config_id:
password = base64.b64decode(encoded.encode()).decode() if encoded else "" return "", ""
except Exception:
password = "" ok, cfg = LoginDialog._load_config(config_id, full=True)
return username, password if not ok:
return "", ""
return cfg.config("username", ""), cfg.config("password", "") # nosec B106