Browse Source

Initial move of the code of CuraPluginOAuth2Module

CURA-5744
Jaime van Kessel 6 years ago
parent
commit
3830fa0fd9

+ 127 - 0
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()

+ 105 - 0
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]

+ 25 - 0
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

+ 151 - 0
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)

+ 67 - 0
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

+ 60 - 0
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")
+}

+ 2 - 0
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.