AuthorizationService.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. # Copyright (c) 2021 Ultimaker B.V.
  2. # Cura is released under the terms of the LGPLv3 or higher.
  3. import json
  4. from datetime import datetime, timedelta
  5. from typing import Optional, TYPE_CHECKING, Dict
  6. from urllib.parse import urlencode, quote_plus
  7. import requests.exceptions
  8. from PyQt5.QtCore import QUrl
  9. from PyQt5.QtGui import QDesktopServices
  10. from UM.Logger import Logger
  11. from UM.Message import Message
  12. from UM.Signal import Signal
  13. from UM.i18n import i18nCatalog
  14. from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers, TOKEN_TIMESTAMP_FORMAT
  15. from cura.OAuth2.LocalAuthorizationServer import LocalAuthorizationServer
  16. from cura.OAuth2.Models import AuthenticationResponse
  17. i18n_catalog = i18nCatalog("cura")
  18. if TYPE_CHECKING:
  19. from cura.OAuth2.Models import UserProfile, OAuth2Settings
  20. from UM.Preferences import Preferences
  21. MYCLOUD_LOGOFF_URL = "https://mycloud.ultimaker.com/logoff"
  22. class AuthorizationService:
  23. """The authorization service is responsible for handling the login flow, storing user credentials and providing
  24. account information.
  25. """
  26. # Emit signal when authentication is completed.
  27. onAuthStateChanged = Signal()
  28. # Emit signal when authentication failed.
  29. onAuthenticationError = Signal()
  30. accessTokenChanged = Signal()
  31. def __init__(self, settings: "OAuth2Settings", preferences: Optional["Preferences"] = None) -> None:
  32. self._settings = settings
  33. self._auth_helpers = AuthorizationHelpers(settings)
  34. self._auth_url = "{}/authorize".format(self._settings.OAUTH_SERVER_URL)
  35. self._auth_data = None # type: Optional[AuthenticationResponse]
  36. self._user_profile = None # type: Optional["UserProfile"]
  37. self._preferences = preferences
  38. self._server = LocalAuthorizationServer(self._auth_helpers, self._onAuthStateChanged, daemon=True)
  39. self._unable_to_get_data_message = None # type: Optional[Message]
  40. self.onAuthStateChanged.connect(self._authChanged)
  41. def _authChanged(self, logged_in):
  42. if logged_in and self._unable_to_get_data_message is not None:
  43. self._unable_to_get_data_message.hide()
  44. def initialize(self, preferences: Optional["Preferences"] = None) -> None:
  45. if preferences is not None:
  46. self._preferences = preferences
  47. if self._preferences:
  48. self._preferences.addPreference(self._settings.AUTH_DATA_PREFERENCE_KEY, "{}")
  49. def getUserProfile(self) -> Optional["UserProfile"]:
  50. """Get the user profile as obtained from the JWT (JSON Web Token).
  51. If the JWT is not yet parsed, calling this will take care of that.
  52. :return: UserProfile if a user is logged in, None otherwise.
  53. See also: :py:method:`cura.OAuth2.AuthorizationService.AuthorizationService._parseJWT`
  54. """
  55. if not self._user_profile:
  56. # If no user profile was stored locally, we try to get it from JWT.
  57. try:
  58. self._user_profile = self._parseJWT()
  59. except requests.exceptions.ConnectionError:
  60. # Unable to get connection, can't login.
  61. Logger.logException("w", "Unable to validate user data with the remote server.")
  62. return None
  63. if not self._user_profile and self._auth_data:
  64. # If there is still no user profile from the JWT, we have to log in again.
  65. Logger.log("w", "The user profile could not be loaded. The user must log in again!")
  66. self.deleteAuthData()
  67. return None
  68. return self._user_profile
  69. def _parseJWT(self) -> Optional["UserProfile"]:
  70. """Tries to parse the JWT (JSON Web Token) data, which it does if all the needed data is there.
  71. :return: UserProfile if it was able to parse, None otherwise.
  72. """
  73. if not self._auth_data or self._auth_data.access_token is None:
  74. # If no auth data exists, we should always log in again.
  75. Logger.log("d", "There was no auth data or access token")
  76. return None
  77. user_data = self._auth_helpers.parseJWT(self._auth_data.access_token)
  78. if user_data:
  79. # If the profile was found, we return it immediately.
  80. return user_data
  81. # The JWT was expired or invalid and we should request a new one.
  82. if self._auth_data.refresh_token is None:
  83. Logger.log("w", "There was no refresh token in the auth data.")
  84. return None
  85. self._auth_data = self._auth_helpers.getAccessTokenUsingRefreshToken(self._auth_data.refresh_token)
  86. if not self._auth_data or self._auth_data.access_token is None:
  87. Logger.log("w", "Unable to use the refresh token to get a new access token.")
  88. # The token could not be refreshed using the refresh token. We should login again.
  89. return None
  90. # Ensure it gets stored as otherwise we only have it in memory. The stored refresh token has been deleted
  91. # from the server already.
  92. self._storeAuthData(self._auth_data)
  93. return self._auth_helpers.parseJWT(self._auth_data.access_token)
  94. def getAccessToken(self) -> Optional[str]:
  95. """Get the access token as provided by the repsonse data."""
  96. if self._auth_data is None:
  97. Logger.log("d", "No auth data to retrieve the access_token from")
  98. return None
  99. # Check if the current access token is expired and refresh it if that is the case.
  100. # We have a fallback on a date far in the past for currently stored auth data in cura.cfg.
  101. received_at = datetime.strptime(self._auth_data.received_at, TOKEN_TIMESTAMP_FORMAT) \
  102. if self._auth_data.received_at else datetime(2000, 1, 1)
  103. expiry_date = received_at + timedelta(seconds = float(self._auth_data.expires_in or 0) - 60)
  104. if datetime.now() > expiry_date:
  105. self.refreshAccessToken()
  106. return self._auth_data.access_token if self._auth_data else None
  107. def refreshAccessToken(self) -> None:
  108. """Try to refresh the access token. This should be used when it has expired."""
  109. if self._auth_data is None or self._auth_data.refresh_token is None:
  110. Logger.log("w", "Unable to refresh access token, since there is no refresh token.")
  111. return
  112. response = self._auth_helpers.getAccessTokenUsingRefreshToken(self._auth_data.refresh_token)
  113. if response.success:
  114. self._storeAuthData(response)
  115. self.onAuthStateChanged.emit(logged_in = True)
  116. else:
  117. Logger.log("w", "Failed to get a new access token from the server.")
  118. self.onAuthStateChanged.emit(logged_in = False)
  119. def deleteAuthData(self) -> None:
  120. """Delete the authentication data that we have stored locally (eg; logout)"""
  121. if self._auth_data is not None:
  122. self._storeAuthData()
  123. self.onAuthStateChanged.emit(logged_in = False)
  124. def startAuthorizationFlow(self, force_browser_logout: bool = False) -> None:
  125. """Start the flow to become authenticated. This will start a new webbrowser tap, prompting the user to login."""
  126. Logger.log("d", "Starting new OAuth2 flow...")
  127. # Create the tokens needed for the code challenge (PKCE) extension for OAuth2.
  128. # This is needed because the CuraDrivePlugin is a untrusted (open source) client.
  129. # More details can be found at https://tools.ietf.org/html/rfc7636.
  130. verification_code = self._auth_helpers.generateVerificationCode()
  131. challenge_code = self._auth_helpers.generateVerificationCodeChallenge(verification_code)
  132. state = AuthorizationHelpers.generateVerificationCode()
  133. # Create the query dict needed for the OAuth2 flow.
  134. query_parameters_dict = {
  135. "client_id": self._settings.CLIENT_ID,
  136. "redirect_uri": self._settings.CALLBACK_URL,
  137. "scope": self._settings.CLIENT_SCOPES,
  138. "response_type": "code",
  139. "state": state, # Forever in our Hearts, RIP "(.Y.)" (2018-2020)
  140. "code_challenge": challenge_code,
  141. "code_challenge_method": "S512"
  142. }
  143. # Start a local web server to receive the callback URL on.
  144. try:
  145. self._server.start(verification_code, state)
  146. except OSError:
  147. Logger.logException("w", "Unable to create authorization request server")
  148. Message(i18n_catalog.i18nc("@info", "Unable to start a new sign in process. Check if another sign in attempt is still active."),
  149. title=i18n_catalog.i18nc("@info:title", "Warning")).show()
  150. return
  151. auth_url = self._generate_auth_url(query_parameters_dict, force_browser_logout)
  152. # Open the authorization page in a new browser window.
  153. QDesktopServices.openUrl(QUrl(auth_url))
  154. def _generate_auth_url(self, query_parameters_dict: Dict[str, Optional[str]], force_browser_logout: bool) -> str:
  155. """
  156. Generates the authentications url based on the original auth_url and the query_parameters_dict to be included.
  157. If there is a request to force logging out of mycloud in the browser, the link to logoff from mycloud is
  158. prepended in order to force the browser to logoff from mycloud and then redirect to the authentication url to
  159. login again. This case is used to sync the accounts between Cura and the browser.
  160. :param query_parameters_dict: A dictionary with the query parameters to be url encoded and added to the
  161. authentication link
  162. :param force_browser_logout: If True, Cura will prepend the MYCLOUD_LOGOFF_URL link before the authentication
  163. link to force the a browser logout from mycloud.ultimaker.com
  164. :return: The authentication URL, properly formatted and encoded
  165. """
  166. auth_url = "{}?{}".format(self._auth_url, urlencode(query_parameters_dict))
  167. if force_browser_logout:
  168. # The url after '?next=' should be urlencoded
  169. auth_url = "{}?next={}".format(MYCLOUD_LOGOFF_URL, quote_plus(auth_url))
  170. return auth_url
  171. def _onAuthStateChanged(self, auth_response: AuthenticationResponse) -> None:
  172. """Callback method for the authentication flow."""
  173. if auth_response.success:
  174. self._storeAuthData(auth_response)
  175. self.onAuthStateChanged.emit(logged_in = True)
  176. else:
  177. self.onAuthenticationError.emit(logged_in = False, error_message = auth_response.err_message)
  178. self._server.stop() # Stop the web server at all times.
  179. def loadAuthDataFromPreferences(self) -> None:
  180. """Load authentication data from preferences."""
  181. if self._preferences is None:
  182. Logger.log("e", "Unable to load authentication data, since no preference has been set!")
  183. return
  184. try:
  185. preferences_data = json.loads(self._preferences.getValue(self._settings.AUTH_DATA_PREFERENCE_KEY))
  186. if preferences_data:
  187. self._auth_data = AuthenticationResponse(**preferences_data)
  188. # Also check if we can actually get the user profile information.
  189. user_profile = self.getUserProfile()
  190. if user_profile is not None:
  191. self.onAuthStateChanged.emit(logged_in = True)
  192. else:
  193. if self._unable_to_get_data_message is not None:
  194. self._unable_to_get_data_message.hide()
  195. self._unable_to_get_data_message = Message(i18n_catalog.i18nc("@info", "Unable to reach the Ultimaker account server."), title = i18n_catalog.i18nc("@info:title", "Warning"))
  196. self._unable_to_get_data_message.show()
  197. except (ValueError, TypeError):
  198. Logger.logException("w", "Could not load auth data from preferences")
  199. def _storeAuthData(self, auth_data: Optional[AuthenticationResponse] = None) -> None:
  200. """Store authentication data in preferences."""
  201. Logger.log("d", "Attempting to store the auth data for [%s]", self._settings.OAUTH_SERVER_URL)
  202. if self._preferences is None:
  203. Logger.log("e", "Unable to save authentication data, since no preference has been set!")
  204. return
  205. self._auth_data = auth_data
  206. if auth_data:
  207. self._user_profile = self.getUserProfile()
  208. self._preferences.setValue(self._settings.AUTH_DATA_PREFERENCE_KEY, json.dumps(vars(auth_data)))
  209. else:
  210. self._user_profile = None
  211. self._preferences.resetPreference(self._settings.AUTH_DATA_PREFERENCE_KEY)
  212. self.accessTokenChanged.emit()