Initial move of the code of CuraPluginOAuth2Module

CURA-5744
This commit is contained in:
Jaime van Kessel 2018-09-21 11:58:30 +02:00
parent dbe0d6d82a
commit 3830fa0fd9
7 changed files with 537 additions and 0 deletions

View file

@ -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()

View file

@ -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]

View file

@ -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

View file

@ -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)

View file

@ -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
cura/OAuth2/Models.py Normal file
View file

@ -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
cura/OAuth2/__init__.py Normal file
View file

@ -0,0 +1,2 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.