Cleanup: quiet float argument to in type warning
[blender-addons.git] / blender_id / communication.py
blob24a04ebfac07375e852850d3252c9804cb50d05a
1 # SPDX-License-Identifier: GPL-2.0-or-later
3 import functools
4 import logging
5 import typing
7 log = logging.getLogger(__name__)
9 # Can be overridden by setting the environment variable BLENDER_ID_ENDPOINT.
10 BLENDER_ID_ENDPOINT = 'https://id.blender.org/'
12 # Will become a requests.Session at the first request to Blender ID.
13 requests_session = None
15 # Request timeout, in seconds.
16 REQUESTS_TIMEOUT = 5.0
19 class BlenderIdCommError(RuntimeError):
20 """Raised when there was an error communicating with Blender ID"""
23 class AuthResult:
24 def __init__(self, *, success: bool,
25 user_id: str=None, token: str=None, expires: str=None,
26 error_message: typing.Any=None): # when success=False
27 self.success = success
28 self.user_id = user_id
29 self.token = token
30 self.error_message = str(error_message)
31 self.expires = expires
34 @functools.lru_cache(maxsize=None)
35 def host_label():
36 import socket
38 return 'Blender running on %r' % socket.gethostname()
41 def blender_id_session():
42 """Returns the Requests session, creating it if necessary."""
43 global requests_session
44 import requests.adapters
46 if requests_session is not None:
47 return requests_session
49 requests_session = requests.session()
51 # Retry with backoff factor, so that a restart of Blender ID or hickup
52 # in the connection doesn't immediately fail the request.
53 retries = requests.packages.urllib3.util.retry.Retry(
54 total=5,
55 backoff_factor=0.05,
57 http_adapter = requests.adapters.HTTPAdapter(max_retries=retries)
58 requests_session.mount('https://', http_adapter)
59 requests_session.mount('http://', http_adapter)
61 # Construct the User-Agent header with Blender and add-on versions.
62 try:
63 import bpy
64 except ImportError:
65 blender_version = 'unknown'
66 else:
67 blender_version = '.'.join(str(component) for component in bpy.app.version)
69 from blender_id import bl_info
70 addon_version = '.'.join(str(component) for component in bl_info['version'])
71 requests_session.headers['User-Agent'] = f'Blender/{blender_version} Blender-ID-Addon/{addon_version}'
73 return requests_session
76 @functools.lru_cache(maxsize=None)
77 def blender_id_endpoint(endpoint_path=None):
78 """Gets the endpoint for the authentication API. If the BLENDER_ID_ENDPOINT env variable
79 is defined, it's possible to override the (default) production address.
80 """
81 import os
82 import urllib.parse
84 base_url = os.environ.get('BLENDER_ID_ENDPOINT')
85 if base_url:
86 log.warning('Using overridden Blender ID url %s', base_url)
87 else:
88 base_url = BLENDER_ID_ENDPOINT
89 log.info('Using standard Blender ID url %s', base_url)
91 # urljoin() is None-safe for the 2nd parameter.
92 return urllib.parse.urljoin(base_url, endpoint_path)
95 def blender_id_server_authenticate(username, password) -> AuthResult:
96 """Authenticate the user with the server with a single transaction
97 containing username and password (must happen via HTTPS).
99 If the transaction is successful, status will be 'successful' and we
100 return the user's unique blender id and a token (that will be used to
101 represent that username and password combination).
102 If there was a problem, status will be 'fail' and we return an error
103 message. Problems may be with the connection or wrong user/password.
106 import requests.exceptions
108 payload = dict(
109 username=username,
110 password=password,
111 host_label=host_label()
114 url = blender_id_endpoint('u/identify')
115 session = blender_id_session()
116 try:
117 r = session.post(url, data=payload, verify=True, timeout=REQUESTS_TIMEOUT)
118 except (requests.exceptions.SSLError,
119 requests.exceptions.HTTPError,
120 requests.exceptions.ConnectionError) as e:
121 msg = 'Exception POSTing to {}: {}'.format(url, e)
122 print(msg)
123 return AuthResult(success=False, error_message=msg)
125 if r.status_code == 200:
126 resp = r.json()
127 status = resp['status']
128 if status == 'success':
129 return AuthResult(success=True,
130 user_id=str(resp['data']['user_id']),
131 token=resp['data']['oauth_token']['access_token'],
132 expires=resp['data']['oauth_token']['expires'],
134 if status == 'fail':
135 return AuthResult(success=False, error_message='Username and/or password is incorrect')
137 return AuthResult(success=False,
138 error_message='There was a problem communicating with'
139 ' the server. Error code is: %s' % r.status_code)
142 def blender_id_server_validate(token) -> typing.Tuple[typing.Optional[str], typing.Optional[str]]:
143 """Validate the auth token with the server.
145 @param token: the authentication token
146 @type token: str
147 @returns: tuple (expiry, error).
148 The expiry is the expiry date of the token if it is valid, else None.
149 The error is None if the token is valid, or an error message when it's invalid.
152 import requests.exceptions
154 url = blender_id_endpoint('u/validate_token')
155 session = blender_id_session()
156 try:
157 r = session.post(url, data={'token': token}, verify=True, timeout=REQUESTS_TIMEOUT)
158 except requests.exceptions.ConnectionError:
159 log.exception('error connecting to Blender ID at %s', url)
160 return None, 'Unable to connect to Blender ID'
161 except requests.exceptions.RequestException as e:
162 log.exception('error validating token at %s', url)
163 return None, str(e)
165 if r.status_code != 200:
166 return None, 'Authentication token invalid'
168 response = r.json()
169 return response['token_expires'], None
172 def blender_id_server_logout(user_id, token):
173 """Logs out of the Blender ID service by removing the token server-side.
175 @param user_id: the email address of the user.
176 @type user_id: str
177 @param token: the token to remove
178 @type token: str
179 @return: {'status': 'fail' or 'success', 'error_message': str}
180 @rtype: dict
183 import requests.exceptions
185 payload = dict(
186 user_id=user_id,
187 token=token
189 session = blender_id_session()
190 try:
191 r = session.post(blender_id_endpoint('u/delete_token'),
192 data=payload, verify=True, timeout=REQUESTS_TIMEOUT)
193 except (requests.exceptions.SSLError,
194 requests.exceptions.HTTPError,
195 requests.exceptions.ConnectionError) as e:
196 return dict(
197 status='fail',
198 error_message=format('There was a problem setting up a connection to '
199 'the server. Error type is: %s' % type(e).__name__)
202 if r.status_code != 200:
203 return dict(
204 status='fail',
205 error_message=format('There was a problem communicating with'
206 ' the server. Error code is: %s' % r.status_code)
209 resp = r.json()
210 return dict(
211 status=resp['status'],
212 error_message=None
216 def subclient_create_token(auth_token: str, subclient_id: str) -> dict:
217 """Creates a subclient-specific authentication token.
219 :returns: the token along with its expiry timestamp, in a {'scst': 'token',
220 'expiry': datetime.datetime} dict.
223 payload = {'subclient_id': subclient_id,
224 'host_label': host_label()}
226 r = make_authenticated_call('POST', 'subclients/create_token', auth_token, payload)
227 if r.status_code == 401:
228 raise BlenderIdCommError('Your Blender ID login is not valid, try logging in again.')
230 if r.status_code != 201:
231 raise BlenderIdCommError('Invalid response, HTTP code %i received' % r.status_code)
233 resp = r.json()
234 if resp['status'] != 'success':
235 raise BlenderIdCommError(resp['message'])
237 return resp['data']
240 def make_authenticated_call(method, url, auth_token, data):
241 """Makes a HTTP call authenticated with the OAuth token."""
243 import requests.exceptions
245 session = blender_id_session()
246 try:
247 r = session.request(method,
248 blender_id_endpoint(url),
249 data=data,
250 headers={'Authorization': 'Bearer %s' % auth_token},
251 verify=True,
252 timeout=REQUESTS_TIMEOUT)
253 except (requests.exceptions.HTTPError,
254 requests.exceptions.ConnectionError) as e:
255 raise BlenderIdCommError(str(e))
257 return r
260 def send_token_to_subclient(webservice_endpoint: str, user_id: str,
261 subclient_token: str, subclient_id: str) -> str:
262 """Sends the subclient-specific token to the subclient.
264 The subclient verifies this token with BlenderID. If it's accepted, the
265 subclient ensures there is a valid user created server-side. The ID of
266 that user is returned.
268 :returns: the user ID at the subclient.
271 import requests.exceptions
272 import urllib.parse
274 url = urllib.parse.urljoin(webservice_endpoint, 'blender_id/store_scst')
275 session = blender_id_session()
276 try:
277 r = session.post(url,
278 data={'user_id': user_id,
279 'subclient_id': subclient_id,
280 'token': subclient_token},
281 verify=True,
282 timeout=REQUESTS_TIMEOUT)
283 r.raise_for_status()
284 except (requests.exceptions.HTTPError,
285 requests.exceptions.ConnectionError) as e:
286 raise BlenderIdCommError(str(e))
287 resp = r.json()
289 if resp['status'] != 'success':
290 raise BlenderIdCommError('Error sending subclient-specific token to %s, error is: %s'
291 % (webservice_endpoint, resp))
293 return resp['subclient_user_id']