Merge branch 'blender-v4.0-release'
[blender-addons.git] / blender_id / __init__.py
blobf7bd92c44470f30fbfd96b22388c9f5ab7ebdc04
1 # SPDX-FileCopyrightText: 2014-2018 Blender Foundation
3 # SPDX-License-Identifier: GPL-2.0-or-later
5 bl_info = {
6 'name': 'Blender ID authentication',
7 'author': 'Sybren A. Stüvel, Francesco Siddi, and Inês Almeida',
8 'version': (2, 1, 0),
9 'blender': (2, 80, 0),
10 'location': 'Add-on preferences',
11 'description':
12 'Stores your Blender ID credentials for usage with other add-ons',
13 "doc_url": "{BLENDER_MANUAL_URL}/addons/system/blender_id.html",
14 'category': 'System',
15 'support': 'OFFICIAL',
18 import datetime
19 import typing
21 import bpy
22 from bpy.types import AddonPreferences, Operator, PropertyGroup
23 from bpy.props import PointerProperty, StringProperty
24 from bpy.app.translations import pgettext_tip as tip_
26 if 'communication' in locals():
27 import importlib
29 # noinspection PyUnboundLocalVariable
30 communication = importlib.reload(communication)
31 # noinspection PyUnboundLocalVariable
32 profiles = importlib.reload(profiles)
33 else:
34 from . import communication, profiles
35 BlenderIdProfile = profiles.BlenderIdProfile
36 BlenderIdCommError = communication.BlenderIdCommError
38 __all__ = ('get_active_profile', 'get_active_user_id', 'is_logged_in', 'create_subclient_token',
39 'BlenderIdProfile', 'BlenderIdCommError')
42 # Public API functions
43 def get_active_user_id() -> str:
44 """Get the id of the currently active profile. If there is no
45 active profile on the file, this function will return an empty string.
46 """
48 return BlenderIdProfile.user_id
51 def get_active_profile() -> BlenderIdProfile:
52 """Returns the active Blender ID profile. If there is no
53 active profile on the file, this function will return None.
55 :rtype: BlenderIdProfile
56 """
58 if not BlenderIdProfile.user_id:
59 return None
61 return BlenderIdProfile
64 def is_logged_in() -> bool:
65 """Returns whether the user is logged in on Blender ID or not."""
67 return bool(BlenderIdProfile.user_id)
70 def create_subclient_token(subclient_id: str, webservice_endpoint: str) -> dict:
71 """Lets the Blender ID server create a subclient token.
73 :param subclient_id: the ID of the subclient
74 :param webservice_endpoint: the URL of the endpoint of the webservice
75 that belongs to this subclient.
76 :returns: the token along with its expiry timestamp, in a {'scst': 'token',
77 'expiry': datetime.datetime} dict.
78 :raises: blender_id.communication.BlenderIdCommError when the
79 token cannot be created.
80 """
82 # Communication between us and Blender ID.
83 profile = get_active_profile()
84 scst_info = communication.subclient_create_token(profile.token, subclient_id)
85 subclient_token = scst_info['token']
87 # Send the token to the webservice.
88 user_id = communication.send_token_to_subclient(webservice_endpoint, profile.user_id,
89 subclient_token, subclient_id)
91 # Now that everything is okay we can store the token locally.
92 profile.subclients[subclient_id] = {'subclient_user_id': user_id, 'token': subclient_token}
93 profile.save_json()
95 return scst_info
98 def get_subclient_user_id(subclient_id: str) -> str:
99 """Returns the user ID at the given subclient.
101 Requires that the user has been authenticated at the subclient using
102 a call to create_subclient_token(...)
104 :returns: the subclient-local user ID, or the empty string if not logged in.
107 if not BlenderIdProfile.user_id:
108 return ''
110 return BlenderIdProfile.subclients[subclient_id]['subclient_user_id']
113 def validate_token() -> typing.Optional[str]:
114 """Validates the current user's token with Blender ID.
116 Also refreshes the stored token expiry time.
118 :returns: None if everything was ok, otherwise returns an error message.
121 expires, err = communication.blender_id_server_validate(token=BlenderIdProfile.token)
122 if err is not None:
123 return err
125 BlenderIdProfile.expires = expires
126 BlenderIdProfile.save_json()
128 return None
131 def token_expires() -> typing.Optional[datetime.datetime]:
132 """Returns the token expiry timestamp.
134 Returns None if the token expiry is unknown. This can happen when
135 the last login/validation was performed using a version of this
136 add-on that was older than 1.3.
139 exp = BlenderIdProfile.expires
140 if not exp:
141 return None
143 # Try parsing as different formats. A new Blender ID is coming,
144 # which may change the format in which timestamps are sent.
145 formats = [
146 '%Y-%m-%dT%H:%M:%SZ', # ISO 8601 with Z-suffix
147 '%Y-%m-%dT%H:%M:%S.%fZ', # ISO 8601 with fractional seconds and Z-suffix
148 '%a, %d %b %Y %H:%M:%S GMT', # RFC 1123, used by old Blender ID
150 for fmt in formats:
151 try:
152 return datetime.datetime.strptime(exp, fmt)
153 except ValueError:
154 # Just use the next format string and try again.
155 pass
157 # Unable to parse, may as well not be there then.
158 return None
161 class BlenderIdPreferences(AddonPreferences):
162 bl_idname = __name__
164 error_message: StringProperty(
165 name='Error Message',
166 default='',
167 options={'HIDDEN', 'SKIP_SAVE'}
169 ok_message: StringProperty(
170 name='Message',
171 default='',
172 options={'HIDDEN', 'SKIP_SAVE'}
174 blender_id_username: StringProperty(
175 name='E-mail address',
176 default='',
177 options={'HIDDEN', 'SKIP_SAVE'}
179 blender_id_password: StringProperty(
180 name='Password',
181 default='',
182 options={'HIDDEN', 'SKIP_SAVE'},
183 subtype='PASSWORD'
186 def reset_messages(self):
187 self.ok_message = ''
188 self.error_message = ''
190 def draw(self, context):
191 layout = self.layout
193 if self.error_message:
194 sub = layout.row()
195 sub.alert = True # labels don't display in red :(
196 sub.label(text=self.error_message, icon='ERROR')
197 if self.ok_message:
198 sub = layout.row()
199 sub.label(text=self.ok_message, icon='FILE_TICK')
201 active_profile = get_active_profile()
202 if active_profile:
203 expiry = token_expires()
204 now = datetime.datetime.utcnow()
206 if expiry is None:
207 layout.label(text='We do not know when your token expires, please validate it')
208 elif now >= expiry:
209 layout.label(text='Your login has expired! Log out and log in again to refresh it',
210 icon='ERROR')
211 else:
212 time_left = expiry - now
213 if time_left.days > 14:
214 exp_str = tip_('on {:%Y-%m-%d}').format(expiry)
215 elif time_left.days > 1:
216 exp_str = tip_('in %i days') % time_left.days
217 elif time_left.seconds >= 7200:
218 exp_str = tip_('in %i hours') % round(time_left.seconds / 3600)
219 elif time_left.seconds >= 120:
220 exp_str = tip_('in %i minutes') % round(time_left.seconds / 60)
221 else:
222 exp_str = tip_('within seconds')
224 endpoint = communication.blender_id_endpoint()
225 if endpoint == communication.BLENDER_ID_ENDPOINT:
226 msg = tip_('You are logged in as %s') % active_profile.username
227 else:
228 msg = tip_('You are logged in as %s at %s') % (active_profile.username, endpoint)
230 col = layout.column(align=True)
231 col.label(text=msg, icon='WORLD_DATA')
232 if time_left.days < 14:
233 col.label(text=tip_('Your token will expire %s. Please log out and log in again '
234 'to refresh it') % exp_str, icon='PREVIEW_RANGE')
235 else:
236 col.label(text=tip_('Your authentication token expires %s') % exp_str,
237 icon='BLANK1')
239 row = layout.row().split(factor=0.8)
240 row.operator('blender_id.logout')
241 row.operator('blender_id.validate')
242 else:
243 layout.prop(self, 'blender_id_username')
244 layout.prop(self, 'blender_id_password')
246 layout.operator('blender_id.login')
249 class BlenderIdMixin:
250 @staticmethod
251 def addon_prefs(context):
252 try:
253 prefs = context.preferences
254 except AttributeError:
255 prefs = context.user_preferences
257 addon_prefs = prefs.addons[__name__].preferences
258 addon_prefs.reset_messages()
259 return addon_prefs
262 class BlenderIdLogin(BlenderIdMixin, Operator):
263 bl_idname = 'blender_id.login'
264 bl_label = 'Login'
266 def execute(self, context):
267 import random
268 import string
270 addon_prefs = self.addon_prefs(context)
272 auth_result = communication.blender_id_server_authenticate(
273 username=addon_prefs.blender_id_username,
274 password=addon_prefs.blender_id_password
277 if auth_result.success:
278 # Prevent saving the password in user preferences. Overwrite the password with a
279 # random string, as just setting to '' might only replace the first byte with 0.
280 pwlen = len(addon_prefs.blender_id_password)
281 rnd = ''.join(random.choice(string.ascii_uppercase + string.digits)
282 for _ in range(pwlen + 16))
283 addon_prefs.blender_id_password = rnd
284 addon_prefs.blender_id_password = ''
286 profiles.save_as_active_profile(
287 auth_result,
288 addon_prefs.blender_id_username,
291 addon_prefs.ok_message = tip_('Logged in')
292 else:
293 addon_prefs.error_message = auth_result.error_message
294 if BlenderIdProfile.user_id:
295 profiles.logout(BlenderIdProfile.user_id)
297 BlenderIdProfile.read_json()
299 return {'FINISHED'}
302 class BlenderIdValidate(BlenderIdMixin, Operator):
303 bl_idname = 'blender_id.validate'
304 bl_label = 'Validate'
306 def execute(self, context):
307 addon_prefs = self.addon_prefs(context)
309 err = validate_token()
310 if err is None:
311 addon_prefs.ok_message = tip_('Authentication token is valid')
312 else:
313 addon_prefs.error_message = tip_('%s; you probably want to log out and log in again') % err
315 BlenderIdProfile.read_json()
317 return {'FINISHED'}
320 class BlenderIdLogout(BlenderIdMixin, Operator):
321 bl_idname = 'blender_id.logout'
322 bl_label = 'Logout'
324 def execute(self, context):
325 addon_prefs = self.addon_prefs(context)
327 communication.blender_id_server_logout(BlenderIdProfile.user_id,
328 BlenderIdProfile.token)
330 profiles.logout(BlenderIdProfile.user_id)
331 BlenderIdProfile.read_json()
333 addon_prefs.ok_message = tip_('You have been logged out')
334 return {'FINISHED'}
337 def register():
338 profiles.register()
339 BlenderIdProfile.read_json()
341 bpy.utils.register_class(BlenderIdLogin)
342 bpy.utils.register_class(BlenderIdLogout)
343 bpy.utils.register_class(BlenderIdPreferences)
344 bpy.utils.register_class(BlenderIdValidate)
346 preferences = BlenderIdMixin.addon_prefs(bpy.context)
347 preferences.reset_messages()
350 def unregister():
351 bpy.utils.unregister_class(BlenderIdLogin)
352 bpy.utils.unregister_class(BlenderIdLogout)
353 bpy.utils.unregister_class(BlenderIdPreferences)
354 bpy.utils.unregister_class(BlenderIdValidate)
357 if __name__ == '__main__':
358 register()