1 # ##### BEGIN GPL LICENSE BLOCK #####
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; either version 2
6 # of the License, or (at your option) any later version.
8 # This program is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # GNU General Public License for more details.
13 # You should have received a copy of the GNU General Public License
14 # along with this program; if not, write to the Free Software Foundation,
15 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 # Copyright (C) 2014-2018 Blender Foundation
19 # ##### END GPL LICENSE BLOCK #####
24 'name': 'Blender ID authentication',
25 'author': 'Sybren A. Stüvel, Francesco Siddi, and Inês Almeida',
27 'blender': (2, 80, 0),
28 'location': 'Add-on preferences',
30 'Stores your Blender ID credentials for usage with other add-ons',
31 "wiki_url": "https://docs.blender.org/manual/en/dev/addons/"
32 "system/blender_id.html",
34 'support': 'OFFICIAL',
41 from bpy
.types
import AddonPreferences
, Operator
, PropertyGroup
42 from bpy
.props
import PointerProperty
, StringProperty
44 if 'communication' in locals():
47 # noinspection PyUnboundLocalVariable
48 communication
= importlib
.reload(communication
)
49 # noinspection PyUnboundLocalVariable
50 profiles
= importlib
.reload(profiles
)
52 from . import communication
, profiles
53 BlenderIdProfile
= profiles
.BlenderIdProfile
54 BlenderIdCommError
= communication
.BlenderIdCommError
56 __all__
= ('get_active_profile', 'get_active_user_id', 'is_logged_in', 'create_subclient_token',
57 'BlenderIdProfile', 'BlenderIdCommError')
60 # Public API functions
61 def get_active_user_id() -> str:
62 """Get the id of the currently active profile. If there is no
63 active profile on the file, this function will return an empty string.
66 return BlenderIdProfile
.user_id
69 def get_active_profile() -> BlenderIdProfile
:
70 """Returns the active Blender ID profile. If there is no
71 active profile on the file, this function will return None.
73 :rtype: BlenderIdProfile
76 if not BlenderIdProfile
.user_id
:
79 return BlenderIdProfile
82 def is_logged_in() -> bool:
83 """Returns whether the user is logged in on Blender ID or not."""
85 return bool(BlenderIdProfile
.user_id
)
88 def create_subclient_token(subclient_id
: str, webservice_endpoint
: str) -> dict:
89 """Lets the Blender ID server create a subclient token.
91 :param subclient_id: the ID of the subclient
92 :param webservice_endpoint: the URL of the endpoint of the webservice
93 that belongs to this subclient.
94 :returns: the token along with its expiry timestamp, in a {'scst': 'token',
95 'expiry': datetime.datetime} dict.
96 :raises: blender_id.communication.BlenderIdCommError when the
97 token cannot be created.
100 # Communication between us and Blender ID.
101 profile
= get_active_profile()
102 scst_info
= communication
.subclient_create_token(profile
.token
, subclient_id
)
103 subclient_token
= scst_info
['token']
105 # Send the token to the webservice.
106 user_id
= communication
.send_token_to_subclient(webservice_endpoint
, profile
.user_id
,
107 subclient_token
, subclient_id
)
109 # Now that everything is okay we can store the token locally.
110 profile
.subclients
[subclient_id
] = {'subclient_user_id': user_id
, 'token': subclient_token
}
116 def get_subclient_user_id(subclient_id
: str) -> str:
117 """Returns the user ID at the given subclient.
119 Requires that the user has been authenticated at the subclient using
120 a call to create_subclient_token(...)
122 :returns: the subclient-local user ID, or the empty string if not logged in.
125 if not BlenderIdProfile
.user_id
:
128 return BlenderIdProfile
.subclients
[subclient_id
]['subclient_user_id']
131 def validate_token() -> typing
.Optional
[str]:
132 """Validates the current user's token with Blender ID.
134 Also refreshes the stored token expiry time.
136 :returns: None if everything was ok, otherwise returns an error message.
139 expires
, err
= communication
.blender_id_server_validate(token
=BlenderIdProfile
.token
)
143 BlenderIdProfile
.expires
= expires
144 BlenderIdProfile
.save_json()
149 def token_expires() -> typing
.Optional
[datetime
.datetime
]:
150 """Returns the token expiry timestamp.
152 Returns None if the token expiry is unknown. This can happen when
153 the last login/validation was performed using a version of this
154 add-on that was older than 1.3.
157 exp
= BlenderIdProfile
.expires
161 # Try parsing as different formats. A new Blender ID is coming,
162 # which may change the format in which timestamps are sent.
164 '%Y-%m-%dT%H:%M:%SZ', # ISO 8601 with Z-suffix
165 '%Y-%m-%dT%H:%M:%S.%fZ', # ISO 8601 with fractional seconds and Z-suffix
166 '%a, %d %b %Y %H:%M:%S GMT', # RFC 1123, used by old Blender ID
170 return datetime
.datetime
.strptime(exp
, fmt
)
172 # Just use the next format string and try again.
175 # Unable to parse, may as well not be there then.
179 class BlenderIdPreferences(AddonPreferences
):
182 error_message
: StringProperty(
183 name
='Error Message',
185 options
={'HIDDEN', 'SKIP_SAVE'}
187 ok_message
: StringProperty(
190 options
={'HIDDEN', 'SKIP_SAVE'}
192 blender_id_username
: StringProperty(
193 name
='E-mail address',
195 options
={'HIDDEN', 'SKIP_SAVE'}
197 blender_id_password
: StringProperty(
200 options
={'HIDDEN', 'SKIP_SAVE'},
204 def reset_messages(self
):
206 self
.error_message
= ''
208 def draw(self
, context
):
211 if self
.error_message
:
213 sub
.alert
= True # labels don't display in red :(
214 sub
.label(text
=self
.error_message
, icon
='ERROR')
217 sub
.label(text
=self
.ok_message
, icon
='FILE_TICK')
219 active_profile
= get_active_profile()
221 expiry
= token_expires()
222 now
= datetime
.datetime
.utcnow()
225 layout
.label(text
='We do not know when your token expires, please validate it.')
227 layout
.label(text
='Your login has expired! Log out and log in again to refresh it.',
230 time_left
= expiry
- now
231 if time_left
.days
> 14:
232 exp_str
= 'on {:%Y-%m-%d}'.format(expiry
)
233 elif time_left
.days
> 1:
234 exp_str
= 'in %i days.' % time_left
.days
235 elif time_left
.seconds
>= 7200:
236 exp_str
= 'in %i hours.' % round(time_left
.seconds
/ 3600)
237 elif time_left
.seconds
>= 120:
238 exp_str
= 'in %i minutes.' % round(time_left
.seconds
/ 60)
240 exp_str
= 'within seconds'
242 endpoint
= communication
.blender_id_endpoint()
243 if endpoint
== communication
.BLENDER_ID_ENDPOINT
:
244 msg
= 'You are logged in as %s.' % active_profile
.username
246 msg
= 'You are logged in as %s at %s.' % (active_profile
.username
, endpoint
)
248 col
= layout
.column(align
=True)
249 col
.label(text
=msg
, icon
='WORLD_DATA')
250 if time_left
.days
< 14:
251 col
.label(text
='Your token will expire %s. Please log out and log in again '
252 'to refresh it.' % exp_str
, icon
='PREVIEW_RANGE')
254 col
.label(text
='Your authentication token expires %s.' % exp_str
,
257 row
= layout
.row().split(factor
=0.8)
258 row
.operator('blender_id.logout')
259 row
.operator('blender_id.validate')
261 layout
.prop(self
, 'blender_id_username')
262 layout
.prop(self
, 'blender_id_password')
264 layout
.operator('blender_id.login')
267 class BlenderIdMixin
:
269 def addon_prefs(context
):
271 prefs
= context
.preferences
272 except AttributeError:
273 prefs
= context
.user_preferences
275 addon_prefs
= prefs
.addons
[__name__
].preferences
276 addon_prefs
.reset_messages()
280 class BlenderIdLogin(BlenderIdMixin
, Operator
):
281 bl_idname
= 'blender_id.login'
284 def execute(self
, context
):
288 addon_prefs
= self
.addon_prefs(context
)
290 auth_result
= communication
.blender_id_server_authenticate(
291 username
=addon_prefs
.blender_id_username
,
292 password
=addon_prefs
.blender_id_password
295 if auth_result
.success
:
296 # Prevent saving the password in user preferences. Overwrite the password with a
297 # random string, as just setting to '' might only replace the first byte with 0.
298 pwlen
= len(addon_prefs
.blender_id_password
)
299 rnd
= ''.join(random
.choice(string
.ascii_uppercase
+ string
.digits
)
300 for _
in range(pwlen
+ 16))
301 addon_prefs
.blender_id_password
= rnd
302 addon_prefs
.blender_id_password
= ''
304 profiles
.save_as_active_profile(
306 addon_prefs
.blender_id_username
,
309 addon_prefs
.ok_message
= 'Logged in'
311 addon_prefs
.error_message
= auth_result
.error_message
312 if BlenderIdProfile
.user_id
:
313 profiles
.logout(BlenderIdProfile
.user_id
)
315 BlenderIdProfile
.read_json()
320 class BlenderIdValidate(BlenderIdMixin
, Operator
):
321 bl_idname
= 'blender_id.validate'
322 bl_label
= 'Validate'
324 def execute(self
, context
):
325 addon_prefs
= self
.addon_prefs(context
)
327 err
= validate_token()
329 addon_prefs
.ok_message
= 'Authentication token is valid.'
331 addon_prefs
.error_message
= '%s; you probably want to log out and log in again.' % err
333 BlenderIdProfile
.read_json()
338 class BlenderIdLogout(BlenderIdMixin
, Operator
):
339 bl_idname
= 'blender_id.logout'
342 def execute(self
, context
):
343 addon_prefs
= self
.addon_prefs(context
)
345 communication
.blender_id_server_logout(BlenderIdProfile
.user_id
,
346 BlenderIdProfile
.token
)
348 profiles
.logout(BlenderIdProfile
.user_id
)
349 BlenderIdProfile
.read_json()
351 addon_prefs
.ok_message
= 'You have been logged out.'
357 BlenderIdProfile
.read_json()
359 bpy
.utils
.register_class(BlenderIdLogin
)
360 bpy
.utils
.register_class(BlenderIdLogout
)
361 bpy
.utils
.register_class(BlenderIdPreferences
)
362 bpy
.utils
.register_class(BlenderIdValidate
)
364 preferences
= BlenderIdMixin
.addon_prefs(bpy
.context
)
365 preferences
.reset_messages()
369 bpy
.utils
.unregister_class(BlenderIdLogin
)
370 bpy
.utils
.unregister_class(BlenderIdLogout
)
371 bpy
.utils
.unregister_class(BlenderIdPreferences
)
372 bpy
.utils
.unregister_class(BlenderIdValidate
)
375 if __name__
== '__main__':