src/eric7/WebBrowser/WebAuth/Fido2ManagementDialog.py

branch
eric7
changeset 10854
30c45bd597e6
child 10856
b19cefceca15
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/eric7/WebBrowser/WebAuth/Fido2ManagementDialog.py	Fri Jul 19 18:06:48 2024 +0200
@@ -0,0 +1,472 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2024 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+"""
+Module implementing a dialog to manage FIDO2 security keys.
+"""
+
+from PyQt6.QtCore import Qt, QTimer, pyqtSlot
+from PyQt6.QtWidgets import QDialog, QTreeWidgetItem
+
+from eric7.EricGui import EricPixmapCache
+from eric7.EricGui.EricOverrideCursor import EricOverrideCursor
+from eric7.EricWidgets import EricMessageBox
+
+from .Fido2Management import Fido2DeviceError, Fido2Management, Fido2PinError
+from .Fido2PinDialog import Fido2PinDialog, Fido2PinDialogMode
+from .Ui_Fido2ManagementDialog import Ui_Fido2ManagementDialog
+
+
+class Fido2ManagementDialog(QDialog, Ui_Fido2ManagementDialog):
+    """
+    Class implementing a dialog to manage FIDO2 security keys.
+    """
+
+    CredentialIdRole = Qt.ItemDataRole.UserRole
+    UserIdRole = Qt.ItemDataRole.UserRole + 1
+
+    RelyingPartyColumn = 0
+    CredentialIdColumn = 1
+    DisplayNameColumn = 2
+    UserNameColumn = 3
+
+    def __init__(self, parent=None):
+        """
+        Constructor
+
+        @param parent reference to the parent widget (defaults to None)
+        @type QWidget (optional)
+        """
+        super().__init__(parent)
+        self.setupUi(self)
+
+        self.reloadButton.setIcon(EricPixmapCache.getIcon("reload"))
+        self.lockButton.setIcon(EricPixmapCache.getIcon("locked"))
+
+        self.reloadButton.clicked.connect(self.__populateDeviceSelector)
+
+        self.__manager = Fido2Management(parent=self)
+        ##self.__manager.deviceConnected.connect(self.__deviceConnected)
+        ##self.__manager.deviceDisconnected.connect(self.__deviceDisconnected)
+
+        QTimer.singleShot(0, self.__populateDeviceSelector)
+
+    ############################################################################
+    ## methods related to device handling
+    ############################################################################
+
+    @pyqtSlot()
+    def __populateDeviceSelector(self):
+        """
+        Private slot to populate the device selector combo box.
+        """
+        self.__manager.disconnectFromDevice()
+        self.securityKeysComboBox.clear()
+        self.reloadButton.setEnabled(False)
+
+        securityKeys = self.__manager.getDevices()
+
+        if len(securityKeys) != 1:
+            self.securityKeysComboBox.addItem("")
+        for securityKey in securityKeys:
+            self.securityKeysComboBox.addItem(
+                self.tr("{0} ({1})").format(
+                    securityKey.product_name, securityKey.descriptor.path
+                ),
+                securityKey,
+            )
+
+        self.reloadButton.setEnabled(True)
+
+        if len(securityKeys) == 0:
+            EricMessageBox.information(
+                self,
+                self.tr("FIDO2 Security Key Management"),
+                self.tr(
+                    """No security key could be detected. Attach a key and press"""
+                    """ the "Reload" button."""
+                ),
+            )
+
+    @pyqtSlot(int)
+    def on_securityKeysComboBox_currentIndexChanged(self, index):
+        """
+        Private slot handling the selection of security key.
+
+        @param index index of the selected security key
+        @type int
+        """
+        self.lockButton.setChecked(False)
+        self.__manager.disconnectFromDevice()
+
+        securityKey = self.securityKeysComboBox.itemData(index)
+
+        self.lockButton.setEnabled(securityKey is not None)
+        self.pinButton.setEnabled(securityKey is not None)
+        self.showInfoButton.setEnabled(securityKey is not None)
+        self.resetButton.setEnabled(securityKey is not None)
+        self.loadPasskeysButton.setEnabled(securityKey is not None)
+
+        if securityKey is not None:
+            self.__manager.connectToDevice(securityKey)
+            hasPin = self.__manager.hasPin()
+            forcedPinChange = self.__manager.forcedPinChange()
+            if hasPin is True:
+                self.pinButton.setText(self.tr("Change PIN"))
+            elif hasPin is False:
+                self.pinButton.setText(self.tr("Set PIN"))
+            else:
+                self.pinButton.setEnabled(False)
+            if forcedPinChange or hasPin is False:
+                self.lockButton.setEnabled(False)
+                self.loadPasskeysButton.setEnabled(False)
+                msg = (
+                    self.tr("A PIN change is required.")
+                    if forcedPinChange
+                    else self.tr("You must set a PIN first.")
+                )
+                EricMessageBox.information(
+                    self,
+                    self.tr("FIDO2 Security Key Management"),
+                    msg,
+                )
+
+        self.passkeysList.clear()
+        self.on_passkeysList_itemSelectionChanged()
+
+    @pyqtSlot(bool)
+    def on_lockButton_toggled(self, checked):
+        """
+        Private slot to handle the toggling of the device locked status.
+
+        @param checked state of the lock/unlock button
+        @type bool
+        """
+        if checked:
+            # unlock the selected security key
+            pin = self.__getRequiredPin(self.tr("Unlock Security Key"))
+            if pin:
+                ok, msg = self.__manager.verifyPin(pin=pin)
+                if ok:
+                    self.lockButton.setIcon(EricPixmapCache.getIcon("unlocked"))
+                    self.__manager.unlockDevice(pin)
+                else:
+                    EricMessageBox.critical(
+                        self,
+                        self.tr("Unlock Security Key"),
+                        msg,
+                    )
+                    self.lockButton.setChecked(False)
+            else:
+                self.lockButton.setChecked(False)
+        else:
+            # lock the selected security key
+            self.lockButton.setIcon(EricPixmapCache.getIcon("locked"))
+            self.__manager.lockDevice()
+
+    @pyqtSlot()
+    def on_showInfoButton_clicked(self):
+        """
+        Slot documentation goes here.
+        """
+        # TODO: not implemented yet
+        pass
+
+    ############################################################################
+    ## methods related to PIN handling
+    ############################################################################
+
+    def __checkPinStatus(self, feature):
+        """
+        Private method to check the PIN status of the connected security key.
+
+        @param feature name of the feature requesting the PIN (defaults to None)
+        @type str (optional)
+        @return flag indicating a positive status
+        @rtype bool
+        """
+        feature = self.tr("This feature") if feature is None else f"'{feature}'"
+
+        hasPin = self.__manager.hasPin()
+        retries, powerCycle = self.__manager.getPinRetries()
+
+        if hasPin is None:
+            msg = self.tr("{0} is not supported by the selected security key.").format(
+                feature
+            )
+        elif not hasPin:
+            msg = self.tr("{0} requires having a PIN. Set a PIN first.").format(feature)
+        elif self.__manager.forcedPinChange():
+            msg = self.tr("The security key is locked. Change the PIN first.")
+        elif powerCycle:
+            msg = self.tr(
+                "The security key is locked because the wrong PIN was entered "
+                "too many times. To unlock it, remove and reinsert it."
+            )
+        elif retries == 0:
+            msg = self.tr(
+                "The security key is locked because the wrong PIN was entered too"
+                " many times. You will need to reset the security key."
+            )
+        else:
+            msg = ""
+
+        if msg:
+            EricMessageBox.critical(
+                self,
+                self.tr("FIDO2 Security Key Management"),
+                msg,
+            )
+            return False
+        else:
+            return True
+
+    def __getRequiredPin(self, feature=None):
+        """
+        Private method to check, if a pin has been set for the selected device, and
+        ask the user to enter it.
+
+        @param feature name of the feature requesting the PIN (defaults to None)
+        @type str (optional)
+        @return PIN of the selected security key or None in case of an issue
+        @rtype str or None
+        """
+        if not self.__checkPinStatus(feature=feature):
+            return None
+        else:
+            if self.__manager.isDeviceLocked():
+                retries = self.__manager.getPinRetries()[0]
+                title = self.tr("PIN required") if feature is None else feature
+                dlg = Fido2PinDialog(
+                    mode=Fido2PinDialogMode.GET,
+                    title=title,
+                    message=self.tr(
+                        "Enter the PIN to unlock the security key (%n attempt(s)"
+                        " remaining.",
+                        "",
+                        retries,
+                    ),
+                    minLength=self.__manager.getMinimumPinLength(),
+                    parent=self,
+                )
+                if dlg.exec() == QDialog.DialogCode.Accepted:
+                    return dlg.getPins()[0]
+                else:
+                    return None
+            else:
+                return ""
+
+    @pyqtSlot()
+    def __setPin(self):
+        """
+        Private slot to set a PIN for the selected security key.
+        """
+        # TODO: not implemented yet
+        pass
+
+    @pyqtSlot()
+    def __changePin(self):
+        """
+        Private slot to set a PIN for the selected security key.
+        """
+        # TODO: not implemented yet
+        pass
+
+    @pyqtSlot()
+    def on_pinButton_clicked(self):
+        """
+        Private slot to set or change the PIN for the selected security key.
+        """
+        # TODO: not implemented yet
+        if self.__manager.hasPin():
+            self.__changePin()
+        else:
+            self.__setPin()
+
+    ############################################################################
+    ## methods related to passkeys handling
+    ############################################################################
+
+    @pyqtSlot()
+    def __populatePasskeysList(self):
+        """
+        Private slot to populate the list of store passkeys of the selected security
+        key.
+        """
+        keyIndex = self.securityKeysComboBox.currentData()
+        if keyIndex is None:
+            return
+
+        pin = self.__getRequiredPin(feature=self.tr("Credential Management"))
+        if pin is None:
+            return
+
+        self.passkeysList.clear()
+
+        try:
+            with EricOverrideCursor():
+                passkeys, existingCount, remainingCount = self.__manager.getPasskeys(
+                    pin=pin
+                )
+        except (Fido2DeviceError, Fido2PinError) as err:
+            self.__handleError(
+                error=err,
+                title=self.tr("Load Passkeys"),
+                message=self.tr("The stored passkeys could not be loaded."),
+            )
+            return
+
+        self.existingCountLabel.setText(str(existingCount))
+        self.remainingCountLabel.setText(str(remainingCount))
+
+        for relyingParty in passkeys:
+            rpItem = QTreeWidgetItem(self.passkeysList, [relyingParty])
+            rpItem.setFirstColumnSpanned(True)
+            rpItem.setExpanded(True)
+            for passDict in passkeys[relyingParty]:
+                item = QTreeWidgetItem(
+                    rpItem,
+                    [
+                        "",
+                        passDict["credentialId"]["id"].hex(),
+                        passDict["displayName"],
+                        passDict["userName"],
+                    ],
+                )
+                item.setData(0, self.CredentialIdRole, passDict["credentialId"])
+                item.setData(0, self.UserIdRole, passDict["userId"])
+
+        self.passkeysList.sortItems(self.DisplayNameColumn, Qt.SortOrder.AscendingOrder)
+        self.passkeysList.sortItems(
+            self.RelyingPartyColumn, Qt.SortOrder.AscendingOrder
+        )
+
+    @pyqtSlot()
+    def on_loadPasskeysButton_clicked(self):
+        """
+        Slot documentation goes here.
+        """
+        self.__populatePasskeysList()
+
+    @pyqtSlot()
+    def on_passkeysList_itemSelectionChanged(self):
+        """
+        Slot documentation goes here.
+        """
+        enableButtons = (
+            len(self.passkeysList.selectedItems()) == 1
+            and self.passkeysList.selectedItems()[0].parent() is not None
+        )
+        self.editButton.setEnabled(enableButtons)
+        self.deleteButton.setEnabled(enableButtons)
+
+    @pyqtSlot()
+    def on_editButton_clicked(self):
+        """
+        Private slot to edit the selected passkey.
+        """
+        from .Fido2PasskeyEditDialog import Fido2PasskeyEditDialog
+
+        selectedItem = self.passkeysList.selectedItems()[0]
+        dlg = Fido2PasskeyEditDialog(
+            displayName=selectedItem.text(self.DisplayNameColumn),
+            userName=selectedItem.text(self.UserNameColumn),
+            relyingParty=selectedItem.parent().text(self.RelyingPartyColumn),
+            parent=self,
+        )
+        if dlg.exec() == QDialog.DialogCode.Accepted:
+            displayName, userName = dlg.getData()
+            if displayName != selectedItem.text(
+                self.DisplayNameColumn
+            ) or userName != selectedItem.text(self.UserNameColumn):
+                # only change on the security key, if there is really a change
+                pin = self.__getRequiredPin(feature=self.tr("Change User Info"))
+                try:
+                    self.__manager.changePasskeyUserInfo(
+                        pin=pin,
+                        credentialId=selectedItem.data(0, self.CredentialIdRole),
+                        userId=selectedItem.data(0, self.UserIdRole),
+                        userName=userName,
+                        displayName=displayName,
+                    )
+                except (Fido2DeviceError, Fido2PinError) as err:
+                    self.__handleError(
+                        error=err,
+                        title=self.tr("Change User Info"),
+                        message=self.tr("The user info could not be changed."),
+                    )
+                    return
+
+                selectedItem.setText(self.DisplayNameColumn, displayName)
+                selectedItem.setText(self.UserNameColumn, userName)
+
+    @pyqtSlot()
+    def on_deleteButton_clicked(self):
+        """
+        Private slot to delete the selected passkey.
+        """
+        selectedItem = self.passkeysList.selectedItems()[0]
+
+        ok = EricMessageBox.yesNo(
+            self,
+            self.tr("Delete Passkey"),
+            self.tr(
+                "<p>Shall the selected passkey really be deleted?</p>"
+                "<ul>"
+                "<li>Relying Party: {0}</li>"
+                "<li>Display Name: {1}</li>"
+                "<li>User Name: {2}</li>"
+                "</ul>"
+            ).format(
+                selectedItem.parent().text(self.RelyingPartyColumn),
+                selectedItem.text(self.DisplayNameColumn),
+                selectedItem.text(self.UserNameColumn),
+            ),
+        )
+        if ok:
+            pin = self.__getRequiredPin(feature=self.tr("Delete Passkey"))
+            try:
+                self.__manager.deletePasskey(
+                    pin=pin,
+                    credentialId=selectedItem.data(0, self.CredentialIdRole),
+                )
+            except (Fido2DeviceError, Fido2PinError) as err:
+                self.__handleError(
+                    error=err,
+                    title=self.tr("Delete Passkey"),
+                    message=self.tr("The passkey could not be deleted."),
+                )
+                return
+
+            rpItem = selectedItem.parent()
+            index = rpItem.indexOfChild(selectedItem)
+            rpItem.takeChild(index)
+            del selectedItem
+            if rpItem.childCount() == 0:
+                index = self.passkeysList.indexOfTopLevelItem(rpItem)
+                self.passkeysList.takeTopLevelItem(index)
+                del rpItem
+
+    ############################################################################
+    ## utility methods
+    ############################################################################
+
+    def __handleError(self, error, title, message):
+        """
+        Private method to handle an error reported by the manager.
+
+        @param error reference to the exception object
+        @type Exception
+        @param title tirle of the message box
+        @type str
+        @param message message to be shown
+        @type str
+        """
+        EricMessageBox.critical(
+            self,
+            title,
+            self.tr("<p>{0}</p><p>Reason: {1}</p>").format(message, str(error)),
+        )
+        if isinstance(error, Fido2DeviceError):
+            self.__populateDeviceSelector()

eric ide

mercurial