diff --git a/cura/OAuth2/AuthorizationHelpers.py b/cura/OAuth2/AuthorizationHelpers.py index cc809abf05..612c00de2f 100644 --- a/cura/OAuth2/AuthorizationHelpers.py +++ b/cura/OAuth2/AuthorizationHelpers.py @@ -16,23 +16,27 @@ from cura.OAuth2.Models import AuthenticationResponse, UserProfile, OAuth2Settin catalog = i18nCatalog("cura") TOKEN_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S" -## Class containing several helpers to deal with the authorization flow. class AuthorizationHelpers: + """Class containing several helpers to deal with the authorization flow.""" + def __init__(self, settings: "OAuth2Settings") -> None: self._settings = settings self._token_url = "{}/token".format(self._settings.OAUTH_SERVER_URL) @property - ## The OAuth2 settings object. def settings(self) -> "OAuth2Settings": + """The OAuth2 settings object.""" + return self._settings - ## 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. def getAccessTokenUsingAuthorizationCode(self, authorization_code: str, verification_code: str) -> "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. + """ + data = { "client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "", "redirect_uri": self._settings.CALLBACK_URL if self._settings.CALLBACK_URL is not None else "", @@ -46,10 +50,13 @@ class AuthorizationHelpers: except requests.exceptions.ConnectionError: return AuthenticationResponse(success=False, err_message="Unable to connect to remote server") - ## Request the access token from the authorization server using a refresh token. - # \param refresh_token: - # \return An AuthenticationResponse object. def getAccessTokenUsingRefreshToken(self, refresh_token: str) -> "AuthenticationResponse": + """Request the access token from the authorization server using a refresh token. + + :param refresh_token: + :return: An AuthenticationResponse object. + """ + Logger.log("d", "Refreshing the access token.") data = { "client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "", @@ -64,10 +71,13 @@ class AuthorizationHelpers: return AuthenticationResponse(success=False, err_message="Unable to connect to remote server") @staticmethod - ## 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. def parseTokenResponse(token_response: requests.models.Response) -> "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: @@ -89,10 +99,13 @@ class AuthorizationHelpers: 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. - # \return Dict containing some profile data. 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. + """ + try: token_request = requests.get("{}/check-token".format(self._settings.OAUTH_SERVER_URL), headers = { "Authorization": "Bearer {}".format(access_token) @@ -115,16 +128,22 @@ class AuthorizationHelpers: ) @staticmethod - ## Generate a verification code of arbitrary length. - # \param code_length: How long should the code be? This should never be lower than 16, but it's probably better to - # leave it at 32 def generateVerificationCode(code_length: int = 32) -> str: + """Generate a verification code of arbitrary length. + + :param code_length:: How long should the code be? This should never be lower than 16, but it's probably + better to leave it at 32 + """ + return "".join(random.choice("0123456789ABCDEF") for i in range(code_length)) @staticmethod - ## Generates a base64 encoded sha512 encrypted version of a given string. - # \param verification_code: - # \return The encrypted code in base64 format. 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 index b002039491..9bcfbfc805 100644 --- a/cura/OAuth2/AuthorizationRequestHandler.py +++ b/cura/OAuth2/AuthorizationRequestHandler.py @@ -14,9 +14,12 @@ if TYPE_CHECKING: catalog = i18nCatalog("cura") -## 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. 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) -> None: super().__init__(request, client_address, server) @@ -55,10 +58,13 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler): # 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) - ## 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. def _handleCallback(self, query: Dict[Any, List]) -> Tuple[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. + """ + code = self._queryGet(query, "code") state = self._queryGet(query, "state") if state != self.state: @@ -95,9 +101,10 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler): self.authorization_helpers.settings.AUTH_FAILED_REDIRECT ), token_response - ## Handle all other non-existing server calls. @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: @@ -110,7 +117,8 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler): def _sendData(self, data: bytes) -> None: self.wfile.write(data) - ## Convenience helper for getting values from a pre-parsed query string @staticmethod def _queryGet(query_data: Dict[Any, List], key: str, default: Optional[str] = None) -> Optional[str]: + """Convenience 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 index 687bbf5ad8..e2f9dddc32 100644 --- a/cura/OAuth2/AuthorizationRequestServer.py +++ b/cura/OAuth2/AuthorizationRequestServer.py @@ -9,21 +9,26 @@ if TYPE_CHECKING: from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers -## 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. class AuthorizationRequestServer(HTTPServer): - ## Set the authorization helpers instance on the request handler. + """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 # type: ignore - ## Set the authorization callback on the request handler. def setAuthorizationCallback(self, authorization_callback: Callable[["AuthenticationResponse"], Any]) -> None: + """Set the authorization callback on the request handler.""" + self.RequestHandlerClass.authorization_callback = authorization_callback # type: ignore - ## Set the verification code on the request handler. def setVerificationCode(self, verification_code: str) -> None: + """Set the verification code on the request handler.""" + self.RequestHandlerClass.verification_code = verification_code # type: ignore def setState(self, state: str) -> None: diff --git a/cura/OAuth2/AuthorizationService.py b/cura/OAuth2/AuthorizationService.py index 2f865456b6..5de7ff2dce 100644 --- a/cura/OAuth2/AuthorizationService.py +++ b/cura/OAuth2/AuthorizationService.py @@ -25,9 +25,11 @@ if TYPE_CHECKING: from UM.Preferences import Preferences -## The authorization service is responsible for handling the login flow, -# storing user credentials and providing account information. 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() @@ -59,11 +61,16 @@ class AuthorizationService: if self._preferences: self._preferences.addPreference(self._settings.AUTH_DATA_PREFERENCE_KEY, "{}") - ## Get the user profile as obtained from the JWT (JSON Web Token). - # If the JWT is not yet parsed, calling this will take care of that. - # \return UserProfile if a user is logged in, None otherwise. - # \sa _parseJWT def getUserProfile(self) -> Optional["UserProfile"]: + """Get the user profile as obtained from the JWT (JSON Web Token). + + If the JWT is not yet parsed, calling this will take care of that. + + :return: UserProfile if a user is logged in, None otherwise. + + See also: :py:method:`cura.OAuth2.AuthorizationService.AuthorizationService._parseJWT` + """ + if not self._user_profile: # If no user profile was stored locally, we try to get it from JWT. try: @@ -81,9 +88,12 @@ class AuthorizationService: return self._user_profile - ## Tries to parse the JWT (JSON Web Token) data, which it does if all the needed data is there. - # \return UserProfile if it was able to parse, None otherwise. def _parseJWT(self) -> Optional["UserProfile"]: + """Tries to parse the JWT (JSON Web Token) data, which it does if all the needed data is there. + + :return: UserProfile if it was able to parse, None otherwise. + """ + if not self._auth_data or self._auth_data.access_token is None: # If no auth data exists, we should always log in again. Logger.log("d", "There was no auth data or access token") @@ -106,8 +116,9 @@ class AuthorizationService: self._storeAuthData(self._auth_data) return self._auth_helpers.parseJWT(self._auth_data.access_token) - ## Get the access token as provided by the repsonse data. def getAccessToken(self) -> Optional[str]: + """Get the access token as provided by the repsonse data.""" + if self._auth_data is None: Logger.log("d", "No auth data to retrieve the access_token from") return None @@ -122,8 +133,9 @@ class AuthorizationService: 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: + """Try to refresh the access token. This should be used when it has expired.""" + if self._auth_data is None or self._auth_data.refresh_token is None: Logger.log("w", "Unable to refresh access token, since there is no refresh token.") return @@ -135,14 +147,16 @@ class AuthorizationService: Logger.log("w", "Failed to get a new access token from the server.") self.onAuthStateChanged.emit(logged_in = False) - ## Delete the authentication data that we have stored locally (eg; logout) def deleteAuthData(self) -> None: + """Delete the authentication data that we have stored locally (eg; logout)""" + if self._auth_data is not None: self._storeAuthData() self.onAuthStateChanged.emit(logged_in = False) - ## Start the flow to become authenticated. This will start a new webbrowser tap, prompting the user to login. def startAuthorizationFlow(self) -> None: + """Start the flow to become authenticated. This will start a new webbrowser tap, prompting the user to login.""" + Logger.log("d", "Starting new OAuth2 flow...") # Create the tokens needed for the code challenge (PKCE) extension for OAuth2. @@ -177,8 +191,9 @@ class AuthorizationService: QDesktopServices.openUrl(QUrl("{}?{}".format(self._auth_url, query_string))) - ## Callback method for the authentication flow. def _onAuthStateChanged(self, auth_response: AuthenticationResponse) -> None: + """Callback method for the authentication flow.""" + if auth_response.success: self._storeAuthData(auth_response) self.onAuthStateChanged.emit(logged_in = True) @@ -186,8 +201,9 @@ class AuthorizationService: self.onAuthenticationError.emit(logged_in = False, error_message = auth_response.err_message) self._server.stop() # Stop the web server at all times. - ## Load authentication data from preferences. def loadAuthDataFromPreferences(self) -> None: + """Load authentication data from preferences.""" + if self._preferences is None: Logger.log("e", "Unable to load authentication data, since no preference has been set!") return @@ -208,8 +224,9 @@ class AuthorizationService: except ValueError: Logger.logException("w", "Could not load auth data from preferences") - ## Store authentication data in preferences. def _storeAuthData(self, auth_data: Optional[AuthenticationResponse] = None) -> None: + """Store authentication data in preferences.""" + Logger.log("d", "Attempting to store the auth data") if self._preferences is None: Logger.log("e", "Unable to save authentication data, since no preference has been set!") diff --git a/cura/OAuth2/LocalAuthorizationServer.py b/cura/OAuth2/LocalAuthorizationServer.py index 0e4e491e46..942967301a 100644 --- a/cura/OAuth2/LocalAuthorizationServer.py +++ b/cura/OAuth2/LocalAuthorizationServer.py @@ -20,18 +20,23 @@ if TYPE_CHECKING: class LocalAuthorizationServer: - ## The local LocalAuthorizationServer takes care of the oauth2 callbacks. - # Once the flow is completed, this server should be closed down again by - # calling stop() - # \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. def __init__(self, auth_helpers: "AuthorizationHelpers", auth_state_changed_callback: Callable[["AuthenticationResponse"], Any], daemon: bool) -> None: + """The local LocalAuthorizationServer takes care of the oauth2 callbacks. + + Once the flow is completed, this server should be closed down again by calling + :py:meth:`cura.OAuth2.LocalAuthorizationServer.LocalAuthorizationServer.stop()` + + :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[AuthorizationRequestServer] self._web_server_thread = None # type: Optional[threading.Thread] self._web_server_port = auth_helpers.settings.CALLBACK_PORT @@ -39,10 +44,13 @@ class LocalAuthorizationServer: self._auth_state_changed_callback = auth_state_changed_callback self._daemon = daemon - ## Starts the local web server to handle the authorization callback. - # \param verification_code The verification code part of the OAuth2 client identification. - # \param state The unique state code (to ensure that the request we get back is really from the server. def start(self, verification_code: str, state: str) -> None: + """Starts the local web server to handle the authorization callback. + + :param verification_code: The verification code part of the OAuth2 client identification. + :param state: The unique state code (to ensure that the request we get back is really from the server. + """ + 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. @@ -66,8 +74,9 @@ class LocalAuthorizationServer: self._web_server_thread = threading.Thread(None, self._web_server.serve_forever, daemon = self._daemon) self._web_server_thread.start() - ## Stops the web server if it was running. It also does some cleanup. def stop(self) -> None: + """Stops the web server if it was running. It also does some cleanup.""" + Logger.log("d", "Stopping local oauth2 web server...") if self._web_server: diff --git a/cura/OAuth2/Models.py b/cura/OAuth2/Models.py index dd935fef6e..93b44e8057 100644 --- a/cura/OAuth2/Models.py +++ b/cura/OAuth2/Models.py @@ -8,8 +8,9 @@ class BaseModel: self.__dict__.update(kwargs) -## OAuth OAuth2Settings data template. class OAuth2Settings(BaseModel): + """OAuth OAuth2Settings data template.""" + CALLBACK_PORT = None # type: Optional[int] OAUTH_SERVER_URL = None # type: Optional[str] CLIENT_ID = None # type: Optional[str] @@ -20,16 +21,18 @@ class OAuth2Settings(BaseModel): AUTH_FAILED_REDIRECT = "https://ultimaker.com" # type: str -## User profile data template. class UserProfile(BaseModel): + """User profile data template.""" + 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.""" + """Authentication data template.""" + + # 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] @@ -40,22 +43,25 @@ class AuthenticationResponse(BaseModel): received_at = None # type: Optional[str] -## Response status template. class ResponseStatus(BaseModel): + """Response status template.""" + code = 200 # type: int message = "" # type: str -## Response data template. class ResponseData(BaseModel): + """Response data template.""" + status = None # type: ResponseStatus data_stream = None # type: Optional[bytes] redirect_uri = None # type: Optional[str] content_type = "text/html" # type: str -## Possible HTTP responses. HTTP_STATUS = { +"""Possible HTTP responses.""" + "OK": ResponseStatus(code = 200, message = "OK"), "NOT_FOUND": ResponseStatus(code = 404, message = "NOT FOUND"), "REDIRECT": ResponseStatus(code = 302, message = "REDIRECT")