diff options
author | Sybren A. Stüvel <sybren@stuvel.eu> | 2016-08-07 12:37:23 +0300 |
---|---|---|
committer | Sybren A. Stüvel <sybren@stuvel.eu> | 2016-08-07 12:37:23 +0300 |
commit | 84a93440fd5c5ecbe80d7bb9743c1747d0bde3eb (patch) | |
tree | f16b034a7d309526dfa24ffe8bc61fb9b9f8a5d1 /blender_id | |
parent | e2ebaa80b48550cdbb0e47f29e39e59ab57085b1 (diff) |
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
Diffstat (limited to 'blender_id')
-rw-r--r-- | blender_id/README.md | 109 | ||||
-rw-r--r-- | blender_id/__init__.py | 277 | ||||
-rw-r--r-- | blender_id/communication.py | 250 | ||||
-rw-r--r-- | blender_id/profiles.py | 216 |
4 files changed, 852 insertions, 0 deletions
diff --git a/blender_id/README.md b/blender_id/README.md new file mode 100644 index 00000000..936e6e3e --- /dev/null +++ b/blender_id/README.md @@ -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() diff --git a/blender_id/__init__.py b/blender_id/__init__.py new file mode 100644 index 00000000..ae24af86 --- /dev/null +++ b/blender_id/__init__.py @@ -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() diff --git a/blender_id/communication.py b/blender_id/communication.py new file mode 100644 index 00000000..ee71c553 --- /dev/null +++ b/blender_id/communication.py @@ -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'] diff --git a/blender_id/profiles.py b/blender_id/profiles.py new file mode 100644 index 00000000..7dd6e121 --- /dev/null +++ b/blender_id/profiles.py @@ -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) |