Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Mergin/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@
icon_path,
mm_symbol_path,
mergin_project_local_path,
PROJS_PER_PAGE,
remove_project_variables,
set_qgis_project_mergin_variables,
unsaved_project_check,
Expand All @@ -55,6 +54,7 @@
AuthTokenExpiredError,
set_qgsexpressionscontext,
get_authcfg,
AuthSync,
)

from .mergin.merginproject import MerginProject
Expand Down Expand Up @@ -478,6 +478,7 @@ def create_new_project(self):

def current_project_sync(self):
"""Synchronise current Mergin Maps project."""
AuthSync().export_auth(self.mc)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just wondering, If here is permission check somewhere, or it's not called by design?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is inside the export method, do you prefer to put it outside?

self.manager.project_status(self.mergin_proj_dir)

def find_project(self):
Expand Down
11 changes: 10 additions & 1 deletion Mergin/projects_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,10 @@
UnsavedChangesStrategy,
write_project_variables,
bytes_to_human_size,
is_file_changed,
push_error_message,
)
from .utils_auth import get_stored_mergin_server_url
from .utils_auth import AuthSync, AUTH_CONFIG_FILENAME

from .mergin.merginproject import MerginProject
from .project_status_dialog import ProjectStatusDialog
Expand Down Expand Up @@ -69,6 +70,9 @@ def open_project(self, project_dir):

qgis_files = find_qgis_files(project_dir)
if len(qgis_files) == 1:
# singleton project object is not in the interface yet, we need to pass the qgis file to retrieve the project id
AuthSync(qgis_files[0]).import_auth()

