diff --git a/cura/OAuth2/AuthorizationHelpers.py b/cura/OAuth2/AuthorizationHelpers.py new file mode 100644 index 0000000000..10041f70ce --- /dev/null +++ b/cura/OAuth2/AuthorizationHelpers.py @@ -0,0 +1,127 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +import json +import random +from _sha512 import sha512 +from base64 import b64encode +from typing import Optional + +import requests + +# As this module is specific for Cura plugins, we can rely on these imports. +from UM.Logger import Logger + +# Plugin imports need to be relative to work in final builds. +from .models import AuthenticationResponse, UserProfile, OAuth2Settings + + +class AuthorizationHelpers: + """Class containing several helpers to deal with the authorization flow.""" + + def __init__(self, settings: "OAuth2Settings"): + self._settings = settings + self._token_url = "{}/token".format(self._settings.OAUTH_SERVER_URL) + + @property + def settings(self) -> "OAuth2Settings": + """Get the OAuth2 settings object.""" + return self._settings + + def getAccessTokenUsingAuthorizationCode(self, authorization_code: str, verification_code: str)->\ + Optional["AuthenticationResponse"]: + """ + Request the access token from the authorization server. + :param authorization_code: The authorization code from the 1st step. + :param verification_code: The verification code needed for the PKCE extension. + :return: An AuthenticationResponse object. + """ + return self.parseTokenResponse(requests.post(self._token_url, data={ + "client_id": self._settings.CLIENT_ID, + "redirect_uri": self._settings.CALLBACK_URL, + "grant_type": "authorization_code", + "code": authorization_code, + "code_verifier": verification_code, + "scope": self._settings.CLIENT_SCOPES + })) + + def getAccessTokenUsingRefreshToken(self, refresh_token: str) -> Optional["AuthenticationResponse"]: + """ + Request the access token from the authorization server using a refresh token. + :param refresh_token: + :return: An AuthenticationResponse object. + """ + return self.parseTokenResponse(requests.post(self._token_url, data={ + "client_id": self._settings.CLIENT_ID, + "redirect_uri": self._settings.CALLBACK_URL, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "scope": self._settings.CLIENT_SCOPES + })) + + @staticmethod + def parseTokenResponse(token_response: "requests.request") -> Optional["AuthenticationResponse"]: + """ + Parse the token response from the authorization server into an AuthenticationResponse object. + :param token_response: The JSON string data response from the authorization server. + :return: An AuthenticationResponse object. + """ + token_data = None + + try: + token_data = json.loads(token_response.text) + except ValueError: + Logger.log("w", "Could not parse token response data: %s", token_response.text) + + if not token_data: + return AuthenticationResponse(success=False, err_message="Could not read response.") + + 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"]) + + def parseJWT(self, access_token: str) -> Optional["UserProfile"]: + """ + Calls the authentication API endpoint to get the token data. + :param access_token: The encoded JWT token. + :return: Dict containing some profile data. + """ + token_request = requests.get("{}/check-token".format(self._settings.OAUTH_SERVER_URL), headers = { + "Authorization": "Bearer {}".format(access_token) + }) + if token_request.status_code not in (200, 201): + Logger.log("w", "Could not retrieve token data from auth server: %s", token_request.text) + return None + user_data = token_request.json().get("data") + if not user_data or not isinstance(user_data, dict): + Logger.log("w", "Could not parse user data from token: %s", user_data) + return None + return UserProfile( + user_id = user_data["user_id"], + username = user_data["username"], + profile_image_url = user_data.get("profile_image_url", "") + ) + + @staticmethod + def generateVerificationCode(code_length: int = 16) -> str: + """ + Generate a 16-character verification code. + :param code_length: + :return: + """ + return "".join(random.choice("0123456789ABCDEF") for i in range(code_length)) + + @staticmethod + def generateVerificationCodeChallenge(verification_code: str) -> str: + """ + Generates a base64 encoded sha512 encrypted version of a given string. + :param verification_code: + :return: The encrypted code in base64 format. + """ + encoded = sha512(verification_code.encode()).digest() + return b64encode(encoded, altchars = b"_-").decode() diff --git a/cura/OAuth2/AuthorizationRequestHandler.py b/cura/OAuth2/AuthorizationRequestHandler.py new file mode 100644 index 0000000000..eb703fc5c1 --- /dev/null +++ b/cura/OAuth2/AuthorizationRequestHandler.py @@ -0,0 +1,105 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import Optional, Callable + +from http.server import BaseHTTPRequestHandler +from urllib.parse import parse_qs, urlparse + +# Plugin imports need to be relative to work in final builds. +from .AuthorizationHelpers import AuthorizationHelpers +from .models import AuthenticationResponse, ResponseData, HTTP_STATUS, ResponseStatus + + +class AuthorizationRequestHandler(BaseHTTPRequestHandler): + """ + This handler handles all HTTP requests on the local web server. + It also requests the access token for the 2nd stage of the OAuth flow. + """ + + def __init__(self, request, client_address, server): + super().__init__(request, client_address, server) + + # These values will be injected by the HTTPServer that this handler belongs to. + self.authorization_helpers = None # type: AuthorizationHelpers + self.authorization_callback = None # type: Callable[[AuthenticationResponse], None] + self.verification_code = None # type: str + + def do_GET(self): + """Entry point for GET requests""" + + # Extract values from the query string. + parsed_url = urlparse(self.path) + query = parse_qs(parsed_url.query) + + # Handle the possible requests + if parsed_url.path == "/callback": + server_response, token_response = self._handleCallback(query) + else: + server_response = self._handleNotFound() + token_response = None + + # Send the data to the browser. + self._sendHeaders(server_response.status, server_response.content_type, server_response.redirect_uri) + + if server_response.data_stream: + # If there is data in the response, we send it. + self._sendData(server_response.data_stream) + + if token_response: + # Trigger the callback if we got a response. + # This will cause the server to shut down, so we do it at the very end of the request handling. + self.authorization_callback(token_response) + + def _handleCallback(self, query: dict) -> ("ResponseData", Optional["AuthenticationResponse"]): + """ + Handler for the callback URL redirect. + :param query: Dict containing the HTTP query parameters. + :return: HTTP ResponseData containing a success page to show to the user. + """ + if self._queryGet(query, "code"): + # If the code was returned we get the access token. + token_response = self.authorization_helpers.getAccessTokenUsingAuthorizationCode( + self._queryGet(query, "code"), self.verification_code) + + elif self._queryGet(query, "error_code") == "user_denied": + # Otherwise we show an error message (probably the user clicked "Deny" in the auth dialog). + token_response = AuthenticationResponse( + success=False, + err_message="Please give the required permissions when authorizing this application." + ) + + else: + # We don't know what went wrong here, so instruct the user to check the logs. + token_response = AuthenticationResponse( + success=False, + error_message="Something unexpected happened when trying to log in, please try again." + ) + + return ResponseData( + status=HTTP_STATUS["REDIRECT"], + data_stream=b"Redirecting...", + redirect_uri=self.authorization_helpers.settings.AUTH_SUCCESS_REDIRECT if token_response.success else + self.authorization_helpers.settings.AUTH_FAILED_REDIRECT + ), token_response + + @staticmethod + def _handleNotFound() -> "ResponseData": + """Handle all other non-existing server calls.""" + return ResponseData(status=HTTP_STATUS["NOT_FOUND"], content_type="text/html", data_stream=b"Not found.") + + def _sendHeaders(self, status: "ResponseStatus", content_type: str, redirect_uri: str = None) -> None: + """Send out the headers""" + self.send_response(status.code, status.message) + self.send_header("Content-type", content_type) + if redirect_uri: + self.send_header("Location", redirect_uri) + self.end_headers() + + def _sendData(self, data: bytes) -> None: + """Send out the data""" + self.wfile.write(data) + + @staticmethod + def _queryGet(query_data: dict, key: str, default=None) -> Optional[str]: + """Helper for getting values from a pre-parsed query string""" + return query_data.get(key, [default])[0] diff --git a/cura/OAuth2/AuthorizationRequestServer.py b/cura/OAuth2/AuthorizationRequestServer.py new file mode 100644 index 0000000000..ee428bc236 --- /dev/null +++ b/cura/OAuth2/AuthorizationRequestServer.py @@ -0,0 +1,25 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from http.server import HTTPServer + +from .AuthorizationHelpers import AuthorizationHelpers + + +class AuthorizationRequestServer(HTTPServer): + """ + The authorization request callback handler server. + This subclass is needed to be able to pass some data to the request handler. + This cannot be done on the request handler directly as the HTTPServer creates an instance of the handler after init. + """ + + def setAuthorizationHelpers(self, authorization_helpers: "AuthorizationHelpers") -> None: + """Set the authorization helpers instance on the request handler.""" + self.RequestHandlerClass.authorization_helpers = authorization_helpers + + def setAuthorizationCallback(self, authorization_callback) -> None: + """Set the authorization callback on the request handler.""" + self.RequestHandlerClass.authorization_callback = authorization_callback + + def setVerificationCode(self, verification_code: str) -> None: + """Set the verification code on the request handler.""" + self.RequestHandlerClass.verification_code = verification_code diff --git a/cura/OAuth2/AuthorizationService.py b/cura/OAuth2/AuthorizationService.py new file mode 100644 index 0000000000..f425e3a003 --- /dev/null +++ b/cura/OAuth2/AuthorizationService.py @@ -0,0 +1,151 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +import json +import webbrowser +from typing import Optional +from urllib.parse import urlencode + +# As this module is specific for Cura plugins, we can rely on these imports. +from UM.Logger import Logger +from UM.Signal import Signal + +# Plugin imports need to be relative to work in final builds. +from .LocalAuthorizationServer import LocalAuthorizationServer +from .AuthorizationHelpers import AuthorizationHelpers +from .models import OAuth2Settings, AuthenticationResponse, UserProfile + + +class AuthorizationService: + """ + The authorization service is responsible for handling the login flow, + storing user credentials and providing account information. + """ + + # Emit signal when authentication is completed. + onAuthStateChanged = Signal() + + # Emit signal when authentication failed. + onAuthenticationError = Signal() + + def __init__(self, preferences, settings: "OAuth2Settings"): + self._settings = settings + self._auth_helpers = AuthorizationHelpers(settings) + self._auth_url = "{}/authorize".format(self._settings.OAUTH_SERVER_URL) + self._auth_data = None # type: Optional[AuthenticationResponse] + self._user_profile = None # type: Optional[UserProfile] + self._cura_preferences = preferences + self._server = LocalAuthorizationServer(self._auth_helpers, self._onAuthStateChanged, daemon=True) + self._loadAuthData() + + def getUserProfile(self) -> Optional["UserProfile"]: + """ + Get the user data that is stored in the JWT token. + :return: Dict containing some user data. + """ + if not self._user_profile: + # If no user profile was stored locally, we try to get it from JWT. + self._user_profile = self._parseJWT() + if not self._user_profile: + # If there is still no user profile from the JWT, we have to log in again. + return None + return self._user_profile + + def _parseJWT(self) -> Optional["UserProfile"]: + """ + Tries to parse the JWT if all the needed data exists. + :return: UserProfile if found, otherwise None. + """ + if not self._auth_data: + # If no auth data exists, we should always log in again. + return None + user_data = self._auth_helpers.parseJWT(self._auth_data.access_token) + if user_data: + # If the profile was found, we return it immediately. + return user_data + # The JWT was expired or invalid and we should request a new one. + self._auth_data = self._auth_helpers.getAccessTokenUsingRefreshToken(self._auth_data.refresh_token) + if not self._auth_data: + # The token could not be refreshed using the refresh token. We should login again. + return None + return self._auth_helpers.parseJWT(self._auth_data.access_token) + + def getAccessToken(self) -> Optional[str]: + """ + Get the access token response data. + :return: Dict containing token data. + """ + 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. + return None + return self._auth_data.access_token + + def refreshAccessToken(self) -> None: + """ + Refresh the access token when it expired. + """ + self._storeAuthData(self._auth_helpers.getAccessTokenUsingRefreshToken(self._auth_data.refresh_token)) + self.onAuthStateChanged.emit(logged_in=True) + + def deleteAuthData(self): + """Delete authentication data from preferences and locally.""" + self._storeAuthData() + self.onAuthStateChanged.emit(logged_in=False) + + def startAuthorizationFlow(self) -> None: + """Start a new OAuth2 authorization flow.""" + + Logger.log("d", "Starting new OAuth2 flow...") + + # Create the tokens needed for the code challenge (PKCE) extension for OAuth2. + # This is needed because the CuraDrivePlugin is a untrusted (open source) client. + # More details can be found at https://tools.ietf.org/html/rfc7636. + verification_code = self._auth_helpers.generateVerificationCode() + challenge_code = self._auth_helpers.generateVerificationCodeChallenge(verification_code) + + # Create the query string needed for the OAuth2 flow. + query_string = urlencode({ + "client_id": self._settings.CLIENT_ID, + "redirect_uri": self._settings.CALLBACK_URL, + "scope": self._settings.CLIENT_SCOPES, + "response_type": "code", + "state": "CuraDriveIsAwesome", + "code_challenge": challenge_code, + "code_challenge_method": "S512" + }) + + # Open the authorization page in a new browser window. + webbrowser.open_new("{}?{}".format(self._auth_url, query_string)) + + # Start a local web server to receive the callback URL on. + self._server.start(verification_code) + + def _onAuthStateChanged(self, auth_response: "AuthenticationResponse") -> None: + """Callback method for an authentication flow.""" + if auth_response.success: + self._storeAuthData(auth_response) + self.onAuthStateChanged.emit(logged_in=True) + else: + self.onAuthenticationError.emit(logged_in=False, error_message=auth_response.err_message) + self._server.stop() # Stop the web server at all times. + + def _loadAuthData(self) -> None: + """Load authentication data from preferences if available.""" + self._cura_preferences.addPreference(self._settings.AUTH_DATA_PREFERENCE_KEY, "{}") + try: + preferences_data = json.loads(self._cura_preferences.getValue(self._settings.AUTH_DATA_PREFERENCE_KEY)) + if preferences_data: + self._auth_data = AuthenticationResponse(**preferences_data) + self.onAuthStateChanged.emit(logged_in=True) + except ValueError as err: + Logger.log("w", "Could not load auth data from preferences: %s", err) + + def _storeAuthData(self, auth_data: Optional["AuthenticationResponse"] = None) -> None: + """Store authentication data in preferences and locally.""" + self._auth_data = auth_data + if auth_data: + self._user_profile = self.getUserProfile() + self._cura_preferences.setValue(self._settings.AUTH_DATA_PREFERENCE_KEY, json.dumps(vars(auth_data))) + else: + self._user_profile = None + self._cura_preferences.resetPreference(self._settings.AUTH_DATA_PREFERENCE_KEY) diff --git a/cura/OAuth2/LocalAuthorizationServer.py b/cura/OAuth2/LocalAuthorizationServer.py new file mode 100644 index 0000000000..5dc05786bf --- /dev/null +++ b/cura/OAuth2/LocalAuthorizationServer.py @@ -0,0 +1,67 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +import threading +from http.server import HTTPServer +from typing import Optional, Callable + +# As this module is specific for Cura plugins, we can rely on these imports. +from UM.Logger import Logger + +# Plugin imports need to be relative to work in final builds. +from .AuthorizationHelpers import AuthorizationHelpers +from .AuthorizationRequestServer import AuthorizationRequestServer +from .AuthorizationRequestHandler import AuthorizationRequestHandler +from .models import AuthenticationResponse + + +class LocalAuthorizationServer: + def __init__(self, auth_helpers: "AuthorizationHelpers", + auth_state_changed_callback: "Callable[[AuthenticationResponse], any]", + daemon: bool): + """ + :param auth_helpers: An instance of the authorization helpers class. + :param auth_state_changed_callback: A callback function to be called when the authorization state changes. + :param daemon: Whether the server thread should be run in daemon mode. Note: Daemon threads are abruptly stopped + at shutdown. Their resources (e.g. open files) may never be released. + """ + self._web_server = None # type: Optional[HTTPServer] + self._web_server_thread = None # type: Optional[threading.Thread] + self._web_server_port = auth_helpers.settings.CALLBACK_PORT + self._auth_helpers = auth_helpers + self._auth_state_changed_callback = auth_state_changed_callback + self._daemon = daemon + + def start(self, verification_code: "str") -> None: + """ + Starts the local web server to handle the authorization callback. + :param verification_code: The verification code part of the OAuth2 client identification. + """ + if self._web_server: + # If the server is already running (because of a previously aborted auth flow), we don't have to start it. + # We still inject the new verification code though. + self._web_server.setVerificationCode(verification_code) + return + + Logger.log("d", "Starting local web server to handle authorization callback on port %s", + self._web_server_port) + + # Create the server and inject the callback and code. + self._web_server = AuthorizationRequestServer(("0.0.0.0", self._web_server_port), + AuthorizationRequestHandler) + self._web_server.setAuthorizationHelpers(self._auth_helpers) + self._web_server.setAuthorizationCallback(self._auth_state_changed_callback) + self._web_server.setVerificationCode(verification_code) + + # Start the server on a new thread. + self._web_server_thread = threading.Thread(None, self._web_server.serve_forever, daemon = self._daemon) + self._web_server_thread.start() + + def stop(self) -> None: + """ Stops the web server if it was running. Also deletes the objects. """ + + Logger.log("d", "Stopping local web server...") + + if self._web_server: + self._web_server.server_close() + self._web_server = None + self._web_server_thread = None diff --git a/cura/OAuth2/Models.py b/cura/OAuth2/Models.py new file mode 100644 index 0000000000..08bed7e6d9 --- /dev/null +++ b/cura/OAuth2/Models.py @@ -0,0 +1,60 @@ +# Copyright (c) 2018 Ultimaker B.V. +from typing import Optional + + +class BaseModel: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + +# OAuth OAuth2Settings data template. +class OAuth2Settings(BaseModel): + CALLBACK_PORT = None # type: Optional[str] + OAUTH_SERVER_URL = None # type: Optional[str] + CLIENT_ID = None # type: Optional[str] + CLIENT_SCOPES = None # type: Optional[str] + CALLBACK_URL = None # type: Optional[str] + AUTH_DATA_PREFERENCE_KEY = None # type: Optional[str] + AUTH_SUCCESS_REDIRECT = "https://ultimaker.com" # type: str + AUTH_FAILED_REDIRECT = "https://ultimaker.com" # type: str + + +# User profile data template. +class UserProfile(BaseModel): + user_id = None # type: Optional[str] + username = None # type: Optional[str] + profile_image_url = None # type: Optional[str] + + +# Authentication data template. +class AuthenticationResponse(BaseModel): + """Data comes from the token response with success flag and error message added.""" + success = True # type: bool + token_type = None # type: Optional[str] + access_token = None # type: Optional[str] + refresh_token = None # type: Optional[str] + expires_in = None # type: Optional[str] + scope = None # type: Optional[str] + err_message = None # type: Optional[str] + + +# Response status template. +class ResponseStatus(BaseModel): + code = 200 # type: int + message = "" # type str + + +# Response data template. +class ResponseData(BaseModel): + status = None # type: Optional[ResponseStatus] + data_stream = None # type: Optional[bytes] + redirect_uri = None # type: Optional[str] + content_type = "text/html" # type: str + + +# Possible HTTP responses. +HTTP_STATUS = { + "OK": ResponseStatus(code=200, message="OK"), + "NOT_FOUND": ResponseStatus(code=404, message="NOT FOUND"), + "REDIRECT": ResponseStatus(code=302, message="REDIRECT") +} diff --git a/cura/OAuth2/__init__.py b/cura/OAuth2/__init__.py new file mode 100644 index 0000000000..f3f6970c54 --- /dev/null +++ b/cura/OAuth2/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher.