sun_position: fix warning from deleted prop in User Preferences
[blender-addons.git] / blender_id / communication.py
blob6ebb3ea32f0e83dd185150ef3b0aa041302800b9
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 # ##### END GPL LICENSE BLOCK #####
19 # <pep8 compliant>
21 import functools
22 import logging
23 import typing
25 log = logging.getLogger(__name__)
27 # Can be overridden by setting the environment variable BLENDER_ID_ENDPOINT.
28 BLENDER_ID_ENDPOINT = 'https://id.blender.org/'
30 # Will become a requests.Session at the first request to Blender ID.
31 requests_session = None
33 # Request timeout, in seconds.
34 REQUESTS_TIMEOUT = 5.0
37 class BlenderIdCommError(RuntimeError):
38 """Raised when there was an error communicating with Blender ID"""
41 class AuthResult:
42 def __init__(self, *, success: bool,
43 user_id: str=None, token: str=None, expires: str=None,
44 error_message: typing.Any=None): # when success=False
45 self.success = success
46 self.user_id = user_id
47 self.token = token
48 self.error_message = str(error_message)
49 self.expires = expires
52 @functools.lru_cache(maxsize=None)
53 def host_label():
54 import socket
56 return 'Blender running on %r' % socket.gethostname()
59 def blender_id_session():
60 """Returns the Requests session, creating it if necessary."""
61 global requests_session
62 import requests.adapters
64 if requests_session is not None:
65 return requests_session
67 requests_session = requests.session()
69 # Retry with backoff factor, so that a restart of Blender ID or hickup
70 # in the connection doesn't immediately fail the request.
71 retries = requests.packages.urllib3.util.retry.Retry(
72 total=5,
73 backoff_factor=0.05,
75 http_adapter = requests.adapters.HTTPAdapter(max_retries=retries)
76 requests_session.mount('https://', http_adapter)
77 requests_session.mount('http://', http_adapter)
79 # Construct the User-Agent header with Blender and add-on versions.
80 try:
81 import bpy
82 except ImportError:
83 blender_version = 'unknown'
84 else:
85 blender_version = '.'.join(str(component) for component in bpy.app.version)
87 from blender_id import bl_info
88 addon_version = '.'.join(str(component) for component in bl_info['version'])
89 requests_session.headers['User-Agent'] = f'Blender/{blender_version} Blender-ID-Addon/{addon_version}'
91 return requests_session
94 @functools.lru_cache(maxsize=None)
95 def blender_id_endpoint(endpoint_path=None):
96 """Gets the endpoint for the authentication API. If the BLENDER_ID_ENDPOINT env variable
97 is defined, it's possible to override the (default) production address.
98 """
99 import os
100 import urllib.parse
102 base_url = os.environ.get('BLENDER_ID_ENDPOINT')
103 if base_url:
104 log.warning('Using overridden Blender ID url %s', base_url)
105 else:
106 base_url = BLENDER_ID_ENDPOINT
107 log.info('Using standard Blender ID url %s', base_url)
109 # urljoin() is None-safe for the 2nd parameter.
110 return urllib.parse.urljoin(base_url, endpoint_path)
113 def blender_id_server_authenticate(username, password) -> AuthResult:
114 """Authenticate the user with the server with a single transaction
115 containing username and password (must happen via HTTPS).
117 If the transaction is successful, status will be 'successful' and we
118 return the user's unique blender id and a token (that will be used to
119 represent that username and password combination).
120 If there was a problem, status will be 'fail' and we return an error
121 message. Problems may be with the connection or wrong user/password.
124 import requests.exceptions
126 payload = dict(
127 username=username,
128 password=password,
129 host_label=host_label()
132 url = blender_id_endpoint('u/identify')
133 session = blender_id_session()
134 try:
135 r = session.post(url, data=payload, verify=True, timeout=REQUESTS_TIMEOUT)
136 except (requests.exceptions.SSLError,
137 requests.exceptions.HTTPError,
138 requests.exceptions.ConnectionError) as e:
139 msg = 'Exception POSTing to {}: {}'.format(url, e)
140 print(msg)
141 return AuthResult(success=False, error_message=msg)
143 if r.status_code == 200:
144 resp = r.json()
145 status = resp['status']
146 if status == 'success':
147 return AuthResult(success=True,
148 user_id=str(resp['data']['user_id']),
149 token=resp['data']['oauth_token']['access_token'],
150 expires=resp['data']['oauth_token']['expires'],
152 if status == 'fail':
153 return AuthResult(success=False, error_message='Username and/or password is incorrect')
155 return AuthResult(success=False,
156 error_message='There was a problem communicating with'
157 ' the server. Error code is: %s' % r.status_code)
160 def blender_id_server_validate(token) -> typing.Tuple[typing.Optional[str], typing.Optional[str]]:
161 """Validate the auth token with the server.
163 @param token: the authentication token
164 @type token: str
165 @returns: tuple (expiry, error).
166 The expiry is the expiry date of the token if it is valid, else None.
167 The error is None if the token is valid, or an error message when it's invalid.
170 import requests.exceptions
172 url = blender_id_endpoint('u/validate_token')
173 session = blender_id_session()
174 try:
175 r = session.post(url, data={'token': token}, verify=True, timeout=REQUESTS_TIMEOUT)
176 except requests.exceptions.ConnectionError:
177 log.exception('error connecting to Blender ID at %s', url)
178 return None, 'Unable to connect to Blender ID'
179 except requests.exceptions.RequestException as e:
180 log.exception('error validating token at %s', url)
181 return None, str(e)
183 if r.status_code != 200:
184 return None, 'Authentication token invalid'
186 response = r.json()
187 return response['token_expires'], None
190 def blender_id_server_logout(user_id, token):
191 """Logs out of the Blender ID service by removing the token server-side.
193 @param user_id: the email address of the user.
194 @type user_id: str
195 @param token: the token to remove
196 @type token: str
197 @return: {'status': 'fail' or 'success', 'error_message': str}
198 @rtype: dict
201 import requests.exceptions
203 payload = dict(
204 user_id=user_id,
205 token=token
207 session = blender_id_session()
208 try:
209 r = session.post(blender_id_endpoint('u/delete_token'),
210 data=payload, verify=True, timeout=REQUESTS_TIMEOUT)
211 except (requests.exceptions.SSLError,
212 requests.exceptions.HTTPError,
213 requests.exceptions.ConnectionError) as e:
214 return dict(
215 status='fail',
216 error_message=format('There was a problem setting up a connection to '
217 'the server. Error type is: %s' % type(e).__name__)
220 if r.status_code != 200:
221 return dict(
222 status='fail',
223 error_message=format('There was a problem communicating with'
224 ' the server. Error code is: %s' % r.status_code)
227 resp = r.json()
228 return dict(
229 status=resp['status'],
230 error_message=None
234 def subclient_create_token(auth_token: str, subclient_id: str) -> dict:
235 """Creates a subclient-specific authentication token.
237 :returns: the token along with its expiry timestamp, in a {'scst': 'token',
238 'expiry': datetime.datetime} dict.
241 payload = {'subclient_id': subclient_id,
242 'host_label': host_label()}
244 r = make_authenticated_call('POST', 'subclients/create_token', auth_token, payload)
245 if r.status_code == 401:
246 raise BlenderIdCommError('Your Blender ID login is not valid, try logging in again.')
248 if r.status_code != 201:
249 raise BlenderIdCommError('Invalid response, HTTP code %i received' % r.status_code)
251 resp = r.json()
252 if resp['status'] != 'success':
253 raise BlenderIdCommError(resp['message'])
255 return resp['data']
258 def make_authenticated_call(method, url, auth_token, data):
259 """Makes a HTTP call authenticated with the OAuth token."""
261 import requests.exceptions
263 session = blender_id_session()
264 try:
265 r = session.request(method,
266 blender_id_endpoint(url),
267 data=data,
268 headers={'Authorization': 'Bearer %s' % auth_token},
269 verify=True,
270 timeout=REQUESTS_TIMEOUT)
271 except (requests.exceptions.HTTPError,
272 requests.exceptions.ConnectionError) as e:
273 raise BlenderIdCommError(str(e))
275 return r
278 def send_token_to_subclient(webservice_endpoint: str, user_id: str,
279 subclient_token: str, subclient_id: str) -> str:
280 """Sends the subclient-specific token to the subclient.
282 The subclient verifies this token with BlenderID. If it's accepted, the
283 subclient ensures there is a valid user created server-side. The ID of
284 that user is returned.
286 :returns: the user ID at the subclient.
289 import requests.exceptions
290 import urllib.parse
292 url = urllib.parse.urljoin(webservice_endpoint, 'blender_id/store_scst')
293 session = blender_id_session()
294 try:
295 r = session.post(url,
296 data={'user_id': user_id,
297 'subclient_id': subclient_id,
298 'token': subclient_token},
299 verify=True,
300 timeout=REQUESTS_TIMEOUT)
301 r.raise_for_status()
302 except (requests.exceptions.HTTPError,
303 requests.exceptions.ConnectionError) as e:
304 raise BlenderIdCommError(str(e))
305 resp = r.json()
307 if resp['status'] != 'success':
308 raise BlenderIdCommError('Error sending subclient-specific token to %s, error is: %s'
309 % (webservice_endpoint, resp))
311 return resp['subclient_user_id']