iface.addProject(qgis_files[0])
if self.mc.has_unfinished_pull(project_dir):
widget = iface.messageBar().createMessage(
Expand Down Expand Up @@ -424,12 +428,17 @@ def sync_project(self, project_dir, project_name=None):

if dlg.pull_conflicts:
self.report_conflicts(dlg.pull_conflicts)
if is_file_changed(pull_changes, AUTH_CONFIG_FILENAME):
AuthSync().import_auth()
return

if not dlg.is_complete:
# we were cancelled
return

if is_file_changed(pull_changes, AUTH_CONFIG_FILENAME):
AuthSync().import_auth()

# pull finished, start push
if any(push_changes.values()) and not self.mc.has_writing_permissions(project_name):
QMessageBox.information(
Expand Down
7 changes: 6 additions & 1 deletion Mergin/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import shutil
from datetime import datetime, timezone
from enum import Enum
from typing import Any
from typing import Any, Dict, List
from urllib.error import URLError, HTTPError
import configparser
import os
Expand Down Expand Up @@ -1726,6 +1726,11 @@ def escape_html_minimal(s: str) -> str:
return s


def is_file_changed(changes: Dict[str, List[dict]], filename: str) -> bool:
"""Check whether a file is added or updated"""
return any(f.get("path") == filename for key in ["added", "updated"] for f in changes.get(key, []))


def sanitize_path(expr: str) -> str:
if not expr:
return expr
Expand Down
138 changes: 136 additions & 2 deletions Mergin/utils_auth.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import datetime
# GPLv3 license
# Copyright Lutra Consulting Limited

import hashlib
import os
import re
import typing
import uuid
import json
Expand All @@ -14,16 +19,23 @@
QgsNetworkAccessManager,
QgsExpressionContextUtils,
Qgis,
QgsProject,
QgsProviderRegistry,
)
from qgis.PyQt.QtCore import QSettings, QUrl
from qgis.PyQt.QtNetwork import QNetworkRequest
from qgis.PyQt.QtWidgets import QMessageBox

from .mergin.client import MerginClient, ServerType, AuthTokenExpiredError
from .mergin.common import ClientError, LoginError
from .mergin.common import ClientError, LoginError, ProjectRole
from .mergin.merginproject import MerginProject

from .utils import MERGIN_URL, get_qgis_proxy_config, get_plugin_version


AUTH_CONFIG_FILENAME = "qgis_cfg.xml"


class LoginType(Enum):
"""Types of login supported by Mergin Maps."""

Expand Down Expand Up @@ -552,3 +564,125 @@ def qgis_support_sso() -> bool:
"""
# QGIS 3.40+ supports SSO
return Qgis.versionInt() >= 34000


class AuthSync:
def __init__(self, qgis_file=None):
if qgis_file is None:
self.project = QgsProject.instance()
else:
self.project = QgsProject()
self.project.read(qgis_file)
self.project_path = self.project.homePath()
self.auth_file = os.path.join(self.project_path, AUTH_CONFIG_FILENAME)
self.mp = MerginProject(self.project_path)
self.project_id = self.mp.project_id()
self.auth_mngr = QgsApplication.authManager()

def get_layers_auth_ids(self) -> list[str]:
"""Get the auth config IDs of the protected layers in the current project."""
auth_ids = set()
reg = QgsProviderRegistry.instance()
for layer in self.project.mapLayers().values():
source = layer.source()
prov_type = layer.providerType()
decoded_uri = reg.decodeUri(prov_type, source)
auth_id = decoded_uri.get("authcfg")
if auth_id:
auth_ids.add(auth_id)
return list(auth_ids)

def get_auth_config_hash(self, auth_ids: list[str]) -> str:
"""
Generates a stable hash from the decrypted content of the given auth IDs.
This allows us to detect config changes regardless of random encryption salts in the encrypted XML file.
"""
sorted_ids = sorted(auth_ids)

hasher = hashlib.sha256()

for auth_id in sorted_ids:
config = QgsAuthMethodConfig()
if not self.auth_mngr.loadAuthenticationConfig(auth_id, config, True): # True to decrypt full details
self.mp.log.error(f"Failed to load the authentication config for the auth ID: {auth_id}")
continue

header_data = f"{config.id()}|{config.method()}|{config.uri()}"
hasher.update(header_data.encode("utf-8"))

config_map = config.configMap()
for key in sorted(config_map.keys()):
entry = f"|{key}={config_map[key]}"
hasher.update(entry.encode("utf-8"))

return hasher.hexdigest()

def export_auth(self, client) -> None:
"""Export auth DB credentials for protected layers if they have changed"""

auth_ids = self.get_layers_auth_ids()
if not auth_ids:
if os.path.exists(self.auth_file):
os.remove(self.auth_file)
return
project_info = client.project_info(self.mp.project_full_name())
role = project_info.get("role")
if not (role and role in ("writer", "owner")):
return

if not self.auth_mngr.masterPasswordIsSet():
self.mp.log.warning("Master Password not set. Cannot export auth configs.")
msg = "Failed to export authentication configuration. If you want to share the credentials of the protected layer(s), set the master password please."
QMessageBox.warning(
None, "Cannot export configuration for protected layer", msg, QMessageBox.StandardButton.Close
)
return

current_hash = self.get_auth_config_hash(auth_ids)

# Compare current hash with the hash in the existing file
file_exists = os.path.exists(self.auth_file)
if file_exists:
with open(self.auth_file, "r", encoding="utf-8") as f:
content = f.read()
pattern = r"<!--\s*HASH:\s*([A-Za-z0-9]+)\s*-->"
match = re.search(pattern, content)
if match:
existing_hash = match.group(1)
if existing_hash == current_hash:
self.mp.log.info("No change in auth config. No update needed.")
return
else:
self.mp.log.info("Auth config file change detected. Updating file...")
else:
self.mp.log.warning("No hash found in existing config file. Creating one...")

# Export and inject hash
temp_file = os.path.join(self.project_path, f"temp_{AUTH_CONFIG_FILENAME}")

ok = self.auth_mngr.exportAuthenticationConfigsToXml(temp_file, list(auth_ids), self.project_id)

if ok:
with open(temp_file, "r", encoding="utf-8") as f:
xml_content = f.read()

hashed_content = xml_content + f"\n<!-- HASH: {current_hash} -->"

with open(self.auth_file, "w", encoding="utf-8") as f:
f.write(hashed_content)

if os.path.exists(temp_file):
os.remove(temp_file)

def import_auth(self) -> None:
"""Import credentials for protected layers"""

if os.path.isfile(self.auth_file):
if not self.auth_mngr.masterPasswordIsSet():
self.mp.log.warning("Master password is not set. Could not import auth config.")
user_msg = "Could not import authentication configuration for the protected layer(s). Set the master password and reload the project if you want to access the protected layer(s)."
QMessageBox.warning(None, "Could not load protected layer", user_msg, QMessageBox.StandardButton.Close)
return

ok = self.auth_mngr.importAuthenticationConfigsFromXml(self.auth_file, self.project_id, overwrite=True)
self.mp.log.info(f"QGIS auth imported: {ok}")
Loading