|
1 | | -import datetime |
| 1 | +# GPLv3 license |
| 2 | +# Copyright Lutra Consulting Limited |
| 3 | + |
| 4 | +import hashlib |
| 5 | +import os |
| 6 | +import re |
2 | 7 | import typing |
3 | 8 | import uuid |
4 | 9 | import json |
|
14 | 19 | QgsNetworkAccessManager, |
15 | 20 | QgsExpressionContextUtils, |
16 | 21 | Qgis, |
| 22 | + QgsProject, |
| 23 | + QgsProviderRegistry, |
17 | 24 | ) |
18 | 25 | from qgis.PyQt.QtCore import QSettings, QUrl |
19 | 26 | from qgis.PyQt.QtNetwork import QNetworkRequest |
| 27 | +from qgis.PyQt.QtWidgets import QMessageBox |
20 | 28 |
|
21 | 29 | from .mergin.client import MerginClient, ServerType, AuthTokenExpiredError |
22 | 30 | from .mergin.common import ClientError, LoginError |
| 31 | +from .mergin.merginproject import MerginProject |
23 | 32 |
|
24 | 33 | from .utils import MERGIN_URL, get_qgis_proxy_config, get_plugin_version |
25 | 34 |
|
26 | 35 |
|
| 36 | +AUTH_CONFIG_FILENAME = "qgis_cfg.xml" |
| 37 | + |
| 38 | + |
27 | 39 | class LoginType(Enum): |
28 | 40 | """Types of login supported by Mergin Maps.""" |
29 | 41 |
|
@@ -552,3 +564,129 @@ def qgis_support_sso() -> bool: |
552 | 564 | """ |
553 | 565 | # QGIS 3.40+ supports SSO |
554 | 566 | return Qgis.versionInt() >= 34000 |
| 567 | + |
| 568 | + |
| 569 | +class AuthSync: |
| 570 | + def __init__(self, qgis_file=None): |
| 571 | + if qgis_file is None: |
| 572 | + self.project = QgsProject.instance() |
| 573 | + else: |
| 574 | + self.project = QgsProject() |
| 575 | + self.project.read(qgis_file) |
| 576 | + self.project_path = self.project.homePath() |
| 577 | + self.auth_file = os.path.join(self.project_path, AUTH_CONFIG_FILENAME) |
| 578 | + self.mp = MerginProject(self.project_path) |
| 579 | + self.project_id = self.mp.project_id() |
| 580 | + self.auth_mngr = QgsApplication.authManager() |
| 581 | + |
| 582 | + def get_layers_auth_ids(self) -> typing.List[str]: |
| 583 | + """Get the auth config IDs of the protected layers in the current project.""" |
| 584 | + auth_ids = set() |
| 585 | + reg = QgsProviderRegistry.instance() |
| 586 | + for layer in self.project.mapLayers().values(): |
| 587 | + source = layer.source() |
| 588 | + prov_type = layer.providerType() |
| 589 | + decoded_uri = reg.decodeUri(prov_type, source) |
| 590 | + auth_id = decoded_uri.get("authcfg") |
| 591 | + if auth_id: |
| 592 | + auth_ids.add(auth_id) |
| 593 | + return list(auth_ids) |
| 594 | + |
| 595 | + def get_auth_config_hash(self, auth_ids: typing.List[str]) -> str: |
| 596 | + """ |
| 597 | + Generates a stable hash from the decrypted content of the given auth IDs. |
| 598 | + This allows us to detect config changes regardless of random encryption salts in the encrypted XML file. |
| 599 | + """ |
| 600 | + sorted_ids = sorted(auth_ids) |
| 601 | + |
| 602 | + hasher = hashlib.sha256() |
| 603 | + |
| 604 | + for auth_id in sorted_ids: |
| 605 | + config = QgsAuthMethodConfig() |
| 606 | + if not self.auth_mngr.loadAuthenticationConfig(auth_id, config, True): # True to decrypt full details |
| 607 | + self.mp.log.error(f"Failed to load the authentication config for the auth ID: {auth_id}") |
| 608 | + continue |
| 609 | + |
| 610 | + header_data = f"{config.id()}|{config.method()}|{config.uri()}" |
| 611 | + hasher.update(header_data.encode("utf-8")) |
| 612 | + |
| 613 | + config_map = config.configMap() |
| 614 | + for key in sorted(config_map.keys()): |
| 615 | + entry = f"|{key}={config_map[key]}" |
| 616 | + hasher.update(entry.encode("utf-8")) |
| 617 | + |
| 618 | + return hasher.hexdigest() |
| 619 | + |
| 620 | + def export_auth(self, client) -> None: |
| 621 | + """Export auth DB credentials for protected layers if they have changed""" |
| 622 | + |
| 623 | + # permission check - auth config .xml file can be modified from the writer role above |
| 624 | + project_info = client.project_info(self.mp.project_full_name()) |
| 625 | + role = project_info.get("role") |
| 626 | + if not (role and role in ("writer", "owner")): |
| 627 | + return |
| 628 | + |
| 629 | + referenced_ids = self.get_layers_auth_ids() |
| 630 | + available_ids = self.auth_mngr.configIds() |
| 631 | + auth_ids = [aid for aid in referenced_ids if aid in available_ids] |
| 632 | + if not auth_ids: |
| 633 | + if os.path.exists(self.auth_file): |
| 634 | + os.remove(self.auth_file) |
| 635 | + return |
| 636 | + |
| 637 | + if not self.auth_mngr.masterPasswordIsSet(): |
| 638 | + self.mp.log.warning("Master Password not set. Cannot export auth configs.") |
| 639 | + msg = "Failed to export authentication configuration. If you want to share the credentials of the protected layer(s), set the master password please." |
| 640 | + QMessageBox.warning( |
| 641 | + None, "Cannot export configuration for protected layer", msg, QMessageBox.StandardButton.Close |
| 642 | + ) |
| 643 | + return |
| 644 | + |
| 645 | + current_hash = self.get_auth_config_hash(auth_ids) |
| 646 | + |
| 647 | + # Compare current hash with the hash in the existing file |
| 648 | + file_exists = os.path.exists(self.auth_file) |
| 649 | + if file_exists: |
| 650 | + with open(self.auth_file, "r", encoding="utf-8") as f: |
| 651 | + content = f.read() |
| 652 | + pattern = r"<!--\s*HASH:\s*([A-Za-z0-9]+)\s*-->" |
| 653 | + match = re.search(pattern, content) |
| 654 | + if match: |
| 655 | + existing_hash = match.group(1) |
| 656 | + if existing_hash == current_hash: |
| 657 | + self.mp.log.info("No change in auth config. No update needed.") |
| 658 | + return |
| 659 | + else: |
| 660 | + self.mp.log.info("Auth config file change detected. Updating file...") |
| 661 | + else: |
| 662 | + self.mp.log.warning("No hash found in existing config file. Creating one...") |
| 663 | + |
| 664 | + # Export and inject hash |
| 665 | + temp_file = os.path.join(self.project_path, f"temp_{AUTH_CONFIG_FILENAME}") |
| 666 | + |
| 667 | + ok = self.auth_mngr.exportAuthenticationConfigsToXml(temp_file, list(auth_ids), self.project_id) |
| 668 | + |
| 669 | + if ok: |
| 670 | + with open(temp_file, "r", encoding="utf-8") as f: |
| 671 | + xml_content = f.read() |
| 672 | + |
| 673 | + hashed_content = xml_content + f"\n<!-- HASH: {current_hash} -->" |
| 674 | + |
| 675 | + with open(self.auth_file, "w", encoding="utf-8") as f: |
| 676 | + f.write(hashed_content) |
| 677 | + |
| 678 | + if os.path.exists(temp_file): |
| 679 | + os.remove(temp_file) |
| 680 | + |
| 681 | + def import_auth(self) -> None: |
| 682 | + """Import credentials for protected layers""" |
| 683 | + |
| 684 | + if os.path.isfile(self.auth_file): |
| 685 | + if not self.auth_mngr.masterPasswordIsSet(): |
| 686 | + self.mp.log.warning("Master password is not set. Could not import auth config.") |
| 687 | + 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)." |
| 688 | + QMessageBox.warning(None, "Could not load protected layer", user_msg, QMessageBox.StandardButton.Close) |
| 689 | + return |
| 690 | + |
| 691 | + ok = self.auth_mngr.importAuthenticationConfigsFromXml(self.auth_file, self.project_id, overwrite=True) |
| 692 | + self.mp.log.info(f"QGIS auth imported: {ok}") |
0 commit comments