From 87b9e91c0c8843429ef6712f9c96f423b29d7457 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sybren=20A=2E=20St=C3=BCvel?= Date: Wed, 14 Jun 2017 15:10:55 +0200 Subject: Updated Blender ID add-on to 1.3.0 --- blender_id/CHANGELOG.md | 16 +++++++ blender_id/__init__.py | 106 +++++++++++++++++++++++++++++++++++++++----- blender_id/communication.py | 65 ++++++++++++++------------- blender_id/profiles.py | 40 +++++++++++------ 4 files changed, 170 insertions(+), 57 deletions(-) create mode 100644 blender_id/CHANGELOG.md diff --git a/blender_id/CHANGELOG.md b/blender_id/CHANGELOG.md new file mode 100644 index 00000000..201ee1d0 --- /dev/null +++ b/blender_id/CHANGELOG.md @@ -0,0 +1,16 @@ +# Blender ID Add-on Changelog + + +## Version 1.3 (released 2017-06-14) + +- Show a message after logging out. +- Store token expiry date in profile JSON. +- Show "validate" button when the token expiration is unknown. +- Urge the user to log out & back in again to refresh the auth token if it expires within 2 weeks. +- Added a method `validate_token()` to the public Blender ID Add-on API. + + +## Older versions + +The history of older versions can be found in the +[Blender ID Add-on Git repository](https://developer.blender.org/diffusion/BIA/). diff --git a/blender_id/__init__.py b/blender_id/__init__.py index ae24af86..a7094c7f 100644 --- a/blender_id/__init__.py +++ b/blender_id/__init__.py @@ -21,7 +21,7 @@ bl_info = { 'name': 'Blender ID authentication', 'author': 'Francesco Siddi, Inês Almeida and Sybren A. Stüvel', - 'version': (1, 2, 0), + 'version': (1, 3, 0), 'blender': (2, 77, 0), 'location': 'Add-on preferences', 'description': @@ -32,6 +32,9 @@ bl_info = { 'support': 'OFFICIAL', } +import datetime +import typing + import bpy from bpy.types import AddonPreferences, Operator, PropertyGroup from bpy.props import PointerProperty, StringProperty @@ -123,6 +126,53 @@ def get_subclient_user_id(subclient_id: str) -> str: return BlenderIdProfile.subclients[subclient_id]['subclient_user_id'] +def validate_token() -> typing.Optional[str]: + """Validates the current user's token with Blender ID. + + Also refreshes the stored token expiry time. + + :returns: None if everything was ok, otherwise returns an error message. + """ + + expires, err = communication.blender_id_server_validate(token=BlenderIdProfile.token) + if err is not None: + return err + + BlenderIdProfile.expires = expires + BlenderIdProfile.save_json() + + return None + + +def token_expires() -> typing.Optional[datetime.datetime]: + """Returns the token expiry timestamp. + + Returns None if the token expiry is unknown. This can happen when + the last login/validation was performed using a version of this + add-on that was older than 1.3. + """ + + exp = BlenderIdProfile.expires + if not exp: + return None + + # Try parsing as different formats. A new Blender ID is coming, + # which may change the format in which timestamps are sent. + formats = [ + '%Y-%m-%dT%H:%M:%S.%fZ', # ISO 8601 with Z-suffix, used by new Blender ID + '%a, %d %b %Y %H:%M:%S GMT', # RFC 1123, used by current Blender ID + ] + for fmt in formats: + try: + return datetime.datetime.strptime(exp, fmt) + except ValueError: + # Just use the next format string and try again. + pass + + # Unable to parse, may as well not be there then. + return None + + class BlenderIdPreferences(AddonPreferences): bl_idname = __name__ @@ -165,11 +215,41 @@ class BlenderIdPreferences(AddonPreferences): 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') + expiry = token_expires() + now = datetime.datetime.utcnow() + show_validate_button = bpy.app.debug + + if expiry is None: + layout.label(text='We do not know when your token expires, please validate it.') + show_validate_button = True + elif now >= expiry: + layout.label(text='Your login has expired! Log out and log in again to refresh it.', + icon='ERROR') + else: + time_left = expiry - now + if time_left.days > 14: + exp_str = 'on {:%Y-%m-%d}'.format(expiry) + elif time_left.days > 1: + exp_str = 'in %i days.' % time_left.days + elif time_left.seconds >= 7200: + exp_str = 'in %i hours.' % round(time_left.seconds / 3600) + elif time_left.seconds >= 120: + exp_str = 'in %i minutes.' % round(time_left.seconds / 60) + else: + exp_str = 'within seconds' + + if time_left.days < 14: + layout.label('You are logged in as %s.' % active_profile.username, + icon='WORLD_DATA') + layout.label(text='Your token will expire %s. Please log out and log in again ' + 'to refresh it.' % exp_str, icon='PREVIEW_RANGE') + else: + layout.label('You are logged in as %s. Your authentication token expires %s.' + % (active_profile.username, exp_str), icon='WORLD_DATA') + row = layout.row() row.operator('blender_id.logout') - if bpy.app.debug: + if show_validate_button: row.operator('blender_id.validate') else: layout.prop(self, 'blender_id_username') @@ -196,12 +276,12 @@ class BlenderIdLogin(BlenderIdMixin, Operator): addon_prefs = self.addon_prefs(context) - resp = communication.blender_id_server_authenticate( + auth_result = communication.blender_id_server_authenticate( username=addon_prefs.blender_id_username, password=addon_prefs.blender_id_password ) - if resp['status'] == 'success': + if auth_result.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) @@ -211,14 +291,13 @@ class BlenderIdLogin(BlenderIdMixin, Operator): addon_prefs.blender_id_password = '' profiles.save_as_active_profile( - resp['user_id'], - resp['token'], + auth_result, addon_prefs.blender_id_username, {} ) addon_prefs.ok_message = 'Logged in' else: - addon_prefs.error_message = resp['error_message'] + addon_prefs.error_message = auth_result.error_message if BlenderIdProfile.user_id: profiles.logout(BlenderIdProfile.user_id) @@ -234,11 +313,11 @@ class BlenderIdValidate(BlenderIdMixin, Operator): def execute(self, context): addon_prefs = self.addon_prefs(context) - resp = communication.blender_id_server_validate(token=BlenderIdProfile.token) - if resp is None: + err = validate_token() + if err 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 + addon_prefs.error_message = '%s; you probably want to log out and log in again.' % err BlenderIdProfile.read_json() @@ -250,12 +329,15 @@ class BlenderIdLogout(BlenderIdMixin, Operator): bl_label = 'Logout' def execute(self, context): + addon_prefs = self.addon_prefs(context) + communication.blender_id_server_logout(BlenderIdProfile.user_id, BlenderIdProfile.token) profiles.logout(BlenderIdProfile.user_id) BlenderIdProfile.read_json() + addon_prefs.ok_message = 'You have been logged out.' return {'FINISHED'} diff --git a/blender_id/communication.py b/blender_id/communication.py index ee71c553..90ccf9a1 100644 --- a/blender_id/communication.py +++ b/blender_id/communication.py @@ -19,12 +19,24 @@ # import functools +import typing class BlenderIdCommError(RuntimeError): """Raised when there was an error communicating with Blender ID""" +class AuthResult: + def __init__(self, *, success: bool, + user_id: str=None, token: str=None, expires: str=None, + error_message: typing.Any=None): # when success=False + self.success = success + self.user_id = user_id + self.token = token + self.error_message = str(error_message) + self.expires = expires + + @functools.lru_cache(maxsize=None) def host_label(): import socket @@ -46,7 +58,7 @@ def blender_id_endpoint(endpoint_path=None): return urllib.parse.urljoin(base_url, endpoint_path) -def blender_id_server_authenticate(username, password): +def blender_id_server_authenticate(username, password) -> AuthResult: """Authenticate the user with the server with a single transaction containing username and password (must happen via HTTPS). @@ -73,45 +85,33 @@ def blender_id_server_authenticate(username, password): 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 + return AuthResult(status, error_message=e) 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 AuthResult(success=True, + user_id=str(resp['data']['user_id']), + token=resp['data']['oauth_token']['access_token'], + expires=resp['data']['oauth_token']['expires'], + ) + if status == 'fail': + return AuthResult(success=False, error_message='Username and/or password is incorrect') - return dict( - status=status, - user_id=user_id, - token=token, - error_message=error_message - ) + return AuthResult(success=False, + error_message='There was a problem communicating with' + ' the server. Error code is: %s' % r.status_code) -def blender_id_server_validate(token): +def blender_id_server_validate(token) -> typing.Tuple[typing.Optional[str], typing.Optional[str]]: """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. + @returns: tuple (expiry, error). + The expiry is the expiry date of the token if it is valid, else None. + The error is None if the token is valid, or an error message when it's invalid. """ import requests @@ -121,12 +121,13 @@ def blender_id_server_validate(token): r = requests.post(blender_id_endpoint('u/validate_token'), data={'token': token}, verify=True) except requests.exceptions.RequestException as e: - return str(e) + return (str(e), None) - if r.status_code == 200: - return None + if r.status_code != 200: + return (None, 'Authentication token invalid') - return 'Authentication token invalid' + response = r.json() + return (response['token_expires'], None) def blender_id_server_logout(user_id, token): diff --git a/blender_id/profiles.py b/blender_id/profiles.py index 7dd6e121..2e872a50 100644 --- a/blender_id/profiles.py +++ b/blender_id/profiles.py @@ -21,6 +21,8 @@ import os import bpy +from . import communication + # Set/created upon register. profiles_path = '' profiles_file = '' @@ -44,23 +46,32 @@ class BlenderIdProfile(metaclass=_BIPMeta): user_id = '' username = '' token = '' + expires = '' subclients = {} + @classmethod + def reset(cls): + cls.user_id = '' + cls.username = '' + cls.token = '' + cls.expires = '' + cls.subclients = {} + @classmethod def read_json(cls): """Updates the active profile information from the JSON file.""" + cls.reset() + 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. + if not active_profile: + return + + for key, value in active_profile.items(): + if hasattr(cls, key): + setattr(cls, key, value) + else: + print('Skipping key %r from profile JSON' % key) @classmethod def save_json(cls, make_active_profile=False): @@ -70,6 +81,7 @@ class BlenderIdProfile(metaclass=_BIPMeta): jsonfile['profiles'][cls.user_id] = { 'username': cls.username, 'token': cls.token, + 'expires': cls.expires, 'subclients': cls.subclients, } @@ -184,11 +196,13 @@ def save_profiles_data(all_profiles: dict): json.dump(all_profiles, outfile, sort_keys=True) -def save_as_active_profile(user_id, token, username, subclients): +def save_as_active_profile(auth_result: communication.AuthResult, username, subclients): """Saves the given info as the active profile.""" - BlenderIdProfile.user_id = user_id - BlenderIdProfile.token = token + BlenderIdProfile.user_id = auth_result.user_id + BlenderIdProfile.token = auth_result.token + BlenderIdProfile.expires = auth_result.expires + BlenderIdProfile.username = username BlenderIdProfile.subclients = subclients -- cgit v1.2.3