diff --git a/cura/API/Account.py b/cura/API/Account.py index a04f97ef1c..8a8b708cfa 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -57,7 +57,6 @@ class Account(QObject): def initialize(self) -> None: self._authorization_service.initialize(self._application.getPreferences()) - self._authorization_service.onAuthStateChanged.connect(self._onLoginStateChanged) self._authorization_service.onAuthenticationError.connect(self._onLoginStateChanged) self._authorization_service.loadAuthDataFromPreferences() diff --git a/cura/OAuth2/AuthorizationHelpers.py b/cura/OAuth2/AuthorizationHelpers.py index b4bf4af83e..314b5631f8 100644 --- a/cura/OAuth2/AuthorizationHelpers.py +++ b/cura/OAuth2/AuthorizationHelpers.py @@ -1,18 +1,20 @@ # Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. - -from base64 import b64encode -from hashlib import sha512 +from datetime import datetime import json import random -import requests +from hashlib import sha512 +from base64 import b64encode from typing import Optional +import requests + from UM.i18n import i18nCatalog from UM.Logger import Logger -from cura.OAuth2.Models import AuthenticationResponse, UserProfile, OAuth2Settings +from cura.OAuth2.Models import AuthenticationResponse, UserProfile, OAuth2Settings catalog = i18nCatalog("cura") +TOKEN_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S" ## Class containing several helpers to deal with the authorization flow. class AuthorizationHelpers: @@ -72,12 +74,13 @@ class AuthorizationHelpers: if token_response.status_code not in (200, 201): return AuthenticationResponse(success = False, err_message = token_data["error_description"]) - return AuthenticationResponse(success = True, - token_type = token_data["token_type"], - access_token = token_data["access_token"], - refresh_token = token_data["refresh_token"], - expires_in = token_data["expires_in"], - scope = token_data["scope"]) + return AuthenticationResponse(success=True, + token_type=token_data["token_type"], + access_token=token_data["access_token"], + refresh_token=token_data["refresh_token"], + expires_in=token_data["expires_in"], + scope=token_data["scope"], + received_at=datetime.now().strftime(TOKEN_TIMESTAMP_FORMAT)) ## Calls the authentication API endpoint to get the token data. # \param access_token: The encoded JWT token. diff --git a/cura/OAuth2/AuthorizationRequestHandler.py b/cura/OAuth2/AuthorizationRequestHandler.py index 8f0982ccbf..66ecfc2787 100644 --- a/cura/OAuth2/AuthorizationRequestHandler.py +++ b/cura/OAuth2/AuthorizationRequestHandler.py @@ -21,7 +21,7 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler): super().__init__(request, client_address, server) # These values will be injected by the HTTPServer that this handler belongs to. - self.authorization_helpers = None # type: Optional["AuthorizationHelpers"] + self.authorization_helpers = None # type: Optional[AuthorizationHelpers] self.authorization_callback = None # type: Optional[Callable[[AuthenticationResponse], None]] self.verification_code = None # type: Optional[str] diff --git a/cura/OAuth2/AuthorizationService.py b/cura/OAuth2/AuthorizationService.py index 5117a2da66..377ec080aa 100644 --- a/cura/OAuth2/AuthorizationService.py +++ b/cura/OAuth2/AuthorizationService.py @@ -3,6 +3,7 @@ import json import webbrowser +from datetime import datetime, timedelta from typing import Optional, TYPE_CHECKING from urllib.parse import urlencode import requests.exceptions @@ -12,7 +13,7 @@ from UM.Logger import Logger from UM.Signal import Signal from cura.OAuth2.LocalAuthorizationServer import LocalAuthorizationServer -from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers +from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers, TOKEN_TIMESTAMP_FORMAT from cura.OAuth2.Models import AuthenticationResponse if TYPE_CHECKING: @@ -87,17 +88,19 @@ class AuthorizationService: ## Get the access token as provided by the repsonse data. def getAccessToken(self) -> Optional[str]: - if not self.getUserProfile(): - # We check if we can get the user profile. - # If we can't get it, that means the access token (JWT) was invalid or expired. - Logger.log("w", "Unable to get the user profile.") - return None - if self._auth_data is None: Logger.log("d", "No auth data to retrieve the access_token from") return None - return self._auth_data.access_token + # Check if the current access token is expired and refresh it if that is the case. + # We have a fallback on a date far in the past for currently stored auth data in cura.cfg. + received_at = datetime.strptime(self._auth_data.received_at, TOKEN_TIMESTAMP_FORMAT) \ + if self._auth_data.received_at else datetime(2000, 1, 1) + expiry_date = received_at + timedelta(seconds = float(self._auth_data.expires_in or 0)) + if datetime.now() > expiry_date: + self.refreshAccessToken() + + return self._auth_data.access_token if self._auth_data else None ## Try to refresh the access token. This should be used when it has expired. def refreshAccessToken(self) -> None: diff --git a/cura/OAuth2/Models.py b/cura/OAuth2/Models.py index 62b62c42e0..468351c62b 100644 --- a/cura/OAuth2/Models.py +++ b/cura/OAuth2/Models.py @@ -1,6 +1,5 @@ # Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. - from typing import Optional @@ -38,12 +37,13 @@ class AuthenticationResponse(BaseModel): expires_in = None # type: Optional[str] scope = None # type: Optional[str] err_message = None # type: Optional[str] + received_at = None # type: Optional[str] ## Response status template. class ResponseStatus(BaseModel): code = 200 # type: int - message = "" # type str + message = "" # type: str ## Response data template. diff --git a/cura/Settings/MachineManager.py b/cura/Settings/MachineManager.py index 0d21f1e1b6..202224dd64 100755 --- a/cura/Settings/MachineManager.py +++ b/cura/Settings/MachineManager.py @@ -511,13 +511,13 @@ class MachineManager(QObject): @pyqtProperty(str, notify = globalContainerChanged) def activeMachineFirmwareVersion(self) -> str: - if not self._printer_output_devices[0]: + if not self._printer_output_devices: return "" return self._printer_output_devices[0].firmwareVersion @pyqtProperty(str, notify = globalContainerChanged) def activeMachineAddress(self) -> str: - if not self._printer_output_devices[0]: + if not self._printer_output_devices: return "" return self._printer_output_devices[0].address @@ -547,14 +547,18 @@ class MachineManager(QObject): return bool(self._printer_output_devices) and len(self._printer_output_devices[0].printers) > 1 @pyqtProperty(bool, notify = printerConnectedStatusChanged) - def activeMachineHasActiveNetworkConnection(self) -> bool: + def activeMachineHasNetworkConnection(self) -> bool: # A network connection is only available if any output device is actually a network connected device. return any(d.connectionType == ConnectionType.NetworkConnection for d in self._printer_output_devices) @pyqtProperty(bool, notify = printerConnectedStatusChanged) - def activeMachineHasActiveCloudConnection(self) -> bool: + def activeMachineHasCloudConnection(self) -> bool: # A cloud connection is only available if any output device actually is a cloud connected device. return any(d.connectionType == ConnectionType.CloudConnection for d in self._printer_output_devices) + + @pyqtProperty(bool, notify = printerConnectedStatusChanged) + def activeMachineIsUsingCloudConnection(self) -> bool: + return self.activeMachineHasCloudConnection and not self.activeMachineHasNetworkConnection def activeMachineNetworkKey(self) -> str: if self._global_container_stack: diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorPrintJobCard.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorPrintJobCard.qml index 0bf3d0ca52..a23b8ab0d3 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorPrintJobCard.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorPrintJobCard.qml @@ -24,7 +24,7 @@ Item // If the printer is a cloud printer or not. Other items base their enabled state off of this boolean. In the future // they might not need to though. - property bool cloudConnection: Cura.MachineManager.activeMachineHasActiveCloudConnection + property bool cloudConnection: Cura.MachineManager.activeMachineIsUsingCloudConnection width: parent.width height: childrenRect.height diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorPrinterCard.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorPrinterCard.qml index 0e541c484d..8c63e1ef1a 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorPrinterCard.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorPrinterCard.qml @@ -30,7 +30,7 @@ Item // If the printer is a cloud printer or not. Other items base their enabled state off of this boolean. In the future // they might not need to though. - property bool cloudConnection: Cura.MachineManager.activeMachineHasActiveCloudConnection + property bool cloudConnection: Cura.MachineManager.activeMachineIsUsingCloudConnection width: 834 * screenScaleFactor // TODO: Theme! height: childrenRect.height diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py index 9c1e8e1cdb..adff94bbbc 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -103,8 +103,9 @@ class CloudApiClient: request = QNetworkRequest(QUrl(path)) if content_type: request.setHeader(QNetworkRequest.ContentTypeHeader, content_type) - if self._account.isLoggedIn: - request.setRawHeader(b"Authorization", "Bearer {}".format(self._account.accessToken).encode()) + access_token = self._account.accessToken + if access_token: + request.setRawHeader(b"Authorization", "Bearer {}".format(access_token).encode()) return request ## Parses the given JSON network reply into a status code and a dictionary, handling unexpected errors as well. diff --git a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py index 4bf899d193..3400cae757 100644 --- a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py +++ b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py @@ -4,6 +4,7 @@ import json from queue import Queue from threading import Event, Thread from time import time +import os from zeroconf import Zeroconf, ServiceBrowser, ServiceStateChange, ServiceInfo from PyQt5.QtNetwork import QNetworkRequest, QNetworkAccessManager @@ -412,13 +413,18 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): active_machine = self._application.getMachineManager().activeMachine # type: Optional["GlobalStack"] if active_machine: - # Check 1: Printer isn't already configured for cloud + # Check 1A: Printer isn't already configured for cloud if ConnectionType.CloudConnection.value in active_machine.configuredConnectionTypes: Logger.log("d", "Active machine was already configured for cloud.") return + + # Check 1B: Printer isn't already configured for cloud + if active_machine.getMetaDataEntry("cloud_flow_complete", False): + Logger.log("d", "Active machine was already configured for cloud.") + return # Check 2: User did not already say "Don't ask me again" - if active_machine.getMetaDataEntry("show_cloud_message", "value") is False: + if active_machine.getMetaDataEntry("do_not_show_cloud_message", False): Logger.log("d", "Active machine shouldn't ask about cloud anymore.") return @@ -428,7 +434,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): return # Check 4: Machine is configured for network connectivity - if not self._application.getMachineManager().activeMachineHasActiveNetworkConnection: + if not self._application.getMachineManager().activeMachineHasNetworkConnection: Logger.log("d", "Cloud Flow not possible: Machine is not connected!") return @@ -448,7 +454,9 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): if not self._start_cloud_flow_message: self._start_cloud_flow_message = Message( text = i18n_catalog.i18nc("@info:status", "Send and monitor print jobs from anywhere using your Ultimaker account."), - image_source = "../../../../../Cura/plugins/UM3NetworkPrinting/resources/svg/cloud-flow-start.svg", + lifetime = 0, + image_source = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "resources", "svg", + "cloud-flow-start.svg"), image_caption = i18n_catalog.i18nc("@info:status", "Connect to Ultimaker Cloud"), option_text = i18n_catalog.i18nc("@action", "Don't ask me again for this printer."), option_state = False @@ -469,7 +477,8 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): self._cloud_flow_complete_message = Message( text = i18n_catalog.i18nc("@info:status", "You can now send and monitor print jobs from anywhere using your Ultimaker account."), lifetime = 30, - image_source = "../../../../../Cura/plugins/UM3NetworkPrinting/resources/svg/cloud-flow-completed.svg", + image_source = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "resources", "svg", + "cloud-flow-completed.svg"), image_caption = i18n_catalog.i18nc("@info:status", "Connected!") ) self._cloud_flow_complete_message.addAction("", i18n_catalog.i18nc("@action", "Review your connection"), "", "", 1) # TODO: Icon @@ -479,13 +488,13 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): # Set the machine's cloud flow as complete so we don't ask the user again and again for cloud connected printers active_machine = self._application.getMachineManager().activeMachine if active_machine: - active_machine.setMetaDataEntry("cloud_flow_complete", True) + active_machine.setMetaDataEntry("do_not_show_cloud_message", True) return - def _onDontAskMeAgain(self, messageId: str, checked: bool) -> None: + def _onDontAskMeAgain(self, messageId: str) -> None: active_machine = self._application.getMachineManager().activeMachine # type: Optional["GlobalStack"] if active_machine: - active_machine.setMetaDataEntry("show_cloud_message", False) + active_machine.setMetaDataEntry("do_not_show_cloud_message", True) Logger.log("d", "Will not ask the user again to cloud connect for current printer.") return diff --git a/resources/qml/Dialogs/AboutDialog.qml b/resources/qml/Dialogs/AboutDialog.qml index 9a92bcbe1f..604cbc16ba 100644 --- a/resources/qml/Dialogs/AboutDialog.qml +++ b/resources/qml/Dialogs/AboutDialog.qml @@ -28,7 +28,7 @@ UM.Dialog anchors.topMargin: - margin anchors.horizontalCenter: parent.horizontalCenter - color: UM.Theme.getColor("viewport_background") + color: UM.Theme.getColor("main_window_header_background") } Image @@ -52,7 +52,7 @@ UM.Dialog text: catalog.i18nc("@label","version: %1").arg(UM.Application.version) font: UM.Theme.getFont("large_bold") - color: UM.Theme.getColor("text") + color: UM.Theme.getColor("button_text") anchors.right : logo.right anchors.top: logo.bottom anchors.topMargin: (UM.Theme.getSize("default_margin").height / 2) | 0 diff --git a/resources/qml/PrinterSelector/MachineSelector.qml b/resources/qml/PrinterSelector/MachineSelector.qml index cd5e041606..e9452f4d35 100644 --- a/resources/qml/PrinterSelector/MachineSelector.qml +++ b/resources/qml/PrinterSelector/MachineSelector.qml @@ -11,8 +11,8 @@ Cura.ExpandablePopup { id: machineSelector - property bool isNetworkPrinter: Cura.MachineManager.activeMachineHasActiveNetworkConnection - property bool isCloudPrinter: Cura.MachineManager.activeMachineHasActiveCloudConnection + property bool isNetworkPrinter: Cura.MachineManager.activeMachineHasNetworkConnection + property bool isCloudPrinter: Cura.MachineManager.activeMachineHasCloudConnection property bool isGroup: Cura.MachineManager.activeMachineIsGroup contentPadding: UM.Theme.getSize("default_lining").width diff --git a/resources/themes/cura-dark/icons/sign_in_to_cloud.svg b/resources/themes/cura-dark/icons/sign_in_to_cloud.svg new file mode 100644 index 0000000000..09ba300b6a --- /dev/null +++ b/resources/themes/cura-dark/icons/sign_in_to_cloud.svg @@ -0,0 +1,16 @@ + + + + Group-cloud Copy + Created with Sketch. + + + + + + + + + + + \ No newline at end of file diff --git a/resources/themes/cura-light/icons/sign_in_to_cloud.svg b/resources/themes/cura-light/icons/sign_in_to_cloud.svg index 5c8f39a85b..27471fddce 100644 --- a/resources/themes/cura-light/icons/sign_in_to_cloud.svg +++ b/resources/themes/cura-light/icons/sign_in_to_cloud.svg @@ -1,25 +1,14 @@ - - - Group 2 + + + Group-cloud Created with Sketch. - - - - - - - - - - - - - - - - - + + + + + + diff --git a/tests/TestOAuth2.py b/tests/TestOAuth2.py index 608d529e9f..26d6b8e332 100644 --- a/tests/TestOAuth2.py +++ b/tests/TestOAuth2.py @@ -1,8 +1,9 @@ import webbrowser +from datetime import datetime from unittest.mock import MagicMock, patch from UM.Preferences import Preferences -from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers +from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers, TOKEN_TIMESTAMP_FORMAT from cura.OAuth2.AuthorizationService import AuthorizationService from cura.OAuth2.LocalAuthorizationServer import LocalAuthorizationServer from cura.OAuth2.Models import OAuth2Settings, AuthenticationResponse, UserProfile @@ -12,19 +13,27 @@ OAUTH_ROOT = "https://account.ultimaker.com" CLOUD_API_ROOT = "https://api.ultimaker.com" OAUTH_SETTINGS = OAuth2Settings( - OAUTH_SERVER_URL= OAUTH_ROOT, - CALLBACK_PORT=CALLBACK_PORT, - CALLBACK_URL="http://localhost:{}/callback".format(CALLBACK_PORT), - CLIENT_ID="", - CLIENT_SCOPES="", - AUTH_DATA_PREFERENCE_KEY="test/auth_data", - AUTH_SUCCESS_REDIRECT="{}/app/auth-success".format(OAUTH_ROOT), - AUTH_FAILED_REDIRECT="{}/app/auth-error".format(OAUTH_ROOT) - ) + OAUTH_SERVER_URL= OAUTH_ROOT, + CALLBACK_PORT=CALLBACK_PORT, + CALLBACK_URL="http://localhost:{}/callback".format(CALLBACK_PORT), + CLIENT_ID="", + CLIENT_SCOPES="", + AUTH_DATA_PREFERENCE_KEY="test/auth_data", + AUTH_SUCCESS_REDIRECT="{}/app/auth-success".format(OAUTH_ROOT), + AUTH_FAILED_REDIRECT="{}/app/auth-error".format(OAUTH_ROOT) +) -FAILED_AUTH_RESPONSE = AuthenticationResponse(success = False, err_message = "FAILURE!") +FAILED_AUTH_RESPONSE = AuthenticationResponse( + success = False, + err_message = "FAILURE!" +) -SUCCESFULL_AUTH_RESPONSE = AuthenticationResponse(access_token = "beep", refresh_token = "beep?") +SUCCESSFUL_AUTH_RESPONSE = AuthenticationResponse( + access_token = "beep", + refresh_token = "beep?", + received_at = datetime.now().strftime(TOKEN_TIMESTAMP_FORMAT), + expires_in = 300 # 5 minutes should be more than enough for testing +) MALFORMED_AUTH_RESPONSE = AuthenticationResponse() @@ -64,7 +73,7 @@ def test_storeAuthData(get_user_profile) -> None: authorization_service.initialize() # Write stuff to the preferences. - authorization_service._storeAuthData(SUCCESFULL_AUTH_RESPONSE) + authorization_service._storeAuthData(SUCCESSFUL_AUTH_RESPONSE) preference_value = preferences.getValue(OAUTH_SETTINGS.AUTH_DATA_PREFERENCE_KEY) # Check that something was actually put in the preferences assert preference_value is not None and preference_value != {} @@ -73,7 +82,7 @@ def test_storeAuthData(get_user_profile) -> None: second_auth_service = AuthorizationService(OAUTH_SETTINGS, preferences) second_auth_service.initialize() second_auth_service.loadAuthDataFromPreferences() - assert second_auth_service.getAccessToken() == SUCCESFULL_AUTH_RESPONSE.access_token + assert second_auth_service.getAccessToken() == SUCCESSFUL_AUTH_RESPONSE.access_token @patch.object(LocalAuthorizationServer, "stop") @@ -101,9 +110,9 @@ def test_loginAndLogout() -> None: authorization_service.onAuthStateChanged.emit = MagicMock() authorization_service.initialize() - # Let the service think there was a succesfull response + # Let the service think there was a successful response with patch.object(AuthorizationHelpers, "parseJWT", return_value=UserProfile()): - authorization_service._onAuthStateChanged(SUCCESFULL_AUTH_RESPONSE) + authorization_service._onAuthStateChanged(SUCCESSFUL_AUTH_RESPONSE) # Ensure that the error signal was not triggered assert authorization_service.onAuthenticationError.emit.call_count == 0