Fix T49008: Blender-ID add-on for inclusion as OFFICIAL

Added Blender-ID add-on version 1.2.0.

For more info, see the repository at
https://developer.blender.org/diffusion/BIA/

To bundle a new version, run this from the Blender-ID add-on source:

python3 setup.py bdist bundle --path /path/to/blender/release/scripts/addons
This commit is contained in:
Sybren A. Stüvel 2016-08-07 11:37:23 +02:00
parent e2ebaa80b4
commit 84a93440fd
Notes: blender-bot 2023-02-14 19:45:27 +01:00
Referenced by issue #49008, Blender-ID add-on for inclusion as OFFICIAL
4 changed files with 852 additions and 0 deletions

109
blender_id/README.md Normal file
View File

@ -0,0 +1,109 @@
Blender ID addon
================
This addon allows you to authenticate your Blender with your
[Blender ID](https://www.blender.org/id/) account. This authentication
can then be used by other addons, such as the
[Blender Cloud addon](https://developer.blender.org/diffusion/BCA/)
Blender compatibility
---------------------
Blender ID add-on version 1.2.0 removed some workarounds necessary for
Blender 2.77a. As such, versions 1.1.x are the last versions compatible with
Blender 2.77a, and 1.2.0 and newer require at least Blender 2.78.
Building & Bundling
-------------------
* To build the addon, run `python3 setup.py bdist`
* To bundle the addon with Blender, run `python3 setup.py bdist bundle --path
../blender-git/blender/release/scripts/addons`.
* If you don't want to bundle, you can install the addon from Blender
(User Preferences → Addons → Install from file...) by pointing it to
`dist/blender_id*.addon.zip`.
Using the addon
---------------
* Install the addon as described above.
* Enable the addon in User Preferences → Addons → System.
* Sign up for an account at the
[Blender ID site](https://www.blender.org/id/) if you don't have an
account yet.
* Log in with your Blender ID and password. You only have to do this
once.
Your password is never saved on your machine, just an access token. It
is stored next to your Blender configuration files, in
* Linux and similar: `$HOME/.config/blender/{version}/config/blender_id`
* MacOS: `$HOME/Library/Application Support/Blender/{version}/config/blender_id`
* Windows: `%APPDATA%\Blender Foundation\Blender\{version}\config\blender_id`
where `{version}` is the Blender version.
Using the addon from another addon
----------------------------------
The following functions can be used from other addons to use the Blender
ID functionality:
**blender_id.get_active_profile()** returns the `BlenderIdProfile` that
represents the currently logged in user, or `None` when the user isn't
logged in:
lang=python
class BlenderIdProfile:
user_id = '41234'
username = 'username@example.com'
token = '41344124-auth-token-434134'
**blender_id.get_active_user_id()** returns the user ID of the logged
in user, or `''` when the user isn't logged in.
**blender_id.is_logged_in()** returns `True` if the user is logged
in, and `False` otherwise.
Here is an example of a simple addon that shows your username in its
preferences panel:
lang=python,name=demo_blender_id_addon.py
# Extend this with your info
bl_info = {
'name': 'Demo addon using Blender ID',
'location': 'Add-on preferences',
'category': 'System',
'support': 'TESTING',
}
import bpy
class DemoPreferences(bpy.types.AddonPreferences):
bl_idname = __name__
def draw(self, context):
import blender_id
profile = blender_id.get_active_profile()
if profile:
self.layout.label('You are logged in as %s' % profile.username)
else:
self.layout.label('You are not logged in on Blender ID')
def register():
bpy.utils.register_module(__name__)
def unregister():
bpy.utils.unregister_module(__name__)
if __name__ == '__main__':
register()

277
blender_id/__init__.py Normal file
View File

@ -0,0 +1,277 @@
# ##### 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 #####
# <pep8 compliant>
bl_info = {
'name': 'Blender ID authentication',
'author': 'Francesco Siddi, Inês Almeida and Sybren A. Stüvel',
'version': (1, 2, 0),
'blender': (2, 77, 0),
'location': 'Add-on preferences',
'description':
'Stores your Blender ID credentials for usage with other add-ons',
'wiki_url': 'http://wiki.blender.org/index.php/Extensions:2.6/Py/'
'Scripts/System/BlenderID',
'category': 'System',
'support': 'OFFICIAL',
}
import bpy
from bpy.types import AddonPreferences, Operator, PropertyGroup
from bpy.props import PointerProperty, StringProperty
if 'communication' in locals():
import importlib
# noinspection PyUnboundLocalVariable
communication = importlib.reload(communication)
# noinspection PyUnboundLocalVariable
profiles = importlib.reload(profiles)
else:
from . import communication, profiles
BlenderIdProfile = profiles.BlenderIdProfile
BlenderIdCommError = communication.BlenderIdCommError
__all__ = ('get_active_profile', 'get_active_user_id', 'is_logged_in', 'create_subclient_token',
'BlenderIdProfile', 'BlenderIdCommError')
# Public API functions
def get_active_user_id() -> str:
"""Get the id of the currently active profile. If there is no
active profile on the file, this function will return an empty string.
"""
return BlenderIdProfile.user_id
def get_active_profile() -> BlenderIdProfile:
"""Returns the active Blender ID profile. If there is no
active profile on the file, this function will return None.
:rtype: BlenderIdProfile
"""
if not BlenderIdProfile.user_id:
return None
return BlenderIdProfile
def is_logged_in() -> bool:
"""Returns whether the user is logged in on Blender ID or not."""
return bool(BlenderIdProfile.user_id)
def create_subclient_token(subclient_id: str, webservice_endpoint: str) -> dict:
"""Lets the Blender ID server create a subclient token.
:param subclient_id: the ID of the subclient
:param webservice_endpoint: the URL of the endpoint of the webservice
that belongs to this subclient.
:returns: the token along with its expiry timestamp, in a {'scst': 'token',
'expiry': datetime.datetime} dict.
:raises: blender_id.communication.BlenderIdCommError when the
token cannot be created.
"""
# Communication between us and Blender ID.
profile = get_active_profile()
scst_info = communication.subclient_create_token(profile.token, subclient_id)
subclient_token = scst_info['token']
# Send the token to the webservice.
user_id = communication.send_token_to_subclient(webservice_endpoint, profile.user_id,
subclient_token, subclient_id)
# Now that everything is okay we can store the token locally.
profile.subclients[subclient_id] = {'subclient_user_id': user_id, 'token': subclient_token}
profile.save_json()
return scst_info
def get_subclient_user_id(subclient_id: str) -> str:
"""Returns the user ID at the given subclient.
Requires that the user has been authenticated at the subclient using
a call to create_subclient_token(...)
:returns: the subclient-local user ID, or None if not logged in.
"""
if not BlenderIdProfile.user_id:
return None
return BlenderIdProfile.subclients[subclient_id]['subclient_user_id']
class BlenderIdPreferences(AddonPreferences):
bl_idname = __name__
error_message = StringProperty(
name='Error Message',
default='',
options={'HIDDEN', 'SKIP_SAVE'}
)
ok_message = StringProperty(
name='Message',
default='',
options={'HIDDEN', 'SKIP_SAVE'}
)
blender_id_username = StringProperty(
name='E-mail address',
default='',
options={'HIDDEN', 'SKIP_SAVE'}
)
blender_id_password = StringProperty(
name='Password',
default='',
options={'HIDDEN', 'SKIP_SAVE'},
subtype='PASSWORD'
)
def reset_messages(self):
self.ok_message = ''
self.error_message = ''
def draw(self, context):
layout = self.layout
if self.error_message:
sub = layout.row()
sub.alert = True # labels don't display in red :(
sub.label(self.error_message, icon='ERROR')
if self.ok_message:
sub = layout.row()
sub.label(self.ok_message, icon='FILE_TICK')
active_profile = get_active_profile()
if active_profile:
text = 'You are logged in as {0}'.format(active_profile.username)
layout.label(text=text, icon='WORLD_DATA')
row = layout.row()
row.operator('blender_id.logout')
if bpy.app.debug:
row.operator('blender_id.validate')
else:
layout.prop(self, 'blender_id_username')
layout.prop(self, 'blender_id_password')
layout.operator('blender_id.login')
class BlenderIdMixin:
@staticmethod
def addon_prefs(context):
preferences = context.user_preferences.addons[__name__].preferences
preferences.reset_messages()
return preferences
class BlenderIdLogin(BlenderIdMixin, Operator):
bl_idname = 'blender_id.login'
bl_label = 'Login'
def execute(self, context):
import random
import string
addon_prefs = self.addon_prefs(context)
resp = communication.blender_id_server_authenticate(
username=addon_prefs.blender_id_username,
password=addon_prefs.blender_id_password
)
if resp['status'] == 'success':
# Prevent saving the password in user preferences. Overwrite the password with a
# random string, as just setting to '' might only replace the first byte with 0.
pwlen = len(addon_prefs.blender_id_password)
rnd = ''.join(random.choice(string.ascii_uppercase + string.digits)
for _ in range(pwlen + 16))
addon_prefs.blender_id_password = rnd
addon_prefs.blender_id_password = ''
profiles.save_as_active_profile(
resp['user_id'],
resp['token'],
addon_prefs.blender_id_username,
{}
)
addon_prefs.ok_message = 'Logged in'
else:
addon_prefs.error_message = resp['error_message']
if BlenderIdProfile.user_id:
profiles.logout(BlenderIdProfile.user_id)
BlenderIdProfile.read_json()
return {'FINISHED'}
class BlenderIdValidate(BlenderIdMixin, Operator):
bl_idname = 'blender_id.validate'
bl_label = 'Validate'
def execute(self, context):
addon_prefs = self.addon_prefs(context)
resp = communication.blender_id_server_validate(token=BlenderIdProfile.token)
if resp is None:
addon_prefs.ok_message = 'Authentication token is valid.'
else:
addon_prefs.error_message = '%s; you probably want to log out and log in again.' % resp
BlenderIdProfile.read_json()
return {'FINISHED'}
class BlenderIdLogout(BlenderIdMixin, Operator):
bl_idname = 'blender_id.logout'
bl_label = 'Logout'
def execute(self, context):
communication.blender_id_server_logout(BlenderIdProfile.user_id,
BlenderIdProfile.token)
profiles.logout(BlenderIdProfile.user_id)
BlenderIdProfile.read_json()
return {'FINISHED'}
def register():
profiles.register()
BlenderIdProfile.read_json()
bpy.utils.register_module(__name__)
preferences = bpy.context.user_preferences.addons[__name__].preferences
preferences.reset_messages()
def unregister():
bpy.utils.unregister_module(__name__)
if __name__ == '__main__':
register()

250
blender_id/communication.py Normal file
View File

@ -0,0 +1,250 @@
# ##### 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 #####
# <pep8 compliant>
import functools
class BlenderIdCommError(RuntimeError):
"""Raised when there was an error communicating with Blender ID"""
@functools.lru_cache(maxsize=None)
def host_label():
import socket
return 'Blender running on %r' % socket.gethostname()
@functools.lru_cache(maxsize=None)
def blender_id_endpoint(endpoint_path=None):
"""Gets the endpoint for the authentication API. If the BLENDER_ID_ENDPOINT env variable
is defined, it's possible to override the (default) production address.
"""
import os
import urllib.parse
base_url = os.environ.get('BLENDER_ID_ENDPOINT', 'https://www.blender.org/id/')
# urljoin() is None-safe for the 2nd parameter.
return urllib.parse.urljoin(base_url, endpoint_path)
def blender_id_server_authenticate(username, password):
"""Authenticate the user with the server with a single transaction
containing username and password (must happen via HTTPS).
If the transaction is successful, status will be 'successful' and we
return the user's unique blender id and a token (that will be used to
represent that username and password combination).
If there was a problem, status will be 'fail' and we return an error
message. Problems may be with the connection or wrong user/password.
"""
import requests
import requests.exceptions
payload = dict(
username=username,
password=password,
host_label=host_label()
)
url = blender_id_endpoint('u/identify')
try:
r = requests.post(url, data=payload, verify=True)
except (requests.exceptions.SSLError,
requests.exceptions.HTTPError,
requests.exceptions.ConnectionError) as e:
print('Exception POSTing to {}: {}'.format(url, e))
return dict(
status='fail',
user_id=None,
token=None,
error_message=str(e)
)
user_id = None
token = None
error_message = None
if r.status_code == 200:
resp = r.json()
status = resp['status']
if status == 'success':
user_id = str(resp['data']['user_id'])
# We just use the access token for now.
token = resp['data']['oauth_token']['access_token']
elif status == 'fail':
error_message = 'Username and/or password is incorrect'
else:
status = 'fail'
error_message = format('There was a problem communicating with'
' the server. Error code is: %s' % r.status_code)
return dict(
status=status,
user_id=user_id,
token=token,
error_message=error_message
)
def blender_id_server_validate(token):
"""Validate the auth token with the server.
@param token: the authentication token
@type token: str
@returns: None if the token is valid, or an error message when it's invalid.
"""
import requests
import requests.exceptions
try:
r = requests.post(blender_id_endpoint('u/validate_token'),
data={'token': token}, verify=True)
except requests.exceptions.RequestException as e:
return str(e)
if r.status_code == 200:
return None
return 'Authentication token invalid'
def blender_id_server_logout(user_id, token):
"""Logs out of the Blender ID service by removing the token server-side.
@param user_id: the email address of the user.
@type user_id: str
@param token: the token to remove
@type token: str
@return: {'status': 'fail' or 'success', 'error_message': str}
@rtype: dict
"""
import requests
import requests.exceptions
payload = dict(
user_id=user_id,
token=token
)
try:
r = requests.post(blender_id_endpoint('u/delete_token'),
data=payload, verify=True)
except (requests.exceptions.SSLError,
requests.exceptions.HTTPError,
requests.exceptions.ConnectionError) as e:
return dict(
status='fail',
error_message=format('There was a problem setting up a connection to '
'the server. Error type is: %s' % type(e).__name__)
)
if r.status_code != 200:
return dict(
status='fail',
error_message=format('There was a problem communicating with'
' the server. Error code is: %s' % r.status_code)
)
resp = r.json()
return dict(
status=resp['status'],
error_message=None
)
def subclient_create_token(auth_token: str, subclient_id: str) -> dict:
"""Creates a subclient-specific authentication token.
:returns: the token along with its expiry timestamp, in a {'scst': 'token',
'expiry': datetime.datetime} dict.
"""
payload = {'subclient_id': subclient_id,
'host_label': host_label()}
r = make_authenticated_call('POST', 'subclients/create_token', auth_token, payload)
if r.status_code == 401:
raise BlenderIdCommError('Your Blender ID login is not valid, try logging in again.')
if r.status_code != 201:
raise BlenderIdCommError('Invalid response, HTTP code %i received' % r.status_code)
resp = r.json()
if resp['status'] != 'success':
raise BlenderIdCommError(resp['message'])
return resp['data']
def make_authenticated_call(method, url, auth_token, data):
"""Makes a HTTP call authenticated with the OAuth token."""
import requests
import requests.exceptions
try:
r = requests.request(method,
blender_id_endpoint(url),
data=data,
headers={'Authorization': 'Bearer %s' % auth_token},
verify=True)
except (requests.exceptions.HTTPError,
requests.exceptions.ConnectionError) as e:
raise BlenderIdCommError(str(e))
return r
def send_token_to_subclient(webservice_endpoint: str, user_id: str,
subclient_token: str, subclient_id: str) -> str:
"""Sends the subclient-specific token to the subclient.
The subclient verifies this token with BlenderID. If it's accepted, the
subclient ensures there is a valid user created server-side. The ID of
that user is returned.
:returns: the user ID at the subclient.
"""
import requests
import urllib.parse
url = urllib.parse.urljoin(webservice_endpoint, 'blender_id/store_scst')
try:
r = requests.post(url,
data={'user_id': user_id,
'subclient_id': subclient_id,
'token': subclient_token},
verify=True)
r.raise_for_status()
except (requests.exceptions.HTTPError,
requests.exceptions.ConnectionError) as e:
raise BlenderIdCommError(str(e))
resp = r.json()
if resp['status'] != 'success':
raise BlenderIdCommError('Error sending subclient-specific token to %s, error is: %s'
% (webservice_endpoint, resp))
return resp['subclient_user_id']

216
blender_id/profiles.py Normal file
View File

@ -0,0 +1,216 @@
# ##### 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 #####
# <pep8 compliant>
import os
import bpy
# Set/created upon register.
profiles_path = ''
profiles_file = ''
class _BIPMeta(type):
"""Metaclass for BlenderIdProfile."""
def __str__(self):
# noinspection PyUnresolvedReferences
return '%s(user_id=%r)' % (self.__qualname__, self.user_id)
class BlenderIdProfile(metaclass=_BIPMeta):
"""Current Blender ID profile.
This is always stored at class level, as there is only one current
profile anyway.
"""
user_id = ''
username = ''
token = ''
subclients = {}
@classmethod
def read_json(cls):
"""Updates the active profile information from the JSON file."""
active_profile = get_active_profile()
if active_profile:
cls.user_id = active_profile['user_id']
cls.username = active_profile['username']
cls.token = active_profile['token']
cls.subclients = active_profile.get('subclients', {})
else:
cls.user_id = ''
cls.username = ''
cls.token = ''
cls.subclients = {} # mapping from subclient-ID to user info dict.
@classmethod
def save_json(cls, make_active_profile=False):
"""Updates the JSON file with the active profile information."""
jsonfile = get_profiles_data()
jsonfile['profiles'][cls.user_id] = {
'username': cls.username,
'token': cls.token,
'subclients': cls.subclients,
}
if make_active_profile:
jsonfile['active_profile'] = cls.user_id
save_profiles_data(jsonfile)
def register():
global profiles_path, profiles_file
profiles_path = bpy.utils.user_resource('CONFIG', 'blender_id', create=True)
profiles_file = os.path.join(profiles_path, 'profiles.json')
def _create_default_file():
"""Creates the default profile file, returning its contents."""
import json
profiles_default_data = {
'active_profile': None,
'profiles': {}
}
os.makedirs(profiles_path, exist_ok=True)
# Populate the file, ensuring that its permissions are restrictive enough.
old_umask = os.umask(0o077)
try:
with open(profiles_file, 'w', encoding='utf8') as outfile:
json.dump(profiles_default_data, outfile)
finally:
os.umask(old_umask)
return profiles_default_data
def get_profiles_data():
"""Returns the profiles.json content from a blender_id folder in the
Blender config directory. If the file does not exist we create one with the
basic data structure.
"""
import json
# if the file does not exist
if not os.path.exists(profiles_file):
return _create_default_file()
# try parsing the file
with open(profiles_file, 'r', encoding='utf8') as f:
try:
file_data = json.load(f)
file_data['active_profile']
file_data['profiles']
return file_data
except (ValueError, # malformed json data
KeyError): # it doesn't have the expected content
print('(%s) '
'Warning: profiles.json is either empty or malformed. '
'The file will be reset.' % __name__)
# overwrite the file
return _create_default_file()
def get_active_user_id():
"""Get the id of the currently active profile. If there is no
active profile on the file, this function will return None.
"""
return get_profiles_data()['active_profile']
def get_active_profile():
"""Pick the active profile from profiles.json. If there is no
active profile on the file, this function will return None.
@returns: dict like {'user_id': 1234, 'username': 'email@blender.org'}
"""
file_content = get_profiles_data()
user_id = file_content['active_profile']
if not user_id or user_id not in file_content['profiles']:
return None
profile = file_content['profiles'][user_id]
profile['user_id'] = user_id
return profile
def get_profile(user_id):
"""Loads the profile data for a given user_id if existing
else it returns None.
"""
file_content = get_profiles_data()
if not user_id or user_id not in file_content['profiles']:
return None
profile = file_content['profiles'][user_id]
return dict(
username=profile['username'],
token=profile['token']
)
def save_profiles_data(all_profiles: dict):
"""Saves the profiles data to JSON."""
import json
with open(profiles_file, 'w', encoding='utf8') as outfile:
json.dump(all_profiles, outfile, sort_keys=True)
def save_as_active_profile(user_id, token, username, subclients):
"""Saves the given info as the active profile."""
BlenderIdProfile.user_id = user_id
BlenderIdProfile.token = token
BlenderIdProfile.username = username
BlenderIdProfile.subclients = subclients
BlenderIdProfile.save_json(make_active_profile=True)
def logout(user_id):
"""Invalidates the token and state of active for this user.
This is different from switching the active profile, where the active
profile is changed but there isn't an explicit logout.
"""
import json
file_content = get_profiles_data()
# Remove user from 'active profile'
if file_content['active_profile'] == user_id:
file_content['active_profile'] = ""
# Remove both user and token from profiles list
if user_id in file_content['profiles']:
del file_content['profiles'][user_id]
with open(profiles_file, 'w', encoding='utf8') as outfile:
json.dump(file_content, outfile)