Updated Blender ID add-on to 1.3.0

This commit is contained in:
Sybren A. Stüvel 2017-06-14 15:10:55 +02:00
parent 6de5ea8376
commit 87b9e91c0c
4 changed files with 170 additions and 57 deletions

16
blender_id/CHANGELOG.md Normal file
View File

@ -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/).

View File

@ -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'}

View File

@ -19,12 +19,24 @@
# <pep8 compliant>
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):

View File

@ -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