AuthorizationHelpers.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. # Copyright (c) 2021 Ultimaker B.V.
  2. # Cura is released under the terms of the LGPLv3 or higher.
  3. from base64 import b64encode
  4. from datetime import datetime
  5. from hashlib import sha512
  6. from PyQt6.QtNetwork import QNetworkReply
  7. import secrets
  8. from typing import Callable, Optional
  9. import urllib.parse
  10. from cura.OAuth2.Models import AuthenticationResponse, UserProfile, OAuth2Settings
  11. from UM.i18n import i18nCatalog
  12. from UM.Logger import Logger
  13. from UM.TaskManagement.HttpRequestManager import HttpRequestManager # To download log-in tokens.
  14. catalog = i18nCatalog("cura")
  15. TOKEN_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S"
  16. REQUEST_TIMEOUT = 5 # Seconds
  17. class AuthorizationHelpers:
  18. """Class containing several helpers to deal with the authorization flow."""
  19. def __init__(self, settings: "OAuth2Settings") -> None:
  20. self._settings = settings
  21. self._token_url = "{}/token".format(self._settings.OAUTH_SERVER_URL)
  22. @property
  23. def settings(self) -> "OAuth2Settings":
  24. """The OAuth2 settings object."""
  25. return self._settings
  26. def getAccessTokenUsingAuthorizationCode(self, authorization_code: str, verification_code: str, callback: Callable[[AuthenticationResponse], None]) -> None:
  27. """
  28. Request the access token from the authorization server.
  29. :param authorization_code: The authorization code from the 1st step.
  30. :param verification_code: The verification code needed for the PKCE extension.
  31. :param callback: Once the token has been obtained, this function will be called with the response.
  32. """
  33. data = {
  34. "client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "",
  35. "client_secret": self._settings.CLIENT_SECRET if self._settings.CLIENT_SECRET is not None else "",
  36. "redirect_uri": self._settings.CALLBACK_URL if self._settings.CALLBACK_URL is not None else "",
  37. "grant_type": "authorization_code",
  38. "code": authorization_code,
  39. "code_verifier": verification_code,
  40. "scope": self._settings.CLIENT_SCOPES if self._settings.CLIENT_SCOPES is not None else "",
  41. }
  42. headers = {"Content-type": "application/x-www-form-urlencoded"}
  43. HttpRequestManager.getInstance().post(
  44. self._token_url,
  45. data = urllib.parse.urlencode(data).encode("UTF-8"),
  46. headers_dict = headers,
  47. callback = lambda response: self.parseTokenResponse(response, callback),
  48. error_callback = lambda response, _: self.parseTokenResponse(response, callback),
  49. timeout = REQUEST_TIMEOUT
  50. )
  51. def getAccessTokenUsingRefreshToken(self, refresh_token: str, callback: Callable[[AuthenticationResponse], None]) -> None:
  52. """
  53. Request the access token from the authorization server using a refresh token.
  54. :param refresh_token: A long-lived token used to refresh the authentication token.
  55. :param callback: Once the token has been obtained, this function will be called with the response.
  56. """
  57. Logger.log("d", "Refreshing the access token for [%s]", self._settings.OAUTH_SERVER_URL)
  58. data = {
  59. "client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "",
  60. "client_secret": self._settings.CLIENT_SECRET if self._settings.CLIENT_SECRET is not None else "",
  61. "redirect_uri": self._settings.CALLBACK_URL if self._settings.CALLBACK_URL is not None else "",
  62. "grant_type": "refresh_token",
  63. "refresh_token": refresh_token,
  64. "scope": self._settings.CLIENT_SCOPES if self._settings.CLIENT_SCOPES is not None else "",
  65. }
  66. headers = {"Content-type": "application/x-www-form-urlencoded"}
  67. HttpRequestManager.getInstance().post(
  68. self._token_url,
  69. data = urllib.parse.urlencode(data).encode("UTF-8"),
  70. headers_dict = headers,
  71. callback = lambda response: self.parseTokenResponse(response, callback),
  72. error_callback = lambda response, _: self.parseTokenResponse(response, callback),
  73. urgent = True,
  74. timeout = REQUEST_TIMEOUT
  75. )
  76. def parseTokenResponse(self, token_response: QNetworkReply, callback: Callable[[AuthenticationResponse], None]) -> None:
  77. """Parse the token response from the authorization server into an AuthenticationResponse object.
  78. :param token_response: The JSON string data response from the authorization server.
  79. :return: An AuthenticationResponse object.
  80. """
  81. token_data = HttpRequestManager.readJSON(token_response)
  82. if not token_data:
  83. callback(AuthenticationResponse(success = False, err_message = catalog.i18nc("@message", "Could not read response.")))
  84. return
  85. if token_response.error() != QNetworkReply.NetworkError.NoError:
  86. callback(AuthenticationResponse(success = False, err_message = token_data.get("error_description", "an unknown server error occurred")))
  87. return
  88. callback(AuthenticationResponse(success = True,
  89. token_type = token_data["token_type"],
  90. access_token = token_data["access_token"],
  91. refresh_token = token_data["refresh_token"],
  92. expires_in = token_data["expires_in"],
  93. scope = token_data["scope"],
  94. received_at = datetime.now().strftime(TOKEN_TIMESTAMP_FORMAT)))
  95. return
  96. def checkToken(self, access_token: str, success_callback: Optional[Callable[[UserProfile], None]] = None, failed_callback: Optional[Callable[[], None]] = None) -> None:
  97. """Calls the authentication API endpoint to get the token data.
  98. The API is called asynchronously. When a response is given, the callback is called with the user's profile.
  99. :param access_token: The encoded JWT token.
  100. :param success_callback: When a response is given, this function will be called with a user profile. If None,
  101. there will not be a callback.
  102. :param failed_callback: When the request failed or the response didn't parse, this function will be called.
  103. """
  104. check_token_url = "{}/check-token".format(self._settings.OAUTH_SERVER_URL)
  105. Logger.log("d", "Checking the access token for [%s]", check_token_url)
  106. headers = {
  107. "Authorization": f"Bearer {access_token}"
  108. }
  109. HttpRequestManager.getInstance().get(
  110. check_token_url,
  111. headers_dict = headers,
  112. callback = lambda reply: self._parseUserProfile(reply, success_callback, failed_callback),
  113. error_callback = lambda _, _2: failed_callback() if failed_callback is not None else None,
  114. timeout = REQUEST_TIMEOUT
  115. )
  116. def _parseUserProfile(self, reply: QNetworkReply, success_callback: Optional[Callable[[UserProfile], None]], failed_callback: Optional[Callable[[], None]] = None) -> None:
  117. """
  118. Parses the user profile from a reply to /check-token.
  119. If the response is valid, the callback will be called to return the user profile to the caller.
  120. :param reply: A network reply to a request to the /check-token URL.
  121. :param success_callback: A function to call once a user profile was successfully obtained.
  122. :param failed_callback: A function to call if parsing the profile failed.
  123. """
  124. if reply.error() != QNetworkReply.NetworkError.NoError:
  125. Logger.warning(f"Could not access account information. QNetworkError {reply.errorString()}")
  126. if failed_callback is not None:
  127. failed_callback()
  128. return
  129. profile_data = HttpRequestManager.getInstance().readJSON(reply)
  130. if profile_data is None or "data" not in profile_data:
  131. Logger.warning("Could not parse user data from token.")
  132. if failed_callback is not None:
  133. failed_callback()
  134. return
  135. profile_data = profile_data["data"]
  136. required_fields = {"user_id", "username"}
  137. if "user_id" not in profile_data or "username" not in profile_data:
  138. Logger.warning(f"User data missing required field(s): {required_fields - set(profile_data.keys())}")
  139. if failed_callback is not None:
  140. failed_callback()
  141. return
  142. if success_callback is not None:
  143. success_callback(UserProfile(
  144. user_id = profile_data["user_id"],
  145. username = profile_data["username"],
  146. profile_image_url = profile_data.get("profile_image_url", ""),
  147. organization_id = profile_data.get("organization", {}).get("organization_id"),
  148. subscriptions = profile_data.get("subscriptions", [])
  149. ))
  150. @staticmethod
  151. def generateVerificationCode(code_length: int = 32) -> str:
  152. """Generate a verification code of arbitrary length.
  153. :param code_length:: How long should the code be in bytes? This should never be lower than 16, but it's probably
  154. better to leave it at 32
  155. """
  156. return secrets.token_hex(code_length)
  157. @staticmethod
  158. def generateVerificationCodeChallenge(verification_code: str) -> str:
  159. """Generates a base64 encoded sha512 encrypted version of a given string.
  160. :param verification_code:
  161. :return: The encrypted code in base64 format.
  162. """
  163. encoded = sha512(verification_code.encode()).digest()
  164. return b64encode(encoded, altchars = b"_-").decode()