diff --git a/openconnect_sso/browser/ui/__init__.py b/openconnect_sso/browser/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/openconnect_sso/browser/ui/webauthdialog.ui b/openconnect_sso/browser/ui/webauthdialog.ui new file mode 100644 index 0000000..9bee3d3 --- /dev/null +++ b/openconnect_sso/browser/ui/webauthdialog.ui @@ -0,0 +1,166 @@ + + + WebAuthDialog + + + + 0 + 0 + 293 + 341 + + + + Dialog + + + + + 20 + 280 + 251 + 32 + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok|QDialogButtonBox::Retry + + + + + + 20 + 20 + 251 + 16 + + + + Heading + + + false + + + + + + 20 + 40 + 251 + 51 + + + + Description + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + true + + + + + + 20 + 100 + 251 + 161 + + + + + QLayout::SetDefaultConstraint + + + + + + + + true + + + + + 10 + 20 + 58 + 16 + + + + PIN + + + + + + 90 + 20 + 113 + 21 + + + + QLineEdit::Password + + + + + + 10 + 50 + 81 + 16 + + + + Confirm PIN + + + + + + 90 + 50 + 113 + 21 + + + + QLineEdit::Password + + + + + + 10 + 80 + 231 + 51 + + + + TextLabel + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + true + + + + + + + + + + diff --git a/openconnect_sso/browser/webauthdialog.py b/openconnect_sso/browser/webauthdialog.py new file mode 100644 index 0000000..5f60499 --- /dev/null +++ b/openconnect_sso/browser/webauthdialog.py @@ -0,0 +1,193 @@ +from PyQt6.QtCore import Qt, pyqtSlot, QObject +from PyQt6.QtWidgets import QDialog, QButtonGroup, QScrollArea, QWidget, QVBoxLayout, QDialogButtonBox, QSizePolicy, QRadioButton +from PyQt6.QtWebEngineCore import QWebEngineWebAuthUxRequest +from PyQt6.uic import loadUiType +from . import ui +from importlib import resources as rsrc + +WebAuthDialogUi, baseClass = loadUiType(rsrc.files(ui) / "webauthdialog.ui") + +class WebAuthUXDialog(baseClass): + def __init__(self, parent, request : QWebEngineWebAuthUxRequest): + super().__init__(parent) + self.uxRequest = request + self.ui = WebAuthDialogUi() + self.ui.setupUi(self) + self.buttonGroup = QButtonGroup(self) + self.buttonGroup.setExclusive(True) + self.scrollArea = QScrollArea(self) + self.selectAccountWidget = QWidget(self) + self.scrollArea.setWidget(self.selectAccountWidget) + self.scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.selectAccountWidget.resize(290, 150) + self.selectAccountLayout = QVBoxLayout(self.selectAccountWidget) + self.ui.m_mainVerticalLayout.addWidget(self.scrollArea) + self.selectAccountLayout.setAlignment(Qt.AlignmentFlag.AlignTop) + + self.updateDisplay() + self.ui.buttonBox.rejected.connect(self.onCancelRequest) + self.ui.buttonBox.accepted.connect(self.onAcceptRequest) + retry = self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Retry) + retry.clicked.connect(self.onRetry) + self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding) + + + def updateDisplay(self): + if self.uxRequest.state() == QWebEngineWebAuthUxRequest.WebAuthUxState.SelectAccount: + self.setupSelectAccountUI() + elif self.uxRequest.state() == QWebEngineWebAuthUxRequest.WebAuthUxState.CollectPin: + self.setupCollectPinUI() + elif self.uxRequest.state() == QWebEngineWebAuthUxRequest.WebAuthUxState.FinishTokenCollection: + self.setupFinishCollectTokenUI() + elif self.uxRequest.state() == QWebEngineWebAuthUxRequest.WebAuthUxState.RequestFailed: + self.setupErrorUI() + else: + pass + self.adjustSize() + + @pyqtSlot() + def onCancelRequest(self): + self.uxRequest.cancel() + + @pyqtSlot() + def onAcceptRequest(self): + if self.uxRequest.state() == QWebEngineWebAuthUxRequest.WebAuthUxState.SelectAccount: + if self.buttonGroup.checkedButton(): + self.uxRequest.setSelectedAccount(self.buttonGroup.checkedButton().text()) + elif self.uxRequest.state() == QWebEngineWebAuthUxRequest.WebAuthUxState.CollectPin: + self.uxRequest.setPin(self.ui.m_pinLineEdit.text()) + else: + pass + + @pyqtSlot() + def onRetry(self): + self.uxRequest.retry() + + def setupSelectAccountUI(self): + _tr = QObject.tr + self.ui.m_headingLabel.setText(_tr("Choose a Passkey")) + self.ui.m_description.setText(_tr("Which passkey do you want to use for ") + + self.uxRequest.relyingPartyId() + _tr("? ")) + self.ui.m_pinGroupBox.setVisible(False) + self.ui.m_mainVerticalLayout.removeWidget(self.ui.m_pinGroupBox) + self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Retry).setVisible(False) + self.clearSelectAccountButtons() + self.scrollArea.setVisible(True) + self.selectAccountWidget.resize(self.width(), self.height()) + userNames = self.uxRequest.userNames() + for name in iter(userNames): + radioButton = QRadioButton(name) + self.selectAccountLayout.addWidget(radioButton) + self.buttonGroup.addButton(radioButton) + self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Ok).setText(_tr("Ok")) + self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Ok).setVisible(True) + self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setVisible(True) + self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Retry).setVisible(False) + + def clearSelectAccountButtons(self): + buttons = self.buttonGroup.buttons() + for btn in iter(buttons): + self.selectAccountLayout.removeWidget(btn) + self.buttonGroup.removeButton(btn) + + def setupFinishCollectTokenUI(self): + _tr = QObject.tr + self.clearSelectAccountButtons() + self.ui.m_headingLabel.setText(_tr("Use your security key with ") + self.uxRequest.relyingPartyId()) + self.ui.m_description.setText(_tr("Touch your security key again to complete the request.")) + self.ui.m_pinGroupBox.setVisible(False) + self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Ok).setVisible(False) + self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Retry).setVisible(False) + self.scrollArea.setVisible(False) + + def setupCollectPinUI(self): + _tr = QObject.tr + self.clearSelectAccountButtons() + self.ui.m_mainVerticalLayout.addWidget(self.ui.m_pinGroupBox) + self.ui.m_pinGroupBox.setVisible(True) + self.ui.m_confirmPinLabel.setVisible(False) + self.ui.m_confirmPinLineEdit.setVisible(False) + self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Ok).setText(_tr("Next")) + self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Ok).setVisible(True) + self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setVisible(True) + self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Retry).setVisible(False) + self.scrollArea.setVisible(False) + + pinRequestInfo = self.uxRequest.pinRequest() + if pinRequestInfo.reason == QWebEngineWebAuthUxRequest.PinEntryReason.Challenge: + self.ui.m_headingLabel.setText(_tr("PIN Required")) + self.ui.m_description.setText(_tr("Enter the PIN for your security key")) + self.ui.m_confirmPinLabel.setVisible(False) + self.ui.m_confirmPinLineEdit.setVisible(False) + else: + if pinRequestInfo.reason == QWebEngineWebAuthUxRequest.PinEntryReason.Set: + self.ui.m_headingLabel.setText(_tr("New PIN Required")) + self.ui.m_description.setText(_tr("Set new PIN for your security key")) + else: + self.ui.m_headingLabel.setText(_tr("Change PIN Required")) + self.ui.m_description.setText(_tr("Change PIN for your security key")) + self.ui.m_confirmPinLabel.setVisible(True) + self.ui.m_confirmPinLineEdit.setVisible(True) + + errorDetails = "" + if pinRequestInfo.error == QWebEngineWebAuthUxRequest.PinEntryError.InternalUvLocked: + errorDetails = _tr("Internal User Verification Locked") + elif pinRequestInfo.error == QWebEngineWebAuthUxRequest.PinEntryError.WrongPin: + errorDetails = _tr("Wrong PIN") + elif pinRequestInfo.error == QWebEngineWebAuthUxRequest.PinEntryError.TooShort: + errorDetails = _tr("Too Short") + elif pinRequestInfo.error == QWebEngineWebAuthUxRequest.PinEntryError.InvalidCharacters: + errorDetails = _tr("Invalid Characters") + elif pinRequestInfo.error == QWebEngineWebAuthUxRequest.PinEntryError.SameAsCurrentPin: + errorDetails = _tr("Same as current PIN") + + if len(errorDetails) > 0: + errorDetails += _tr(" ") + str(pinRequestInfo.remainingAttempts) + _tr(" attempts remaining") + + self.ui.m_pinEntryErrorLabel.setText(errorDetails) + + def setupErrorUI(self): + _tr = QObject.tr + self.clearSelectAccountButtons() + errorDesc = "" + errorHeading = _tr("Something went wrong") + isVisibleRetry = False + if self.uxRequest.requestFailureReason() == QWebEngineWebAuthUxRequest.RequestFailureReason.Timeout: + errorDesc = _tr("Request Timeout") + elif self.uxRequest.requestFailureReason() == QWebEngineWebAuthUxRequest.RequestFailureReason.KeyNotRegistered: + errorDesc = _tr("Key not registered") + elif self.uxRequest.requestFailureReason() == QWebEngineWebAuthUxRequest.RequestFailureReason.KeyAlreadyRegistered: + errorDesc = _tr("You already registered this device. Try agin with device") + isVisibleRetry = True + elif self.uxRequest.requestFailureReason() == QWebEngineWebAuthUxRequest.RequestFailureReason.SoftPinBlock: + errorDesc = _tr("The security key is locked because the wrong PIN was entered too many times. To unlock it, remove and reinsert it.") + isVisibleRetry = True + elif self.uxRequest.requestFailureReason() == QWebEngineWebAuthUxRequest.RequestFailureReason.HardPinBlock: + errorDesc = _tr("The security key is locked because the wrong PIN was entered too many times. You'll need to reset the security key.") + elif self.uxRequest.requestFailureReason() == QWebEngineWebAuthUxRequest.RequestFailureReason.AuthenticatorRemovedDuringPinEntry: + errorDesc = _tr("Authenticator removed during verification. Please reinsert and try again") + elif self.uxRequest.requestFailureReason() == QWebEngineWebAuthUxRequest.RequestFailureReason.AuthenticatorMissingResidentKeys: + errorDesc = _tr("Authenticator doesn't have resident key support") + elif self.uxRequest.requestFailureReason() == QWebEngineWebAuthUxRequest.RequestFailureReason.AuthenticatorMissingLargeBlob: + errorDesc = _tr("Authenticator missing Large Blob support") + elif self.uxRequest.requestFailureReason() == QWebEngineWebAuthUxRequest.RequestFailureReason.NoCommonAlgorithms: + errorDesc = _tr("No common algorithms") + elif self.uxRequest.requestFailureReason() == QWebEngineWebAuthUxRequest.RequestFailureReason.StorageFull: + errorDesc = _tr("Storage Full") + elif self.uxRequest.requestFailureReason() == QWebEngineWebAuthUxRequest.RequestFailureReason.UserConsentDenied: + errorDesc = _tr("User consent denied") + elif self.uxRequest.requestFailureReason() == QWebEngineWebAuthUxRequest.RequestFailureReason.WinUserCancelled: + errorDesc = _tr("User Cancelled Request") + + self.ui.m_headingLabel.setText(errorHeading) + self.ui.m_description.setText(errorDesc) + self.ui.m_description.adjustSize() + self.ui.m_pinGroupBox.setVisible(False) + self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Ok).setVisble(False) + self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Retry).setVisible(isVisibleRetry) + if isVisibleRetry: + self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Retry).setFocus() + self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setVisible(True) + self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setText(_tr("Close")) + self.scrollArea.setVisible(False) \ No newline at end of file diff --git a/openconnect_sso/browser/webengine_process.py b/openconnect_sso/browser/webengine_process.py index cb49acc..1657356 100644 --- a/openconnect_sso/browser/webengine_process.py +++ b/openconnect_sso/browser/webengine_process.py @@ -11,11 +11,12 @@ from PyQt6.QtCore import QUrl, QTimer, pyqtSlot, Qt from PyQt6.QtNetwork import QNetworkCookie, QNetworkProxy -from PyQt6.QtWebEngineCore import QWebEngineScript, QWebEngineProfile, QWebEnginePage +from PyQt6.QtWebEngineCore import QWebEngineScript, QWebEngineProfile, QWebEnginePage, QWebEngineWebAuthUxRequest from PyQt6.QtWebEngineWidgets import QWebEngineView from PyQt6.QtWidgets import QApplication, QWidget, QSizePolicy, QVBoxLayout from openconnect_sso import config +from .webauthdialog import WebAuthUXDialog app = None @@ -151,6 +152,7 @@ def __init__(self, auto_fill_rules, on_update, profile): cookie_store = self.page().profile().cookieStore() cookie_store.cookieAdded.connect(self._on_cookie_added) self.page().loadFinished.connect(self._on_load_finished) + self.page().webAuthUxRequested.connect(self._on_webauth_requested) def createWindow(self, type): if type == QWebEnginePage.WebDialog: @@ -198,6 +200,22 @@ def _on_load_finished(self, success): self._on_update(Url(url)) + def _on_webauth_requested(self, request): + logger.debug("WebAuth UX requested") + self.webAuth = WebAuthUXDialog(self, request) + self.webAuth.setModal(False) + self.webAuth.setWindowFlags(self.webAuth.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint) + request.stateChanged.connect(self._on_webauth_statechanged) + self.webAuth.show() + + @pyqtSlot('QWebEngineWebAuthUxRequest::WebAuthUxState') + def _on_webauth_statechanged(self, state): + if state == QWebEngineWebAuthUxRequest.WebAuthUxState.Completed or state == QWebEngineWebAuthUxRequest.WebAuthUxState.Cancelled: + if self.webAuth is not None: + self.webAuth.close() + self.webAuth = None + else: + self.webAuth.updateDisplay() class WebPopupWindow(QWidget): def __init__(self, profile): diff --git a/pyproject.toml b/pyproject.toml index f349a97..27d8046 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,8 +33,8 @@ structlog = ">=20.1" toml = "^0.10" setuptools = ">40.0" PySocks = "^1.7.1" -PyQt6 = "^6.3.0" -PyQt6-WebEngine = "^6.3.0" +PyQt6 = "^6.7.0" +PyQt6-WebEngine = "^6.7.0" pyotp = "^2.7.0" [tool.poetry.dev-dependencies]