Files
blender-portable-repo/extensions/user_default/blenderkit/bkit_oauth.py
T
Raincloud 692e200ffe work
save startup blend for animation tab & whatnot
2026-04-08 12:10:18 -06:00

299 lines
9.8 KiB
Python

# ##### 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
import uuid
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,
search_price,
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()
search_price.clear_price_cache()
# 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)
system_id = get_system_id()
authorize_url = (
f"/o/authorize?client_id={CLIENT_ID}&response_type=code&state={state}"
f"&redirect_uri={redirect_URI}&code_challenge={code_challenge}"
f"&code_challenge_method=S256&system_id={system_id}"
)
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("Login page in browser opened (%s)", 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 get_system_id() -> str:
return f"{uuid.getnode():015d}"
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
search_price.clear_price_cache()
# 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)