2025-07-01
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
# ##### 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
|
||||
|
||||
|
||||
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
|
||||
from . import override_extension_draw
|
||||
|
||||
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 challange.
|
||||
The challange 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):
|
||||
from . import override_extension_draw
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user