1 # SPDX-FileCopyrightText: 2016-2022 Blender Foundation
3 # SPDX-License-Identifier: GPL-2.0-or-later
9 log
= logging
.getLogger(__name__
)
11 # Can be overridden by setting the environment variable BLENDER_ID_ENDPOINT.
12 BLENDER_ID_ENDPOINT
= 'https://id.blender.org/'
14 # Will become a requests.Session at the first request to Blender ID.
15 requests_session
= None
17 # Request timeout, in seconds.
18 REQUESTS_TIMEOUT
= 5.0
21 class BlenderIdCommError(RuntimeError):
22 """Raised when there was an error communicating with Blender ID"""
26 def __init__(self
, *, success
: bool,
27 user_id
: str=None, token
: str=None, expires
: str=None,
28 error_message
: typing
.Any
=None): # when success=False
29 self
.success
= success
30 self
.user_id
= user_id
32 self
.error_message
= str(error_message
)
33 self
.expires
= expires
36 @functools.lru_cache(maxsize
=None)
40 return 'Blender running on %r' % socket
.gethostname()
43 def blender_id_session():
44 """Returns the Requests session, creating it if necessary."""
45 global requests_session
46 import requests
.adapters
48 if requests_session
is not None:
49 return requests_session
51 requests_session
= requests
.session()
53 # Retry with backoff factor, so that a restart of Blender ID or hickup
54 # in the connection doesn't immediately fail the request.
55 retries
= requests
.packages
.urllib3
.util
.retry
.Retry(
59 http_adapter
= requests
.adapters
.HTTPAdapter(max_retries
=retries
)
60 requests_session
.mount('https://', http_adapter
)
61 requests_session
.mount('http://', http_adapter
)
63 # Construct the User-Agent header with Blender and add-on versions.
67 blender_version
= 'unknown'
69 blender_version
= '.'.join(str(component
) for component
in bpy
.app
.version
)
71 from blender_id
import bl_info
72 addon_version
= '.'.join(str(component
) for component
in bl_info
['version'])
73 requests_session
.headers
['User-Agent'] = f
'Blender/{blender_version} Blender-ID-Addon/{addon_version}'
75 return requests_session
78 @functools.lru_cache(maxsize
=None)
79 def blender_id_endpoint(endpoint_path
=None):
80 """Gets the endpoint for the authentication API. If the BLENDER_ID_ENDPOINT env variable
81 is defined, it's possible to override the (default) production address.
86 base_url
= os
.environ
.get('BLENDER_ID_ENDPOINT')
88 log
.warning('Using overridden Blender ID url %s', base_url
)
90 base_url
= BLENDER_ID_ENDPOINT
91 log
.info('Using standard Blender ID url %s', base_url
)
93 # urljoin() is None-safe for the 2nd parameter.
94 return urllib
.parse
.urljoin(base_url
, endpoint_path
)
97 def blender_id_server_authenticate(username
, password
) -> AuthResult
:
98 """Authenticate the user with the server with a single transaction
99 containing username and password (must happen via HTTPS).
101 If the transaction is successful, status will be 'successful' and we
102 return the user's unique blender id and a token (that will be used to
103 represent that username and password combination).
104 If there was a problem, status will be 'fail' and we return an error
105 message. Problems may be with the connection or wrong user/password.
108 import requests
.exceptions
113 host_label
=host_label()
116 url
= blender_id_endpoint('u/identify')
117 session
= blender_id_session()
119 r
= session
.post(url
, data
=payload
, verify
=True, timeout
=REQUESTS_TIMEOUT
)
120 except (requests
.exceptions
.SSLError
,
121 requests
.exceptions
.HTTPError
,
122 requests
.exceptions
.ConnectionError
) as e
:
123 msg
= 'Exception POSTing to {}: {}'.format(url
, e
)
125 return AuthResult(success
=False, error_message
=msg
)
127 if r
.status_code
== 200:
129 status
= resp
['status']
130 if status
== 'success':
131 return AuthResult(success
=True,
132 user_id
=str(resp
['data']['user_id']),
133 token
=resp
['data']['oauth_token']['access_token'],
134 expires
=resp
['data']['oauth_token']['expires'],
137 return AuthResult(success
=False, error_message
='Username and/or password is incorrect')
139 return AuthResult(success
=False,
140 error_message
='There was a problem communicating with'
141 ' the server. Error code is: %s' % r
.status_code
)
144 def blender_id_server_validate(token
) -> typing
.Tuple
[typing
.Optional
[str], typing
.Optional
[str]]:
145 """Validate the auth token with the server.
147 @param token: the authentication token
149 @returns: tuple (expiry, error).
150 The expiry is the expiry date of the token if it is valid, else None.
151 The error is None if the token is valid, or an error message when it's invalid.
154 import requests
.exceptions
156 url
= blender_id_endpoint('u/validate_token')
157 session
= blender_id_session()
159 r
= session
.post(url
, data
={'token': token
}, verify
=True, timeout
=REQUESTS_TIMEOUT
)
160 except requests
.exceptions
.ConnectionError
:
161 log
.exception('error connecting to Blender ID at %s', url
)
162 return None, 'Unable to connect to Blender ID'
163 except requests
.exceptions
.RequestException
as e
:
164 log
.exception('error validating token at %s', url
)
167 if r
.status_code
!= 200:
168 return None, 'Authentication token invalid'
171 return response
['token_expires'], None
174 def blender_id_server_logout(user_id
, token
):
175 """Logs out of the Blender ID service by removing the token server-side.
177 @param user_id: the email address of the user.
179 @param token: the token to remove
181 @return: {'status': 'fail' or 'success', 'error_message': str}
185 import requests
.exceptions
191 session
= blender_id_session()
193 r
= session
.post(blender_id_endpoint('u/delete_token'),
194 data
=payload
, verify
=True, timeout
=REQUESTS_TIMEOUT
)
195 except (requests
.exceptions
.SSLError
,
196 requests
.exceptions
.HTTPError
,
197 requests
.exceptions
.ConnectionError
) as e
:
200 error_message
=format('There was a problem setting up a connection to '
201 'the server. Error type is: %s' % type(e
).__name
__)
204 if r
.status_code
!= 200:
207 error_message
=format('There was a problem communicating with'
208 ' the server. Error code is: %s' % r
.status_code
)
213 status
=resp
['status'],
218 def subclient_create_token(auth_token
: str, subclient_id
: str) -> dict:
219 """Creates a subclient-specific authentication token.
221 :returns: the token along with its expiry timestamp, in a {'scst': 'token',
222 'expiry': datetime.datetime} dict.
225 payload
= {'subclient_id': subclient_id
,
226 'host_label': host_label()}
228 r
= make_authenticated_call('POST', 'subclients/create_token', auth_token
, payload
)
229 if r
.status_code
== 401:
230 raise BlenderIdCommError('Your Blender ID login is not valid, try logging in again.')
232 if r
.status_code
!= 201:
233 raise BlenderIdCommError('Invalid response, HTTP code %i received' % r
.status_code
)
236 if resp
['status'] != 'success':
237 raise BlenderIdCommError(resp
['message'])
242 def make_authenticated_call(method
, url
, auth_token
, data
):
243 """Makes a HTTP call authenticated with the OAuth token."""
245 import requests
.exceptions
247 session
= blender_id_session()
249 r
= session
.request(method
,
250 blender_id_endpoint(url
),
252 headers
={'Authorization': 'Bearer %s' % auth_token
},
254 timeout
=REQUESTS_TIMEOUT
)
255 except (requests
.exceptions
.HTTPError
,
256 requests
.exceptions
.ConnectionError
) as e
:
257 raise BlenderIdCommError(str(e
))
262 def send_token_to_subclient(webservice_endpoint
: str, user_id
: str,
263 subclient_token
: str, subclient_id
: str) -> str:
264 """Sends the subclient-specific token to the subclient.
266 The subclient verifies this token with BlenderID. If it's accepted, the
267 subclient ensures there is a valid user created server-side. The ID of
268 that user is returned.
270 :returns: the user ID at the subclient.
273 import requests
.exceptions
276 url
= urllib
.parse
.urljoin(webservice_endpoint
, 'blender_id/store_scst')
277 session
= blender_id_session()
279 r
= session
.post(url
,
280 data
={'user_id': user_id
,
281 'subclient_id': subclient_id
,
282 'token': subclient_token
},
284 timeout
=REQUESTS_TIMEOUT
)
286 except (requests
.exceptions
.HTTPError
,
287 requests
.exceptions
.ConnectionError
) as e
:
288 raise BlenderIdCommError(str(e
))
291 if resp
['status'] != 'success':
292 raise BlenderIdCommError('Error sending subclient-specific token to %s, error is: %s'
293 % (webservice_endpoint
, resp
))
295 return resp
['subclient_user_id']