1 # SPDX-FileCopyrightText: 2014-2018 Blender Foundation
3 # SPDX-License-Identifier: GPL-2.0-or-later
6 'name': 'Blender ID authentication',
7 'author': 'Sybren A. Stüvel, Francesco Siddi, and Inês Almeida',
10 'location': 'Add-on preferences',
12 'Stores your Blender ID credentials for usage with other add-ons',
13 "doc_url": "{BLENDER_MANUAL_URL}/addons/system/blender_id.html",
15 'support': 'OFFICIAL',
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():
29 # noinspection PyUnboundLocalVariable
30 communication
= importlib
.reload(communication
)
31 # noinspection PyUnboundLocalVariable
32 profiles
= importlib
.reload(profiles
)
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.
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
58 if not BlenderIdProfile
.user_id
:
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.
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
}
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
:
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
)
125 BlenderIdProfile
.expires
= expires
126 BlenderIdProfile
.save_json()
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
143 # Try parsing as different formats. A new Blender ID is coming,
144 # which may change the format in which timestamps are sent.
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
152 return datetime
.datetime
.strptime(exp
, fmt
)
154 # Just use the next format string and try again.
157 # Unable to parse, may as well not be there then.
161 class BlenderIdPreferences(AddonPreferences
):
164 error_message
: StringProperty(
165 name
='Error Message',
167 options
={'HIDDEN', 'SKIP_SAVE'}
169 ok_message
: StringProperty(
172 options
={'HIDDEN', 'SKIP_SAVE'}
174 blender_id_username
: StringProperty(
175 name
='E-mail address',
177 options
={'HIDDEN', 'SKIP_SAVE'}
179 blender_id_password
: StringProperty(
182 options
={'HIDDEN', 'SKIP_SAVE'},
186 def reset_messages(self
):
188 self
.error_message
= ''
190 def draw(self
, context
):
193 if self
.error_message
:
195 sub
.alert
= True # labels don't display in red :(
196 sub
.label(text
=self
.error_message
, icon
='ERROR')
199 sub
.label(text
=self
.ok_message
, icon
='FILE_TICK')
201 active_profile
= get_active_profile()
203 expiry
= token_expires()
204 now
= datetime
.datetime
.utcnow()
207 layout
.label(text
='We do not know when your token expires, please validate it')
209 layout
.label(text
='Your login has expired! Log out and log in again to refresh it',
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)
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
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')
236 col
.label(text
=tip_('Your authentication token expires %s') % exp_str
,
239 row
= layout
.row().split(factor
=0.8)
240 row
.operator('blender_id.logout')
241 row
.operator('blender_id.validate')
243 layout
.prop(self
, 'blender_id_username')
244 layout
.prop(self
, 'blender_id_password')
246 layout
.operator('blender_id.login')
249 class BlenderIdMixin
:
251 def addon_prefs(context
):
253 prefs
= context
.preferences
254 except AttributeError:
255 prefs
= context
.user_preferences
257 addon_prefs
= prefs
.addons
[__name__
].preferences
258 addon_prefs
.reset_messages()
262 class BlenderIdLogin(BlenderIdMixin
, Operator
):
263 bl_idname
= 'blender_id.login'
266 def execute(self
, context
):
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(
288 addon_prefs
.blender_id_username
,
291 addon_prefs
.ok_message
= tip_('Logged in')
293 addon_prefs
.error_message
= auth_result
.error_message
294 if BlenderIdProfile
.user_id
:
295 profiles
.logout(BlenderIdProfile
.user_id
)
297 BlenderIdProfile
.read_json()
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()
311 addon_prefs
.ok_message
= tip_('Authentication token is valid')
313 addon_prefs
.error_message
= tip_('%s; you probably want to log out and log in again') % err
315 BlenderIdProfile
.read_json()
320 class BlenderIdLogout(BlenderIdMixin
, Operator
):
321 bl_idname
= 'blender_id.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')
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()
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__':