From 7465a6551a7a0f8331237dc2f1bd27c1e9d7c306 Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Wed, 22 Nov 2017 11:59:07 +0100 Subject: [PATCH] Setup the authentication stuff for LegacyUM3 CL-541 --- .../NetworkedPrinterOutputDevice.py | 39 ++- cura/PrinterOutputDevice.py | 2 + .../LegacyUM3OutputDevice.py | 249 +++++++++++++++++- 3 files changed, 284 insertions(+), 6 deletions(-) diff --git a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py index d2886328de..b9bd27c129 100644 --- a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py +++ b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py @@ -8,9 +8,19 @@ from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QTimer, py from time import time from typing import Callable, Any +from enum import IntEnum + + +class AuthState(IntEnum): + NotAuthenticated = 1 + AuthenticationRequested = 2 + Authenticated = 3 + AuthenticationDenied = 4 + AuthenticationReceived = 5 class NetworkedPrinterOutputDevice(PrinterOutputDevice): + authenticationStateChanged = pyqtSignal() def __init__(self, device_id, address: str, properties, parent = None): super().__init__(device_id = device_id, parent = parent) self._manager = None @@ -27,6 +37,16 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): self._user_agent = "%s/%s " % (Application.getInstance().getApplicationName(), Application.getInstance().getVersion()) self._onFinishedCallbacks = {} + self._authentication_state = AuthState.NotAuthenticated + + def setAuthenticationState(self, authentication_state): + if self._authentication_state != authentication_state: + self._authentication_state = authentication_state + self.authenticationStateChanged.emit() + + @pyqtProperty(int, notify=authenticationStateChanged) + def authenticationState(self): + return self._authentication_state def _update(self): if self._last_response_time: @@ -81,23 +101,30 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): self._last_request_time = time() pass - def _post(self, target: str, data: str, onFinished: Callable[[Any, QNetworkReply], None], onProgress: Callable): + def _post(self, target: str, data: str, onFinished: Callable[[Any, QNetworkReply], None], onProgress: Callable = None): if self._manager is None: self._createNetworkManager() + request = self._createEmptyRequest(target) self._last_request_time = time() - pass + reply = self._manager.post(request, data) + if onProgress is not None: + reply.uploadProgress.connect(onProgress) + self._onFinishedCallbacks[reply.url().toString() + str(reply.operation())] = onFinished + + def _onAuthenticationRequired(self, reply, authenticator): + Logger.log("w", "Request to {url} required authentication, which was not implemented".format(url = reply.url().toString())) def _createNetworkManager(self): Logger.log("d", "Creating network manager") if self._manager: self._manager.finished.disconnect(self.__handleOnFinished) #self._manager.networkAccessibleChanged.disconnect(self._onNetworkAccesibleChanged) - #self._manager.authenticationRequired.disconnect(self._onAuthenticationRequired) + self._manager.authenticationRequired.disconnect(self._onAuthenticationRequired) self._manager = QNetworkAccessManager() self._manager.finished.connect(self.__handleOnFinished) self._last_manager_create_time = time() - #self._manager.authenticationRequired.connect(self._onAuthenticationRequired) + self._manager.authenticationRequired.connect(self._onAuthenticationRequired) #self._manager.networkAccessibleChanged.connect(self._onNetworkAccesibleChanged) # for debug purposes def __handleOnFinished(self, reply: QNetworkReply): @@ -107,7 +134,9 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): self._last_response_time = time() - self.setConnectionState(ConnectionState.connected) + if self._connection_state == ConnectionState.connecting: + self.setConnectionState(ConnectionState.connected) + try: self._onFinishedCallbacks[reply.url().toString() + str(reply.operation())](reply) except Exception: diff --git a/cura/PrinterOutputDevice.py b/cura/PrinterOutputDevice.py index 56ac318f20..a170037311 100644 --- a/cura/PrinterOutputDevice.py +++ b/cura/PrinterOutputDevice.py @@ -135,11 +135,13 @@ class PrinterOutputDevice(QObject, OutputDevice): ## Attempt to establish connection def connect(self): + self.setConnectionState(ConnectionState.connecting) self._update_timer.start() ## Attempt to close the connection def close(self): self._update_timer.stop() + self.setConnectionState(ConnectionState.closed) ## Ensure that close gets called when object is destroyed def __del__(self): diff --git a/plugins/UM3NetworkPrinting/LegacyUM3OutputDevice.py b/plugins/UM3NetworkPrinting/LegacyUM3OutputDevice.py index 60409ec729..cb9959ec69 100644 --- a/plugins/UM3NetworkPrinting/LegacyUM3OutputDevice.py +++ b/plugins/UM3NetworkPrinting/LegacyUM3OutputDevice.py @@ -1,28 +1,256 @@ -from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice +from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel from cura.PrinterOutput.PrintJobOutputModel import PrintJobOutputModel from cura.PrinterOutput.MaterialOutputModel import MaterialOutputModel from UM.Logger import Logger from UM.Settings.ContainerRegistry import ContainerRegistry +from UM.Application import Application +from UM.i18n import i18nCatalog +from UM.Message import Message from PyQt5.QtNetwork import QNetworkRequest +from PyQt5.QtCore import QTimer import json +import os # To get the username + +i18n_catalog = i18nCatalog("cura") +## This is the output device for the "Legacy" API of the UM3. All firmware before 4.0.1 uses this API. +# Everything after that firmware uses the ClusterUM3Output. +# The Legacy output device can only have one printer (whereas the cluster can have 0 to n). +# +# Authentication is done in a number of steps; +# 1. Request an id / key pair by sending the application & user name. (state = authRequested) +# 2. Machine sends this back and will display an approve / deny message on screen. (state = AuthReceived) +# 3. OutputDevice will poll if the button was pressed. +# 4. At this point the machine either has the state Authenticated or AuthenticationDenied. +# 5. As a final step, we verify the authentication, as this forces the QT manager to setup the authenticator. class LegacyUM3OutputDevice(NetworkedPrinterOutputDevice): def __init__(self, device_id, address: str, properties, parent = None): super().__init__(device_id = device_id, address = address, properties = properties, parent = parent) self._api_prefix = "/api/v1/" self._number_of_extruders = 2 + self._authentication_id = None + self._authentication_key = None + + self._authentication_counter = 0 + self._max_authentication_counter = 5 * 60 # Number of attempts before authentication timed out (5 min) + + self._authentication_timer = QTimer() + self._authentication_timer.setInterval(1000) # TODO; Add preference for update interval + self._authentication_timer.setSingleShot(False) + + self._authentication_timer.timeout.connect(self._onAuthenticationTimer) + + # The messages are created when connect is called the first time. + # This ensures that the messages are only created for devices that actually want to connect. + self._authentication_requested_message = None + self._authentication_failed_message = None + self._not_authenticated_message = None + + def _setupMessages(self): + self._authentication_requested_message = Message(i18n_catalog.i18nc("@info:status", + "Access to the printer requested. Please approve the request on the printer"), + lifetime=0, dismissable=False, progress=0, + title=i18n_catalog.i18nc("@info:title", + "Authentication status")) + + self._authentication_failed_message = Message(i18n_catalog.i18nc("@info:status", ""), + title=i18n_catalog.i18nc("@info:title", "Authentication Status")) + self._authentication_failed_message.addAction("Retry", i18n_catalog.i18nc("@action:button", "Retry"), None, + i18n_catalog.i18nc("@info:tooltip", "Re-send the access request")) + self._authentication_failed_message.actionTriggered.connect(self._requestAuthentication) + self._authentication_succeeded_message = Message( + i18n_catalog.i18nc("@info:status", "Access to the printer accepted"), + title=i18n_catalog.i18nc("@info:title", "Authentication Status")) + + self._not_authenticated_message = Message( + i18n_catalog.i18nc("@info:status", "No access to print with this printer. Unable to send print job."), + title=i18n_catalog.i18nc("@info:title", "Authentication Status")) + self._not_authenticated_message.addAction("Request", i18n_catalog.i18nc("@action:button", "Request Access"), + None, i18n_catalog.i18nc("@info:tooltip", + "Send access request to the printer")) + self._not_authenticated_message.actionTriggered.connect(self._requestAuthentication) + + def connect(self): + super().connect() + self._setupMessages() + global_container = Application.getInstance().getGlobalContainerStack() + if global_container: + self._authentication_id = global_container.getMetaDataEntry("network_authentication_id", None) + self._authentication_key = global_container.getMetaDataEntry("network_authentication_key", None) + + def close(self): + super().close() + if self._authentication_requested_message: + self._authentication_requested_message.hide() + if self._authentication_failed_message: + self._authentication_failed_message.hide() + if self._authentication_succeeded_message: + self._authentication_succeeded_message.hide() + + self._authentication_timer.stop() + + ## Send all material profiles to the printer. + def sendMaterialProfiles(self): + # TODO + pass + def _update(self): if not super()._update(): return + if self._authentication_state == AuthState.NotAuthenticated: + if self._authentication_id is None and self._authentication_key is None: + # This machine doesn't have any authentication, so request it. + self._requestAuthentication() + elif self._authentication_id is not None and self._authentication_key is not None: + # We have authentication info, but we haven't checked it out yet. Do so now. + self._verifyAuthentication() + elif self._authentication_state == AuthState.AuthenticationReceived: + # We have an authentication, but it's not confirmed yet. + self._checkAuthentication() + + # We don't need authentication for requesting info, so we can go right ahead with requesting this. self._get("printer", onFinished=self._onGetPrinterDataFinished) self._get("print_job", onFinished=self._onGetPrintJobFinished) + def _resetAuthenticationRequestedMessage(self): + if self._authentication_requested_message: + self._authentication_requested_message.hide() + self._authentication_timer.stop() + self._authentication_counter = 0 + + def _onAuthenticationTimer(self): + self._authentication_counter += 1 + self._authentication_requested_message.setProgress( + self._authentication_counter / self._max_authentication_counter * 100) + if self._authentication_counter > self._max_authentication_counter: + self._authentication_timer.stop() + Logger.log("i", "Authentication timer ended. Setting authentication to denied for printer: %s" % self._key) + self.setAuthenticationState(AuthState.AuthenticationDenied) + self._resetAuthenticationRequestedMessage() + self._authentication_failed_message.show() + + def _verifyAuthentication(self): + Logger.log("d", "Attempting to verify authentication") + # This will ensure that the "_onAuthenticationRequired" is triggered, which will setup the authenticator. + self._get("auth/verify", onFinished=self._onVerifyAuthenticationCompleted) + + def _onVerifyAuthenticationCompleted(self, reply): + status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) + if status_code == 401: + # Something went wrong; We somehow tried to verify authentication without having one. + Logger.log("d", "Attempted to verify auth without having one.") + self._authentication_id = None + self._authentication_key = None + self.setAuthenticationState(AuthState.NotAuthenticated) + elif status_code == 403: + Logger.log("d", + "While trying to verify the authentication state, we got a forbidden response. Our own auth state was %s", + self._authentication_state) + self.setAuthenticationState(AuthState.AuthenticationDenied) + self._authentication_failed_message.show() + elif status_code == 200: + self.setAuthenticationState(AuthState.Authenticated) + # Now we know for sure that we are authenticated, send the material profiles to the machine. + self.sendMaterialProfiles() + + def _checkAuthentication(self): + Logger.log("d", "Checking if authentication is correct for id %s and key %s", self._authentication_id, self._getSafeAuthKey()) + self._get("auth/check/" + str(self._authentication_id), onFinished=self._onCheckAuthenticationFinished) + + def _onCheckAuthenticationFinished(self, reply): + if str(self._authentication_id) not in reply.url().toString(): + Logger.log("w", "Got an old id response.") + # Got response for old authentication ID. + return + try: + data = json.loads(bytes(reply.readAll()).decode("utf-8")) + except json.decoder.JSONDecodeError: + Logger.log("w", "Received an invalid authentication check from printer: Not valid JSON.") + return + + if data.get("message", "") == "authorized": + Logger.log("i", "Authentication was approved") + self.setAuthenticationState(AuthState.Authenticated) + self._saveAuthentication() + + # Double check that everything went well. + self._verifyAuthentication() + + # Notify the user. + self._resetAuthenticationRequestedMessage() + self._authentication_succeeded_message.show() + elif data.get("message", "") == "unauthorized": + Logger.log("i", "Authentication was denied.") + self.setAuthenticationState(AuthState.AuthenticationDenied) + self._authentication_failed_message.show() + + def _saveAuthentication(self): + global_container_stack = Application.getInstance().getGlobalContainerStack() + if global_container_stack: + if "network_authentication_key" in global_container_stack.getMetaData(): + global_container_stack.setMetaDataEntry("network_authentication_key", self._authentication_key) + else: + global_container_stack.addMetaDataEntry("network_authentication_key", self._authentication_key) + + if "network_authentication_id" in global_container_stack.getMetaData(): + global_container_stack.setMetaDataEntry("network_authentication_id", self._authentication_id) + else: + global_container_stack.addMetaDataEntry("network_authentication_id", self._authentication_id) + + # Force save so we are sure the data is not lost. + Application.getInstance().saveStack(global_container_stack) + Logger.log("i", "Authentication succeeded for id %s and key %s", self._authentication_id, + self._getSafeAuthKey()) + else: + Logger.log("e", "Unable to save authentication for id %s and key %s", self._authentication_id, + self._getSafeAuthKey()) + + def _onRequestAuthenticationFinished(self, reply): + try: + data = json.loads(bytes(reply.readAll()).decode("utf-8")) + except json.decoder.JSONDecodeError: + Logger.log("w", "Received an invalid authentication request reply from printer: Not valid JSON.") + self.setAuthenticationState(AuthState.NotAuthenticated) + return + + self.setAuthenticationState(AuthState.AuthenticationReceived) + self._authentication_id = data["id"] + self._authentication_key = data["key"] + Logger.log("i", "Got a new authentication ID (%s) and KEY (%s). Waiting for authorization.", + self._authentication_id, self._getSafeAuthKey()) + + def _requestAuthentication(self): + self._authentication_requested_message.show() + self._authentication_timer.start() + + # Reset any previous authentication info. If this isn't done, the "Retry" action on the failed message might + # give issues. + self._authentication_key = None + self._authentication_id = None + + self._post("auth/request", + json.dumps({"application": "Cura-" + Application.getInstance().getVersion(), + "user": self._getUserName()}).encode(), + onFinished=self._onRequestAuthenticationFinished) + + self.setAuthenticationState(AuthState.AuthenticationRequested) + + def _onAuthenticationRequired(self, reply, authenticator): + if self._authentication_id is not None and self._authentication_key is not None: + Logger.log("d", + "Authentication was required for printer: %s. Setting up authenticator with ID %s and key %s", + self._id, self._authentication_id, self._getSafeAuthKey()) + authenticator.setUser(self._authentication_id) + authenticator.setPassword(self._authentication_key) + else: + Logger.log("d", "No authentication is available to use for %s, but we did got a request for it.", self._key) + def _onGetPrintJobFinished(self, reply): status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) @@ -104,3 +332,22 @@ class LegacyUM3OutputDevice(NetworkedPrinterOutputDevice): else: Logger.log("w", "Got status code {status_code} while trying to get printer data".format(status_code = status_code)) + + ## Convenience function to "blur" out all but the last 5 characters of the auth key. + # This can be used to debug print the key, without it compromising the security. + def _getSafeAuthKey(self): + if self._authentication_key is not None: + result = self._authentication_key[-5:] + result = "********" + result + return result + + return self._authentication_key + + ## Convenience function to get the username from the OS. + # The code was copied from the getpass module, as we try to use as little dependencies as possible. + def _getUserName(self): + for name in ("LOGNAME", "USER", "LNAME", "USERNAME"): + user = os.environ.get(name) + if user: + return user + return "Unknown User" # Couldn't find out username. \ No newline at end of file