1 # SPDX-License-Identifier: GPL-2.0-or-later
2 # Copyright 2014-2018 Blender Foundation.
5 'name': 'Blender ID authentication',
6 'author': 'Sybren A. Stüvel, Francesco Siddi, and Inês Almeida',
9 'location': 'Add-on preferences',
11 'Stores your Blender ID credentials for usage with other add-ons',
12 "doc_url": "{BLENDER_MANUAL_URL}/addons/system/blender_id.html",
14 'support': 'OFFICIAL',
21 from bpy
.types
import AddonPreferences
, Operator
, PropertyGroup
22 from bpy
.props
import PointerProperty
, StringProperty
23 from bpy
.app
.translations
import pgettext_tip
as tip_
25 if 'communication' in locals():
28 # noinspection PyUnboundLocalVariable
29 communication
= importlib
.reload(communication
)
30 # noinspection PyUnboundLocalVariable
31 profiles
= importlib
.reload(profiles
)
33 from . import communication
, profiles
34 BlenderIdProfile
= profiles
.BlenderIdProfile
35 BlenderIdCommError
= communication
.BlenderIdCommError
37 __all__
= ('get_active_profile', 'get_active_user_id', 'is_logged_in', 'create_subclient_token',
38 'BlenderIdProfile', 'BlenderIdCommError')
41 # Public API functions
42 def get_active_user_id() -> str:
43 """Get the id of the currently active profile. If there is no
44 active profile on the file, this function will return an empty string.
47 return BlenderIdProfile
.user_id
50 def get_active_profile() -> BlenderIdProfile
:
51 """Returns the active Blender ID profile. If there is no
52 active profile on the file, this function will return None.
54 :rtype: BlenderIdProfile
57 if not BlenderIdProfile
.user_id
:
60 return BlenderIdProfile
63 def is_logged_in() -> bool:
64 """Returns whether the user is logged in on Blender ID or not."""
66 return bool(BlenderIdProfile
.user_id
)
69 def create_subclient_token(subclient_id
: str, webservice_endpoint
: str) -> dict:
70 """Lets the Blender ID server create a subclient token.
72 :param subclient_id: the ID of the subclient
73 :param webservice_endpoint: the URL of the endpoint of the webservice
74 that belongs to this subclient.
75 :returns: the token along with its expiry timestamp, in a {'scst': 'token',
76 'expiry': datetime.datetime} dict.
77 :raises: blender_id.communication.BlenderIdCommError when the
78 token cannot be created.
81 # Communication between us and Blender ID.
82 profile
= get_active_profile()
83 scst_info
= communication
.subclient_create_token(profile
.token
, subclient_id
)
84 subclient_token
= scst_info
['token']
86 # Send the token to the webservice.
87 user_id
= communication
.send_token_to_subclient(webservice_endpoint
, profile
.user_id
,
88 subclient_token
, subclient_id
)
90 # Now that everything is okay we can store the token locally.
91 profile
.subclients
[subclient_id
] = {'subclient_user_id': user_id
, 'token': subclient_token
}
97 def get_subclient_user_id(subclient_id
: str) -> str:
98 """Returns the user ID at the given subclient.
100 Requires that the user has been authenticated at the subclient using
101 a call to create_subclient_token(...)
103 :returns: the subclient-local user ID, or the empty string if not logged in.
106 if not BlenderIdProfile
.user_id
:
109 return BlenderIdProfile
.subclients
[subclient_id
]['subclient_user_id']
112 def validate_token() -> typing
.Optional
[str]:
113 """Validates the current user's token with Blender ID.
115 Also refreshes the stored token expiry time.
117 :returns: None if everything was ok, otherwise returns an error message.
120 expires
, err
= communication
.blender_id_server_validate(token
=BlenderIdProfile
.token
)
124 BlenderIdProfile
.expires
= expires
125 BlenderIdProfile
.save_json()
130 def token_expires() -> typing
.Optional
[datetime
.datetime
]:
131 """Returns the token expiry timestamp.
133 Returns None if the token expiry is unknown. This can happen when
134 the last login/validation was performed using a version of this
135 add-on that was older than 1.3.
138 exp
= BlenderIdProfile
.expires
142 # Try parsing as different formats. A new Blender ID is coming,
143 # which may change the format in which timestamps are sent.
145 '%Y-%m-%dT%H:%M:%SZ', # ISO 8601 with Z-suffix
146 '%Y-%m-%dT%H:%M:%S.%fZ', # ISO 8601 with fractional seconds and Z-suffix
147 '%a, %d %b %Y %H:%M:%S GMT', # RFC 1123, used by old Blender ID
151 return datetime
.datetime
.strptime(exp
, fmt
)
153 # Just use the next format string and try again.
156 # Unable to parse, may as well not be there then.
160 class BlenderIdPreferences(AddonPreferences
):
163 error_message
: StringProperty(
164 name
='Error Message',
166 options
={'HIDDEN', 'SKIP_SAVE'}
168 ok_message
: StringProperty(
171 options
={'HIDDEN', 'SKIP_SAVE'}
173 blender_id_username
: StringProperty(
174 name
='E-mail address',
176 options
={'HIDDEN', 'SKIP_SAVE'}
178 blender_id_password
: StringProperty(
181 options
={'HIDDEN', 'SKIP_SAVE'},
185 def reset_messages(self
):
187 self
.error_message
= ''
189 def draw(self
, context
):
192 if self
.error_message
:
194 sub
.alert
= True # labels don't display in red :(
195 sub
.label(text
=self
.error_message
, icon
='ERROR')
198 sub
.label(text
=self
.ok_message
, icon
='FILE_TICK')
200 active_profile
= get_active_profile()
202 expiry
= token_expires()
203 now
= datetime
.datetime
.utcnow()
206 layout
.label(text
='We do not know when your token expires, please validate it.')
208 layout
.label(text
='Your login has expired! Log out and log in again to refresh it.',
211 time_left
= expiry
- now
212 if time_left
.days
> 14:
213 exp_str
= tip_('on {:%Y-%m-%d}').format(expiry
)
214 elif time_left
.days
> 1:
215 exp_str
= tip_('in %i days.') % time_left
.days
216 elif time_left
.seconds
>= 7200:
217 exp_str
= tip_('in %i hours.') % round(time_left
.seconds
/ 3600)
218 elif time_left
.seconds
>= 120:
219 exp_str
= tip_('in %i minutes.') % round(time_left
.seconds
/ 60)
221 exp_str
= tip_('within seconds')
223 endpoint
= communication
.blender_id_endpoint()
224 if endpoint
== communication
.BLENDER_ID_ENDPOINT
:
225 msg
= tip_('You are logged in as %s.') % active_profile
.username
227 msg
= tip_('You are logged in as %s at %s.') % (active_profile
.username
, endpoint
)
229 col
= layout
.column(align
=True)
230 col
.label(text
=msg
, icon
='WORLD_DATA')
231 if time_left
.days
< 14:
232 col
.label(text
=tip_('Your token will expire %s. Please log out and log in again '
233 'to refresh it.') % exp_str
, icon
='PREVIEW_RANGE')
235 col
.label(text
=tip_('Your authentication token expires %s.') % exp_str
,
238 row
= layout
.row().split(factor
=0.8)
239 row
.operator('blender_id.logout')
240 row
.operator('blender_id.validate')
242 layout
.prop(self
, 'blender_id_username')
243 layout
.prop(self
, 'blender_id_password')
245 layout
.operator('blender_id.login')
248 class BlenderIdMixin
:
250 def addon_prefs(context
):
252 prefs
= context
.preferences
253 except AttributeError:
254 prefs
= context
.user_preferences
256 addon_prefs
= prefs
.addons
[__name__
].preferences
257 addon_prefs
.reset_messages()
261 class BlenderIdLogin(BlenderIdMixin
, Operator
):
262 bl_idname
= 'blender_id.login'
265 def execute(self
, context
):
269 addon_prefs
= self
.addon_prefs(context
)
271 auth_result
= communication
.blender_id_server_authenticate(
272 username
=addon_prefs
.blender_id_username
,
273 password
=addon_prefs
.blender_id_password
276 if auth_result
.success
:
277 # Prevent saving the password in user preferences. Overwrite the password with a
278 # random string, as just setting to '' might only replace the first byte with 0.
279 pwlen
= len(addon_prefs
.blender_id_password
)
280 rnd
= ''.join(random
.choice(string
.ascii_uppercase
+ string
.digits
)
281 for _
in range(pwlen
+ 16))
282 addon_prefs
.blender_id_password
= rnd
283 addon_prefs
.blender_id_password
= ''
285 profiles
.save_as_active_profile(
287 addon_prefs
.blender_id_username
,
290 addon_prefs
.ok_message
= tip_('Logged in')
292 addon_prefs
.error_message
= auth_result
.error_message
293 if BlenderIdProfile
.user_id
:
294 profiles
.logout(BlenderIdProfile
.user_id
)
296 BlenderIdProfile
.read_json()
301 class BlenderIdValidate(BlenderIdMixin
, Operator
):
302 bl_idname
= 'blender_id.validate'
303 bl_label
= 'Validate'
305 def execute(self
, context
):
306 addon_prefs
= self
.addon_prefs(context
)
308 err
= validate_token()
310 addon_prefs
.ok_message
= tip_('Authentication token is valid.')
312 addon_prefs
.error_message
= tip_('%s; you probably want to log out and log in again.') % err
314 BlenderIdProfile
.read_json()
319 class BlenderIdLogout(BlenderIdMixin
, Operator
):
320 bl_idname
= 'blender_id.logout'
323 def execute(self
, context
):
324 addon_prefs
= self
.addon_prefs(context
)
326 communication
.blender_id_server_logout(BlenderIdProfile
.user_id
,
327 BlenderIdProfile
.token
)
329 profiles
.logout(BlenderIdProfile
.user_id
)
330 BlenderIdProfile
.read_json()
332 addon_prefs
.ok_message
= tip_('You have been logged out.')
338 BlenderIdProfile
.read_json()
340 bpy
.utils
.register_class(BlenderIdLogin
)
341 bpy
.utils
.register_class(BlenderIdLogout
)
342 bpy
.utils
.register_class(BlenderIdPreferences
)
343 bpy
.utils
.register_class(BlenderIdValidate
)
345 preferences
= BlenderIdMixin
.addon_prefs(bpy
.context
)
346 preferences
.reset_messages()
350 bpy
.utils
.unregister_class(BlenderIdLogin
)
351 bpy
.utils
.unregister_class(BlenderIdLogout
)
352 bpy
.utils
.unregister_class(BlenderIdPreferences
)
353 bpy
.utils
.unregister_class(BlenderIdValidate
)
356 if __name__
== '__main__':