# ##### BEGIN GPL LICENSE BLOCK ##### # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software Foundation, # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # # ##### END GPL LICENSE BLOCK ##### import base64 import hashlib import logging import random import secrets import string import time from urllib.parse import quote as urlquote from webbrowser import open_new_tab import bpy from bpy.props import BoolProperty from . import client_lib, client_tasks, datas, global_vars, reports, tasks_queue, utils if bpy.app.version >= (4, 2, 0): from . import override_extension_draw CLIENT_ID = "IdFRwa3SGA8eMpzhRVFMg5Ts8sPK93xBjif93x0F" REFRESH_RESERVE = 60 * 60 * 24 * 3 # 3 days active_authenticator = None bk_logger = logging.getLogger(__name__) def handle_login_task(task: client_tasks.Task): """Handles incoming task of type Login. Writes tokens if it finished successfully, logouts the user on error.""" if task.status == "finished": tasks_queue.add_task( ( write_tokens, ( task.result["access_token"], task.result["refresh_token"], task.result, ), ) ) elif task.status == "error": logout() reports.add_report(task.message, type="ERROR", details=task.message_detailed) # TODO: probably not needed anymore, check if true and remove def handle_token_refresh_task(task: client_tasks.Task): """Handle incoming task of type token_refresh. If the new token is meant for the current user, calls handle_login_task. Otherwise it ignores the incoming task. """ preferences = bpy.context.preferences.addons[__package__].preferences # type: ignore if task.data.get("old_api_key") != preferences.api_key: # type: ignore bk_logger.info("Refreshed token is not meant for current user. Ignoring.") return if task.status == "finished": reports.add_report(task.message) tasks_queue.add_task( ( write_tokens, ( task.result["access_token"], task.result["refresh_token"], task.result, ), ) ) elif task.status == "error": logout() reports.add_report(task.message, details=task.message_detailed) def handle_logout_task(task: client_tasks.Task): """Handles incoming task of type oauth2/logout. This could be triggered from another add-on also. Shows messages depending on result of tokens revocation. Regardless of revocation results, it also cleans login data.""" if task.status == "finished": reports.add_report(task.message, timeout=3) elif task.status == "error": reports.add_report(task.message, type="ERROR") clean_login_data() def clean_login_data(): preferences = bpy.context.preferences.addons[__package__].preferences preferences.login_attempt = False preferences.api_key_refresh = "" preferences.api_key = "" preferences.api_key_timeout = 0 global_vars.BKIT_PROFILE = datas.MineProfile() # Cleanup also the api key in the extensions repository setting and clean the cache if bpy.app.version >= (4, 2, 0): override_extension_draw.ensure_repository(api_key="") override_extension_draw.clear_repo_cache() def logout() -> None: """Logs out user from add-on. Also calls BlenderKit-client to revoke the tokens.""" bk_logger.info("Logging out.") client_lib.oauth2_logout() clean_login_data() def login(signup: bool) -> None: """Logs user into the addon. Opens a browser with login page. Once user is logged it redirects browser to Client handling access code via URL querry parameter. Using the access_code Client then requests api_token and handles the results as a task with status finished/error. This is handled by function handle_login_task which saves tokens, or shows error message. """ # We use redirect_URI without /vX.Y/ API path prefix to not complicate stuff on the server side. redirect_URI = f"http://localhost:{client_lib.get_port()}/consumer/exchange/" code_verifier, code_challenge = generate_pkce_pair() state = secrets.token_urlsafe() client_lib.send_oauth_verification_data(code_verifier, state) authorize_url = f"/o/authorize?client_id={CLIENT_ID}&response_type=code&state={state}&redirect_uri={redirect_URI}&code_challenge={code_challenge}&code_challenge_method=S256" if signup: authorize_url = urlquote(authorize_url) authorize_url = f"{global_vars.SERVER}/accounts/register/?next={authorize_url}" else: authorize_url = f"{global_vars.SERVER}{authorize_url}" ok = open_new_tab(authorize_url) bk_logger.info(f"Login page in browser opened ({ok})") def generate_pkce_pair() -> tuple[str, str]: """Generate PKCE pair - a code verifier and code challenge. The challenge should be sent first to the server, the verifier is used in next steps to verify identity (handles Client). """ rand = random.SystemRandom() code_verifier = "".join(rand.choices(string.ascii_letters + string.digits, k=128)) code_sha_256 = hashlib.sha256(code_verifier.encode("utf-8")).digest() b64 = base64.urlsafe_b64encode(code_sha_256) code_challenge = b64.decode("utf-8").replace("=", "") return code_verifier, code_challenge def write_tokens(auth_token, refresh_token, oauth_response): preferences = bpy.context.preferences.addons[__package__].preferences preferences.api_key_timeout = int(time.time() + oauth_response["expires_in"]) preferences.login_attempt = False preferences.api_key_refresh = refresh_token preferences.api_key = auth_token # triggers api_key update function # write token also to extensions repository setting and clear the cache if bpy.app.version >= (4, 2, 0): override_extension_draw.ensure_repository(api_key=auth_token) override_extension_draw.clear_repo_cache() def ensure_token_refresh() -> bool: """Check if API token needs refresh, call refresh and return True if so. Otherwise do nothing and return False. """ preferences = bpy.context.preferences.addons[__package__].preferences # type: ignore if preferences.api_key == "": # type: ignore return False # not logged in if preferences.api_key_refresh == "": # type: ignore # Using manually inserted permanent token return False if time.time() + REFRESH_RESERVE < preferences.api_key_timeout: # type: ignore # Token is not old return False # Token is at the end of life, refresh token exists, it is time to refresh client_lib.refresh_token(preferences.api_key_refresh, preferences.api_key) # type: ignore return True class LoginOnline(bpy.types.Operator): """Login or register online on BlenderKit webpage""" bl_idname = "wm.blenderkit_login" bl_label = "BlenderKit login/signup" bl_options = {"REGISTER", "UNDO"} signup: BoolProperty( # type: ignore name="create a new account", description="True for register, otherwise login", default=False, options={"SKIP_SAVE"}, ) message: bpy.props.StringProperty( # type: ignore name="Message", description="", default="You were logged out from BlenderKit.\n Clicking OK takes you to web login. ", ) @classmethod def poll(cls, context): return True def draw(self, context): layout = self.layout utils.label_multiline(layout, text=self.message, width=300) def execute(self, context): preferences = bpy.context.preferences.addons[__package__].preferences preferences.login_attempt = True login(self.signup) return {"FINISHED"} def invoke(self, context, event): wm = bpy.context.window_manager preferences = bpy.context.preferences.addons[__package__].preferences preferences.api_key_refresh = "" preferences.api_key = "" return wm.invoke_props_dialog(self) class Logout(bpy.types.Operator): """Logout from BlenderKit immediately""" bl_idname = "wm.blenderkit_logout" bl_label = "BlenderKit logout" bl_options = {"REGISTER", "UNDO"} @classmethod def poll(cls, context): return True def execute(self, context): logout() return {"FINISHED"} class CancelLoginOnline(bpy.types.Operator): """Cancel login attempt""" bl_idname = "wm.blenderkit_login_cancel" bl_label = "BlenderKit login cancel" bl_options = {"REGISTER", "UNDO"} @classmethod def poll(cls, context): return True def execute(self, context): preferences = bpy.context.preferences.addons[__package__].preferences preferences.login_attempt = False return {"FINISHED"} classes = ( LoginOnline, CancelLoginOnline, Logout, ) def register(): for c in classes: bpy.utils.register_class(c) def unregister(): for c in classes: bpy.utils.unregister_class(c)