src/eric7/WebBrowser/WebAuth/Fido2ManagementDialog.py

branch
eric7
changeset 10854
30c45bd597e6
child 10856
b19cefceca15
equal deleted inserted replaced
10853:1f651b204780 10854:30c45bd597e6
1 # -*- coding: utf-8 -*-
2 # Copyright (c) 2024 Detlev Offenbach <detlev@die-offenbachs.de>
3 #
4
5 """
6 Module implementing a dialog to manage FIDO2 security keys.
7 """
8
9 from PyQt6.QtCore import Qt, QTimer, pyqtSlot
10 from PyQt6.QtWidgets import QDialog, QTreeWidgetItem
11
12 from eric7.EricGui import EricPixmapCache
13 from eric7.EricGui.EricOverrideCursor import EricOverrideCursor
14 from eric7.EricWidgets import EricMessageBox
15
16 from .Fido2Management import Fido2DeviceError, Fido2Management, Fido2PinError
17 from .Fido2PinDialog import Fido2PinDialog, Fido2PinDialogMode
18 from .Ui_Fido2ManagementDialog import Ui_Fido2ManagementDialog
19
20
21 class Fido2ManagementDialog(QDialog, Ui_Fido2ManagementDialog):
22 """
23 Class implementing a dialog to manage FIDO2 security keys.
24 """
25
26 CredentialIdRole = Qt.ItemDataRole.UserRole
27 UserIdRole = Qt.ItemDataRole.UserRole + 1
28
29 RelyingPartyColumn = 0
30 CredentialIdColumn = 1
31 DisplayNameColumn = 2
32 UserNameColumn = 3
33
34 def __init__(self, parent=None):
35 """
36 Constructor
37
38 @param parent reference to the parent widget (defaults to None)
39 @type QWidget (optional)
40 """
41 super().__init__(parent)
42 self.setupUi(self)
43
44 self.reloadButton.setIcon(EricPixmapCache.getIcon("reload"))
45 self.lockButton.setIcon(EricPixmapCache.getIcon("locked"))
46
47 self.reloadButton.clicked.connect(self.__populateDeviceSelector)
48
49 self.__manager = Fido2Management(parent=self)
50 ##self.__manager.deviceConnected.connect(self.__deviceConnected)
51 ##self.__manager.deviceDisconnected.connect(self.__deviceDisconnected)
52
53 QTimer.singleShot(0, self.__populateDeviceSelector)
54
55 ############################################################################
56 ## methods related to device handling
57 ############################################################################
58
59 @pyqtSlot()
60 def __populateDeviceSelector(self):
61 """
62 Private slot to populate the device selector combo box.
63 """
64 self.__manager.disconnectFromDevice()
65 self.securityKeysComboBox.clear()
66 self.reloadButton.setEnabled(False)
67
68 securityKeys = self.__manager.getDevices()
69
70 if len(securityKeys) != 1:
71 self.securityKeysComboBox.addItem("")
72 for securityKey in securityKeys:
73 self.securityKeysComboBox.addItem(
74 self.tr("{0} ({1})").format(
75 securityKey.product_name, securityKey.descriptor.path
76 ),
77 securityKey,
78 )
79
80 self.reloadButton.setEnabled(True)
81
82 if len(securityKeys) == 0:
83 EricMessageBox.information(
84 self,
85 self.tr("FIDO2 Security Key Management"),
86 self.tr(
87 """No security key could be detected. Attach a key and press"""
88 """ the "Reload" button."""
89 ),
90 )
91
92 @pyqtSlot(int)
93 def on_securityKeysComboBox_currentIndexChanged(self, index):
94 """
95 Private slot handling the selection of security key.
96
97 @param index index of the selected security key
98 @type int
99 """
100 self.lockButton.setChecked(False)
101 self.__manager.disconnectFromDevice()
102
103 securityKey = self.securityKeysComboBox.itemData(index)
104
105 self.lockButton.setEnabled(securityKey is not None)
106 self.pinButton.setEnabled(securityKey is not None)
107 self.showInfoButton.setEnabled(securityKey is not None)
108 self.resetButton.setEnabled(securityKey is not None)
109 self.loadPasskeysButton.setEnabled(securityKey is not None)
110
111 if securityKey is not None:
112 self.__manager.connectToDevice(securityKey)
113 hasPin = self.__manager.hasPin()
114 forcedPinChange = self.__manager.forcedPinChange()
115 if hasPin is True:
116 self.pinButton.setText(self.tr("Change PIN"))
117 elif hasPin is False:
118 self.pinButton.setText(self.tr("Set PIN"))
119 else:
120 self.pinButton.setEnabled(False)
121 if forcedPinChange or hasPin is False:
122 self.lockButton.setEnabled(False)
123 self.loadPasskeysButton.setEnabled(False)
124 msg = (
125 self.tr("A PIN change is required.")
126 if forcedPinChange
127 else self.tr("You must set a PIN first.")
128 )
129 EricMessageBox.information(
130 self,
131 self.tr("FIDO2 Security Key Management"),
132 msg,
133 )
134
135 self.passkeysList.clear()
136 self.on_passkeysList_itemSelectionChanged()
137
138 @pyqtSlot(bool)
139 def on_lockButton_toggled(self, checked):
140 """
141 Private slot to handle the toggling of the device locked status.
142
143 @param checked state of the lock/unlock button
144 @type bool
145 """
146 if checked:
147 # unlock the selected security key
148 pin = self.__getRequiredPin(self.tr("Unlock Security Key"))
149 if pin:
150 ok, msg = self.__manager.verifyPin(pin=pin)
151 if ok:
152 self.lockButton.setIcon(EricPixmapCache.getIcon("unlocked"))
153 self.__manager.unlockDevice(pin)
154 else:
155 EricMessageBox.critical(
156 self,
157 self.tr("Unlock Security Key"),
158 msg,
159 )
160 self.lockButton.setChecked(False)
161 else:
162 self.lockButton.setChecked(False)
163 else:
164 # lock the selected security key
165 self.lockButton.setIcon(EricPixmapCache.getIcon("locked"))
166 self.__manager.lockDevice()
167
168 @pyqtSlot()
169 def on_showInfoButton_clicked(self):
170 """
171 Slot documentation goes here.
172 """
173 # TODO: not implemented yet
174 pass
175
176 ############################################################################
177 ## methods related to PIN handling
178 ############################################################################
179
180 def __checkPinStatus(self, feature):
181 """
182 Private method to check the PIN status of the connected security key.
183
184 @param feature name of the feature requesting the PIN (defaults to None)
185 @type str (optional)
186 @return flag indicating a positive status
187 @rtype bool
188 """
189 feature = self.tr("This feature") if feature is None else f"'{feature}'"
190
191 hasPin = self.__manager.hasPin()
192 retries, powerCycle = self.__manager.getPinRetries()
193
194 if hasPin is None:
195 msg = self.tr("{0} is not supported by the selected security key.").format(
196 feature
197 )
198 elif not hasPin:
199 msg = self.tr("{0} requires having a PIN. Set a PIN first.").format(feature)
200 elif self.__manager.forcedPinChange():
201 msg = self.tr("The security key is locked. Change the PIN first.")
202 elif powerCycle:
203 msg = self.tr(
204 "The security key is locked because the wrong PIN was entered "
205 "too many times. To unlock it, remove and reinsert it."
206 )
207 elif retries == 0:
208 msg = self.tr(
209 "The security key is locked because the wrong PIN was entered too"
210 " many times. You will need to reset the security key."
211 )
212 else:
213 msg = ""
214
215 if msg:
216 EricMessageBox.critical(
217 self,
218 self.tr("FIDO2 Security Key Management"),
219 msg,
220 )
221 return False
222 else:
223 return True
224
225 def __getRequiredPin(self, feature=None):
226 """
227 Private method to check, if a pin has been set for the selected device, and
228 ask the user to enter it.
229
230 @param feature name of the feature requesting the PIN (defaults to None)
231 @type str (optional)
232 @return PIN of the selected security key or None in case of an issue
233 @rtype str or None
234 """
235 if not self.__checkPinStatus(feature=feature):
236 return None
237 else:
238 if self.__manager.isDeviceLocked():
239 retries = self.__manager.getPinRetries()[0]
240 title = self.tr("PIN required") if feature is None else feature
241 dlg = Fido2PinDialog(
242 mode=Fido2PinDialogMode.GET,
243 title=title,
244 message=self.tr(
245 "Enter the PIN to unlock the security key (%n attempt(s)"
246 " remaining.",
247 "",
248 retries,
249 ),
250 minLength=self.__manager.getMinimumPinLength(),
251 parent=self,
252 )
253 if dlg.exec() == QDialog.DialogCode.Accepted:
254 return dlg.getPins()[0]
255 else:
256 return None
257 else:
258 return ""
259
260 @pyqtSlot()
261 def __setPin(self):
262 """
263 Private slot to set a PIN for the selected security key.
264 """
265 # TODO: not implemented yet
266 pass
267
268 @pyqtSlot()
269 def __changePin(self):
270 """
271 Private slot to set a PIN for the selected security key.
272 """
273 # TODO: not implemented yet
274 pass
275
276 @pyqtSlot()
277 def on_pinButton_clicked(self):
278 """
279 Private slot to set or change the PIN for the selected security key.
280 """
281 # TODO: not implemented yet
282 if self.__manager.hasPin():
283 self.__changePin()
284 else:
285 self.__setPin()
286
287 ############################################################################
288 ## methods related to passkeys handling
289 ############################################################################
290
291 @pyqtSlot()
292 def __populatePasskeysList(self):
293 """
294 Private slot to populate the list of store passkeys of the selected security
295 key.
296 """
297 keyIndex = self.securityKeysComboBox.currentData()
298 if keyIndex is None:
299 return
300
301 pin = self.__getRequiredPin(feature=self.tr("Credential Management"))
302 if pin is None:
303 return
304
305 self.passkeysList.clear()
306
307 try:
308 with EricOverrideCursor():
309 passkeys, existingCount, remainingCount = self.__manager.getPasskeys(
310 pin=pin
311 )
312 except (Fido2DeviceError, Fido2PinError) as err:
313 self.__handleError(
314 error=err,
315 title=self.tr("Load Passkeys"),
316 message=self.tr("The stored passkeys could not be loaded."),
317 )
318 return
319
320 self.existingCountLabel.setText(str(existingCount))
321 self.remainingCountLabel.setText(str(remainingCount))
322
323 for relyingParty in passkeys:
324 rpItem = QTreeWidgetItem(self.passkeysList, [relyingParty])
325 rpItem.setFirstColumnSpanned(True)
326 rpItem.setExpanded(True)
327 for passDict in passkeys[relyingParty]:
328 item = QTreeWidgetItem(
329 rpItem,
330 [
331 "",
332 passDict["credentialId"]["id"].hex(),
333 passDict["displayName"],
334 passDict["userName"],
335 ],
336 )
337 item.setData(0, self.CredentialIdRole, passDict["credentialId"])
338 item.setData(0, self.UserIdRole, passDict["userId"])
339
340 self.passkeysList.sortItems(self.DisplayNameColumn, Qt.SortOrder.AscendingOrder)
341 self.passkeysList.sortItems(
342 self.RelyingPartyColumn, Qt.SortOrder.AscendingOrder
343 )
344
345 @pyqtSlot()
346 def on_loadPasskeysButton_clicked(self):
347 """
348 Slot documentation goes here.
349 """
350 self.__populatePasskeysList()
351
352 @pyqtSlot()
353 def on_passkeysList_itemSelectionChanged(self):
354 """
355 Slot documentation goes here.
356 """
357 enableButtons = (
358 len(self.passkeysList.selectedItems()) == 1
359 and self.passkeysList.selectedItems()[0].parent() is not None
360 )
361 self.editButton.setEnabled(enableButtons)
362 self.deleteButton.setEnabled(enableButtons)
363
364 @pyqtSlot()
365 def on_editButton_clicked(self):
366 """
367 Private slot to edit the selected passkey.
368 """
369 from .Fido2PasskeyEditDialog import Fido2PasskeyEditDialog
370
371 selectedItem = self.passkeysList.selectedItems()[0]
372 dlg = Fido2PasskeyEditDialog(
373 displayName=selectedItem.text(self.DisplayNameColumn),
374 userName=selectedItem.text(self.UserNameColumn),
375 relyingParty=selectedItem.parent().text(self.RelyingPartyColumn),
376 parent=self,
377 )
378 if dlg.exec() == QDialog.DialogCode.Accepted:
379 displayName, userName = dlg.getData()
380 if displayName != selectedItem.text(
381 self.DisplayNameColumn
382 ) or userName != selectedItem.text(self.UserNameColumn):
383 # only change on the security key, if there is really a change
384 pin = self.__getRequiredPin(feature=self.tr("Change User Info"))
385 try:
386 self.__manager.changePasskeyUserInfo(
387 pin=pin,
388 credentialId=selectedItem.data(0, self.CredentialIdRole),
389 userId=selectedItem.data(0, self.UserIdRole),
390 userName=userName,
391 displayName=displayName,
392 )
393 except (Fido2DeviceError, Fido2PinError) as err:
394 self.__handleError(
395 error=err,
396 title=self.tr("Change User Info"),
397 message=self.tr("The user info could not be changed."),
398 )
399 return
400
401 selectedItem.setText(self.DisplayNameColumn, displayName)
402 selectedItem.setText(self.UserNameColumn, userName)
403
404 @pyqtSlot()
405 def on_deleteButton_clicked(self):
406 """
407 Private slot to delete the selected passkey.
408 """
409 selectedItem = self.passkeysList.selectedItems()[0]
410
411 ok = EricMessageBox.yesNo(
412 self,
413 self.tr("Delete Passkey"),
414 self.tr(
415 "<p>Shall the selected passkey really be deleted?</p>"
416 "<ul>"
417 "<li>Relying Party: {0}</li>"
418 "<li>Display Name: {1}</li>"
419 "<li>User Name: {2}</li>"
420 "</ul>"
421 ).format(
422 selectedItem.parent().text(self.RelyingPartyColumn),
423 selectedItem.text(self.DisplayNameColumn),
424 selectedItem.text(self.UserNameColumn),
425 ),
426 )
427 if ok:
428 pin = self.__getRequiredPin(feature=self.tr("Delete Passkey"))
429 try:
430 self.__manager.deletePasskey(
431 pin=pin,
432 credentialId=selectedItem.data(0, self.CredentialIdRole),
433 )
434 except (Fido2DeviceError, Fido2PinError) as err:
435 self.__handleError(
436 error=err,
437 title=self.tr("Delete Passkey"),
438 message=self.tr("The passkey could not be deleted."),
439 )
440 return
441
442 rpItem = selectedItem.parent()
443 index = rpItem.indexOfChild(selectedItem)
444 rpItem.takeChild(index)
445 del selectedItem
446 if rpItem.childCount() == 0:
447 index = self.passkeysList.indexOfTopLevelItem(rpItem)
448 self.passkeysList.takeTopLevelItem(index)
449 del rpItem
450
451 ############################################################################
452 ## utility methods
453 ############################################################################
454
455 def __handleError(self, error, title, message):
456 """
457 Private method to handle an error reported by the manager.
458
459 @param error reference to the exception object
460 @type Exception
461 @param title tirle of the message box
462 @type str
463 @param message message to be shown
464 @type str
465 """
466 EricMessageBox.critical(
467 self,
468 title,
469 self.tr("<p>{0}</p><p>Reason: {1}</p>").format(message, str(error)),
470 )
471 if isinstance(error, Fido2DeviceError):
472 self.__populateDeviceSelector()

eric ide

mercurial